diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index e8b7249d0..86d56b43c 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -529,7 +529,6 @@ 7F61F9ACD5EC9E845EF3EFBF /* BugReportServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFFD3200F9960D4996159F10 /* BugReportServiceTests.swift */; }; 7F7EA51A9A43125A8CB6AC90 /* NotificationSettingsScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46D560DDA3B20C82766ACFAD /* NotificationSettingsScreenViewModel.swift */; }; 7F941B063C94E1718DFC2CF3 /* RoomChangeRolesScreenRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23E6EB7960BC9D0F7396B3BD /* RoomChangeRolesScreenRow.swift */; }; - 7FED77802940EA7DF4D0D3A2 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 36DA824791172B9821EACBED /* PrivacyInfo.xcprivacy */; }; 7FF6E1FBE6E9517FD29A1D8E /* RoomChangeRolesScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48A5C34C4E4268EF65D171EF /* RoomChangeRolesScreenModels.swift */; }; 8015842CB4DE1BE414D2CDED /* AppLockSetupBiometricsScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C62E07C1164F5120727A2A8 /* AppLockSetupBiometricsScreenCoordinator.swift */; }; 804C15D8ADE0EA7A5268F58A /* OverridableAvatarImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648DD1C10E4957CB791FE0B8 /* OverridableAvatarImage.swift */; }; @@ -583,7 +582,6 @@ 8AB8ED1051216546CB35FA0E /* UserSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E5E9C044BEB7C70B1378E91 /* UserSession.swift */; }; 8AC256AF0EC54658321C9241 /* LegalInformationScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E5725BC6C63604CB769145B /* LegalInformationScreenViewModelTests.swift */; }; 8B1D5CE017EEC734CF5FE130 /* Encodable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 260004737C573A56FA01E86E /* Encodable.swift */; }; - 8B408C574E35E1C9B43A50CE /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 048A21188AB19349D026BECD /* PrivacyInfo.xcprivacy */; }; 8B41D0357B91CD3B6F6A3BCA /* EmoteRoomTimelineItemContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE378083653EF0C9B5E9D580 /* EmoteRoomTimelineItemContent.swift */; }; 8B76191B9DDD1AC90A6E3A35 /* MediaFileHandleProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEC1D382565A4E9CAC2F14EA /* MediaFileHandleProxy.swift */; }; 8BC8EF6705A78946C1F22891 /* SoftLogoutScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71A7D4DDEEE5D2CA0C8D63CD /* SoftLogoutScreen.swift */; }; @@ -601,6 +599,7 @@ 8ED8AF57A06F5EE9978ED23F /* AuthenticationStartScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8FB89DC7F9A4A91020037001 /* AuthenticationStartScreenViewModelTests.swift */; }; 8EF63DDDC1B54F122070B04D /* ReadMarkerRoomTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6311F21F911E23BE4DF51B4 /* ReadMarkerRoomTimelineView.swift */; }; 8F2FAA98457750D9D664136F /* PostHog in Frameworks */ = {isa = PBXBuildFile; productRef = 4278261E147DB2DE5CFB7FC5 /* PostHog */; }; + 90067BB37EBA60FA4AEF2DA3 /* QRCodeLoginServiceMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90B4ED923603F6110D4960C5 /* QRCodeLoginServiceMock.swift */; }; 904F06C9C1AEF884C2077542 /* RoomDirectorySearchScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2E4EF80DFB8FE7C4469B15D /* RoomDirectorySearchScreen.swift */; }; 90733645AE76FB33DAD28C2B /* URLSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE40D4A5DD857AC16EED945A /* URLSession.swift */; }; 9095B9E40DB5CF8BA26CE0D8 /* ReactionsSummaryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 153726EDCE1ACBB3D466A916 /* ReactionsSummaryView.swift */; }; @@ -1057,6 +1056,7 @@ FD4C21F8DA1E273DE94FCD1A /* NotificationItemProxyProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B927CF5EF7FCCDA5EDC474B /* NotificationItemProxyProtocol.swift */; }; FD4DEC88210F35C35B2FB386 /* ProcessInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3A1398EFF65090FDA1CB639 /* ProcessInfo.swift */; }; FD762761C5D0C30E6255C3D8 /* ServerConfirmationScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABA4CF2F5B4F68D02E412004 /* ServerConfirmationScreenViewModelProtocol.swift */; }; + FDD5B4B616D9FF4DE3E9A418 /* QRCodeScannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92DB574F954CC2B40F7BE892 /* QRCodeScannerView.swift */; }; FE4593FC2A02AAF92E089565 /* ElementAnimations.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF1593DD87F974F8509BB619 /* ElementAnimations.swift */; }; FEFD5290B31FCBA6999912C8 /* RoomChangePermissionsScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2721D7B051F0159AA919DA05 /* RoomChangePermissionsScreenViewModelProtocol.swift */; }; FF34BF2AF731340AF9414A18 /* SwipeRightAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4552D3466B1453F287223ADA /* SwipeRightAction.swift */; }; @@ -1678,12 +1678,14 @@ 90791B9C739C716A40E1B230 /* target.yml */ = {isa = PBXFileReference; lastKnownFileType = text.yaml; path = target.yml; sourceTree = ""; }; 907FA4DE17DEA1A3738EFB83 /* AudioRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioRecorder.swift; sourceTree = ""; }; 90A55430639712CFACA34F43 /* TextRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextRoomTimelineItem.swift; sourceTree = ""; }; + 90B4ED923603F6110D4960C5 /* QRCodeLoginServiceMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCodeLoginServiceMock.swift; sourceTree = ""; }; 90DFF217B3D9D0941283278C /* RoomRolesAndPermissionsScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomRolesAndPermissionsScreenViewModelProtocol.swift; sourceTree = ""; }; 90F2F8998E5632668B0AD848 /* RoomTimelineItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineItemView.swift; sourceTree = ""; }; 913C8E13B8B602C7B6C0C4AE /* PillTextAttachmentData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PillTextAttachmentData.swift; sourceTree = ""; }; 91868EB98818044E6FEBE532 /* NotificationPermissionsScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationPermissionsScreenCoordinator.swift; sourceTree = ""; }; 91CF6F7D08228D16BA69B63B /* zh-Hant-TW */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant-TW"; path = "zh-Hant-TW.lproj/Localizable.strings"; sourceTree = ""; }; 92390F9FA98255440A6BF5F8 /* OIDCAuthenticationPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OIDCAuthenticationPresenter.swift; sourceTree = ""; }; + 92DB574F954CC2B40F7BE892 /* QRCodeScannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCodeScannerView.swift; sourceTree = ""; }; 92FCD9116ADDE820E4E30F92 /* UIKitBackgroundTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIKitBackgroundTask.swift; sourceTree = ""; }; 9332DFE9642F0A46ECA0497B /* BlurHashEncode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlurHashEncode.swift; sourceTree = ""; }; 933B074F006F8E930DB98B4E /* TimelineMediaFrame.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMediaFrame.swift; sourceTree = ""; }; @@ -2661,6 +2663,7 @@ E2F96CCBEAAA7F2185BFA354 /* ClientProxyMock.swift */, 382B50F7E379B3DBBD174364 /* NotificationSettingsProxyMock.swift */, D38391154120264910D19528 /* PollMock.swift */, + 90B4ED923603F6110D4960C5 /* QRCodeLoginServiceMock.swift */, 894EE8F5B399A165BA2A6634 /* RoomDirectorySearchMock.swift */, 36FD673E24FBFCFDF398716A /* RoomMemberProxyMock.swift */, F5D1BAA90F3A073D91B4F16B /* RoomNotificationSettingsProxyMock.swift */, @@ -4617,6 +4620,7 @@ isa = PBXGroup; children = ( BFA9EA59D5C0DA1BFC7B3621 /* QRCodeLoginScreen.swift */, + 92DB574F954CC2B40F7BE892 /* QRCodeScannerView.swift */, ); path = View; sourceTree = ""; @@ -5419,7 +5423,6 @@ 5F5488FBC9CFEB6F433D74A4 /* Localizable.strings in Resources */, 0EA6537A07E2DC882AEA5962 /* Localizable.stringsdict in Resources */, 6860721DB3091BE08164C132 /* MapAssets.xcassets in Resources */, - 8B408C574E35E1C9B43A50CE /* PrivacyInfo.xcprivacy in Resources */, C3317EF833AB4060988DF098 /* SAS.strings in Resources */, CE1694C7BB93C3311524EF28 /* Untranslated.strings in Resources */, 2797C9D9BA642370F1C85D78 /* Untranslated.stringsdict in Resources */, @@ -5432,7 +5435,6 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - 7FED77802940EA7DF4D0D3A2 /* PrivacyInfo.xcprivacy in Resources */, D2D70B5DB1A5E4AF0CD88330 /* target.yml in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -6196,7 +6198,9 @@ 46FCD999E92D9717D24AAB94 /* QRCodeLoginScreenModels.swift in Sources */, 30E5628F74AD3C27A061BF25 /* QRCodeLoginScreenViewModel.swift in Sources */, E9D2ED1C4186931E3D5FDA4E /* QRCodeLoginScreenViewModelProtocol.swift in Sources */, + 90067BB37EBA60FA4AEF2DA3 /* QRCodeLoginServiceMock.swift in Sources */, BB04B1D8E7401C90506D401E /* QRCodeLoginServiceProtocol.swift in Sources */, + FDD5B4B616D9FF4DE3E9A418 /* QRCodeScannerView.swift in Sources */, 9095B9E40DB5CF8BA26CE0D8 /* ReactionsSummaryView.swift in Sources */, 743790BF6A5B0577EA74AF14 /* ReadMarkerRoomTimelineItem.swift in Sources */, 8EF63DDDC1B54F122070B04D /* ReadMarkerRoomTimelineView.swift in Sources */, diff --git a/ElementX/Resources/Localizations/en.lproj/Localizable.strings b/ElementX/Resources/Localizations/en.lproj/Localizable.strings index aaf212f77..185e138c8 100644 --- a/ElementX/Resources/Localizations/en.lproj/Localizable.strings +++ b/ElementX/Resources/Localizations/en.lproj/Localizable.strings @@ -418,6 +418,12 @@ "screen_invites_decline_direct_chat_title" = "Decline chat"; "screen_invites_empty_list" = "No Invites"; "screen_invites_invited_you" = "%1$@ (%2$@) invited you"; +"screen_join_room_join_action" = "Join room"; +"screen_join_room_knock_action" = "Knock to join"; +"screen_join_room_subtitle_knock" = "Click the button below and a room administrator will be notified. You’ll be able to join the conversation once approved."; +"screen_join_room_subtitle_no_preview" = "You must be a member of this room to view the message history."; +"screen_join_room_title_knock" = "Want to join this room?"; +"screen_join_room_title_no_preview" = "Preview is not available"; "screen_key_backup_disable_confirmation_action_turn_off" = "Turn off"; "screen_key_backup_disable_confirmation_description" = "You will lose your encrypted messages if you are signed out of all devices."; "screen_key_backup_disable_confirmation_title" = "Are you sure you want to turn off backup?"; @@ -476,6 +482,7 @@ "screen_polls_history_filter_ongoing" = "Ongoing"; "screen_polls_history_filter_past" = "Past"; "screen_polls_history_title" = "Polls"; +"screen_qr_code_login_connecting_subtitle" = "Establishing connection"; "screen_qr_code_login_initial_state_item_1" = "Open Element on a desktop device"; "screen_qr_code_login_initial_state_item_2" = "Click on your avatar"; "screen_qr_code_login_initial_state_item_3" = "Select %1$@"; @@ -483,6 +490,10 @@ "screen_qr_code_login_initial_state_item_4" = "Select %1$@"; "screen_qr_code_login_initial_state_item_4_action" = "“Show QR code”"; "screen_qr_code_login_initial_state_title" = "Open Element on another device to get the QR code"; +"screen_qr_code_login_invalid_scan_state_description" = "Use the QR code shown on the other device."; +"screen_qr_code_login_invalid_scan_state_retry_button" = "Try Again"; +"screen_qr_code_login_invalid_scan_state_subtitle" = "Wrong QR code"; +"screen_qr_code_login_scanning_state_title" = "Scan the QR code"; "screen_recovery_key_change_description" = "Get a new recovery key if you've lost your existing one. After changing your recovery key, your old one will no longer work."; "screen_recovery_key_change_generate_key" = "Generate a new recovery key"; "screen_recovery_key_change_generate_key_description" = "Make sure you can store your recovery key somewhere safe"; diff --git a/ElementX/Sources/Application/AppCoordinator.swift b/ElementX/Sources/Application/AppCoordinator.swift index af79325a1..066172cdd 100644 --- a/ElementX/Sources/Application/AppCoordinator.swift +++ b/ElementX/Sources/Application/AppCoordinator.swift @@ -406,7 +406,8 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg navigationRootCoordinator: navigationRootCoordinator, appSettings: appSettings, analytics: ServiceLocator.shared.analytics, - userIndicatorController: ServiceLocator.shared.userIndicatorController) + userIndicatorController: ServiceLocator.shared.userIndicatorController, + orientationManager: windowManager) authenticationFlowCoordinator?.delegate = self authenticationFlowCoordinator?.start() diff --git a/ElementX/Sources/FlowCoordinators/AuthenticationFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/AuthenticationFlowCoordinator.swift index 813f0f511..ab3ab5a15 100644 --- a/ElementX/Sources/FlowCoordinators/AuthenticationFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/AuthenticationFlowCoordinator.swift @@ -30,6 +30,7 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol { private let appSettings: AppSettings private let analytics: AnalyticsService private let userIndicatorController: UserIndicatorControllerProtocol + private let orientationManager: OrientationManagerProtocol private var cancellables = Set() @@ -45,13 +46,15 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol { navigationRootCoordinator: NavigationRootCoordinator, appSettings: AppSettings, analytics: AnalyticsService, - userIndicatorController: UserIndicatorControllerProtocol) { + userIndicatorController: UserIndicatorControllerProtocol, + orientationManager: WindowManagerProtocol) { self.authenticationService = authenticationService self.bugReportService = bugReportService self.navigationRootCoordinator = navigationRootCoordinator self.appSettings = appSettings self.analytics = analytics self.userIndicatorController = userIndicatorController + self.orientationManager = orientationManager navigationStackCoordinator = NavigationStackCoordinator() } @@ -103,7 +106,8 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol { } private func startQRCodeLogin() { - let coordinator = QRCodeLoginScreenCoordinator(parameters: .init(qrCodeLoginService: QRCodeLoginService())) + let coordinator = QRCodeLoginScreenCoordinator(parameters: .init(qrCodeLoginService: QRCodeLoginService(), + orientationManager: orientationManager)) coordinator.actionsPublisher.sink { [weak self] action in guard let self else { return diff --git a/ElementX/Sources/Generated/Strings.swift b/ElementX/Sources/Generated/Strings.swift index a0b12fe83..2af8b9ca6 100644 --- a/ElementX/Sources/Generated/Strings.swift +++ b/ElementX/Sources/Generated/Strings.swift @@ -1039,6 +1039,18 @@ internal enum L10n { internal static func screenInvitesInvitedYou(_ p1: Any, _ p2: Any) -> String { return L10n.tr("Localizable", "screen_invites_invited_you", String(describing: p1), String(describing: p2)) } + /// Join room + internal static var screenJoinRoomJoinAction: String { return L10n.tr("Localizable", "screen_join_room_join_action") } + /// Knock to join + internal static var screenJoinRoomKnockAction: String { return L10n.tr("Localizable", "screen_join_room_knock_action") } + /// Click the button below and a room administrator will be notified. You’ll be able to join the conversation once approved. + internal static var screenJoinRoomSubtitleKnock: String { return L10n.tr("Localizable", "screen_join_room_subtitle_knock") } + /// You must be a member of this room to view the message history. + internal static var screenJoinRoomSubtitleNoPreview: String { return L10n.tr("Localizable", "screen_join_room_subtitle_no_preview") } + /// Want to join this room? + internal static var screenJoinRoomTitleKnock: String { return L10n.tr("Localizable", "screen_join_room_title_knock") } + /// Preview is not available + internal static var screenJoinRoomTitleNoPreview: String { return L10n.tr("Localizable", "screen_join_room_title_no_preview") } /// Turn off internal static var screenKeyBackupDisableConfirmationActionTurnOff: String { return L10n.tr("Localizable", "screen_key_backup_disable_confirmation_action_turn_off") } /// You will lose your encrypted messages if you are signed out of all devices. @@ -1167,6 +1179,8 @@ internal enum L10n { internal static var screenPollsHistoryFilterPast: String { return L10n.tr("Localizable", "screen_polls_history_filter_past") } /// Polls internal static var screenPollsHistoryTitle: String { return L10n.tr("Localizable", "screen_polls_history_title") } + /// Establishing connection + internal static var screenQrCodeLoginConnectingSubtitle: String { return L10n.tr("Localizable", "screen_qr_code_login_connecting_subtitle") } /// Open Element on a desktop device internal static var screenQrCodeLoginInitialStateItem1: String { return L10n.tr("Localizable", "screen_qr_code_login_initial_state_item_1") } /// Click on your avatar @@ -1185,6 +1199,14 @@ internal enum L10n { internal static var screenQrCodeLoginInitialStateItem4Action: String { return L10n.tr("Localizable", "screen_qr_code_login_initial_state_item_4_action") } /// Open Element on another device to get the QR code internal static var screenQrCodeLoginInitialStateTitle: String { return L10n.tr("Localizable", "screen_qr_code_login_initial_state_title") } + /// Use the QR code shown on the other device. + internal static var screenQrCodeLoginInvalidScanStateDescription: String { return L10n.tr("Localizable", "screen_qr_code_login_invalid_scan_state_description") } + /// Try Again + internal static var screenQrCodeLoginInvalidScanStateRetryButton: String { return L10n.tr("Localizable", "screen_qr_code_login_invalid_scan_state_retry_button") } + /// Wrong QR code + internal static var screenQrCodeLoginInvalidScanStateSubtitle: String { return L10n.tr("Localizable", "screen_qr_code_login_invalid_scan_state_subtitle") } + /// Scan the QR code + internal static var screenQrCodeLoginScanningStateTitle: String { return L10n.tr("Localizable", "screen_qr_code_login_scanning_state_title") } /// Get a new recovery key if you've lost your existing one. After changing your recovery key, your old one will no longer work. internal static var screenRecoveryKeyChangeDescription: String { return L10n.tr("Localizable", "screen_recovery_key_change_description") } /// Generate a new recovery key diff --git a/ElementX/Sources/Mocks/QRCodeLoginServiceMock.swift b/ElementX/Sources/Mocks/QRCodeLoginServiceMock.swift new file mode 100644 index 000000000..d0950a93f --- /dev/null +++ b/ElementX/Sources/Mocks/QRCodeLoginServiceMock.swift @@ -0,0 +1,28 @@ +// +// Copyright 2024 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +struct QRCodeLoginServiceMockConfiguration { + var isAuthorized = true +} + +extension QRCodeLoginServiceMock { + convenience init(configuration: QRCodeLoginServiceMockConfiguration) { + self.init() + requestAuthorizationIfNeededReturnValue = configuration.isAuthorized + } +} diff --git a/ElementX/Sources/Screens/AuthenticationStartScreen/AuthenticationStartScreenViewModel.swift b/ElementX/Sources/Screens/AuthenticationStartScreen/AuthenticationStartScreenViewModel.swift index 11653b4cf..3db52059a 100644 --- a/ElementX/Sources/Screens/AuthenticationStartScreen/AuthenticationStartScreenViewModel.swift +++ b/ElementX/Sources/Screens/AuthenticationStartScreen/AuthenticationStartScreenViewModel.swift @@ -30,9 +30,11 @@ class AuthenticationStartScreenViewModel: AuthenticationStartScreenViewModelType init(appSettings: AppSettings) { self.appSettings = appSettings super.init(initialViewState: AuthenticationStartScreenViewState()) - appSettings.$qrCodeLoginEnabled - .weakAssign(to: \.state.isQRCodeLoginEnabled, on: self) - .store(in: &cancellables) + if !ProcessInfo.processInfo.isiOSAppOnMac { + appSettings.$qrCodeLoginEnabled + .weakAssign(to: \.state.isQRCodeLoginEnabled, on: self) + .store(in: &cancellables) + } } override func process(viewAction: AuthenticationStartScreenViewAction) { diff --git a/ElementX/Sources/Screens/QRCodeLoginScreen/QRCodeLoginScreenCoordinator.swift b/ElementX/Sources/Screens/QRCodeLoginScreen/QRCodeLoginScreenCoordinator.swift index 4062091bc..ed67a5ed8 100644 --- a/ElementX/Sources/Screens/QRCodeLoginScreen/QRCodeLoginScreenCoordinator.swift +++ b/ElementX/Sources/Screens/QRCodeLoginScreen/QRCodeLoginScreenCoordinator.swift @@ -21,6 +21,7 @@ import SwiftUI struct QRCodeLoginScreenCoordinatorParameters { let qrCodeLoginService: QRCodeLoginServiceProtocol + let orientationManager: OrientationManagerProtocol } enum QRCodeLoginScreenCoordinatorAction { @@ -29,6 +30,7 @@ enum QRCodeLoginScreenCoordinatorAction { final class QRCodeLoginScreenCoordinator: CoordinatorProtocol { private let viewModel: QRCodeLoginScreenViewModelProtocol + private let orientationManager: OrientationManagerProtocol private var cancellables = Set() @@ -39,6 +41,7 @@ final class QRCodeLoginScreenCoordinator: CoordinatorProtocol { init(parameters: QRCodeLoginScreenCoordinatorParameters) { viewModel = QRCodeLoginScreenViewModel(qrCodeLoginService: parameters.qrCodeLoginService) + orientationManager = parameters.orientationManager } func start() { @@ -52,6 +55,13 @@ final class QRCodeLoginScreenCoordinator: CoordinatorProtocol { } } .store(in: &cancellables) + + orientationManager.setOrientation(.portrait) + orientationManager.lockOrientation(.portrait) + } + + func stop() { + orientationManager.lockOrientation(.all) } func toPresentable() -> AnyView { diff --git a/ElementX/Sources/Screens/QRCodeLoginScreen/QRCodeLoginScreenModels.swift b/ElementX/Sources/Screens/QRCodeLoginScreen/QRCodeLoginScreenModels.swift index 45a9bb8ba..a492c485b 100644 --- a/ElementX/Sources/Screens/QRCodeLoginScreen/QRCodeLoginScreenModels.swift +++ b/ElementX/Sources/Screens/QRCodeLoginScreen/QRCodeLoginScreenModels.swift @@ -56,12 +56,24 @@ enum QRCodeLoginScreenViewAction { case startScan } -enum QRCodeLoginState { +enum QRCodeLoginState: Equatable { + /// Initial state where the user is informed how to perform the scan case initial - case scanning + /// The camera is scanning + case scan(QRCodeLoginScanningState) + /// Any full screen error state case error(QRCodeLoginErrorState) - enum QRCodeLoginErrorState { + enum QRCodeLoginErrorState: Equatable { case noCameraPermission } + + enum QRCodeLoginScanningState: Equatable { + /// the qr code is scanning + case scanning + /// the qr code has been detected and is being processed + case connecting + /// the qr code has been processed and is invalid + case invalid + } } diff --git a/ElementX/Sources/Screens/QRCodeLoginScreen/QRCodeLoginScreenViewModel.swift b/ElementX/Sources/Screens/QRCodeLoginScreen/QRCodeLoginScreenViewModel.swift index a7fac6439..8b9f6997b 100644 --- a/ElementX/Sources/Screens/QRCodeLoginScreen/QRCodeLoginScreenViewModel.swift +++ b/ElementX/Sources/Screens/QRCodeLoginScreen/QRCodeLoginScreenViewModel.swift @@ -45,6 +45,18 @@ class QRCodeLoginScreenViewModel: QRCodeLoginScreenViewModelType, QRCodeLoginScr } private func startScanIfPossible() async { - state.state = await qrCodeLoginService.requestAuthorizationIfNeeded() ? .scanning : .error(.noCameraPermission) + state.state = await qrCodeLoginService.requestAuthorizationIfNeeded() ? .scan(.scanning) : .error(.noCameraPermission) + } + + /// Only for mocking initial states + fileprivate init(state: QRCodeLoginState) { + qrCodeLoginService = QRCodeLoginServiceMock(configuration: .init()) + super.init(initialViewState: .init(state: state)) + } +} + +extension QRCodeLoginScreenViewModel { + static func mock(state: QRCodeLoginState) -> QRCodeLoginScreenViewModel { + QRCodeLoginScreenViewModel(state: state) } } diff --git a/ElementX/Sources/Screens/QRCodeLoginScreen/View/QRCodeLoginScreen.swift b/ElementX/Sources/Screens/QRCodeLoginScreen/View/QRCodeLoginScreen.swift index 6ecfa08b4..57173078f 100644 --- a/ElementX/Sources/Screens/QRCodeLoginScreen/View/QRCodeLoginScreen.swift +++ b/ElementX/Sources/Screens/QRCodeLoginScreen/View/QRCodeLoginScreen.swift @@ -19,6 +19,7 @@ import SwiftUI struct QRCodeLoginScreen: View { @ObservedObject var context: QRCodeLoginScreenViewModel.Context + @State private var qrFrame = CGRect.zero var body: some View { NavigationStack { @@ -36,13 +37,15 @@ struct QRCodeLoginScreen: View { switch context.viewState.state { case .initial: initialContent - case .scanning, .error: - // TODO: Handle states + case .scan: + qrScanContent + case .error: + // TODO: Handle error states EmptyView() } } - var initialContent: some View { + private var initialContent: some View { FullscreenDialog { VStack(alignment: .leading, spacing: 40) { VStack(spacing: 16) { @@ -64,6 +67,75 @@ struct QRCodeLoginScreen: View { .buttonStyle(.compound(.primary)) } } + + private var qrScanContent: some View { + FullscreenDialog { + VStack(spacing: 40) { + VStack(spacing: 16) { + HeroImage(icon: \.takePhotoSolid, style: .subtle) + + Text(L10n.screenQrCodeLoginScanningStateTitle) + .foregroundColor(.compound.textPrimary) + .font(.compound.headingMDBold) + .multilineTextAlignment(.center) + } + + qrScanner + } + } bottomContent: { + qrScanFooter + } + .padding(.horizontal, 24) + } + + @ViewBuilder + private var qrScanFooter: some View { + if case let .scan(scanState) = context.viewState.state { + switch scanState { + case .connecting: + VStack(spacing: 4) { + ProgressView() + Text(L10n.screenQrCodeLoginConnectingSubtitle) + .foregroundColor(.compound.textSecondary) + .font(.compound.bodySM) + } + case .scanning: + // To keep the spacing consistent between states + Button("") { } + .buttonStyle(.compound(.primary)) + .hidden() + case .invalid: + VStack(spacing: 16) { + Button(L10n.screenQrCodeLoginInvalidScanStateRetryButton) { + // TODO: Implement try again + } + .buttonStyle(.compound(.primary)) + + VStack(spacing: 0) { + Label(L10n.screenQrCodeLoginInvalidScanStateSubtitle, icon: \.error, iconSize: .medium, relativeTo: .compound.bodyMDSemibold) + .labelStyle(.custom(spacing: 10)) + .font(.compound.bodyMDSemibold) + .foregroundColor(.compound.textCriticalPrimary) + + Text(L10n.screenQrCodeLoginInvalidScanStateDescription) + .foregroundColor(.compound.textSecondary) + .font(.compound.bodySM) + } + } + } + } + } + + private var qrScanner: some View { + QRCodeScannerView() + .aspectRatio(1.0, contentMode: .fill) + .frame(maxWidth: 312) + .readFrame($qrFrame) + .background(.compound.bgCanvasDefault) + .overlay( + QRScannerViewOverlay(length: qrFrame.height) + ) + } @ToolbarContentBuilder private var toolbar: some ToolbarContent { @@ -75,12 +147,53 @@ struct QRCodeLoginScreen: View { } } +private struct QRScannerViewOverlay: View { + let length: CGFloat + + private let dashRatio: CGFloat = 80.0 / 312.0 + private let emptyRatio: CGFloat = 232.0 / 312.0 + private let dashPhaseRatio: CGFloat = 40.0 / 312.0 + + private var dashLength: CGFloat { + length * dashRatio + } + + private var emptyLength: CGFloat { + length * emptyRatio + } + + private var dashPhase: CGFloat { + length * dashPhaseRatio + } + + var body: some View { + Rectangle() + .stroke(.compound.textPrimary, style: StrokeStyle(lineWidth: 4.0, lineCap: .square, dash: [dashLength, emptyLength], dashPhase: dashPhase)) + } +} + // MARK: - Previews struct QRCodeLoginScreen_Previews: PreviewProvider, TestablePreview { - static let viewModel = QRCodeLoginScreenViewModel(qrCodeLoginService: QRCodeLoginServiceMock()) + static let initialStateViewModel = QRCodeLoginScreenViewModel.mock(state: .initial) + + static let scanningStateViewModel = QRCodeLoginScreenViewModel.mock(state: .scan(.scanning)) + + static let connectingStateViewModel = QRCodeLoginScreenViewModel.mock(state: .scan(.connecting)) + + static let invalidStateViewModel = QRCodeLoginScreenViewModel.mock(state: .scan(.invalid)) + static var previews: some View { - QRCodeLoginScreen(context: viewModel.context) + QRCodeLoginScreen(context: initialStateViewModel.context) .previewDisplayName("Initial") + + QRCodeLoginScreen(context: scanningStateViewModel.context) + .previewDisplayName("Scanning") + + QRCodeLoginScreen(context: connectingStateViewModel.context) + .previewDisplayName("Connecting") + + QRCodeLoginScreen(context: invalidStateViewModel.context) + .previewDisplayName("Invalid") } } diff --git a/ElementX/Sources/Screens/QRCodeLoginScreen/View/QRCodeScannerView.swift b/ElementX/Sources/Screens/QRCodeLoginScreen/View/QRCodeScannerView.swift new file mode 100644 index 000000000..d5ec974d2 --- /dev/null +++ b/ElementX/Sources/Screens/QRCodeLoginScreen/View/QRCodeScannerView.swift @@ -0,0 +1,87 @@ +// +// Copyright 2024 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import AVFoundation +import SwiftUI +import UIKit + +struct QRCodeScannerView: UIViewControllerRepresentable { + func makeUIViewController(context: Context) -> QRScannerController { + let controller = QRScannerController() + + return controller + } + + func updateUIViewController(_ uiViewController: QRScannerController, context: Context) { } +} + +final class QRScannerController: UIViewController { + private var captureSession = AVCaptureSession() + private var videoPreviewLayer: AVCaptureVideoPreviewLayer? + private var qrCodeFrameView: UIView? + + var delegate: AVCaptureMetadataOutputObjectsDelegate? + + override func viewDidLoad() { + super.viewDidLoad() + + // Get the back-facing camera for capturing videos + guard let captureDevice = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) else { + MXLog.error("Failed to get the camera device") + return + } + + let videoInput: AVCaptureDeviceInput + + do { + // Get an instance of the AVCaptureDeviceInput class using the previous device object. + videoInput = try AVCaptureDeviceInput(device: captureDevice) + + } catch { + // If any error occurs, simply print it out and don't continue any more. + MXLog.error("ACaptureDeviceInput error: \(error)") + return + } + + // Set the input device on the capture session. + captureSession.addInput(videoInput) + + // Initialize a AVCaptureMetadataOutput object and set it as the output device to the capture session. + let captureMetadataOutput = AVCaptureMetadataOutput() + captureSession.addOutput(captureMetadataOutput) + + // Set delegate and use the default dispatch queue to execute the call back + captureMetadataOutput.setMetadataObjectsDelegate(delegate, queue: DispatchQueue.main) + captureMetadataOutput.metadataObjectTypes = [.qr] + + // Initialize the video preview layer and add it as a sublayer to the viewPreview view's layer. + let previewLayer = AVCaptureVideoPreviewLayer(session: captureSession) + videoPreviewLayer = previewLayer + videoPreviewLayer?.videoGravity = AVLayerVideoGravity.resizeAspectFill + videoPreviewLayer?.frame = view.layer.bounds + view.layer.addSublayer(previewLayer) + + // Start video capture. + DispatchQueue.global(qos: .userInitiated).async { + self.captureSession.startRunning() + } + } + + override func viewWillLayoutSubviews() { + super.viewWillLayoutSubviews() + videoPreviewLayer?.frame = view.layer.bounds + } +} diff --git a/ElementX/Sources/UITests/UITestsAppCoordinator.swift b/ElementX/Sources/UITests/UITestsAppCoordinator.swift index fe2cc56c8..2ba820283 100644 --- a/ElementX/Sources/UITests/UITestsAppCoordinator.swift +++ b/ElementX/Sources/UITests/UITestsAppCoordinator.swift @@ -128,7 +128,8 @@ class MockScreen: Identifiable { navigationRootCoordinator: navigationRootCoordinator, appSettings: ServiceLocator.shared.settings, analytics: ServiceLocator.shared.analytics, - userIndicatorController: ServiceLocator.shared.userIndicatorController) + userIndicatorController: ServiceLocator.shared.userIndicatorController, + orientationManager: windowManager) flowCoordinator.start() retainedState.append(flowCoordinator) return nil diff --git a/PreviewTests/__Snapshots__/PreviewTests/test_qRCodeLoginScreen-iPad-en-GB.Connecting.png b/PreviewTests/__Snapshots__/PreviewTests/test_qRCodeLoginScreen-iPad-en-GB.Connecting.png new file mode 100644 index 000000000..b8742763d --- /dev/null +++ b/PreviewTests/__Snapshots__/PreviewTests/test_qRCodeLoginScreen-iPad-en-GB.Connecting.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:feb1e093f66598f864fc69cf1d27d573ebf560f45faae367a368d667af4c1fc9 +size 119170 diff --git a/PreviewTests/__Snapshots__/PreviewTests/test_qRCodeLoginScreen-iPad-en-GB.Invalid.png b/PreviewTests/__Snapshots__/PreviewTests/test_qRCodeLoginScreen-iPad-en-GB.Invalid.png new file mode 100644 index 000000000..e79562cfd --- /dev/null +++ b/PreviewTests/__Snapshots__/PreviewTests/test_qRCodeLoginScreen-iPad-en-GB.Invalid.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3dac2b751ad28ba1bff26ab347acaa203f1a9bc8111c946a93359821a078f135 +size 139056 diff --git a/PreviewTests/__Snapshots__/PreviewTests/test_qRCodeLoginScreen-iPad-en-GB.Scanning.png b/PreviewTests/__Snapshots__/PreviewTests/test_qRCodeLoginScreen-iPad-en-GB.Scanning.png new file mode 100644 index 000000000..a200e3ed9 --- /dev/null +++ b/PreviewTests/__Snapshots__/PreviewTests/test_qRCodeLoginScreen-iPad-en-GB.Scanning.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fab3b11a840a9c7e1d0913548c2b3a8b9e5bb7bbd1f8ad39f42ea8e1752f8ce7 +size 108617 diff --git a/PreviewTests/__Snapshots__/PreviewTests/test_qRCodeLoginScreen-iPad-pseudo.Connecting.png b/PreviewTests/__Snapshots__/PreviewTests/test_qRCodeLoginScreen-iPad-pseudo.Connecting.png new file mode 100644 index 000000000..7e83611c9 --- /dev/null +++ b/PreviewTests/__Snapshots__/PreviewTests/test_qRCodeLoginScreen-iPad-pseudo.Connecting.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d1ab1222ff6bca79dd7daefcb772d46d96ed98fe3cdc992151bb2e1370052ffc +size 138588 diff --git a/PreviewTests/__Snapshots__/PreviewTests/test_qRCodeLoginScreen-iPad-pseudo.Invalid.png b/PreviewTests/__Snapshots__/PreviewTests/test_qRCodeLoginScreen-iPad-pseudo.Invalid.png new file mode 100644 index 000000000..6e1496994 --- /dev/null +++ b/PreviewTests/__Snapshots__/PreviewTests/test_qRCodeLoginScreen-iPad-pseudo.Invalid.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c2d58a58ea521543158b54cf477e25b49e732afd32b06f7807dbb8995bc1f5f3 +size 173491 diff --git a/PreviewTests/__Snapshots__/PreviewTests/test_qRCodeLoginScreen-iPad-pseudo.Scanning.png b/PreviewTests/__Snapshots__/PreviewTests/test_qRCodeLoginScreen-iPad-pseudo.Scanning.png new file mode 100644 index 000000000..cb419d9d6 --- /dev/null +++ b/PreviewTests/__Snapshots__/PreviewTests/test_qRCodeLoginScreen-iPad-pseudo.Scanning.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6c84232c26e308c664b7f43477462301a4639eabf871a320aac52feff9478647 +size 122684 diff --git a/PreviewTests/__Snapshots__/PreviewTests/test_qRCodeLoginScreen-iPhone-15-en-GB.Connecting.png b/PreviewTests/__Snapshots__/PreviewTests/test_qRCodeLoginScreen-iPhone-15-en-GB.Connecting.png new file mode 100644 index 000000000..feba7526d --- /dev/null +++ b/PreviewTests/__Snapshots__/PreviewTests/test_qRCodeLoginScreen-iPhone-15-en-GB.Connecting.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b993abba1f7f55d40e5ef7cba92bb5b92c32d8ec6e61752f922358ec0289ede8 +size 61775 diff --git a/PreviewTests/__Snapshots__/PreviewTests/test_qRCodeLoginScreen-iPhone-15-en-GB.Invalid.png b/PreviewTests/__Snapshots__/PreviewTests/test_qRCodeLoginScreen-iPhone-15-en-GB.Invalid.png new file mode 100644 index 000000000..6d04709f1 --- /dev/null +++ b/PreviewTests/__Snapshots__/PreviewTests/test_qRCodeLoginScreen-iPhone-15-en-GB.Invalid.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:47b1d00666502211bb71b85a8cce5f6e94d14704fde0a725b134d8bc0d319365 +size 79146 diff --git a/PreviewTests/__Snapshots__/PreviewTests/test_qRCodeLoginScreen-iPhone-15-en-GB.Scanning.png b/PreviewTests/__Snapshots__/PreviewTests/test_qRCodeLoginScreen-iPhone-15-en-GB.Scanning.png new file mode 100644 index 000000000..6093c785d --- /dev/null +++ b/PreviewTests/__Snapshots__/PreviewTests/test_qRCodeLoginScreen-iPhone-15-en-GB.Scanning.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b41c9c5b2f2be75bab8f38f5af720c32d76c5700f75208a478ef9bf759a0960a +size 52335 diff --git a/PreviewTests/__Snapshots__/PreviewTests/test_qRCodeLoginScreen-iPhone-15-pseudo.Connecting.png b/PreviewTests/__Snapshots__/PreviewTests/test_qRCodeLoginScreen-iPhone-15-pseudo.Connecting.png new file mode 100644 index 000000000..fc140e16a --- /dev/null +++ b/PreviewTests/__Snapshots__/PreviewTests/test_qRCodeLoginScreen-iPhone-15-pseudo.Connecting.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b4f241b79fe1a5a1638d032380a8f014b393edcb4ecad306661e01d019690de7 +size 82179 diff --git a/PreviewTests/__Snapshots__/PreviewTests/test_qRCodeLoginScreen-iPhone-15-pseudo.Invalid.png b/PreviewTests/__Snapshots__/PreviewTests/test_qRCodeLoginScreen-iPhone-15-pseudo.Invalid.png new file mode 100644 index 000000000..863300896 --- /dev/null +++ b/PreviewTests/__Snapshots__/PreviewTests/test_qRCodeLoginScreen-iPhone-15-pseudo.Invalid.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:91b2615e13ad67a0546e89b08d0c801b6ba1c759fc4ac03c67d3737bba41a764 +size 114400 diff --git a/PreviewTests/__Snapshots__/PreviewTests/test_qRCodeLoginScreen-iPhone-15-pseudo.Scanning.png b/PreviewTests/__Snapshots__/PreviewTests/test_qRCodeLoginScreen-iPhone-15-pseudo.Scanning.png new file mode 100644 index 000000000..b7c06686f --- /dev/null +++ b/PreviewTests/__Snapshots__/PreviewTests/test_qRCodeLoginScreen-iPhone-15-pseudo.Scanning.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4b16f40a5776c3edf0dd1848e95f07ae3911a551e13b1077a6e42043d9c2365c +size 66567 diff --git a/changelog.d/pr-2674.wip b/changelog.d/pr-2674.wip new file mode 100644 index 000000000..931013d0e --- /dev/null +++ b/changelog.d/pr-2674.wip @@ -0,0 +1 @@ +QR Code login screen displays now the camera view. \ No newline at end of file