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:
co-authored by
Claude Sonnet 5.5
parent
22a6602f08
commit
02d612bfec
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ?? "")
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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', () {
|
||||||
|
|||||||
Reference in New Issue
Block a user