diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index f925d61f8..752cf8e77 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -659,6 +659,7 @@ 7434A7F02D587A920B376A9A /* LoginScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A43964330459965AF048A8C /* LoginScreenViewModelTests.swift */; }; 743790BF6A5B0577EA74AF14 /* ReadMarkerRoomTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF3D25B3EDB283B5807EADCF /* ReadMarkerRoomTimelineItem.swift */; }; 748F482FEF4E04D61C39AAD7 /* EmojiPickerScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = F174A5627CDB3CAF280D1880 /* EmojiPickerScreenModels.swift */; }; + 74DF5BC17DE9F51E077FD457 /* LinkNewDeviceServiceMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA257D747DD7E6FFA5C2BE2D /* LinkNewDeviceServiceMock.swift */; }; 7501442D52A65F73DF79FFD4 /* PaginationIndicatorRoomTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B987FC3FDBAA0E1C5AA235C /* PaginationIndicatorRoomTimelineItem.swift */; }; 754602A7B2AAD443C4228ED4 /* SwiftState in Frameworks */ = {isa = PBXBuildFile; productRef = 9573B94B1C86C6DF751AF3FD /* SwiftState */; }; 755395927DDD6EBDDA5E217A /* SettingsFlowCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F7A6CEEA4A2815B0F0F55 /* SettingsFlowCoordinator.swift */; }; @@ -785,7 +786,6 @@ 88F348E2CB14FF71CBBB665D /* AudioRoomTimelineItemContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7475C5AE20BA896930907EA8 /* AudioRoomTimelineItemContent.swift */; }; 890F0D453FE388756479AC97 /* AnalyticsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C687844F60BFF532D49A994C /* AnalyticsTests.swift */; }; 89198AE2649DD77673D5793B /* ExtensionLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41A8571A8A071FB41778C016 /* ExtensionLogger.swift */; }; - 89261D215E4A432E887CD156 /* GrantLoginWithQrCodeHandlerSDKMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBD288168C48D3F76177FCBF /* GrantLoginWithQrCodeHandlerSDKMock.swift */; }; 8944548A684F1C837CEC47F4 /* RoomMembersListScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D0946F77B696176E062D037 /* RoomMembersListScreenModels.swift */; }; 89658A44C9FC19B58FD1C226 /* ServerConfirmationScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F08776C48FFB47CACF64ED10 /* ServerConfirmationScreenViewModelTests.swift */; }; 899793EFC63DF93C3E0141E7 /* RoomMemberDetailsScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FA60F848D1C14F873F9621A /* RoomMemberDetailsScreenCoordinator.swift */; }; @@ -2544,6 +2544,7 @@ B902EA6CD3296B0E10EE432B /* HomeScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreen.swift; sourceTree = ""; }; B99E13633862847D8B7E2815 /* StartChatScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartChatScreenModels.swift; sourceTree = ""; }; BA241DEEF7C8A7181C0AEDC9 /* UserPreferenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserPreferenceTests.swift; sourceTree = ""; }; + BA257D747DD7E6FFA5C2BE2D /* LinkNewDeviceServiceMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkNewDeviceServiceMock.swift; sourceTree = ""; }; BA40B98B098B6F0371B750B3 /* TemplateScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemplateScreenModels.swift; sourceTree = ""; }; BA919F521E9F0EE3638AFC85 /* BugReportScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugReportScreen.swift; sourceTree = ""; }; BB284643AF7AB131E307DCE0 /* AudioSessionProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioSessionProtocol.swift; sourceTree = ""; }; @@ -2884,7 +2885,6 @@ FB9EABCA9348DFA27439A809 /* WaveformCursorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WaveformCursorView.swift; sourceTree = ""; }; FBB0328F2887BF0A65BC5D49 /* NotificationSettingsEditScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsEditScreen.swift; sourceTree = ""; }; FBC776F301D374A3298C69DA /* AppCoordinatorProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppCoordinatorProtocol.swift; sourceTree = ""; }; - FBD288168C48D3F76177FCBF /* GrantLoginWithQrCodeHandlerSDKMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrantLoginWithQrCodeHandlerSDKMock.swift; sourceTree = ""; }; FC3797A2325BE44FFB478BE9 /* LeaveSpaceRoomDetailsCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LeaveSpaceRoomDetailsCell.swift; sourceTree = ""; }; FC83F47D2173B7538AA72E0E /* RoomSummaryProviderMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomSummaryProviderMock.swift; sourceTree = ""; }; FC9044BE0E4A66F5B963E834 /* AudioFileEventsTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioFileEventsTimelineView.swift; sourceTree = ""; }; @@ -3623,6 +3623,7 @@ 867DC9530C42F7B5176BE465 /* JoinedRoomProxyMock.swift */, 9E8F4D7D61B80EBD5CB92F8A /* KnockedRoomProxyMock.swift */, 7F957320D0EB7D7B4E30C79D /* KnockRequestProxyMock.swift */, + BA257D747DD7E6FFA5C2BE2D /* LinkNewDeviceServiceMock.swift */, 6F65E4BB9E82EB8373207CF8 /* MediaProviderMock.swift */, 8DA1E8F287680C8ED25EDBAC /* NetworkMonitorMock.swift */, 840182D7A61402D5947DE094 /* NotificationItemProxyMock.swift */, @@ -6430,7 +6431,6 @@ children = ( 8EAF4A49F3ACD8BB8B0D2371 /* ClientSDKMock.swift */, 0A81FD0C60175FA081EB19AD /* EventTimelineItem.swift */, - FBD288168C48D3F76177FCBF /* GrantLoginWithQrCodeHandlerSDKMock.swift */, 5EFB1D29B0870AFB6A56E9B8 /* IdentityResetHandleSDKMock.swift */, 580BDCD23DD02481AB5FFB47 /* LeaveSpaceHandleSDKMock.swift */, ); @@ -8024,7 +8024,6 @@ D34E328E9E65904358248FDD /* GlobalSearchScreenModels.swift in Sources */, 55D18AA4F4A2257642EBDB94 /* GlobalSearchScreenViewModel.swift in Sources */, E32A18802EB37EEE3EF7B965 /* GlobalSearchScreenViewModelProtocol.swift in Sources */, - 89261D215E4A432E887CD156 /* GrantLoginWithQrCodeHandlerSDKMock.swift in Sources */, F3C9CAD26FD4D7D6EBACF501 /* HTMLFixtures.swift in Sources */, E8C4D9F93F0DCED211D5F187 /* HTMLParserStyle.swift in Sources */, 0C1E537A49ABB386F7554D4A /* HighlightedTimelineItemModifier.swift in Sources */, @@ -8124,6 +8123,7 @@ C16E25C41B858BF27E0C4FC6 /* LinkNewDeviceScreenViewModel.swift in Sources */, 92FE657CDFAFE3031576EB43 /* LinkNewDeviceScreenViewModelProtocol.swift in Sources */, A37BFB32EAB8AEF6DD5BA0DC /* LinkNewDeviceService.swift in Sources */, + 74DF5BC17DE9F51E077FD457 /* LinkNewDeviceServiceMock.swift in Sources */, 866FA35E7A2339EF8B6D91CA /* LinkPreviewView.swift in Sources */, 6E47D126DD7585E8F8237CE7 /* LoadableAvatarImage.swift in Sources */, D9F80CE61BF8FF627FDB0543 /* LoadableImage.swift in Sources */, diff --git a/ElementX/Sources/Mocks/ClientProxyMock.swift b/ElementX/Sources/Mocks/ClientProxyMock.swift index 67870912a..8bbc8c633 100644 --- a/ElementX/Sources/Mocks/ClientProxyMock.swift +++ b/ElementX/Sources/Mocks/ClientProxyMock.swift @@ -106,6 +106,7 @@ extension ClientProxyMock { resetIdentityReturnValue = .success(IdentityResetHandleSDKMock(.init())) spaceService = SpaceServiceProxyMock(configuration.spaceServiceConfiguration) + linkNewDeviceServiceReturnValue = LinkNewDeviceServiceMock(.init()) roomForIdentifierClosure = { [weak self] identifier in if let room = self?.roomSummaryProvider.roomListPublisher.value.first(where: { $0.id == identifier }) { diff --git a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift index 41b6a18ce..eca9512cb 100644 --- a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift @@ -4049,13 +4049,13 @@ class ClientProxyMock: ClientProxyProtocol, @unchecked Sendable { return linkNewDeviceServiceCallsCount > 0 } - var linkNewDeviceServiceUnderlyingReturnValue: LinkNewDeviceService! - var linkNewDeviceServiceReturnValue: LinkNewDeviceService! { + var linkNewDeviceServiceUnderlyingReturnValue: LinkNewDeviceServiceProtocol! + var linkNewDeviceServiceReturnValue: LinkNewDeviceServiceProtocol! { get { if Thread.isMainThread { return linkNewDeviceServiceUnderlyingReturnValue } else { - var returnValue: LinkNewDeviceService? = nil + var returnValue: LinkNewDeviceServiceProtocol? = nil DispatchQueue.main.sync { returnValue = linkNewDeviceServiceUnderlyingReturnValue } @@ -4073,9 +4073,9 @@ class ClientProxyMock: ClientProxyProtocol, @unchecked Sendable { } } } - var linkNewDeviceServiceClosure: (() -> LinkNewDeviceService)? + var linkNewDeviceServiceClosure: (() -> LinkNewDeviceServiceProtocol)? - func linkNewDeviceService() -> LinkNewDeviceService { + func linkNewDeviceService() -> LinkNewDeviceServiceProtocol { linkNewDeviceServiceCallsCount += 1 if let linkNewDeviceServiceClosure = linkNewDeviceServiceClosure { return linkNewDeviceServiceClosure() @@ -10917,6 +10917,143 @@ class KnockedRoomProxyMock: KnockedRoomProxyProtocol, @unchecked Sendable { } } } +class LinkNewDeviceServiceMock: LinkNewDeviceServiceProtocol, @unchecked Sendable { + + //MARK: - linkMobileDevice + + var linkMobileDeviceUnderlyingCallsCount = 0 + var linkMobileDeviceCallsCount: Int { + get { + if Thread.isMainThread { + return linkMobileDeviceUnderlyingCallsCount + } else { + var returnValue: Int? = nil + DispatchQueue.main.sync { + returnValue = linkMobileDeviceUnderlyingCallsCount + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + linkMobileDeviceUnderlyingCallsCount = newValue + } else { + DispatchQueue.main.sync { + linkMobileDeviceUnderlyingCallsCount = newValue + } + } + } + } + var linkMobileDeviceCalled: Bool { + return linkMobileDeviceCallsCount > 0 + } + + var linkMobileDeviceUnderlyingReturnValue: LinkNewDeviceService.LinkMobileProgressPublisher! + var linkMobileDeviceReturnValue: LinkNewDeviceService.LinkMobileProgressPublisher! { + get { + if Thread.isMainThread { + return linkMobileDeviceUnderlyingReturnValue + } else { + var returnValue: LinkNewDeviceService.LinkMobileProgressPublisher? = nil + DispatchQueue.main.sync { + returnValue = linkMobileDeviceUnderlyingReturnValue + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + linkMobileDeviceUnderlyingReturnValue = newValue + } else { + DispatchQueue.main.sync { + linkMobileDeviceUnderlyingReturnValue = newValue + } + } + } + } + var linkMobileDeviceClosure: (() -> LinkNewDeviceService.LinkMobileProgressPublisher)? + + func linkMobileDevice() -> LinkNewDeviceService.LinkMobileProgressPublisher { + linkMobileDeviceCallsCount += 1 + if let linkMobileDeviceClosure = linkMobileDeviceClosure { + return linkMobileDeviceClosure() + } else { + return linkMobileDeviceReturnValue + } + } + //MARK: - linkDesktopDevice + + var linkDesktopDeviceWithUnderlyingCallsCount = 0 + var linkDesktopDeviceWithCallsCount: Int { + get { + if Thread.isMainThread { + return linkDesktopDeviceWithUnderlyingCallsCount + } else { + var returnValue: Int? = nil + DispatchQueue.main.sync { + returnValue = linkDesktopDeviceWithUnderlyingCallsCount + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + linkDesktopDeviceWithUnderlyingCallsCount = newValue + } else { + DispatchQueue.main.sync { + linkDesktopDeviceWithUnderlyingCallsCount = newValue + } + } + } + } + var linkDesktopDeviceWithCalled: Bool { + return linkDesktopDeviceWithCallsCount > 0 + } + var linkDesktopDeviceWithReceivedScannedQRData: Data? + var linkDesktopDeviceWithReceivedInvocations: [Data] = [] + + var linkDesktopDeviceWithUnderlyingReturnValue: LinkNewDeviceService.LinkDesktopProgressPublisher! + var linkDesktopDeviceWithReturnValue: LinkNewDeviceService.LinkDesktopProgressPublisher! { + get { + if Thread.isMainThread { + return linkDesktopDeviceWithUnderlyingReturnValue + } else { + var returnValue: LinkNewDeviceService.LinkDesktopProgressPublisher? = nil + DispatchQueue.main.sync { + returnValue = linkDesktopDeviceWithUnderlyingReturnValue + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + linkDesktopDeviceWithUnderlyingReturnValue = newValue + } else { + DispatchQueue.main.sync { + linkDesktopDeviceWithUnderlyingReturnValue = newValue + } + } + } + } + var linkDesktopDeviceWithClosure: ((Data) -> LinkNewDeviceService.LinkDesktopProgressPublisher)? + + func linkDesktopDevice(with scannedQRData: Data) -> LinkNewDeviceService.LinkDesktopProgressPublisher { + linkDesktopDeviceWithCallsCount += 1 + linkDesktopDeviceWithReceivedScannedQRData = scannedQRData + DispatchQueue.main.async { + self.linkDesktopDeviceWithReceivedInvocations.append(scannedQRData) + } + if let linkDesktopDeviceWithClosure = linkDesktopDeviceWithClosure { + return linkDesktopDeviceWithClosure(scannedQRData) + } else { + return linkDesktopDeviceWithReturnValue + } + } +} class MediaLoaderMock: MediaLoaderProtocol, @unchecked Sendable { //MARK: - loadMediaContentForSource diff --git a/ElementX/Sources/Mocks/LinkNewDeviceServiceMock.swift b/ElementX/Sources/Mocks/LinkNewDeviceServiceMock.swift new file mode 100644 index 000000000..9d3aa604b --- /dev/null +++ b/ElementX/Sources/Mocks/LinkNewDeviceServiceMock.swift @@ -0,0 +1,33 @@ +// +// 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 SwiftUI + +extension LinkNewDeviceServiceMock { + static var mockQRCodeImage: UIImage { + mockBase64QRCode.data(using: .utf8).flatMap { UIImage(qrCodeData: $0) } ?? UIImage() + } + + static let mockBase64QRCode = """ + TUFUUklYAgS0yzZ1QVpQ1jlnoxWX3d5jrWRFfELxjS2gN7pz9y+3PABaaHR0 + cHM6Ly9zeW5hcHNlLW9pZGMubGFiLmVsZW1lbnQuZGV2L19zeW5hcHNlL2Ns + aWVudC9yZW5kZXp2b3VzLzAxSFg5SzAwUTFINktQRDQ3RUc0RzFUM1hHACVo + dHRwczovL3N5bmFwc2Utb2lkYy5sYWIuZWxlbWVudC5kZXYv + """ + + struct Configuration { + var linkMobileProgressPublisher: LinkNewDeviceService.LinkMobileProgressPublisher = .init(.starting) + var linkDesktopProgressPublisher: LinkNewDeviceService.LinkDesktopProgressPublisher = .init(.starting) + } + + convenience init(_ configuration: Configuration) { + self.init() + + linkMobileDeviceReturnValue = configuration.linkMobileProgressPublisher + linkDesktopDeviceWithReturnValue = configuration.linkDesktopProgressPublisher + } +} diff --git a/ElementX/Sources/Mocks/SDK/GrantLoginWithQrCodeHandlerSDKMock.swift b/ElementX/Sources/Mocks/SDK/GrantLoginWithQrCodeHandlerSDKMock.swift deleted file mode 100644 index b8806b9ff..000000000 --- a/ElementX/Sources/Mocks/SDK/GrantLoginWithQrCodeHandlerSDKMock.swift +++ /dev/null @@ -1,34 +0,0 @@ -// -// 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 Foundation -import MatrixRustSDK -import MatrixRustSDKMocks - -extension GrantLoginWithQrCodeHandlerSDKMock { - struct Configuration { - var generateDelay: Duration = .seconds(0) - var generatedBase64QRCode = """ - TUFUUklYAgS0yzZ1QVpQ1jlnoxWX3d5jrWRFfELxjS2gN7pz9y+3PABaaHR0 - cHM6Ly9zeW5hcHNlLW9pZGMubGFiLmVsZW1lbnQuZGV2L19zeW5hcHNlL2Ns - aWVudC9yZW5kZXp2b3VzLzAxSFg5SzAwUTFINktQRDQ3RUc0RzFUM1hHACVo - dHRwczovL3N5bmFwc2Utb2lkYy5sYWIuZWxlbWVudC5kZXYv - """ - } - - convenience init(_ configuration: Configuration) { - self.init() - - generateProgressListenerClosure = { listener in - Task { - try await Task.sleep(for: configuration.generateDelay) - let bytes = Data(base64Encoded: configuration.generatedBase64QRCode) ?? Data() - try listener.onUpdate(state: .qrReady(qrCode: .fromBytes(bytes: bytes))) - } - } - } -} diff --git a/ElementX/Sources/Screens/LinkNewDeviceScreen/LinkNewDeviceScreenModels.swift b/ElementX/Sources/Screens/LinkNewDeviceScreen/LinkNewDeviceScreenModels.swift index ea2613f64..9205d1bee 100644 --- a/ElementX/Sources/Screens/LinkNewDeviceScreen/LinkNewDeviceScreenModels.swift +++ b/ElementX/Sources/Screens/LinkNewDeviceScreen/LinkNewDeviceScreenModels.swift @@ -16,6 +16,8 @@ enum LinkNewDeviceScreenViewModelAction { struct LinkNewDeviceScreenViewState: BindableState { enum Mode: Equatable { case loading, readyToLink(isGeneratingCode: Bool), notSupported } var mode: Mode = .loading + + let showLinkDesktopComputerButton: Bool } enum LinkNewDeviceScreenViewAction { diff --git a/ElementX/Sources/Screens/LinkNewDeviceScreen/LinkNewDeviceScreenViewModel.swift b/ElementX/Sources/Screens/LinkNewDeviceScreen/LinkNewDeviceScreenViewModel.swift index 60c7ebb2b..953ac5ae3 100644 --- a/ElementX/Sources/Screens/LinkNewDeviceScreen/LinkNewDeviceScreenViewModel.swift +++ b/ElementX/Sources/Screens/LinkNewDeviceScreen/LinkNewDeviceScreenViewModel.swift @@ -22,7 +22,9 @@ class LinkNewDeviceScreenViewModel: LinkNewDeviceScreenViewModelType, LinkNewDev init(clientProxy: ClientProxyProtocol) { self.clientProxy = clientProxy - super.init(initialViewState: LinkNewDeviceScreenViewState()) + let isQRCodeScanningSupported = !ProcessInfo.processInfo.isiOSAppOnMac + + super.init(initialViewState: LinkNewDeviceScreenViewState(showLinkDesktopComputerButton: isQRCodeScanningSupported)) Task { await checkQRCodeLoginSupport() } } diff --git a/ElementX/Sources/Screens/LinkNewDeviceScreen/View/LinkNewDeviceScreen.swift b/ElementX/Sources/Screens/LinkNewDeviceScreen/View/LinkNewDeviceScreen.swift index a51d2b0ff..623b52d3d 100644 --- a/ElementX/Sources/Screens/LinkNewDeviceScreen/View/LinkNewDeviceScreen.swift +++ b/ElementX/Sources/Screens/LinkNewDeviceScreen/View/LinkNewDeviceScreen.swift @@ -70,11 +70,13 @@ struct LinkNewDeviceScreen: View { .buttonStyle(.compound(.primary)) .accessibilityIdentifier(A11yIdentifiers.linkNewDeviceScreen.mobileDevice) - Button { context.send(viewAction: .linkDesktopComputer) } label: { - Label(L10n.screenLinkNewDeviceRootDesktopComputer, icon: \.computer) + if context.viewState.showLinkDesktopComputerButton { + Button { context.send(viewAction: .linkDesktopComputer) } label: { + Label(L10n.screenLinkNewDeviceRootDesktopComputer, icon: \.computer) + } + .buttonStyle(.compound(.primary)) + .accessibilityIdentifier(A11yIdentifiers.linkNewDeviceScreen.desktopComputer) } - .buttonStyle(.compound(.primary)) - .accessibilityIdentifier(A11yIdentifiers.linkNewDeviceScreen.desktopComputer) } .disabled(isGeneratingCode) case .notSupported: @@ -97,8 +99,6 @@ struct LinkNewDeviceScreen: View { // MARK: - Previews -import MatrixRustSDKMocks - struct LinkNewDeviceScreen_Previews: PreviewProvider, TestablePreview { static let viewModel = makeViewModel(mode: .readyToLink(isGeneratingCode: false)) static let generatingViewModel = makeViewModel(mode: .readyToLink(isGeneratingCode: true)) @@ -143,7 +143,6 @@ struct LinkNewDeviceScreen_Previews: PreviewProvider, TestablePreview { return false } } - clientProxy.linkNewDeviceServiceReturnValue = .init(handler: GrantLoginWithQrCodeHandlerSDKMock(.init(generateDelay: .seconds(20)))) let viewModel = LinkNewDeviceScreenViewModel(clientProxy: clientProxy) diff --git a/ElementX/Sources/Screens/QRCodeLoginScreen/QRCodeLoginScreenModels.swift b/ElementX/Sources/Screens/QRCodeLoginScreen/QRCodeLoginScreenModels.swift index 265593a8e..c4d249ca7 100644 --- a/ElementX/Sources/Screens/QRCodeLoginScreen/QRCodeLoginScreenModels.swift +++ b/ElementX/Sources/Screens/QRCodeLoginScreen/QRCodeLoginScreenModels.swift @@ -19,7 +19,7 @@ enum QRCodeLoginScreenMode { /// Configures the screen to login this device by scanning a QR code. case login(QRCodeLoginServiceProtocol) /// Configures the screen to link another device by scanning a QR code. - case linkDesktop(LinkNewDeviceService) + case linkDesktop(LinkNewDeviceServiceProtocol) /// Configures the screen to link another device by showing it a QR code. case linkMobile(LinkNewDeviceService.LinkMobileProgressPublisher) } @@ -180,6 +180,13 @@ enum QRCodeLoginState: Equatable { } } + var isDisplayQR: Bool { + switch self { + case .displayQR: true + default: false + } + } + var isError: Bool { switch self { case .error, .scan(.scanFailed): true diff --git a/ElementX/Sources/Screens/QRCodeLoginScreen/QRCodeLoginScreenViewModel.swift b/ElementX/Sources/Screens/QRCodeLoginScreen/QRCodeLoginScreenViewModel.swift index d0ead6b95..80f3ca563 100644 --- a/ElementX/Sources/Screens/QRCodeLoginScreen/QRCodeLoginScreenViewModel.swift +++ b/ElementX/Sources/Screens/QRCodeLoginScreen/QRCodeLoginScreenViewModel.swift @@ -153,7 +153,7 @@ class QRCodeLoginScreenViewModel: QRCodeLoginScreenViewModelType, QRCodeLoginScr // TODO: when user cancels in UI then the underlying login needs to be cancelled too. It's unclear if we have that exposed in the bindings yet. - private func handleScan(qrData: Data, linkService: LinkNewDeviceService) { + private func handleScan(qrData: Data, linkService: LinkNewDeviceServiceProtocol) { guard currentTask == nil else { return } state.state = .scan(.connecting) diff --git a/ElementX/Sources/Screens/QRCodeLoginScreen/View/QRCodeLoginScreen.swift b/ElementX/Sources/Screens/QRCodeLoginScreen/View/QRCodeLoginScreen.swift index 21b1b256c..212ba16b6 100644 --- a/ElementX/Sources/Screens/QRCodeLoginScreen/View/QRCodeLoginScreen.swift +++ b/ElementX/Sources/Screens/QRCodeLoginScreen/View/QRCodeLoginScreen.swift @@ -337,11 +337,7 @@ struct QRCodeLoginScreen_Previews: PreviewProvider, TestablePreview { static let deviceNotSignedInStateViewModel = QRCodeLoginScreenViewModel.mock(state: .scan(.scanFailed(.deviceNotSignedIn))) // Showing - static let showingStateViewModel = { - let base64QRCode = GrantLoginWithQrCodeHandlerSDKMock.Configuration().generatedBase64QRCode - let image = base64QRCode.data(using: .utf8).flatMap { UIImage(qrCodeData: $0) } ?? UIImage() - return QRCodeLoginScreenViewModel.mock(state: .displayQR(image)) - }() + static let showingStateViewModel = QRCodeLoginScreenViewModel.mock(state: .displayQR(LinkNewDeviceServiceMock.mockQRCodeImage)) // Displaying codes static let deviceCodeStateViewModel = QRCodeLoginScreenViewModel.mock(state: .displayCode(.deviceCode("12"))) diff --git a/ElementX/Sources/Services/Authentication/LinkNewDeviceService.swift b/ElementX/Sources/Services/Authentication/LinkNewDeviceService.swift index 5dc236fe2..19dab69a9 100644 --- a/ElementX/Sources/Services/Authentication/LinkNewDeviceService.swift +++ b/ElementX/Sources/Services/Authentication/LinkNewDeviceService.swift @@ -10,7 +10,15 @@ import CoreImage.CIFilterBuiltins import MatrixRustSDK import SwiftUI -class LinkNewDeviceService { +// sourcery: AutoMockable +protocol LinkNewDeviceServiceProtocol { + /// Links a new device by showing it a QR code. + func linkMobileDevice() -> LinkNewDeviceService.LinkMobileProgressPublisher + /// Links a new device using a QR code generated by said device. + func linkDesktopDevice(with scannedQRData: Data) -> LinkNewDeviceService.LinkDesktopProgressPublisher +} + +class LinkNewDeviceService: LinkNewDeviceServiceProtocol { /// Publishes the progress of linking a new device by showing it a QR code. typealias LinkMobileProgressPublisher = CurrentValuePublisher /// Publishes the progress of linking a new device by scanning a QR code generated by said device. diff --git a/ElementX/Sources/Services/Client/ClientProxy.swift b/ElementX/Sources/Services/Client/ClientProxy.swift index 5ed6ed6f6..a637d360e 100644 --- a/ElementX/Sources/Services/Client/ClientProxy.swift +++ b/ElementX/Sources/Services/Client/ClientProxy.swift @@ -716,7 +716,7 @@ class ClientProxy: ClientProxyProtocol { } } - func linkNewDeviceService() -> LinkNewDeviceService { + func linkNewDeviceService() -> LinkNewDeviceServiceProtocol { LinkNewDeviceService(handler: client.newGrantLoginWithQrCodeHandler()) } diff --git a/ElementX/Sources/Services/Client/ClientProxyProtocol.swift b/ElementX/Sources/Services/Client/ClientProxyProtocol.swift index 2916679c9..5e48b6c46 100644 --- a/ElementX/Sources/Services/Client/ClientProxyProtocol.swift +++ b/ElementX/Sources/Services/Client/ClientProxyProtocol.swift @@ -195,7 +195,7 @@ protocol ClientProxyProtocol: AnyObject { func removeUserAvatar() async -> Result - func linkNewDeviceService() -> LinkNewDeviceService + func linkNewDeviceService() -> LinkNewDeviceServiceProtocol func deactivateAccount(password: String?, eraseData: Bool) async -> Result diff --git a/ElementX/Sources/UITests/UITestsAppCoordinator.swift b/ElementX/Sources/UITests/UITestsAppCoordinator.swift index 95687b780..097f51d25 100644 --- a/ElementX/Sources/UITests/UITestsAppCoordinator.swift +++ b/ElementX/Sources/UITests/UITestsAppCoordinator.swift @@ -736,9 +736,15 @@ class MockScreen: Identifiable { return navigationStackCoordinator case .linkNewDevice: + let linkMobileProgressSubject: CurrentValueSubject = .init(.qrReady(LinkNewDeviceServiceMock.mockQRCodeImage)) + let linkNewDeviceService = LinkNewDeviceServiceMock(.init(linkMobileProgressPublisher: linkMobileProgressSubject.asCurrentValuePublisher())) + + let clientProxy = ClientProxyMock(.init()) + clientProxy.linkNewDeviceServiceReturnValue = linkNewDeviceService + let navigationStackCoordinator = NavigationStackCoordinator() let flowCoordinator = LinkNewDeviceFlowCoordinator(navigationStackCoordinator: navigationStackCoordinator, - flowParameters: CommonFlowParameters(userSession: UserSessionMock(.init()), + flowParameters: CommonFlowParameters(userSession: UserSessionMock(.init(clientProxy: clientProxy)), bugReportService: BugReportServiceMock(.init()), elementCallService: ElementCallServiceMock(.init()), timelineControllerFactory: TimelineControllerFactoryMock(.init()), diff --git a/UITests/Sources/LinkNewDeviceTests.swift b/UITests/Sources/LinkNewDeviceTests.swift index 33ff89523..a0055f027 100644 --- a/UITests/Sources/LinkNewDeviceTests.swift +++ b/UITests/Sources/LinkNewDeviceTests.swift @@ -11,16 +11,38 @@ import XCTest class LinkNewDeviceTests: XCTestCase { enum Step { static let selectDevice = 1 + static let linkMobileDevice = 2 + static let linkDesktopComputer = 3 static let dismissed = 99 } func testFlow() async throws { + // Root screen let app = Application.launch(.linkNewDevice) try await app.assertScreenshot(step: Step.selectDevice) + // Link showing a QR code + let mobileDeviceButton = app.buttons[A11yIdentifiers.linkNewDeviceScreen.mobileDevice] + mobileDeviceButton.tap() + try await app.assertScreenshot(step: Step.linkMobileDevice) + + // Pop back to the root screen + let backButton = app.buttons["Link new device"] + backButton.tap() + try await app.assertScreenshot(step: Step.selectDevice) + + // Link scanning a QR code + let desktopComputerButton = app.buttons[A11yIdentifiers.linkNewDeviceScreen.desktopComputer] + desktopComputerButton.tap() + try await app.assertScreenshot(step: Step.linkDesktopComputer) + + // Pop back to the root screen + backButton.tap() + try await app.assertScreenshot(step: Step.selectDevice) + + // Dismiss the flow let cancelButton = app.buttons[A11yIdentifiers.linkNewDeviceScreen.cancel] cancelButton.tap() - try await app.assertScreenshot(step: Step.dismissed) } } diff --git a/UITests/Sources/__Snapshots__/Application/linkNewDevice.testFlow-iPad-en-GB-2.png b/UITests/Sources/__Snapshots__/Application/linkNewDevice.testFlow-iPad-en-GB-2.png new file mode 100644 index 000000000..61e1c81c4 --- /dev/null +++ b/UITests/Sources/__Snapshots__/Application/linkNewDevice.testFlow-iPad-en-GB-2.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d01d3cb0985b5720c032a0e157bbe7e453f4b7cba00d050f7731e8a821814be0 +size 190548 diff --git a/UITests/Sources/__Snapshots__/Application/linkNewDevice.testFlow-iPad-en-GB-3.png b/UITests/Sources/__Snapshots__/Application/linkNewDevice.testFlow-iPad-en-GB-3.png new file mode 100644 index 000000000..a7c813fa1 --- /dev/null +++ b/UITests/Sources/__Snapshots__/Application/linkNewDevice.testFlow-iPad-en-GB-3.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4f35198629723417903f1709bffd656ec968ce9ec135be1f52df5e7fa41352d5 +size 173283 diff --git a/UITests/Sources/__Snapshots__/Application/linkNewDevice.testFlow-iPhone-en-GB-2.png b/UITests/Sources/__Snapshots__/Application/linkNewDevice.testFlow-iPhone-en-GB-2.png new file mode 100644 index 000000000..f19fd2f18 --- /dev/null +++ b/UITests/Sources/__Snapshots__/Application/linkNewDevice.testFlow-iPhone-en-GB-2.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d64aface8fd46495e451a982c47f477f9381a1e669778f027582bc968537d4ce +size 164767 diff --git a/UITests/Sources/__Snapshots__/Application/linkNewDevice.testFlow-iPhone-en-GB-3.png b/UITests/Sources/__Snapshots__/Application/linkNewDevice.testFlow-iPhone-en-GB-3.png new file mode 100644 index 000000000..a86b50b97 --- /dev/null +++ b/UITests/Sources/__Snapshots__/Application/linkNewDevice.testFlow-iPhone-en-GB-3.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c1fc3df2dca28137e09abdb4a411168fb1c68cfab92bcaf40951351ec9812722 +size 136852 diff --git a/UnitTests/Sources/QRCodeLoginScreenViewModelTests.swift b/UnitTests/Sources/QRCodeLoginScreenViewModelTests.swift index ea795f613..7890cb1aa 100644 --- a/UnitTests/Sources/QRCodeLoginScreenViewModelTests.swift +++ b/UnitTests/Sources/QRCodeLoginScreenViewModelTests.swift @@ -9,41 +9,63 @@ import Combine import XCTest -import MatrixRustSDK - @testable import ElementX +import MatrixRustSDKMocks @MainActor final class QRCodeLoginScreenViewModelTests: XCTestCase { - private var qrProgressSubject: CurrentValueSubject! - private var qrServiceMock: QRCodeLoginServiceMock! - private var appMediatorMock: AppMediatorMock! + private var qrLoginProgressSubject: CurrentValueSubject! + private var qrCodeLoginService: QRCodeLoginServiceMock! + + private var linkMobileProgressSubject: CurrentValueSubject! + private var linkDesktopProgressSubject: CurrentValueSubject! + private var linkNewDeviceService: LinkNewDeviceServiceMock! + + private var appMediator: AppMediatorMock! + private var viewModel: QRCodeLoginScreenViewModelProtocol! - - private var context: QRCodeLoginScreenViewModelType.Context { - viewModel.context - } + private var context: QRCodeLoginScreenViewModelType.Context { viewModel.context } - override func setUp() { - qrProgressSubject = .init(.starting) - qrServiceMock = QRCodeLoginServiceMock() - qrServiceMock.loginWithQRCodeDataReturnValue = qrProgressSubject.asCurrentValuePublisher() - appMediatorMock = AppMediatorMock.default - viewModel = QRCodeLoginScreenViewModel(mode: .login(qrServiceMock), - canSignInManually: true, - appMediator: appMediatorMock) - } - - func testInitialState() { + func testLoginInitialState() { + setupViewModel(mode: .login) + XCTAssertEqual(context.viewState.state, .loginInstructions) XCTAssertNil(context.qrResult) - XCTAssertFalse(qrServiceMock.loginWithQRCodeDataCalled) - XCTAssertFalse(appMediatorMock.requestAuthorizationIfNeededCalled) - XCTAssertFalse(appMediatorMock.openAppSettingsCalled) + XCTAssertFalse(qrCodeLoginService.loginWithQRCodeDataCalled) + XCTAssertFalse(appMediator.requestAuthorizationIfNeededCalled) + XCTAssertFalse(appMediator.openAppSettingsCalled) + + XCTAssertFalse(linkNewDeviceService.linkMobileDeviceCalled) + XCTAssertFalse(linkNewDeviceService.linkDesktopDeviceWithCalled) + } + + func testLinkDesktopInitialState() { + setupViewModel(mode: .linkDesktop) + + XCTAssertEqual(context.viewState.state, .linkDesktopInstructions) + XCTAssertNil(context.qrResult) + XCTAssertFalse(linkNewDeviceService.linkDesktopDeviceWithCalled) + XCTAssertFalse(appMediator.requestAuthorizationIfNeededCalled) + XCTAssertFalse(appMediator.openAppSettingsCalled) + + XCTAssertFalse(linkNewDeviceService.linkMobileDeviceCalled) + XCTAssertFalse(qrCodeLoginService.loginWithQRCodeDataCalled) + } + + func testLinkMobileInitialState() { + setupViewModel(mode: .linkMobile) + + XCTAssertTrue(context.viewState.state.isDisplayQR) + XCTAssertTrue(linkNewDeviceService.linkMobileDeviceCalled) + + XCTAssertFalse(linkNewDeviceService.linkDesktopDeviceWithCalled) + XCTAssertFalse(qrCodeLoginService.loginWithQRCodeDataCalled) + XCTAssertNil(context.qrResult) } func testRequestCameraPermission() async throws { - appMediatorMock.requestAuthorizationIfNeededReturnValue = false + setupViewModel(mode: .login) + appMediator.requestAuthorizationIfNeededReturnValue = false XCTAssert(context.viewState.state == .loginInstructions) let deferred = deferFulfillment(viewModel.context.$viewState) { state in @@ -51,15 +73,16 @@ final class QRCodeLoginScreenViewModelTests: XCTestCase { } context.send(viewAction: .startScan) try await deferred.fulfill() - XCTAssertTrue(appMediatorMock.requestAuthorizationIfNeededCalled) + XCTAssertTrue(appMediator.requestAuthorizationIfNeededCalled) context.send(viewAction: .errorAction(.openSettings)) await Task.yield() - XCTAssertTrue(appMediatorMock.openAppSettingsCalled) + XCTAssertTrue(appMediator.openAppSettingsCalled) XCTAssertNil(context.qrResult) } func testLogin() async throws { + setupViewModel(mode: .login) XCTAssert(context.viewState.state == .loginInstructions) var deferred = deferFulfillment(context.$viewState) { state in @@ -67,7 +90,7 @@ final class QRCodeLoginScreenViewModelTests: XCTestCase { } context.send(viewAction: .startScan) try await deferred.fulfill() - XCTAssertTrue(appMediatorMock.requestAuthorizationIfNeededCalled) + XCTAssertTrue(appMediator.requestAuthorizationIfNeededCalled) deferred = deferFulfillment(context.$viewState) { state in state.state == .scan(.connecting) @@ -78,13 +101,13 @@ final class QRCodeLoginScreenViewModelTests: XCTestCase { deferred = deferFulfillment(context.$viewState) { state in state.state == .displayCode(.deviceCode("01")) } - qrProgressSubject.send(.establishingSecureChannel(checkCode: 1, checkCodeString: "01")) + qrLoginProgressSubject.send(.establishingSecureChannel(checkCode: 1, checkCodeString: "01")) try await deferred.fulfill() deferred = deferFulfillment(context.$viewState) { state in state.state == .displayCode(.verificationCode("ABCDEF")) } - qrProgressSubject.send(.waitingForToken(userCode: "ABCDEF")) + qrLoginProgressSubject.send(.waitingForToken(userCode: "ABCDEF")) try await deferred.fulfill() let deferredAction = deferFulfillment(viewModel.actionsPublisher) { action in @@ -93,7 +116,109 @@ final class QRCodeLoginScreenViewModelTests: XCTestCase { default: false } } - qrProgressSubject.send(.signedIn(UserSessionMock(.init(clientProxy: ClientProxyMock())))) + qrLoginProgressSubject.send(.signedIn(UserSessionMock(.init(clientProxy: ClientProxyMock())))) try await deferredAction.fulfill() } + + func testLinkDesktopComputer() async throws { + setupViewModel(mode: .linkDesktop) + XCTAssert(context.viewState.state == .linkDesktopInstructions) + + var deferred = deferFulfillment(context.$viewState) { $0.state == .scan(.scanning) } + context.send(viewAction: .startScan) + try await deferred.fulfill() + XCTAssertTrue(appMediator.requestAuthorizationIfNeededCalled) + + deferred = deferFulfillment(context.$viewState) { $0.state == .scan(.connecting) } + context.qrResult = .init() + try await deferred.fulfill() + + deferred = deferFulfillment(context.$viewState) { $0.state == .displayCode(.deviceCode("01")) } + linkDesktopProgressSubject.send(.establishingSecureChannel(checkCodeString: "01")) + try await deferred.fulfill() + + var deferredAction = deferFulfillment(viewModel.actionsPublisher) { action in + guard case .requestOIDCAuthorisation = action else { return false } + return true + } + linkDesktopProgressSubject.send(.waitingForAuthorisation(verificationURL: .homeDirectory)) + try await deferredAction.fulfill() + + let currentState = context.viewState.state + let deferredFailure = deferFailure(context.$viewState, timeout: 1) { $0.state != currentState } + linkDesktopProgressSubject.send(.syncingSecrets) + try await deferredFailure.fulfill() + + deferredAction = deferFulfillment(viewModel.actionsPublisher) { action in + guard case .dismiss = action else { return false } + return true + } + linkDesktopProgressSubject.send(.done) + try await deferredAction.fulfill() + } + + func testLinkMobileDevice() async throws { + setupViewModel(mode: .linkMobile) + XCTAssert(context.viewState.state.isDisplayQR) + + let checkCodeSender = CheckCodeSenderSDKMock() + let checkCodeSenderProxy = CheckCodeSenderProxy(underlyingSender: checkCodeSender) + var deferredState = deferFulfillment(context.$viewState) { $0.state == .confirmCode(.inputCode(checkCodeSenderProxy)) } + linkMobileProgressSubject.send(.qrScanned(checkCodeSenderProxy)) + try await deferredState.fulfill() + + deferredState = deferFulfillment(context.$viewState) { $0.state == .confirmCode(.sendingCode) } + context.checkCodeInput = "01" + context.send(viewAction: .sendCheckCode) + try await deferredState.fulfill() + + var deferredAction = deferFulfillment(viewModel.actionsPublisher) { action in + guard case .requestOIDCAuthorisation = action else { return false } + return true + } + linkMobileProgressSubject.send(.waitingForAuthorisation(verificationURL: .homeDirectory)) + try await deferredAction.fulfill() + + let currentState = context.viewState.state + let deferredFailure = deferFailure(context.$viewState, timeout: 1) { $0.state != currentState } + linkMobileProgressSubject.send(.syncingSecrets) + try await deferredFailure.fulfill() + + deferredAction = deferFulfillment(viewModel.actionsPublisher) { action in + guard case .dismiss = action else { return false } + return true + } + linkMobileProgressSubject.send(.done) + try await deferredAction.fulfill() + } + + // MARK: - Helpers + + enum Mode { case login, linkDesktop, linkMobile } + + private func setupViewModel(mode: Mode) { + qrLoginProgressSubject = .init(.starting) + qrCodeLoginService = QRCodeLoginServiceMock() + qrCodeLoginService.loginWithQRCodeDataReturnValue = qrLoginProgressSubject.asCurrentValuePublisher() + + linkMobileProgressSubject = .init(.qrReady(LinkNewDeviceServiceMock.mockQRCodeImage)) + linkDesktopProgressSubject = .init(.starting) + linkNewDeviceService = LinkNewDeviceServiceMock(.init(linkMobileProgressPublisher: linkMobileProgressSubject.asCurrentValuePublisher(), + linkDesktopProgressPublisher: linkDesktopProgressSubject.asCurrentValuePublisher())) + + let screenMode: QRCodeLoginScreenMode + switch mode { + case .login: + screenMode = .login(qrCodeLoginService) + case .linkDesktop: + screenMode = .linkDesktop(linkNewDeviceService) + case .linkMobile: + screenMode = .linkMobile(linkNewDeviceService.linkMobileDevice()) + } + + appMediator = AppMediatorMock.default + viewModel = QRCodeLoginScreenViewModel(mode: screenMode, + canSignInManually: true, + appMediator: appMediator) + } }