mirror of
https://github.com/VibedByKaKi/t3-code-android-nightly.git
synced 2026-10-10 12:21:16 +02:00
185 lines
7.6 KiB
Swift
185 lines
7.6 KiB
Swift
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
|
|
}
|
|
// Quick Look knows which formats it renders; refuse before presenting so the caller
|
|
// can fall back instead of showing an "unsupported format" page.
|
|
guard QLPreviewController.canPreview(file as NSURL) else {
|
|
try? FileManager.default.removeItem(at: file.deletingLastPathComponent())
|
|
throw NSError(
|
|
domain: "T3NativePresentation",
|
|
code: 3,
|
|
userInfo: [NSLocalizedDescriptionKey: "This file type cannot be previewed on this device."]
|
|
)
|
|
}
|
|
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 {
|
|
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()
|
|
// Pictures and PDFs are identified by content so a misnamed file still opens correctly.
|
|
// Anything else keeps its own extension: Quick Look decides from that whether it can
|
|
// render the document (Office, iWork, RTF and more) and refuses before we present.
|
|
let filename = URL(fileURLWithPath: title).lastPathComponent as NSString
|
|
let originalExtension = filename.pathExtension
|
|
var 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 if originalExtension.lowercased() == "svg" {
|
|
type = .svg
|
|
}
|
|
let fileExtension: String
|
|
if let type {
|
|
fileExtension = UTType(filenameExtension: originalExtension) == type
|
|
? originalExtension : type.preferredFilenameExtension ?? originalExtension
|
|
} else {
|
|
fileExtension = originalExtension
|
|
}
|
|
let stem = filename.deletingPathExtension
|
|
var name = String(stem.prefix(60)).components(separatedBy: .controlCharacters).joined(separator: "_")
|
|
while name.utf8.count > 200 { name.removeLast() }
|
|
let baseName = name.isEmpty ? "Preview" : name
|
|
let file = directory.appendingPathComponent(
|
|
fileExtension.isEmpty ? baseName : "\(baseName).\(fileExtension)")
|
|
try FileManager.default.moveItem(at: download, to: file)
|
|
return file
|
|
} catch {
|
|
try? FileManager.default.removeItem(at: directory)
|
|
throw error
|
|
}
|
|
}
|
|
}
|