iOS Share Extension: pick the destination inside the share sheet
SwiftUI folder picker in the extension (WebDAV PROPFIND), account shared via a Keychain group, upload on a background session that outlives the extension and is finished by the app. The inbox + notification hand-off remains as the fallback (no account / 'choose later'). Shared code moves to ios/Shared. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
525504bc47
commit
afc9b90963
+44
-29
@@ -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/<batch>/`, 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/<batch>/`) 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 <udid> 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.
|
||||
|
||||
@@ -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 = "<group>"; };
|
||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
|
||||
1F2C956C98F58A97B5595307 /* SharedAccount.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SharedAccount.swift; sourceTree = "<group>"; };
|
||||
20B0C3085FA6467C1A4A2947 /* TransferTypes.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TransferTypes.swift; sourceTree = "<group>"; };
|
||||
27BDF61F5197F9B8F648E8AE /* ShareViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = "<group>"; };
|
||||
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
51604DEE43E937A5FDCA7A58 /* DavFolders.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DavFolders.swift; sourceTree = "<group>"; };
|
||||
5BE7B8C31FF78E212BD71DFC /* SharePickerView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SharePickerView.swift; sourceTree = "<group>"; };
|
||||
65F57A1EB0C62774E561860D /* ShareExtension.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = ShareExtension.entitlements; sourceTree = "<group>"; };
|
||||
6F798220CB598B611CA03EFC /* WebDAV.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WebDAV.swift; sourceTree = "<group>"; };
|
||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
|
||||
@@ -87,6 +100,7 @@
|
||||
8064CC1652B8F0A44E371638 /* TransferManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TransferManager.swift; sourceTree = "<group>"; };
|
||||
836D37F2376AFCE0EFDD30A7 /* SharedInbox.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SharedInbox.swift; sourceTree = "<group>"; };
|
||||
857A0E7637776A71AFBB0614 /* Runner.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; };
|
||||
871EDB5791D5B3DA0ECF3D4E /* WebDAV.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WebDAV.swift; sourceTree = "<group>"; };
|
||||
901C0CDB4FB819302E2A7563 /* NativeServices.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NativeServices.swift; sourceTree = "<group>"; };
|
||||
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
|
||||
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
|
||||
@@ -95,6 +109,7 @@
|
||||
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
B93DB1F595F44E2F2057BB2A /* ShareUpload.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShareUpload.swift; sourceTree = "<group>"; };
|
||||
/* 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;
|
||||
};
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -57,6 +57,8 @@
|
||||
<true/>
|
||||
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||
<true/>
|
||||
<key>NooKeychainAccessGroup</key>
|
||||
<string>$(AppIdentifierPrefix)dev.ayushya.noo.shared</string>
|
||||
<key>NSFaceIDUsageDescription</key>
|
||||
<string>Noo uses Face ID to unlock the app and to protect hidden files.</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -6,5 +6,9 @@
|
||||
<array>
|
||||
<string>group.dev.ayushya.noo</string>
|
||||
</array>
|
||||
<key>keychain-access-groups</key>
|
||||
<array>
|
||||
<string>$(AppIdentifierPrefix)dev.ayushya.noo.shared</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -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
|
||||
"""
|
||||
<d:response><d:href>\(r.href)</d:href><d:propstat><d:prop>\
|
||||
<d:resourcetype>\(r.collection ? "<d:collection/>" : "")</d:resourcetype>\
|
||||
</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>
|
||||
"""
|
||||
}.joined()
|
||||
return Data(
|
||||
"<?xml version=\"1.0\"?><d:multistatus xmlns:d=\"DAV:\">\(body)</d:multistatus>".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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>NooKeychainAccessGroup</key>
|
||||
<string>$(AppIdentifierPrefix)dev.ayushya.noo.shared</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionAttributes</key>
|
||||
|
||||
@@ -6,5 +6,9 @@
|
||||
<array>
|
||||
<string>group.dev.ayushya.noo</string>
|
||||
</array>
|
||||
<key>keychain-access-groups</key>
|
||||
<array>
|
||||
<string>$(AppIdentifierPrefix)dev.ayushya.noo.shared</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -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<Content: View>(@ViewBuilder _ content: () -> Content) -> some View {
|
||||
VStack { Spacer(); content(); Spacer() }.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = """
|
||||
<?xml version="1.0"?>
|
||||
<d:propfind xmlns:d="DAV:"><d:prop><d:resourcetype/></d:prop></d:propfind>
|
||||
"""
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
+11
-1
@@ -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
|
||||
|
||||
@@ -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<void> 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<void> clear() => invokeIfAvailable(_channel, 'clearAccount');
|
||||
}
|
||||
Reference in New Issue
Block a user