diff --git a/.claude/context/server.md b/.claude/context/server.md index 74f0e99..df64637 100644 --- a/.claude/context/server.md +++ b/.claude/context/server.md @@ -908,19 +908,32 @@ Reminders/Notes - the destination is picked *inside* the sheet: `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 + settings, or the active account's hidden-files filter / storage scope; + 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's "lock hidden files" is on, - the sheet asks for Face ID/passcode first (`DeviceAuth`, the + - **Toggles**: the folder list has two menu chips, **Hidden** (hide / only + hidden / all) and **Storage** (cloud only / only external / all + + external), each starting at the chosen account's own app setting + (`hiddenFilter`, `storageScope` published per account) and changeable for + this share only - nothing is written back. The list is fetched once per + folder (`DavClient.listFolders` returns everything, hidden and external + included, with `nc:mount-type` requested) and filtered in memory + (`ShareModel.visibleFolders`), so flipping a toggle just returns to the + top. Hidden uses the app's rule (`HiddenFilter`): a folder is hidden when + it *or any ancestor* starts with a dot. External storage: only a mount's + *root* has `mount-type=external`, so `ShareModel` remembers the roots it + has seen and treats anything under one as external too (the app's own + filter doesn't, which empties its list inside a mount). "All" lists + external folders in their own "External storage" group. + - **Hidden unlock**: turning hidden folders *on* (from `hide`), or opening + the sheet with the account's filter already not `hide`, asks for + Face ID/passcode when "lock hidden files" is on (`DeviceAuth`, the `.deviceOwnerAuthentication` policy - biometrics with passcode fallback, - like `local_auth` with `biometricOnly: false`); cancelling falls back to - hiding them, with a note. + like `local_auth` with `biometricOnly: false`); cancelling keeps them + hidden, with a note. Switching `only` <-> `all`, or back to `hide`, never + asks. - **Account switching**: choosing any account other than the app's active one asks for the same unlock when "lock account switching" is on. One successful unlock covers the rest of that sheet. - Opening the sheet itself is not gated - only these two actions are diff --git a/ios/Runner/Native/NativeServices.swift b/ios/Runner/Native/NativeServices.swift index 954d235..8a770cd 100644 --- a/ios/Runner/Native/NativeServices.swift +++ b/ios/Runner/Native/NativeServices.swift @@ -95,6 +95,7 @@ enum NativeServices { let accounts = try? JSONDecoder().decode(SharedAccounts.self, from: data) else { throw TransferError(message: "Bad accounts payload.") } try SharedAccountStore.save(accounts) + NSLog("[ShareAccounts] saved %d account(s), active=%@", accounts.accounts.count, accounts.activeId ?? "none") } case "clearAccount": SharedAccountStore.clear() diff --git a/ios/RunnerTests/RunnerTests.swift b/ios/RunnerTests/RunnerTests.swift index f390cdd..f324061 100644 --- a/ios/RunnerTests/RunnerTests.swift +++ b/ios/RunnerTests/RunnerTests.swift @@ -247,4 +247,44 @@ class RunnerTests: XCTestCase { XCTAssertEqual(TransferNotifications.files(1), "1 file") XCTAssertEqual(TransferNotifications.files(3), "3 files") } + + // MARK: - External storage + tolerant decoding + + func testFoldersFlagExternalMountsAndStorageFilterAppliesToThem() { + let xml = Data( + """ + + /remote.php/dav/files/a/ + + /remote.php/dav/files/a/NAS/ + external + + /remote.php/dav/files/a/Docs/ + + /remote.php/dav/files/a/Shared/ + shared + + + """.utf8) + let folders = DavFolderParser.folders(from: xml, excluding: "/") + XCTAssertEqual(folders.map(\.name), ["Docs", "NAS", "Shared"]) + XCTAssertEqual(folders.map(\.isExternal), [false, true, false], "only mount-type=external counts") + + XCTAssertTrue(StorageFilter.cloud.shows(isExternal: false)) + XCTAssertFalse(StorageFilter.cloud.shows(isExternal: true)) + XCTAssertTrue(StorageFilter.external.shows(isExternal: true)) + XCTAssertFalse(StorageFilter.external.shows(isExternal: false)) + XCTAssertTrue(StorageFilter.all.shows(isExternal: true) && StorageFilter.all.shows(isExternal: false)) + XCTAssertEqual(StorageFilter(raw: "bogus"), .cloud) + } + + func testSharedAccountDecodesDataWrittenBeforeNewFieldsExisted() throws { + let old = Data( + """ + {"id":"a","serverUrl":"https://x","username":"u","authHeader":"Basic x","displayName":"u@x"} + """.utf8) + let decoded = try JSONDecoder().decode(SharedAccount.self, from: old) + XCTAssertEqual(decoded.hiddenFilter, "hide") + XCTAssertEqual(decoded.storageScope, "cloud") + } } diff --git a/ios/ShareExtension/SharePickerView.swift b/ios/ShareExtension/SharePickerView.swift index 8ab7de6..9b963da 100644 --- a/ios/ShareExtension/SharePickerView.swift +++ b/ios/ShareExtension/SharePickerView.swift @@ -15,7 +15,6 @@ final class ShareModel: ObservableObject { @Published var stage: Stage = .preparing @Published var items: [SharedItem] = [] @Published var path = "/" - @Published var folders: [DavFolder] = [] @Published var isLoadingFolders = false @Published var folderError: String? /// A short explanation shown under the list - e.g. hidden folders stayed @@ -23,11 +22,21 @@ final class ShareModel: ObservableObject { @Published var notice: String? @Published private(set) var selected: SharedAccount? + /// The sheet's two toggles. They start at the app's own setting for the + /// chosen account and can be changed for this share only. + @Published private(set) var hiddenFilter: HiddenFilter = .hide + @Published private(set) var storageFilter: StorageFilter = .cloud + + /// Every child folder of [path], unfiltered; the toggles filter it live. + @Published private var allFolders: [DavFolder] = [] + /// Mount points of external storage seen so far. Only the mount's root is + /// flagged by the server, so anything under one of these is external too. + private var externalRoots: Set = [] + 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 = {} @@ -50,6 +59,23 @@ final class ShareModel: ObservableObject { items.count == 1 ? "1 file" : "\(items.count) files" } + // MARK: - Folders, filtered by the toggles + + private func isExternal(_ folder: DavFolder) -> Bool { + folder.isExternal + || externalRoots.contains { folder.path == $0 || folder.path.hasPrefix($0 + "/") } + } + + private var visibleFolders: [DavFolder] { + allFolders.filter { hiddenFilter.shows(path: $0.path) && storageFilter.shows(isExternal: isExternal($0)) } + } + + /// Internal folders - and, for "All", the external ones are listed apart. + var internalFolders: [DavFolder] { visibleFolders.filter { !isExternal($0) } } + var externalFolders: [DavFolder] { visibleFolders.filter { isExternal($0) } } + + // MARK: - Accounts + /// With several accounts the list comes first; with one it goes straight /// to its folders. func start() async { @@ -57,6 +83,7 @@ final class ShareModel: ObservableObject { stage = .noAccount return } + NSLog("[ShareExtension] %d account(s), active=%@", shared.accounts.count, shared.active?.id ?? "none") if shared.accounts.count > 1 { stage = .chooseAccount } else { @@ -71,8 +98,8 @@ final class ShareModel: ObservableObject { /// 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. + /// The toggles start at that account's own app settings; hidden folders + /// are behind the app's "lock hidden files" unlock. func select(_ account: SharedAccount) async { guard let shared else { return } notice = nil @@ -83,11 +110,14 @@ final class ShareModel: ObservableObject { return } } + NSLog("[ShareExtension] selected %@ (hidden=%@ storage=%@)", account.id, account.hiddenFilter, account.storageScope) selected = account - hidden = HiddenFilter(raw: account.hiddenFilter) - if hidden != .hide, shared.needsUnlockForHidden { + externalRoots = [] + storageFilter = StorageFilter(raw: account.storageScope) + hiddenFilter = HiddenFilter(raw: account.hiddenFilter) + if hiddenFilter != .hide, shared.needsUnlockForHidden { if await !unlock("Unlock to show hidden folders") { - hidden = .hide + hiddenFilter = .hide notice = "Hidden folders are locked, so they're not shown." } } @@ -95,6 +125,31 @@ final class ShareModel: ObservableObject { await open("/") } + // MARK: - Toggles + + /// Turning hidden folders *on* is what the app locks; going back to + /// hiding them never exposes anything, and neither does switching + /// between the two revealing modes. + func setHiddenFilter(_ filter: HiddenFilter) async { + guard filter != hiddenFilter else { return } + if hiddenFilter == .hide, shared?.needsUnlockForHidden == true { + guard await unlock("Unlock to show hidden folders") else { + notice = "Hidden folders are locked, so they're not shown." + return + } + } + notice = nil + hiddenFilter = filter + await open("/") + } + + func setStorageFilter(_ filter: StorageFilter) async { + guard filter != storageFilter else { return } + notice = nil + storageFilter = filter + await open("/") + } + private func unlock(_ reason: String) async -> Bool { if unlocked { return true } let ok = await DeviceAuth.authenticate(reason: reason) @@ -102,6 +157,10 @@ final class ShareModel: ObservableObject { return ok } + // MARK: - Browsing + + /// A toggle can leave you inside a folder it now hides, so changing one + /// always returns to the top - same as [open] with "/". func open(_ path: String) async { guard let account = selected else { return } self.path = path @@ -109,9 +168,10 @@ final class ShareModel: ObservableObject { folderError = nil defer { isLoadingFolders = false } do { - folders = try await DavClient.listFolders(account: account, path: path, hidden: hidden) + allFolders = try await DavClient.listFolders(account: account, path: path) + externalRoots.formUnion(allFolders.filter(\.isExternal).map(\.path)) } catch { - folders = [] + allFolders = [] folderError = error.localizedDescription } } @@ -124,6 +184,7 @@ final class ShareModel: ObservableObject { struct SharePickerView: View { @ObservedObject var model: ShareModel + @State private var externalExpanded = true var body: some View { VStack(spacing: 0) { @@ -203,12 +264,12 @@ struct SharePickerView: View { .listStyle(.insetGrouped) } + // MARK: Folders + private var folderList: some View { List { - Section( - header: accountHeader, - footer: model.notice.map { Text($0) } - ) { + Section(header: accountHeader, footer: model.notice.map { Text($0) }) { + filterRow if model.path != "/" { Button { Task { await model.openParent() } @@ -220,15 +281,19 @@ struct SharePickerView: View { HStack { Spacer(); ProgressView(); Spacer() } } else if let error = model.folderError { Text(error).foregroundColor(.secondary) - } else if model.folders.isEmpty { + } else if model.internalFolders.isEmpty && model.externalFolders.isEmpty { Text("No folders here").foregroundColor(.secondary) } else { - ForEach(model.folders, id: \.path) { folder in - Button { - Task { await model.open(folder.path) } - } label: { - Label(folder.name, systemImage: "folder") - } + ForEach(model.internalFolders, id: \.path) { folderRow($0) } + } + } + // "All" keeps external storage in its own group, like the app's list. + if !model.isLoadingFolders, model.folderError == nil, !model.externalFolders.isEmpty { + Section { + DisclosureGroup(isExpanded: $externalExpanded) { + ForEach(model.externalFolders, id: \.path) { folderRow($0) } + } label: { + Label("External storage (\(model.externalFolders.count))", systemImage: "externaldrive") } } } @@ -236,6 +301,73 @@ struct SharePickerView: View { .listStyle(.insetGrouped) } + private func folderRow(_ folder: DavFolder) -> some View { + Button { + Task { await model.open(folder.path) } + } label: { + Label(folder.name, systemImage: folder.isExternal ? "externaldrive" : "folder") + } + } + + /// The two toggles, as menus - Hidden folders and External storage each + /// start at the app's setting and can be changed for this share. + private var filterRow: some View { + HStack(spacing: 8) { + Menu { + Picker("Hidden folders", selection: hiddenBinding) { + Text("Hide hidden folders").tag(HiddenFilter.hide) + Text("Only hidden folders").tag(HiddenFilter.only) + Text("Show all").tag(HiddenFilter.include) + } + } label: { + chip("eye", "Hidden", hiddenTitle) + } + Menu { + Picker("External storage", selection: storageBinding) { + Text("Cloud only").tag(StorageFilter.cloud) + Text("Only external storage").tag(StorageFilter.external) + Text("All + external storage").tag(StorageFilter.all) + } + } label: { + chip("externaldrive", "Storage", storageTitle) + } + Spacer() + } + .buttonStyle(.borderless) + } + + private func chip(_ icon: String, _ name: String, _ value: String) -> some View { + Label("\(name): \(value)", systemImage: icon) + .font(.footnote.weight(.medium)) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Capsule().fill(Color(.secondarySystemFill))) + } + + private var hiddenBinding: Binding { + Binding(get: { model.hiddenFilter }, set: { value in Task { await model.setHiddenFilter(value) } }) + } + + private var storageBinding: Binding { + Binding(get: { model.storageFilter }, set: { value in Task { await model.setStorageFilter(value) } }) + } + + private var hiddenTitle: String { + switch model.hiddenFilter { + case .hide: return "Hide" + case .only: return "Only" + case .include: return "All" + } + } + + private var storageTitle: String { + switch model.storageFilter { + case .cloud: return "Cloud" + case .external: return "External" + case .all: return "All" + } + } + private var accountHeader: some View { HStack { Text(model.selected?.displayName ?? "") diff --git a/ios/Shared/DavFolders.swift b/ios/Shared/DavFolders.swift index a855b6e..bb502c6 100644 --- a/ios/Shared/DavFolders.swift +++ b/ios/Shared/DavFolders.swift @@ -2,9 +2,31 @@ import Foundation /// A folder in the account's WebDAV tree. [path] is relative to the user's /// root with a leading slash ("/Documents/Tax Forms"); "/" is the root. +/// [isExternal] is true for an external-storage mount point (Nextcloud's +/// `nc:mount-type` = `external`) - only the mount's root carries it, its +/// children don't, so callers track mount roots to know what's inside one. struct DavFolder: Equatable { let name: String let path: String + var isExternal = false +} + +/// The app's Files storage scope (`StorageScope` in Dart): internal ("cloud") +/// folders only (the default), only external-storage ones, or both. +enum StorageFilter: String { + case cloud, external, all + + init(raw: String) { + self = StorageFilter(rawValue: raw) ?? .cloud + } + + func shows(isExternal: Bool) -> Bool { + switch self { + case .cloud: return !isExternal + case .external: return isExternal + case .all: return true + } + } } /// The app's Files "hidden files" filter (`HiddenFilesFilter` in Dart): @@ -37,6 +59,7 @@ enum DavFolderParser { private struct Response { var href = "" var isCollection = false + var mountType = "" } private final class Delegate: NSObject, XMLParserDelegate { @@ -54,7 +77,7 @@ enum DavFolderParser { ) { switch local(elementName) { case "response": current = Response() - case "href": text = "" + case "href", "mount-type": text = "" case "collection": current?.isCollection = true default: break } @@ -70,6 +93,7 @@ enum DavFolderParser { ) { switch local(elementName) { case "href": current?.href = text.trimmingCharacters(in: .whitespacesAndNewlines) + case "mount-type": current?.mountType = text.trimmingCharacters(in: .whitespacesAndNewlines) case "response": if let current { responses.append(current) } current = nil @@ -109,7 +133,7 @@ enum DavFolderParser { guard let path = relativePath(fromHref: response.href), path != current else { return nil } let name = (path as NSString).lastPathComponent guard !name.isEmpty, hidden.shows(path: path) else { return nil } - return DavFolder(name: name, path: path) + return DavFolder(name: name, path: path, isExternal: response.mountType == "external") } .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } } @@ -120,10 +144,12 @@ enum DavFolderParser { enum DavClient { private static let body = """ - + """ - static func listFolders(account: SharedAccount, path: String, hidden: HiddenFilter) async throws -> [DavFolder] { + /// Every child folder, hidden and external ones included - the sheet's + /// toggles filter that list in memory, so changing one doesn't refetch. + static func listFolders(account: SharedAccount, path: String) async throws -> [DavFolder] { guard let url = WebDAV.fileURL(serverUrl: account.serverUrl, username: account.username, remotePath: path) else { throw TransferError(message: "Invalid server address.") } var request = URLRequest(url: url) @@ -139,6 +165,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, hidden: hidden) + return DavFolderParser.folders(from: data, excluding: path, hidden: .include) } } diff --git a/ios/Shared/SharedAccount.swift b/ios/Shared/SharedAccount.swift index 2423ff8..aa9ff97 100644 --- a/ios/Shared/SharedAccount.swift +++ b/ios/Shared/SharedAccount.swift @@ -15,6 +15,37 @@ struct SharedAccount: Codable, Equatable, Identifiable { /// default), `only` or `include` (see `HiddenFilter`). The share sheet /// follows it instead of having a setting of its own. let hiddenFilter: String + /// The app's storage scope for this account - `cloud` (the default), + /// `external` or `all` (see `StorageFilter`). + let storageScope: String + + init( + id: String, serverUrl: String, username: String, authHeader: String, displayName: String, + hiddenFilter: String = "hide", storageScope: String = "cloud" + ) { + self.id = id + self.serverUrl = serverUrl + self.username = username + self.authHeader = authHeader + self.displayName = displayName + self.hiddenFilter = hiddenFilter + self.storageScope = storageScope + } + + /// Tolerates data written before a field existed instead of failing the + /// whole decode (which would look like "signed out" to the extension). + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + self.init( + id: try c.decode(String.self, forKey: .id), + serverUrl: try c.decode(String.self, forKey: .serverUrl), + username: try c.decode(String.self, forKey: .username), + authHeader: try c.decode(String.self, forKey: .authHeader), + displayName: try c.decode(String.self, forKey: .displayName), + hiddenFilter: try c.decodeIfPresent(String.self, forKey: .hiddenFilter) ?? "hide", + storageScope: try c.decodeIfPresent(String.self, forKey: .storageScope) ?? "cloud" + ) + } } /// Everything the app publishes for the extension: every account that can diff --git a/lib/providers/files_controller.dart b/lib/providers/files_controller.dart index 552ae90..486a1e1 100644 --- a/lib/providers/files_controller.dart +++ b/lib/providers/files_controller.dart @@ -677,6 +677,19 @@ class FilesController extends ChangeNotifier ); } + /// The storage scope saved for [accountId] (see [savedHiddenFilter]). + static StorageScope savedStorageScope( + SharedPreferences prefs, + String Function(String accountId, String baseKey) accountPrefKey, + String accountId, + ) { + final name = prefs.getString(accountPrefKey(accountId, _prefStorageScope)); + return StorageScope.values.firstWhere( + (s) => s.name == name, + orElse: () => StorageScope.cloud, + ); + } + /// 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. diff --git a/lib/services/share_account_service.dart b/lib/services/share_account_service.dart index 6b39003..fccd277 100644 --- a/lib/services/share_account_service.dart +++ b/lib/services/share_account_service.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:convert'; +import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import '../providers/files_controller.dart'; import '../providers/session_controller.dart'; @@ -16,12 +17,16 @@ class ShareAccountEntry { /// The account's Files "hidden files" filter: `hide`, `only` or `include`. final String hiddenFilter; + /// The account's Files storage scope: `cloud`, `external` or `all`. + final String storageScope; + const ShareAccountEntry({ required this.id, required this.serverUrl, required this.username, required this.password, required this.hiddenFilter, + required this.storageScope, }); } @@ -57,6 +62,7 @@ class ShareAccountService { 'displayName': '${a.username}@${Uri.tryParse(a.serverUrl)?.host ?? a.serverUrl}', 'hiddenFilter': a.hiddenFilter, + 'storageScope': a.storageScope, }, ], 'activeId': activeId, @@ -68,8 +74,8 @@ class ShareAccountService { /// 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. + /// filter and storage scope taken live from [files] - the saved prefs are + /// written asynchronously, so they can lag a change that just happened. static Future publish( SessionController session, FilesController files, @@ -82,16 +88,32 @@ class ShareAccountService { final signedOut = store.loadSignedOut(prefs); final entries = []; for (final account in session.accounts) { - if (signedOut.contains(account.id)) continue; + if (signedOut.contains(account.id)) { + debugPrint('[ShareAccounts] skipping ${account.id}: signed out'); + continue; + } final password = await store.readPassword(account.id); - if (password == null) continue; - final filter = account.id == activeId + if (password == null) { + debugPrint( + '[ShareAccounts] skipping ${account.id}: no stored password', + ); + continue; + } + final isActive = account.id == activeId; + final filter = isActive ? files.hiddenFilter : FilesController.savedHiddenFilter( prefs, store.accountPrefKey, account.id, ); + final scope = isActive + ? files.storageScope + : FilesController.savedStorageScope( + prefs, + store.accountPrefKey, + account.id, + ); entries.add( ShareAccountEntry( id: account.id, @@ -99,9 +121,15 @@ class ShareAccountService { username: account.username, password: password, hiddenFilter: filter.name, + storageScope: scope.name, ), ); } + debugPrint( + '[ShareAccounts] publishing ${entries.length} of ' + '${session.accounts.length} saved accounts, active=$activeId ' + '(${signedOut.length} signed out)', + ); if (entries.isEmpty) return clear(); await invokeIfAvailable(_channel, 'setAccounts', { @@ -152,6 +180,7 @@ class ShareAccountSync { session.lockAccountSwitching, session.lockHiddenFiles, files.hiddenFilter.name, + files.storageScope.name, ].join('|'); void _onChange() { diff --git a/test/services/share_account_service_test.dart b/test/services/share_account_service_test.dart index 813306e..1f3c70c 100644 --- a/test/services/share_account_service_test.dart +++ b/test/services/share_account_service_test.dart @@ -11,6 +11,7 @@ void main() { username: 'alice', password: 's3cret', hiddenFilter: 'include', + storageScope: 'all', ); const bob = ShareAccountEntry( id: 'nc_home_lan__bob', @@ -18,6 +19,7 @@ void main() { username: 'bob', password: 'pw', hiddenFilter: 'hide', + storageScope: 'cloud', ); Map build({ @@ -52,10 +54,12 @@ void main() { ); expect(accounts[0]['displayName'], 'alice@cloud.example.com'); expect(accounts[0]['hiddenFilter'], 'include'); + expect(accounts[0]['storageScope'], 'all'); // 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'); + expect(accounts[1]['storageScope'], 'cloud'); }); test('the active account and the lock settings are passed through', () {