Share Extension: account picker first, hidden folders follow the app's filter + unlock
The app now publishes every uploadable account, the active one, the three lock settings and each account's own hidden-files filter (ShareAccountSync). The sheet lists accounts first when there are several, asks for unlock to use a non-active account (login lock + account-switching lock), and shows hidden folders per the account's app setting behind the hidden-files unlock. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
0cda4a1241
commit
a37524e959
@@ -885,13 +885,34 @@ Reminders/Notes - the destination is picked *inside* the sheet:
|
|||||||
loadFileRepresentation`, public.item) into the App Group container
|
loadFileRepresentation`, public.item) into the App Group container
|
||||||
(`group.dev.ayushya.noo`, `SharedInbox/<batch>/`) and hosts the SwiftUI
|
(`group.dev.ayushya.noo`, `SharedInbox/<batch>/`) and hosts the SwiftUI
|
||||||
`SharePickerView` (`ShareModel` holds its state).
|
`SharePickerView` (`ShareModel` holds its state).
|
||||||
2. It reads the signed-in account from the shared Keychain
|
2. It reads the accounts from the shared Keychain (`SharedAccountStore` ->
|
||||||
(`SharedAccountStore`) and lists folders over WebDAV (`DavClient`
|
`SharedAccounts`) and lists folders over WebDAV (`DavClient` `PROPFIND`
|
||||||
`PROPFIND` Depth 1, parsed by `DavFolderParser`; dot-folders hidden).
|
Depth 1, parsed by `DavFolderParser`). With **several accounts an account
|
||||||
The Dart side keeps that account current: `ShareAccountService.publish`
|
list comes first** (the app's active one is marked "Active"; "Change
|
||||||
(`lib/services/share_account_service.dart`) runs on every account-ready
|
account" returns to it); with one it goes straight to its folders. The
|
||||||
event and `clear` on account-cleared, through the native `share_account`
|
Dart side keeps the keychain data current: `ShareAccountSync`
|
||||||
channel - both wired where `SessionController` is created in `main.dart`.
|
(`lib/services/share_account_service.dart`, started from the shell's
|
||||||
|
`initState`) republishes - through the native `share_account` channel's
|
||||||
|
`setAccounts` - when an account becomes ready or when its change
|
||||||
|
signature moves: the account list/active account, the three lock
|
||||||
|
settings, or the active account's hidden-files filter; sign-out calls
|
||||||
|
`clearAccount`. It publishes every saved account that has a stored
|
||||||
|
password and isn't signed out, each with its **own Files hidden-files
|
||||||
|
filter** (`FilesController.savedHiddenFilter`).
|
||||||
|
- **Hidden folders** follow that app setting - there is no share-sheet
|
||||||
|
setting of their own. `hide` (default) drops dot-folders, `only` lists
|
||||||
|
just them, `include` lists everything; a folder is hidden when it *or
|
||||||
|
any ancestor* starts with a dot (`HiddenFilter`, same rule as the app).
|
||||||
|
When the filter isn't `hide` and the app has login lock + "lock hidden
|
||||||
|
files" on, the sheet asks for Face ID/passcode first (`DeviceAuth`, the
|
||||||
|
`.deviceOwnerAuthentication` policy - biometrics with passcode fallback,
|
||||||
|
like `local_auth` with `biometricOnly: false`); cancelling falls back to
|
||||||
|
hiding them, with a note.
|
||||||
|
- **Account switching**: choosing any account other than the app's
|
||||||
|
active one asks for the same unlock when login lock + "lock account
|
||||||
|
switching" are on. One successful unlock covers the rest of that sheet.
|
||||||
|
- Opening the sheet itself is not gated - only these two actions are
|
||||||
|
(as in the app, where login lock guards launch and these toggles).
|
||||||
3. **Upload** calls `ShareUpload.enqueue`: one `PUT` per file on a *background*
|
3. **Upload** calls `ShareUpload.enqueue`: one `PUT` per file on a *background*
|
||||||
`URLSession` (`dev.ayushya.noo.transfers.share`, with
|
`URLSession` (`dev.ayushya.noo.transfers.share`, with
|
||||||
`sharedContainerIdentifier`) that outlives the extension, and posts
|
`sharedContainerIdentifier`) that outlives the extension, and posts
|
||||||
@@ -919,7 +940,7 @@ Reminders/Notes - the destination is picked *inside* the sheet:
|
|||||||
The keychain group is `$(AppIdentifierPrefix)dev.ayushya.noo.shared`
|
The keychain group is `$(AppIdentifierPrefix)dev.ayushya.noo.shared`
|
||||||
(`keychain-access-groups` in both `.entitlements`); both Info.plists also
|
(`keychain-access-groups` in both `.entitlements`); both Info.plists also
|
||||||
carry it as `NooKeychainAccessGroup`, which `SharedAccountStore` reads, so the
|
carry it as `NooKeychainAccessGroup`, which `SharedAccountStore` reads, so the
|
||||||
two processes always agree on the team-prefixed id. The account is stored as
|
two processes always agree on the team-prefixed id. Everything is stored as
|
||||||
JSON in one generic-password item (`kSecAttrAccessibleAfterFirstUnlock`).
|
JSON in one generic-password item (`kSecAttrAccessibleAfterFirstUnlock`).
|
||||||
|
|
||||||
`ios/Shared/` (compiled into both targets): `SharedInbox`, `SharedAccount`,
|
`ios/Shared/` (compiled into both targets): `SharedInbox`, `SharedAccount`,
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; };
|
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; };
|
||||||
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
|
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
|
||||||
852C0C8EBA96CB6389DF6917 /* SharedAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F2C956C98F58A97B5595307 /* SharedAccount.swift */; };
|
852C0C8EBA96CB6389DF6917 /* SharedAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F2C956C98F58A97B5595307 /* SharedAccount.swift */; };
|
||||||
|
8C73B4CB445AEF383B7640E1 /* DeviceAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97ACCF43E1EC13AA2538E42C /* DeviceAuth.swift */; };
|
||||||
91C1658DC7467CACB2AAC7FF /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0AE6F681A3100A461B4A5318 /* Foundation.framework */; };
|
91C1658DC7467CACB2AAC7FF /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0AE6F681A3100A461B4A5318 /* Foundation.framework */; };
|
||||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
||||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
||||||
@@ -107,6 +108,7 @@
|
|||||||
901C0CDB4FB819302E2A7563 /* NativeServices.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NativeServices.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>"; };
|
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>"; };
|
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
|
||||||
|
97ACCF43E1EC13AA2538E42C /* DeviceAuth.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DeviceAuth.swift; sourceTree = "<group>"; };
|
||||||
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
|
97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
|
||||||
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||||
@@ -158,6 +160,7 @@
|
|||||||
49213989F0FA1B83B0B8A523 /* Info.plist */,
|
49213989F0FA1B83B0B8A523 /* Info.plist */,
|
||||||
65F57A1EB0C62774E561860D /* ShareExtension.entitlements */,
|
65F57A1EB0C62774E561860D /* ShareExtension.entitlements */,
|
||||||
5BE7B8C31FF78E212BD71DFC /* SharePickerView.swift */,
|
5BE7B8C31FF78E212BD71DFC /* SharePickerView.swift */,
|
||||||
|
97ACCF43E1EC13AA2538E42C /* DeviceAuth.swift */,
|
||||||
);
|
);
|
||||||
name = ShareExtension;
|
name = ShareExtension;
|
||||||
path = ShareExtension;
|
path = ShareExtension;
|
||||||
@@ -438,6 +441,7 @@
|
|||||||
3C1A0DAA5F9B37FEA4C604BE /* DavFolders.swift in Sources */,
|
3C1A0DAA5F9B37FEA4C604BE /* DavFolders.swift in Sources */,
|
||||||
66AFC246E251554A80B2476F /* SharePickerView.swift in Sources */,
|
66AFC246E251554A80B2476F /* SharePickerView.swift in Sources */,
|
||||||
A16680889B3A9D8672FA29C1 /* TransferNotifications.swift in Sources */,
|
A16680889B3A9D8672FA29C1 /* TransferNotifications.swift in Sources */,
|
||||||
|
8C73B4CB445AEF383B7640E1 /* DeviceAuth.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -80,22 +80,21 @@ enum NativeServices {
|
|||||||
|
|
||||||
// MARK: - Account for the Share Extension
|
// MARK: - Account for the Share Extension
|
||||||
|
|
||||||
/// `share_account`: Dart tells native which account is active so the Share
|
/// `share_account`: Dart hands native every account that can upload, which
|
||||||
/// Extension - a separate process with no Flutter engine - can list folders
|
/// one is active, and the app-lock settings, so the Share Extension - a
|
||||||
/// and upload as it. Stored in the shared Keychain group.
|
/// separate process with no Flutter engine - can list folders and upload as
|
||||||
|
/// them. Stored in the shared Keychain group.
|
||||||
private static func registerShareAccount(messenger: FlutterBinaryMessenger) {
|
private static func registerShareAccount(messenger: FlutterBinaryMessenger) {
|
||||||
FlutterMethodChannel(name: "dev.ayushya.noo/share_account", binaryMessenger: messenger)
|
FlutterMethodChannel(name: "dev.ayushya.noo/share_account", binaryMessenger: messenger)
|
||||||
.setMethodCallHandler { call, result in
|
.setMethodCallHandler { call, result in
|
||||||
switch call.method {
|
switch call.method {
|
||||||
case "setAccount":
|
case "setAccounts":
|
||||||
handle(call, result) { args in
|
handle(call, result) { args in
|
||||||
try SharedAccountStore.save(
|
guard let json = args["accounts"] as? String,
|
||||||
SharedAccount(
|
let data = json.data(using: .utf8),
|
||||||
serverUrl: try string(args, "serverUrl"),
|
let accounts = try? JSONDecoder().decode(SharedAccounts.self, from: data)
|
||||||
username: try string(args, "username"),
|
else { throw TransferError(message: "Bad accounts payload.") }
|
||||||
authHeader: try string(args, "authHeader"),
|
try SharedAccountStore.save(accounts)
|
||||||
displayName: (args["displayName"] as? String) ?? ""
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
case "clearAccount":
|
case "clearAccount":
|
||||||
SharedAccountStore.clear()
|
SharedAccountStore.clear()
|
||||||
|
|||||||
@@ -124,8 +124,24 @@ class RunnerTests: XCTestCase {
|
|||||||
DavFolderParser.folders(from: xml, excluding: "/Docs"),
|
DavFolderParser.folders(from: xml, excluding: "/Docs"),
|
||||||
[DavFolder(name: "Tax Forms", path: "/Docs/Tax Forms"), DavFolder(name: "zeta", path: "/Docs/zeta")])
|
[DavFolder(name: "Tax Forms", path: "/Docs/Tax Forms"), DavFolder(name: "zeta", path: "/Docs/zeta")])
|
||||||
XCTAssertEqual(
|
XCTAssertEqual(
|
||||||
DavFolderParser.folders(from: xml, excluding: "/Docs", includeHidden: true).map(\.name),
|
DavFolderParser.folders(from: xml, excluding: "/Docs", hidden: .include).map(\.name),
|
||||||
[".git", "Tax Forms", "zeta"])
|
[".git", "Tax Forms", "zeta"])
|
||||||
|
XCTAssertEqual(
|
||||||
|
DavFolderParser.folders(from: xml, excluding: "/Docs", hidden: .only).map(\.name),
|
||||||
|
[".git"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testHiddenFilterTreatsAnyDotSegmentAsHidden() {
|
||||||
|
XCTAssertTrue(HiddenFilter.isHidden(path: "/.cache"))
|
||||||
|
XCTAssertTrue(HiddenFilter.isHidden(path: "/.cache/inside/deeper"), "children of a hidden folder are hidden")
|
||||||
|
XCTAssertFalse(HiddenFilter.isHidden(path: "/Docs/Tax Forms"))
|
||||||
|
XCTAssertTrue(HiddenFilter.hide.shows(path: "/Docs"))
|
||||||
|
XCTAssertFalse(HiddenFilter.hide.shows(path: "/.git"))
|
||||||
|
XCTAssertTrue(HiddenFilter.only.shows(path: "/.git/hooks"))
|
||||||
|
XCTAssertFalse(HiddenFilter.only.shows(path: "/Docs"))
|
||||||
|
XCTAssertTrue(HiddenFilter.include.shows(path: "/Docs"))
|
||||||
|
XCTAssertEqual(HiddenFilter(raw: "include"), .include)
|
||||||
|
XCTAssertEqual(HiddenFilter(raw: "nonsense"), .hide, "unknown values fall back to hiding")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testFoldersAtRootAndUnderServerSubPath() {
|
func testFoldersAtRootAndUnderServerSubPath() {
|
||||||
@@ -150,19 +166,26 @@ class RunnerTests: XCTestCase {
|
|||||||
|
|
||||||
// MARK: - Shared account (Keychain group shared with the extension)
|
// MARK: - Shared account (Keychain group shared with the extension)
|
||||||
|
|
||||||
func testSharedAccountRoundTripsThroughTheKeychain() throws {
|
private func account(_ id: String, hidden: String = "hide") -> SharedAccount {
|
||||||
let account = SharedAccount(
|
SharedAccount(
|
||||||
serverUrl: "https://cloud.example.com", username: "alice",
|
id: id, serverUrl: "https://\(id).example.com", username: id,
|
||||||
authHeader: "Basic YWxpY2U6c2VjcmV0", displayName: "alice@cloud.example.com")
|
authHeader: "Basic \(id)", displayName: "\(id)@\(id).example.com", hiddenFilter: hidden)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSharedAccountsRoundTripThroughTheKeychain() throws {
|
||||||
|
let accounts = SharedAccounts(
|
||||||
|
accounts: [account("alice", hidden: "include"), account("bob")], activeId: "bob",
|
||||||
|
loginLockEnabled: true, lockAccountSwitching: true, lockHiddenFiles: false)
|
||||||
addTeardownBlock { SharedAccountStore.clear() }
|
addTeardownBlock { SharedAccountStore.clear() }
|
||||||
|
|
||||||
SharedAccountStore.clear()
|
SharedAccountStore.clear()
|
||||||
XCTAssertNil(SharedAccountStore.load())
|
XCTAssertNil(SharedAccountStore.load())
|
||||||
try SharedAccountStore.save(account)
|
try SharedAccountStore.save(accounts)
|
||||||
XCTAssertEqual(SharedAccountStore.load(), account)
|
XCTAssertEqual(SharedAccountStore.load(), accounts)
|
||||||
|
|
||||||
let replacement = SharedAccount(
|
let replacement = SharedAccounts(
|
||||||
serverUrl: "https://other.example.org", username: "bob", authHeader: "Basic Ym9i", displayName: "bob")
|
accounts: [account("carol")], activeId: "carol",
|
||||||
|
loginLockEnabled: false, lockAccountSwitching: false, lockHiddenFiles: false)
|
||||||
try SharedAccountStore.save(replacement)
|
try SharedAccountStore.save(replacement)
|
||||||
XCTAssertEqual(SharedAccountStore.load(), replacement, "saving replaces, never duplicates")
|
XCTAssertEqual(SharedAccountStore.load(), replacement, "saving replaces, never duplicates")
|
||||||
|
|
||||||
@@ -170,6 +193,31 @@ class RunnerTests: XCTestCase {
|
|||||||
XCTAssertNil(SharedAccountStore.load())
|
XCTAssertNil(SharedAccountStore.load())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testSharedAccountsActiveFallsBackToTheFirstAccount() {
|
||||||
|
var shared = SharedAccounts(
|
||||||
|
accounts: [account("alice"), account("bob")], activeId: "bob",
|
||||||
|
loginLockEnabled: false, lockAccountSwitching: false, lockHiddenFiles: false)
|
||||||
|
XCTAssertEqual(shared.active?.id, "bob")
|
||||||
|
shared.activeId = "gone"
|
||||||
|
XCTAssertEqual(shared.active?.id, "alice")
|
||||||
|
shared.activeId = nil
|
||||||
|
XCTAssertEqual(shared.active?.id, "alice")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testUnlockRulesNeedTheMasterLockAndTheirOwnToggle() {
|
||||||
|
func rules(master: Bool, switching: Bool, hidden: Bool) -> (Bool, Bool) {
|
||||||
|
let shared = SharedAccounts(
|
||||||
|
accounts: [account("a")], activeId: "a",
|
||||||
|
loginLockEnabled: master, lockAccountSwitching: switching, lockHiddenFiles: hidden)
|
||||||
|
return (shared.needsUnlockToSwitchAccount, shared.needsUnlockForHidden)
|
||||||
|
}
|
||||||
|
XCTAssertTrue(rules(master: true, switching: true, hidden: false) == (true, false))
|
||||||
|
XCTAssertTrue(rules(master: true, switching: false, hidden: true) == (false, true))
|
||||||
|
XCTAssertTrue(
|
||||||
|
rules(master: false, switching: true, hidden: true) == (false, false),
|
||||||
|
"the sub-toggles mean nothing while the login lock is off, as in the app")
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - TransferBatchStore
|
// MARK: - TransferBatchStore
|
||||||
|
|
||||||
func testBatchSummaryFiresOnlyOnTheLastFile() {
|
func testBatchSummaryFiresOnlyOnTheLastFile() {
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import LocalAuthentication
|
||||||
|
|
||||||
|
/// Face ID / Touch ID with the device passcode as the fallback - the same
|
||||||
|
/// policy the app's lock uses (`local_auth` with `biometricOnly: false`), so
|
||||||
|
/// the share sheet asks for exactly what the app would.
|
||||||
|
enum DeviceAuth {
|
||||||
|
/// True only if the user actually authenticated; a cancel, a failure, or a
|
||||||
|
/// device with nothing to authenticate with all count as "not unlocked".
|
||||||
|
static func authenticate(reason: String) async -> Bool {
|
||||||
|
let context = LAContext()
|
||||||
|
var error: NSError?
|
||||||
|
guard context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) else { return false }
|
||||||
|
return await withCheckedContinuation { continuation in
|
||||||
|
context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: reason) { success, _ in
|
||||||
|
continuation.resume(returning: success)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,8 @@
|
|||||||
<string>$(MARKETING_VERSION)</string>
|
<string>$(MARKETING_VERSION)</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||||
|
<key>NSFaceIDUsageDescription</key>
|
||||||
|
<string>Noo uses Face ID to unlock hidden folders and accounts when you share a file.</string>
|
||||||
<key>NooKeychainAccessGroup</key>
|
<key>NooKeychainAccessGroup</key>
|
||||||
<string>$(AppIdentifierPrefix)dev.ayushya.noo.shared</string>
|
<string>$(AppIdentifierPrefix)dev.ayushya.noo.shared</string>
|
||||||
<key>NSExtension</key>
|
<key>NSExtension</key>
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
/// State behind the share sheet's folder picker.
|
/// State behind the share sheet's account and folder pickers.
|
||||||
@MainActor
|
@MainActor
|
||||||
final class ShareModel: ObservableObject {
|
final class ShareModel: ObservableObject {
|
||||||
enum Stage: Equatable {
|
enum Stage: Equatable {
|
||||||
case preparing
|
case preparing
|
||||||
|
case chooseAccount
|
||||||
case picking
|
case picking
|
||||||
case uploading
|
case uploading
|
||||||
case noAccount
|
case noAccount
|
||||||
@@ -17,18 +18,30 @@ final class ShareModel: ObservableObject {
|
|||||||
@Published var folders: [DavFolder] = []
|
@Published var folders: [DavFolder] = []
|
||||||
@Published var isLoadingFolders = false
|
@Published var isLoadingFolders = false
|
||||||
@Published var folderError: String?
|
@Published var folderError: String?
|
||||||
|
/// A short explanation shown under the list - e.g. hidden folders stayed
|
||||||
|
/// hidden because the unlock was cancelled.
|
||||||
|
@Published var notice: String?
|
||||||
|
@Published private(set) var selected: SharedAccount?
|
||||||
|
|
||||||
let account: SharedAccount?
|
let shared: SharedAccounts?
|
||||||
|
|
||||||
|
/// One successful unlock covers every gate for the rest of this sheet.
|
||||||
|
private var unlocked = false
|
||||||
|
private var hidden: HiddenFilter = .hide
|
||||||
|
|
||||||
/// Set by the view controller: what each button actually does.
|
/// Set by the view controller: what each button actually does.
|
||||||
var onUpload: () -> Void = {}
|
var onUpload: () -> Void = {}
|
||||||
var onSaveForLater: () -> Void = {}
|
var onSaveForLater: () -> Void = {}
|
||||||
var onCancel: () -> Void = {}
|
var onCancel: () -> Void = {}
|
||||||
|
|
||||||
init(account: SharedAccount?) {
|
init(shared: SharedAccounts?) {
|
||||||
self.account = account
|
self.shared = shared
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var accounts: [SharedAccount] { shared?.accounts ?? [] }
|
||||||
|
var canChangeAccount: Bool { accounts.count > 1 }
|
||||||
|
var activeAccountId: String? { shared?.active?.id }
|
||||||
|
|
||||||
var currentFolderName: String {
|
var currentFolderName: String {
|
||||||
path == "/" ? "All files" : (path as NSString).lastPathComponent
|
path == "/" ? "All files" : (path as NSString).lastPathComponent
|
||||||
}
|
}
|
||||||
@@ -37,14 +50,66 @@ final class ShareModel: ObservableObject {
|
|||||||
items.count == 1 ? "1 file" : "\(items.count) files"
|
items.count == 1 ? "1 file" : "\(items.count) files"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// With several accounts the list comes first; with one it goes straight
|
||||||
|
/// to its folders.
|
||||||
|
func start() async {
|
||||||
|
guard let shared, let first = shared.accounts.first else {
|
||||||
|
stage = .noAccount
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if shared.accounts.count > 1 {
|
||||||
|
stage = .chooseAccount
|
||||||
|
} else {
|
||||||
|
await select(first)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func showAccounts() {
|
||||||
|
notice = nil
|
||||||
|
stage = .chooseAccount
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Uploading to an account other than the one the app is on is "switching"
|
||||||
|
/// in the app's terms, so it asks for the same unlock when the app does.
|
||||||
|
/// Hidden folders follow the account's own app setting, behind the app's
|
||||||
|
/// "lock hidden files" unlock.
|
||||||
|
func select(_ account: SharedAccount) async {
|
||||||
|
guard let shared else { return }
|
||||||
|
notice = nil
|
||||||
|
if account.id != shared.active?.id, shared.needsUnlockToSwitchAccount {
|
||||||
|
guard await unlock("Unlock to upload to \(account.displayName)") else {
|
||||||
|
notice = "Unlock to upload to a different account."
|
||||||
|
stage = .chooseAccount
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
selected = account
|
||||||
|
hidden = HiddenFilter(raw: account.hiddenFilter)
|
||||||
|
if hidden != .hide, shared.needsUnlockForHidden {
|
||||||
|
if await !unlock("Unlock to show hidden folders") {
|
||||||
|
hidden = .hide
|
||||||
|
notice = "Hidden folders are locked, so they're not shown."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stage = .picking
|
||||||
|
await open("/")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func unlock(_ reason: String) async -> Bool {
|
||||||
|
if unlocked { return true }
|
||||||
|
let ok = await DeviceAuth.authenticate(reason: reason)
|
||||||
|
if ok { unlocked = true }
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
func open(_ path: String) async {
|
func open(_ path: String) async {
|
||||||
guard let account else { return }
|
guard let account = selected else { return }
|
||||||
self.path = path
|
self.path = path
|
||||||
isLoadingFolders = true
|
isLoadingFolders = true
|
||||||
folderError = nil
|
folderError = nil
|
||||||
defer { isLoadingFolders = false }
|
defer { isLoadingFolders = false }
|
||||||
do {
|
do {
|
||||||
folders = try await DavClient.listFolders(account: account, path: path)
|
folders = try await DavClient.listFolders(account: account, path: path, hidden: hidden)
|
||||||
} catch {
|
} catch {
|
||||||
folders = []
|
folders = []
|
||||||
folderError = error.localizedDescription
|
folderError = error.localizedDescription
|
||||||
@@ -107,19 +172,48 @@ struct SharePickerView: View {
|
|||||||
centered {
|
centered {
|
||||||
Text(message).multilineTextAlignment(.center).foregroundColor(.secondary).padding()
|
Text(message).multilineTextAlignment(.center).foregroundColor(.secondary).padding()
|
||||||
}
|
}
|
||||||
|
case .chooseAccount:
|
||||||
|
accountList
|
||||||
case .picking:
|
case .picking:
|
||||||
folderList
|
folderList
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var accountList: some View {
|
||||||
|
List {
|
||||||
|
Section(
|
||||||
|
header: Text("Upload \(model.fileCountText) to which account?"),
|
||||||
|
footer: model.notice.map { Text($0) }
|
||||||
|
) {
|
||||||
|
ForEach(model.accounts) { account in
|
||||||
|
Button {
|
||||||
|
Task { await model.select(account) }
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Label(account.displayName, systemImage: "person.crop.circle")
|
||||||
|
Spacer()
|
||||||
|
if account.id == model.activeAccountId {
|
||||||
|
Text("Active").font(.caption).foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.insetGrouped)
|
||||||
|
}
|
||||||
|
|
||||||
private var folderList: some View {
|
private var folderList: some View {
|
||||||
List {
|
List {
|
||||||
Section(header: Text(model.account?.displayName ?? "")) {
|
Section(
|
||||||
|
header: accountHeader,
|
||||||
|
footer: model.notice.map { Text($0) }
|
||||||
|
) {
|
||||||
if model.path != "/" {
|
if model.path != "/" {
|
||||||
Button {
|
Button {
|
||||||
Task { await model.openParent() }
|
Task { await model.openParent() }
|
||||||
} label: {
|
} label: {
|
||||||
Label("Up to \((model.path as NSString).deletingLastPathComponent == "/" ? "All files" : ((model.path as NSString).deletingLastPathComponent as NSString).lastPathComponent)", systemImage: "arrow.turn.left.up")
|
Label("Up to \(parentName)", systemImage: "arrow.turn.left.up")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if model.isLoadingFolders {
|
if model.isLoadingFolders {
|
||||||
@@ -142,6 +236,22 @@ struct SharePickerView: View {
|
|||||||
.listStyle(.insetGrouped)
|
.listStyle(.insetGrouped)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var accountHeader: some View {
|
||||||
|
HStack {
|
||||||
|
Text(model.selected?.displayName ?? "")
|
||||||
|
Spacer()
|
||||||
|
if model.canChangeAccount {
|
||||||
|
Button("Change account") { model.showAccounts() }
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var parentName: String {
|
||||||
|
let parent = (model.path as NSString).deletingLastPathComponent
|
||||||
|
return parent.isEmpty || parent == "/" ? "All files" : (parent as NSString).lastPathComponent
|
||||||
|
}
|
||||||
|
|
||||||
private var footer: some View {
|
private var footer: some View {
|
||||||
VStack(spacing: 8) {
|
VStack(spacing: 8) {
|
||||||
if model.stage == .picking {
|
if model.stage == .picking {
|
||||||
@@ -154,7 +264,7 @@ struct SharePickerView: View {
|
|||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.borderedProminent)
|
||||||
.disabled(model.isLoadingFolders)
|
.disabled(model.isLoadingFolders)
|
||||||
}
|
}
|
||||||
if model.stage == .picking || model.stage == .noAccount {
|
if model.stage == .picking || model.stage == .chooseAccount || model.stage == .noAccount {
|
||||||
Button("Choose a folder later in Noo", action: model.onSaveForLater)
|
Button("Choose a folder later in Noo", action: model.onSaveForLater)
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ import UserNotifications
|
|||||||
/// shows a folder picker, then starts a background upload and closes:
|
/// shows a folder picker, then starts a background upload and closes:
|
||||||
///
|
///
|
||||||
/// 1. Copies whatever was shared into the App Group (`SharedInbox`).
|
/// 1. Copies whatever was shared into the App Group (`SharedInbox`).
|
||||||
/// 2. Reads the signed-in account the app left in the shared Keychain
|
/// 2. Reads the accounts the app left in the shared Keychain
|
||||||
/// (`SharedAccountStore`) and lists folders over WebDAV (`DavClient`).
|
/// (`SharedAccountStore`) - asking which one first if there are several -
|
||||||
|
/// and lists folders over WebDAV (`DavClient`), honouring the app's
|
||||||
|
/// hidden-files filter and its unlock settings (`ShareModel`).
|
||||||
/// 3. "Upload" queues the files on a background session that outlives this
|
/// 3. "Upload" queues the files on a background session that outlives this
|
||||||
/// process (`ShareUpload`); the app is relaunched to finish and notify.
|
/// process (`ShareUpload`); the app is relaunched to finish and notify.
|
||||||
///
|
///
|
||||||
@@ -22,7 +24,7 @@ final class ShareViewController: UIViewController {
|
|||||||
|
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
model = ShareModel(account: SharedAccountStore.load())
|
model = ShareModel(shared: SharedAccountStore.load())
|
||||||
model.onUpload = { [weak self] in self?.upload() }
|
model.onUpload = { [weak self] in self?.upload() }
|
||||||
model.onSaveForLater = { [weak self] in self?.saveForLater() }
|
model.onSaveForLater = { [weak self] in self?.saveForLater() }
|
||||||
model.onCancel = { [weak self] in self?.cancel() }
|
model.onCancel = { [weak self] in self?.cancel() }
|
||||||
@@ -68,12 +70,7 @@ final class ShareViewController: UIViewController {
|
|||||||
model.stage = .failed("Noo can only receive files and photos.")
|
model.stage = .failed("Noo can only receive files and photos.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard model.account != nil else {
|
await model.start()
|
||||||
model.stage = .noAccount
|
|
||||||
return
|
|
||||||
}
|
|
||||||
model.stage = .picking
|
|
||||||
await model.open("/")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `loadFileRepresentation`'s URL only lives for the duration of its
|
/// `loadFileRepresentation`'s URL only lives for the duration of its
|
||||||
@@ -102,7 +99,7 @@ final class ShareViewController: UIViewController {
|
|||||||
// MARK: - Actions
|
// MARK: - Actions
|
||||||
|
|
||||||
private func upload() {
|
private func upload() {
|
||||||
guard let account = model.account else { return }
|
guard let account = model.selected else { return }
|
||||||
do {
|
do {
|
||||||
try ShareUpload.enqueue(items: model.items, account: account, remoteFolder: model.path)
|
try ShareUpload.enqueue(items: model.items, account: account, remoteFolder: model.path)
|
||||||
model.stage = .uploading
|
model.stage = .uploading
|
||||||
|
|||||||
@@ -7,6 +7,30 @@ struct DavFolder: Equatable {
|
|||||||
let path: String
|
let path: String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The app's Files "hidden files" filter (`HiddenFilesFilter` in Dart):
|
||||||
|
/// leave dot-folders out (`hide`, the default), list only them (`only`), or
|
||||||
|
/// list everything (`include`). A folder counts as hidden when it - or any
|
||||||
|
/// folder above it - starts with a dot, exactly as in the app.
|
||||||
|
enum HiddenFilter: String {
|
||||||
|
case hide, only, include
|
||||||
|
|
||||||
|
init(raw: String) {
|
||||||
|
self = HiddenFilter(rawValue: raw) ?? .hide
|
||||||
|
}
|
||||||
|
|
||||||
|
static func isHidden(path: String) -> Bool {
|
||||||
|
path.split(separator: "/").contains { $0.hasPrefix(".") }
|
||||||
|
}
|
||||||
|
|
||||||
|
func shows(path: String) -> Bool {
|
||||||
|
switch self {
|
||||||
|
case .hide: return !Self.isHidden(path: path)
|
||||||
|
case .only: return Self.isHidden(path: path)
|
||||||
|
case .include: return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Parses a WebDAV `PROPFIND` multistatus into folders. Pure (no
|
/// Parses a WebDAV `PROPFIND` multistatus into folders. Pure (no
|
||||||
/// networking), so it's unit-tested. Compiled into both targets.
|
/// networking), so it's unit-tested. Compiled into both targets.
|
||||||
enum DavFolderParser {
|
enum DavFolderParser {
|
||||||
@@ -67,11 +91,11 @@ enum DavFolderParser {
|
|||||||
return "/" + parts.joined(separator: "/")
|
return "/" + parts.joined(separator: "/")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Child folders of [currentPath], sorted by name. The requested folder
|
/// Child folders of [currentPath], sorted by name, filtered by [hidden].
|
||||||
/// itself (which a Depth-1 PROPFIND also returns) and - unless
|
/// The requested folder itself (which a Depth-1 PROPFIND also returns) is
|
||||||
/// [includeHidden] - dot-folders are left out.
|
/// always left out.
|
||||||
static func folders(
|
static func folders(
|
||||||
from xml: Data, excluding currentPath: String, includeHidden: Bool = false
|
from xml: Data, excluding currentPath: String, hidden: HiddenFilter = .hide
|
||||||
) -> [DavFolder] {
|
) -> [DavFolder] {
|
||||||
let delegate = Delegate()
|
let delegate = Delegate()
|
||||||
let parser = XMLParser(data: xml)
|
let parser = XMLParser(data: xml)
|
||||||
@@ -84,7 +108,7 @@ enum DavFolderParser {
|
|||||||
.compactMap { response -> DavFolder? in
|
.compactMap { response -> DavFolder? in
|
||||||
guard let path = relativePath(fromHref: response.href), path != current else { return nil }
|
guard let path = relativePath(fromHref: response.href), path != current else { return nil }
|
||||||
let name = (path as NSString).lastPathComponent
|
let name = (path as NSString).lastPathComponent
|
||||||
guard !name.isEmpty, includeHidden || !name.hasPrefix(".") else { return nil }
|
guard !name.isEmpty, hidden.shows(path: path) else { return nil }
|
||||||
return DavFolder(name: name, path: path)
|
return DavFolder(name: name, path: path)
|
||||||
}
|
}
|
||||||
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
|
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
|
||||||
@@ -99,7 +123,7 @@ enum DavClient {
|
|||||||
<d:propfind xmlns:d="DAV:"><d:prop><d:resourcetype/></d:prop></d:propfind>
|
<d:propfind xmlns:d="DAV:"><d:prop><d:resourcetype/></d:prop></d:propfind>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
static func listFolders(account: SharedAccount, path: String) async throws -> [DavFolder] {
|
static func listFolders(account: SharedAccount, path: String, hidden: HiddenFilter) async throws -> [DavFolder] {
|
||||||
guard let url = WebDAV.fileURL(serverUrl: account.serverUrl, username: account.username, remotePath: path)
|
guard let url = WebDAV.fileURL(serverUrl: account.serverUrl, username: account.username, remotePath: path)
|
||||||
else { throw TransferError(message: "Invalid server address.") }
|
else { throw TransferError(message: "Invalid server address.") }
|
||||||
var request = URLRequest(url: url)
|
var request = URLRequest(url: url)
|
||||||
@@ -115,6 +139,6 @@ enum DavClient {
|
|||||||
guard status == 207 else {
|
guard status == 207 else {
|
||||||
throw TransferError(message: status == 401 ? "Signed out - open Noo to sign in again." : "Server returned \(status).")
|
throw TransferError(message: status == 401 ? "Signed out - open Noo to sign in again." : "Server returned \(status).")
|
||||||
}
|
}
|
||||||
return DavFolderParser.folders(from: data, excluding: path)
|
return DavFolderParser.folders(from: data, excluding: path, hidden: hidden)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,57 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Security
|
import Security
|
||||||
|
|
||||||
/// The signed-in account, as much of it as the Share Extension needs to list
|
/// One 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
|
/// folders and upload. Compiled into both targets.
|
||||||
/// clears it on sign-out. Compiled into both targets.
|
struct SharedAccount: Codable, Equatable, Identifiable {
|
||||||
struct SharedAccount: Codable, Equatable {
|
let id: String
|
||||||
let serverUrl: String
|
let serverUrl: String
|
||||||
let username: String
|
let username: String
|
||||||
/// `Basic ...` - the app password never leaves the Keychain as plain text.
|
/// `Basic ...` - the app password never leaves the Keychain as plain text.
|
||||||
let authHeader: String
|
let authHeader: String
|
||||||
/// Shown in the picker, e.g. "alice@cloud.example.com".
|
/// Shown in the picker, e.g. "alice@cloud.example.com".
|
||||||
let displayName: String
|
let displayName: String
|
||||||
|
/// The app's Files "hidden files" filter for this account - `hide` (the
|
||||||
|
/// default), `only` or `include` (see `HiddenFilter`). The share sheet
|
||||||
|
/// follows it instead of having a setting of its own.
|
||||||
|
let hiddenFilter: String
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Keeps the [SharedAccount] in a Keychain access group both the app and the
|
/// Everything the app publishes for the extension: every account that can
|
||||||
|
/// upload, which one is active in the app, and the app-lock settings the
|
||||||
|
/// extension has to honour. The app rewrites it whenever any of that changes.
|
||||||
|
struct SharedAccounts: Codable, Equatable {
|
||||||
|
var accounts: [SharedAccount]
|
||||||
|
var activeId: String?
|
||||||
|
/// Settings -> Security: the master "login lock" and its two sub-toggles.
|
||||||
|
/// The sub-toggles only count while the master one is on (as in the app).
|
||||||
|
var loginLockEnabled: Bool
|
||||||
|
var lockAccountSwitching: Bool
|
||||||
|
var lockHiddenFiles: Bool
|
||||||
|
|
||||||
|
/// The account the app is currently using, else the first one.
|
||||||
|
var active: SharedAccount? {
|
||||||
|
accounts.first { $0.id == activeId } ?? accounts.first
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Uploading to an account other than the active one is "switching" in
|
||||||
|
/// the app's terms, so it needs the same unlock.
|
||||||
|
var needsUnlockToSwitchAccount: Bool { loginLockEnabled && lockAccountSwitching }
|
||||||
|
|
||||||
|
/// Showing hidden folders needs the same unlock the app asks for when you
|
||||||
|
/// turn hidden files on.
|
||||||
|
var needsUnlockForHidden: Bool { loginLockEnabled && lockHiddenFiles }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keeps the [SharedAccounts] in a Keychain access group both the app and the
|
||||||
/// extension are entitled to (`keychain-access-groups`). The group's full id
|
/// 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
|
/// carries the signing team's prefix, so it's injected into each target's
|
||||||
/// Info.plist as `NooKeychainAccessGroup` (= `$(AppIdentifierPrefix)` +
|
/// Info.plist as `NooKeychainAccessGroup` (= `$(AppIdentifierPrefix)` +
|
||||||
/// `dev.ayushya.noo.shared`) instead of being hard-coded - both processes
|
/// `dev.ayushya.noo.shared`) instead of being hard-coded - both processes
|
||||||
/// then always agree on it, with or without a team.
|
/// then always agree on it, with or without a team.
|
||||||
enum SharedAccountStore {
|
enum SharedAccountStore {
|
||||||
private static let service = "dev.ayushya.noo.shared-account"
|
private static let service = "dev.ayushya.noo.shared-accounts"
|
||||||
private static let account = "active"
|
private static let account = "all"
|
||||||
|
|
||||||
static var accessGroup: String? {
|
static var accessGroup: String? {
|
||||||
Bundle.main.object(forInfoDictionaryKey: "NooKeychainAccessGroup") as? String
|
Bundle.main.object(forInfoDictionaryKey: "NooKeychainAccessGroup") as? String
|
||||||
@@ -39,7 +69,7 @@ enum SharedAccountStore {
|
|||||||
return query
|
return query
|
||||||
}
|
}
|
||||||
|
|
||||||
static func save(_ value: SharedAccount) throws {
|
static func save(_ value: SharedAccounts) throws {
|
||||||
let data = try JSONEncoder().encode(value)
|
let data = try JSONEncoder().encode(value)
|
||||||
clear()
|
clear()
|
||||||
var query = baseQuery()
|
var query = baseQuery()
|
||||||
@@ -51,15 +81,17 @@ enum SharedAccountStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static func load() -> SharedAccount? {
|
static func load() -> SharedAccounts? {
|
||||||
var query = baseQuery()
|
var query = baseQuery()
|
||||||
query[kSecReturnData as String] = true
|
query[kSecReturnData as String] = true
|
||||||
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
||||||
var result: AnyObject?
|
var result: AnyObject?
|
||||||
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
|
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
|
||||||
let data = result as? Data
|
let data = result as? Data,
|
||||||
|
let decoded = try? JSONDecoder().decode(SharedAccounts.self, from: data),
|
||||||
|
!decoded.accounts.isEmpty
|
||||||
else { return nil }
|
else { return nil }
|
||||||
return try? JSONDecoder().decode(SharedAccount.self, from: data)
|
return decoded
|
||||||
}
|
}
|
||||||
|
|
||||||
static func clear() {
|
static func clear() {
|
||||||
|
|||||||
+12
-5
@@ -61,11 +61,9 @@ void main() {
|
|||||||
ChangeNotifierProvider(
|
ChangeNotifierProvider(
|
||||||
create: (context) {
|
create: (context) {
|
||||||
final session = SessionController(context.read());
|
final session = SessionController(context.read());
|
||||||
// iOS's Share Extension has no Flutter engine; it picks its
|
// iOS's Share Extension has no Flutter engine; sign-out must wipe
|
||||||
// folders/uploads as whichever account was last published here.
|
// what it was given. (Publishing happens in the shell - see
|
||||||
session.addAccountReadyListener(
|
// ShareAccountSync - once there's a FilesController to read.)
|
||||||
() => ShareAccountService.publish(session),
|
|
||||||
);
|
|
||||||
session.addAccountClearedListener(ShareAccountService.clear);
|
session.addAccountClearedListener(ShareAccountService.clear);
|
||||||
return session;
|
return session;
|
||||||
},
|
},
|
||||||
@@ -215,6 +213,7 @@ class _MainShellViewState extends State<MainShellView> {
|
|||||||
late final Map<AppTab, ScrollController> _scrollControllers;
|
late final Map<AppTab, ScrollController> _scrollControllers;
|
||||||
StreamSubscription<List<SharedFileRef>>? _shareSub;
|
StreamSubscription<List<SharedFileRef>>? _shareSub;
|
||||||
StreamSubscription<PickRequest>? _pickSub;
|
StreamSubscription<PickRequest>? _pickSub;
|
||||||
|
ShareAccountSync? _shareAccountSync;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -236,6 +235,13 @@ class _MainShellViewState extends State<MainShellView> {
|
|||||||
ShareIntentService.getInitialShare().then(_handleSharedFiles);
|
ShareIntentService.getInitialShare().then(_handleSharedFiles);
|
||||||
_shareSub = ShareIntentService.onNewShare.listen(_handleSharedFiles);
|
_shareSub = ShareIntentService.onNewShare.listen(_handleSharedFiles);
|
||||||
|
|
||||||
|
// iOS's Share Extension picks accounts/folders on its own, so it's kept
|
||||||
|
// supplied with the accounts, lock settings and hidden-files filters.
|
||||||
|
_shareAccountSync = ShareAccountSync(
|
||||||
|
context.read<SessionController>(),
|
||||||
|
context.read<FilesController>(),
|
||||||
|
)..start();
|
||||||
|
|
||||||
// Same cold-start-vs-already-running split as the share intent above:
|
// Same cold-start-vs-already-running split as the share intent above:
|
||||||
// another app may have launched Noo as its GET_CONTENT picker.
|
// another app may have launched Noo as its GET_CONTENT picker.
|
||||||
PickIntentService.getPickRequest().then((request) {
|
PickIntentService.getPickRequest().then((request) {
|
||||||
@@ -320,6 +326,7 @@ class _MainShellViewState extends State<MainShellView> {
|
|||||||
}
|
}
|
||||||
_shareSub?.cancel();
|
_shareSub?.cancel();
|
||||||
_pickSub?.cancel();
|
_pickSub?.cancel();
|
||||||
|
_shareAccountSync?.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -677,6 +677,19 @@ class FilesController extends ChangeNotifier
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The hidden-files filter saved for [accountId] - not just the active
|
||||||
|
/// account's, which is all [hiddenFilter] holds. For iOS's Share Extension,
|
||||||
|
/// which follows each account's own setting.
|
||||||
|
static HiddenFilesFilter savedHiddenFilter(
|
||||||
|
SharedPreferences prefs,
|
||||||
|
String Function(String accountId, String baseKey) accountPrefKey,
|
||||||
|
String accountId,
|
||||||
|
) => loadHiddenFilter(
|
||||||
|
prefs,
|
||||||
|
accountPrefKey(accountId, _prefHiddenFilter),
|
||||||
|
accountPrefKey(accountId, _prefShowHiddenFiles),
|
||||||
|
);
|
||||||
|
|
||||||
/// Reads a persisted [HiddenFilesFilter], falling back to the old
|
/// Reads a persisted [HiddenFilesFilter], falling back to the old
|
||||||
/// show-hidden bool (`true` meant everything incl. hidden) so a pref
|
/// show-hidden bool (`true` meant everything incl. hidden) so a pref
|
||||||
/// saved before the three-way filter keeps its meaning. [key]s are
|
/// saved before the three-way filter keeps its meaning. [key]s are
|
||||||
|
|||||||
@@ -1,30 +1,175 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import '../providers/files_controller.dart';
|
||||||
import '../providers/session_controller.dart';
|
import '../providers/session_controller.dart';
|
||||||
import 'native_channel.dart';
|
import 'native_channel.dart';
|
||||||
|
|
||||||
/// Tells the native side which account is active, so iOS's Share Extension -
|
/// One account as the iOS Share Extension needs it - plain data, so the
|
||||||
/// a separate process with no Flutter engine - can list folders and upload as
|
/// payload builder is testable without a session.
|
||||||
/// it without opening the app (`ios/Runner/Native/NativeServices.swift`'s
|
class ShareAccountEntry {
|
||||||
|
final String id;
|
||||||
|
final String serverUrl;
|
||||||
|
final String username;
|
||||||
|
final String password;
|
||||||
|
|
||||||
|
/// The account's Files "hidden files" filter: `hide`, `only` or `include`.
|
||||||
|
final String hiddenFilter;
|
||||||
|
|
||||||
|
const ShareAccountEntry({
|
||||||
|
required this.id,
|
||||||
|
required this.serverUrl,
|
||||||
|
required this.username,
|
||||||
|
required this.password,
|
||||||
|
required this.hiddenFilter,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tells the native side which accounts can upload, which one is active, and
|
||||||
|
/// the app-lock settings, so iOS's Share Extension - a separate process with
|
||||||
|
/// no Flutter engine - can offer an account picker, list folders and upload
|
||||||
|
/// as them without opening the app (`ios/Runner/Native/NativeServices.swift`'s
|
||||||
/// `share_account`, stored in a Keychain group shared with the extension).
|
/// `share_account`, stored in a Keychain group shared with the extension).
|
||||||
/// Android has no equivalent (its share intent opens the app itself), so
|
/// Android has no equivalent (its share intent opens the app itself), so there
|
||||||
/// there the channel simply doesn't exist and both calls are no-ops.
|
/// the channel simply doesn't exist and everything here is a no-op.
|
||||||
class ShareAccountService {
|
class ShareAccountService {
|
||||||
static const _channel = MethodChannel('dev.ayushya.noo/share_account');
|
static const _channel = MethodChannel('dev.ayushya.noo/share_account');
|
||||||
|
|
||||||
/// Publishes the active account (call whenever one becomes active).
|
static String basicAuth(String username, String password) =>
|
||||||
static Future<void> publish(SessionController session) async {
|
'Basic ${base64Encode(utf8.encode('$username:$password'))}';
|
||||||
final authHeader = session.service?.authHeaders['Authorization'];
|
|
||||||
if (authHeader == null || session.serverUrl.isEmpty) return;
|
/// The JSON `SharedAccounts` (`ios/Shared/SharedAccount.swift`) decodes.
|
||||||
final host = Uri.tryParse(session.serverUrl)?.host ?? session.serverUrl;
|
static String buildPayload({
|
||||||
await invokeIfAvailable(_channel, 'setAccount', {
|
required List<ShareAccountEntry> accounts,
|
||||||
'serverUrl': session.serverUrl,
|
required String? activeId,
|
||||||
'username': session.username,
|
required bool loginLockEnabled,
|
||||||
'authHeader': authHeader,
|
required bool lockAccountSwitching,
|
||||||
'displayName': '${session.username}@$host',
|
required bool lockHiddenFiles,
|
||||||
|
}) {
|
||||||
|
return jsonEncode({
|
||||||
|
'accounts': [
|
||||||
|
for (final a in accounts)
|
||||||
|
{
|
||||||
|
'id': a.id,
|
||||||
|
'serverUrl': a.serverUrl,
|
||||||
|
'username': a.username,
|
||||||
|
'authHeader': basicAuth(a.username, a.password),
|
||||||
|
'displayName':
|
||||||
|
'${a.username}@${Uri.tryParse(a.serverUrl)?.host ?? a.serverUrl}',
|
||||||
|
'hiddenFilter': a.hiddenFilter,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'activeId': activeId,
|
||||||
|
'loginLockEnabled': loginLockEnabled,
|
||||||
|
'lockAccountSwitching': lockAccountSwitching,
|
||||||
|
'lockHiddenFiles': lockHiddenFiles,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Forgets it (sign-out / switching away) so the extension stops offering
|
/// Publishes every saved account that can actually upload (has a stored
|
||||||
/// uploads to an account that's no longer signed in.
|
/// password and isn't signed out), with the active one's hidden-files
|
||||||
|
/// filter taken live from [files] - its saved pref is written
|
||||||
|
/// asynchronously, so it can lag a change that just happened.
|
||||||
|
static Future<void> publish(
|
||||||
|
SessionController session,
|
||||||
|
FilesController files,
|
||||||
|
) async {
|
||||||
|
final activeId = session.activeAccountId;
|
||||||
|
if (activeId == null) return clear();
|
||||||
|
|
||||||
|
final prefs = await session.prefsFuture;
|
||||||
|
final store = session.accountStore;
|
||||||
|
final signedOut = store.loadSignedOut(prefs);
|
||||||
|
final entries = <ShareAccountEntry>[];
|
||||||
|
for (final account in session.accounts) {
|
||||||
|
if (signedOut.contains(account.id)) continue;
|
||||||
|
final password = await store.readPassword(account.id);
|
||||||
|
if (password == null) continue;
|
||||||
|
final filter = account.id == activeId
|
||||||
|
? files.hiddenFilter
|
||||||
|
: FilesController.savedHiddenFilter(
|
||||||
|
prefs,
|
||||||
|
store.accountPrefKey,
|
||||||
|
account.id,
|
||||||
|
);
|
||||||
|
entries.add(
|
||||||
|
ShareAccountEntry(
|
||||||
|
id: account.id,
|
||||||
|
serverUrl: account.serverUrl,
|
||||||
|
username: account.username,
|
||||||
|
password: password,
|
||||||
|
hiddenFilter: filter.name,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (entries.isEmpty) return clear();
|
||||||
|
|
||||||
|
await invokeIfAvailable(_channel, 'setAccounts', {
|
||||||
|
'accounts': buildPayload(
|
||||||
|
accounts: entries,
|
||||||
|
activeId: activeId,
|
||||||
|
loginLockEnabled: session.loginLockEnabled,
|
||||||
|
lockAccountSwitching: session.lockAccountSwitching,
|
||||||
|
lockHiddenFiles: session.lockHiddenFiles,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forgets everything (sign-out) so the extension stops offering uploads.
|
||||||
static Future<void> clear() => invokeIfAvailable(_channel, 'clearAccount');
|
static Future<void> clear() => invokeIfAvailable(_channel, 'clearAccount');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Keeps [ShareAccountService]'s published data current while the app is
|
||||||
|
/// running: republishes when an account becomes ready or when anything the
|
||||||
|
/// extension depends on changes - the account list or active account, the
|
||||||
|
/// lock settings, or the active account's hidden-files filter.
|
||||||
|
class ShareAccountSync {
|
||||||
|
final SessionController session;
|
||||||
|
final FilesController files;
|
||||||
|
|
||||||
|
String? _lastSignature;
|
||||||
|
bool _disposed = false;
|
||||||
|
Future<void> _queue = Future.value();
|
||||||
|
|
||||||
|
ShareAccountSync(this.session, this.files);
|
||||||
|
|
||||||
|
void start() {
|
||||||
|
session.addAccountReadyListener(_publish);
|
||||||
|
session.addListener(_onChange);
|
||||||
|
files.addListener(_onChange);
|
||||||
|
}
|
||||||
|
|
||||||
|
void dispose() {
|
||||||
|
_disposed = true;
|
||||||
|
session.removeListener(_onChange);
|
||||||
|
files.removeListener(_onChange);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _signature() => [
|
||||||
|
session.activeAccountId,
|
||||||
|
session.accounts.map((a) => a.id).join(','),
|
||||||
|
session.loginLockEnabled,
|
||||||
|
session.lockAccountSwitching,
|
||||||
|
session.lockHiddenFiles,
|
||||||
|
files.hiddenFilter.name,
|
||||||
|
].join('|');
|
||||||
|
|
||||||
|
void _onChange() {
|
||||||
|
if (_signature() != _lastSignature) _publish();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs one at a time, in order, so a slow publish can't land after a
|
||||||
|
/// newer one and leave the extension with stale data.
|
||||||
|
void _publish() {
|
||||||
|
if (_disposed) return;
|
||||||
|
_lastSignature = _signature();
|
||||||
|
_queue = _queue.then((_) async {
|
||||||
|
if (_disposed) return;
|
||||||
|
try {
|
||||||
|
await ShareAccountService.publish(session, files);
|
||||||
|
} catch (_) {
|
||||||
|
// Best effort: the extension just keeps the previous data.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:noo/services/share_account_service.dart';
|
||||||
|
|
||||||
|
/// What the iOS Share Extension is given - the contract with
|
||||||
|
/// `ios/Shared/SharedAccount.swift`'s `SharedAccounts`.
|
||||||
|
void main() {
|
||||||
|
const alice = ShareAccountEntry(
|
||||||
|
id: 'cloud_example_com__alice',
|
||||||
|
serverUrl: 'https://cloud.example.com',
|
||||||
|
username: 'alice',
|
||||||
|
password: 's3cret',
|
||||||
|
hiddenFilter: 'include',
|
||||||
|
);
|
||||||
|
const bob = ShareAccountEntry(
|
||||||
|
id: 'nc_home_lan__bob',
|
||||||
|
serverUrl: 'https://nc.home.lan:8443/nextcloud',
|
||||||
|
username: 'bob',
|
||||||
|
password: 'pw',
|
||||||
|
hiddenFilter: 'hide',
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> build({
|
||||||
|
List<ShareAccountEntry> accounts = const [alice, bob],
|
||||||
|
String? activeId = 'nc_home_lan__bob',
|
||||||
|
bool login = true,
|
||||||
|
bool switching = true,
|
||||||
|
bool hidden = false,
|
||||||
|
}) => jsonDecode(
|
||||||
|
ShareAccountService.buildPayload(
|
||||||
|
accounts: accounts,
|
||||||
|
activeId: activeId,
|
||||||
|
loginLockEnabled: login,
|
||||||
|
lockAccountSwitching: switching,
|
||||||
|
lockHiddenFiles: hidden,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
test('basic auth header matches what the app sends', () {
|
||||||
|
expect(
|
||||||
|
ShareAccountService.basicAuth('alice', 's3cret'),
|
||||||
|
'Basic ${base64Encode(utf8.encode('alice:s3cret'))}',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('every account is listed with its own header, name and filter', () {
|
||||||
|
final accounts = (build()['accounts'] as List).cast<Map<String, dynamic>>();
|
||||||
|
expect(accounts.map((a) => a['id']), [alice.id, bob.id]);
|
||||||
|
expect(
|
||||||
|
accounts[0]['authHeader'],
|
||||||
|
ShareAccountService.basicAuth('alice', 's3cret'),
|
||||||
|
);
|
||||||
|
expect(accounts[0]['displayName'], 'alice@cloud.example.com');
|
||||||
|
expect(accounts[0]['hiddenFilter'], 'include');
|
||||||
|
// The display name uses the host only - not the port or sub-path.
|
||||||
|
expect(accounts[1]['displayName'], 'bob@nc.home.lan');
|
||||||
|
expect(accounts[1]['serverUrl'], 'https://nc.home.lan:8443/nextcloud');
|
||||||
|
expect(accounts[1]['hiddenFilter'], 'hide');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the active account and the lock settings are passed through', () {
|
||||||
|
final payload = build(login: true, switching: false, hidden: true);
|
||||||
|
expect(payload['activeId'], 'nc_home_lan__bob');
|
||||||
|
expect(payload['loginLockEnabled'], true);
|
||||||
|
expect(payload['lockAccountSwitching'], false);
|
||||||
|
expect(payload['lockHiddenFiles'], true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no active account is a null activeId, not a missing key', () {
|
||||||
|
expect(build(activeId: null).containsKey('activeId'), true);
|
||||||
|
expect(build(activeId: null)['activeId'], isNull);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user