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
+1
View File
@@ -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()
+40
View File
@@ -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(
"""
<?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")
}
}
+152 -20
View File
@@ -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<String> = []
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<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 {
HStack {
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
/// 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 = """
<?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)
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)
}
}
+31
View File
@@ -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