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:
co-authored by
Claude Sonnet 5.5
parent
0cda4a1241
commit
a37524e959
@@ -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`,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
/// `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.
|
||||
class ShareAccountService {
|
||||
static const _channel = MethodChannel('dev.ayushya.noo/share_account');
|
||||
/// 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;
|
||||
|
||||
/// 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',
|
||||
/// 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,
|
||||
});
|
||||
}
|
||||
|
||||
/// Forgets it (sign-out / switching away) so the extension stops offering
|
||||
/// uploads to an account that's no longer signed in.
|
||||
/// 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 everything here is a no-op.
|
||||
class ShareAccountService {
|
||||
static const _channel = MethodChannel('dev.ayushya.noo/share_account');
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
/// 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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user