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]
}
/// 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.
func configure(with windowScene: UIWindowScene) {
mainWindow = windowScene.keyWindow
@ -58,21 +63,33 @@ class WindowManager {
/// Shows the main and overlay window combo, hiding the alternate window.
func switchToMain() {
mainWindow.isHidden = false
overlayWindow.isHidden = false
alternateWindow.isHidden = true
switchTask = Task {
mainWindow.isHidden = false
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.
func switchToAlternate() {
alternateWindow.isHidden = false
overlayWindow.isHidden = true
mainWindow.isHidden = true
// 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
// e.g. the keyboard being displayed on top of a call sheet.
mainWindow.endEditing(true)
switchTask = Task {
alternateWindow.isHidden = false
// 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
// e.g. the keyboard being displayed on top of a call sheet.
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.
showPlaceholder()
notificationCenter.publisher(for: UIApplication.willResignActiveNotification)
.sink { [weak self] _ in
self?.applicationWillResignActive()
}
.store(in: &cancellables)
notificationCenter.publisher(for: UIApplication.didEnterBackgroundNotification)
.sink { [weak self] _ in
self?.applicationDidEnterBackground()
}
.store(in: &cancellables)
notificationCenter.publisher(for: UIApplication.willEnterForegroundNotification)
notificationCenter.publisher(for: UIApplication.didBecomeActiveNotification)
.sink { [weak self] _ in
self?.applicationWillEnterForeground()
self?.applicationDidBecomeActive()
}
.store(in: &cancellables)
}
@ -68,19 +74,23 @@ class AppLockFlowCoordinator: CoordinatorProtocol {
// MARK: - App unlock
private func applicationDidEnterBackground() {
private func applicationWillResignActive() {
unlockTask = nil
guard appLockService.isEnabled else { return }
appLockService.applicationDidEnterBackground()
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.computeNeedsUnlock(willEnterForegroundAt: .now) else {
guard appLockService.computeNeedsUnlock(didBecomeActiveAt: .now) else {
// Reveal the app again if within the grace period.
actionsSubject.send(.unlockApp)
return

View File

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

View File

@ -111,8 +111,8 @@ class AppLockService: AppLockServiceProtocol {
timer.applicationDidEnterBackground()
}
func computeNeedsUnlock(willEnterForegroundAt date: Date) -> Bool {
timer.computeLockState(willEnterForegroundAt: date)
func computeNeedsUnlock(didBecomeActiveAt date: Date) -> Bool {
timer.computeLockState(didBecomeActiveAt: date)
}
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.
func applicationDidEnterBackground()
/// 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.
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.
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.
private var isLocked = true
/// The date when the app was last backgrounded whilst in an unlocked state.
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.
/// - Parameter gracePeriod: The amount of time the app should remain unlocked for whilst backgrounded.
init(gracePeriod: TimeInterval) {
@ -36,13 +41,18 @@ class AppLockTimer {
/// Signals to the timer to track how long the app will be backgrounded for.
func applicationDidEnterBackground(date: Date = .now) {
isInBackground = true
// Only update the last background date if the app is currently unlocked.
guard !isLocked else { return }
lastUnlockedBackground = date
}
/// Asks the timer to recompute the lock state on foregrounding.
func computeLockState(willEnterForegroundAt date: Date) -> Bool {
/// Asks the timer to recompute the lock state when active. If ``applicationDidEnterBackground`` hasn't been
/// 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 }
isLocked = date.timeIntervalSince(lastUnlockedBackground) >= gracePeriod

View File

@ -187,7 +187,9 @@ class MockScreen: Identifiable {
context: context)
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.")
}
}

View File

@ -45,7 +45,7 @@ class AppLockUITests: XCTestCase {
try await app.assertScreenshot(.appLockFlow, step: Step.placeholder)
// 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.
try await app.assertScreenshot(.appLockFlow, step: Step.lockScreen)
@ -73,7 +73,7 @@ class AppLockUITests: XCTestCase {
try await app.assertScreenshot(.appLockFlow, step: Step.unlocked)
// 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.
try await app.assertScreenshot(.appLockFlow, step: Step.unlocked)
@ -87,7 +87,7 @@ class AppLockUITests: XCTestCase {
try await app.assertScreenshot(.appLockFlow, step: Step.unlocked)
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)
// When entering an incorrect PIN
@ -106,6 +106,28 @@ class AppLockUITests: XCTestCase {
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
func enterPIN() {

View File

@ -34,63 +34,63 @@ class AppLockTimerTests: XCTestCase {
func testTimerLockedOnStartup() {
setupTimer(unlocked: false)
XCTAssertTrue(timer.computeLockState(willEnterForegroundAt: now),
XCTAssertTrue(timer.computeLockState(didBecomeActiveAt: now),
"The app should be locked on a fresh launch.")
setupTimer(unlocked: false)
XCTAssertTrue(timer.computeLockState(willEnterForegroundAt: now + 1),
XCTAssertTrue(timer.computeLockState(didBecomeActiveAt: now + 1),
"The app should be locked after a fresh launch.")
setupTimer(unlocked: false)
XCTAssertTrue(timer.computeLockState(willEnterForegroundAt: now + halfGracePeriod),
XCTAssertTrue(timer.computeLockState(didBecomeActiveAt: now + halfGracePeriod),
"The app should be locked after a fresh launch.")
setupTimer(unlocked: false)
XCTAssertTrue(timer.computeLockState(willEnterForegroundAt: now + gracePeriod),
XCTAssertTrue(timer.computeLockState(didBecomeActiveAt: now + gracePeriod),
"The app should be locked after a fresh launch.")
setupTimer(unlocked: false)
XCTAssertTrue(timer.computeLockState(willEnterForegroundAt: now + gracePeriodX10),
XCTAssertTrue(timer.computeLockState(didBecomeActiveAt: now + gracePeriodX10),
"The app should be locked after a fresh launch.")
}
func testTimerBeforeFirstUnlock() {
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.")
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.")
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.")
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.")
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.")
}
func testTimerWhenUnlocked() {
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.")
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.")
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.")
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.")
}
@ -98,27 +98,27 @@ class AppLockTimerTests: XCTestCase {
setupTimer(unlocked: true, backgroundedAt: now)
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.")
timer.applicationDidEnterBackground(date: nextCheck)
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.")
timer.applicationDidEnterBackground(date: nextCheck)
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.")
timer.applicationDidEnterBackground(date: nextCheck)
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.")
timer.applicationDidEnterBackground(date: nextCheck)
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.")
}
@ -128,22 +128,59 @@ class AppLockTimerTests: XCTestCase {
let backgroundDate = now + gracePeriodX10
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.")
}
func testChangingTimeLocksApp() {
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.")
}
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.
/// - 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.
/// - 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) {
timer = AppLockTimer(gracePeriod: 180)
private func setupTimer(gracePeriod: TimeInterval = 180, unlocked: Bool, backgroundedAt backgroundedDate: Date? = nil) {
timer = AppLockTimer(gracePeriod: gracePeriod)
if unlocked {
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.