diff --git a/.claude/context/server.md b/.claude/context/server.md index a3dfb15..ddac4dc 100644 --- a/.claude/context/server.md +++ b/.claude/context/server.md @@ -885,13 +885,34 @@ Reminders/Notes - the destination is picked *inside* the sheet: loadFileRepresentation`, public.item) into the App Group container (`group.dev.ayushya.noo`, `SharedInbox//`) and hosts the SwiftUI `SharePickerView` (`ShareModel` holds its state). -2. It reads the signed-in account from the shared Keychain - (`SharedAccountStore`) and lists folders over WebDAV (`DavClient` - `PROPFIND` Depth 1, parsed by `DavFolderParser`; dot-folders hidden). - The Dart side keeps that account current: `ShareAccountService.publish` - (`lib/services/share_account_service.dart`) runs on every account-ready - event and `clear` on account-cleared, through the native `share_account` - channel - both wired where `SessionController` is created in `main.dart`. +2. It reads the accounts from the shared Keychain (`SharedAccountStore` -> + `SharedAccounts`) and lists folders over WebDAV (`DavClient` `PROPFIND` + Depth 1, parsed by `DavFolderParser`). With **several accounts an account + list comes first** (the app's active one is marked "Active"; "Change + account" returns to it); with one it goes straight to its folders. The + Dart side keeps the keychain data current: `ShareAccountSync` + (`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* `URLSession` (`dev.ayushya.noo.transfers.share`, with `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` (`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 +two processes always agree on the team-prefixed id. Everything is stored as JSON in one generic-password item (`kSecAttrAccessibleAfterFirstUnlock`). `ios/Shared/` (compiled into both targets): `SharedInbox`, `SharedAccount`, diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 583e205..65e4e79 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -26,6 +26,7 @@ 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 */; }; + 8C73B4CB445AEF383B7640E1 /* DeviceAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97ACCF43E1EC13AA2538E42C /* DeviceAuth.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 */; }; @@ -107,6 +108,7 @@ 901C0CDB4FB819302E2A7563 /* NativeServices.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NativeServices.swift; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97ACCF43E1EC13AA2538E42C /* DeviceAuth.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DeviceAuth.swift; sourceTree = ""; }; 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 = ""; }; 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; @@ -158,6 +160,7 @@ 49213989F0FA1B83B0B8A523 /* Info.plist */, 65F57A1EB0C62774E561860D /* ShareExtension.entitlements */, 5BE7B8C31FF78E212BD71DFC /* SharePickerView.swift */, + 97ACCF43E1EC13AA2538E42C /* DeviceAuth.swift */, ); name = ShareExtension; path = ShareExtension; @@ -438,6 +441,7 @@ 3C1A0DAA5F9B37FEA4C604BE /* DavFolders.swift in Sources */, 66AFC246E251554A80B2476F /* SharePickerView.swift in Sources */, A16680889B3A9D8672FA29C1 /* TransferNotifications.swift in Sources */, + 8C73B4CB445AEF383B7640E1 /* DeviceAuth.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/ios/Runner/Native/NativeServices.swift b/ios/Runner/Native/NativeServices.swift index db47b4e..954d235 100644 --- a/ios/Runner/Native/NativeServices.swift +++ b/ios/Runner/Native/NativeServices.swift @@ -80,22 +80,21 @@ enum NativeServices { // 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. + /// `share_account`: Dart hands native every account that can upload, which + /// one is active, and the app-lock settings, so the Share Extension - a + /// 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) { FlutterMethodChannel(name: "dev.ayushya.noo/share_account", binaryMessenger: messenger) .setMethodCallHandler { call, result in switch call.method { - case "setAccount": + case "setAccounts": 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) ?? "" - )) + guard let json = args["accounts"] as? String, + let data = json.data(using: .utf8), + let accounts = try? JSONDecoder().decode(SharedAccounts.self, from: data) + else { throw TransferError(message: "Bad accounts payload.") } + try SharedAccountStore.save(accounts) } case "clearAccount": SharedAccountStore.clear() diff --git a/ios/RunnerTests/RunnerTests.swift b/ios/RunnerTests/RunnerTests.swift index 74ece5f..ff1b899 100644 --- a/ios/RunnerTests/RunnerTests.swift +++ b/ios/RunnerTests/RunnerTests.swift @@ -124,8 +124,24 @@ class RunnerTests: XCTestCase { 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), + DavFolderParser.folders(from: xml, excluding: "/Docs", hidden: .include).map(\.name), [".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() { @@ -150,19 +166,26 @@ class RunnerTests: XCTestCase { // 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") + private func account(_ id: String, hidden: String = "hide") -> SharedAccount { + SharedAccount( + id: id, serverUrl: "https://\(id).example.com", username: id, + 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() } SharedAccountStore.clear() XCTAssertNil(SharedAccountStore.load()) - try SharedAccountStore.save(account) - XCTAssertEqual(SharedAccountStore.load(), account) + try SharedAccountStore.save(accounts) + XCTAssertEqual(SharedAccountStore.load(), accounts) - let replacement = SharedAccount( - serverUrl: "https://other.example.org", username: "bob", authHeader: "Basic Ym9i", displayName: "bob") + let replacement = SharedAccounts( + accounts: [account("carol")], activeId: "carol", + loginLockEnabled: false, lockAccountSwitching: false, lockHiddenFiles: false) try SharedAccountStore.save(replacement) XCTAssertEqual(SharedAccountStore.load(), replacement, "saving replaces, never duplicates") @@ -170,6 +193,31 @@ class RunnerTests: XCTestCase { 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 func testBatchSummaryFiresOnlyOnTheLastFile() { diff --git a/ios/ShareExtension/DeviceAuth.swift b/ios/ShareExtension/DeviceAuth.swift new file mode 100644 index 0000000..b7c4cab --- /dev/null +++ b/ios/ShareExtension/DeviceAuth.swift @@ -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) + } + } + } +} diff --git a/ios/ShareExtension/Info.plist b/ios/ShareExtension/Info.plist index 5206ecc..68865d3 100644 --- a/ios/ShareExtension/Info.plist +++ b/ios/ShareExtension/Info.plist @@ -20,6 +20,8 @@ $(MARKETING_VERSION) CFBundleVersion $(CURRENT_PROJECT_VERSION) + NSFaceIDUsageDescription + Noo uses Face ID to unlock hidden folders and accounts when you share a file. NooKeychainAccessGroup $(AppIdentifierPrefix)dev.ayushya.noo.shared NSExtension diff --git a/ios/ShareExtension/SharePickerView.swift b/ios/ShareExtension/SharePickerView.swift index f2165ec..8ab7de6 100644 --- a/ios/ShareExtension/SharePickerView.swift +++ b/ios/ShareExtension/SharePickerView.swift @@ -1,10 +1,11 @@ import SwiftUI -/// State behind the share sheet's folder picker. +/// State behind the share sheet's account and folder pickers. @MainActor final class ShareModel: ObservableObject { enum Stage: Equatable { case preparing + case chooseAccount case picking case uploading case noAccount @@ -17,18 +18,30 @@ final class ShareModel: ObservableObject { @Published var folders: [DavFolder] = [] @Published var isLoadingFolders = false @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. var onUpload: () -> Void = {} var onSaveForLater: () -> Void = {} var onCancel: () -> Void = {} - init(account: SharedAccount?) { - self.account = account + init(shared: SharedAccounts?) { + self.shared = shared } + var accounts: [SharedAccount] { shared?.accounts ?? [] } + var canChangeAccount: Bool { accounts.count > 1 } + var activeAccountId: String? { shared?.active?.id } + var currentFolderName: String { path == "/" ? "All files" : (path as NSString).lastPathComponent } @@ -37,14 +50,66 @@ final class ShareModel: ObservableObject { 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 { - guard let account else { return } + guard let account = selected else { return } self.path = path isLoadingFolders = true folderError = nil defer { isLoadingFolders = false } do { - folders = try await DavClient.listFolders(account: account, path: path) + folders = try await DavClient.listFolders(account: account, path: path, hidden: hidden) } catch { folders = [] folderError = error.localizedDescription @@ -107,19 +172,48 @@ struct SharePickerView: View { centered { Text(message).multilineTextAlignment(.center).foregroundColor(.secondary).padding() } + case .chooseAccount: + accountList case .picking: 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 { List { - Section(header: Text(model.account?.displayName ?? "")) { + Section( + header: accountHeader, + footer: model.notice.map { Text($0) } + ) { 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") + Label("Up to \(parentName)", systemImage: "arrow.turn.left.up") } } if model.isLoadingFolders { @@ -142,6 +236,22 @@ struct SharePickerView: View { .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 { VStack(spacing: 8) { if model.stage == .picking { @@ -154,7 +264,7 @@ struct SharePickerView: View { .buttonStyle(.borderedProminent) .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) .font(.subheadline) } diff --git a/ios/ShareExtension/ShareViewController.swift b/ios/ShareExtension/ShareViewController.swift index 0aba663..4a05cc3 100644 --- a/ios/ShareExtension/ShareViewController.swift +++ b/ios/ShareExtension/ShareViewController.swift @@ -8,8 +8,10 @@ import UserNotifications /// 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`). +/// 2. Reads the accounts the app left in the shared Keychain +/// (`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 /// process (`ShareUpload`); the app is relaunched to finish and notify. /// @@ -22,7 +24,7 @@ final class ShareViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() - model = ShareModel(account: SharedAccountStore.load()) + model = ShareModel(shared: SharedAccountStore.load()) model.onUpload = { [weak self] in self?.upload() } model.onSaveForLater = { [weak self] in self?.saveForLater() } 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.") return } - guard model.account != nil else { - model.stage = .noAccount - return - } - model.stage = .picking - await model.open("/") + await model.start() } /// `loadFileRepresentation`'s URL only lives for the duration of its @@ -102,7 +99,7 @@ final class ShareViewController: UIViewController { // MARK: - Actions private func upload() { - guard let account = model.account else { return } + guard let account = model.selected else { return } do { try ShareUpload.enqueue(items: model.items, account: account, remoteFolder: model.path) model.stage = .uploading diff --git a/ios/Shared/DavFolders.swift b/ios/Shared/DavFolders.swift index 3838af4..a855b6e 100644 --- a/ios/Shared/DavFolders.swift +++ b/ios/Shared/DavFolders.swift @@ -7,6 +7,30 @@ struct DavFolder: Equatable { 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 /// networking), so it's unit-tested. Compiled into both targets. enum DavFolderParser { @@ -67,11 +91,11 @@ enum DavFolderParser { 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. + /// Child folders of [currentPath], sorted by name, filtered by [hidden]. + /// The requested folder itself (which a Depth-1 PROPFIND also returns) is + /// always left out. static func folders( - from xml: Data, excluding currentPath: String, includeHidden: Bool = false + from xml: Data, excluding currentPath: String, hidden: HiddenFilter = .hide ) -> [DavFolder] { let delegate = Delegate() let parser = XMLParser(data: xml) @@ -84,7 +108,7 @@ enum DavFolderParser { .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 } + guard !name.isEmpty, hidden.shows(path: path) else { return nil } return DavFolder(name: name, path: path) } .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } @@ -99,7 +123,7 @@ enum DavClient { """ - 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) else { throw TransferError(message: "Invalid server address.") } var request = URLRequest(url: url) @@ -115,6 +139,6 @@ enum DavClient { 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) + return DavFolderParser.folders(from: data, excluding: path, hidden: hidden) } } diff --git a/ios/Shared/SharedAccount.swift b/ios/Shared/SharedAccount.swift index 939268e..ed5672f 100644 --- a/ios/Shared/SharedAccount.swift +++ b/ios/Shared/SharedAccount.swift @@ -1,27 +1,57 @@ 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 { +/// One signed-in account, as much of it as the Share Extension needs to list +/// folders and upload. Compiled into both targets. +struct SharedAccount: Codable, Equatable, Identifiable { + let id: String 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 + /// 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 /// 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" + private static let service = "dev.ayushya.noo.shared-accounts" + private static let account = "all" static var accessGroup: String? { Bundle.main.object(forInfoDictionaryKey: "NooKeychainAccessGroup") as? String @@ -39,7 +69,7 @@ enum SharedAccountStore { return query } - static func save(_ value: SharedAccount) throws { + static func save(_ value: SharedAccounts) throws { let data = try JSONEncoder().encode(value) clear() var query = baseQuery() @@ -51,15 +81,17 @@ enum SharedAccountStore { } } - static func load() -> SharedAccount? { + static func load() -> SharedAccounts? { 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 + let data = result as? Data, + let decoded = try? JSONDecoder().decode(SharedAccounts.self, from: data), + !decoded.accounts.isEmpty else { return nil } - return try? JSONDecoder().decode(SharedAccount.self, from: data) + return decoded } static func clear() { diff --git a/lib/main.dart b/lib/main.dart index d042399..6ed8da5 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -61,11 +61,9 @@ void main() { ChangeNotifierProvider( 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), - ); + // iOS's Share Extension has no Flutter engine; sign-out must wipe + // what it was given. (Publishing happens in the shell - see + // ShareAccountSync - once there's a FilesController to read.) session.addAccountClearedListener(ShareAccountService.clear); return session; }, @@ -215,6 +213,7 @@ class _MainShellViewState extends State { late final Map _scrollControllers; StreamSubscription>? _shareSub; StreamSubscription? _pickSub; + ShareAccountSync? _shareAccountSync; @override void initState() { @@ -236,6 +235,13 @@ class _MainShellViewState extends State { ShareIntentService.getInitialShare().then(_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(), + context.read(), + )..start(); + // Same cold-start-vs-already-running split as the share intent above: // another app may have launched Noo as its GET_CONTENT picker. PickIntentService.getPickRequest().then((request) { @@ -320,6 +326,7 @@ class _MainShellViewState extends State { } _shareSub?.cancel(); _pickSub?.cancel(); + _shareAccountSync?.dispose(); super.dispose(); } diff --git a/lib/providers/files_controller.dart b/lib/providers/files_controller.dart index 1ed4b8b..552ae90 100644 --- a/lib/providers/files_controller.dart +++ b/lib/providers/files_controller.dart @@ -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 /// show-hidden bool (`true` meant everything incl. hidden) so a pref /// saved before the three-way filter keeps its meaning. [key]s are diff --git a/lib/services/share_account_service.dart b/lib/services/share_account_service.dart index df2efdc..6b39003 100644 --- a/lib/services/share_account_service.dart +++ b/lib/services/share_account_service.dart @@ -1,30 +1,175 @@ +import 'dart:async'; +import 'dart:convert'; import 'package:flutter/services.dart'; +import '../providers/files_controller.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 +/// One account as the iOS Share Extension needs it - plain data, so the +/// payload builder is testable without a session. +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). -/// 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. +/// Android has no equivalent (its share intent opens the app itself), so there +/// the channel simply doesn't exist and everything here is a no-op. class ShareAccountService { static const _channel = MethodChannel('dev.ayushya.noo/share_account'); - /// Publishes the active account (call whenever one becomes active). - static Future publish(SessionController session) async { - final authHeader = session.service?.authHeaders['Authorization']; - if (authHeader == null || session.serverUrl.isEmpty) return; - final host = Uri.tryParse(session.serverUrl)?.host ?? session.serverUrl; - await invokeIfAvailable(_channel, 'setAccount', { - 'serverUrl': session.serverUrl, - 'username': session.username, - 'authHeader': authHeader, - 'displayName': '${session.username}@$host', + static String basicAuth(String username, String password) => + 'Basic ${base64Encode(utf8.encode('$username:$password'))}'; + + /// The JSON `SharedAccounts` (`ios/Shared/SharedAccount.swift`) decodes. + static String buildPayload({ + required List accounts, + required String? activeId, + required bool loginLockEnabled, + required bool lockAccountSwitching, + 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 - /// uploads to an account that's no longer signed in. + /// Publishes every saved account that can actually upload (has a stored + /// 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 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 = []; + 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 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 _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. + } + }); + } +} diff --git a/test/services/share_account_service_test.dart b/test/services/share_account_service_test.dart new file mode 100644 index 0000000..813306e --- /dev/null +++ b/test/services/share_account_service_test.dart @@ -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 build({ + List 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>(); + 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); + }); +}