Share Extension: account picker first, hidden folders follow the app's filter + unlock

The app now publishes every uploadable account, the active one, the three
lock settings and each account's own hidden-files filter (ShareAccountSync).
The sheet lists accounts first when there are several, asks for unlock to use
a non-active account (login lock + account-switching lock), and shows hidden
folders per the account's app setting behind the hidden-files unlock.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Ayushya Amitabh
2026-10-06 19:59:29 -04:00
co-authored by Claude Sonnet 5.5
parent 0cda4a1241
commit a37524e959
14 changed files with 581 additions and 87 deletions
+29 -8
View File
@@ -885,13 +885,34 @@ Reminders/Notes - the destination is picked *inside* the sheet:
loadFileRepresentation`, public.item) into the App Group container
(`group.dev.ayushya.noo`, `SharedInbox/<batch>/`) and hosts the SwiftUI
`SharePickerView` (`ShareModel` holds its state).
2. It reads the signed-in account from the shared Keychain
(`SharedAccountStore`) and lists folders over WebDAV (`DavClient`
`PROPFIND` Depth 1, parsed by `DavFolderParser`; dot-folders hidden).
The Dart side keeps that account current: `ShareAccountService.publish`
(`lib/services/share_account_service.dart`) runs on every account-ready
event and `clear` on account-cleared, through the native `share_account`
channel - both wired where `SessionController` is created in `main.dart`.
2. It reads the accounts from the shared Keychain (`SharedAccountStore` ->
`SharedAccounts`) and lists folders over WebDAV (`DavClient` `PROPFIND`
Depth 1, parsed by `DavFolderParser`). With **several accounts an account
list comes first** (the app's active one is marked "Active"; "Change
account" returns to it); with one it goes straight to its folders. The
Dart side keeps the keychain data current: `ShareAccountSync`
(`lib/services/share_account_service.dart`, started from the shell's
`initState`) republishes - through the native `share_account` channel's
`setAccounts` - when an account becomes ready or when its change
signature moves: the account list/active account, the three lock
settings, or the active account's hidden-files filter; sign-out calls
`clearAccount`. It publishes every saved account that has a stored
password and isn't signed out, each with its **own Files hidden-files
filter** (`FilesController.savedHiddenFilter`).
- **Hidden folders** follow that app setting - there is no share-sheet
setting of their own. `hide` (default) drops dot-folders, `only` lists
just them, `include` lists everything; a folder is hidden when it *or
any ancestor* starts with a dot (`HiddenFilter`, same rule as the app).
When the filter isn't `hide` and the app has login lock + "lock hidden
files" on, the sheet asks for Face ID/passcode first (`DeviceAuth`, the
`.deviceOwnerAuthentication` policy - biometrics with passcode fallback,
like `local_auth` with `biometricOnly: false`); cancelling falls back to
hiding them, with a note.
- **Account switching**: choosing any account other than the app's
active one asks for the same unlock when login lock + "lock account
switching" are on. One successful unlock covers the rest of that sheet.
- Opening the sheet itself is not gated - only these two actions are
(as in the app, where login lock guards launch and these toggles).
3. **Upload** calls `ShareUpload.enqueue`: one `PUT` per file on a *background*
`URLSession` (`dev.ayushya.noo.transfers.share`, with
`sharedContainerIdentifier`) that outlives the extension, and posts
@@ -919,7 +940,7 @@ Reminders/Notes - the destination is picked *inside* the sheet:
The keychain group is `$(AppIdentifierPrefix)dev.ayushya.noo.shared`
(`keychain-access-groups` in both `.entitlements`); both Info.plists also
carry it as `NooKeychainAccessGroup`, which `SharedAccountStore` reads, so the
two processes always agree on the team-prefixed id. The account is stored as
two processes always agree on the team-prefixed id. Everything is stored as
JSON in one generic-password item (`kSecAttrAccessibleAfterFirstUnlock`).
`ios/Shared/` (compiled into both targets): `SharedInbox`, `SharedAccount`,
+4
View File
@@ -26,6 +26,7 @@
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; };
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
852C0C8EBA96CB6389DF6917 /* SharedAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F2C956C98F58A97B5595307 /* SharedAccount.swift */; };
8C73B4CB445AEF383B7640E1 /* DeviceAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97ACCF43E1EC13AA2538E42C /* DeviceAuth.swift */; };
91C1658DC7467CACB2AAC7FF /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0AE6F681A3100A461B4A5318 /* Foundation.framework */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
@@ -107,6 +108,7 @@
901C0CDB4FB819302E2A7563 /* NativeServices.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NativeServices.swift; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
97ACCF43E1EC13AA2538E42C /* DeviceAuth.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DeviceAuth.swift; sourceTree = "<group>"; };
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
@@ -158,6 +160,7 @@
49213989F0FA1B83B0B8A523 /* Info.plist */,
65F57A1EB0C62774E561860D /* ShareExtension.entitlements */,
5BE7B8C31FF78E212BD71DFC /* SharePickerView.swift */,
97ACCF43E1EC13AA2538E42C /* DeviceAuth.swift */,
);
name = ShareExtension;
path = ShareExtension;
@@ -438,6 +441,7 @@
3C1A0DAA5F9B37FEA4C604BE /* DavFolders.swift in Sources */,
66AFC246E251554A80B2476F /* SharePickerView.swift in Sources */,
A16680889B3A9D8672FA29C1 /* TransferNotifications.swift in Sources */,
8C73B4CB445AEF383B7640E1 /* DeviceAuth.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
+10 -11
View File
@@ -80,22 +80,21 @@ enum NativeServices {
// MARK: - Account for the Share Extension
/// `share_account`: Dart tells native which account is active so the Share
/// Extension - a separate process with no Flutter engine - can list folders
/// and upload as it. Stored in the shared Keychain group.
/// `share_account`: Dart hands native every account that can upload, which
/// one is active, and the app-lock settings, so the Share Extension - a
/// separate process with no Flutter engine - can list folders and upload as
/// them. Stored in the shared Keychain group.
private static func registerShareAccount(messenger: FlutterBinaryMessenger) {
FlutterMethodChannel(name: "dev.ayushya.noo/share_account", binaryMessenger: messenger)
.setMethodCallHandler { call, result in
switch call.method {
case "setAccount":
case "setAccounts":
handle(call, result) { args in
try SharedAccountStore.save(
SharedAccount(
serverUrl: try string(args, "serverUrl"),
username: try string(args, "username"),
authHeader: try string(args, "authHeader"),
displayName: (args["displayName"] as? String) ?? ""
))
guard let json = args["accounts"] as? String,
let data = json.data(using: .utf8),
let accounts = try? JSONDecoder().decode(SharedAccounts.self, from: data)
else { throw TransferError(message: "Bad accounts payload.") }
try SharedAccountStore.save(accounts)
}
case "clearAccount":
SharedAccountStore.clear()
+57 -9
View File
@@ -124,8 +124,24 @@ class RunnerTests: XCTestCase {
DavFolderParser.folders(from: xml, excluding: "/Docs"),
[DavFolder(name: "Tax Forms", path: "/Docs/Tax Forms"), DavFolder(name: "zeta", path: "/Docs/zeta")])
XCTAssertEqual(
DavFolderParser.folders(from: xml, excluding: "/Docs", includeHidden: true).map(\.name),
DavFolderParser.folders(from: xml, excluding: "/Docs", hidden: .include).map(\.name),
[".git", "Tax Forms", "zeta"])
XCTAssertEqual(
DavFolderParser.folders(from: xml, excluding: "/Docs", hidden: .only).map(\.name),
[".git"])
}
func testHiddenFilterTreatsAnyDotSegmentAsHidden() {
XCTAssertTrue(HiddenFilter.isHidden(path: "/.cache"))
XCTAssertTrue(HiddenFilter.isHidden(path: "/.cache/inside/deeper"), "children of a hidden folder are hidden")
XCTAssertFalse(HiddenFilter.isHidden(path: "/Docs/Tax Forms"))
XCTAssertTrue(HiddenFilter.hide.shows(path: "/Docs"))
XCTAssertFalse(HiddenFilter.hide.shows(path: "/.git"))
XCTAssertTrue(HiddenFilter.only.shows(path: "/.git/hooks"))
XCTAssertFalse(HiddenFilter.only.shows(path: "/Docs"))
XCTAssertTrue(HiddenFilter.include.shows(path: "/Docs"))
XCTAssertEqual(HiddenFilter(raw: "include"), .include)
XCTAssertEqual(HiddenFilter(raw: "nonsense"), .hide, "unknown values fall back to hiding")
}
func testFoldersAtRootAndUnderServerSubPath() {
@@ -150,19 +166,26 @@ class RunnerTests: XCTestCase {
// MARK: - Shared account (Keychain group shared with the extension)
func testSharedAccountRoundTripsThroughTheKeychain() throws {
let account = SharedAccount(
serverUrl: "https://cloud.example.com", username: "alice",
authHeader: "Basic YWxpY2U6c2VjcmV0", displayName: "alice@cloud.example.com")
private func account(_ id: String, hidden: String = "hide") -> SharedAccount {
SharedAccount(
id: id, serverUrl: "https://\(id).example.com", username: id,
authHeader: "Basic \(id)", displayName: "\(id)@\(id).example.com", hiddenFilter: hidden)
}
func testSharedAccountsRoundTripThroughTheKeychain() throws {
let accounts = SharedAccounts(
accounts: [account("alice", hidden: "include"), account("bob")], activeId: "bob",
loginLockEnabled: true, lockAccountSwitching: true, lockHiddenFiles: false)
addTeardownBlock { SharedAccountStore.clear() }
SharedAccountStore.clear()
XCTAssertNil(SharedAccountStore.load())
try SharedAccountStore.save(account)
XCTAssertEqual(SharedAccountStore.load(), account)
try SharedAccountStore.save(accounts)
XCTAssertEqual(SharedAccountStore.load(), accounts)
let replacement = SharedAccount(
serverUrl: "https://other.example.org", username: "bob", authHeader: "Basic Ym9i", displayName: "bob")
let replacement = SharedAccounts(
accounts: [account("carol")], activeId: "carol",
loginLockEnabled: false, lockAccountSwitching: false, lockHiddenFiles: false)
try SharedAccountStore.save(replacement)
XCTAssertEqual(SharedAccountStore.load(), replacement, "saving replaces, never duplicates")
@@ -170,6 +193,31 @@ class RunnerTests: XCTestCase {
XCTAssertNil(SharedAccountStore.load())
}
func testSharedAccountsActiveFallsBackToTheFirstAccount() {
var shared = SharedAccounts(
accounts: [account("alice"), account("bob")], activeId: "bob",
loginLockEnabled: false, lockAccountSwitching: false, lockHiddenFiles: false)
XCTAssertEqual(shared.active?.id, "bob")
shared.activeId = "gone"
XCTAssertEqual(shared.active?.id, "alice")
shared.activeId = nil
XCTAssertEqual(shared.active?.id, "alice")
}
func testUnlockRulesNeedTheMasterLockAndTheirOwnToggle() {
func rules(master: Bool, switching: Bool, hidden: Bool) -> (Bool, Bool) {
let shared = SharedAccounts(
accounts: [account("a")], activeId: "a",
loginLockEnabled: master, lockAccountSwitching: switching, lockHiddenFiles: hidden)
return (shared.needsUnlockToSwitchAccount, shared.needsUnlockForHidden)
}
XCTAssertTrue(rules(master: true, switching: true, hidden: false) == (true, false))
XCTAssertTrue(rules(master: true, switching: false, hidden: true) == (false, true))
XCTAssertTrue(
rules(master: false, switching: true, hidden: true) == (false, false),
"the sub-toggles mean nothing while the login lock is off, as in the app")
}
// MARK: - TransferBatchStore
func testBatchSummaryFiresOnlyOnTheLastFile() {
+19
View File
@@ -0,0 +1,19 @@
import LocalAuthentication
/// Face ID / Touch ID with the device passcode as the fallback - the same
/// policy the app's lock uses (`local_auth` with `biometricOnly: false`), so
/// the share sheet asks for exactly what the app would.
enum DeviceAuth {
/// True only if the user actually authenticated; a cancel, a failure, or a
/// device with nothing to authenticate with all count as "not unlocked".
static func authenticate(reason: String) async -> Bool {
let context = LAContext()
var error: NSError?
guard context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) else { return false }
return await withCheckedContinuation { continuation in
context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: reason) { success, _ in
continuation.resume(returning: success)
}
}
}
}
+2
View File
@@ -20,6 +20,8 @@
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>NSFaceIDUsageDescription</key>
<string>Noo uses Face ID to unlock hidden folders and accounts when you share a file.</string>
<key>NooKeychainAccessGroup</key>
<string>$(AppIdentifierPrefix)dev.ayushya.noo.shared</string>
<key>NSExtension</key>
+119 -9
View File
@@ -1,10 +1,11 @@
import SwiftUI
/// State behind the share sheet's folder picker.
/// State behind the share sheet's account and folder pickers.
@MainActor
final class ShareModel: ObservableObject {
enum Stage: Equatable {
case preparing
case chooseAccount
case picking
case uploading
case noAccount
@@ -17,18 +18,30 @@ final class ShareModel: ObservableObject {
@Published var folders: [DavFolder] = []
@Published var isLoadingFolders = false
@Published var folderError: String?
/// A short explanation shown under the list - e.g. hidden folders stayed
/// hidden because the unlock was cancelled.
@Published var notice: String?
@Published private(set) var selected: SharedAccount?
let account: SharedAccount?
let shared: SharedAccounts?
/// One successful unlock covers every gate for the rest of this sheet.
private var unlocked = false
private var hidden: HiddenFilter = .hide
/// Set by the view controller: what each button actually does.
var onUpload: () -> Void = {}
var onSaveForLater: () -> Void = {}
var onCancel: () -> Void = {}
init(account: SharedAccount?) {
self.account = account
init(shared: SharedAccounts?) {
self.shared = shared
}
var accounts: [SharedAccount] { shared?.accounts ?? [] }
var canChangeAccount: Bool { accounts.count > 1 }
var activeAccountId: String? { shared?.active?.id }
var currentFolderName: String {
path == "/" ? "All files" : (path as NSString).lastPathComponent
}
@@ -37,14 +50,66 @@ final class ShareModel: ObservableObject {
items.count == 1 ? "1 file" : "\(items.count) files"
}
/// With several accounts the list comes first; with one it goes straight
/// to its folders.
func start() async {
guard let shared, let first = shared.accounts.first else {
stage = .noAccount
return
}
if shared.accounts.count > 1 {
stage = .chooseAccount
} else {
await select(first)
}
}
func showAccounts() {
notice = nil
stage = .chooseAccount
}
/// Uploading to an account other than the one the app is on is "switching"
/// in the app's terms, so it asks for the same unlock when the app does.
/// Hidden folders follow the account's own app setting, behind the app's
/// "lock hidden files" unlock.
func select(_ account: SharedAccount) async {
guard let shared else { return }
notice = nil
if account.id != shared.active?.id, shared.needsUnlockToSwitchAccount {
guard await unlock("Unlock to upload to \(account.displayName)") else {
notice = "Unlock to upload to a different account."
stage = .chooseAccount
return
}
}
selected = account
hidden = HiddenFilter(raw: account.hiddenFilter)
if hidden != .hide, shared.needsUnlockForHidden {
if await !unlock("Unlock to show hidden folders") {
hidden = .hide
notice = "Hidden folders are locked, so they're not shown."
}
}
stage = .picking
await open("/")
}
private func unlock(_ reason: String) async -> Bool {
if unlocked { return true }
let ok = await DeviceAuth.authenticate(reason: reason)
if ok { unlocked = true }
return ok
}
func open(_ path: String) async {
guard let account else { return }
guard let account = selected else { return }
self.path = path
isLoadingFolders = true
folderError = nil
defer { isLoadingFolders = false }
do {
folders = try await DavClient.listFolders(account: account, path: path)
folders = try await DavClient.listFolders(account: account, path: path, hidden: hidden)
} catch {
folders = []
folderError = error.localizedDescription
@@ -107,19 +172,48 @@ struct SharePickerView: View {
centered {
Text(message).multilineTextAlignment(.center).foregroundColor(.secondary).padding()
}
case .chooseAccount:
accountList
case .picking:
folderList
}
}
private var accountList: some View {
List {
Section(
header: Text("Upload \(model.fileCountText) to which account?"),
footer: model.notice.map { Text($0) }
) {
ForEach(model.accounts) { account in
Button {
Task { await model.select(account) }
} label: {
HStack {
Label(account.displayName, systemImage: "person.crop.circle")
Spacer()
if account.id == model.activeAccountId {
Text("Active").font(.caption).foregroundColor(.secondary)
}
}
}
}
}
}
.listStyle(.insetGrouped)
}
private var folderList: some View {
List {
Section(header: Text(model.account?.displayName ?? "")) {
Section(
header: accountHeader,
footer: model.notice.map { Text($0) }
) {
if model.path != "/" {
Button {
Task { await model.openParent() }
} label: {
Label("Up to \((model.path as NSString).deletingLastPathComponent == "/" ? "All files" : ((model.path as NSString).deletingLastPathComponent as NSString).lastPathComponent)", systemImage: "arrow.turn.left.up")
Label("Up to \(parentName)", systemImage: "arrow.turn.left.up")
}
}
if model.isLoadingFolders {
@@ -142,6 +236,22 @@ struct SharePickerView: View {
.listStyle(.insetGrouped)
}
private var accountHeader: some View {
HStack {
Text(model.selected?.displayName ?? "")
Spacer()
if model.canChangeAccount {
Button("Change account") { model.showAccounts() }
.font(.caption)
}
}
}
private var parentName: String {
let parent = (model.path as NSString).deletingLastPathComponent
return parent.isEmpty || parent == "/" ? "All files" : (parent as NSString).lastPathComponent
}
private var footer: some View {
VStack(spacing: 8) {
if model.stage == .picking {
@@ -154,7 +264,7 @@ struct SharePickerView: View {
.buttonStyle(.borderedProminent)
.disabled(model.isLoadingFolders)
}
if model.stage == .picking || model.stage == .noAccount {
if model.stage == .picking || model.stage == .chooseAccount || model.stage == .noAccount {
Button("Choose a folder later in Noo", action: model.onSaveForLater)
.font(.subheadline)
}
+7 -10
View File
@@ -8,8 +8,10 @@ import UserNotifications
/// shows a folder picker, then starts a background upload and closes:
///
/// 1. Copies whatever was shared into the App Group (`SharedInbox`).
/// 2. Reads the signed-in account the app left in the shared Keychain
/// (`SharedAccountStore`) and lists folders over WebDAV (`DavClient`).
/// 2. Reads the accounts the app left in the shared Keychain
/// (`SharedAccountStore`) - asking which one first if there are several -
/// and lists folders over WebDAV (`DavClient`), honouring the app's
/// hidden-files filter and its unlock settings (`ShareModel`).
/// 3. "Upload" queues the files on a background session that outlives this
/// process (`ShareUpload`); the app is relaunched to finish and notify.
///
@@ -22,7 +24,7 @@ final class ShareViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
model = ShareModel(account: SharedAccountStore.load())
model = ShareModel(shared: SharedAccountStore.load())
model.onUpload = { [weak self] in self?.upload() }
model.onSaveForLater = { [weak self] in self?.saveForLater() }
model.onCancel = { [weak self] in self?.cancel() }
@@ -68,12 +70,7 @@ final class ShareViewController: UIViewController {
model.stage = .failed("Noo can only receive files and photos.")
return
}
guard model.account != nil else {
model.stage = .noAccount
return
}
model.stage = .picking
await model.open("/")
await model.start()
}
/// `loadFileRepresentation`'s URL only lives for the duration of its
@@ -102,7 +99,7 @@ final class ShareViewController: UIViewController {
// MARK: - Actions
private func upload() {
guard let account = model.account else { return }
guard let account = model.selected else { return }
do {
try ShareUpload.enqueue(items: model.items, account: account, remoteFolder: model.path)
model.stage = .uploading
+31 -7
View File
@@ -7,6 +7,30 @@ struct DavFolder: Equatable {
let path: String
}
/// The app's Files "hidden files" filter (`HiddenFilesFilter` in Dart):
/// leave dot-folders out (`hide`, the default), list only them (`only`), or
/// list everything (`include`). A folder counts as hidden when it - or any
/// folder above it - starts with a dot, exactly as in the app.
enum HiddenFilter: String {
case hide, only, include
init(raw: String) {
self = HiddenFilter(rawValue: raw) ?? .hide
}
static func isHidden(path: String) -> Bool {
path.split(separator: "/").contains { $0.hasPrefix(".") }
}
func shows(path: String) -> Bool {
switch self {
case .hide: return !Self.isHidden(path: path)
case .only: return Self.isHidden(path: path)
case .include: return true
}
}
}
/// Parses a WebDAV `PROPFIND` multistatus into folders. Pure (no
/// networking), so it's unit-tested. Compiled into both targets.
enum DavFolderParser {
@@ -67,11 +91,11 @@ enum DavFolderParser {
return "/" + parts.joined(separator: "/")
}
/// Child folders of [currentPath], sorted by name. The requested folder
/// itself (which a Depth-1 PROPFIND also returns) and - unless
/// [includeHidden] - dot-folders are left out.
/// Child folders of [currentPath], sorted by name, filtered by [hidden].
/// The requested folder itself (which a Depth-1 PROPFIND also returns) is
/// always left out.
static func folders(
from xml: Data, excluding currentPath: String, includeHidden: Bool = false
from xml: Data, excluding currentPath: String, hidden: HiddenFilter = .hide
) -> [DavFolder] {
let delegate = Delegate()
let parser = XMLParser(data: xml)
@@ -84,7 +108,7 @@ enum DavFolderParser {
.compactMap { response -> DavFolder? in
guard let path = relativePath(fromHref: response.href), path != current else { return nil }
let name = (path as NSString).lastPathComponent
guard !name.isEmpty, includeHidden || !name.hasPrefix(".") else { return nil }
guard !name.isEmpty, hidden.shows(path: path) else { return nil }
return DavFolder(name: name, path: path)
}
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
@@ -99,7 +123,7 @@ enum DavClient {
<d:propfind xmlns:d="DAV:"><d:prop><d:resourcetype/></d:prop></d:propfind>
"""
static func listFolders(account: SharedAccount, path: String) async throws -> [DavFolder] {
static func listFolders(account: SharedAccount, path: String, hidden: HiddenFilter) async throws -> [DavFolder] {
guard let url = WebDAV.fileURL(serverUrl: account.serverUrl, username: account.username, remotePath: path)
else { throw TransferError(message: "Invalid server address.") }
var request = URLRequest(url: url)
@@ -115,6 +139,6 @@ enum DavClient {
guard status == 207 else {
throw TransferError(message: status == 401 ? "Signed out - open Noo to sign in again." : "Server returned \(status).")
}
return DavFolderParser.folders(from: data, excluding: path)
return DavFolderParser.folders(from: data, excluding: path, hidden: hidden)
}
}
+43 -11
View File
@@ -1,27 +1,57 @@
import Foundation
import Security
/// The signed-in account, as much of it as the Share Extension needs to list
/// folders and upload: the app writes it when an account becomes active and
/// clears it on sign-out. Compiled into both targets.
struct SharedAccount: Codable, Equatable {
/// One signed-in account, as much of it as the Share Extension needs to list
/// folders and upload. Compiled into both targets.
struct SharedAccount: Codable, Equatable, Identifiable {
let id: String
let serverUrl: String
let username: String
/// `Basic ...` - the app password never leaves the Keychain as plain text.
let authHeader: String
/// Shown in the picker, e.g. "alice@cloud.example.com".
let displayName: String
/// The app's Files "hidden files" filter for this account - `hide` (the
/// default), `only` or `include` (see `HiddenFilter`). The share sheet
/// follows it instead of having a setting of its own.
let hiddenFilter: String
}
/// Keeps the [SharedAccount] in a Keychain access group both the app and the
/// Everything the app publishes for the extension: every account that can
/// upload, which one is active in the app, and the app-lock settings the
/// extension has to honour. The app rewrites it whenever any of that changes.
struct SharedAccounts: Codable, Equatable {
var accounts: [SharedAccount]
var activeId: String?
/// Settings -> Security: the master "login lock" and its two sub-toggles.
/// The sub-toggles only count while the master one is on (as in the app).
var loginLockEnabled: Bool
var lockAccountSwitching: Bool
var lockHiddenFiles: Bool
/// The account the app is currently using, else the first one.
var active: SharedAccount? {
accounts.first { $0.id == activeId } ?? accounts.first
}
/// Uploading to an account other than the active one is "switching" in
/// the app's terms, so it needs the same unlock.
var needsUnlockToSwitchAccount: Bool { loginLockEnabled && lockAccountSwitching }
/// Showing hidden folders needs the same unlock the app asks for when you
/// turn hidden files on.
var needsUnlockForHidden: Bool { loginLockEnabled && lockHiddenFiles }
}
/// Keeps the [SharedAccounts] in a Keychain access group both the app and the
/// extension are entitled to (`keychain-access-groups`). The group's full id
/// carries the signing team's prefix, so it's injected into each target's
/// Info.plist as `NooKeychainAccessGroup` (= `$(AppIdentifierPrefix)` +
/// `dev.ayushya.noo.shared`) instead of being hard-coded - both processes
/// then always agree on it, with or without a team.
enum SharedAccountStore {
private static let service = "dev.ayushya.noo.shared-account"
private static let account = "active"
private static let service = "dev.ayushya.noo.shared-accounts"
private static let account = "all"
static var accessGroup: String? {
Bundle.main.object(forInfoDictionaryKey: "NooKeychainAccessGroup") as? String
@@ -39,7 +69,7 @@ enum SharedAccountStore {
return query
}
static func save(_ value: SharedAccount) throws {
static func save(_ value: SharedAccounts) throws {
let data = try JSONEncoder().encode(value)
clear()
var query = baseQuery()
@@ -51,15 +81,17 @@ enum SharedAccountStore {
}
}
static func load() -> SharedAccount? {
static func load() -> SharedAccounts? {
var query = baseQuery()
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
var result: AnyObject?
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
let data = result as? Data
let data = result as? Data,
let decoded = try? JSONDecoder().decode(SharedAccounts.self, from: data),
!decoded.accounts.isEmpty
else { return nil }
return try? JSONDecoder().decode(SharedAccount.self, from: data)
return decoded
}
static func clear() {
+12 -5
View File
@@ -61,11 +61,9 @@ void main() {
ChangeNotifierProvider(
create: (context) {
final session = SessionController(context.read());
// iOS's Share Extension has no Flutter engine; it picks its
// folders/uploads as whichever account was last published here.
session.addAccountReadyListener(
() => ShareAccountService.publish(session),
);
// iOS's Share Extension has no Flutter engine; sign-out must wipe
// what it was given. (Publishing happens in the shell - see
// ShareAccountSync - once there's a FilesController to read.)
session.addAccountClearedListener(ShareAccountService.clear);
return session;
},
@@ -215,6 +213,7 @@ class _MainShellViewState extends State<MainShellView> {
late final Map<AppTab, ScrollController> _scrollControllers;
StreamSubscription<List<SharedFileRef>>? _shareSub;
StreamSubscription<PickRequest>? _pickSub;
ShareAccountSync? _shareAccountSync;
@override
void initState() {
@@ -236,6 +235,13 @@ class _MainShellViewState extends State<MainShellView> {
ShareIntentService.getInitialShare().then(_handleSharedFiles);
_shareSub = ShareIntentService.onNewShare.listen(_handleSharedFiles);
// iOS's Share Extension picks accounts/folders on its own, so it's kept
// supplied with the accounts, lock settings and hidden-files filters.
_shareAccountSync = ShareAccountSync(
context.read<SessionController>(),
context.read<FilesController>(),
)..start();
// Same cold-start-vs-already-running split as the share intent above:
// another app may have launched Noo as its GET_CONTENT picker.
PickIntentService.getPickRequest().then((request) {
@@ -320,6 +326,7 @@ class _MainShellViewState extends State<MainShellView> {
}
_shareSub?.cancel();
_pickSub?.cancel();
_shareAccountSync?.dispose();
super.dispose();
}
+13
View File
@@ -677,6 +677,19 @@ class FilesController extends ChangeNotifier
);
}
/// The hidden-files filter saved for [accountId] - not just the active
/// account's, which is all [hiddenFilter] holds. For iOS's Share Extension,
/// which follows each account's own setting.
static HiddenFilesFilter savedHiddenFilter(
SharedPreferences prefs,
String Function(String accountId, String baseKey) accountPrefKey,
String accountId,
) => loadHiddenFilter(
prefs,
accountPrefKey(accountId, _prefHiddenFilter),
accountPrefKey(accountId, _prefShowHiddenFiles),
);
/// Reads a persisted [HiddenFilesFilter], falling back to the old
/// show-hidden bool (`true` meant everything incl. hidden) so a pref
/// saved before the three-way filter keeps its meaning. [key]s are
+162 -17
View File
@@ -1,30 +1,175 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/services.dart';
import '../providers/files_controller.dart';
import '../providers/session_controller.dart';
import 'native_channel.dart';
/// Tells the native side which account is active, so iOS's Share Extension -
/// a separate process with no Flutter engine - can list folders and upload as
/// it without opening the app (`ios/Runner/Native/NativeServices.swift`'s
/// One account as the iOS Share Extension needs it - plain data, so the
/// payload builder is testable without a session.
class ShareAccountEntry {
final String id;
final String serverUrl;
final String username;
final String password;
/// The account's Files "hidden files" filter: `hide`, `only` or `include`.
final String hiddenFilter;
const ShareAccountEntry({
required this.id,
required this.serverUrl,
required this.username,
required this.password,
required this.hiddenFilter,
});
}
/// Tells the native side which accounts can upload, which one is active, and
/// the app-lock settings, so iOS's Share Extension - a separate process with
/// no Flutter engine - can offer an account picker, list folders and upload
/// as them without opening the app (`ios/Runner/Native/NativeServices.swift`'s
/// `share_account`, stored in a Keychain group shared with the extension).
/// Android has no equivalent (its share intent opens the app itself), so
/// there the channel simply doesn't exist and both calls are no-ops.
/// Android has no equivalent (its share intent opens the app itself), so there
/// the channel simply doesn't exist and everything here is a no-op.
class ShareAccountService {
static const _channel = MethodChannel('dev.ayushya.noo/share_account');
/// Publishes the active account (call whenever one becomes active).
static Future<void> publish(SessionController session) async {
final authHeader = session.service?.authHeaders['Authorization'];
if (authHeader == null || session.serverUrl.isEmpty) return;
final host = Uri.tryParse(session.serverUrl)?.host ?? session.serverUrl;
await invokeIfAvailable(_channel, 'setAccount', {
'serverUrl': session.serverUrl,
'username': session.username,
'authHeader': authHeader,
'displayName': '${session.username}@$host',
static String basicAuth(String username, String password) =>
'Basic ${base64Encode(utf8.encode('$username:$password'))}';
/// The JSON `SharedAccounts` (`ios/Shared/SharedAccount.swift`) decodes.
static String buildPayload({
required List<ShareAccountEntry> accounts,
required String? activeId,
required bool loginLockEnabled,
required bool lockAccountSwitching,
required bool lockHiddenFiles,
}) {
return jsonEncode({
'accounts': [
for (final a in accounts)
{
'id': a.id,
'serverUrl': a.serverUrl,
'username': a.username,
'authHeader': basicAuth(a.username, a.password),
'displayName':
'${a.username}@${Uri.tryParse(a.serverUrl)?.host ?? a.serverUrl}',
'hiddenFilter': a.hiddenFilter,
},
],
'activeId': activeId,
'loginLockEnabled': loginLockEnabled,
'lockAccountSwitching': lockAccountSwitching,
'lockHiddenFiles': lockHiddenFiles,
});
}
/// Forgets it (sign-out / switching away) so the extension stops offering
/// uploads to an account that's no longer signed in.
/// Publishes every saved account that can actually upload (has a stored
/// password and isn't signed out), with the active one's hidden-files
/// filter taken live from [files] - its saved pref is written
/// asynchronously, so it can lag a change that just happened.
static Future<void> publish(
SessionController session,
FilesController files,
) async {
final activeId = session.activeAccountId;
if (activeId == null) return clear();
final prefs = await session.prefsFuture;
final store = session.accountStore;
final signedOut = store.loadSignedOut(prefs);
final entries = <ShareAccountEntry>[];
for (final account in session.accounts) {
if (signedOut.contains(account.id)) continue;
final password = await store.readPassword(account.id);
if (password == null) continue;
final filter = account.id == activeId
? files.hiddenFilter
: FilesController.savedHiddenFilter(
prefs,
store.accountPrefKey,
account.id,
);
entries.add(
ShareAccountEntry(
id: account.id,
serverUrl: account.serverUrl,
username: account.username,
password: password,
hiddenFilter: filter.name,
),
);
}
if (entries.isEmpty) return clear();
await invokeIfAvailable(_channel, 'setAccounts', {
'accounts': buildPayload(
accounts: entries,
activeId: activeId,
loginLockEnabled: session.loginLockEnabled,
lockAccountSwitching: session.lockAccountSwitching,
lockHiddenFiles: session.lockHiddenFiles,
),
});
}
/// Forgets everything (sign-out) so the extension stops offering uploads.
static Future<void> clear() => invokeIfAvailable(_channel, 'clearAccount');
}
/// Keeps [ShareAccountService]'s published data current while the app is
/// running: republishes when an account becomes ready or when anything the
/// extension depends on changes - the account list or active account, the
/// lock settings, or the active account's hidden-files filter.
class ShareAccountSync {
final SessionController session;
final FilesController files;
String? _lastSignature;
bool _disposed = false;
Future<void> _queue = Future.value();
ShareAccountSync(this.session, this.files);
void start() {
session.addAccountReadyListener(_publish);
session.addListener(_onChange);
files.addListener(_onChange);
}
void dispose() {
_disposed = true;
session.removeListener(_onChange);
files.removeListener(_onChange);
}
String _signature() => [
session.activeAccountId,
session.accounts.map((a) => a.id).join(','),
session.loginLockEnabled,
session.lockAccountSwitching,
session.lockHiddenFiles,
files.hiddenFilter.name,
].join('|');
void _onChange() {
if (_signature() != _lastSignature) _publish();
}
/// Runs one at a time, in order, so a slow publish can't land after a
/// newer one and leave the extension with stale data.
void _publish() {
if (_disposed) return;
_lastSignature = _signature();
_queue = _queue.then((_) async {
if (_disposed) return;
try {
await ShareAccountService.publish(session, files);
} catch (_) {
// Best effort: the extension just keeps the previous data.
}
});
}
}
@@ -0,0 +1,73 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:noo/services/share_account_service.dart';
/// What the iOS Share Extension is given - the contract with
/// `ios/Shared/SharedAccount.swift`'s `SharedAccounts`.
void main() {
const alice = ShareAccountEntry(
id: 'cloud_example_com__alice',
serverUrl: 'https://cloud.example.com',
username: 'alice',
password: 's3cret',
hiddenFilter: 'include',
);
const bob = ShareAccountEntry(
id: 'nc_home_lan__bob',
serverUrl: 'https://nc.home.lan:8443/nextcloud',
username: 'bob',
password: 'pw',
hiddenFilter: 'hide',
);
Map<String, dynamic> build({
List<ShareAccountEntry> accounts = const [alice, bob],
String? activeId = 'nc_home_lan__bob',
bool login = true,
bool switching = true,
bool hidden = false,
}) => jsonDecode(
ShareAccountService.buildPayload(
accounts: accounts,
activeId: activeId,
loginLockEnabled: login,
lockAccountSwitching: switching,
lockHiddenFiles: hidden,
),
);
test('basic auth header matches what the app sends', () {
expect(
ShareAccountService.basicAuth('alice', 's3cret'),
'Basic ${base64Encode(utf8.encode('alice:s3cret'))}',
);
});
test('every account is listed with its own header, name and filter', () {
final accounts = (build()['accounts'] as List).cast<Map<String, dynamic>>();
expect(accounts.map((a) => a['id']), [alice.id, bob.id]);
expect(
accounts[0]['authHeader'],
ShareAccountService.basicAuth('alice', 's3cret'),
);
expect(accounts[0]['displayName'], 'alice@cloud.example.com');
expect(accounts[0]['hiddenFilter'], 'include');
// The display name uses the host only - not the port or sub-path.
expect(accounts[1]['displayName'], 'bob@nc.home.lan');
expect(accounts[1]['serverUrl'], 'https://nc.home.lan:8443/nextcloud');
expect(accounts[1]['hiddenFilter'], 'hide');
});
test('the active account and the lock settings are passed through', () {
final payload = build(login: true, switching: false, hidden: true);
expect(payload['activeId'], 'nc_home_lan__bob');
expect(payload['loginLockEnabled'], true);
expect(payload['lockAccountSwitching'], false);
expect(payload['lockHiddenFiles'], true);
});
test('no active account is a null activeId, not a missing key', () {
expect(build(activeId: null).containsKey('activeId'), true);
expect(build(activeId: null)['activeId'], isNull);
});
}