diff --git a/ElementX/Sources/Application/Windowing/WindowManager.swift b/ElementX/Sources/Application/Windowing/WindowManager.swift index 06206bfa3..84b72d342 100644 --- a/ElementX/Sources/Application/Windowing/WindowManager.swift +++ b/ElementX/Sources/Application/Windowing/WindowManager.swift @@ -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? + /// 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 + } } } diff --git a/ElementX/Sources/FlowCoordinators/AppLockFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/AppLockFlowCoordinator.swift index 4d235cfae..1a6c60e34 100644 --- a/ElementX/Sources/FlowCoordinators/AppLockFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/AppLockFlowCoordinator.swift @@ -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 diff --git a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift index cb04a2dcd..f8030dda0 100644 --- a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift @@ -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 diff --git a/ElementX/Sources/Services/AppLock/AppLockService.swift b/ElementX/Sources/Services/AppLock/AppLockService.swift index 794478dcf..429b19998 100644 --- a/ElementX/Sources/Services/AppLock/AppLockService.swift +++ b/ElementX/Sources/Services/AppLock/AppLockService.swift @@ -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 { diff --git a/ElementX/Sources/Services/AppLock/AppLockServiceProtocol.swift b/ElementX/Sources/Services/AppLock/AppLockServiceProtocol.swift index fdd328964..ca4194e74 100644 --- a/ElementX/Sources/Services/AppLock/AppLockServiceProtocol.swift +++ b/ElementX/Sources/Services/AppLock/AppLockServiceProtocol.swift @@ -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 diff --git a/ElementX/Sources/Services/AppLock/AppLockTimer.swift b/ElementX/Sources/Services/AppLock/AppLockTimer.swift index 59ada94f8..5aa8f908d 100644 --- a/ElementX/Sources/Services/AppLock/AppLockTimer.swift +++ b/ElementX/Sources/Services/AppLock/AppLockTimer.swift @@ -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 diff --git a/ElementX/Sources/UITests/UITestsAppCoordinator.swift b/ElementX/Sources/UITests/UITestsAppCoordinator.swift index b5b68a6a3..1dca56f56 100644 --- a/ElementX/Sources/UITests/UITestsAppCoordinator.swift +++ b/ElementX/Sources/UITests/UITestsAppCoordinator.swift @@ -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.") } } diff --git a/UITests/Sources/AppLockUITests.swift b/UITests/Sources/AppLockUITests.swift index e8a6dd761..00bba9668 100644 --- a/UITests/Sources/AppLockUITests.swift +++ b/UITests/Sources/AppLockUITests.swift @@ -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() { diff --git a/UnitTests/Sources/AppLock/AppLockTimerTests.swift b/UnitTests/Sources/AppLock/AppLockTimerTests.swift index 54b22faf2..633d158c8 100644 --- a/UnitTests/Sources/AppLock/AppLockTimerTests.swift +++ b/UnitTests/Sources/AppLock/AppLockTimerTests.swift @@ -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() } diff --git a/changelog.d/2026.change b/changelog.d/2026.change new file mode 100644 index 000000000..52b325c6f --- /dev/null +++ b/changelog.d/2026.change @@ -0,0 +1 @@ +Show the lock screen placeholder whenever the app resigns as active. \ No newline at end of file