Skip to content

Commit 9f60702

Browse files
authored
Merge pull request #2653 from nextcloud/feat/noid/improve-media-viewer
feat: Improve media viewer performance
2 parents e0480c8 + 0370a2b commit 9f60702

10 files changed

Lines changed: 1137 additions & 108 deletions

File tree

‎NextcloudTalk.xcodeproj/project.pbxproj‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -556,9 +556,9 @@
556556
"Chat cells/SwiftMarkdownObjCBridge.swift",
557557
"Chat upload/ChatFileUpload.swift",
558558
"Chat upload/ChatFileUploadDestination.swift",
559+
"Chat upload/ChatFileUploader.swift",
559560
"Chat upload/ChatFileUploadError.swift",
560561
"Chat upload/ChatFileUploadMetadata.swift",
561-
"Chat upload/ChatFileUploader.swift",
562562
"Chat views/NCChatTitleView.swift",
563563
"Chat views/NCChatTitleView.xib",
564564
"Chat views/NCMessageTextView.swift",

‎NextcloudTalk/Chat/NCChatFileController.swift‎

Lines changed: 49 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ public class NCChatFileController: NSObject {
2727
private let account: TalkAccount
2828
private let deleteFilesOlderThanDays = 7
2929
private var fileStatus: NCChatFileStatus?
30+
private var cancelDownloadHandler: (() -> Void)?
31+
private var isCancelled = false
3032

3133
init(account: TalkAccount) {
3234
self.account = account
@@ -123,19 +125,15 @@ public class NCChatFileController: NSObject {
123125
}
124126

125127
private func setDate(onFile filePath: String, withCreationDate creationDate: Date?, withModificationDate modificationDate: Date?) {
126-
var attributes = [FileAttributeKey: Any]()
127-
128-
if let creationDate {
129-
attributes[.creationDate] = creationDate
130-
}
131-
128+
// Setting both, modification- and creationDate in one go does not work, we will end up with the modification date in both fields
129+
// Also the creationDate needs to be set after modificationDate (most likely because creation cannot be later than modification in theory)
132130
if let modificationDate {
133-
attributes[.modificationDate] = modificationDate
131+
try? FileManager.default.setAttributes([.modificationDate: modificationDate], ofItemAtPath: filePath)
134132
}
135133

136-
guard !attributes.isEmpty else { return }
137-
138-
try? FileManager.default.setAttributes(attributes, ofItemAtPath: filePath)
134+
if let creationDate {
135+
try? FileManager.default.setAttributes([.creationDate: creationDate], ofItemAtPath: filePath)
136+
}
139137
}
140138

141139
public func moveFileToTemporaryDirectory(fromSourcePath sourcePath: String, destinationPath: String) -> Bool {
@@ -156,9 +154,44 @@ public class NCChatFileController: NSObject {
156154
}
157155
}
158156

157+
///
158+
/// Locally cached file, without asking the server whether our copy is still current.
159+
///
160+
/// Only matches when the size is the one announced in the chat message, so a file that was
161+
/// replaced with a differently sized one on the server is not returned. A replacement with the
162+
/// exact same size is only caught once `downloadFile(withFileId:)` has validated the file.
163+
///
164+
public func cachedFileURL(forFileNamed fileName: String, expectedSize: Int) -> URL? {
165+
guard expectedSize > 0 else { return nil }
166+
167+
let filePath = (self.tempDirectoryPath as NSString).appendingPathComponent(fileName)
168+
169+
guard FileManager.default.fileExists(atPath: filePath),
170+
let attributes = try? FileManager.default.attributesOfItem(atPath: filePath),
171+
let size = attributes[.size] as? Int, size == expectedSize
172+
else { return nil }
173+
174+
return URL(fileURLWithPath: filePath)
175+
}
176+
177+
// Stops an ongoing download. No delegate method is called afterwards.
178+
public func cancelDownload() {
179+
self.isCancelled = true
180+
self.cancelDownloadHandler?()
181+
self.cancelDownloadHandler = nil
182+
183+
if self.fileStatus?.isDownloading == true {
184+
self.didChangeIsDownloadingNotification(isDownloading: false)
185+
}
186+
}
187+
159188
public func downloadFile(withFileId fileId: String) {
189+
self.isCancelled = false
190+
160191
// getFileById already sets up NextcloudKit
161192
NCAPIController.sharedInstance().getFileById(forAccount: self.account, withFileId: fileId) { file, error in
193+
guard !self.isCancelled else { return }
194+
162195
guard let file else {
163196
print("An error occurred while getting file with fileId \(fileId): \(error?.errorDescription ?? "")")
164197
self.delegate?.fileControllerDidFailLoadingFile(self, withFileId: fileId, withErrorDescription: error?.errorDescription ?? "")
@@ -190,11 +223,15 @@ public class NCChatFileController: NSObject {
190223
return
191224
}
192225

193-
NextcloudKit.shared.download(serverUrlFileName: serverUrlFileName, fileNameLocalPath: fileLocalPath, queue: .main) { _ in
194-
print("Download task")
226+
NextcloudKit.shared.download(serverUrlFileName: serverUrlFileName, fileNameLocalPath: fileLocalPath, queue: .main) { request in
227+
self.cancelDownloadHandler = { _ = request.cancel() }
195228
} progressHandler: { progress in
196229
self.didChangeDownloadProgressNotification(progress: progress)
197230
} completionHandler: { _, _, _, _, _, error in
231+
self.cancelDownloadHandler = nil
232+
233+
guard !self.isCancelled else { return }
234+
198235
if error.errorCode == 0 {
199236
// Set modification date to invalidate our cache
200237
// Set creation date to delete older files from cache
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
//
2+
// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
3+
// SPDX-License-Identifier: GPL-3.0-or-later
4+
//
5+
6+
import UIKit
7+
8+
// Unobtrusive download progress shown on top of an already visible preview
9+
@objcMembers class MediaDownloadIndicatorView: UIView {
10+
11+
private static let preferredSize: CGFloat = 32
12+
13+
private var retryHandler: (() -> Void)?
14+
private var backgroundView: UIVisualEffectView?
15+
16+
private lazy var activityIndicator = {
17+
let activityIndicator = NCActivityIndicator(frame: .init(x: 0, y: 0, width: 24, height: 24))
18+
activityIndicator.translatesAutoresizingMaskIntoConstraints = false
19+
activityIndicator.radius = 9
20+
activityIndicator.strokeWidth = 2
21+
activityIndicator.indicatorMode = .indeterminate
22+
23+
return activityIndicator
24+
}()
25+
26+
private lazy var retryButton = {
27+
let retryButton = UIButton(type: .system)
28+
retryButton.translatesAutoresizingMaskIntoConstraints = false
29+
retryButton.setImage(UIImage(systemName: "arrow.clockwise"), for: .normal)
30+
retryButton.isHidden = true
31+
retryButton.accessibilityLabel = NSLocalizedString("Retry", comment: "Retry downloading the original file")
32+
retryButton.addAction(UIAction { [weak self] _ in
33+
self?.retryHandler?()
34+
}, for: .touchUpInside)
35+
36+
return retryButton
37+
}()
38+
39+
override init(frame: CGRect) {
40+
super.init(frame: frame)
41+
42+
self.translatesAutoresizingMaskIntoConstraints = false
43+
44+
self.addBackgroundView()
45+
46+
// Foreground content belongs inside the effect view, that is what gets the legibility treatment
47+
let contentView = self.backgroundView?.contentView ?? self
48+
contentView.addSubview(self.activityIndicator)
49+
contentView.addSubview(self.retryButton)
50+
51+
self.updateForegroundColor()
52+
self.observeGlassAppearance()
53+
54+
NSLayoutConstraint.activate([
55+
self.widthAnchor.constraint(equalToConstant: MediaDownloadIndicatorView.preferredSize),
56+
self.heightAnchor.constraint(equalToConstant: MediaDownloadIndicatorView.preferredSize),
57+
self.activityIndicator.centerXAnchor.constraint(equalTo: self.centerXAnchor),
58+
self.activityIndicator.centerYAnchor.constraint(equalTo: self.centerYAnchor),
59+
self.retryButton.leadingAnchor.constraint(equalTo: self.leadingAnchor),
60+
self.retryButton.trailingAnchor.constraint(equalTo: self.trailingAnchor),
61+
self.retryButton.topAnchor.constraint(equalTo: self.topAnchor),
62+
self.retryButton.bottomAnchor.constraint(equalTo: self.bottomAnchor)
63+
])
64+
65+
self.isAccessibilityElement = true
66+
self.accessibilityLabel = NSLocalizedString("Downloading full quality media", comment: "Accessibility label of the indicator shown while the original file is downloaded")
67+
self.accessibilityTraits = .updatesFrequently
68+
}
69+
70+
required init?(coder: NSCoder) {
71+
fatalError("init(coder:) has not been implemented")
72+
}
73+
74+
///
75+
/// Follows the appearance the glass picked for the brightness of the picture behind it.
76+
///
77+
/// The glass reports that as a user interface style on its content view only, not on us. The
78+
/// indicator draws into a layer, whose color does not re-resolve on its own, so the color is
79+
/// resolved explicitly here.
80+
///
81+
private func updateForegroundColor() {
82+
let contentTraitCollection = self.backgroundView?.contentView.traitCollection ?? self.traitCollection
83+
84+
// Progress is ambient, the retry glyph is a control and stays at full strength
85+
self.activityIndicator.cycleColors = [UIColor.secondaryLabel.resolvedColor(with: contentTraitCollection)]
86+
self.retryButton.tintColor = UIColor.label.resolvedColor(with: contentTraitCollection)
87+
}
88+
89+
private func observeGlassAppearance() {
90+
guard #available(iOS 17.0, *), let contentView = self.backgroundView?.contentView else { return }
91+
92+
_ = contentView.registerForTraitChanges([UITraitUserInterfaceStyle.self]) { [weak self] (_: UIView, _: UITraitCollection) in
93+
self?.updateForegroundColor()
94+
}
95+
}
96+
97+
private func addBackgroundView() {
98+
let backgroundView: UIVisualEffectView
99+
100+
if #available(iOS 26.0, *) {
101+
backgroundView = self.addGlassView()
102+
} else {
103+
backgroundView = UIVisualEffectView(effect: UIBlurEffect(style: .systemUltraThinMaterial))
104+
backgroundView.translatesAutoresizingMaskIntoConstraints = false
105+
self.insertSubview(backgroundView, at: 0)
106+
107+
NSLayoutConstraint.activate([
108+
backgroundView.leadingAnchor.constraint(equalTo: self.leadingAnchor),
109+
backgroundView.trailingAnchor.constraint(equalTo: self.trailingAnchor),
110+
backgroundView.topAnchor.constraint(equalTo: self.topAnchor),
111+
backgroundView.bottomAnchor.constraint(equalTo: self.bottomAnchor)
112+
])
113+
}
114+
115+
// Glass draws outside its bounds, so it has to be clipped to the circle as well
116+
backgroundView.layer.cornerRadius = MediaDownloadIndicatorView.preferredSize / 2
117+
backgroundView.layer.masksToBounds = true
118+
119+
self.backgroundView = backgroundView
120+
}
121+
122+
func showProgress() {
123+
self.retryHandler = nil
124+
self.retryButton.isHidden = true
125+
self.activityIndicator.isHidden = false
126+
self.activityIndicator.startAnimating()
127+
128+
self.isAccessibilityElement = true
129+
}
130+
131+
func showRetry(handler: @escaping () -> Void) {
132+
self.retryHandler = handler
133+
self.activityIndicator.stopAnimating()
134+
self.activityIndicator.isHidden = true
135+
self.retryButton.isHidden = false
136+
137+
// The button carries the accessibility information in this mode
138+
self.isAccessibilityElement = false
139+
self.accessibilityValue = nil
140+
}
141+
142+
func stopAnimating() {
143+
self.activityIndicator.stopAnimating()
144+
}
145+
146+
func setProgress(_ progress: Float) {
147+
guard self.retryHandler == nil else { return }
148+
149+
self.activityIndicator.indicatorMode = .determinate
150+
self.activityIndicator.setProgress(progress, animated: true)
151+
self.accessibilityValue = String(format: "%.0f%%", progress * 100)
152+
}
153+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
//
2+
// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
3+
// SPDX-License-Identifier: GPL-3.0-or-later
4+
//
5+
6+
import Foundation
7+
import ImageIO
8+
import UIKit
9+
import os
10+
11+
// Decodes images from disk at a bounded size. Loading with UIImage(contentsOfFile:) and scaling
12+
// afterwards needs the full resolution bitmap and the scaled one at the same time, which is what
13+
// made the media viewer run out of memory on large pictures.
14+
enum MediaImageDownsampler {
15+
16+
private static let minimumPixelSize: CGFloat = 1024
17+
private static let maximumPixelSize: CGFloat = 4096
18+
19+
// Must not be called on the main thread, the decode happens synchronously here
20+
static func decodeImage(at url: URL, maxPixelSize: CGFloat) -> UIImage? {
21+
guard let source = CGImageSourceCreateWithURL(url as CFURL, [kCGImageSourceShouldCache: false] as CFDictionary) else { return nil }
22+
23+
let options: [CFString: Any] = [
24+
kCGImageSourceCreateThumbnailFromImageAlways: true,
25+
// ImageIO does not apply the EXIF orientation on its own, unlike UIImage(contentsOfFile:).
26+
// The server-side preview we replace is already rotated, so getting this wrong flips the image.
27+
kCGImageSourceCreateThumbnailWithTransform: true,
28+
// Decode on this thread instead of lazily on first draw, which would be on the main thread
29+
kCGImageSourceShouldCacheImmediately: true,
30+
kCGImageSourceThumbnailMaxPixelSize: maxPixelSize
31+
]
32+
33+
guard let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else { return nil }
34+
35+
return UIImage(cgImage: cgImage)
36+
}
37+
38+
// Enough pixels to stay sharp up to the given zoom, clamped to what we can afford to allocate
39+
static func recommendedPixelSize(forViewportSize viewportSize: CGSize, displayScale: CGFloat, zoomScale: CGFloat) -> CGFloat {
40+
let scale = displayScale > 0 ? displayScale : 2
41+
let longestEdge = max(viewportSize.width, viewportSize.height)
42+
let needed = longestEdge * scale * max(zoomScale, 1)
43+
44+
return min(max(needed, minimumPixelSize), min(maximumPixelSize, affordablePixelSize()))
45+
}
46+
47+
private static func affordablePixelSize() -> CGFloat {
48+
// Zero when there is no limit to report, e.g. inside an app extension
49+
let availableBytes = Double(os_proc_available_memory())
50+
51+
guard availableBytes > 0 else { return maximumPixelSize }
52+
53+
// Give a single image a quarter of the remaining headroom, assuming 4 bytes per pixel
54+
// and a square image as the worst case for a given longest edge.
55+
let edge = (availableBytes / 4 / 4).squareRoot()
56+
57+
return max(CGFloat(edge), minimumPixelSize)
58+
}
59+
}

0 commit comments

Comments
 (0)