diff --git a/bitchat/App/AppLockModel.swift b/bitchat/App/AppLockModel.swift index 7a57f0dd..63956e55 100644 --- a/bitchat/App/AppLockModel.swift +++ b/bitchat/App/AppLockModel.swift @@ -45,6 +45,10 @@ final class AppLockModel: ObservableObject { private let isEnabledProvider: () -> Bool private let authenticate: (@escaping @MainActor (Bool) -> Void) -> Void + /// Bumped on every lock, so a success callback from a prior unlock + /// attempt (e.g. Face ID resolving just as the app backgrounded and + /// re-locked) cannot clear the new lock. + private var lockGeneration = 0 /// Providers are injectable so tests drive the lock without LAContext /// or the shared UserDefaults. @@ -67,14 +71,20 @@ final class AppLockModel: ObservableObject { func lockIfEnabled() { guard isEnabledProvider() else { return } + lockGeneration &+= 1 + isAuthenticating = false isLocked = true } func requestUnlock() { guard isLocked, !isAuthenticating else { return } isAuthenticating = true + let generation = lockGeneration authenticate { [weak self] success in guard let self else { return } + // Ignore a callback for a lock cycle that has already been + // superseded (backgrounded and re-locked mid-authentication). + guard generation == self.lockGeneration else { return } self.isAuthenticating = false if success { self.isLocked = false @@ -89,7 +99,11 @@ final class AppLockModel: ObservableObject { // Fail OPEN, deliberately: removing the device passcode already // requires knowing it, so this state means the owner disabled // it — locking them out of their own chats would punish exactly - // the wrong person. The settings copy states this rule. + // the wrong person. Turn the setting itself off too, so the + // toggle stops claiming the lock is on and re-adding a passcode + // later doesn't silently reactivate it (the copy promises the + // lock "turns itself off"). + AppLockSettings.setEnabled(false) Task { @MainActor in completion(true) } return } diff --git a/bitchatTests/ChatViewModelTests.swift b/bitchatTests/ChatViewModelTests.swift index 64c1e22a..73f11c0b 100644 --- a/bitchatTests/ChatViewModelTests.swift +++ b/bitchatTests/ChatViewModelTests.swift @@ -2420,6 +2420,23 @@ struct AppLockModelTests { #expect(model.isLocked) } + @Test @MainActor + func staleAuthCallbackCannotUnlockAfterRelock() { + var pending: ((Bool) -> Void)? + let model = AppLockModel( + isEnabledProvider: { true }, + authenticate: { completion in pending = completion } + ) + #expect(model.isLocked) + + // Auth begins, then the app backgrounds and re-locks before Face ID + // resolves; the late success must be ignored. + model.requestUnlock() + model.lockIfEnabled() + pending?(true) + #expect(model.isLocked) + } + @Test @MainActor func staysInertWhenDisabled() { let model = AppLockModel(