diff --git a/.claude/context/server.md b/.claude/context/server.md index 6cc1370..2199dc6 100644 --- a/.claude/context/server.md +++ b/.claude/context/server.md @@ -874,37 +874,52 @@ iOS. `OfflineController` and `localSyncedFilePath` both go through it. ### iOS Share Extension (`ios/ShareExtension/`, `ios/Shared/`) -"Share to Noo" from any app's share sheet. An extension can't pick a -destination, hold a session, or launch its host app, so it only *hands off*: +"Share to Noo" from any app's share sheet. A Share Extension can't open its +host app (`extensionContext.open` is Today-widget-only), so - like +Reminders/Notes - the destination is picked *inside* the sheet: -1. `ShareViewController` (extension) copies each shared file - (`NSItemProvider.loadFileRepresentation`, public.item) into the App Group - container (`group.dev.ayushya.noo`) under `SharedInbox//`, appends - them to `SharedInbox/pending.json`, and posts a local notification ("Open - Noo to choose where to upload N files") since it can't open the app. -2. The app reads that manifest through `SharedInbox.consume` - once on cold - start via `share_intent.getInitialShare`, and whenever it becomes active - (`UIApplication.didBecomeActiveNotification`) via the `share_intent/new` - event stream - and from there it's the same Dart flow as an Android - share: `main.dart`'s `_handleSharedFiles` -> `ShareUploadView` -> - `UploadService`. The `file://` URI points into the App Group container; - `TransferManager.startUpload` deletes that copy once it has staged it. -3. `ios/Shared/SharedInbox.swift` is compiled into both targets (manifest - read/append/consume under an `NSFileCoordinator`, entries whose file is - gone are dropped, each share is delivered once) and unit-tested in - `RunnerTests`. +1. `ShareViewController` copies each shared file (`NSItemProvider. + loadFileRepresentation`, public.item) into the App Group container + (`group.dev.ayushya.noo`, `SharedInbox//`) and hosts the SwiftUI + `SharePickerView` (`ShareModel` holds its state). +2. It reads the signed-in account from the shared Keychain + (`SharedAccountStore`) and lists folders over WebDAV (`DavClient` + `PROPFIND` Depth 1, parsed by `DavFolderParser`; dot-folders hidden). + The Dart side keeps that account current: `ShareAccountService.publish` + (`lib/services/share_account_service.dart`) runs on every account-ready + event and `clear` on account-cleared, through the native `share_account` + 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 + 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. +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 + `share_intent` (`getInitialShare` at cold start, `share_intent/new` on + `UIApplication.didBecomeActiveNotification`) into the same Dart + `ShareUploadView` flow as an Android share, and `TransferManager. + startUpload` deletes the inbox copy once staged. -Both targets carry `group.dev.ayushya.noo` in their `.entitlements` -(`Runner/Runner.entitlements`, `ShareExtension/ShareExtension.entitlements`); -the extension's bundle id is `dev.ayushya.noo.ShareExtension`, and its -version/build come from the same `FLUTTER_BUILD_NAME`/`FLUTTER_BUILD_NUMBER` -xcconfig as the app (iOS rejects a mismatch). The extension is embedded via an -"Embed Foundation Extensions" phase placed *before* Flutter's script phases -(after them Xcode reports a dependency cycle). The simulator honours the App -Group without a signing team; a real device needs a team that owns the group -id and both bundle ids registered. To try it without the share sheet, put a -file and a `pending.json` in the simulator's group container -(`xcrun simctl get_app_container dev.ayushya.noo groups`) and launch. +The keychain group is `$(AppIdentifierPrefix)dev.ayushya.noo.shared` +(`keychain-access-groups` in both `.entitlements`); both Info.plists also +carry it as `NooKeychainAccessGroup`, which `SharedAccountStore` reads, so the +two processes always agree on the team-prefixed id. The account is stored as +JSON in one generic-password item (`kSecAttrAccessibleAfterFirstUnlock`). + +`ios/Shared/` (compiled into both targets): `SharedInbox`, `SharedAccount`, +`ShareUpload`, `DavFolders`, `TransferTypes`, `WebDAV`. Everything in it that +isn't UI is unit-tested in `RunnerTests`. The extension's bundle id is +`dev.ayushya.noo.ShareExtension` and its version/build come from the same +`FLUTTER_BUILD_NAME`/`FLUTTER_BUILD_NUMBER` xcconfig as the app (iOS rejects a +mismatch); it's embedded by an "Embed Foundation Extensions" phase placed +*before* Flutter's script phases (after them Xcode reports a dependency +cycle). The simulator honours the App Group and keychain group without a +signing team; a real device needs a team that owns both ids. Not built on iOS yet: the picker (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 080248d..36c92b5 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -7,21 +7,31 @@ objects = { /* Begin PBXBuildFile section */ + 134157C4C8F53C9997FF265C /* TransferTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20B0C3085FA6467C1A4A2947 /* TransferTypes.swift */; }; 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 167672C5B1FBD70656C2FEED /* TransferTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20B0C3085FA6467C1A4A2947 /* TransferTypes.swift */; }; 223784E5DAE5A653F0F61771 /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 405A5408F4071B4D12F08B21 /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; 33B3C3BED4C1F26439DD6825 /* SharedInbox.swift in Sources */ = {isa = PBXBuildFile; fileRef = 836D37F2376AFCE0EFDD30A7 /* SharedInbox.swift */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 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 */; }; 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 */; }; - 66D3CBDE6B3CD3DC578CECBE /* WebDAV.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F798220CB598B611CA03EFC /* WebDAV.swift */; }; + 66AFC246E251554A80B2476F /* SharePickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BE7B8C31FF78E212BD71DFC /* SharePickerView.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 */; }; + 852C0C8EBA96CB6389DF6917 /* SharedAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F2C956C98F58A97B5595307 /* SharedAccount.swift */; }; 91C1658DC7467CACB2AAC7FF /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0AE6F681A3100A461B4A5318 /* Foundation.framework */; }; 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 */; }; + 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 */; }; F8D51B4045340275FBD12D16 /* SharedInbox.swift in Sources */ = {isa = PBXBuildFile; fileRef = 836D37F2376AFCE0EFDD30A7 /* SharedInbox.swift */; }; FDFA55C4921747A9CBCBBEEC /* TransferManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8064CC1652B8F0A44E371638 /* TransferManager.swift */; }; /* End PBXBuildFile section */ @@ -71,14 +81,17 @@ 0AE6F681A3100A461B4A5318 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; }; 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 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 = ""; }; 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 = ""; }; 405A5408F4071B4D12F08B21 /* ShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 49213989F0FA1B83B0B8A523 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 51604DEE43E937A5FDCA7A58 /* DavFolders.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DavFolders.swift; sourceTree = ""; }; + 5BE7B8C31FF78E212BD71DFC /* SharePickerView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SharePickerView.swift; sourceTree = ""; }; 65F57A1EB0C62774E561860D /* ShareExtension.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = ShareExtension.entitlements; 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 = ""; }; @@ -87,6 +100,7 @@ 8064CC1652B8F0A44E371638 /* TransferManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TransferManager.swift; sourceTree = ""; }; 836D37F2376AFCE0EFDD30A7 /* SharedInbox.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SharedInbox.swift; sourceTree = ""; }; 857A0E7637776A71AFBB0614 /* Runner.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = ""; }; + 871EDB5791D5B3DA0ECF3D4E /* WebDAV.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WebDAV.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 = ""; }; @@ -95,6 +109,7 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + B93DB1F595F44E2F2057BB2A /* ShareUpload.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShareUpload.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -139,6 +154,7 @@ 27BDF61F5197F9B8F648E8AE /* ShareViewController.swift */, 49213989F0FA1B83B0B8A523 /* Info.plist */, 65F57A1EB0C62774E561860D /* ShareExtension.entitlements */, + 5BE7B8C31FF78E212BD71DFC /* SharePickerView.swift */, ); name = ShareExtension; path = ShareExtension; @@ -147,7 +163,6 @@ 5055E53DC55C0BCABD5F04F4 /* Native */ = { isa = PBXGroup; children = ( - 6F798220CB598B611CA03EFC /* WebDAV.swift */, 8064CC1652B8F0A44E371638 /* TransferManager.swift */, 901C0CDB4FB819302E2A7563 /* NativeServices.swift */, ); @@ -159,6 +174,11 @@ isa = PBXGroup; children = ( 836D37F2376AFCE0EFDD30A7 /* SharedInbox.swift */, + 871EDB5791D5B3DA0ECF3D4E /* WebDAV.swift */, + 20B0C3085FA6467C1A4A2947 /* TransferTypes.swift */, + 1F2C956C98F58A97B5595307 /* SharedAccount.swift */, + B93DB1F595F44E2F2057BB2A /* ShareUpload.swift */, + 51604DEE43E937A5FDCA7A58 /* DavFolders.swift */, ); name = Shared; path = Shared; @@ -407,6 +427,12 @@ files = ( 63229CACE4281880037D33E6 /* ShareViewController.swift in Sources */, 33B3C3BED4C1F26439DD6825 /* SharedInbox.swift in Sources */, + F598B112F4D33953984FEFF4 /* WebDAV.swift in Sources */, + 167672C5B1FBD70656C2FEED /* TransferTypes.swift in Sources */, + 852C0C8EBA96CB6389DF6917 /* SharedAccount.swift in Sources */, + 3DEDE94F2A4C921407EF4382 /* ShareUpload.swift in Sources */, + 3C1A0DAA5F9B37FEA4C604BE /* DavFolders.swift in Sources */, + 66AFC246E251554A80B2476F /* SharePickerView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -417,10 +443,14 @@ 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 */, F8D51B4045340275FBD12D16 /* SharedInbox.swift in Sources */, + 43DB9AB631DD359AEC613A1D /* WebDAV.swift in Sources */, + 134157C4C8F53C9997FF265C /* TransferTypes.swift in Sources */, + E295E45196E3973A5F7E1D8E /* SharedAccount.swift in Sources */, + 62028E64976F15B4FDEDA9F1 /* ShareUpload.swift in Sources */, + A3B3AB501C29516E20455F23 /* DavFolders.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index c86e0c2..ae86176 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -30,7 +30,7 @@ import UserNotifications handleEventsForBackgroundURLSession identifier: String, completionHandler: @escaping () -> Void ) { - if identifier == TransferManager.sessionIdentifier { + if identifier == TransferManager.sessionIdentifier || identifier == ShareUpload.sessionIdentifier { TransferManager.shared.backgroundCompletionHandler = completionHandler } else { super.application( diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 7f64075..5d33b03 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -57,6 +57,8 @@ LSSupportsOpeningDocumentsInPlace + NooKeychainAccessGroup + $(AppIdentifierPrefix)dev.ayushya.noo.shared 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 index 6430998..e9ea450 100644 --- a/ios/Runner/Native/NativeServices.swift +++ b/ios/Runner/Native/NativeServices.swift @@ -75,6 +75,35 @@ enum NativeServices { } TransferManager.shared.reconnect() registerShare(messenger: messenger) + registerShareAccount(messenger: messenger) + } + + // MARK: - Account for the Share Extension + + /// `share_account`: Dart tells native which account is active so the Share + /// Extension - a separate process with no Flutter engine - can list folders + /// and upload as it. Stored in the shared Keychain group. + private static func registerShareAccount(messenger: FlutterBinaryMessenger) { + FlutterMethodChannel(name: "dev.ayushya.noo/share_account", binaryMessenger: messenger) + .setMethodCallHandler { call, result in + switch call.method { + case "setAccount": + handle(call, result) { args in + try SharedAccountStore.save( + SharedAccount( + serverUrl: try string(args, "serverUrl"), + username: try string(args, "username"), + authHeader: try string(args, "authHeader"), + displayName: (args["displayName"] as? String) ?? "" + )) + } + case "clearAccount": + SharedAccountStore.clear() + result(nil) + default: + result(FlutterMethodNotImplemented) + } + } } // MARK: - Share Extension inbox diff --git a/ios/Runner/Native/TransferManager.swift b/ios/Runner/Native/TransferManager.swift index 85c4a1d..f28fc9a 100644 --- a/ios/Runner/Native/TransferManager.swift +++ b/ios/Runner/Native/TransferManager.swift @@ -1,43 +1,11 @@ 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` @@ -61,9 +29,6 @@ final class TransferManager: NSObject { /// 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 @@ -72,10 +37,17 @@ final class TransferManager: NSObject { return URLSession(configuration: config, delegate: self, delegateQueue: nil) }() - /// Touch the session at launch so a system relaunch for finished + /// The session the Share Extension's uploads run on (see `ShareUpload`). + /// The extension is gone by the time they finish, so the app recreates it - + /// same identifier and shared container - to receive the results. + private lazy var shareSession = URLSession( + configuration: ShareUpload.configuration(), delegate: self, delegateQueue: nil) + + /// Touch both sessions at launch so a system relaunch for finished /// background transfers reconnects to them. func reconnect() { _ = session + _ = shareSession } // MARK: - Starting transfers @@ -130,7 +102,7 @@ final class TransferManager: NSObject { ) tasks.append(task) } - saveBatch( + TransferBatchStore.save( TransferBatch(kind: .upload, remoteFolder: remoteFolder, total: tasks.count, succeeded: 0, failed: 0), id: batch ) @@ -158,7 +130,7 @@ final class TransferManager: NSObject { ) tasks.append(task) } - saveBatch( + TransferBatchStore.save( TransferBatch(kind: .download, remoteFolder: nil, total: tasks.count, succeeded: 0, failed: 0), id: batch ) @@ -195,30 +167,9 @@ final class TransferManager: NSObject { .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) } + if let batch = TransferBatchStore.record(info, success: success) { finish(batch) } } private func finish(_ batch: TransferBatch) { diff --git a/ios/Runner/Runner.entitlements b/ios/Runner/Runner.entitlements index cd9db7f..3ebfa4a 100644 --- a/ios/Runner/Runner.entitlements +++ b/ios/Runner/Runner.entitlements @@ -6,5 +6,9 @@ group.dev.ayushya.noo + keychain-access-groups + + $(AppIdentifierPrefix)dev.ayushya.noo.shared + diff --git a/ios/RunnerTests/RunnerTests.swift b/ios/RunnerTests/RunnerTests.swift index fca6dfd..def112b 100644 --- a/ios/RunnerTests/RunnerTests.swift +++ b/ios/RunnerTests/RunnerTests.swift @@ -97,4 +97,92 @@ class RunnerTests: XCTestCase { try SharedInbox.append([], in: root) XCTAssertEqual(SharedInbox.consume(in: root), []) } + + // MARK: - DavFolderParser (Share Extension folder picker) + + private func multistatus(_ responses: [(href: String, collection: Bool)]) -> Data { + let body = responses.map { r in + """ + \(r.href)\ + \(r.collection ? "" : "")\ + HTTP/1.1 200 OK + """ + }.joined() + return Data( + "\(body)".utf8) + } + + func testFoldersListsOnlyChildFoldersSortedWithoutHidden() { + let xml = multistatus([ + ("/remote.php/dav/files/alice/Docs/", true), + ("/remote.php/dav/files/alice/Docs/zeta/", true), + ("/remote.php/dav/files/alice/Docs/Tax%20Forms/", true), + ("/remote.php/dav/files/alice/Docs/note.txt", false), + ("/remote.php/dav/files/alice/Docs/.git/", true), + ]) + XCTAssertEqual( + DavFolderParser.folders(from: xml, excluding: "/Docs"), + [DavFolder(name: "Tax Forms", path: "/Docs/Tax Forms"), DavFolder(name: "zeta", path: "/Docs/zeta")]) + XCTAssertEqual( + DavFolderParser.folders(from: xml, excluding: "/Docs", includeHidden: true).map(\.name), + [".git", "Tax Forms", "zeta"]) + } + + func testFoldersAtRootAndUnderServerSubPath() { + let xml = multistatus([ + ("/nextcloud/remote.php/dav/files/bob/", true), + ("/nextcloud/remote.php/dav/files/bob/Photos/", true), + ]) + XCTAssertEqual( + DavFolderParser.folders(from: xml, excluding: "/"), + [DavFolder(name: "Photos", path: "/Photos")]) + } + + func testRelativePath() { + XCTAssertEqual(DavFolderParser.relativePath(fromHref: "/remote.php/dav/files/a/B%20C/D/"), "/B C/D") + XCTAssertEqual(DavFolderParser.relativePath(fromHref: "/remote.php/dav/files/a/"), "/") + XCTAssertNil(DavFolderParser.relativePath(fromHref: "/somewhere/else")) + } + + func testFoldersIgnoresGarbage() { + XCTAssertEqual(DavFolderParser.folders(from: Data("not xml".utf8), excluding: "/"), []) + } + + // MARK: - Shared account (Keychain group shared with the extension) + + func testSharedAccountRoundTripsThroughTheKeychain() throws { + let account = SharedAccount( + serverUrl: "https://cloud.example.com", username: "alice", + authHeader: "Basic YWxpY2U6c2VjcmV0", displayName: "alice@cloud.example.com") + addTeardownBlock { SharedAccountStore.clear() } + + SharedAccountStore.clear() + XCTAssertNil(SharedAccountStore.load()) + try SharedAccountStore.save(account) + XCTAssertEqual(SharedAccountStore.load(), account) + + let replacement = SharedAccount( + serverUrl: "https://other.example.org", username: "bob", authHeader: "Basic Ym9i", displayName: "bob") + try SharedAccountStore.save(replacement) + XCTAssertEqual(SharedAccountStore.load(), replacement, "saving replaces, never duplicates") + + SharedAccountStore.clear() + XCTAssertNil(SharedAccountStore.load()) + } + + // MARK: - TransferBatchStore + + func testBatchSummaryFiresOnlyOnTheLastFile() { + let batchId = UUID().uuidString + TransferBatchStore.save( + TransferBatch(kind: .upload, remoteFolder: "/Docs", total: 3, succeeded: 0, failed: 0), id: batchId) + let info = TransferTaskInfo(kind: .upload, batch: batchId, name: "a", remoteFolder: "/Docs", stagedPath: nil) + + XCTAssertNil(TransferBatchStore.record(info, success: true)) + XCTAssertNil(TransferBatchStore.record(info, success: false)) + let finished = TransferBatchStore.record(info, success: true) + XCTAssertEqual(finished?.succeeded, 2) + XCTAssertEqual(finished?.failed, 1) + XCTAssertEqual(finished?.remoteFolder, "/Docs") + } } diff --git a/ios/ShareExtension/Info.plist b/ios/ShareExtension/Info.plist index 4c57b84..5206ecc 100644 --- a/ios/ShareExtension/Info.plist +++ b/ios/ShareExtension/Info.plist @@ -20,6 +20,8 @@ $(MARKETING_VERSION) CFBundleVersion $(CURRENT_PROJECT_VERSION) + NooKeychainAccessGroup + $(AppIdentifierPrefix)dev.ayushya.noo.shared NSExtension NSExtensionAttributes diff --git a/ios/ShareExtension/ShareExtension.entitlements b/ios/ShareExtension/ShareExtension.entitlements index cd9db7f..3ebfa4a 100644 --- a/ios/ShareExtension/ShareExtension.entitlements +++ b/ios/ShareExtension/ShareExtension.entitlements @@ -6,5 +6,9 @@ group.dev.ayushya.noo + keychain-access-groups + + $(AppIdentifierPrefix)dev.ayushya.noo.shared + diff --git a/ios/ShareExtension/SharePickerView.swift b/ios/ShareExtension/SharePickerView.swift new file mode 100644 index 0000000..f2165ec --- /dev/null +++ b/ios/ShareExtension/SharePickerView.swift @@ -0,0 +1,168 @@ +import SwiftUI + +/// State behind the share sheet's folder picker. +@MainActor +final class ShareModel: ObservableObject { + enum Stage: Equatable { + case preparing + case picking + case uploading + case noAccount + case failed(String) + } + + @Published var stage: Stage = .preparing + @Published var items: [SharedItem] = [] + @Published var path = "/" + @Published var folders: [DavFolder] = [] + @Published var isLoadingFolders = false + @Published var folderError: String? + + let account: SharedAccount? + + /// Set by the view controller: what each button actually does. + var onUpload: () -> Void = {} + var onSaveForLater: () -> Void = {} + var onCancel: () -> Void = {} + + init(account: SharedAccount?) { + self.account = account + } + + var currentFolderName: String { + path == "/" ? "All files" : (path as NSString).lastPathComponent + } + + var fileCountText: String { + items.count == 1 ? "1 file" : "\(items.count) files" + } + + func open(_ path: String) async { + guard let account else { return } + self.path = path + isLoadingFolders = true + folderError = nil + defer { isLoadingFolders = false } + do { + folders = try await DavClient.listFolders(account: account, path: path) + } catch { + folders = [] + folderError = error.localizedDescription + } + } + + func openParent() async { + let parent = (path as NSString).deletingLastPathComponent + await open(parent.isEmpty ? "/" : parent) + } +} + +struct SharePickerView: View { + @ObservedObject var model: ShareModel + + var body: some View { + VStack(spacing: 0) { + header + Divider() + content + Divider() + footer + } + .background(Color(.systemBackground)) + } + + private var header: some View { + HStack { + Button("Cancel", action: model.onCancel) + Spacer() + Text("Upload to Noo").font(.headline) + Spacer() + // Balances Cancel so the title stays centred. + Button("Cancel", action: {}).hidden() + } + .padding(.horizontal) + .padding(.vertical, 12) + } + + @ViewBuilder + private var content: some View { + switch model.stage { + case .preparing: + centered { ProgressView("Preparing \(model.items.isEmpty ? "" : model.fileCountText)…") } + case .uploading: + centered { + VStack(spacing: 8) { + ProgressView() + Text("Uploading \(model.fileCountText) in the background").font(.subheadline) + } + } + case .noAccount: + centered { + Text("Sign in to Noo first, then share again. You can also save these files and choose a folder from inside Noo.") + .multilineTextAlignment(.center) + .foregroundColor(.secondary) + .padding() + } + case .failed(let message): + centered { + Text(message).multilineTextAlignment(.center).foregroundColor(.secondary).padding() + } + case .picking: + folderList + } + } + + private var folderList: some View { + List { + Section(header: Text(model.account?.displayName ?? "")) { + if model.path != "/" { + Button { + Task { await model.openParent() } + } label: { + Label("Up to \((model.path as NSString).deletingLastPathComponent == "/" ? "All files" : ((model.path as NSString).deletingLastPathComponent as NSString).lastPathComponent)", systemImage: "arrow.turn.left.up") + } + } + if model.isLoadingFolders { + HStack { Spacer(); ProgressView(); Spacer() } + } else if let error = model.folderError { + Text(error).foregroundColor(.secondary) + } else if model.folders.isEmpty { + Text("No folders here").foregroundColor(.secondary) + } else { + ForEach(model.folders, id: \.path) { folder in + Button { + Task { await model.open(folder.path) } + } label: { + Label(folder.name, systemImage: "folder") + } + } + } + } + } + .listStyle(.insetGrouped) + } + + private var footer: some View { + VStack(spacing: 8) { + if model.stage == .picking { + Button(action: model.onUpload) { + Text("Upload \(model.fileCountText) to \(model.currentFolderName)") + .font(.headline) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + } + .buttonStyle(.borderedProminent) + .disabled(model.isLoadingFolders) + } + if model.stage == .picking || model.stage == .noAccount { + Button("Choose a folder later in Noo", action: model.onSaveForLater) + .font(.subheadline) + } + } + .padding() + } + + private func centered(@ViewBuilder _ content: () -> Content) -> some View { + VStack { Spacer(); content(); Spacer() }.frame(maxWidth: .infinity) + } +} diff --git a/ios/ShareExtension/ShareViewController.swift b/ios/ShareExtension/ShareViewController.swift index d482f04..0aba663 100644 --- a/ios/ShareExtension/ShareViewController.swift +++ b/ios/ShareExtension/ShareViewController.swift @@ -1,66 +1,79 @@ +import SwiftUI import UIKit import UniformTypeIdentifiers import UserNotifications -/// "Share to Noo": copies whatever was shared into the App Group container -/// (`SharedInbox`) and finishes. The extension can't pick a destination or -/// upload - it has no session and a tight memory/time budget - so the app -/// does that when it next opens, through its existing destination picker. A -/// notification tells the user to open it, because an extension can't launch -/// its host app. +/// "Share to Noo". Like Reminders' or Notes' share sheet, the destination is +/// chosen *inside* the sheet - an extension can't open its host app - so this +/// shows a folder picker, then starts a background upload and closes: +/// +/// 1. Copies whatever was shared into the App Group (`SharedInbox`). +/// 2. Reads the signed-in account the app left in the shared Keychain +/// (`SharedAccountStore`) and lists folders over WebDAV (`DavClient`). +/// 3. "Upload" queues the files on a background session that outlives this +/// process (`ShareUpload`); the app is relaunched to finish and notify. +/// +/// With no account, or if the user prefers, the files are instead left in the +/// inbox for the app to offer its own destination picker next time it opens +/// (with a notification saying so). final class ShareViewController: UIViewController { - private let label = UILabel() - private let spinner = UIActivityIndicatorView(style: .large) + private var model: ShareModel! + private var batchDirectory: URL? override func viewDidLoad() { super.viewDidLoad() - view.backgroundColor = .systemBackground - label.text = "Saving to Noo…" - label.font = .preferredFont(forTextStyle: .headline) - label.textAlignment = .center - label.numberOfLines = 0 - spinner.startAnimating() + model = ShareModel(account: SharedAccountStore.load()) + model.onUpload = { [weak self] in self?.upload() } + model.onSaveForLater = { [weak self] in self?.saveForLater() } + model.onCancel = { [weak self] in self?.cancel() } - let stack = UIStackView(arrangedSubviews: [spinner, label]) - stack.axis = .vertical - stack.spacing = 16 - stack.alignment = .center - stack.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(stack) + let host = UIHostingController(rootView: SharePickerView(model: model)) + addChild(host) + host.view.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(host.view) NSLayoutConstraint.activate([ - stack.centerXAnchor.constraint(equalTo: view.centerXAnchor), - stack.centerYAnchor.constraint(equalTo: view.centerYAnchor), - stack.leadingAnchor.constraint(greaterThanOrEqualTo: view.leadingAnchor, constant: 24), - stack.trailingAnchor.constraint(lessThanOrEqualTo: view.trailingAnchor, constant: -24), + host.view.topAnchor.constraint(equalTo: view.topAnchor), + host.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + host.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + host.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), ]) + host.didMove(toParent: self) - Task { await importSharedItems() } + Task { await prepare() } } - private func importSharedItems() async { + // MARK: - Importing + + @MainActor + private func prepare() async { guard let root = AppGroup.containerURL else { - return finish(message: "Noo isn't set up to receive files yet.", saved: 0) + model.stage = .failed("Noo isn't set up to receive files yet.") + return } - let providers = (extensionContext?.inputItems as? [NSExtensionItem])? - .flatMap { $0.attachments ?? [] } ?? [] + let providers = + (extensionContext?.inputItems as? [NSExtensionItem])?.flatMap { $0.attachments ?? [] } ?? [] - var saved: [SharedItem] = [] do { let batch = try SharedInbox.makeBatchDirectory(in: root) + batchDirectory = batch for provider in providers where provider.hasItemConformingToTypeIdentifier(UTType.item.identifier) { - if let item = try? await copy(provider, into: batch) { saved.append(item) } + if let item = try? await copy(provider, into: batch) { model.items.append(item) } } - try SharedInbox.append(saved, in: root) } catch { - return finish(message: "Couldn't save to Noo: \(error.localizedDescription)", saved: 0) + model.stage = .failed("Couldn't save to Noo: \(error.localizedDescription)") + return } - if saved.isEmpty { - finish(message: "Noo can only receive files and photos.", saved: 0) - } else { - notify(count: saved.count) - finish(message: saved.count == 1 ? "Saved 1 file to Noo" : "Saved \(saved.count) files to Noo", saved: saved.count) + guard !model.items.isEmpty else { + model.stage = .failed("Noo can only receive files and photos.") + return } + guard model.account != nil else { + model.stage = .noAccount + return + } + model.stage = .picking + await model.open("/") } /// `loadFileRepresentation`'s URL only lives for the duration of its @@ -72,7 +85,7 @@ final class ShareViewController: UIViewController { return continuation.resume(throwing: error ?? CocoaError(.fileReadUnknown)) } do { - let destination = Self.uniqueURL(in: directory, name: url.lastPathComponent) + let destination = LocalFiles.uniqueURL(in: directory, name: url.lastPathComponent) try FileManager.default.copyItem(at: url, to: destination) let size = (try? FileManager.default.attributesOfItem(atPath: destination.path))?[.size] as? Int64 ?? 0 let mime = UTType(filenameExtension: destination.pathExtension)?.preferredMIMEType @@ -86,19 +99,42 @@ final class ShareViewController: UIViewController { } } - private static func uniqueURL(in directory: URL, name: String) -> URL { - var candidate = directory.appendingPathComponent(name) - var index = 1 - while FileManager.default.fileExists(atPath: candidate.path) { - let ext = (name as NSString).pathExtension - let stem = (name as NSString).deletingPathExtension - candidate = directory.appendingPathComponent(ext.isEmpty ? "\(stem) (\(index))" : "\(stem) (\(index)).\(ext)") - index += 1 + // MARK: - Actions + + private func upload() { + guard let account = model.account else { return } + do { + try ShareUpload.enqueue(items: model.items, account: account, remoteFolder: model.path) + model.stage = .uploading + complete(after: 1.2) + } catch { + model.stage = .failed("Couldn't start the upload: \(error.localizedDescription)") } - return candidate } - private func notify(count: Int) { + private func saveForLater() { + guard let root = AppGroup.containerURL else { return cancel() } + do { + try SharedInbox.append(model.items, in: root) + notifyOpenNoo(count: model.items.count) + complete(after: 0) + } catch { + model.stage = .failed("Couldn't save to Noo: \(error.localizedDescription)") + } + } + + private func cancel() { + if let batchDirectory { try? FileManager.default.removeItem(at: batchDirectory) } + extensionContext?.cancelRequest(withError: CocoaError(.userCancelled)) + } + + private func complete(after delay: TimeInterval) { + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { + self.extensionContext?.completeRequest(returningItems: nil) + } + } + + private func notifyOpenNoo(count: Int) { let content = UNMutableNotificationContent() content.title = "Noo" content.body = @@ -109,14 +145,4 @@ final class ShareViewController: UIViewController { UNUserNotificationCenter.current().add( UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)) } - - private func finish(message: String, saved: Int) { - DispatchQueue.main.async { - self.spinner.stopAnimating() - self.label.text = message - DispatchQueue.main.asyncAfter(deadline: .now() + (saved > 0 ? 1.2 : 2.5)) { - self.extensionContext?.completeRequest(returningItems: nil) - } - } - } } diff --git a/ios/Shared/DavFolders.swift b/ios/Shared/DavFolders.swift new file mode 100644 index 0000000..3838af4 --- /dev/null +++ b/ios/Shared/DavFolders.swift @@ -0,0 +1,120 @@ +import Foundation + +/// A folder in the account's WebDAV tree. [path] is relative to the user's +/// root with a leading slash ("/Documents/Tax Forms"); "/" is the root. +struct DavFolder: Equatable { + let name: String + let path: String +} + +/// Parses a WebDAV `PROPFIND` multistatus into folders. Pure (no +/// networking), so it's unit-tested. Compiled into both targets. +enum DavFolderParser { + private struct Response { + var href = "" + var isCollection = false + } + + private final class Delegate: NSObject, XMLParserDelegate { + var responses: [Response] = [] + private var current: Response? + private var text = "" + + private func local(_ name: String) -> String { + name.split(separator: ":").last.map(String.init) ?? name + } + + func parser( + _ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, + qualifiedName qName: String?, attributes attributeDict: [String: String] = [:] + ) { + switch local(elementName) { + case "response": current = Response() + case "href": text = "" + case "collection": current?.isCollection = true + default: break + } + } + + func parser(_ parser: XMLParser, foundCharacters string: String) { + text += string + } + + func parser( + _ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?, + qualifiedName qName: String? + ) { + switch local(elementName) { + case "href": current?.href = text.trimmingCharacters(in: .whitespacesAndNewlines) + case "response": + if let current { responses.append(current) } + current = nil + default: break + } + } + } + + /// "/nextcloud/remote.php/dav/files/alice/Tax%20Forms/" -> "/Tax Forms". + /// The server may live under a sub-path, so the files root is located by + /// its marker instead of assumed to start at the beginning. + static func relativePath(fromHref href: String) -> String? { + let decoded = href.removingPercentEncoding ?? href + let marker = "/remote.php/dav/files/" + guard let range = decoded.range(of: marker) else { return nil } + var parts = decoded[range.upperBound...].split(separator: "/", omittingEmptySubsequences: true) + guard !parts.isEmpty else { return nil } + parts.removeFirst() // the username + return "/" + parts.joined(separator: "/") + } + + /// Child folders of [currentPath], sorted by name. The requested folder + /// itself (which a Depth-1 PROPFIND also returns) and - unless + /// [includeHidden] - dot-folders are left out. + static func folders( + from xml: Data, excluding currentPath: String, includeHidden: Bool = false + ) -> [DavFolder] { + let delegate = Delegate() + let parser = XMLParser(data: xml) + parser.delegate = delegate + parser.parse() + + let current = currentPath == "/" ? "/" : currentPath.replacingOccurrences(of: "/+$", with: "", options: .regularExpression) + return delegate.responses + .filter(\.isCollection) + .compactMap { response -> DavFolder? in + guard let path = relativePath(fromHref: response.href), path != current else { return nil } + let name = (path as NSString).lastPathComponent + guard !name.isEmpty, includeHidden || !name.hasPrefix(".") else { return nil } + return DavFolder(name: name, path: path) + } + .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + } +} + +/// Lists folders over WebDAV for the Share Extension's picker, in the +/// foreground (the sheet is on screen while it runs). +enum DavClient { + private static let body = """ + + + """ + + static func listFolders(account: SharedAccount, path: String) async throws -> [DavFolder] { + guard let url = WebDAV.fileURL(serverUrl: account.serverUrl, username: account.username, remotePath: path) + else { throw TransferError(message: "Invalid server address.") } + var request = URLRequest(url: url) + request.httpMethod = "PROPFIND" + request.setValue("1", forHTTPHeaderField: "Depth") + request.setValue("application/xml", forHTTPHeaderField: "Content-Type") + request.setValue(account.authHeader, forHTTPHeaderField: "Authorization") + request.httpBody = body.data(using: .utf8) + request.timeoutInterval = 20 + + let (data, response) = try await URLSession.shared.data(for: request) + let status = (response as? HTTPURLResponse)?.statusCode ?? 0 + guard status == 207 else { + throw TransferError(message: status == 401 ? "Signed out - open Noo to sign in again." : "Server returned \(status).") + } + return DavFolderParser.folders(from: data, excluding: path) + } +} diff --git a/ios/Shared/ShareUpload.swift b/ios/Shared/ShareUpload.swift new file mode 100644 index 0000000..1394963 --- /dev/null +++ b/ios/Shared/ShareUpload.swift @@ -0,0 +1,73 @@ +import Foundation + +/// Starts the Share Extension's uploads on a background `URLSession` that +/// outlives the extension. The extension process is torn down as soon as its +/// sheet closes; because the session has a `sharedContainerIdentifier` and +/// the same identifier is recreated by the app at launch +/// (`TransferManager.reconnect`), the *app* is what gets relaunched to +/// receive the results, post the summary notification and clean up. +/// Compiled into both targets. +enum ShareUpload { + static let sessionIdentifier = "dev.ayushya.noo.transfers.share" + + static func configuration() -> URLSessionConfiguration { + let config = URLSessionConfiguration.background(withIdentifier: sessionIdentifier) + config.sharedContainerIdentifier = AppGroup.identifier + config.sessionSendsLaunchEvents = true + config.isDiscretionary = false + config.waitsForConnectivity = true + return config + } + + /// A `PUT` for [item] into [remoteFolder] - shared by the extension's + /// [enqueue] and the app's own uploads so both build requests identically. + static func uploadRequest(for item: SharedItem, account: SharedAccount, remoteFolder: String) -> URLRequest? { + guard + let url = WebDAV.fileURL( + serverUrl: account.serverUrl, + username: account.username, + remotePath: WebDAV.join(remoteFolder, item.name) + ) + else { return nil } + var request = URLRequest(url: url) + request.httpMethod = "PUT" + request.setValue(account.authHeader, forHTTPHeaderField: "Authorization") + if let modified = (try? FileManager.default.attributesOfItem(atPath: item.path))?[.modificationDate] as? Date { + request.setValue(String(Int(modified.timeIntervalSince1970)), forHTTPHeaderField: "X-OC-Mtime") + } + return request + } + + /// Queues every item (already copied into the App Group by the extension) + /// and returns once the system has accepted them. + 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()) + + var tasks: [URLSessionUploadTask] = [] + for item in items { + guard let request = uploadRequest(for: item, account: account, remoteFolder: remoteFolder) else { + throw TransferError(message: "Invalid server address.") + } + let task = session.uploadTask(with: request, fromFile: URL(fileURLWithPath: item.path)) + task.taskDescription = + (try? JSONEncoder().encode( + TransferTaskInfo( + kind: .upload, batch: batch, name: item.name, remoteFolder: remoteFolder, stagedPath: item.path) + )).flatMap { String(data: $0, encoding: .utf8) } + tasks.append(task) + } + TransferBatchStore.save( + TransferBatch(kind: .upload, remoteFolder: remoteFolder, total: tasks.count, succeeded: 0, failed: 0), + id: batch) + tasks.forEach { $0.resume() } + session.finishTasksAndInvalidate() + } +} + +struct TransferError: LocalizedError { + let message: String + var errorDescription: String? { message } +} diff --git a/ios/Shared/SharedAccount.swift b/ios/Shared/SharedAccount.swift new file mode 100644 index 0000000..939268e --- /dev/null +++ b/ios/Shared/SharedAccount.swift @@ -0,0 +1,68 @@ +import Foundation +import Security + +/// The signed-in account, as much of it as the Share Extension needs to list +/// folders and upload: the app writes it when an account becomes active and +/// clears it on sign-out. Compiled into both targets. +struct SharedAccount: Codable, Equatable { + let serverUrl: String + let username: String + /// `Basic ...` - the app password never leaves the Keychain as plain text. + let authHeader: String + /// Shown in the picker, e.g. "alice@cloud.example.com". + let displayName: String +} + +/// Keeps the [SharedAccount] in a Keychain access group both the app and the +/// extension are entitled to (`keychain-access-groups`). The group's full id +/// carries the signing team's prefix, so it's injected into each target's +/// Info.plist as `NooKeychainAccessGroup` (= `$(AppIdentifierPrefix)` + +/// `dev.ayushya.noo.shared`) instead of being hard-coded - both processes +/// then always agree on it, with or without a team. +enum SharedAccountStore { + private static let service = "dev.ayushya.noo.shared-account" + private static let account = "active" + + static var accessGroup: String? { + Bundle.main.object(forInfoDictionaryKey: "NooKeychainAccessGroup") as? String + } + + private static func baseQuery() -> [String: Any] { + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + ] + if let group = accessGroup, !group.isEmpty { + query[kSecAttrAccessGroup as String] = group + } + return query + } + + static func save(_ value: SharedAccount) throws { + let data = try JSONEncoder().encode(value) + clear() + var query = baseQuery() + query[kSecValueData as String] = data + query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock + let status = SecItemAdd(query as CFDictionary, nil) + guard status == errSecSuccess else { + throw NSError(domain: NSOSStatusErrorDomain, code: Int(status)) + } + } + + static func load() -> SharedAccount? { + var query = baseQuery() + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + var result: AnyObject? + guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, + let data = result as? Data + else { return nil } + return try? JSONDecoder().decode(SharedAccount.self, from: data) + } + + static func clear() { + SecItemDelete(baseQuery() as CFDictionary) + } +} diff --git a/ios/Shared/TransferTypes.swift b/ios/Shared/TransferTypes.swift new file mode 100644 index 0000000..f9d76c7 --- /dev/null +++ b/ios/Shared/TransferTypes.swift @@ -0,0 +1,67 @@ +import Foundation + +/// 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 - possibly for a task the Share Extension +/// started. Compiled into both targets. +struct TransferTaskInfo: Codable { + enum Kind: String, Codable { case upload, download } + + let kind: Kind + let batch: String + let name: String + let remoteFolder: String? + /// A file to delete once the task ends (the upload's staged copy). + let stagedPath: String? +} + +/// Running totals for one "upload these 3 files" / "download these 2 files" +/// request, so the one summary notification (and the Dart-side "folder +/// changed" event) fires exactly once, on the last file. +struct TransferBatch: Codable { + let kind: TransferTaskInfo.Kind + let remoteFolder: String? + let total: Int + var succeeded: Int + var failed: Int + + var finished: Int { succeeded + failed } +} + +/// Persists [TransferBatch]es in the App Group's `UserDefaults`, not the +/// process's own: the Share Extension starts an upload batch, and the app - +/// a different process, possibly relaunched in the background - finishes +/// counting it. +enum TransferBatchStore { + private static let lock = NSLock() + + private static var defaults: UserDefaults { + UserDefaults(suiteName: AppGroup.identifier) ?? .standard + } + + private static func key(_ id: String) -> String { "transfer.batch.\(id)" } + + static func save(_ 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. 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? { + lock.lock() + defer { lock.unlock() } + 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 } + if batch.finished >= batch.total { + defaults.removeObject(forKey: storageKey) + return batch + } + if let data = try? JSONEncoder().encode(batch) { defaults.set(data, forKey: storageKey) } + return nil + } +} diff --git a/ios/Runner/Native/WebDAV.swift b/ios/Shared/WebDAV.swift similarity index 100% rename from ios/Runner/Native/WebDAV.swift rename to ios/Shared/WebDAV.swift diff --git a/lib/main.dart b/lib/main.dart index 1a4006f..0e253d0 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -22,6 +22,7 @@ import 'providers/shares_controller.dart'; import 'providers/sync_status_controller.dart'; import 'providers/trash_controller.dart'; import 'services/pick_intent_service.dart'; +import 'services/share_account_service.dart'; import 'services/share_intent_service.dart'; import 'theme/app_theme.dart'; import 'theme/design_tokens.dart'; @@ -58,7 +59,16 @@ void main() { providers: [ ChangeNotifierProvider(create: (_) => ConnectivityController()), ChangeNotifierProvider( - create: (context) => SessionController(context.read()), + create: (context) { + final session = SessionController(context.read()); + // iOS's Share Extension has no Flutter engine; it picks its + // folders/uploads as whichever account was last published here. + session.addAccountReadyListener( + () => ShareAccountService.publish(session), + ); + session.addAccountClearedListener(ShareAccountService.clear); + return session; + }, ), ChangeNotifierProvider(create: (_) => SettingsController()), // Before SyncStatusController, which follows its Files Cache rule diff --git a/lib/services/share_account_service.dart b/lib/services/share_account_service.dart new file mode 100644 index 0000000..df2efdc --- /dev/null +++ b/lib/services/share_account_service.dart @@ -0,0 +1,30 @@ +import 'package:flutter/services.dart'; +import '../providers/session_controller.dart'; +import 'native_channel.dart'; + +/// Tells the native side which account is active, so iOS's Share Extension - +/// a separate process with no Flutter engine - can list folders and upload as +/// it without opening the app (`ios/Runner/Native/NativeServices.swift`'s +/// `share_account`, stored in a Keychain group shared with the extension). +/// Android has no equivalent (its share intent opens the app itself), so +/// there the channel simply doesn't exist and both calls are no-ops. +class ShareAccountService { + static const _channel = MethodChannel('dev.ayushya.noo/share_account'); + + /// Publishes the active account (call whenever one becomes active). + static Future publish(SessionController session) async { + final authHeader = session.service?.authHeaders['Authorization']; + if (authHeader == null || session.serverUrl.isEmpty) return; + final host = Uri.tryParse(session.serverUrl)?.host ?? session.serverUrl; + await invokeIfAvailable(_channel, 'setAccount', { + 'serverUrl': session.serverUrl, + 'username': session.username, + 'authHeader': authHeader, + 'displayName': '${session.username}@$host', + }); + } + + /// Forgets it (sign-out / switching away) so the extension stops offering + /// uploads to an account that's no longer signed in. + static Future clear() => invokeIfAvailable(_channel, 'clearAccount'); +}