From 469e57cca708100398139f0da4509fd24b43db1f Mon Sep 17 00:00:00 2001 From: Ayushya Amitabh Date: Tue, 6 Oct 2026 19:12:15 -0400 Subject: [PATCH] iOS: native upload and download services (background URLSession) Swift implementation of the upload_service/download_service channels: background PUT/GET against the account's WebDAV root, one summary local notification per batch, Downloads visible in the Files app. Pure helpers covered by RunnerTests. Co-Authored-By: Claude Sonnet 5.5 --- .claude/context/server.md | 43 +++- ios/Runner.xcodeproj/project.pbxproj | 21 ++ ios/Runner/AppDelegate.swift | 15 ++ ios/Runner/Info.plist | 4 + ios/Runner/Native/NativeServices.swift | 114 +++++++++ ios/Runner/Native/TransferManager.swift | 292 ++++++++++++++++++++++++ ios/Runner/Native/WebDAV.swift | 53 +++++ ios/RunnerTests/RunnerTests.swift | 46 +++- 8 files changed, 579 insertions(+), 9 deletions(-) create mode 100644 ios/Runner/Native/NativeServices.swift create mode 100644 ios/Runner/Native/TransferManager.swift create mode 100644 ios/Runner/Native/WebDAV.swift diff --git a/.claude/context/server.md b/.claude/context/server.md index 1ab5158..17681b4 100644 --- a/.claude/context/server.md +++ b/.claude/context/server.md @@ -816,10 +816,11 @@ files, 15 for folders), keeping the existing reshare bit, plus Remove access. The five `dev.ayushya.noo/*` channels (`share_intent`, `pick_intent`, `upload_service`, `download_service`, `sync_service`, plus their status -`EventChannel`s) are implemented in Kotlin only; iOS has no handler for any -of them yet, so every call there throws `MissingPluginException`. -`lib/services/native_channel.dart` makes that safe until each one is built -in Swift: +`EventChannel`s) were written for Android in Kotlin. iOS implements +`upload_service` (+ its status stream) and `download_service` so far +(`ios/Runner/Native/`, see below); for the other three there's no handler, so +calls throw `MissingPluginException`. `lib/services/native_channel.dart` +makes that safe until each one is built in Swift: - `invokeIfAvailable` - a missing implementation returns null. For cold-start checks (`getInitialShare`, `getPickRequest`, `getSyncStatus`) and @@ -839,6 +840,36 @@ side - the helpers only act on `MissingPluginException`. lives: `getExternalStorageDirectory()` on Android (what `SyncEngine.kt` writes), the app support directory elsewhere, because that call throws on iOS. `OfflineController` and `localSyncedFilePath` both go through it. +### iOS upload/download (`ios/Runner/Native/`) + +- `NativeServices.swift` registers the channels from + `AppDelegate.didInitializeImplicitFlutterEngine` (via + `engineBridge.applicationRegistrar.messenger()`) and forwards + `startUpload`/`startDownload` to `TransferManager`; a batch's completion + goes back over `upload_service/status` so `FilesController` refreshes the + folder like on Android. +- `TransferManager.swift` runs everything on one *background* `URLSession` + (`dev.ayushya.noo.transfers`, `sessionSendsLaunchEvents`), so transfers + finish after the app is suspended/closed; `AppDelegate` hands the system's + `handleEventsForBackgroundURLSession` completion handler to it and calls + `reconnect()` at launch. Each task carries its info in `taskDescription` + and its batch's running totals live in `UserDefaults`, so the one summary + local notification ("Uploaded 3 files") still fires after a relaunch. +- Uploads: one `PUT` per file to `/remote.php/dav/files//...` + from a staged copy in Caches (no chunking, so very large files are bound by + the server's single-request limit; an existing file with the same name is + overwritten). Sends `X-OC-Mtime`. Downloads: `GET` into + `Documents/Downloads`, with `UIFileSharingEnabled` + + `LSSupportsOpeningDocumentsInPlace` set so it shows in the Files app under + "On My iPhone > Noo"; a name clash becomes `a (1).txt`. +- No progress notification (iOS can't update one from a background session + the way Android's foreground service does) and no cancel yet - only the + final summary. The Dart snackbar text still says "see the notification for + progress". +- `WebDAV.swift` (URL building) and `LocalFiles.uniqueURL` are pure and + covered by `ios/RunnerTests` (`xcodebuild test -workspace + ios/Runner.xcworkspace -scheme Runner -destination 'platform=iOS + Simulator,id='`). + Not built on iOS yet: Share Extension (needs an App Group), the picker -(File Provider), background uploads/downloads (`URLSession`), and sync -(`BGTaskScheduler`) - see the iOS handoff notes. +(File Provider), and sync (`BGTaskScheduler`) - see the iOS handoff notes. diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 8da8431..325749d 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -10,12 +10,15 @@ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 5694337ECF8D3ADD3695CBCD /* NativeServices.swift in Sources */ = {isa = PBXBuildFile; fileRef = 901C0CDB4FB819302E2A7563 /* NativeServices.swift */; }; + 66D3CBDE6B3CD3DC578CECBE /* WebDAV.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F798220CB598B611CA03EFC /* WebDAV.swift */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; }; 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + FDFA55C4921747A9CBCBBEEC /* TransferManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8064CC1652B8F0A44E371638 /* TransferManager.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -47,11 +50,14 @@ 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 6F798220CB598B611CA03EFC /* WebDAV.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WebDAV.swift; sourceTree = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 8064CC1652B8F0A44E371638 /* TransferManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TransferManager.swift; sourceTree = ""; }; + 901C0CDB4FB819302E2A7563 /* NativeServices.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NativeServices.swift; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -81,6 +87,17 @@ path = RunnerTests; sourceTree = ""; }; + 5055E53DC55C0BCABD5F04F4 /* Native */ = { + isa = PBXGroup; + children = ( + 6F798220CB598B611CA03EFC /* WebDAV.swift */, + 8064CC1652B8F0A44E371638 /* TransferManager.swift */, + 901C0CDB4FB819302E2A7563 /* NativeServices.swift */, + ); + name = Native; + path = Native; + sourceTree = ""; + }; 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( @@ -124,6 +141,7 @@ 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + 5055E53DC55C0BCABD5F04F4 /* Native */, ); path = Runner; sourceTree = ""; @@ -284,6 +302,9 @@ 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */, + 66D3CBDE6B3CD3DC578CECBE /* WebDAV.swift in Sources */, + FDFA55C4921747A9CBCBBEEC /* TransferManager.swift in Sources */, + 5694337ECF8D3ADD3695CBCD /* NativeServices.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index c30b367..93ed6ed 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -7,10 +7,25 @@ import UIKit _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { + TransferManager.shared.reconnect() return super.application(application, didFinishLaunchingWithOptions: launchOptions) } + override func application( + _ application: UIApplication, + handleEventsForBackgroundURLSession identifier: String, + completionHandler: @escaping () -> Void + ) { + if identifier == TransferManager.sessionIdentifier { + TransferManager.shared.backgroundCompletionHandler = completionHandler + } else { + super.application( + application, handleEventsForBackgroundURLSession: identifier, completionHandler: completionHandler) + } + } + func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + NativeServices.register(messenger: engineBridge.applicationRegistrar.messenger()) } } diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 89fef25..7f64075 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -53,6 +53,10 @@ LaunchScreen UIMainStoryboardFile Main + UIFileSharingEnabled + + LSSupportsOpeningDocumentsInPlace + NSFaceIDUsageDescription Noo uses Face ID to unlock the app and to protect hidden files. NSPhotoLibraryUsageDescription diff --git a/ios/Runner/Native/NativeServices.swift b/ios/Runner/Native/NativeServices.swift new file mode 100644 index 0000000..cdc24b8 --- /dev/null +++ b/ios/Runner/Native/NativeServices.swift @@ -0,0 +1,114 @@ +import Flutter +import Foundation + +/// Pushes upload-batch completions to Dart's `UploadService.completions`. +final class UploadStatusStreamHandler: NSObject, FlutterStreamHandler { + var sink: FlutterEventSink? + + func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) + -> FlutterError? + { + sink = events + return nil + } + + func onCancel(withArguments arguments: Any?) -> FlutterError? { + sink = nil + return nil + } +} + +/// The iOS side of the `dev.ayushya.noo/*` channels `lib/services/` talks to +/// (Android's lives in `MainActivity.kt`). Only the upload and download +/// services are implemented so far; the rest still throw +/// `MissingPluginException`, which the Dart side treats as "unavailable" +/// (`lib/services/native_channel.dart`). +enum NativeServices { + private static let uploadStatus = UploadStatusStreamHandler() + + static func register(messenger: FlutterBinaryMessenger) { + FlutterMethodChannel(name: "dev.ayushya.noo/upload_service", binaryMessenger: messenger) + .setMethodCallHandler { call, result in + guard call.method == "startUpload" else { return result(FlutterMethodNotImplemented) } + handle(call, result) { args in + let files = try parseJSONList(args["files"]) + try TransferManager.shared.startUpload( + serverUrl: try string(args, "serverUrl"), + username: try string(args, "username"), + authHeader: try string(args, "authHeader"), + files: files.compactMap { entry in + guard let uri = entry["uri"] as? String, let name = entry["name"] as? String else { + return nil + } + return UploadFile(uri: uri, name: name) + }, + remoteFolder: (args["remoteFolder"] as? String) ?? "/" + ) + } + } + + FlutterMethodChannel(name: "dev.ayushya.noo/download_service", binaryMessenger: messenger) + .setMethodCallHandler { call, result in + guard call.method == "startDownload" else { return result(FlutterMethodNotImplemented) } + handle(call, result) { args in + let items = try parseJSONList(args["files"]) + try TransferManager.shared.startDownload( + serverUrl: try string(args, "serverUrl"), + username: try string(args, "username"), + authHeader: try string(args, "authHeader"), + items: items.compactMap { entry in + guard let path = entry["path"] as? String, let name = entry["name"] as? String else { + return nil + } + return (path: path, name: name) + } + ) + } + } + + FlutterEventChannel(name: "dev.ayushya.noo/upload_service/status", binaryMessenger: messenger) + .setStreamHandler(uploadStatus) + TransferManager.shared.onUploadBatchFinished = { folder, succeeded, failed in + uploadStatus.sink?(["remoteFolder": folder, "succeeded": succeeded, "failed": failed]) + } + TransferManager.shared.reconnect() + } + + // MARK: - Argument helpers + + /// Runs [work] with the call's argument map, turning a thrown error into + /// the `FlutterError` Dart's `catch` shows the user. + private static func handle( + _ call: FlutterMethodCall, + _ result: @escaping FlutterResult, + work: ([String: Any]) throws -> Void + ) { + guard let args = call.arguments as? [String: Any] else { + return result(FlutterError(code: "bad_args", message: "Missing arguments.", details: nil)) + } + do { + try work(args) + result(nil) + } catch { + result(FlutterError(code: "transfer_failed", message: error.localizedDescription, details: nil)) + } + } + + private static func string(_ args: [String: Any], _ key: String) throws -> String { + guard let value = args[key] as? String, !value.isEmpty else { + throw TransferError(message: "Missing \(key).") + } + return value + } + + /// The Dart services pass their file list as one JSON string. + private static func parseJSONList(_ value: Any?) throws -> [[String: Any]] { + guard let json = value as? String, + let data = json.data(using: .utf8), + let list = try JSONSerialization.jsonObject(with: data) as? [[String: Any]] + else { + throw TransferError(message: "Missing file list.") + } + return list + } +} diff --git a/ios/Runner/Native/TransferManager.swift b/ios/Runner/Native/TransferManager.swift new file mode 100644 index 0000000..d682b0b --- /dev/null +++ b/ios/Runner/Native/TransferManager.swift @@ -0,0 +1,292 @@ +import Foundation +import UserNotifications + +/// What a background upload/download task is, stored in the task's +/// `taskDescription` so it's still known if the app was relaunched by the +/// system to deliver the result. +struct TransferTaskInfo: Codable { + enum Kind: String, Codable { case upload, download } + + let kind: Kind + let batch: String + let name: String + let remoteFolder: String? + let stagedPath: String? +} + +/// Running totals for one "upload these 3 files" / "download these 2 files" +/// request, persisted so the one summary notification (and the Dart-side +/// "folder changed" event) fires exactly once, on the last file, even across +/// an app relaunch. +struct TransferBatch: Codable { + let kind: TransferTaskInfo.Kind + let remoteFolder: String? + let total: Int + var succeeded: Int + var failed: Int + + var finished: Int { succeeded + failed } +} + +struct UploadFile { + let uri: String + let name: String +} + +struct TransferError: LocalizedError { + let message: String + var errorDescription: String? { message } +} + +/// Uploads and downloads on a background `URLSession`, so a transfer keeps +/// going - and finishes - after the app is suspended or closed. The iOS +/// counterpart of Android's `ShareUploadService`/`DownloadService` +/// foreground services, behind the same Dart channels (`NativeServices`). +/// +/// Uploads are one `PUT` per file straight to the account's WebDAV root +/// (no chunking), from a staged copy in Caches - a background upload task +/// needs a file that stays put, and the picker's temp file may not. Downloads +/// land in `Documents/Downloads`, which is visible in the Files app +/// (`UIFileSharingEnabled` + `LSSupportsOpeningDocumentsInPlace`). +final class TransferManager: NSObject { + static let shared = TransferManager() + static let sessionIdentifier = "dev.ayushya.noo.transfers" + + /// Called on the main thread when an upload batch ends with at least one + /// file uploaded: (remote folder, succeeded, failed). + var onUploadBatchFinished: ((String, Int, Int) -> Void)? + + /// The system's completion handler from + /// `application(_:handleEventsForBackgroundURLSession:)`, called once the + /// session has delivered every pending event. + var backgroundCompletionHandler: (() -> Void)? + + private let defaults = UserDefaults.standard + private let lock = NSLock() + + private lazy var session: URLSession = { + let config = URLSessionConfiguration.background(withIdentifier: Self.sessionIdentifier) + config.sessionSendsLaunchEvents = true + config.isDiscretionary = false + config.waitsForConnectivity = true + return URLSession(configuration: config, delegate: self, delegateQueue: nil) + }() + + /// Touch the session at launch so a system relaunch for finished + /// background transfers reconnects to them. + func reconnect() { + _ = session + } + + // MARK: - Starting transfers + + func startUpload( + serverUrl: String, + username: String, + authHeader: String, + files: [UploadFile], + remoteFolder: String + ) throws { + guard !files.isEmpty else { throw TransferError(message: "No files to upload.") } + let batch = UUID().uuidString + let stageDir = try Self.stagingDirectory().appendingPathComponent(batch, isDirectory: true) + try FileManager.default.createDirectory(at: stageDir, withIntermediateDirectories: true) + + var tasks: [URLSessionUploadTask] = [] + for (index, file) in files.enumerated() { + guard let source = URL(string: file.uri), source.isFileURL else { + throw TransferError(message: "Can't read \(file.name).") + } + let staged = stageDir.appendingPathComponent("\(index)-\(file.name)") + do { + try FileManager.default.copyItem(at: source, to: staged) + } catch { + throw TransferError(message: "Can't read \(file.name): \(error.localizedDescription)") + } + guard let url = WebDAV.fileURL( + serverUrl: serverUrl, + username: username, + remotePath: WebDAV.join(remoteFolder, file.name) + ) else { + throw TransferError(message: "Invalid server address.") + } + var request = URLRequest(url: url) + request.httpMethod = "PUT" + request.setValue(authHeader, forHTTPHeaderField: "Authorization") + if let modified = (try? FileManager.default.attributesOfItem(atPath: staged.path))?[.modificationDate] as? Date { + request.setValue(String(Int(modified.timeIntervalSince1970)), forHTTPHeaderField: "X-OC-Mtime") + } + let task = session.uploadTask(with: request, fromFile: staged) + task.taskDescription = Self.encode( + TransferTaskInfo( + kind: .upload, batch: batch, name: file.name, + remoteFolder: remoteFolder, stagedPath: staged.path + ) + ) + tasks.append(task) + } + saveBatch( + TransferBatch(kind: .upload, remoteFolder: remoteFolder, total: tasks.count, succeeded: 0, failed: 0), + id: batch + ) + tasks.forEach { $0.resume() } + } + + func startDownload( + serverUrl: String, + username: String, + authHeader: String, + items: [(path: String, name: String)] + ) throws { + guard !items.isEmpty else { throw TransferError(message: "No files to download.") } + let batch = UUID().uuidString + var tasks: [URLSessionDownloadTask] = [] + for item in items { + guard let url = WebDAV.fileURL(serverUrl: serverUrl, username: username, remotePath: item.path) else { + throw TransferError(message: "Invalid server address.") + } + var request = URLRequest(url: url) + request.setValue(authHeader, forHTTPHeaderField: "Authorization") + let task = session.downloadTask(with: request) + task.taskDescription = Self.encode( + TransferTaskInfo(kind: .download, batch: batch, name: item.name, remoteFolder: nil, stagedPath: nil) + ) + tasks.append(task) + } + saveBatch( + TransferBatch(kind: .download, remoteFolder: nil, total: tasks.count, succeeded: 0, failed: 0), + id: batch + ) + tasks.forEach { $0.resume() } + } + + // MARK: - Locations + + private static func stagingDirectory() throws -> URL { + let caches = try FileManager.default.url( + for: .cachesDirectory, in: .userDomainMask, appropriateFor: nil, create: true + ) + return caches.appendingPathComponent("uploads", isDirectory: true) + } + + static func downloadsDirectory() throws -> URL { + let documents = try FileManager.default.url( + for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true + ) + let dir = documents.appendingPathComponent("Downloads", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir + } + + // MARK: - Batch bookkeeping + + private static func encode(_ info: TransferTaskInfo) -> String? { + (try? JSONEncoder().encode(info)).flatMap { String(data: $0, encoding: .utf8) } + } + + private static func info(for task: URLSessionTask) -> TransferTaskInfo? { + task.taskDescription + .flatMap { $0.data(using: .utf8) } + .flatMap { try? JSONDecoder().decode(TransferTaskInfo.self, from: $0) } + } + + private func key(_ id: String) -> String { "transfer.batch.\(id)" } + + private func saveBatch(_ batch: TransferBatch, id: String) { + lock.lock() + defer { lock.unlock() } + if let data = try? JSONEncoder().encode(batch) { defaults.set(data, forKey: key(id)) } + } + + /// Counts one file's outcome; fires the batch summary when it was the last. + private func record(_ info: TransferTaskInfo, success: Bool) { + lock.lock() + let storageKey = key(info.batch) + var batch = + (defaults.data(forKey: storageKey).flatMap { try? JSONDecoder().decode(TransferBatch.self, from: $0) }) + ?? TransferBatch(kind: info.kind, remoteFolder: info.remoteFolder, total: 1, succeeded: 0, failed: 0) + if success { batch.succeeded += 1 } else { batch.failed += 1 } + let done = batch.finished >= batch.total + if done { + defaults.removeObject(forKey: storageKey) + } else if let data = try? JSONEncoder().encode(batch) { + defaults.set(data, forKey: storageKey) + } + lock.unlock() + if done { finish(batch) } + } + + private func finish(_ batch: TransferBatch) { + let verb = batch.kind == .upload ? "upload" : "download" + let past = batch.kind == .upload ? "Uploaded" : "Downloaded" + let body: String + if batch.failed == 0 { + body = "\(past) \(Self.files(batch.succeeded))" + } else if batch.succeeded == 0 { + body = "Couldn't \(verb) \(Self.files(batch.failed))" + } else { + body = "\(past) \(Self.files(batch.succeeded)), \(batch.failed) failed" + } + notify(body) + if batch.kind == .upload, batch.succeeded > 0, let folder = batch.remoteFolder { + DispatchQueue.main.async { self.onUploadBatchFinished?(folder, batch.succeeded, batch.failed) } + } + } + + private static func files(_ count: Int) -> String { + count == 1 ? "1 file" : "\(count) files" + } + + private func notify(_ body: String) { + let content = UNMutableNotificationContent() + content.title = "Noo" + content.body = body + content.sound = .default + UNUserNotificationCenter.current().add( + UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil) + ) + } +} + +// MARK: - URLSession delegates + +extension TransferManager: URLSessionDownloadDelegate { + func urlSession( + _ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL + ) { + guard let info = Self.info(for: downloadTask) else { return } + let status = (downloadTask.response as? HTTPURLResponse)?.statusCode ?? 0 + guard (200..<300).contains(status) else { + record(info, success: false) + return + } + do { + let destination = LocalFiles.uniqueURL(in: try Self.downloadsDirectory(), name: info.name) + try FileManager.default.moveItem(at: location, to: destination) + record(info, success: true) + } catch { + record(info, success: false) + } + } + + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + guard let info = Self.info(for: task) else { return } + switch info.kind { + case .upload: + if let staged = info.stagedPath { try? FileManager.default.removeItem(atPath: staged) } + let status = (task.response as? HTTPURLResponse)?.statusCode ?? 0 + record(info, success: error == nil && (200..<300).contains(status)) + case .download: + // A finished download was already counted in didFinishDownloadingTo; + // only a transport failure arrives here. + if error != nil { record(info, success: false) } + } + } + + func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) { + DispatchQueue.main.async { + self.backgroundCompletionHandler?() + self.backgroundCompletionHandler = nil + } + } +} diff --git a/ios/Runner/Native/WebDAV.swift b/ios/Runner/Native/WebDAV.swift new file mode 100644 index 0000000..aefbae2 --- /dev/null +++ b/ios/Runner/Native/WebDAV.swift @@ -0,0 +1,53 @@ +import Foundation + +/// URL building for a Nextcloud account's WebDAV files root. Pure functions +/// (no networking), so they're unit-tested in `RunnerTests`. +enum WebDAV { + /// `urlPathAllowed` minus "/", so a file name containing a slash-like + /// character can't introduce an extra path segment. + private static let segmentAllowed: CharacterSet = { + var set = CharacterSet.urlPathAllowed + set.remove(charactersIn: "/") + return set + }() + + static func encodeSegment(_ segment: String) -> String { + segment.addingPercentEncoding(withAllowedCharacters: segmentAllowed) ?? segment + } + + /// `/remote.php/dav/files//` - [remotePath] is + /// relative to the user's root, with or without a leading slash. A server + /// installed under a sub-path (`https://host/nextcloud`) keeps it. + static func fileURL(serverUrl: String, username: String, remotePath: String) -> URL? { + var base = serverUrl + while base.hasSuffix("/") { base.removeLast() } + let segments = remotePath.split(separator: "/").map { encodeSegment(String($0)) } + var url = base + "/remote.php/dav/files/" + encodeSegment(username) + if !segments.isEmpty { url += "/" + segments.joined(separator: "/") } + return URL(string: url) + } + + /// `folder` + `name` with exactly one slash between them. + static func join(_ folder: String, _ name: String) -> String { + folder.hasSuffix("/") ? folder + name : folder + "/" + name + } +} + +enum LocalFiles { + /// A URL in [directory] for [name] that doesn't exist yet: "a.txt", then + /// "a (1).txt", "a (2).txt"... - so a second download of the same file + /// never overwrites the first. + static func uniqueURL(in directory: URL, name: String, fileManager: FileManager = .default) -> URL { + let candidate = directory.appendingPathComponent(name) + if !fileManager.fileExists(atPath: candidate.path) { return candidate } + let ext = (name as NSString).pathExtension + let stem = (name as NSString).deletingPathExtension + var index = 1 + while true { + let numbered = ext.isEmpty ? "\(stem) (\(index))" : "\(stem) (\(index)).\(ext)" + let url = directory.appendingPathComponent(numbered) + if !fileManager.fileExists(atPath: url.path) { return url } + index += 1 + } + } +} diff --git a/ios/RunnerTests/RunnerTests.swift b/ios/RunnerTests/RunnerTests.swift index 86a7c3b..602d74c 100644 --- a/ios/RunnerTests/RunnerTests.swift +++ b/ios/RunnerTests/RunnerTests.swift @@ -2,11 +2,51 @@ import Flutter import UIKit import XCTest +@testable import Runner + class RunnerTests: XCTestCase { - func testExample() { - // If you add code to the Runner application, consider adding tests here. - // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + func testFileURLEncodesEachSegment() { + let url = WebDAV.fileURL( + serverUrl: "https://cloud.example.com", username: "alice", + remotePath: "/Tax Forms/2026 #1/a&b.pdf") + XCTAssertEqual( + url?.absoluteString, + "https://cloud.example.com/remote.php/dav/files/alice/Tax%20Forms/2026%20%231/a&b.pdf") } + func testFileURLKeepsServerSubPathAndDropsTrailingSlash() { + let url = WebDAV.fileURL( + serverUrl: "https://example.com/nextcloud/", username: "bob@home", remotePath: "docs/x.txt") + XCTAssertEqual( + url?.absoluteString, + "https://example.com/nextcloud/remote.php/dav/files/bob@home/docs/x.txt") + } + + func testFileURLForRoot() { + XCTAssertEqual( + WebDAV.fileURL(serverUrl: "https://c.io", username: "u", remotePath: "/")?.absoluteString, + "https://c.io/remote.php/dav/files/u") + } + + func testJoinUsesOneSlash() { + XCTAssertEqual(WebDAV.join("/", "a.txt"), "/a.txt") + XCTAssertEqual(WebDAV.join("/docs", "a.txt"), "/docs/a.txt") + XCTAssertEqual(WebDAV.join("/docs/", "a.txt"), "/docs/a.txt") + } + + func testUniqueURLNumbersCollisions() throws { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("noo-tests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + + XCTAssertEqual(LocalFiles.uniqueURL(in: dir, name: "a.txt").lastPathComponent, "a.txt") + FileManager.default.createFile(atPath: dir.appendingPathComponent("a.txt").path, contents: nil) + XCTAssertEqual(LocalFiles.uniqueURL(in: dir, name: "a.txt").lastPathComponent, "a (1).txt") + FileManager.default.createFile(atPath: dir.appendingPathComponent("a (1).txt").path, contents: nil) + XCTAssertEqual(LocalFiles.uniqueURL(in: dir, name: "a.txt").lastPathComponent, "a (2).txt") + FileManager.default.createFile(atPath: dir.appendingPathComponent("README").path, contents: nil) + XCTAssertEqual(LocalFiles.uniqueURL(in: dir, name: "README").lastPathComponent, "README (1)") + } }