-
Notifications
You must be signed in to change notification settings - Fork 5.2k
feat(mobile): add native image and PDF previews #8959
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
165 changes: 165 additions & 0 deletions
165
apps/mobile/modules/t3-native-controls/ios/T3NativeFilePresentation.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,165 @@ | ||||||
| import ImageIO | ||||||
| import QuickLook | ||||||
| import UIKit | ||||||
| import UniformTypeIdentifiers | ||||||
|
|
||||||
| private final class FilePreviewItem: NSObject, QLPreviewItem { | ||||||
| var previewItemURL: URL? | ||||||
| var previewItemTitle: String? | ||||||
| } | ||||||
|
|
||||||
| private final class FilePreviewController: QLPreviewController { | ||||||
| var onAppear: (() -> Void)? | ||||||
|
|
||||||
| override func viewDidAppear(_ animated: Bool) { | ||||||
| super.viewDidAppear(animated) | ||||||
| onAppear?() | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| /// Quick Look owns image and document controls, zooming, and source-view transitions. | ||||||
| final class T3NativeFilePresentation: NSObject, QLPreviewControllerDataSource, | ||||||
| QLPreviewControllerDelegate, UIAdaptivePresentationControllerDelegate { | ||||||
| let identifier: String | ||||||
| private var controller: UIViewController? | ||||||
| private let completion: (Error?) -> Void | ||||||
| private weak var sources: T3PresentationSources? | ||||||
| private let sourceIdentifier: String | ||||||
| private let item = FilePreviewItem() | ||||||
| private var loading: Task<Void, Never>? | ||||||
| private var dismissRequested = false | ||||||
| private var finished = false | ||||||
|
|
||||||
| init(identifier: String, sources: T3PresentationSources, sourceIdentifier: String, completion: @escaping (Error?) -> Void) { | ||||||
| self.identifier = identifier | ||||||
| self.sources = sources | ||||||
| self.sourceIdentifier = sourceIdentifier | ||||||
| self.completion = completion | ||||||
| super.init() | ||||||
| } | ||||||
|
|
||||||
| func present(url: URL, title: String, from presenter: UIViewController) { | ||||||
| loading = Task { @MainActor [self] in | ||||||
| do { | ||||||
| let file = try await Self.prepareFile(url: url, title: title) | ||||||
| guard !finished, !Task.isCancelled else { | ||||||
| try? FileManager.default.removeItem(at: file.deletingLastPathComponent()) | ||||||
| return | ||||||
| } | ||||||
| item.previewItemURL = file | ||||||
| item.previewItemTitle = title | ||||||
| let preview = FilePreviewController() | ||||||
| preview.delegate = self | ||||||
| preview.dataSource = self | ||||||
| preview.onAppear = { [weak self] in self?.resumePendingDismissal() } | ||||||
| controller = preview | ||||||
| presenter.present(preview, animated: !UIAccessibility.isReduceMotionEnabled) { [self] in | ||||||
| resumePendingDismissal() | ||||||
| } | ||||||
| preview.presentationController?.delegate = self | ||||||
| } catch { | ||||||
| finish(error: error) | ||||||
| } | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| func dismiss() { | ||||||
| dismissRequested = true | ||||||
| loading?.cancel() | ||||||
| guard !finished else { return } | ||||||
| guard let controller else { finish(); return } | ||||||
| // Drain Close from viewDidAppear after opening or cancelling an interactive dismissal. | ||||||
| // Starting a second modal transition while UIKit is settling the first can strand it. | ||||||
| guard !controller.isBeingPresented, !controller.isBeingDismissed else { return } | ||||||
| controller.dismiss(animated: !UIAccessibility.isReduceMotionEnabled) { [self] in finish() } | ||||||
| } | ||||||
|
|
||||||
| private func resumePendingDismissal() { | ||||||
| // Appearance callbacks run before UIKit has cleared the current transition. | ||||||
| DispatchQueue.main.async { [weak self] in | ||||||
| if self?.dismissRequested == true { self?.dismiss() } | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| func numberOfPreviewItems(in controller: QLPreviewController) -> Int { item.previewItemURL == nil ? 0 : 1 } | ||||||
|
|
||||||
| func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem { | ||||||
| item | ||||||
| } | ||||||
|
|
||||||
| func previewController(_ controller: QLPreviewController, transitionViewFor item: QLPreviewItem) -> UIView? { | ||||||
| guard !UIAccessibility.isReduceMotionEnabled else { return nil } | ||||||
| return sources?.view(for: sourceIdentifier) | ||||||
| } | ||||||
|
|
||||||
| func previewController(_ controller: QLPreviewController, frameFor item: QLPreviewItem, | ||||||
| inSourceView view: AutoreleasingUnsafeMutablePointer<UIView?>) -> CGRect { | ||||||
| guard !UIAccessibility.isReduceMotionEnabled, let source = sources?.view(for: sourceIdentifier) else { return .zero } | ||||||
| view.pointee = source | ||||||
| return source.bounds | ||||||
| } | ||||||
|
|
||||||
| func previewControllerDidDismiss(_ controller: QLPreviewController) { finish() } | ||||||
|
|
||||||
| func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { finish() } | ||||||
|
|
||||||
| private func finish(error: Error? = nil) { | ||||||
| guard !finished else { return } | ||||||
| finished = true | ||||||
| loading?.cancel() | ||||||
| loading = nil | ||||||
| if let file = item.previewItemURL { | ||||||
| try? FileManager.default.removeItem(at: file.deletingLastPathComponent()) | ||||||
| } | ||||||
| item.previewItemURL = nil | ||||||
| DispatchQueue.main.async { [completion] in completion(error) } | ||||||
| } | ||||||
|
|
||||||
| /// Copy original bytes so preview and sharing do not mutate a draft or workspace file. | ||||||
| nonisolated private static func prepareFile(url: URL, title: String) async throws -> URL { | ||||||
| try Task.checkCancellation() | ||||||
| let directory = FileManager.default.temporaryDirectory.appendingPathComponent("t3-preview-\(UUID().uuidString)") | ||||||
| try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) | ||||||
| do { | ||||||
| let download = directory.appendingPathComponent("original") | ||||||
| if url.isFileURL { | ||||||
| try FileManager.default.copyItem(at: url, to: download) | ||||||
| } else if url.scheme == "data" { | ||||||
| try Data(contentsOf: url).write(to: download, options: .atomic) | ||||||
| } else { | ||||||
| guard ["https", "http"].contains(url.scheme?.lowercased() ?? "") else { | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 High
Suggested change
🚀 Reply "fix it for me" or copy this AI Prompt for your agent: |
||||||
| throw URLError(.unsupportedURL) | ||||||
| } | ||||||
| let (temporaryFile, response) = try await URLSession.shared.download(from: url) | ||||||
| guard let response = response as? HTTPURLResponse, (200..<300).contains(response.statusCode) else { | ||||||
| throw URLError(.badServerResponse) | ||||||
| } | ||||||
| try FileManager.default.moveItem(at: temporaryFile, to: download) | ||||||
| } | ||||||
| try Task.checkCancellation() | ||||||
| let type: UTType | ||||||
| if let image = CGImageSourceCreateWithURL(download as CFURL, nil), | ||||||
| CGImageSourceGetCount(image) > 0, let imageType = CGImageSourceGetType(image), | ||||||
| let detectedType = UTType(imageType as String) { | ||||||
| type = detectedType | ||||||
| } else if CGPDFDocument(download as CFURL) != nil { | ||||||
| type = .pdf | ||||||
| } else { | ||||||
| throw URLError(.cannotDecodeContentData) | ||||||
| } | ||||||
| let filename = URL(fileURLWithPath: title).lastPathComponent as NSString | ||||||
| let originalExtension = filename.pathExtension | ||||||
| let fileExtension = UTType(filenameExtension: originalExtension) == type | ||||||
| ? originalExtension : type.preferredFilenameExtension ?? "png" | ||||||
| let stem = filename.deletingPathExtension | ||||||
| var name = String(stem.prefix(60)).components(separatedBy: .controlCharacters).joined(separator: "_") | ||||||
| while name.utf8.count > 200 { name.removeLast() } | ||||||
| let file = directory.appendingPathComponent("\(name.isEmpty ? "Preview" : name).\(fileExtension)") | ||||||
| try FileManager.default.moveItem(at: download, to: file) | ||||||
| return file | ||||||
| } catch { | ||||||
| try? FileManager.default.removeItem(at: directory) | ||||||
| throw error | ||||||
| } | ||||||
| } | ||||||
| } | ||||||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Throw a descriptive error instead of
URLError(.cannotLoadFromNetwork).This guard fails when a preview is already active or when no presenter exists.
URLError(.cannotLoadFromNetwork)reports a severed network load, so the rejection reason that reaches JS and the logs is wrong.presentVideoandshareFilein this file both throw anNSErrorwith a readableNSLocalizedDescriptionKey. Match that pattern.🧹 Proposed fix
guard filePresentation == nil, videoPresentation == nil, let presenter = appContext?.utilities?.currentViewController() - else { throw URLError(.cannotLoadFromNetwork) } + else { + throw NSError( + domain: "T3NativeFilePresentation", + code: 2, + userInfo: [NSLocalizedDescriptionKey: "The file preview is no longer available."] + ) + }🤖 Prompt for AI Agents