Share sheet: Hidden and Storage toggles, plus diagnostics for the missing accounts

Two menu chips start at the account's app settings (hidden filter, storage
scope - now published per account), hidden-on asks for the app's unlock, and
external storage is detected via nc:mount-type incl. everything under a
mount. Adds logging for which accounts get published and why one is skipped.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Ayushya Amitabh
2026-10-06 20:15:19 -04:00
co-authored by Claude Sonnet 5.5
parent 22a6602f08
commit 02d612bfec
9 changed files with 328 additions and 39 deletions
+22 -9
View File
@@ -908,19 +908,32 @@ Reminders/Notes - the destination is picked *inside* the sheet:
`initState`) republishes - through the native `share_account` channel's `initState`) republishes - through the native `share_account` channel's
`setAccounts` - when an account becomes ready or when its change `setAccounts` - when an account becomes ready or when its change
signature moves: the account list/active account, the three lock 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 `clearAccount`. It publishes every saved account that has a stored
password and isn't signed out, each with its **own Files hidden-files password and isn't signed out, each with its **own Files hidden-files
filter** (`FilesController.savedHiddenFilter`). filter** (`FilesController.savedHiddenFilter`).
- **Hidden folders** follow that app setting - there is no share-sheet - **Toggles**: the folder list has two menu chips, **Hidden** (hide / only
setting of their own. `hide` (default) drops dot-folders, `only` lists hidden / all) and **Storage** (cloud only / only external / all +
just them, `include` lists everything; a folder is hidden when it *or external), each starting at the chosen account's own app setting
any ancestor* starts with a dot (`HiddenFilter`, same rule as the app). (`hiddenFilter`, `storageScope` published per account) and changeable for
When the filter isn't `hide` and the app's "lock hidden files" is on, this share only - nothing is written back. The list is fetched once per
the sheet asks for Face ID/passcode first (`DeviceAuth`, the 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, `.deviceOwnerAuthentication` policy - biometrics with passcode fallback,
like `local_auth` with `biometricOnly: false`); cancelling falls back to like `local_auth` with `biometricOnly: false`); cancelling keeps them
hiding them, with a note. hidden, with a note. Switching `only` <-> `all`, or back to `hide`, never
asks.
- **Account switching**: choosing any account other than the app's - **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. 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 - Opening the sheet itself is not gated - only these two actions are
+1
View File
@@ -95,6 +95,7 @@ enum NativeServices {
let accounts = try? JSONDecoder().decode(SharedAccounts.self, from: data) let accounts = try? JSONDecoder().decode(SharedAccounts.self, from: data)
else { throw TransferError(message: "Bad accounts payload.") } else { throw TransferError(message: "Bad accounts payload.") }
try SharedAccountStore.save(accounts) try SharedAccountStore.save(accounts)
NSLog("[ShareAccounts] saved %d account(s), active=%@", accounts.accounts.count, accounts.activeId ?? "none")
} }
case "clearAccount": case "clearAccount":
SharedAccountStore.clear() SharedAccountStore.clear()
+40
View File
@@ -247,4 +247,44 @@ class RunnerTests: XCTestCase {
XCTAssertEqual(TransferNotifications.files(1), "1 file") XCTAssertEqual(TransferNotifications.files(1), "1 file")
XCTAssertEqual(TransferNotifications.files(3), "3 files") XCTAssertEqual(TransferNotifications.files(3), "3 files")
} }
// MARK: - External storage + tolerant decoding
func testFoldersFlagExternalMountsAndStorageFilterAppliesToThem() {
let xml = Data(
"""
<?xml version="1.0"?><d:multistatus xmlns:d="DAV:" xmlns:nc="http://nextcloud.org/ns">
<d:response><d:href>/remote.php/dav/files/a/</d:href><d:propstat><d:prop>
<d:resourcetype><d:collection/></d:resourcetype></d:prop></d:propstat></d:response>
<d:response><d:href>/remote.php/dav/files/a/NAS/</d:href><d:propstat><d:prop>
<d:resourcetype><d:collection/></d:resourcetype><nc:mount-type>external</nc:mount-type>
</d:prop></d:propstat></d:response>
<d:response><d:href>/remote.php/dav/files/a/Docs/</d:href><d:propstat><d:prop>
<d:resourcetype><d:collection/></d:resourcetype></d:prop></d:propstat></d:response>
<d:response><d:href>/remote.php/dav/files/a/Shared/</d:href><d:propstat><d:prop>
<d:resourcetype><d:collection/></d:resourcetype><nc:mount-type>shared</nc:mount-type>
</d:prop></d:propstat></d:response>
</d:multistatus>
""".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")
}
} }
+151 -19
View File
@@ -15,7 +15,6 @@ final class ShareModel: ObservableObject {
@Published var stage: Stage = .preparing @Published var stage: Stage = .preparing
@Published var items: [SharedItem] = [] @Published var items: [SharedItem] = []
@Published var path = "/" @Published var path = "/"
@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 /// 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 var notice: String?
@Published private(set) var selected: SharedAccount? @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<String> = []
let shared: SharedAccounts? let shared: SharedAccounts?
/// One successful unlock covers every gate for the rest of this sheet. /// One successful unlock covers every gate for the rest of this sheet.
private var unlocked = false 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 = {}
@@ -50,6 +59,23 @@ final class ShareModel: ObservableObject {
items.count == 1 ? "1 file" : "\(items.count) files" 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 /// With several accounts the list comes first; with one it goes straight
/// to its folders. /// to its folders.
func start() async { func start() async {
@@ -57,6 +83,7 @@ final class ShareModel: ObservableObject {
stage = .noAccount stage = .noAccount
return return
} }
NSLog("[ShareExtension] %d account(s), active=%@", shared.accounts.count, shared.active?.id ?? "none")
if shared.accounts.count > 1 { if shared.accounts.count > 1 {
stage = .chooseAccount stage = .chooseAccount
} else { } else {
@@ -71,8 +98,8 @@ final class ShareModel: ObservableObject {
/// Uploading to an account other than the one the app is on is "switching" /// 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. /// 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 /// The toggles start at that account's own app settings; hidden folders
/// "lock hidden files" unlock. /// are behind the app's "lock hidden files" unlock.
func select(_ account: SharedAccount) async { func select(_ account: SharedAccount) async {
guard let shared else { return } guard let shared else { return }
notice = nil notice = nil
@@ -83,11 +110,14 @@ final class ShareModel: ObservableObject {
return return
} }
} }
NSLog("[ShareExtension] selected %@ (hidden=%@ storage=%@)", account.id, account.hiddenFilter, account.storageScope)
selected = account selected = account
hidden = HiddenFilter(raw: account.hiddenFilter) externalRoots = []
if hidden != .hide, shared.needsUnlockForHidden { storageFilter = StorageFilter(raw: account.storageScope)
hiddenFilter = HiddenFilter(raw: account.hiddenFilter)
if hiddenFilter != .hide, shared.needsUnlockForHidden {
if await !unlock("Unlock to show hidden folders") { if await !unlock("Unlock to show hidden folders") {
hidden = .hide hiddenFilter = .hide
notice = "Hidden folders are locked, so they're not shown." notice = "Hidden folders are locked, so they're not shown."
} }
} }
@@ -95,6 +125,31 @@ final class ShareModel: ObservableObject {
await open("/") 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 { private func unlock(_ reason: String) async -> Bool {
if unlocked { return true } if unlocked { return true }
let ok = await DeviceAuth.authenticate(reason: reason) let ok = await DeviceAuth.authenticate(reason: reason)
@@ -102,6 +157,10 @@ final class ShareModel: ObservableObject {
return ok 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 { func open(_ path: String) async {
guard let account = selected else { return } guard let account = selected else { return }
self.path = path self.path = path
@@ -109,9 +168,10 @@ final class ShareModel: ObservableObject {
folderError = nil folderError = nil
defer { isLoadingFolders = false } defer { isLoadingFolders = false }
do { 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 { } catch {
folders = [] allFolders = []
folderError = error.localizedDescription folderError = error.localizedDescription
} }
} }
@@ -124,6 +184,7 @@ final class ShareModel: ObservableObject {
struct SharePickerView: View { struct SharePickerView: View {
@ObservedObject var model: ShareModel @ObservedObject var model: ShareModel
@State private var externalExpanded = true
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
@@ -203,12 +264,12 @@ struct SharePickerView: View {
.listStyle(.insetGrouped) .listStyle(.insetGrouped)
} }
// MARK: Folders
private var folderList: some View { private var folderList: some View {
List { List {
Section( Section(header: accountHeader, footer: model.notice.map { Text($0) }) {
header: accountHeader, filterRow
footer: model.notice.map { Text($0) }
) {
if model.path != "/" { if model.path != "/" {
Button { Button {
Task { await model.openParent() } Task { await model.openParent() }
@@ -220,22 +281,93 @@ struct SharePickerView: View {
HStack { Spacer(); ProgressView(); Spacer() } HStack { Spacer(); ProgressView(); Spacer() }
} else if let error = model.folderError { } else if let error = model.folderError {
Text(error).foregroundColor(.secondary) Text(error).foregroundColor(.secondary)
} else if model.folders.isEmpty { } else if model.internalFolders.isEmpty && model.externalFolders.isEmpty {
Text("No folders here").foregroundColor(.secondary) Text("No folders here").foregroundColor(.secondary)
} else { } else {
ForEach(model.folders, id: \.path) { folder in ForEach(model.internalFolders, id: \.path) { folderRow($0) }
Button {
Task { await model.open(folder.path) }
} label: {
Label(folder.name, systemImage: "folder")
} }
} }
// "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")
}
} }
} }
} }
.listStyle(.insetGrouped) .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<HiddenFilter> {
Binding(get: { model.hiddenFilter }, set: { value in Task { await model.setHiddenFilter(value) } })
}
private var storageBinding: Binding<StorageFilter> {
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 { private var accountHeader: some View {
HStack { HStack {
Text(model.selected?.displayName ?? "") Text(model.selected?.displayName ?? "")
+31 -5
View File
@@ -2,9 +2,31 @@ import Foundation
/// A folder in the account's WebDAV tree. [path] is relative to the user's /// 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. /// 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 { struct DavFolder: Equatable {
let name: String let name: String
let path: 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): /// The app's Files "hidden files" filter (`HiddenFilesFilter` in Dart):
@@ -37,6 +59,7 @@ enum DavFolderParser {
private struct Response { private struct Response {
var href = "" var href = ""
var isCollection = false var isCollection = false
var mountType = ""
} }
private final class Delegate: NSObject, XMLParserDelegate { private final class Delegate: NSObject, XMLParserDelegate {
@@ -54,7 +77,7 @@ enum DavFolderParser {
) { ) {
switch local(elementName) { switch local(elementName) {
case "response": current = Response() case "response": current = Response()
case "href": text = "" case "href", "mount-type": text = ""
case "collection": current?.isCollection = true case "collection": current?.isCollection = true
default: break default: break
} }
@@ -70,6 +93,7 @@ enum DavFolderParser {
) { ) {
switch local(elementName) { switch local(elementName) {
case "href": current?.href = text.trimmingCharacters(in: .whitespacesAndNewlines) case "href": current?.href = text.trimmingCharacters(in: .whitespacesAndNewlines)
case "mount-type": current?.mountType = text.trimmingCharacters(in: .whitespacesAndNewlines)
case "response": case "response":
if let current { responses.append(current) } if let current { responses.append(current) }
current = nil current = nil
@@ -109,7 +133,7 @@ enum DavFolderParser {
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, hidden.shows(path: path) 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, isExternal: response.mountType == "external")
} }
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
} }
@@ -120,10 +144,12 @@ enum DavFolderParser {
enum DavClient { enum DavClient {
private static let body = """ private static let body = """
<?xml version="1.0"?> <?xml version="1.0"?>
<d:propfind xmlns:d="DAV:"><d:prop><d:resourcetype/></d:prop></d:propfind> <d:propfind xmlns:d="DAV:" xmlns:nc="http://nextcloud.org/ns"><d:prop><d:resourcetype/><nc:mount-type/></d:prop></d:propfind>
""" """
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) 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)
@@ -139,6 +165,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, hidden: hidden) return DavFolderParser.folders(from: data, excluding: path, hidden: .include)
} }
} }
+31
View File
@@ -15,6 +15,37 @@ struct SharedAccount: Codable, Equatable, Identifiable {
/// default), `only` or `include` (see `HiddenFilter`). The share sheet /// default), `only` or `include` (see `HiddenFilter`). The share sheet
/// follows it instead of having a setting of its own. /// follows it instead of having a setting of its own.
let hiddenFilter: String 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 /// Everything the app publishes for the extension: every account that can
+13
View File
@@ -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 /// The hidden-files filter saved for [accountId] - not just the active
/// account's, which is all [hiddenFilter] holds. For iOS's Share Extension, /// account's, which is all [hiddenFilter] holds. For iOS's Share Extension,
/// which follows each account's own setting. /// which follows each account's own setting.
+34 -5
View File
@@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import '../providers/files_controller.dart'; import '../providers/files_controller.dart';
import '../providers/session_controller.dart'; import '../providers/session_controller.dart';
@@ -16,12 +17,16 @@ class ShareAccountEntry {
/// The account's Files "hidden files" filter: `hide`, `only` or `include`. /// The account's Files "hidden files" filter: `hide`, `only` or `include`.
final String hiddenFilter; final String hiddenFilter;
/// The account's Files storage scope: `cloud`, `external` or `all`.
final String storageScope;
const ShareAccountEntry({ const ShareAccountEntry({
required this.id, required this.id,
required this.serverUrl, required this.serverUrl,
required this.username, required this.username,
required this.password, required this.password,
required this.hiddenFilter, required this.hiddenFilter,
required this.storageScope,
}); });
} }
@@ -57,6 +62,7 @@ class ShareAccountService {
'displayName': 'displayName':
'${a.username}@${Uri.tryParse(a.serverUrl)?.host ?? a.serverUrl}', '${a.username}@${Uri.tryParse(a.serverUrl)?.host ?? a.serverUrl}',
'hiddenFilter': a.hiddenFilter, 'hiddenFilter': a.hiddenFilter,
'storageScope': a.storageScope,
}, },
], ],
'activeId': activeId, 'activeId': activeId,
@@ -68,8 +74,8 @@ class ShareAccountService {
/// Publishes every saved account that can actually upload (has a stored /// Publishes every saved account that can actually upload (has a stored
/// password and isn't signed out), with the active one's hidden-files /// password and isn't signed out), with the active one's hidden-files
/// filter taken live from [files] - its saved pref is written /// filter and storage scope taken live from [files] - the saved prefs are
/// asynchronously, so it can lag a change that just happened. /// written asynchronously, so they can lag a change that just happened.
static Future<void> publish( static Future<void> publish(
SessionController session, SessionController session,
FilesController files, FilesController files,
@@ -82,16 +88,32 @@ class ShareAccountService {
final signedOut = store.loadSignedOut(prefs); final signedOut = store.loadSignedOut(prefs);
final entries = <ShareAccountEntry>[]; final entries = <ShareAccountEntry>[];
for (final account in session.accounts) { 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); final password = await store.readPassword(account.id);
if (password == null) continue; if (password == null) {
final filter = account.id == activeId debugPrint(
'[ShareAccounts] skipping ${account.id}: no stored password',
);
continue;
}
final isActive = account.id == activeId;
final filter = isActive
? files.hiddenFilter ? files.hiddenFilter
: FilesController.savedHiddenFilter( : FilesController.savedHiddenFilter(
prefs, prefs,
store.accountPrefKey, store.accountPrefKey,
account.id, account.id,
); );
final scope = isActive
? files.storageScope
: FilesController.savedStorageScope(
prefs,
store.accountPrefKey,
account.id,
);
entries.add( entries.add(
ShareAccountEntry( ShareAccountEntry(
id: account.id, id: account.id,
@@ -99,9 +121,15 @@ class ShareAccountService {
username: account.username, username: account.username,
password: password, password: password,
hiddenFilter: filter.name, 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(); if (entries.isEmpty) return clear();
await invokeIfAvailable(_channel, 'setAccounts', { await invokeIfAvailable(_channel, 'setAccounts', {
@@ -152,6 +180,7 @@ class ShareAccountSync {
session.lockAccountSwitching, session.lockAccountSwitching,
session.lockHiddenFiles, session.lockHiddenFiles,
files.hiddenFilter.name, files.hiddenFilter.name,
files.storageScope.name,
].join('|'); ].join('|');
void _onChange() { void _onChange() {
@@ -11,6 +11,7 @@ void main() {
username: 'alice', username: 'alice',
password: 's3cret', password: 's3cret',
hiddenFilter: 'include', hiddenFilter: 'include',
storageScope: 'all',
); );
const bob = ShareAccountEntry( const bob = ShareAccountEntry(
id: 'nc_home_lan__bob', id: 'nc_home_lan__bob',
@@ -18,6 +19,7 @@ void main() {
username: 'bob', username: 'bob',
password: 'pw', password: 'pw',
hiddenFilter: 'hide', hiddenFilter: 'hide',
storageScope: 'cloud',
); );
Map<String, dynamic> build({ Map<String, dynamic> build({
@@ -52,10 +54,12 @@ void main() {
); );
expect(accounts[0]['displayName'], 'alice@cloud.example.com'); expect(accounts[0]['displayName'], 'alice@cloud.example.com');
expect(accounts[0]['hiddenFilter'], 'include'); expect(accounts[0]['hiddenFilter'], 'include');
expect(accounts[0]['storageScope'], 'all');
// The display name uses the host only - not the port or sub-path. // The display name uses the host only - not the port or sub-path.
expect(accounts[1]['displayName'], 'bob@nc.home.lan'); expect(accounts[1]['displayName'], 'bob@nc.home.lan');
expect(accounts[1]['serverUrl'], 'https://nc.home.lan:8443/nextcloud'); expect(accounts[1]['serverUrl'], 'https://nc.home.lan:8443/nextcloud');
expect(accounts[1]['hiddenFilter'], 'hide'); expect(accounts[1]['hiddenFilter'], 'hide');
expect(accounts[1]['storageScope'], 'cloud');
}); });
test('the active account and the lock settings are passed through', () { test('the active account and the lock settings are passed through', () {