mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-03 16:10:20 +00:00
Ensure that the current user is the device owner before linking a new device. (#5773)
This commit is contained in:
@@ -313,6 +313,7 @@
|
|||||||
31DE6B7FD15E1C6E43885717 /* RoomRole.swift in Sources */ = {isa = PBXBuildFile; fileRef = 578AF9CE60816069536C0953 /* RoomRole.swift */; };
|
31DE6B7FD15E1C6E43885717 /* RoomRole.swift in Sources */ = {isa = PBXBuildFile; fileRef = 578AF9CE60816069536C0953 /* RoomRole.swift */; };
|
||||||
32B7891D937377A59606EDFC /* UserFlowTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21DD8599815136EFF5B73F38 /* UserFlowTests.swift */; };
|
32B7891D937377A59606EDFC /* UserFlowTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21DD8599815136EFF5B73F38 /* UserFlowTests.swift */; };
|
||||||
32F47002A331817F0E6BD7EB /* RoomMembershipDetailsProxyProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1434D5169F0EE319E226DA7F /* RoomMembershipDetailsProxyProtocol.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 */; };
|
339BC18777912E1989F2F17D /* Section.swift in Sources */ = {isa = PBXBuildFile; fileRef = 584A61D9C459FAFEF038A7C0 /* Section.swift */; };
|
||||||
33BA0964A308D2286B39976D /* LabsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9C5AA3EF7EC67C01C75CEDD /* LabsScreen.swift */; };
|
33BA0964A308D2286B39976D /* LabsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9C5AA3EF7EC67C01C75CEDD /* LabsScreen.swift */; };
|
||||||
33CAC1226DFB8B5D8447D286 /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = 67E7A6F388D3BF85767609D9 /* Sentry */; };
|
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 = "<group>"; };
|
E10DA51DBC8C7E1460DBCCBD /* UserProfileListRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileListRow.swift; sourceTree = "<group>"; };
|
||||||
E157152B11E347F735C3FD6E /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = tr; path = tr.lproj/Localizable.stringsdict; sourceTree = "<group>"; };
|
E157152B11E347F735C3FD6E /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = tr; path = tr.lproj/Localizable.stringsdict; sourceTree = "<group>"; };
|
||||||
E1573D28C8A9FB6399D0EEFB /* SecureBackupLogoutConfirmationScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureBackupLogoutConfirmationScreenCoordinator.swift; sourceTree = "<group>"; };
|
E1573D28C8A9FB6399D0EEFB /* SecureBackupLogoutConfirmationScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureBackupLogoutConfirmationScreenCoordinator.swift; sourceTree = "<group>"; };
|
||||||
|
E1714DD78BFE27ADD40A6F89 /* LinkNewDeviceScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkNewDeviceScreenViewModelTests.swift; sourceTree = "<group>"; };
|
||||||
E1A5FEF17ED7E6176D922D4F /* RoomDetailsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomDetailsScreen.swift; sourceTree = "<group>"; };
|
E1A5FEF17ED7E6176D922D4F /* RoomDetailsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomDetailsScreen.swift; sourceTree = "<group>"; };
|
||||||
E1E0B4A34E69BD2132BEC521 /* MessageText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageText.swift; sourceTree = "<group>"; };
|
E1E0B4A34E69BD2132BEC521 /* MessageText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageText.swift; sourceTree = "<group>"; };
|
||||||
E1ED17433ADC77287F8904F9 /* CallNotificationRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallNotificationRoomTimelineItem.swift; sourceTree = "<group>"; };
|
E1ED17433ADC77287F8904F9 /* CallNotificationRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallNotificationRoomTimelineItem.swift; sourceTree = "<group>"; };
|
||||||
@@ -4982,6 +4984,7 @@
|
|||||||
DE5127D6EA05B2E45D0A7D59 /* JoinRoomScreenViewModelTests.swift */,
|
DE5127D6EA05B2E45D0A7D59 /* JoinRoomScreenViewModelTests.swift */,
|
||||||
FDB9C37196A4C79F24CE80C6 /* KeychainControllerTests.swift */,
|
FDB9C37196A4C79F24CE80C6 /* KeychainControllerTests.swift */,
|
||||||
C9AC2CC94FA06F728883B694 /* KnockRequestsListScreenViewModelTests.swift */,
|
C9AC2CC94FA06F728883B694 /* KnockRequestsListScreenViewModelTests.swift */,
|
||||||
|
E1714DD78BFE27ADD40A6F89 /* LinkNewDeviceScreenViewModelTests.swift */,
|
||||||
C5CB64E711E86270B722FAF7 /* LiveLocationManagerTests.swift */,
|
C5CB64E711E86270B722FAF7 /* LiveLocationManagerTests.swift */,
|
||||||
C070FD43DC6BF4E50217965A /* LocalizationTests.swift */,
|
C070FD43DC6BF4E50217965A /* LocalizationTests.swift */,
|
||||||
EED6D8956E554CEDFD4FE00D /* LocationSharingScreenViewModelTests.swift */,
|
EED6D8956E554CEDFD4FE00D /* LocationSharingScreenViewModelTests.swift */,
|
||||||
@@ -7955,6 +7958,7 @@
|
|||||||
EEC40663922856C65D1E0DF5 /* KeychainControllerTests.swift in Sources */,
|
EEC40663922856C65D1E0DF5 /* KeychainControllerTests.swift in Sources */,
|
||||||
BA48D6AFF6421D199148C0A1 /* KnockRequestsListScreenViewModelTests.swift in Sources */,
|
BA48D6AFF6421D199148C0A1 /* KnockRequestsListScreenViewModelTests.swift in Sources */,
|
||||||
CC961529F9F1854BEC3272C9 /* LayoutMocks.swift in Sources */,
|
CC961529F9F1854BEC3272C9 /* LayoutMocks.swift in Sources */,
|
||||||
|
33544885F5DB8016736CC610 /* LinkNewDeviceScreenViewModelTests.swift in Sources */,
|
||||||
CB6FBAF32B1305F914CF4225 /* LiveLocationManagerTests.swift in Sources */,
|
CB6FBAF32B1305F914CF4225 /* LiveLocationManagerTests.swift in Sources */,
|
||||||
0033481EE363E4914295F188 /* LocalizationTests.swift in Sources */,
|
0033481EE363E4914295F188 /* LocalizationTests.swift in Sources */,
|
||||||
F6D07D834279BE17D18A33E9 /* LocationSharingScreenViewModelTests.swift in Sources */,
|
F6D07D834279BE17D18A33E9 /* LocationSharingScreenViewModelTests.swift in Sources */,
|
||||||
|
|||||||
@@ -932,6 +932,7 @@
|
|||||||
"screen_leave_space_title" = "Leave %1$@?";
|
"screen_leave_space_title" = "Leave %1$@?";
|
||||||
"screen_leave_space_title_last_admin" = "You are the only admin for %1$@";
|
"screen_leave_space_title_last_admin" = "You are the only admin for %1$@";
|
||||||
"screen_leave_space_title_last_owner" = "Transfer ownership";
|
"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_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_step1" = "Open %1$@ on a laptop or desktop computer";
|
||||||
"screen_link_new_device_desktop_step3" = "Scan the QR code with this device";
|
"screen_link_new_device_desktop_step3" = "Scan the QR code with this device";
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"NSCameraUsageDescription" = "To take pictures or videos and send them as a message Element X needs access to the camera.";
|
"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.";
|
"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.";
|
"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.";
|
"NSMicrophoneUsageDescription" = "To record and send messages with audio, Element X needs to access the microphone.";
|
||||||
|
|||||||
@@ -932,6 +932,7 @@
|
|||||||
"screen_leave_space_title" = "Leave %1$@?";
|
"screen_leave_space_title" = "Leave %1$@?";
|
||||||
"screen_leave_space_title_last_admin" = "You are the only admin for %1$@";
|
"screen_leave_space_title_last_admin" = "You are the only admin for %1$@";
|
||||||
"screen_leave_space_title_last_owner" = "Transfer ownership";
|
"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_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_step1" = "Open %1$@ on a laptop or desktop computer";
|
||||||
"screen_link_new_device_desktop_step3" = "Scan the QR code with this device";
|
"screen_link_new_device_desktop_step3" = "Scan the QR code with this device";
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ enum LinkNewDeviceFlowCoordinatorAction {
|
|||||||
|
|
||||||
class LinkNewDeviceFlowCoordinator: FlowCoordinatorProtocol {
|
class LinkNewDeviceFlowCoordinator: FlowCoordinatorProtocol {
|
||||||
private let navigationStackCoordinator: NavigationStackCoordinator
|
private let navigationStackCoordinator: NavigationStackCoordinator
|
||||||
|
private let appLockService: AppLockServiceProtocol
|
||||||
private let flowParameters: CommonFlowParameters
|
private let flowParameters: CommonFlowParameters
|
||||||
|
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
@@ -25,8 +26,10 @@ class LinkNewDeviceFlowCoordinator: FlowCoordinatorProtocol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
init(navigationStackCoordinator: NavigationStackCoordinator,
|
init(navigationStackCoordinator: NavigationStackCoordinator,
|
||||||
|
appLockService: AppLockServiceProtocol,
|
||||||
flowParameters: CommonFlowParameters) {
|
flowParameters: CommonFlowParameters) {
|
||||||
self.navigationStackCoordinator = navigationStackCoordinator
|
self.navigationStackCoordinator = navigationStackCoordinator
|
||||||
|
self.appLockService = appLockService
|
||||||
self.flowParameters = flowParameters
|
self.flowParameters = flowParameters
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,6 +47,7 @@ class LinkNewDeviceFlowCoordinator: FlowCoordinatorProtocol {
|
|||||||
|
|
||||||
private func presentLinkNewDeviceScreen() {
|
private func presentLinkNewDeviceScreen() {
|
||||||
let coordinator = LinkNewDeviceScreenCoordinator(parameters: .init(clientProxy: flowParameters.userSession.clientProxy,
|
let coordinator = LinkNewDeviceScreenCoordinator(parameters: .init(clientProxy: flowParameters.userSession.clientProxy,
|
||||||
|
appLockService: appLockService,
|
||||||
orientationManager: flowParameters.appMediator.windowManager))
|
orientationManager: flowParameters.appMediator.windowManager))
|
||||||
coordinator.actionsPublisher
|
coordinator.actionsPublisher
|
||||||
.sink { [weak self] action in
|
.sink { [weak self] action in
|
||||||
|
|||||||
@@ -181,6 +181,7 @@ class SettingsFlowCoordinator: FlowCoordinatorProtocol {
|
|||||||
private func startLinkNewDeviceFlow() {
|
private func startLinkNewDeviceFlow() {
|
||||||
let stackCoordinator = NavigationStackCoordinator()
|
let stackCoordinator = NavigationStackCoordinator()
|
||||||
let flowCoordinator = LinkNewDeviceFlowCoordinator(navigationStackCoordinator: stackCoordinator,
|
let flowCoordinator = LinkNewDeviceFlowCoordinator(navigationStackCoordinator: stackCoordinator,
|
||||||
|
appLockService: appLockService,
|
||||||
flowParameters: flowParameters)
|
flowParameters: flowParameters)
|
||||||
flowCoordinator.actionsPublisher
|
flowCoordinator.actionsPublisher
|
||||||
.sink { [weak self] action in
|
.sink { [weak self] action in
|
||||||
|
|||||||
@@ -2170,6 +2170,8 @@ internal nonisolated enum L10n {
|
|||||||
}
|
}
|
||||||
/// Transfer ownership
|
/// Transfer ownership
|
||||||
internal static var screenLeaveSpaceTitleLastOwner: String { return L10n.tr("Localizable", "screen_leave_space_title_last_owner") }
|
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
|
/// Scan the QR code
|
||||||
internal static var screenLinkNewDeviceDesktopScanningTitle: String { return L10n.tr("Localizable", "screen_link_new_device_desktop_scanning_title") }
|
internal static var screenLinkNewDeviceDesktopScanningTitle: String { return L10n.tr("Localizable", "screen_link_new_device_desktop_scanning_title") }
|
||||||
/// Open %1$@ on a laptop or desktop computer
|
/// Open %1$@ on a laptop or desktop computer
|
||||||
|
|||||||
@@ -1001,6 +1001,48 @@ nonisolated class AppLockServiceMock: AppLockServiceProtocol, @unchecked Sendabl
|
|||||||
return unlockWithBiometricsReturnValue
|
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 {
|
nonisolated class AppMediatorMock: AppMediatorProtocol, @unchecked Sendable {
|
||||||
var windowManager: WindowManagerProtocol {
|
var windowManager: WindowManagerProtocol {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ enum LinkNewDeviceScreenCoordinatorAction {
|
|||||||
|
|
||||||
struct LinkNewDeviceScreenCoordinatorParameters {
|
struct LinkNewDeviceScreenCoordinatorParameters {
|
||||||
let clientProxy: ClientProxyProtocol
|
let clientProxy: ClientProxyProtocol
|
||||||
|
let appLockService: AppLockServiceProtocol
|
||||||
let orientationManager: OrientationManagerProtocol
|
let orientationManager: OrientationManagerProtocol
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,7 +32,8 @@ final class LinkNewDeviceScreenCoordinator: CoordinatorProtocol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
init(parameters: LinkNewDeviceScreenCoordinatorParameters) {
|
init(parameters: LinkNewDeviceScreenCoordinatorParameters) {
|
||||||
viewModel = LinkNewDeviceScreenViewModel(clientProxy: parameters.clientProxy)
|
viewModel = LinkNewDeviceScreenViewModel(clientProxy: parameters.clientProxy,
|
||||||
|
appLockService: parameters.appLockService)
|
||||||
orientationManager = parameters.orientationManager
|
orientationManager = parameters.orientationManager
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,8 +15,14 @@ enum LinkNewDeviceScreenViewModelAction {
|
|||||||
|
|
||||||
struct LinkNewDeviceScreenViewState: BindableState {
|
struct LinkNewDeviceScreenViewState: BindableState {
|
||||||
nonisolated enum Mode: Equatable {
|
nonisolated enum Mode: Equatable {
|
||||||
|
enum ReadyState: Equatable {
|
||||||
|
case idle
|
||||||
|
case verifyingDeviceOwner
|
||||||
|
case generatingCode
|
||||||
|
}
|
||||||
|
|
||||||
case loading
|
case loading
|
||||||
case readyToLink(isGeneratingCode: Bool)
|
case readyToLink(ReadyState)
|
||||||
case error(QRCodeLoginState.ErrorState)
|
case error(QRCodeLoginState.ErrorState)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,15 +12,19 @@ import SwiftUI
|
|||||||
typealias LinkNewDeviceScreenViewModelType = StateStoreViewModelV2<LinkNewDeviceScreenViewState, LinkNewDeviceScreenViewAction>
|
typealias LinkNewDeviceScreenViewModelType = StateStoreViewModelV2<LinkNewDeviceScreenViewState, LinkNewDeviceScreenViewAction>
|
||||||
|
|
||||||
class LinkNewDeviceScreenViewModel: LinkNewDeviceScreenViewModelType, LinkNewDeviceScreenViewModelProtocol {
|
class LinkNewDeviceScreenViewModel: LinkNewDeviceScreenViewModelType, LinkNewDeviceScreenViewModelProtocol {
|
||||||
|
private enum Device { case mobileDevice, desktopComputer }
|
||||||
|
|
||||||
private let clientProxy: ClientProxyProtocol
|
private let clientProxy: ClientProxyProtocol
|
||||||
|
private let appLockService: AppLockServiceProtocol
|
||||||
|
|
||||||
private let actionsSubject: PassthroughSubject<LinkNewDeviceScreenViewModelAction, Never> = .init()
|
private let actionsSubject: PassthroughSubject<LinkNewDeviceScreenViewModelAction, Never> = .init()
|
||||||
var actionsPublisher: AnyPublisher<LinkNewDeviceScreenViewModelAction, Never> {
|
var actionsPublisher: AnyPublisher<LinkNewDeviceScreenViewModelAction, Never> {
|
||||||
actionsSubject.eraseToAnyPublisher()
|
actionsSubject.eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
|
|
||||||
init(clientProxy: ClientProxyProtocol, initialState: LinkNewDeviceScreenViewState? = nil) {
|
init(clientProxy: ClientProxyProtocol, appLockService: AppLockServiceProtocol, initialState: LinkNewDeviceScreenViewState? = nil) {
|
||||||
self.clientProxy = clientProxy
|
self.clientProxy = clientProxy
|
||||||
|
self.appLockService = appLockService
|
||||||
|
|
||||||
if let initialState {
|
if let initialState {
|
||||||
super.init(initialViewState: initialState)
|
super.init(initialViewState: initialState)
|
||||||
@@ -41,9 +45,9 @@ class LinkNewDeviceScreenViewModel: LinkNewDeviceScreenViewModelType, LinkNewDev
|
|||||||
|
|
||||||
switch viewAction {
|
switch viewAction {
|
||||||
case .linkMobileDevice:
|
case .linkMobileDevice:
|
||||||
Task { await linkMobileDevice() }
|
Task { await verifyAndLink(.mobileDevice) }
|
||||||
case .linkDesktopComputer:
|
case .linkDesktopComputer:
|
||||||
actionsSubject.send(.linkDesktopComputer)
|
Task { await verifyAndLink(.desktopComputer) }
|
||||||
case .errorAction(let action):
|
case .errorAction(let action):
|
||||||
handleErrorAction(action)
|
handleErrorAction(action)
|
||||||
case .dismiss:
|
case .dismiss:
|
||||||
@@ -55,14 +59,41 @@ class LinkNewDeviceScreenViewModel: LinkNewDeviceScreenViewModelType, LinkNewDev
|
|||||||
|
|
||||||
private func checkQRCodeLoginSupport() async {
|
private func checkQRCodeLoginSupport() async {
|
||||||
if await clientProxy.isLoginWithQRCodeSupported {
|
if await clientProxy.isLoginWithQRCodeSupported {
|
||||||
state.mode = .readyToLink(isGeneratingCode: false)
|
state.mode = .readyToLink(.idle)
|
||||||
} else {
|
} else {
|
||||||
state.mode = .error(.notSupported)
|
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 {
|
private func linkMobileDevice() async {
|
||||||
state.mode = .readyToLink(isGeneratingCode: true)
|
state.mode = .readyToLink(.generatingCode)
|
||||||
|
|
||||||
let linkNewDeviceService = clientProxy.linkNewDeviceService()
|
let linkNewDeviceService = clientProxy.linkNewDeviceService()
|
||||||
|
|
||||||
@@ -77,7 +108,7 @@ class LinkNewDeviceScreenViewModel: LinkNewDeviceScreenViewModelType, LinkNewDev
|
|||||||
}
|
}
|
||||||
|
|
||||||
actionsSubject.send(.linkMobileDevice(progressPublisher))
|
actionsSubject.send(.linkMobileDevice(progressPublisher))
|
||||||
state.mode = .readyToLink(isGeneratingCode: false)
|
state.mode = .readyToLink(.idle)
|
||||||
} catch {
|
} catch {
|
||||||
// This is hard to share a mapping from the QRCodeLoginError with the
|
// This is hard to share a mapping from the QRCodeLoginError with the
|
||||||
// QRCodeLoginScreen given that some of those are scan errors…
|
// QRCodeLoginScreen given that some of those are scan errors…
|
||||||
@@ -89,7 +120,7 @@ class LinkNewDeviceScreenViewModel: LinkNewDeviceScreenViewModelType, LinkNewDev
|
|||||||
switch action {
|
switch action {
|
||||||
case .startOver:
|
case .startOver:
|
||||||
// Reset to ready state to allow trying again.
|
// Reset to ready state to allow trying again.
|
||||||
state.mode = .readyToLink(isGeneratingCode: false)
|
state.mode = .readyToLink(.idle)
|
||||||
case .openSettings, .signInManually:
|
case .openSettings, .signInManually:
|
||||||
MXLog.error("Unexpected error action: \(action)")
|
MXLog.error("Unexpected error action: \(action)")
|
||||||
actionsSubject.send(.dismiss)
|
actionsSubject.send(.dismiss)
|
||||||
|
|||||||
@@ -59,7 +59,8 @@ struct LinkNewDeviceScreen: View {
|
|||||||
}
|
}
|
||||||
.buttonStyle(.compound(.primary))
|
.buttonStyle(.compound(.primary))
|
||||||
.disabled(true)
|
.disabled(true)
|
||||||
case .readyToLink(let isGeneratingCode):
|
case .readyToLink(let linkState):
|
||||||
|
let isGeneratingCode = linkState == .generatingCode
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
Button { context.send(viewAction: .linkMobileDevice) } label: {
|
Button { context.send(viewAction: .linkMobileDevice) } label: {
|
||||||
Label {
|
Label {
|
||||||
@@ -84,7 +85,7 @@ struct LinkNewDeviceScreen: View {
|
|||||||
.accessibilityIdentifier(A11yIdentifiers.linkNewDeviceScreen.desktopComputer)
|
.accessibilityIdentifier(A11yIdentifiers.linkNewDeviceScreen.desktopComputer)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.disabled(isGeneratingCode)
|
.disabled(linkState != .idle)
|
||||||
case .error:
|
case .error:
|
||||||
EmptyView() // Not reachable.
|
EmptyView() // Not reachable.
|
||||||
}
|
}
|
||||||
@@ -103,8 +104,9 @@ struct LinkNewDeviceScreen: View {
|
|||||||
// MARK: - Previews
|
// MARK: - Previews
|
||||||
|
|
||||||
struct LinkNewDeviceScreen_Previews: PreviewProvider, TestablePreview {
|
struct LinkNewDeviceScreen_Previews: PreviewProvider, TestablePreview {
|
||||||
static let viewModel = makeViewModel(mode: .readyToLink(isGeneratingCode: false))
|
static let viewModel = makeViewModel(mode: .readyToLink(.idle))
|
||||||
static let generatingViewModel = makeViewModel(mode: .readyToLink(isGeneratingCode: true))
|
static let verifyingViewModel = makeViewModel(mode: .readyToLink(.verifyingDeviceOwner))
|
||||||
|
static let generatingViewModel = makeViewModel(mode: .readyToLink(.generatingCode))
|
||||||
static let loadingViewModel = makeViewModel(mode: .loading)
|
static let loadingViewModel = makeViewModel(mode: .loading)
|
||||||
static let unsupportedViewModel = makeViewModel(mode: .error(.notSupported))
|
static let unsupportedViewModel = makeViewModel(mode: .error(.notSupported))
|
||||||
static let unknownErrorViewModel = makeViewModel(mode: .error(.unknown))
|
static let unknownErrorViewModel = makeViewModel(mode: .error(.unknown))
|
||||||
@@ -114,13 +116,19 @@ struct LinkNewDeviceScreen_Previews: PreviewProvider, TestablePreview {
|
|||||||
LinkNewDeviceScreen(context: viewModel.context)
|
LinkNewDeviceScreen(context: viewModel.context)
|
||||||
}
|
}
|
||||||
.previewDisplayName("Ready")
|
.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 {
|
ElementNavigationStack {
|
||||||
LinkNewDeviceScreen(context: generatingViewModel.context)
|
LinkNewDeviceScreen(context: generatingViewModel.context)
|
||||||
}
|
}
|
||||||
.previewDisplayName("Generating")
|
.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 {
|
ElementNavigationStack {
|
||||||
LinkNewDeviceScreen(context: loadingViewModel.context)
|
LinkNewDeviceScreen(context: loadingViewModel.context)
|
||||||
@@ -142,6 +150,7 @@ struct LinkNewDeviceScreen_Previews: PreviewProvider, TestablePreview {
|
|||||||
|
|
||||||
static func makeViewModel(mode: LinkNewDeviceScreenViewState.Mode) -> LinkNewDeviceScreenViewModel {
|
static func makeViewModel(mode: LinkNewDeviceScreenViewState.Mode) -> LinkNewDeviceScreenViewModel {
|
||||||
LinkNewDeviceScreenViewModel(clientProxy: ClientProxyMock(.init()),
|
LinkNewDeviceScreenViewModel(clientProxy: ClientProxyMock(.init()),
|
||||||
|
appLockService: AppLockServiceMock.mock(),
|
||||||
initialState: .init(mode: mode,
|
initialState: .init(mode: mode,
|
||||||
showLinkDesktopComputerButton: true))
|
showLinkDesktopComputerButton: true))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ class AppLockService: AppLockServiceProtocol {
|
|||||||
|
|
||||||
private let timer: AppLockTimer
|
private let timer: AppLockTimer
|
||||||
private let unlockPolicy: LAPolicy = .deviceOwnerAuthenticationWithBiometrics
|
private let unlockPolicy: LAPolicy = .deviceOwnerAuthenticationWithBiometrics
|
||||||
|
private let deviceOwnerPolicy: LAPolicy = .deviceOwnerAuthentication
|
||||||
|
|
||||||
var isMandatory: Bool {
|
var isMandatory: Bool {
|
||||||
appSettings.appLockIsMandatory
|
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
|
// MARK: - Private
|
||||||
|
|
||||||
/// Queries the context for supported biometrics and enrolment state.
|
/// Queries the context for supported biometrics and enrolment state.
|
||||||
@@ -187,6 +212,12 @@ class AppLockService: AppLockServiceProtocol {
|
|||||||
return context
|
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.
|
/// Shared logic for completing an unlock via a PIN or biometrics.
|
||||||
private func completeUnlock() {
|
private func completeUnlock() {
|
||||||
timer.registerUnlock()
|
timer.registerUnlock()
|
||||||
|
|||||||
@@ -34,6 +34,22 @@ enum AppLockServiceBiometricResult {
|
|||||||
case interrupted
|
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 {
|
protocol AppLockServiceProtocol: AnyObject {
|
||||||
/// The use of a PIN code is mandatory for this device.
|
/// The use of a PIN code is mandatory for this device.
|
||||||
var isMandatory: Bool { get }
|
var isMandatory: Bool { get }
|
||||||
@@ -72,6 +88,13 @@ protocol AppLockServiceProtocol: AnyObject {
|
|||||||
/// Attempt to unlock the app using FaceID or TouchID.
|
/// Attempt to unlock the app using FaceID or TouchID.
|
||||||
func unlockWithBiometrics() async -> AppLockServiceBiometricResult
|
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.
|
/// 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.
|
/// Note: We don't track the biometric attempts as LAContext does that automatically.
|
||||||
|
|||||||
@@ -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.
|
/// 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
|
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?
|
||||||
|
|||||||
@@ -25,15 +25,26 @@ nonisolated class LAContextMock: LAContext {
|
|||||||
internalEvaluatedPolicyDomainStateValue
|
internalEvaluatedPolicyDomainStateValue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var canEvaluatePolicyReturnValue: Bool?
|
||||||
override func canEvaluatePolicy(_ policy: LAPolicy, error: NSErrorPointer) -> 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()
|
updateInternalValues()
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
var evaluatePolicyReturnValue: Bool!
|
var evaluatePolicyReturnValue: Bool!
|
||||||
|
var evaluatePolicyThrowableError: Error?
|
||||||
|
var evaluatePolicyCallsCount = 0
|
||||||
|
var evaluatePolicyCalled: Bool {
|
||||||
|
evaluatePolicyCallsCount > 0
|
||||||
|
}
|
||||||
|
|
||||||
override func evaluatePolicy(_ policy: LAPolicy, localizedReason: String) async throws -> Bool {
|
override func evaluatePolicy(_ policy: LAPolicy, localizedReason: String) async throws -> Bool {
|
||||||
|
evaluatePolicyCallsCount += 1
|
||||||
updateInternalValues()
|
updateInternalValues()
|
||||||
|
if let evaluatePolicyThrowableError {
|
||||||
|
throw evaluatePolicyThrowableError
|
||||||
|
}
|
||||||
return evaluatePolicyReturnValue
|
return evaluatePolicyReturnValue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -784,6 +784,7 @@ class MockScreen: Identifiable {
|
|||||||
|
|
||||||
let navigationStackCoordinator = NavigationStackCoordinator()
|
let navigationStackCoordinator = NavigationStackCoordinator()
|
||||||
let flowCoordinator = LinkNewDeviceFlowCoordinator(navigationStackCoordinator: navigationStackCoordinator,
|
let flowCoordinator = LinkNewDeviceFlowCoordinator(navigationStackCoordinator: navigationStackCoordinator,
|
||||||
|
appLockService: AppLockServiceMock.mock(),
|
||||||
flowParameters: CommonFlowParameters(userSession: UserSessionMock(.init(clientProxy: clientProxy)),
|
flowParameters: CommonFlowParameters(userSession: UserSessionMock(.init(clientProxy: clientProxy)),
|
||||||
bugReportService: BugReportServiceMock(.init()),
|
bugReportService: BugReportServiceMock(.init()),
|
||||||
elementCallService: ElementCallServiceMock(.init()),
|
elementCallService: ElementCallServiceMock(.init()),
|
||||||
|
|||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:c550f1ef9a8e960e08afd8b340442ad99b9c73ff62f45cbe998da9ae05405ea5
|
||||||
|
size 111107
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:79f1a9f769ba0b72a31f825e3c60828be64a4ca1084a939b57b8b79ca2ed33c5
|
||||||
|
size 122727
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:217d5d9041037d68c23fbd9c5f5f07aa1c481ee43e3814794c709fd19a5bc505
|
||||||
|
size 62602
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:0675d204a7f5e934cb52f66ecd1840471480575a17f81e64866cf11df98cd8da
|
||||||
|
size 80524
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
@testable import ElementX
|
@testable import ElementX
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import LocalAuthentication
|
||||||
import Testing
|
import Testing
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -346,4 +347,100 @@ final class AppLockServiceTests {
|
|||||||
// Then the attempts counts should both be reset.
|
// Then the attempts counts should both be reset.
|
||||||
#expect(appSettings.appLockNumberOfPINAttempts == 0, "The PIN attempts should 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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<LinkNewDeviceService.LinkMobileProgress, QRCodeLoginError>(.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<LinkNewDeviceService.LinkMobileProgress, QRCodeLoginError>(.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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user