Show the lock screen placeholder on willResignActive. (#2086)

This commit is contained in:
Doug 2023-11-15 15:31:35 +00:00 committed by GitHub
parent 342feb4113
commit 0b9da83470
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 164 additions and 65 deletions

View File

@ -40,6 +40,11 @@ class WindowManager {
[mainWindow, overlayWindow, alternateWindow] [mainWindow, overlayWindow, alternateWindow]
} }
/// The task used to switch windows, so that we don't get stuck in the wrong state with a quick switch.
@CancellableTask private var switchTask: Task<Void, Error>?
/// A duration that allows window switching to wait a couple of frames to avoid a transition through black.
private let windowHideDelay = Duration.milliseconds(33)
/// Configures the window manager to operate on the supplied scene. /// Configures the window manager to operate on the supplied scene.
func configure(with windowScene: UIWindowScene) { func configure(with windowScene: UIWindowScene) {
mainWindow = windowScene.keyWindow mainWindow = windowScene.keyWindow
@ -58,21 +63,33 @@ class WindowManager {
/// Shows the main and overlay window combo, hiding the alternate window. /// Shows the main and overlay window combo, hiding the alternate window.
func switchToMain() { func switchToMain() {
mainWindow.isHidden = false switchTask = Task {
overlayWindow.isHidden = false mainWindow.isHidden = false
alternateWindow.isHidden = true overlayWindow.isHidden = false
// Delay hiding to make sure the main windows are visible.
try await Task.sleep(for: windowHideDelay)
alternateWindow.isHidden = true
}
} }
/// Shows the alternate window, hiding the main and overlay combo. /// Shows the alternate window, hiding the main and overlay combo.
func switchToAlternate() { func switchToAlternate() {
alternateWindow.isHidden = false switchTask = Task {
overlayWindow.isHidden = true alternateWindow.isHidden = false
mainWindow.isHidden = true
// We don't know what route the app will use when returning back
// We don't know what route the app will use when returning back // to the main window, so end any editing operation now to avoid
// to the main window, so end any editing operation now to avoid // e.g. the keyboard being displayed on top of a call sheet.
// e.g. the keyboard being displayed on top of a call sheet. mainWindow.endEditing(true)
mainWindow.endEditing(true)
// Delay hiding to make sure the alternate window is visible.
try await Task.sleep(for: windowHideDelay)
overlayWindow.isHidden = true
mainWindow.isHidden = true
}
} }
} }

View File

@ -49,15 +49,21 @@ class AppLockFlowCoordinator: CoordinatorProtocol {
// Set the initial background state. // Set the initial background state.
showPlaceholder() showPlaceholder()
notificationCenter.publisher(for: UIApplication.willResignActiveNotification)
.sink { [weak self] _ in
self?.applicationWillResignActive()
}
.store(in: &cancellables)
notificationCenter.publisher(for: UIApplication.didEnterBackgroundNotification) notificationCenter.publisher(for: UIApplication.didEnterBackgroundNotification)
.sink { [weak self] _ in .sink { [weak self] _ in
self?.applicationDidEnterBackground() self?.applicationDidEnterBackground()
} }
.store(in: &cancellables) .store(in: &cancellables)
notificationCenter.publisher(for: UIApplication.willEnterForegroundNotification) notificationCenter.publisher(for: UIApplication.didBecomeActiveNotification)
.sink { [weak self] _ in .sink { [weak self] _ in
self?.applicationWillEnterForeground() self?.applicationDidBecomeActive()
} }
.store(in: &cancellables) .store(in: &cancellables)
} }
@ -68,19 +74,23 @@ class AppLockFlowCoordinator: CoordinatorProtocol {
// MARK: - App unlock // MARK: - App unlock
private func applicationDidEnterBackground() { private func applicationWillResignActive() {
unlockTask = nil unlockTask = nil
guard appLockService.isEnabled else { return } guard appLockService.isEnabled else { return }
appLockService.applicationDidEnterBackground()
showPlaceholder() showPlaceholder()
} }
private func applicationWillEnterForeground() { private func applicationDidEnterBackground() {
guard appLockService.isEnabled else { return }
appLockService.applicationDidEnterBackground()
showPlaceholder() // Double call but just to be safe
}
private func applicationDidBecomeActive() {
guard appLockService.isEnabled else { return } guard appLockService.isEnabled else { return }
guard appLockService.computeNeedsUnlock(willEnterForegroundAt: .now) else { guard appLockService.computeNeedsUnlock(didBecomeActiveAt: .now) else {
// Reveal the app again if within the grace period. // Reveal the app again if within the grace period.
actionsSubject.send(.unlockApp) actionsSubject.send(.unlockApp)
return return

View File

@ -246,23 +246,23 @@ class AppLockServiceMock: AppLockServiceProtocol {
} }
//MARK: - computeNeedsUnlock //MARK: - computeNeedsUnlock
var computeNeedsUnlockWillEnterForegroundAtCallsCount = 0 var computeNeedsUnlockDidBecomeActiveAtCallsCount = 0
var computeNeedsUnlockWillEnterForegroundAtCalled: Bool { var computeNeedsUnlockDidBecomeActiveAtCalled: Bool {
return computeNeedsUnlockWillEnterForegroundAtCallsCount > 0 return computeNeedsUnlockDidBecomeActiveAtCallsCount > 0
} }
var computeNeedsUnlockWillEnterForegroundAtReceivedDate: Date? var computeNeedsUnlockDidBecomeActiveAtReceivedDate: Date?
var computeNeedsUnlockWillEnterForegroundAtReceivedInvocations: [Date] = [] var computeNeedsUnlockDidBecomeActiveAtReceivedInvocations: [Date] = []
var computeNeedsUnlockWillEnterForegroundAtReturnValue: Bool! var computeNeedsUnlockDidBecomeActiveAtReturnValue: Bool!
var computeNeedsUnlockWillEnterForegroundAtClosure: ((Date) -> Bool)? var computeNeedsUnlockDidBecomeActiveAtClosure: ((Date) -> Bool)?
func computeNeedsUnlock(willEnterForegroundAt date: Date) -> Bool { func computeNeedsUnlock(didBecomeActiveAt date: Date) -> Bool {
computeNeedsUnlockWillEnterForegroundAtCallsCount += 1 computeNeedsUnlockDidBecomeActiveAtCallsCount += 1
computeNeedsUnlockWillEnterForegroundAtReceivedDate = date computeNeedsUnlockDidBecomeActiveAtReceivedDate = date
computeNeedsUnlockWillEnterForegroundAtReceivedInvocations.append(date) computeNeedsUnlockDidBecomeActiveAtReceivedInvocations.append(date)
if let computeNeedsUnlockWillEnterForegroundAtClosure = computeNeedsUnlockWillEnterForegroundAtClosure { if let computeNeedsUnlockDidBecomeActiveAtClosure = computeNeedsUnlockDidBecomeActiveAtClosure {
return computeNeedsUnlockWillEnterForegroundAtClosure(date) return computeNeedsUnlockDidBecomeActiveAtClosure(date)
} else { } else {
return computeNeedsUnlockWillEnterForegroundAtReturnValue return computeNeedsUnlockDidBecomeActiveAtReturnValue
} }
} }
//MARK: - unlock //MARK: - unlock

View File

@ -111,8 +111,8 @@ class AppLockService: AppLockServiceProtocol {
timer.applicationDidEnterBackground() timer.applicationDidEnterBackground()
} }
func computeNeedsUnlock(willEnterForegroundAt date: Date) -> Bool { func computeNeedsUnlock(didBecomeActiveAt date: Date) -> Bool {
timer.computeLockState(willEnterForegroundAt: date) timer.computeLockState(didBecomeActiveAt: date)
} }
func unlock(with pinCode: String) -> Bool { func unlock(with pinCode: String) -> Bool {

View File

@ -59,7 +59,7 @@ protocol AppLockServiceProtocol: AnyObject {
/// Informs the service that the app has entered the background. /// Informs the service that the app has entered the background.
func applicationDidEnterBackground() func applicationDidEnterBackground()
/// Decides whether the app should be unlocked with a PIN code/biometrics on foregrounding. /// Decides whether the app should be unlocked with a PIN code/biometrics on foregrounding.
func computeNeedsUnlock(willEnterForegroundAt date: Date) -> Bool func computeNeedsUnlock(didBecomeActiveAt date: Date) -> Bool
/// Attempt to unlock the app with the supplied PIN code. /// Attempt to unlock the app with the supplied PIN code.
func unlock(with pinCode: String) -> Bool func unlock(with pinCode: String) -> Bool

View File

@ -21,13 +21,18 @@ class AppLockTimer {
/// The amount of time the app should remain unlocked for whilst backgrounded. /// The amount of time the app should remain unlocked for whilst backgrounded.
let gracePeriod: TimeInterval let gracePeriod: TimeInterval
/// Whether the timer considers the app to be locked or not. /// Whether the timer considers the app to be locked or not. Always starts with a locked app.
/// ///
/// Internally this value may be incorrect, always call `needsUnlock` to get the correct value. /// Internally this value may be incorrect, always call `needsUnlock` to get the correct value.
private var isLocked = true private var isLocked = true
/// The date when the app was last backgrounded whilst in an unlocked state. /// The date when the app was last backgrounded whilst in an unlocked state.
private var lastUnlockedBackground: Date? private var lastUnlockedBackground: Date?
/// Whether or not the app has moved to the background.
///
/// This allows us to distinguish between `willResignActive` and `didEnterBackground`.
private var isInBackground = false
/// Creates a new timer. /// Creates a new timer.
/// - Parameter gracePeriod: The amount of time the app should remain unlocked for whilst backgrounded. /// - Parameter gracePeriod: The amount of time the app should remain unlocked for whilst backgrounded.
init(gracePeriod: TimeInterval) { init(gracePeriod: TimeInterval) {
@ -36,13 +41,18 @@ class AppLockTimer {
/// Signals to the timer to track how long the app will be backgrounded for. /// Signals to the timer to track how long the app will be backgrounded for.
func applicationDidEnterBackground(date: Date = .now) { func applicationDidEnterBackground(date: Date = .now) {
isInBackground = true
// Only update the last background date if the app is currently unlocked. // Only update the last background date if the app is currently unlocked.
guard !isLocked else { return } guard !isLocked else { return }
lastUnlockedBackground = date lastUnlockedBackground = date
} }
/// Asks the timer to recompute the lock state on foregrounding. /// Asks the timer to recompute the lock state when active. If ``applicationDidEnterBackground`` hasn't been
func computeLockState(willEnterForegroundAt date: Date) -> Bool { /// called since the last computation, then this method won't recompute it, instead directly returning the previous state.
func computeLockState(didBecomeActiveAt date: Date) -> Bool {
guard isInBackground else { return isLocked }
isInBackground = false
guard !isLocked, let lastUnlockedBackground else { return true } guard !isLocked, let lastUnlockedBackground else { return true }
isLocked = date.timeIntervalSince(lastUnlockedBackground) >= gracePeriod isLocked = date.timeIntervalSince(lastUnlockedBackground) >= gracePeriod

View File

@ -187,7 +187,9 @@ class MockScreen: Identifiable {
context: context) context: context)
if id == .appLockFlowAlternateWindow { if id == .appLockFlowAlternateWindow {
guard case .success = appLockService.setupPINCode("2023") else { let pinCode = "2023"
guard case .success = appLockService.setupPINCode(pinCode),
appLockService.unlock(with: pinCode) else {
fatalError("Failed to preset the PIN code.") fatalError("Failed to preset the PIN code.")
} }
} }

View File

@ -45,7 +45,7 @@ class AppLockUITests: XCTestCase {
try await app.assertScreenshot(.appLockFlow, step: Step.placeholder) try await app.assertScreenshot(.appLockFlow, step: Step.placeholder)
// When foregrounding the app. // When foregrounding the app.
try client.send(.notification(name: UIApplication.willEnterForegroundNotification)) try client.send(.notification(name: UIApplication.didBecomeActiveNotification))
// Then the Lock Screen should be shown to enter a PIN. // Then the Lock Screen should be shown to enter a PIN.
try await app.assertScreenshot(.appLockFlow, step: Step.lockScreen) try await app.assertScreenshot(.appLockFlow, step: Step.lockScreen)
@ -73,7 +73,7 @@ class AppLockUITests: XCTestCase {
try await app.assertScreenshot(.appLockFlow, step: Step.unlocked) try await app.assertScreenshot(.appLockFlow, step: Step.unlocked)
// When foregrounding the app. // When foregrounding the app.
try client.send(.notification(name: UIApplication.willEnterForegroundNotification)) try client.send(.notification(name: UIApplication.didBecomeActiveNotification))
// Then the app should still remain unlocked. // Then the app should still remain unlocked.
try await app.assertScreenshot(.appLockFlow, step: Step.unlocked) try await app.assertScreenshot(.appLockFlow, step: Step.unlocked)
@ -87,7 +87,7 @@ class AppLockUITests: XCTestCase {
try await app.assertScreenshot(.appLockFlow, step: Step.unlocked) try await app.assertScreenshot(.appLockFlow, step: Step.unlocked)
try client.send(.notification(name: UIApplication.didEnterBackgroundNotification)) try client.send(.notification(name: UIApplication.didEnterBackgroundNotification))
try client.send(.notification(name: UIApplication.willEnterForegroundNotification)) try client.send(.notification(name: UIApplication.didBecomeActiveNotification))
try await app.assertScreenshot(.appLockFlow, step: Step.lockScreen) try await app.assertScreenshot(.appLockFlow, step: Step.lockScreen)
// When entering an incorrect PIN // When entering an incorrect PIN
@ -106,6 +106,28 @@ class AppLockUITests: XCTestCase {
try await app.assertScreenshot(.appLockFlow, step: Step.forcedLogout) try await app.assertScreenshot(.appLockFlow, step: Step.forcedLogout)
} }
func testResignActive() async throws {
// Given an app with screen lock enabled.
let client = try UITestsSignalling.Client(mode: .tests)
app = Application.launch(.appLockFlow)
await client.waitForApp()
// Blank form representing an unlocked app.
try await app.assertScreenshot(.appLockFlow, step: Step.unlocked)
// When the app resigns active but doesn't enter the background.
try client.send(.notification(name: UIApplication.willResignActiveNotification))
// Then the placeholder screen should obscure the content.
try await app.assertScreenshot(.appLockFlow, step: Step.placeholder)
// When the app becomes active again.
try client.send(.notification(name: UIApplication.didBecomeActiveNotification))
// Then the app should not have become unlock.
try await app.assertScreenshot(.appLockFlow, step: Step.unlocked)
}
// MARK: - Helpers // MARK: - Helpers
func enterPIN() { func enterPIN() {

View File

@ -34,63 +34,63 @@ class AppLockTimerTests: XCTestCase {
func testTimerLockedOnStartup() { func testTimerLockedOnStartup() {
setupTimer(unlocked: false) setupTimer(unlocked: false)
XCTAssertTrue(timer.computeLockState(willEnterForegroundAt: now), XCTAssertTrue(timer.computeLockState(didBecomeActiveAt: now),
"The app should be locked on a fresh launch.") "The app should be locked on a fresh launch.")
setupTimer(unlocked: false) setupTimer(unlocked: false)
XCTAssertTrue(timer.computeLockState(willEnterForegroundAt: now + 1), XCTAssertTrue(timer.computeLockState(didBecomeActiveAt: now + 1),
"The app should be locked after a fresh launch.") "The app should be locked after a fresh launch.")
setupTimer(unlocked: false) setupTimer(unlocked: false)
XCTAssertTrue(timer.computeLockState(willEnterForegroundAt: now + halfGracePeriod), XCTAssertTrue(timer.computeLockState(didBecomeActiveAt: now + halfGracePeriod),
"The app should be locked after a fresh launch.") "The app should be locked after a fresh launch.")
setupTimer(unlocked: false) setupTimer(unlocked: false)
XCTAssertTrue(timer.computeLockState(willEnterForegroundAt: now + gracePeriod), XCTAssertTrue(timer.computeLockState(didBecomeActiveAt: now + gracePeriod),
"The app should be locked after a fresh launch.") "The app should be locked after a fresh launch.")
setupTimer(unlocked: false) setupTimer(unlocked: false)
XCTAssertTrue(timer.computeLockState(willEnterForegroundAt: now + gracePeriodX10), XCTAssertTrue(timer.computeLockState(didBecomeActiveAt: now + gracePeriodX10),
"The app should be locked after a fresh launch.") "The app should be locked after a fresh launch.")
} }
func testTimerBeforeFirstUnlock() { func testTimerBeforeFirstUnlock() {
setupTimer(unlocked: false, backgroundedAt: now) setupTimer(unlocked: false, backgroundedAt: now)
XCTAssertTrue(timer.computeLockState(willEnterForegroundAt: now), XCTAssertTrue(timer.computeLockState(didBecomeActiveAt: now),
"The app should always remain locked after backgrounding when locked.") "The app should always remain locked after backgrounding when locked.")
setupTimer(unlocked: false, backgroundedAt: now) setupTimer(unlocked: false, backgroundedAt: now)
XCTAssertTrue(timer.computeLockState(willEnterForegroundAt: now + 1), XCTAssertTrue(timer.computeLockState(didBecomeActiveAt: now + 1),
"The app should always remain locked after backgrounding when locked.") "The app should always remain locked after backgrounding when locked.")
setupTimer(unlocked: false, backgroundedAt: now) setupTimer(unlocked: false, backgroundedAt: now)
XCTAssertTrue(timer.computeLockState(willEnterForegroundAt: now + halfGracePeriod), XCTAssertTrue(timer.computeLockState(didBecomeActiveAt: now + halfGracePeriod),
"The app should always remain locked after backgrounding when locked.") "The app should always remain locked after backgrounding when locked.")
setupTimer(unlocked: false, backgroundedAt: now) setupTimer(unlocked: false, backgroundedAt: now)
XCTAssertTrue(timer.computeLockState(willEnterForegroundAt: now + gracePeriod), XCTAssertTrue(timer.computeLockState(didBecomeActiveAt: now + gracePeriod),
"The app should always remain locked after backgrounding when locked.") "The app should always remain locked after backgrounding when locked.")
setupTimer(unlocked: false, backgroundedAt: now) setupTimer(unlocked: false, backgroundedAt: now)
XCTAssertTrue(timer.computeLockState(willEnterForegroundAt: now + gracePeriodX10), XCTAssertTrue(timer.computeLockState(didBecomeActiveAt: now + gracePeriodX10),
"The app should always remain locked after backgrounding when locked.") "The app should always remain locked after backgrounding when locked.")
} }
func testTimerWhenUnlocked() { func testTimerWhenUnlocked() {
setupTimer(unlocked: true, backgroundedAt: now) setupTimer(unlocked: true, backgroundedAt: now)
XCTAssertFalse(timer.computeLockState(willEnterForegroundAt: now + 1), XCTAssertFalse(timer.computeLockState(didBecomeActiveAt: now + 1),
"The app should remain unlocked when it was unlocked and backgrounded for less then the grace period.") "The app should remain unlocked when it was unlocked and backgrounded for less then the grace period.")
setupTimer(unlocked: true, backgroundedAt: now) setupTimer(unlocked: true, backgroundedAt: now)
XCTAssertFalse(timer.computeLockState(willEnterForegroundAt: now + halfGracePeriod), XCTAssertFalse(timer.computeLockState(didBecomeActiveAt: now + halfGracePeriod),
"The app should remain unlocked when it was unlocked and backgrounded for less then the grace period.") "The app should remain unlocked when it was unlocked and backgrounded for less then the grace period.")
setupTimer(unlocked: true, backgroundedAt: now) setupTimer(unlocked: true, backgroundedAt: now)
XCTAssertTrue(timer.computeLockState(willEnterForegroundAt: now + gracePeriod), XCTAssertTrue(timer.computeLockState(didBecomeActiveAt: now + gracePeriod),
"The app should become locked when it was unlocked and backgrounded for more than the grace period.") "The app should become locked when it was unlocked and backgrounded for more than the grace period.")
setupTimer(unlocked: true, backgroundedAt: now) setupTimer(unlocked: true, backgroundedAt: now)
XCTAssertTrue(timer.computeLockState(willEnterForegroundAt: now + gracePeriodX10), XCTAssertTrue(timer.computeLockState(didBecomeActiveAt: now + gracePeriodX10),
"The app should become locked when it was unlocked and backgrounded for more than the grace period.") "The app should become locked when it was unlocked and backgrounded for more than the grace period.")
} }
@ -98,27 +98,27 @@ class AppLockTimerTests: XCTestCase {
setupTimer(unlocked: true, backgroundedAt: now) setupTimer(unlocked: true, backgroundedAt: now)
var nextCheck = now + halfGracePeriod var nextCheck = now + halfGracePeriod
XCTAssertFalse(timer.computeLockState(willEnterForegroundAt: nextCheck), XCTAssertFalse(timer.computeLockState(didBecomeActiveAt: nextCheck),
"The app should remain unlocked when it was unlocked and backgrounded for less then the grace period.") "The app should remain unlocked when it was unlocked and backgrounded for less then the grace period.")
timer.applicationDidEnterBackground(date: nextCheck) timer.applicationDidEnterBackground(date: nextCheck)
nextCheck = now + gracePeriod nextCheck = now + gracePeriod
XCTAssertFalse(timer.computeLockState(willEnterForegroundAt: nextCheck), XCTAssertFalse(timer.computeLockState(didBecomeActiveAt: nextCheck),
"The app should remain unlocked when repeating the backgrounded and foreground within the grace period.") "The app should remain unlocked when repeating the backgrounded and foreground within the grace period.")
timer.applicationDidEnterBackground(date: nextCheck) timer.applicationDidEnterBackground(date: nextCheck)
nextCheck = now + gracePeriod + halfGracePeriod nextCheck = now + gracePeriod + halfGracePeriod
XCTAssertFalse(timer.computeLockState(willEnterForegroundAt: nextCheck), XCTAssertFalse(timer.computeLockState(didBecomeActiveAt: nextCheck),
"The app should remain unlocked when repeating the backgrounded and foreground within the grace period.") "The app should remain unlocked when repeating the backgrounded and foreground within the grace period.")
timer.applicationDidEnterBackground(date: nextCheck) timer.applicationDidEnterBackground(date: nextCheck)
nextCheck = now + gracePeriodX2 nextCheck = now + gracePeriodX2
XCTAssertFalse(timer.computeLockState(willEnterForegroundAt: nextCheck), XCTAssertFalse(timer.computeLockState(didBecomeActiveAt: nextCheck),
"The app should remain unlocked when repeating the backgrounded and foreground within the grace period.") "The app should remain unlocked when repeating the backgrounded and foreground within the grace period.")
timer.applicationDidEnterBackground(date: nextCheck) timer.applicationDidEnterBackground(date: nextCheck)
nextCheck = now + gracePeriodX10 nextCheck = now + gracePeriodX10
XCTAssertTrue(timer.computeLockState(willEnterForegroundAt: nextCheck), XCTAssertTrue(timer.computeLockState(didBecomeActiveAt: nextCheck),
"The app should become locked however when finally staying backgrounded for longer than the grace period.") "The app should become locked however when finally staying backgrounded for longer than the grace period.")
} }
@ -128,22 +128,59 @@ class AppLockTimerTests: XCTestCase {
let backgroundDate = now + gracePeriodX10 let backgroundDate = now + gracePeriodX10
timer.applicationDidEnterBackground(date: backgroundDate) timer.applicationDidEnterBackground(date: backgroundDate)
XCTAssertFalse(timer.computeLockState(willEnterForegroundAt: backgroundDate + 1), XCTAssertFalse(timer.computeLockState(didBecomeActiveAt: backgroundDate + 1),
"The grace period should be measured from the time the app was backgrounded, and not when it was unlocked.") "The grace period should be measured from the time the app was backgrounded, and not when it was unlocked.")
} }
func testChangingTimeLocksApp() { func testChangingTimeLocksApp() {
setupTimer(unlocked: true, backgroundedAt: now) setupTimer(unlocked: true, backgroundedAt: now)
XCTAssertTrue(timer.computeLockState(willEnterForegroundAt: now - 1), XCTAssertTrue(timer.computeLockState(didBecomeActiveAt: now - 1),
"The the device's clock is changed to before the app was backgrounded, the device should remain locked.") "The the device's clock is changed to before the app was backgrounded, the device should remain locked.")
} }
func testNoGracePeriod() {
// Given a timer with no grace period that is in the background.
setupTimer(gracePeriod: 0, unlocked: true)
let backgroundDate = now + 1
timer.applicationDidEnterBackground(date: backgroundDate)
// Then the app should be locked immediately.
XCTAssertTrue(timer.computeLockState(didBecomeActiveAt: backgroundDate))
}
func testResignActive() {
// Given a timer with no grace period.
setupTimer(gracePeriod: 0, unlocked: true)
// When entering the background.
timer.applicationDidEnterBackground(date: now)
// Then the app should be locked.
XCTAssertTrue(timer.computeLockState(didBecomeActiveAt: now + 1))
// When the app resigns active but doesn't enter the background.
// (Nothing to do here, we just don't call applicationDidEnterBackground).
// Then the app should also remain locked.
XCTAssertTrue(timer.computeLockState(didBecomeActiveAt: now + 2))
// When unlocking the app and resigning active (but not entering the background)
timer.registerUnlock()
// (Again, nothing to do here for resigning active)
// Then the app should not become locked.
XCTAssertFalse(timer.computeLockState(didBecomeActiveAt: now + 3))
}
// MARK: - Helpers
/// Sets up the timer for testing. /// Sets up the timer for testing.
/// - Parameters: /// - Parameters:
/// - gracePeriod: Set up the test with a custom grace period for the timer. Defaults to 3 minutes.
/// - unlocked: Whether the timer should consider itself unlocked or not. /// - unlocked: Whether the timer should consider itself unlocked or not.
/// - backgroundedDate: If not nil, the timer will consider the app to have been backgrounded at the specified date. /// - backgroundedDate: If not nil, the timer will consider the app to have been backgrounded at the specified date.
private func setupTimer(unlocked: Bool, backgroundedAt backgroundedDate: Date? = nil) { private func setupTimer(gracePeriod: TimeInterval = 180, unlocked: Bool, backgroundedAt backgroundedDate: Date? = nil) {
timer = AppLockTimer(gracePeriod: 180) timer = AppLockTimer(gracePeriod: gracePeriod)
if unlocked { if unlocked {
timer.registerUnlock() timer.registerUnlock()
} }

1
changelog.d/2026.change Normal file
View File

@ -0,0 +1 @@
Show the lock screen placeholder whenever the app resigns as active.