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:
Ayushya Amitabh
2026-10-06 19:36:04 -04:00
co-authored by Claude Sonnet 5.5
parent 525504bc47
commit afc9b90963
19 changed files with 841 additions and 154 deletions
+44 -29
View File
@@ -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.
+34 -4
View File
@@ -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;
};
+1 -1
View File
@@ -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(
+2
View File
@@ -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>
+29
View File
@@ -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
+11 -60
View File
@@ -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) {
+4
View File
@@ -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>
+88
View File
@@ -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")
}
}
+2
View File
@@ -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>
+168
View File
@@ -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)
}
}
+85 -59
View File
@@ -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)
}
}
}
}
+120
View File
@@ -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)
}
}
+73
View File
@@ -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 }
}
+68
View File
@@ -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)
}
}
+67
View File
@@ -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
View File
@@ -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
+30
View File
@@ -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');
}