From 0c063c1fb47807bf5a84fbb9ae2f7c8110fcb674 Mon Sep 17 00:00:00 2001 From: Ayushya Amitabh Date: Tue, 6 Oct 2026 19:44:19 -0400 Subject: [PATCH] iOS transfers: 'Uploading...' then 'Uploaded' notification, and count share uploads The Share Extension's session had no delegate, so a fast upload finished unseen and the invalidated session was discarded before the app could hear of it - no summary notification. Results are now handled by a shared delegate in whichever process is alive, started/finished notifications replace each other in place, and the app refreshes the folder the extension uploaded to. Co-Authored-By: Claude Sonnet 5.5 --- .claude/context/server.md | 29 ++++++--- ios/Runner.xcodeproj/project.pbxproj | 6 ++ ios/Runner/Native/NativeServices.swift | 1 + ios/Runner/Native/TransferManager.swift | 48 +++++++-------- ios/RunnerTests/RunnerTests.swift | 15 +++++ ios/Shared/ShareUpload.swift | 7 ++- ios/Shared/TransferNotifications.swift | 80 +++++++++++++++++++++++++ ios/Shared/TransferTypes.swift | 21 +++++++ 8 files changed, 168 insertions(+), 39 deletions(-) create mode 100644 ios/Shared/TransferNotifications.swift diff --git a/.claude/context/server.md b/.claude/context/server.md index 2199dc6..a3dfb15 100644 --- a/.claude/context/server.md +++ b/.claude/context/server.md @@ -863,10 +863,13 @@ iOS. `OfflineController` and `localSyncedFilePath` both go through it. `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". +- Notifications (`ios/Shared/TransferNotifications.swift`): "Uploading 3 + files..." when a batch starts, replaced in place (same request id = + `transfer.`) by "Uploaded 3 files" when it ends. iOS can't show live + byte progress from a background session the way Android's foreground + service does (a Live Activity would, but needs a widget extension), and + there's no cancel yet. 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 @@ -891,12 +894,20 @@ Reminders/Notes - the destination is picked *inside* the sheet: channel - both wired where `SessionController` is created in `main.dart`. 3. **Upload** calls `ShareUpload.enqueue`: one `PUT` per file on a *background* `URLSession` (`dev.ayushya.noo.transfers.share`, with - `sharedContainerIdentifier`) that outlives the extension. The app recreates + `sharedContainerIdentifier`) that outlives the extension, and posts + "Uploading N files...". Results go to whichever process is alive: + files that finish while the sheet is still open are counted by the + extension's `ShareUploadDelegate`; the rest by the app, which recreates that session at launch (`TransferManager.reconnect`) and is relaunched by - the system to receive the results, post the "Uploaded N files" summary and - delete the copies; `TransferBatchStore` keeps batch totals in the App - Group's `UserDefaults` because the extension and the app are different - processes. + the system. Either way `UploadResults.handle` deletes the staged copy, + counts the file in `TransferBatchStore` (batch totals live in the App + Group's `UserDefaults` because the two are different processes) and + posts the "Uploaded N files" summary on the last one; an upload the + extension finished leaves a note (`noteFinishedUpload`) so the app + refreshes that folder on its next activation. **Don't give the + extension's session no delegate** - a fast upload then finishes unseen and + the invalidated session is discarded before the app can ever hear of it + (found the hard way: no notification, no count). 4. Fallbacks: with no account (or the user taps "Choose a folder later in Noo") the files go into `SharedInbox/pending.json` instead and a local notification asks the user to open Noo; the app consumes that through diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 36c92b5..583e205 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -17,6 +17,7 @@ 3C1A0DAA5F9B37FEA4C604BE /* DavFolders.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51604DEE43E937A5FDCA7A58 /* DavFolders.swift */; }; 3DEDE94F2A4C921407EF4382 /* ShareUpload.swift in Sources */ = {isa = PBXBuildFile; fileRef = B93DB1F595F44E2F2057BB2A /* ShareUpload.swift */; }; 43DB9AB631DD359AEC613A1D /* WebDAV.swift in Sources */ = {isa = PBXBuildFile; fileRef = 871EDB5791D5B3DA0ECF3D4E /* WebDAV.swift */; }; + 44078FC9474337A0E102B524 /* TransferNotifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3247AAA4B65FE0F1A7279FA1 /* TransferNotifications.swift */; }; 5694337ECF8D3ADD3695CBCD /* NativeServices.swift in Sources */ = {isa = PBXBuildFile; fileRef = 901C0CDB4FB819302E2A7563 /* NativeServices.swift */; }; 62028E64976F15B4FDEDA9F1 /* ShareUpload.swift in Sources */ = {isa = PBXBuildFile; fileRef = B93DB1F595F44E2F2057BB2A /* ShareUpload.swift */; }; 63229CACE4281880037D33E6 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27BDF61F5197F9B8F648E8AE /* ShareViewController.swift */; }; @@ -29,6 +30,7 @@ 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 */; }; + A16680889B3A9D8672FA29C1 /* TransferNotifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3247AAA4B65FE0F1A7279FA1 /* TransferNotifications.swift */; }; A3B3AB501C29516E20455F23 /* DavFolders.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51604DEE43E937A5FDCA7A58 /* DavFolders.swift */; }; E295E45196E3973A5F7E1D8E /* SharedAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F2C956C98F58A97B5595307 /* SharedAccount.swift */; }; F598B112F4D33953984FEFF4 /* WebDAV.swift in Sources */ = {isa = PBXBuildFile; fileRef = 871EDB5791D5B3DA0ECF3D4E /* WebDAV.swift */; }; @@ -84,6 +86,7 @@ 1F2C956C98F58A97B5595307 /* SharedAccount.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SharedAccount.swift; sourceTree = ""; }; 20B0C3085FA6467C1A4A2947 /* TransferTypes.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TransferTypes.swift; sourceTree = ""; }; 27BDF61F5197F9B8F648E8AE /* ShareViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = ""; }; + 3247AAA4B65FE0F1A7279FA1 /* TransferNotifications.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TransferNotifications.swift; sourceTree = ""; }; 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 = ""; }; @@ -179,6 +182,7 @@ 1F2C956C98F58A97B5595307 /* SharedAccount.swift */, B93DB1F595F44E2F2057BB2A /* ShareUpload.swift */, 51604DEE43E937A5FDCA7A58 /* DavFolders.swift */, + 3247AAA4B65FE0F1A7279FA1 /* TransferNotifications.swift */, ); name = Shared; path = Shared; @@ -433,6 +437,7 @@ 3DEDE94F2A4C921407EF4382 /* ShareUpload.swift in Sources */, 3C1A0DAA5F9B37FEA4C604BE /* DavFolders.swift in Sources */, 66AFC246E251554A80B2476F /* SharePickerView.swift in Sources */, + A16680889B3A9D8672FA29C1 /* TransferNotifications.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -451,6 +456,7 @@ E295E45196E3973A5F7E1D8E /* SharedAccount.swift in Sources */, 62028E64976F15B4FDEDA9F1 /* ShareUpload.swift in Sources */, A3B3AB501C29516E20455F23 /* DavFolders.swift in Sources */, + 44078FC9474337A0E102B524 /* TransferNotifications.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/ios/Runner/Native/NativeServices.swift b/ios/Runner/Native/NativeServices.swift index e9ea450..db47b4e 100644 --- a/ios/Runner/Native/NativeServices.swift +++ b/ios/Runner/Native/NativeServices.swift @@ -125,6 +125,7 @@ enum NativeServices { NotificationCenter.default.addObserver( forName: UIApplication.didBecomeActiveNotification, object: nil, queue: .main ) { _ in + TransferManager.shared.deliverExtensionUploads() guard let sink = shareEvents.sink else { return } let items = consumeShared() if !items.isEmpty { sink(items.map(encode)) } diff --git a/ios/Runner/Native/TransferManager.swift b/ios/Runner/Native/TransferManager.swift index f28fc9a..dae9465 100644 --- a/ios/Runner/Native/TransferManager.swift +++ b/ios/Runner/Native/TransferManager.swift @@ -106,6 +106,7 @@ final class TransferManager: NSObject { TransferBatch(kind: .upload, remoteFolder: remoteFolder, total: tasks.count, succeeded: 0, failed: 0), id: batch ) + TransferNotifications.started(kind: .upload, batch: batch, count: tasks.count) tasks.forEach { $0.resume() } } @@ -134,6 +135,7 @@ final class TransferManager: NSObject { TransferBatch(kind: .download, remoteFolder: nil, total: tasks.count, succeeded: 0, failed: 0), id: batch ) + TransferNotifications.started(kind: .download, batch: batch, count: tasks.count) tasks.forEach { $0.resume() } } @@ -169,38 +171,28 @@ final class TransferManager: NSObject { /// Counts one file's outcome; fires the batch summary when it was the last. private func record(_ info: TransferTaskInfo, success: Bool) { - if let batch = TransferBatchStore.record(info, success: success) { finish(batch) } + if let batch = TransferBatchStore.record(info, success: success) { finish(batch, id: info.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) + private func finish(_ batch: TransferBatch, id: String) { + TransferNotifications.finished(batch, id: id) + reportToDart(batch) + } + + /// Tells Dart (so `FilesController` can refresh the folder) once an upload + /// batch has landed at least one file. + private func reportToDart(_ batch: TransferBatch) { 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) - ) + /// Folders the Share Extension finished uploading into while the app wasn't + /// running - call when the app becomes active. + func deliverExtensionUploads() { + for folder in TransferBatchStore.consumeFinishedUploadFolders() { + DispatchQueue.main.async { self.onUploadBatchFinished?(folder, 1, 0) } + } } } @@ -229,9 +221,9 @@ extension TransferManager: URLSessionDownloadDelegate { 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)) + if let result = UploadResults.handle(task, error: error), let batch = result.batch { + reportToDart(batch) + } case .download: // A finished download was already counted in didFinishDownloadingTo; // only a transport failure arrives here. diff --git a/ios/RunnerTests/RunnerTests.swift b/ios/RunnerTests/RunnerTests.swift index def112b..74ece5f 100644 --- a/ios/RunnerTests/RunnerTests.swift +++ b/ios/RunnerTests/RunnerTests.swift @@ -185,4 +185,19 @@ class RunnerTests: XCTestCase { XCTAssertEqual(finished?.failed, 1) XCTAssertEqual(finished?.remoteFolder, "/Docs") } + + func testFinishedUploadFoldersAreNotedOnceAndConsumed() { + _ = TransferBatchStore.consumeFinishedUploadFolders() // start clean + TransferBatchStore.noteFinishedUpload(folder: "/Docs") + TransferBatchStore.noteFinishedUpload(folder: "/Docs") + TransferBatchStore.noteFinishedUpload(folder: "/Photos") + + XCTAssertEqual(TransferBatchStore.consumeFinishedUploadFolders(), ["/Docs", "/Photos"]) + XCTAssertEqual(TransferBatchStore.consumeFinishedUploadFolders(), []) + } + + func testNotificationFileCountWording() { + XCTAssertEqual(TransferNotifications.files(1), "1 file") + XCTAssertEqual(TransferNotifications.files(3), "3 files") + } } diff --git a/ios/Shared/ShareUpload.swift b/ios/Shared/ShareUpload.swift index 1394963..cb1a9a2 100644 --- a/ios/Shared/ShareUpload.swift +++ b/ios/Shared/ShareUpload.swift @@ -43,8 +43,10 @@ enum ShareUpload { static func enqueue(items: [SharedItem], account: SharedAccount, remoteFolder: String) throws { guard !items.isEmpty else { return } let batch = UUID().uuidString - // No delegate here: the extension is gone before the results arrive. - let session = URLSession(configuration: configuration()) + // Small files can finish while the extension is still on screen; for + // those this delegate counts them. Whatever outlives the extension is + // reported to the app instead (see `TransferManager`). + let session = URLSession(configuration: configuration(), delegate: ShareUploadDelegate(), delegateQueue: nil) var tasks: [URLSessionUploadTask] = [] for item in items { @@ -62,6 +64,7 @@ enum ShareUpload { TransferBatchStore.save( TransferBatch(kind: .upload, remoteFolder: remoteFolder, total: tasks.count, succeeded: 0, failed: 0), id: batch) + TransferNotifications.started(kind: .upload, batch: batch, count: tasks.count) tasks.forEach { $0.resume() } session.finishTasksAndInvalidate() } diff --git a/ios/Shared/TransferNotifications.swift b/ios/Shared/TransferNotifications.swift new file mode 100644 index 0000000..32f4b89 --- /dev/null +++ b/ios/Shared/TransferNotifications.swift @@ -0,0 +1,80 @@ +import Foundation +import UserNotifications + +/// The local notifications for an upload/download batch - one "Uploading 3 +/// files…" when it starts, replaced in place by "Uploaded 3 files" when it +/// ends (both use the batch's id as the request identifier, so the second +/// *replaces* the first instead of stacking). Posted from whichever process +/// sees the event - the app, or the Share Extension while it's still alive. +/// iOS can't update a notification with live byte progress from a background +/// session, so start/finish is as much as a notification can say. +/// Compiled into both targets. +enum TransferNotifications { + static func started(kind: TransferTaskInfo.Kind, batch: String, count: Int) { + let verb = kind == .upload ? "Uploading" : "Downloading" + post(id: batch, body: "\(verb) \(files(count))…", sound: false) + } + + static func finished(_ batch: TransferBatch, id: String) { + let verb = batch.kind == .upload ? "upload" : "download" + let past = batch.kind == .upload ? "Uploaded" : "Downloaded" + let body: String + if batch.failed == 0 { + body = "\(past) \(files(batch.succeeded))" + } else if batch.succeeded == 0 { + body = "Couldn't \(verb) \(files(batch.failed))" + } else { + body = "\(past) \(files(batch.succeeded)), \(batch.failed) failed" + } + post(id: id, body: body, sound: true) + } + + static func files(_ count: Int) -> String { + count == 1 ? "1 file" : "\(count) files" + } + + private static func post(id: String, body: String, sound: Bool) { + let content = UNMutableNotificationContent() + content.title = "Noo" + content.body = body + if sound { content.sound = .default } + UNUserNotificationCenter.current().add( + UNNotificationRequest(identifier: "transfer.\(id)", content: content, trigger: nil)) + } +} + +/// Counting one finished upload task - shared so the result is handled the +/// same way whether the Share Extension (still alive) or the app (relaunched +/// later) receives it. +enum UploadResults { + /// Deletes the task's staged copy, records its outcome, and - if it was the + /// last file of its batch - posts the summary notification and returns the + /// finished batch. + static func handle(_ task: URLSessionTask, error: Error?) -> (info: TransferTaskInfo, batch: TransferBatch?)? { + guard let info = task.taskDescription + .flatMap({ $0.data(using: .utf8) }) + .flatMap({ try? JSONDecoder().decode(TransferTaskInfo.self, from: $0) }), + info.kind == .upload + else { return nil } + if let staged = info.stagedPath { try? FileManager.default.removeItem(atPath: staged) } + let status = (task.response as? HTTPURLResponse)?.statusCode ?? 0 + let ok = error == nil && (200..<300).contains(status) + let batch = TransferBatchStore.record(info, success: ok) + if let batch { TransferNotifications.finished(batch, id: info.batch) } + return (info, batch) + } +} + +/// Delegate for the Share Extension's upload session while the extension is +/// still alive: files that finish before it closes are counted here, the +/// rest by the app (`TransferManager`) once the system relaunches it. +final class ShareUploadDelegate: NSObject, URLSessionTaskDelegate { + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + guard let result = UploadResults.handle(task, error: error) else { return } + // The app wasn't running to hear about this, so leave a note for it to + // refresh that folder next time it's active. + if let batch = result.batch, batch.succeeded > 0, let folder = batch.remoteFolder { + TransferBatchStore.noteFinishedUpload(folder: folder) + } + } +} diff --git a/ios/Shared/TransferTypes.swift b/ios/Shared/TransferTypes.swift index f9d76c7..c7b284f 100644 --- a/ios/Shared/TransferTypes.swift +++ b/ios/Shared/TransferTypes.swift @@ -47,6 +47,27 @@ enum TransferBatchStore { if let data = try? JSONEncoder().encode(batch) { defaults.set(data, forKey: key(id)) } } + private static let finishedFoldersKey = "transfer.finishedUploadFolders" + + /// The Share Extension finished an upload into [folder] while the app + /// wasn't running; the app reads this on its next activation so its file + /// list can refresh. + static func noteFinishedUpload(folder: String) { + lock.lock() + defer { lock.unlock() } + var folders = defaults.stringArray(forKey: finishedFoldersKey) ?? [] + if !folders.contains(folder) { folders.append(folder) } + defaults.set(folders, forKey: finishedFoldersKey) + } + + static func consumeFinishedUploadFolders() -> [String] { + lock.lock() + defer { lock.unlock() } + let folders = defaults.stringArray(forKey: finishedFoldersKey) ?? [] + defaults.removeObject(forKey: finishedFoldersKey) + return folders + } + /// Counts one file's outcome. Returns the batch when that was its last /// file (and forgets it), nil while others are still running. static func record(_ info: TransferTaskInfo, success: Bool) -> TransferBatch? {