Share sheet: ask to unlock on every gated action, like the app

The sheet remembered one unlock for its whole lifetime, so later account picks
and hidden toggles skipped Face ID. Each switch / each turning-on of hidden
folders now prompts again; one action needing both is a single prompt.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Ayushya Amitabh
2026-10-06 20:24:20 -04:00
co-authored by Claude Sonnet 5.5
parent 02d9d799a6
commit 4ab6c5d85c
4 changed files with 104 additions and 28 deletions
+49
View File
@@ -217,6 +217,55 @@ class RunnerTests: XCTestCase {
XCTAssertTrue(rules(master: true, switching: true, hidden: true) == (true, true))
}
// Which actions in the share sheet ask to unlock - and how many prompts.
private func lockedAccounts(switching: Bool, hidden: Bool) -> SharedAccounts {
SharedAccounts(
accounts: [account("alice"), account("bob")], activeId: "alice",
loginLockEnabled: false, lockAccountSwitching: switching, lockHiddenFiles: hidden)
}
func testChoosingTheActiveAccountWithoutHiddenFoldersNeverPrompts() {
let shared = lockedAccounts(switching: true, hidden: true)
let needs = shared.unlockNeeds(choosing: account("alice"), showing: .hide)
XCTAssertEqual(needs, SelectionUnlock(switchesAccount: false, showsHidden: false))
XCTAssertFalse(needs.needsPrompt)
}
func testChoosingAnotherAccountPromptsEveryTime() {
let shared = lockedAccounts(switching: true, hidden: false)
// No unlock is remembered: the same choice asks again on every call.
for _ in 0..<3 {
XCTAssertTrue(shared.unlockNeeds(choosing: account("bob"), showing: .hide).needsPrompt)
}
XCTAssertFalse(
lockedAccounts(switching: false, hidden: false).unlockNeeds(choosing: account("bob"), showing: .hide).needsPrompt)
}
func testSwitchPlusHiddenFoldersIsOnePromptThatCoversBoth() {
let needs = lockedAccounts(switching: true, hidden: true)
.unlockNeeds(choosing: account("bob"), showing: .include)
XCTAssertEqual(needs, SelectionUnlock(switchesAccount: true, showsHidden: true))
XCTAssertTrue(needs.needsPrompt)
}
func testHiddenFoldersAloneOnTheActiveAccountPromptsForHidden() {
let needs = lockedAccounts(switching: true, hidden: true)
.unlockNeeds(choosing: account("alice"), showing: .only)
XCTAssertEqual(needs, SelectionUnlock(switchesAccount: false, showsHidden: true))
}
func testOnlyTurningHiddenFoldersOnAsksToUnlock() {
let locked = lockedAccounts(switching: false, hidden: true)
XCTAssertTrue(locked.needsUnlockToChangeHidden(from: .hide, to: .only))
XCTAssertTrue(locked.needsUnlockToChangeHidden(from: .hide, to: .include))
XCTAssertFalse(locked.needsUnlockToChangeHidden(from: .include, to: .hide), "hiding again never asks")
XCTAssertFalse(locked.needsUnlockToChangeHidden(from: .only, to: .include), "only <-> all never asks")
XCTAssertFalse(
lockedAccounts(switching: false, hidden: false).needsUnlockToChangeHidden(from: .hide, to: .include),
"no lock set, no prompt")
}
// MARK: - TransferBatchStore
func testBatchSummaryFiresOnlyOnTheLastFile() {
+23 -27
View File
@@ -35,9 +35,6 @@ final class ShareModel: ObservableObject {
let shared: SharedAccounts?
/// One successful unlock covers every gate for the rest of this sheet.
private var unlocked = false
/// Set by the view controller: what each button actually does.
var onUpload: () -> Void = {}
var onSaveForLater: () -> Void = {}
@@ -103,24 +100,30 @@ final class ShareModel: ObservableObject {
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
}
}
NSLog("[ShareExtension] selected %@ (hidden=%@ storage=%@)", account.id, account.hiddenFilter, account.storageScope)
selected = account
externalRoots = []
storageFilter = StorageFilter(raw: account.storageScope)
hiddenFilter = HiddenFilter(raw: account.hiddenFilter)
if hiddenFilter != .hide, shared.needsUnlockForHidden {
if await !unlock("Unlock to show hidden folders") {
hiddenFilter = .hide
var wantedHidden = HiddenFilter(raw: account.hiddenFilter)
// Every selection asks again, like the app does for every switch - no
// unlock is remembered. If one action needs both unlocks, one prompt
// covers both.
let needs = shared.unlockNeeds(choosing: account, showing: wantedHidden)
if needs.needsPrompt {
let reason = needs.switchesAccount
? "Unlock to upload to \(account.displayName)" : "Unlock to show hidden folders"
if await !DeviceAuth.authenticate(reason: reason) {
guard !needs.switchesAccount else {
notice = "Unlock to upload to a different account."
stage = .chooseAccount
return
}
wantedHidden = .hide
notice = "Hidden folders are locked, so they're not shown."
}
}
NSLog("[ShareExtension] selected %@ (hidden=%@ storage=%@)", account.id, wantedHidden.rawValue, account.storageScope)
selected = account
externalRoots = []
storageFilter = StorageFilter(raw: account.storageScope)
hiddenFilter = wantedHidden
stage = .picking
await open("/")
}
@@ -132,8 +135,8 @@ final class ShareModel: ObservableObject {
/// 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 {
if shared?.needsUnlockToChangeHidden(from: hiddenFilter, to: filter) == true {
guard await DeviceAuth.authenticate(reason: "Unlock to show hidden folders") else {
notice = "Hidden folders are locked, so they're not shown."
return
}
@@ -150,13 +153,6 @@ final class ShareModel: ObservableObject {
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
}
// MARK: - Browsing
/// A toggle can leave you inside a folder it now hides, so changing one
+23
View File
@@ -73,6 +73,21 @@ struct SharedAccounts: Codable, Equatable {
/// Showing hidden folders needs the same unlock the app asks for when you
/// turn hidden files on.
var needsUnlockForHidden: Bool { lockHiddenFiles }
/// What choosing [account] (with [hidden] folders to show) asks the user
/// to unlock. One action, one prompt - when both the account switch and
/// the hidden folders need it, a single authentication covers both.
func unlockNeeds(choosing account: SharedAccount, showing hidden: HiddenFilter) -> SelectionUnlock {
SelectionUnlock(
switchesAccount: account.id != active?.id && needsUnlockToSwitchAccount,
showsHidden: hidden != .hide && needsUnlockForHidden)
}
/// Turning hidden folders on (from `hide`) is what the app locks; going
/// back to `hide`, or between the two revealing modes, never asks.
func needsUnlockToChangeHidden(from old: HiddenFilter, to new: HiddenFilter) -> Bool {
old == .hide && new != .hide && needsUnlockForHidden
}
}
/// Keeps the [SharedAccounts] in a Keychain access group both the app and the
@@ -136,3 +151,11 @@ enum SharedAccountStore {
SecItemDelete(legacy as CFDictionary)
}
}
/// What one selection needs unlocked - see `SharedAccounts.unlockNeeds`.
struct SelectionUnlock: Equatable {
let switchesAccount: Bool
let showsHidden: Bool
var needsPrompt: Bool { switchesAccount || showsHidden }
}