import Flutter import QuickLook import UIKit import UniformTypeIdentifiers @main @objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { return super.application(application, didFinishLaunchingWithOptions: launchOptions) } func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) if let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "SMDocuments") { Documents.shared.attach(registrar.messenger()) } } } /// Files on the device through the system document picker (Files app and /// file providers). Access exists only for what the user picks: documents /// are kept as security-scoped bookmarks and read/written with /// NSFileCoordinator, inside the iOS sandbox. final class Documents: NSObject, UIDocumentPickerDelegate, QLPreviewControllerDataSource, QLPreviewControllerDelegate { static let shared = Documents() private var channel: FlutterMethodChannel? private var pending: (([URL]?) -> Void)? private var incoming: [[String: Any?]] = [] private var previewURL: URL? private var previewResult: FlutterResult? private let queue = DispatchQueue(label: "eu.chj.sm_mobile.documents") func attach(_ messenger: FlutterBinaryMessenger) { let channel = FlutterMethodChannel(name: "eu.chj.sm_mobile/documents", binaryMessenger: messenger) channel.setMethodCallHandler { [weak self] call, result in self?.handle(call, result) } self.channel = channel } // MARK: identifiers /// "b:" for a picked item; "b:\n" /// for an item inside a picked folder. private func id(bookmarkOf url: URL) throws -> String { let scoped = url.startAccessingSecurityScopedResource() defer { if scoped { url.stopAccessingSecurityScopedResource() } } let data = try url.bookmarkData(options: [], includingResourceValuesForKeys: nil, relativeTo: nil) return "b:" + data.base64EncodedString() } private func resolve(_ id: String) throws -> (url: URL, scope: URL) { let parts = id.split(separator: "\n", maxSplits: 1).map(String.init) guard let first = parts.first, first.hasPrefix("b:"), let data = Data(base64Encoded: String(first.dropFirst(2))) else { throw DocError.code("notFound") } var stale = false let scope: URL do { scope = try URL(resolvingBookmarkData: data, options: [], relativeTo: nil, bookmarkDataIsStale: &stale) } catch { throw DocError.code("stale") } if parts.count == 2 { let relative = parts[1] if relative.split(separator: "/").contains("..") { throw DocError.code("denied") } return (scope.appendingPathComponent(relative), scope) } return (scope, scope) } enum DocError: Error { case code(String) } private func access(_ id: String, _ work: (URL) throws -> T) throws -> T { let (url, scope) = try resolve(id) let scoped = scope.startAccessingSecurityScopedResource() defer { if scoped { scope.stopAccessingSecurityScopedResource() } } return try work(url) } private func describe(_ url: URL, id: String, location: String? = nil) -> [String: Any?] { let keys: Set = [ .nameKey, .fileSizeKey, .contentModificationDateKey, .isDirectoryKey, .isWritableKey, .contentTypeKey, ] let v = try? url.resourceValues(forKeys: keys) let directory = v?.isDirectory ?? false return [ "id": id, "name": v?.name ?? url.lastPathComponent, "size": v?.fileSize, "modified": v?.contentModificationDate.map { Int($0.timeIntervalSince1970 * 1000) }, "directory": directory, "writable": !directory && (v?.isWritable ?? FileManager.default.isWritableFile(atPath: url.path)), "mime": v?.contentType?.preferredMIMEType, "location": location ?? url.deletingLastPathComponent().lastPathComponent, ] } private func coordinatedRead(_ url: URL) throws -> Data { var error: NSError? var result: Result = .failure(DocError.code("io")) NSFileCoordinator().coordinate(readingItemAt: url, options: [], error: &error) { u in result = Result { try Data(contentsOf: u) } } if let error { throw error } return try result.get() } private func coordinatedWrite(_ url: URL, _ data: Data) throws { var error: NSError? var result: Result = .success(()) NSFileCoordinator().coordinate(writingItemAt: url, options: .forReplacing, error: &error) { u in result = Result { try data.write(to: u, options: .atomic) } } if let error { throw error } try result.get() } // MARK: presentation private var top: UIViewController? { let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene } var vc = scenes.flatMap { $0.windows }.first { $0.isKeyWindow }?.rootViewController while let presented = vc?.presentedViewController { vc = presented } return vc } private func present(_ picker: UIDocumentPickerViewController, _ done: @escaping ([URL]?) -> Void) -> Bool { guard pending == nil, let top else { return false } pending = done picker.delegate = self top.present(picker, animated: true) return true } func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { let done = pending pending = nil done?(urls) } func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { let done = pending pending = nil done?(nil) } func numberOfPreviewItems(in controller: QLPreviewController) -> Int { previewURL == nil ? 0 : 1 } func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem { previewURL! as QLPreviewItem } func previewControllerDidDismiss(_ controller: QLPreviewController) { previewResult?(true) previewResult = nil previewURL = nil } // MARK: calls private func fail(_ result: @escaping FlutterResult, _ error: Error) { let code: String switch error { case DocError.code(let c): code = c case let e as NSError where e.domain == NSCocoaErrorDomain: switch e.code { case NSFileWriteNoPermissionError, NSFileReadNoPermissionError: code = "denied" case NSFileWriteOutOfSpaceError: code = "noSpace" case NSFileNoSuchFileError, NSFileReadNoSuchFileError: code = "notFound" case NSFileWriteVolumeReadOnlyError: code = "readOnly" default: code = "io" } default: code = "io" } DispatchQueue.main.async { result(FlutterError(code: code, message: "\(error)", details: nil)) } } private func background(_ result: @escaping FlutterResult, _ work: @escaping () throws -> Any?) { queue.async { do { let value = try work() DispatchQueue.main.async { result(value) } } catch { self.fail(result, error) } } } private func handle(_ call: FlutterMethodCall, _ result: @escaping FlutterResult) { let args = call.arguments as? [String: Any] ?? [:] switch call.method { case "pick": let picker = UIDocumentPickerViewController(forOpeningContentTypes: [.item], asCopy: false) picker.allowsMultipleSelection = args["multiple"] as? Bool ?? false let shown = present(picker) { urls in guard let urls else { return result([]) } self.background(result) { try urls.map { url in let id = try self.id(bookmarkOf: url) return try self.access(id) { self.describe($0, id: id) } } } } if !shown { result(FlutterError(code: "busy", message: nil, details: nil)) } case "pickFolder": let picker = UIDocumentPickerViewController(forOpeningContentTypes: [.folder], asCopy: false) let shown = present(picker) { urls in guard let url = urls?.first else { return result(nil) } self.background(result) { let id = try self.id(bookmarkOf: url) return try self.access(id) { self.describe($0, id: id) } } } if !shown { result(FlutterError(code: "busy", message: nil, details: nil)) } case "list": let folder = args["id"] as! String background(result) { try self.access(folder) { url in let (_, scope) = try self.resolve(folder) let base = folder.split(separator: "\n", maxSplits: 1).first.map(String.init)! let items = try FileManager.default.contentsOfDirectory( at: url, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles]) return items.map { item -> [String: Any?] in let relative = String(item.standardizedFileURL.path.dropFirst(scope.standardizedFileURL.path.count)) .trimmingCharacters(in: CharacterSet(charactersIn: "/")) return self.describe(item, id: base + "\n" + relative, location: url.lastPathComponent) } } } case "stat": let id = args["id"] as! String background(result) { try self.access(id) { self.describe($0, id: id) } } case "read": let id = args["id"] as! String background(result) { try self.access(id) { FlutterStandardTypedData(bytes: try self.coordinatedRead($0)) } } case "write": let id = args["id"] as! String let bytes = (args["bytes"] as! FlutterStandardTypedData).data background(result) { try self.access(id) { url in if !FileManager.default.isWritableFile(atPath: url.path) { throw DocError.code("readOnly") } try self.coordinatedWrite(url, bytes) return self.describe(url, id: id) } } case "create": let folder = args["folder"] as! String let name = args["name"] as! String let bytes = (args["bytes"] as! FlutterStandardTypedData).data background(result) { if name.contains("/") || name == ".." { throw DocError.code("denied") } return try self.access(folder) { url in let target = url.appendingPathComponent(name) if FileManager.default.fileExists(atPath: target.path) { throw DocError.code("exists") } try self.coordinatedWrite(target, bytes) let base = folder.split(separator: "\n", maxSplits: 1).map(String.init) let relative = (base.count == 2 ? base[1] + "/" : "") + name return self.describe(target, id: base[0] + "\n" + relative) } } case "saveAs": let name = args["name"] as! String let bytes = (args["bytes"] as! FlutterStandardTypedData).data let dir = FileManager.default.temporaryDirectory.appendingPathComponent("sm-transfer-export-\(UUID().uuidString)") do { try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) let temp = dir.appendingPathComponent((name as NSString).lastPathComponent) try bytes.write(to: temp, options: .atomic) let picker = UIDocumentPickerViewController(forExporting: [temp], asCopy: true) let shown = present(picker) { urls in try? FileManager.default.removeItem(at: dir) guard let url = urls?.first else { return result(nil) } self.background(result) { if let id = try? self.id(bookmarkOf: url) { return try self.access(id) { self.describe($0, id: id) } } // Saved, but iOS gave no lasting access to the new file. var info = self.describe(url, id: "") info["writable"] = false return info } } if !shown { try? FileManager.default.removeItem(at: dir) result(FlutterError(code: "busy", message: nil, details: nil)) } } catch { fail(result, error) } case "preview": let url = URL(fileURLWithPath: args["path"] as! String) guard url.standardizedFileURL.path.hasPrefix(FileManager.default.temporaryDirectory.standardizedFileURL.path), QLPreviewController.canPreview(url as QLPreviewItem), let top else { return result(false) } previewURL = url previewResult = result let ql = QLPreviewController() ql.dataSource = self ql.delegate = self top.present(ql, animated: true) case "takeIncoming": result(incoming) incoming.removeAll() case "forget": result(nil) default: result(FlutterMethodNotImplemented) } } /// Files opened in SM Mobile from other apps ("Open in", Files). func receive(_ urls: [URL]) { queue.async { let docs: [[String: Any?]] = urls.compactMap { url in guard let id = try? self.id(bookmarkOf: url) else { return nil } return try? self.access(id) { self.describe($0, id: id) } } DispatchQueue.main.async { guard !docs.isEmpty else { return } if let channel = self.channel { channel.invokeMethod("incoming", arguments: self.incoming + docs) self.incoming.removeAll() } else { self.incoming.append(contentsOf: docs) } } } } }