diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index 3e1c65250..d4719bfe2 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -313,6 +313,7 @@ 31DE6B7FD15E1C6E43885717 /* RoomRole.swift in Sources */ = {isa = PBXBuildFile; fileRef = 578AF9CE60816069536C0953 /* RoomRole.swift */; }; 32B7891D937377A59606EDFC /* UserFlowTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21DD8599815136EFF5B73F38 /* UserFlowTests.swift */; }; 32F47002A331817F0E6BD7EB /* RoomMembershipDetailsProxyProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1434D5169F0EE319E226DA7F /* RoomMembershipDetailsProxyProtocol.swift */; }; + 33544885F5DB8016736CC610 /* LinkNewDeviceScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1714DD78BFE27ADD40A6F89 /* LinkNewDeviceScreenViewModelTests.swift */; }; 339BC18777912E1989F2F17D /* Section.swift in Sources */ = {isa = PBXBuildFile; fileRef = 584A61D9C459FAFEF038A7C0 /* Section.swift */; }; 33BA0964A308D2286B39976D /* LabsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9C5AA3EF7EC67C01C75CEDD /* LabsScreen.swift */; }; 33CAC1226DFB8B5D8447D286 /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = 67E7A6F388D3BF85767609D9 /* Sentry */; }; @@ -2917,6 +2918,7 @@ E10DA51DBC8C7E1460DBCCBD /* UserProfileListRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileListRow.swift; sourceTree = ""; }; E157152B11E347F735C3FD6E /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = tr; path = tr.lproj/Localizable.stringsdict; sourceTree = ""; }; E1573D28C8A9FB6399D0EEFB /* SecureBackupLogoutConfirmationScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureBackupLogoutConfirmationScreenCoordinator.swift; sourceTree = ""; }; + E1714DD78BFE27ADD40A6F89 /* LinkNewDeviceScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkNewDeviceScreenViewModelTests.swift; sourceTree = ""; }; E1A5FEF17ED7E6176D922D4F /* RoomDetailsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomDetailsScreen.swift; sourceTree = ""; }; E1E0B4A34E69BD2132BEC521 /* MessageText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageText.swift; sourceTree = ""; }; E1ED17433ADC77287F8904F9 /* CallNotificationRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallNotificationRoomTimelineItem.swift; sourceTree = ""; }; @@ -4982,6 +4984,7 @@ DE5127D6EA05B2E45D0A7D59 /* JoinRoomScreenViewModelTests.swift */, FDB9C37196A4C79F24CE80C6 /* KeychainControllerTests.swift */, C9AC2CC94FA06F728883B694 /* KnockRequestsListScreenViewModelTests.swift */, + E1714DD78BFE27ADD40A6F89 /* LinkNewDeviceScreenViewModelTests.swift */, C5CB64E711E86270B722FAF7 /* LiveLocationManagerTests.swift */, C070FD43DC6BF4E50217965A /* LocalizationTests.swift */, EED6D8956E554CEDFD4FE00D /* LocationSharingScreenViewModelTests.swift */, @@ -7955,6 +7958,7 @@ EEC40663922856C65D1E0DF5 /* KeychainControllerTests.swift in Sources */, BA48D6AFF6421D199148C0A1 /* KnockRequestsListScreenViewModelTests.swift in Sources */, CC961529F9F1854BEC3272C9 /* LayoutMocks.swift in Sources */, + 33544885F5DB8016736CC610 /* LinkNewDeviceScreenViewModelTests.swift in Sources */, CB6FBAF32B1305F914CF4225 /* LiveLocationManagerTests.swift in Sources */, 0033481EE363E4914295F188 /* LocalizationTests.swift in Sources */, F6D07D834279BE17D18A33E9 /* LocationSharingScreenViewModelTests.swift in Sources */, diff --git a/ElementX/Resources/Localizations/en-US.lproj/Localizable.strings b/ElementX/Resources/Localizations/en-US.lproj/Localizable.strings index 0f88e75ed..fb64bcb0c 100644 --- a/ElementX/Resources/Localizations/en-US.lproj/Localizable.strings +++ b/ElementX/Resources/Localizations/en-US.lproj/Localizable.strings @@ -932,6 +932,7 @@ "screen_leave_space_title" = "Leave %1$@?"; "screen_leave_space_title_last_admin" = "You are the only admin for %1$@"; "screen_leave_space_title_last_owner" = "Transfer ownership"; +"screen_link_new_device_authentication_reason_ios" = "Confirm it's you before linking a new device."; "screen_link_new_device_desktop_scanning_title" = "Scan the QR code"; "screen_link_new_device_desktop_step1" = "Open %1$@ on a laptop or desktop computer"; "screen_link_new_device_desktop_step3" = "Scan the QR code with this device"; diff --git a/ElementX/Resources/Localizations/en.lproj/InfoPlist.strings b/ElementX/Resources/Localizations/en.lproj/InfoPlist.strings index af5fe8f29..7ad11888f 100644 --- a/ElementX/Resources/Localizations/en.lproj/InfoPlist.strings +++ b/ElementX/Resources/Localizations/en.lproj/InfoPlist.strings @@ -1,5 +1,5 @@ "NSCameraUsageDescription" = "To take pictures or videos and send them as a message Element X needs access to the camera."; -"NSFaceIDUsageDescription" = "Face ID is used to access your app."; +"NSFaceIDUsageDescription" = "Face ID is used to protect access to your account."; "NSLocationAlwaysAndWhenInUseUsageDescription" = "To share your live location, Element X needs location access when the app is in the background."; "NSLocationWhenInUseUsageDescription" = "Grant location access so that Element X can share your location."; "NSMicrophoneUsageDescription" = "To record and send messages with audio, Element X needs to access the microphone."; diff --git a/ElementX/Resources/Localizations/en.lproj/Localizable.strings b/ElementX/Resources/Localizations/en.lproj/Localizable.strings index 9ed99fe0c..e394e79af 100644 --- a/ElementX/Resources/Localizations/en.lproj/Localizable.strings +++ b/ElementX/Resources/Localizations/en.lproj/Localizable.strings @@ -932,6 +932,7 @@ "screen_leave_space_title" = "Leave %1$@?"; "screen_leave_space_title_last_admin" = "You are the only admin for %1$@"; "screen_leave_space_title_last_owner" = "Transfer ownership"; +"screen_link_new_device_authentication_reason_ios" = "Confirm it's you before linking a new device."; "screen_link_new_device_desktop_scanning_title" = "Scan the QR code"; "screen_link_new_device_desktop_step1" = "Open %1$@ on a laptop or desktop computer"; "screen_link_new_device_desktop_step3" = "Scan the QR code with this device"; diff --git a/ElementX/Sources/FlowCoordinators/LinkNewDeviceFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/LinkNewDeviceFlowCoordinator.swift index 00229f52d..304b9f23e 100644 --- a/ElementX/Sources/FlowCoordinators/LinkNewDeviceFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/LinkNewDeviceFlowCoordinator.swift @@ -15,6 +15,7 @@ enum LinkNewDeviceFlowCoordinatorAction { class LinkNewDeviceFlowCoordinator: FlowCoordinatorProtocol { private let navigationStackCoordinator: NavigationStackCoordinator + private let appLockService: AppLockServiceProtocol private let flowParameters: CommonFlowParameters private var cancellables = Set() @@ -25,8 +26,10 @@ class LinkNewDeviceFlowCoordinator: FlowCoordinatorProtocol { } init(navigationStackCoordinator: NavigationStackCoordinator, + appLockService: AppLockServiceProtocol, flowParameters: CommonFlowParameters) { self.navigationStackCoordinator = navigationStackCoordinator + self.appLockService = appLockService self.flowParameters = flowParameters } @@ -44,6 +47,7 @@ class LinkNewDeviceFlowCoordinator: FlowCoordinatorProtocol { private func presentLinkNewDeviceScreen() { let coordinator = LinkNewDeviceScreenCoordinator(parameters: .init(clientProxy: flowParameters.userSession.clientProxy, + appLockService: appLockService, orientationManager: flowParameters.appMediator.windowManager)) coordinator.actionsPublisher .sink { [weak self] action in diff --git a/ElementX/Sources/FlowCoordinators/SettingsFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/SettingsFlowCoordinator.swift index f812f383c..c9c15f624 100644 --- a/ElementX/Sources/FlowCoordinators/SettingsFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/SettingsFlowCoordinator.swift @@ -181,6 +181,7 @@ class SettingsFlowCoordinator: FlowCoordinatorProtocol { private func startLinkNewDeviceFlow() { let stackCoordinator = NavigationStackCoordinator() let flowCoordinator = LinkNewDeviceFlowCoordinator(navigationStackCoordinator: stackCoordinator, + appLockService: appLockService, flowParameters: flowParameters) flowCoordinator.actionsPublisher .sink { [weak self] action in diff --git a/ElementX/Sources/Generated/Strings.swift b/ElementX/Sources/Generated/Strings.swift index bbb3aeda8..60ec00b70 100644 --- a/ElementX/Sources/Generated/Strings.swift +++ b/ElementX/Sources/Generated/Strings.swift @@ -2170,6 +2170,8 @@ internal nonisolated enum L10n { } /// Transfer ownership internal static var screenLeaveSpaceTitleLastOwner: String { return L10n.tr("Localizable", "screen_leave_space_title_last_owner") } + /// Confirm it's you before linking a new device. + internal static var screenLinkNewDeviceAuthenticationReasonIos: String { return L10n.tr("Localizable", "screen_link_new_device_authentication_reason_ios") } /// Scan the QR code internal static var screenLinkNewDeviceDesktopScanningTitle: String { return L10n.tr("Localizable", "screen_link_new_device_desktop_scanning_title") } /// Open %1$@ on a laptop or desktop computer diff --git a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift index c56e9480f..59d628e07 100644 --- a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift @@ -1001,6 +1001,48 @@ nonisolated class AppLockServiceMock: AppLockServiceProtocol, @unchecked Sendabl return unlockWithBiometricsReturnValue } } + //MARK: - verifyDeviceOwner + + private let verifyDeviceOwnerReasonCallsCountLock = NSLock() + private nonisolated(unsafe) var verifyDeviceOwnerReasonUnderlyingCallsCount = 0 + var verifyDeviceOwnerReasonCallsCount: Int { + get { verifyDeviceOwnerReasonCallsCountLock.withLock { verifyDeviceOwnerReasonUnderlyingCallsCount } } + set { verifyDeviceOwnerReasonCallsCountLock.withLock { verifyDeviceOwnerReasonUnderlyingCallsCount = newValue } } + } + var verifyDeviceOwnerReasonCalled: Bool { + return verifyDeviceOwnerReasonCallsCount > 0 + } + private let verifyDeviceOwnerReasonReceivedReasonLock = NSLock() + private nonisolated(unsafe) var verifyDeviceOwnerReasonUnderlyingReceivedReason: String? + var verifyDeviceOwnerReasonReceivedReason: String? { + get { verifyDeviceOwnerReasonReceivedReasonLock.withLock { verifyDeviceOwnerReasonUnderlyingReceivedReason } } + set { verifyDeviceOwnerReasonReceivedReasonLock.withLock { verifyDeviceOwnerReasonUnderlyingReceivedReason = newValue } } + } + private let verifyDeviceOwnerReasonReceivedInvocationsLock = NSLock() + private nonisolated(unsafe) var verifyDeviceOwnerReasonUnderlyingReceivedInvocations: [String] = [] + var verifyDeviceOwnerReasonReceivedInvocations: [String] { + get { verifyDeviceOwnerReasonReceivedInvocationsLock.withLock { verifyDeviceOwnerReasonUnderlyingReceivedInvocations } } + set { verifyDeviceOwnerReasonReceivedInvocationsLock.withLock { verifyDeviceOwnerReasonUnderlyingReceivedInvocations = newValue } } + } + + private let verifyDeviceOwnerReasonReturnValueLock = NSLock() + private nonisolated(unsafe) var verifyDeviceOwnerReasonUnderlyingReturnValue: AppLockDeviceOwnerResult! + var verifyDeviceOwnerReasonReturnValue: AppLockDeviceOwnerResult! { + get { verifyDeviceOwnerReasonReturnValueLock.withLock { verifyDeviceOwnerReasonUnderlyingReturnValue } } + set { verifyDeviceOwnerReasonReturnValueLock.withLock { verifyDeviceOwnerReasonUnderlyingReturnValue = newValue } } + } + nonisolated(unsafe) var verifyDeviceOwnerReasonClosure: ((String) async -> AppLockDeviceOwnerResult)? + + @concurrent func verifyDeviceOwner(reason: String) async -> AppLockDeviceOwnerResult { + verifyDeviceOwnerReasonCallsCountLock.withLock { verifyDeviceOwnerReasonUnderlyingCallsCount += 1 } + verifyDeviceOwnerReasonReceivedReason = reason + verifyDeviceOwnerReasonReceivedInvocationsLock.withLock { verifyDeviceOwnerReasonUnderlyingReceivedInvocations.append(reason) } + if let verifyDeviceOwnerReasonClosure = verifyDeviceOwnerReasonClosure { + return await verifyDeviceOwnerReasonClosure(reason) + } else { + return verifyDeviceOwnerReasonReturnValue + } + } } nonisolated class AppMediatorMock: AppMediatorProtocol, @unchecked Sendable { var windowManager: WindowManagerProtocol { diff --git a/ElementX/Sources/Screens/LinkNewDeviceScreen/LinkNewDeviceScreenCoordinator.swift b/ElementX/Sources/Screens/LinkNewDeviceScreen/LinkNewDeviceScreenCoordinator.swift index 6961979d7..9370d0d3b 100644 --- a/ElementX/Sources/Screens/LinkNewDeviceScreen/LinkNewDeviceScreenCoordinator.swift +++ b/ElementX/Sources/Screens/LinkNewDeviceScreen/LinkNewDeviceScreenCoordinator.swift @@ -16,6 +16,7 @@ enum LinkNewDeviceScreenCoordinatorAction { struct LinkNewDeviceScreenCoordinatorParameters { let clientProxy: ClientProxyProtocol + let appLockService: AppLockServiceProtocol let orientationManager: OrientationManagerProtocol } @@ -31,7 +32,8 @@ final class LinkNewDeviceScreenCoordinator: CoordinatorProtocol { } init(parameters: LinkNewDeviceScreenCoordinatorParameters) { - viewModel = LinkNewDeviceScreenViewModel(clientProxy: parameters.clientProxy) + viewModel = LinkNewDeviceScreenViewModel(clientProxy: parameters.clientProxy, + appLockService: parameters.appLockService) orientationManager = parameters.orientationManager } diff --git a/ElementX/Sources/Screens/LinkNewDeviceScreen/LinkNewDeviceScreenModels.swift b/ElementX/Sources/Screens/LinkNewDeviceScreen/LinkNewDeviceScreenModels.swift index c2fdb05f6..e30e98e3d 100644 --- a/ElementX/Sources/Screens/LinkNewDeviceScreen/LinkNewDeviceScreenModels.swift +++ b/ElementX/Sources/Screens/LinkNewDeviceScreen/LinkNewDeviceScreenModels.swift @@ -15,8 +15,14 @@ enum LinkNewDeviceScreenViewModelAction { struct LinkNewDeviceScreenViewState: BindableState { nonisolated enum Mode: Equatable { + enum ReadyState: Equatable { + case idle + case verifyingDeviceOwner + case generatingCode + } + case loading - case readyToLink(isGeneratingCode: Bool) + case readyToLink(ReadyState) case error(QRCodeLoginState.ErrorState) } diff --git a/ElementX/Sources/Screens/LinkNewDeviceScreen/LinkNewDeviceScreenViewModel.swift b/ElementX/Sources/Screens/LinkNewDeviceScreen/LinkNewDeviceScreenViewModel.swift index 9c5ae443a..7f7451663 100644 --- a/ElementX/Sources/Screens/LinkNewDeviceScreen/LinkNewDeviceScreenViewModel.swift +++ b/ElementX/Sources/Screens/LinkNewDeviceScreen/LinkNewDeviceScreenViewModel.swift @@ -12,15 +12,19 @@ import SwiftUI typealias LinkNewDeviceScreenViewModelType = StateStoreViewModelV2 class LinkNewDeviceScreenViewModel: LinkNewDeviceScreenViewModelType, LinkNewDeviceScreenViewModelProtocol { + private enum Device { case mobileDevice, desktopComputer } + private let clientProxy: ClientProxyProtocol + private let appLockService: AppLockServiceProtocol private let actionsSubject: PassthroughSubject = .init() var actionsPublisher: AnyPublisher { actionsSubject.eraseToAnyPublisher() } - init(clientProxy: ClientProxyProtocol, initialState: LinkNewDeviceScreenViewState? = nil) { + init(clientProxy: ClientProxyProtocol, appLockService: AppLockServiceProtocol, initialState: LinkNewDeviceScreenViewState? = nil) { self.clientProxy = clientProxy + self.appLockService = appLockService if let initialState { super.init(initialViewState: initialState) @@ -41,9 +45,9 @@ class LinkNewDeviceScreenViewModel: LinkNewDeviceScreenViewModelType, LinkNewDev switch viewAction { case .linkMobileDevice: - Task { await linkMobileDevice() } + Task { await verifyAndLink(.mobileDevice) } case .linkDesktopComputer: - actionsSubject.send(.linkDesktopComputer) + Task { await verifyAndLink(.desktopComputer) } case .errorAction(let action): handleErrorAction(action) case .dismiss: @@ -55,14 +59,41 @@ class LinkNewDeviceScreenViewModel: LinkNewDeviceScreenViewModelType, LinkNewDev private func checkQRCodeLoginSupport() async { if await clientProxy.isLoginWithQRCodeSupported { - state.mode = .readyToLink(isGeneratingCode: false) + state.mode = .readyToLink(.idle) } else { state.mode = .error(.notSupported) } } + private func verifyAndLink(_ device: Device) async { + state.mode = .readyToLink(.verifyingDeviceOwner) + + switch await appLockService.verifyDeviceOwner(reason: L10n.screenLinkNewDeviceAuthenticationReasonIos) { + case .verified, .unavailable: + break + case .appLockPINRequired: + // Follow-up PR: present the App Lock PIN screen to verify the device owner. + return + case .cancelled, .unverified: + state.mode = .readyToLink(.idle) + return + case .error: + // TODO: Failure UX is pending product confirmation; using the generic error state for now. + state.mode = .error(.unknown) + return + } + + switch device { + case .mobileDevice: + await linkMobileDevice() // Automatically sets the state. + case .desktopComputer: + actionsSubject.send(.linkDesktopComputer) + state.mode = .readyToLink(.idle) + } + } + private func linkMobileDevice() async { - state.mode = .readyToLink(isGeneratingCode: true) + state.mode = .readyToLink(.generatingCode) let linkNewDeviceService = clientProxy.linkNewDeviceService() @@ -77,7 +108,7 @@ class LinkNewDeviceScreenViewModel: LinkNewDeviceScreenViewModelType, LinkNewDev } actionsSubject.send(.linkMobileDevice(progressPublisher)) - state.mode = .readyToLink(isGeneratingCode: false) + state.mode = .readyToLink(.idle) } catch { // This is hard to share a mapping from the QRCodeLoginError with the // QRCodeLoginScreen given that some of those are scan errors… @@ -89,7 +120,7 @@ class LinkNewDeviceScreenViewModel: LinkNewDeviceScreenViewModelType, LinkNewDev switch action { case .startOver: // Reset to ready state to allow trying again. - state.mode = .readyToLink(isGeneratingCode: false) + state.mode = .readyToLink(.idle) case .openSettings, .signInManually: MXLog.error("Unexpected error action: \(action)") actionsSubject.send(.dismiss) diff --git a/ElementX/Sources/Screens/LinkNewDeviceScreen/View/LinkNewDeviceScreen.swift b/ElementX/Sources/Screens/LinkNewDeviceScreen/View/LinkNewDeviceScreen.swift index 5ffac086b..3d0576c74 100644 --- a/ElementX/Sources/Screens/LinkNewDeviceScreen/View/LinkNewDeviceScreen.swift +++ b/ElementX/Sources/Screens/LinkNewDeviceScreen/View/LinkNewDeviceScreen.swift @@ -59,7 +59,8 @@ struct LinkNewDeviceScreen: View { } .buttonStyle(.compound(.primary)) .disabled(true) - case .readyToLink(let isGeneratingCode): + case .readyToLink(let linkState): + let isGeneratingCode = linkState == .generatingCode VStack(spacing: 16) { Button { context.send(viewAction: .linkMobileDevice) } label: { Label { @@ -84,7 +85,7 @@ struct LinkNewDeviceScreen: View { .accessibilityIdentifier(A11yIdentifiers.linkNewDeviceScreen.desktopComputer) } } - .disabled(isGeneratingCode) + .disabled(linkState != .idle) case .error: EmptyView() // Not reachable. } @@ -103,8 +104,9 @@ struct LinkNewDeviceScreen: View { // MARK: - Previews struct LinkNewDeviceScreen_Previews: PreviewProvider, TestablePreview { - static let viewModel = makeViewModel(mode: .readyToLink(isGeneratingCode: false)) - static let generatingViewModel = makeViewModel(mode: .readyToLink(isGeneratingCode: true)) + static let viewModel = makeViewModel(mode: .readyToLink(.idle)) + static let verifyingViewModel = makeViewModel(mode: .readyToLink(.verifyingDeviceOwner)) + static let generatingViewModel = makeViewModel(mode: .readyToLink(.generatingCode)) static let loadingViewModel = makeViewModel(mode: .loading) static let unsupportedViewModel = makeViewModel(mode: .error(.notSupported)) static let unknownErrorViewModel = makeViewModel(mode: .error(.unknown)) @@ -114,13 +116,19 @@ struct LinkNewDeviceScreen_Previews: PreviewProvider, TestablePreview { LinkNewDeviceScreen(context: viewModel.context) } .previewDisplayName("Ready") - .snapshotPreferences(expect: viewModel.context.observe(\.viewState.mode).map { $0 == .readyToLink(isGeneratingCode: false) }) + .snapshotPreferences(expect: viewModel.context.observe(\.viewState.mode).map { $0 == .readyToLink(.idle) }) + + ElementNavigationStack { + LinkNewDeviceScreen(context: verifyingViewModel.context) + } + .previewDisplayName("Verifying") + .snapshotPreferences(expect: verifyingViewModel.context.observe(\.viewState.mode).map { $0 == .readyToLink(.verifyingDeviceOwner) }) ElementNavigationStack { LinkNewDeviceScreen(context: generatingViewModel.context) } .previewDisplayName("Generating") - .snapshotPreferences(expect: generatingViewModel.context.observe(\.viewState.mode).map { $0 == .readyToLink(isGeneratingCode: true) }) + .snapshotPreferences(expect: generatingViewModel.context.observe(\.viewState.mode).map { $0 == .readyToLink(.generatingCode) }) ElementNavigationStack { LinkNewDeviceScreen(context: loadingViewModel.context) @@ -142,6 +150,7 @@ struct LinkNewDeviceScreen_Previews: PreviewProvider, TestablePreview { static func makeViewModel(mode: LinkNewDeviceScreenViewState.Mode) -> LinkNewDeviceScreenViewModel { LinkNewDeviceScreenViewModel(clientProxy: ClientProxyMock(.init()), + appLockService: AppLockServiceMock.mock(), initialState: .init(mode: mode, showLinkDesktopComputerButton: true)) } diff --git a/ElementX/Sources/Services/AppLock/AppLockService.swift b/ElementX/Sources/Services/AppLock/AppLockService.swift index c2308dfd9..777271929 100644 --- a/ElementX/Sources/Services/AppLock/AppLockService.swift +++ b/ElementX/Sources/Services/AppLock/AppLockService.swift @@ -17,6 +17,7 @@ class AppLockService: AppLockServiceProtocol { private let timer: AppLockTimer private let unlockPolicy: LAPolicy = .deviceOwnerAuthenticationWithBiometrics + private let deviceOwnerPolicy: LAPolicy = .deviceOwnerAuthentication var isMandatory: Bool { appSettings.appLockIsMandatory @@ -163,6 +164,30 @@ class AppLockService: AppLockServiceProtocol { } } + func verifyDeviceOwner(reason: String) async -> AppLockDeviceOwnerResult { + let context = verificationContext() + + var error: NSError? + guard context.canEvaluatePolicy(deviceOwnerPolicy, error: &error) else { + MXLog.warning("Device owner verification unavailable: \(String(describing: error))") + return isEnabled ? .appLockPINRequired : .unavailable + } + + do { + guard try await context.evaluatePolicy(deviceOwnerPolicy, localizedReason: reason) else { + MXLog.warning("Device owner verification completed without success.") + return .unverified + } + return .verified + } catch LAError.userCancel, LAError.systemCancel { + MXLog.info("Device owner verification was cancelled.") + return .cancelled + } catch { + MXLog.warning("Device owner verification failed: \(error)") + return .error + } + } + // MARK: - Private /// Queries the context for supported biometrics and enrolment state. @@ -187,6 +212,12 @@ class AppLockService: AppLockServiceProtocol { return context } + /// Creates a fresh context for device owner verification so the user is always prompted. + private func verificationContext() -> LAContext { + // Keep using the injected context for tests etc. + type(of: context) == LAContext.self ? LAContext() : context + } + /// Shared logic for completing an unlock via a PIN or biometrics. private func completeUnlock() { timer.registerUnlock() diff --git a/ElementX/Sources/Services/AppLock/AppLockServiceProtocol.swift b/ElementX/Sources/Services/AppLock/AppLockServiceProtocol.swift index c84666344..90c123501 100644 --- a/ElementX/Sources/Services/AppLock/AppLockServiceProtocol.swift +++ b/ElementX/Sources/Services/AppLock/AppLockServiceProtocol.swift @@ -34,6 +34,22 @@ enum AppLockServiceBiometricResult { case interrupted } +/// The result of an attempt to verify that the user is the device owner. +enum AppLockDeviceOwnerResult { + /// The device owner was successfully verified. + case verified + /// The verification completed without verifying the user. + case unverified + /// Verification isn't available because a device passcode isn't set. + case unavailable + /// Verification isn't available, but an App Lock PIN is set and can be used instead. + case appLockPINRequired + /// The verification attempt was cancelled. + case cancelled + /// The verification couldn't be completed due to an error. + case error +} + protocol AppLockServiceProtocol: AnyObject { /// The use of a PIN code is mandatory for this device. var isMandatory: Bool { get } @@ -72,6 +88,13 @@ protocol AppLockServiceProtocol: AnyObject { /// Attempt to unlock the app using FaceID or TouchID. func unlockWithBiometrics() async -> AppLockServiceBiometricResult + /// Verifies the device owner via the device passcode/biometrics, e.g. to gate a sensitive action. + /// + /// This is a standalone identity check: it does **not** unlock the app or touch any App Lock + /// state (the grace-period timer or PIN attempt count). + /// - Parameter reason: The localised reason shown in the system prompt. + func verifyDeviceOwner(reason: String) async -> AppLockDeviceOwnerResult + /// The number of attempts the user had made to unlock with a PIN code. /// /// Note: We don't track the biometric attempts as LAContext does that automatically. diff --git a/ElementX/Sources/Services/AppLock/AppLockTimer.swift b/ElementX/Sources/Services/AppLock/AppLockTimer.swift index 1cd42c107..e674b9585 100644 --- a/ElementX/Sources/Services/AppLock/AppLockTimer.swift +++ b/ElementX/Sources/Services/AppLock/AppLockTimer.swift @@ -15,7 +15,7 @@ final nonisolated class AppLockTimer { /// 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 ``computeLockState`` 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? diff --git a/ElementX/Sources/Services/AppLock/LAContextMock.swift b/ElementX/Sources/Services/AppLock/LAContextMock.swift index 359eba741..c2ee20049 100644 --- a/ElementX/Sources/Services/AppLock/LAContextMock.swift +++ b/ElementX/Sources/Services/AppLock/LAContextMock.swift @@ -25,15 +25,26 @@ nonisolated class LAContextMock: LAContext { internalEvaluatedPolicyDomainStateValue } + var canEvaluatePolicyReturnValue: Bool? override func canEvaluatePolicy(_ policy: LAPolicy, error: NSErrorPointer) -> Bool { - let result = super.canEvaluatePolicy(policy, error: error) + let result = canEvaluatePolicyReturnValue ?? super.canEvaluatePolicy(policy, error: error) updateInternalValues() return result } var evaluatePolicyReturnValue: Bool! + var evaluatePolicyThrowableError: Error? + var evaluatePolicyCallsCount = 0 + var evaluatePolicyCalled: Bool { + evaluatePolicyCallsCount > 0 + } + override func evaluatePolicy(_ policy: LAPolicy, localizedReason: String) async throws -> Bool { + evaluatePolicyCallsCount += 1 updateInternalValues() + if let evaluatePolicyThrowableError { + throw evaluatePolicyThrowableError + } return evaluatePolicyReturnValue } diff --git a/ElementX/Sources/UITests/UITestsAppCoordinator.swift b/ElementX/Sources/UITests/UITestsAppCoordinator.swift index e7fcd03b7..b46695a44 100644 --- a/ElementX/Sources/UITests/UITestsAppCoordinator.swift +++ b/ElementX/Sources/UITests/UITestsAppCoordinator.swift @@ -784,6 +784,7 @@ class MockScreen: Identifiable { let navigationStackCoordinator = NavigationStackCoordinator() let flowCoordinator = LinkNewDeviceFlowCoordinator(navigationStackCoordinator: navigationStackCoordinator, + appLockService: AppLockServiceMock.mock(), flowParameters: CommonFlowParameters(userSession: UserSessionMock(.init(clientProxy: clientProxy)), bugReportService: BugReportServiceMock(.init()), elementCallService: ElementCallServiceMock(.init()), diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/linkNewDeviceScreen.Verifying-iPad-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/linkNewDeviceScreen.Verifying-iPad-en-GB.png new file mode 100644 index 000000000..9830dfe5f --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/linkNewDeviceScreen.Verifying-iPad-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c550f1ef9a8e960e08afd8b340442ad99b9c73ff62f45cbe998da9ae05405ea5 +size 111107 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/linkNewDeviceScreen.Verifying-iPad-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/linkNewDeviceScreen.Verifying-iPad-pseudo.png new file mode 100644 index 000000000..9d293b21d --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/linkNewDeviceScreen.Verifying-iPad-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:79f1a9f769ba0b72a31f825e3c60828be64a4ca1084a939b57b8b79ca2ed33c5 +size 122727 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/linkNewDeviceScreen.Verifying-iPhone-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/linkNewDeviceScreen.Verifying-iPhone-en-GB.png new file mode 100644 index 000000000..833a25eff --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/linkNewDeviceScreen.Verifying-iPhone-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:217d5d9041037d68c23fbd9c5f5f07aa1c481ee43e3814794c709fd19a5bc505 +size 62602 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/linkNewDeviceScreen.Verifying-iPhone-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/linkNewDeviceScreen.Verifying-iPhone-pseudo.png new file mode 100644 index 000000000..bbd0216b0 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/linkNewDeviceScreen.Verifying-iPhone-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0675d204a7f5e934cb52f66ecd1840471480575a17f81e64866cf11df98cd8da +size 80524 diff --git a/UnitTests/Sources/AppLock/AppLockServiceTests.swift b/UnitTests/Sources/AppLock/AppLockServiceTests.swift index fdeb86e62..ee981159f 100644 --- a/UnitTests/Sources/AppLock/AppLockServiceTests.swift +++ b/UnitTests/Sources/AppLock/AppLockServiceTests.swift @@ -8,6 +8,7 @@ @testable import ElementX import Foundation +import LocalAuthentication import Testing @MainActor @@ -346,4 +347,100 @@ final class AppLockServiceTests { // Then the attempts counts should both be reset. #expect(appSettings.appLockNumberOfPINAttempts == 0, "The PIN attempts should be reset.") } + + // MARK: - Device Owner Verification + + @Test + func deviceOwnerVerified() async { + // Given a service that can evaluate the device owner policy successfully. + let context = LAContextMock() + context.canEvaluatePolicyReturnValue = true + context.evaluatePolicyReturnValue = true + service = AppLockService(keychainController: keychainController, appSettings: appSettings, context: context) + + // When verifying the device owner. + let result = await service.verifyDeviceOwner(reason: "Test") + + // Then the owner should be verified. + #expect(result == .verified) + } + + @Test + func deviceOwnerUnverified() async { + // Given a service where the policy evaluation completes without success. + let context = LAContextMock() + context.canEvaluatePolicyReturnValue = true + context.evaluatePolicyReturnValue = false + service = AppLockService(keychainController: keychainController, appSettings: appSettings, context: context) + + // When verifying the device owner. + let result = await service.verifyDeviceOwner(reason: "Test") + + // Then the result should be unverified. + #expect(result == .unverified) + } + + @Test + func deviceOwnerVerificationCancelled() async { + // Given a service where the user cancels the prompt. + let context = LAContextMock() + context.canEvaluatePolicyReturnValue = true + context.evaluatePolicyThrowableError = LAError(.userCancel) + service = AppLockService(keychainController: keychainController, appSettings: appSettings, context: context) + + // When verifying the device owner. + let result = await service.verifyDeviceOwner(reason: "Test") + + // Then the result should be cancelled. + #expect(result == .cancelled) + } + + @Test + func deviceOwnerVerificationErrored() async { + // Given a service where the policy evaluation throws an error. + let context = LAContextMock() + context.canEvaluatePolicyReturnValue = true + context.evaluatePolicyThrowableError = LAError(.authenticationFailed) + service = AppLockService(keychainController: keychainController, appSettings: appSettings, context: context) + + // When verifying the device owner. + let result = await service.verifyDeviceOwner(reason: "Test") + + // Then the result should be an error. + #expect(result == .error) + } + + @Test + func deviceOwnerVerificationUnavailableWithoutPIN() async { + // Given a service that can't evaluate the policy and has no App Lock PIN set. + let context = LAContextMock() + context.canEvaluatePolicyReturnValue = false + service = AppLockService(keychainController: keychainController, appSettings: appSettings, context: context) + #expect(!service.isEnabled, "The service shouldn't be enabled.") + + // When verifying the device owner. + let result = await service.verifyDeviceOwner(reason: "Test") + + // Then verification should be unavailable. + #expect(result == .unavailable) + } + + @Test + func deviceOwnerVerificationRequiresAppLockPIN() async { + // Given a service that can't evaluate the policy but has an App Lock PIN set. + let context = LAContextMock() + context.canEvaluatePolicyReturnValue = false + service = AppLockService(keychainController: keychainController, appSettings: appSettings, context: context) + guard case .success = service.setupPINCode("2023") else { + Issue.record("The PIN should be valid.") + return + } + #expect(service.isEnabled, "The service should be enabled.") + + // When verifying the device owner. + let result = await service.verifyDeviceOwner(reason: "Test") + + // Then it should request the App Lock PIN instead. + #expect(result == .appLockPINRequired) + } } diff --git a/UnitTests/Sources/LinkNewDeviceScreenViewModelTests.swift b/UnitTests/Sources/LinkNewDeviceScreenViewModelTests.swift new file mode 100644 index 000000000..232ccc0ad --- /dev/null +++ b/UnitTests/Sources/LinkNewDeviceScreenViewModelTests.swift @@ -0,0 +1,262 @@ +// +// Copyright 2025 Element Creations Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. +// Please see LICENSE files in the repository root for full details. +// + +import Combine +@testable import ElementX +import SwiftUI +import Testing + +@MainActor +class LinkNewDeviceScreenViewModelTests { + var linkNewDeviceService: LinkNewDeviceServiceMock! + var appLockService: AppLockServiceMock! + + var viewModel: LinkNewDeviceScreenViewModel! + var context: LinkNewDeviceScreenViewModel.Context { + viewModel.context + } + + // MARK: - QR code support + + @Test + func readyWhenQRCodeLoginSupported() async throws { + // Given a client that supports logging in with a QR code. + setupViewModel(isLoginWithQRCodeSupported: true) + + // Then the screen should become ready to link. + let deferred = deferFulfillment(context.observe(\.viewState.mode)) { $0 == .readyToLink(.idle) } + try await deferred.fulfill() + } + + @Test + func errorWhenQRCodeLoginNotSupported() async throws { + // Given a client that doesn't support logging in with a QR code. + setupViewModel(isLoginWithQRCodeSupported: false) + + // Then the screen should show the unsupported error. + let deferred = deferFulfillment(context.observe(\.viewState.mode)) { $0 == .error(.notSupported) } + try await deferred.fulfill() + } + + // MARK: - Linking + + @Test + func linkingMobileDeviceVerifiesThenGeneratesQRCode() async throws { + // Given a ready screen whose QR code isn't immediately available. + let progressSubject = CurrentValueSubject(.starting) + setupViewModel(linkMobileProgressPublisher: progressSubject.asCurrentValuePublisher(), + mode: .loading) + + // When linking a mobile device. + let deferredGenerating = deferFulfillment(context.observe(\.viewState.mode), + transitionValues: [.readyToLink(.verifyingDeviceOwner), + .readyToLink(.generatingCode)]) + context.send(viewAction: .linkMobileDevice) + + // Then it should verify the device owner and start generating the QR code. + try await deferredGenerating.fulfill() + #expect(appLockService.verifyDeviceOwnerReasonCalled) + #expect(linkNewDeviceService.linkMobileDeviceCalled) + + // When the QR code has been generated. + let deferredAction = deferFulfillment(viewModel.actionsPublisher) { $0.isLinkMobileDevice } + let deferredIdle = deferFulfillment(context.observe(\.viewState.mode)) { $0 == .readyToLink(.idle) } + progressSubject.send(.qrReady(LinkNewDeviceServiceMock.mockQRCodeImage)) + + // Then the flow should continue with linking the mobile device. + try await deferredAction.fulfill() + try await deferredIdle.fulfill() + } + + @Test + func linkingDesktopComputerVerifiesThenEmitsAction() async throws { + // Given a ready screen. + setupViewModel(mode: .loading) + + // When linking a desktop computer. + let deferredAction = deferFulfillment(viewModel.actionsPublisher) { $0.isLinkDesktopComputer } + let deferredMode = deferFulfillment(context.observe(\.viewState.mode), + transitionValues: [.readyToLink(.verifyingDeviceOwner), .readyToLink(.idle)]) + context.send(viewAction: .linkDesktopComputer) + + // Then it should verify the device owner, and continue the flow to link a desktop. + try await deferredAction.fulfill() + try await deferredMode.fulfill() + #expect(appLockService.verifyDeviceOwnerReasonCalled) + #expect(!linkNewDeviceService.linkMobileDeviceCalled) + } + + @Test + func linkingProceedsWhenVerificationUnavailable() async throws { + // Given a device with no passcode set, so device owner verification is unavailable. + let progressSubject = CurrentValueSubject(.starting) + setupViewModel(linkMobileProgressPublisher: progressSubject.asCurrentValuePublisher(), + deviceOwnerResult: .unavailable, + mode: .loading) + + // When linking a mobile device. + let deferredGenerating = deferFulfillment(context.observe(\.viewState.mode)) { $0 == .readyToLink(.generatingCode) } + context.send(viewAction: .linkMobileDevice) + + // Then it should proceed by generating a QR code. + try await deferredGenerating.fulfill() + #expect(appLockService.verifyDeviceOwnerReasonCalled) + #expect(linkNewDeviceService.linkMobileDeviceCalled) + } + + @Test + func appLockPINRequiredDoesNotLink() async throws { + // Given a device with no passcode but an App Lock PIN set, so PIN verification is required. + setupViewModel(deviceOwnerResult: .appLockPINRequired, + mode: .loading) + + // When linking a mobile device, it should not proceed to generate a QR code. + // TODO: assert the App Lock PIN screen is presented once it's wired up. + let deferred = deferFailure(context.observe(\.viewState.mode), timeout: .seconds(1)) { $0 == .readyToLink(.generatingCode) } + context.send(viewAction: .linkMobileDevice) + try await deferred.fulfill() + + // Then verification should have been attempted but linking should not have started. + #expect(appLockService.verifyDeviceOwnerReasonCalled) + #expect(!linkNewDeviceService.linkMobileDeviceCalled) + } + + // MARK: - Verification failures + + @Test + func cancellingVerificationReturnsToIdle() async throws { + // Given a screen where the user will cancel the device owner verification. + setupViewModel(deviceOwnerResult: .cancelled, + mode: .loading) + + // When linking a mobile device. + let deferred = deferFulfillment(context.observe(\.viewState.mode), + transitionValues: [.readyToLink(.verifyingDeviceOwner), .readyToLink(.idle)]) + context.send(viewAction: .linkMobileDevice) + + // Then the cancellation should silently return to idle without generating a QR code. + try await deferred.fulfill() + #expect(!linkNewDeviceService.linkMobileDeviceCalled) + } + + @Test + func unverifiedReturnsToIdle() async throws { + // Given a screen where the device owner verification completes without success. + setupViewModel(deviceOwnerResult: .unverified, + mode: .loading) + + // When linking a mobile device. + let deferred = deferFulfillment(context.observe(\.viewState.mode), + transitionValues: [.readyToLink(.verifyingDeviceOwner), .readyToLink(.idle)]) + context.send(viewAction: .linkMobileDevice) + + // Then the unverified result should silently return to idle without generating a QR code. + try await deferred.fulfill() + #expect(!linkNewDeviceService.linkMobileDeviceCalled) + } + + @Test + func verificationErrorShowsError() async throws { + // Given a screen where the device owner verification errors. + setupViewModel(deviceOwnerResult: .error, + mode: .loading) + + // When linking a mobile device. + let deferred = deferFulfillment(context.observe(\.viewState.mode)) { $0 == .error(.unknown) } + context.send(viewAction: .linkMobileDevice) + + // Then the error should surface without generating a QR code. + try await deferred.fulfill() + #expect(!linkNewDeviceService.linkMobileDeviceCalled) + } + + // MARK: - Error actions + + @Test + func startingOverReturnsToIdle() async throws { + // Given a screen in an error state. + setupViewModel(mode: .error(.unknown)) + + // When starting over. + let deferred = deferFulfillment(context.observe(\.viewState.mode)) { $0 == .readyToLink(.idle) } + context.send(viewAction: .errorAction(.startOver)) + + // Then it should return to idle. + try await deferred.fulfill() + } + + @Test + func cancellingErrorEmitsDismiss() async throws { + // Given a screen in an error state. + setupViewModel(mode: .error(.unknown)) + + // When cancelling. + let deferred = deferFulfillment(viewModel.actionsPublisher) { $0.isDismiss } + context.send(viewAction: .errorAction(.cancel)) + + // Then it should dismiss the flow. + try await deferred.fulfill() + } + + @Test + func dismissEmitsAction() async throws { + // Given a ready screen. + setupViewModel(mode: .readyToLink(.idle)) + + // When dismissing. + let deferred = deferFulfillment(viewModel.actionsPublisher) { $0.isDismiss } + context.send(viewAction: .dismiss) + + // Then it should dismiss the flow. + try await deferred.fulfill() + } + + // MARK: - Helpers + + private func setupViewModel(isLoginWithQRCodeSupported: Bool = true, + linkMobileProgressPublisher: LinkNewDeviceService.LinkMobileProgressPublisher = .init(.qrReady(LinkNewDeviceServiceMock.mockQRCodeImage)), + deviceOwnerResult: AppLockDeviceOwnerResult = .verified, + mode: LinkNewDeviceScreenViewState.Mode? = nil) { + let clientProxy = ClientProxyMock(.init()) + clientProxy.underlyingIsLoginWithQRCodeSupported = isLoginWithQRCodeSupported + + linkNewDeviceService = LinkNewDeviceServiceMock(.init(linkMobileProgressPublisher: linkMobileProgressPublisher)) + clientProxy.linkNewDeviceServiceReturnValue = linkNewDeviceService + + appLockService = AppLockServiceMock.mock() + appLockService.verifyDeviceOwnerReasonReturnValue = deviceOwnerResult + + let initialState = mode.map { LinkNewDeviceScreenViewState(mode: $0, showLinkDesktopComputerButton: true) } + + viewModel = LinkNewDeviceScreenViewModel(clientProxy: clientProxy, + appLockService: appLockService, + initialState: initialState) + } +} + +private extension LinkNewDeviceScreenViewModelAction { + var isLinkMobileDevice: Bool { + switch self { + case .linkMobileDevice: true + default: false + } + } + + var isLinkDesktopComputer: Bool { + switch self { + case .linkDesktopComputer: true + default: false + } + } + + var isDismiss: Bool { + switch self { + case .dismiss: true + default: false + } + } +}