From dd8d27bcaca66897c7c3a14a34943729d8dfedba Mon Sep 17 00:00:00 2001 From: Aleksandrs Proskurins Date: Wed, 5 Oct 2022 16:01:17 +0300 Subject: [PATCH 01/15] Unverified sessions screen --- .../Contents.json | 12 ++++++ .../user_other_sessions_unverified.svg | 5 +++ Riot/Assets/en.lproj/Vector.strings | 2 + Riot/Generated/Images.swift | 1 + Riot/Generated/Strings.swift | 8 ++++ .../UserOtherSessionsViewModel.swift | 8 ++-- .../View/UserOtherSessionsHeaderView.swift | 3 +- .../UserSessionsOverviewService.swift | 24 ++++++----- .../MockUserSessionsOverviewService.swift | 2 + .../UserSessionsOverviewServiceProtocol.swift | 1 + .../UserSessionsOverviewViewModel.swift | 5 +-- .../View/UserSessionListItem.swift | 2 +- .../View/UserSessionListItemViewData.swift | 2 + .../UserSessionListItemViewDataFactory.swift | 41 ++++++++++--------- .../UserSessionsOverviewServiceTests.swift | 8 ++++ 15 files changed, 83 insertions(+), 41 deletions(-) create mode 100644 Riot/Assets/Images.xcassets/DeviceManager/user_other_sessions_unverified.imageset/Contents.json create mode 100644 Riot/Assets/Images.xcassets/DeviceManager/user_other_sessions_unverified.imageset/user_other_sessions_unverified.svg diff --git a/Riot/Assets/Images.xcassets/DeviceManager/user_other_sessions_unverified.imageset/Contents.json b/Riot/Assets/Images.xcassets/DeviceManager/user_other_sessions_unverified.imageset/Contents.json new file mode 100644 index 000000000..64debb2e6 --- /dev/null +++ b/Riot/Assets/Images.xcassets/DeviceManager/user_other_sessions_unverified.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "user_other_sessions_unverified.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Riot/Assets/Images.xcassets/DeviceManager/user_other_sessions_unverified.imageset/user_other_sessions_unverified.svg b/Riot/Assets/Images.xcassets/DeviceManager/user_other_sessions_unverified.imageset/user_other_sessions_unverified.svg new file mode 100644 index 000000000..738e3ed9c --- /dev/null +++ b/Riot/Assets/Images.xcassets/DeviceManager/user_other_sessions_unverified.imageset/user_other_sessions_unverified.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Riot/Assets/en.lproj/Vector.strings b/Riot/Assets/en.lproj/Vector.strings index 10f54cf0b..be8d11aad 100644 --- a/Riot/Assets/en.lproj/Vector.strings +++ b/Riot/Assets/en.lproj/Vector.strings @@ -2389,6 +2389,8 @@ To enable access, tap Settings> Location and select Always"; "user_session_push_notifications_message" = "When turned on, this session will receive push notifications."; "user_other_session_security_recommendation_title" = "Security recommendation"; +"user_other_session_unverified_sessions_header_subtitle" = "Verify your sessions for enhanced secure messaging or sign out from those you don’t recognize or use anymore."; +"user_other_session_unverified_current_session_details" = "%@ · Your current session"; // First item is client name and second item is session display name "user_session_name" = "%@: %@"; diff --git a/Riot/Generated/Images.swift b/Riot/Generated/Images.swift index 4908bfb7d..eb71d450c 100644 --- a/Riot/Generated/Images.swift +++ b/Riot/Generated/Images.swift @@ -106,6 +106,7 @@ internal class Asset: NSObject { internal static let deviceTypeUnknown = ImageAsset(name: "device_type_unknown") internal static let deviceTypeWeb = ImageAsset(name: "device_type_web") internal static let userOtherSessionsInactive = ImageAsset(name: "user_other_sessions_inactive") + internal static let userOtherSessionsUnverified = ImageAsset(name: "user_other_sessions_unverified") internal static let userSessionListItemInactiveSession = ImageAsset(name: "user_session_list_item_inactive_session") internal static let userSessionUnverified = ImageAsset(name: "user_session_unverified") internal static let userSessionVerified = ImageAsset(name: "user_session_verified") diff --git a/Riot/Generated/Strings.swift b/Riot/Generated/Strings.swift index 02c55dac0..ba12886a4 100644 --- a/Riot/Generated/Strings.swift +++ b/Riot/Generated/Strings.swift @@ -8495,6 +8495,14 @@ public class VectorL10n: NSObject { public static var userOtherSessionSecurityRecommendationTitle: String { return VectorL10n.tr("Vector", "user_other_session_security_recommendation_title") } + /// %@ · Your current session + public static func userOtherSessionUnverifiedCurrentSessionDetails(_ p1: String) -> String { + return VectorL10n.tr("Vector", "user_other_session_unverified_current_session_details", p1) + } + /// Verify your sessions for enhanced secure messaging or sign out from those you don’t recognize or use anymore. + public static var userOtherSessionUnverifiedSessionsHeaderSubtitle: String { + return VectorL10n.tr("Vector", "user_other_session_unverified_sessions_header_subtitle") + } /// Name public static var userSessionDetailsApplicationName: String { return VectorL10n.tr("Vector", "user_session_details_application_name") diff --git a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/UserOtherSessionsViewModel.swift b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/UserOtherSessionsViewModel.swift index a3c399791..d08ba3426 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/UserOtherSessionsViewModel.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/UserOtherSessionsViewModel.swift @@ -81,11 +81,9 @@ class UserOtherSessionsViewModel: UserOtherSessionsViewModelType, UserOtherSessi subtitle: VectorL10n.userSessionsOverviewSecurityRecommendationsInactiveInfo, iconName: Asset.Images.userOtherSessionsInactive.name) case .unverified: - // TODO: - return UserOtherSessionsHeaderViewData(title: nil, - subtitle: "", - iconName: nil) + return UserOtherSessionsHeaderViewData(title: VectorL10n.userSessionsOverviewSecurityRecommendationsUnverifiedTitle, + subtitle: VectorL10n.userOtherSessionUnverifiedSessionsHeaderSubtitle, + iconName: Asset.Images.userOtherSessionsUnverified.name) } } } - diff --git a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/View/UserOtherSessionsHeaderView.swift b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/View/UserOtherSessionsHeaderView.swift index d6a2b344b..83ba4ce51 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/View/UserOtherSessionsHeaderView.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/View/UserOtherSessionsHeaderView.swift @@ -18,7 +18,7 @@ import SwiftUI struct UserOtherSessionsHeaderViewData: Hashable { var title: String? - var subtitle: String + let subtitle: String var iconName: String? } @@ -36,7 +36,6 @@ struct UserOtherSessionsHeaderView: View { HStack (alignment: .top, spacing: 0) { if let iconName = viewData.iconName { Image(iconName) - .foregroundColor(.red) .frame(width: 40, height: 40) .background(theme.colors.background) .clipShape(backgroundShape) diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/MatrixSDK/UserSessionsOverviewService.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/MatrixSDK/UserSessionsOverviewService.swift index 273072ea7..fbb5c9a4a 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/MatrixSDK/UserSessionsOverviewService.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/MatrixSDK/UserSessionsOverviewService.swift @@ -24,6 +24,7 @@ class UserSessionsOverviewService: UserSessionsOverviewServiceProtocol { private let dataProvider: UserSessionsDataProviderProtocol private(set) var overviewData: UserSessionsOverviewData + private(set) var sessionInfos: [UserSessionInfo] init(dataProvider: UserSessionsDataProviderProtocol) { self.dataProvider = dataProvider @@ -32,7 +33,7 @@ class UserSessionsOverviewService: UserSessionsOverviewServiceProtocol { unverifiedSessions: [], inactiveSessions: [], otherSessions: []) - + sessionInfos = [] setupInitialOverviewData() } @@ -42,7 +43,8 @@ class UserSessionsOverviewService: UserSessionsOverviewServiceProtocol { dataProvider.devices { response in switch response { case .success(let devices): - self.overviewData = self.sessionsOverviewData(from: devices) + self.sessionInfos = self.sortAndConvertDevices(devices: devices) + self.overviewData = self.sessionsOverviewData(from: self.sessionInfos) completion(.success(self.overviewData)) case .failure(let error): completion(.failure(error)) @@ -78,16 +80,18 @@ class UserSessionsOverviewService: UserSessionsOverviewServiceProtocol { } return sessionInfo(from: device, isCurrentSession: true) } - - private func sessionsOverviewData(from devices: [MXDevice]) -> UserSessionsOverviewData { - let allSessions = devices + + private func sortAndConvertDevices(devices: [MXDevice]) -> [UserSessionInfo] { + devices .sorted { $0.lastSeenTs > $1.lastSeenTs } .map { sessionInfo(from: $0, isCurrentSession: $0.deviceId == dataProvider.myDeviceId) } - - return UserSessionsOverviewData(currentSession: allSessions.filter(\.isCurrent).first, - unverifiedSessions: allSessions.filter { !$0.isVerified }, - inactiveSessions: allSessions.filter { !$0.isActive }, - otherSessions: allSessions.filter { !$0.isCurrent }) + } + + private func sessionsOverviewData(from allSessions: [UserSessionInfo]) -> UserSessionsOverviewData { + UserSessionsOverviewData(currentSession: allSessions.filter(\.isCurrent).first, + unverifiedSessions: allSessions.filter { !$0.isVerified }, + inactiveSessions: allSessions.filter { !$0.isActive }, + otherSessions: allSessions.filter { !$0.isCurrent }) } private func sessionInfo(from device: MXDevice, isCurrentSession: Bool) -> UserSessionInfo { diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/Mock/MockUserSessionsOverviewService.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/Mock/MockUserSessionsOverviewService.swift index 5a87dd27b..53c8df8b7 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/Mock/MockUserSessionsOverviewService.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/Mock/MockUserSessionsOverviewService.swift @@ -17,6 +17,7 @@ import Foundation class MockUserSessionsOverviewService: UserSessionsOverviewServiceProtocol { + enum Mode { case currentSessionUnverified case currentSessionVerified @@ -28,6 +29,7 @@ class MockUserSessionsOverviewService: UserSessionsOverviewServiceProtocol { private let mode: Mode var overviewData: UserSessionsOverviewData + var sessionInfos = [UserSessionInfo]() init(mode: Mode = .currentSessionUnverified) { self.mode = mode diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/UserSessionsOverviewServiceProtocol.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/UserSessionsOverviewServiceProtocol.swift index b1bc5f001..3f69b814b 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/UserSessionsOverviewServiceProtocol.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/UserSessionsOverviewServiceProtocol.swift @@ -25,6 +25,7 @@ struct UserSessionsOverviewData { protocol UserSessionsOverviewServiceProtocol { var overviewData: UserSessionsOverviewData { get } + var sessionInfos: [UserSessionInfo] { get } func updateOverviewData(completion: @escaping (Result) -> Void) -> Void diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/UserSessionsOverviewViewModel.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/UserSessionsOverviewViewModel.swift index 72e0168a3..e0f3b481c 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/UserSessionsOverviewViewModel.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/UserSessionsOverviewViewModel.swift @@ -46,8 +46,7 @@ class UserSessionsOverviewViewModel: UserSessionsOverviewViewModelType, UserSess } completion?(.showCurrentSessionOverview(sessionInfo: currentSessionInfo)) case .viewAllUnverifiedSessions: - // TODO: showSessions(filteredBy: .unverified) - break + showSessions(filteredBy: .unverified) case .viewAllInactiveSessions: showSessions(filteredBy: .inactive) case .viewAllOtherSessions: @@ -95,7 +94,7 @@ class UserSessionsOverviewViewModel: UserSessionsOverviewViewModelType, UserSess } private func showSessions(filteredBy filter: OtherUserSessionsFilter) { - completion?(.showOtherSessions(sessionsInfo: userSessionsOverviewService.overviewData.otherSessions, + completion?(.showOtherSessions(sessionsInfo: userSessionsOverviewService.sessionInfos, filter: filter)) } } diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItem.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItem.swift index 001b658b5..bc9df2406 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItem.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItem.swift @@ -49,7 +49,7 @@ struct UserSessionListItem: View { } Text(viewData.sessionDetails) .font(theme.fonts.caption1) - .foregroundColor(theme.colors.secondaryContent) + .foregroundColor(viewData.highlightSessionDetails ? theme.colors.alert : theme.colors.secondaryContent) .multilineTextAlignment(.leading) } } diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItemViewData.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItemViewData.swift index da89c8892..00c24061b 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItemViewData.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItemViewData.swift @@ -29,6 +29,8 @@ struct UserSessionListItemViewData: Identifiable, Hashable { let sessionDetails: String + let highlightSessionDetails: Bool + let deviceAvatarViewData: DeviceAvatarViewData let sessionDetailsIcon: String? diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItemViewDataFactory.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItemViewDataFactory.swift index 49d79433a..2eb9a62b8 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItemViewDataFactory.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItemViewDataFactory.swift @@ -18,49 +18,50 @@ import Foundation struct UserSessionListItemViewDataFactory { - func create(from session: UserSessionInfo) -> UserSessionListItemViewData { - let sessionName = UserSessionNameFormatter.sessionName(deviceType: session.deviceType, - sessionDisplayName: session.name) - let sessionDetails = buildSessionDetails(isVerified: session.isVerified, - lastActivityDate: session.lastSeenTimestamp, - isActive: session.isActive) - let deviceAvatarViewData = DeviceAvatarViewData(deviceType: session.deviceType, - isVerified: session.isVerified) - return UserSessionListItemViewData(sessionId: session.id, + func create(from sessionInfo: UserSessionInfo) -> UserSessionListItemViewData { + let sessionName = UserSessionNameFormatter.sessionName(deviceType: sessionInfo.deviceType, + sessionDisplayName: sessionInfo.name) + let sessionDetails = buildSessionDetails(sessionInfo: sessionInfo) + let deviceAvatarViewData = DeviceAvatarViewData(deviceType: sessionInfo.deviceType, + isVerified: sessionInfo.isVerified) + return UserSessionListItemViewData(sessionId: sessionInfo.id, sessionName: sessionName, sessionDetails: sessionDetails, + highlightSessionDetails: sessionInfo.isCurrent, deviceAvatarViewData: deviceAvatarViewData, - sessionDetailsIcon: getSessionDetailsIcon(isActive: session.isActive)) + sessionDetailsIcon: getSessionDetailsIcon(isActive: sessionInfo.isActive)) } - private func buildSessionDetails(isVerified: Bool, lastActivityDate: TimeInterval?, isActive: Bool) -> String { - if isActive { - return activeSessionDetails(isVerified: isVerified, lastActivityDate: lastActivityDate) + private func buildSessionDetails(sessionInfo: UserSessionInfo) -> String { + if sessionInfo.isActive { + return activeSessionDetails(sessionInfo: sessionInfo) } else { - return inactiveSessionDetails(lastActivityDate: lastActivityDate) + return inactiveSessionDetails(sessionInfo: sessionInfo) } } - private func inactiveSessionDetails(lastActivityDate: TimeInterval?) -> String { - if let lastActivityDate = lastActivityDate { + private func inactiveSessionDetails(sessionInfo: UserSessionInfo) -> String { + if let lastActivityDate = sessionInfo.lastSeenTimestamp { let lastActivityDateString = InactiveUserSessionLastActivityFormatter.lastActivityDateString(from: lastActivityDate) return VectorL10n.userInactiveSessionItemWithDate(lastActivityDateString) } return VectorL10n.userInactiveSessionItem } - private func activeSessionDetails(isVerified: Bool, lastActivityDate: TimeInterval?) -> String { + private func activeSessionDetails(sessionInfo: UserSessionInfo) -> String { let sessionDetailsString: String - let sessionStatusText = isVerified ? VectorL10n.userSessionVerifiedShort : VectorL10n.userSessionUnverifiedShort + let sessionStatusText = sessionInfo.isVerified ? VectorL10n.userSessionVerifiedShort : VectorL10n.userSessionUnverifiedShort var lastActivityDateString: String? - if let lastActivityDate = lastActivityDate { + if let lastActivityDate = sessionInfo.lastSeenTimestamp { lastActivityDateString = UserSessionLastActivityFormatter.lastActivityDateString(from: lastActivityDate) } - if let lastActivityDateString = lastActivityDateString, lastActivityDateString.isEmpty == false { + if sessionInfo.isCurrent { + sessionDetailsString = VectorL10n.userOtherSessionUnverifiedCurrentSessionDetails(sessionStatusText) + } else if let lastActivityDateString = lastActivityDateString, lastActivityDateString.isEmpty == false { sessionDetailsString = VectorL10n.userSessionItemDetails(sessionStatusText, lastActivityDateString) } else { sessionDetailsString = sessionStatusText diff --git a/RiotTests/UserSessionsOverviewServiceTests.swift b/RiotTests/UserSessionsOverviewServiceTests.swift index d52ca31d9..1c3fb7516 100644 --- a/RiotTests/UserSessionsOverviewServiceTests.swift +++ b/RiotTests/UserSessionsOverviewServiceTests.swift @@ -57,6 +57,8 @@ class UserSessionsOverviewServiceTests: XCTestCase { XCTAssertTrue(service.overviewData.unverifiedSessions.isEmpty) XCTAssertTrue(service.overviewData.inactiveSessions.isEmpty) XCTAssertFalse(service.overviewData.otherSessions.isEmpty) + + XCTAssertEqual(service.sessionInfos.count, 2) } func testWithSomeUnverifiedSessions() { @@ -69,6 +71,8 @@ class UserSessionsOverviewServiceTests: XCTestCase { XCTAssertFalse(service.overviewData.unverifiedSessions.isEmpty) XCTAssertTrue(service.overviewData.inactiveSessions.isEmpty) XCTAssertFalse(service.overviewData.otherSessions.isEmpty) + + XCTAssertEqual(service.sessionInfos.count, 3) } func testWithSomeInactiveSessions() { @@ -81,6 +85,8 @@ class UserSessionsOverviewServiceTests: XCTestCase { XCTAssertTrue(service.overviewData.unverifiedSessions.isEmpty) XCTAssertFalse(service.overviewData.inactiveSessions.isEmpty) XCTAssertFalse(service.overviewData.otherSessions.isEmpty) + + XCTAssertEqual(service.sessionInfos.count, 3) } func testWithSomeUnverifiedAndInactiveSessions() { @@ -93,6 +99,8 @@ class UserSessionsOverviewServiceTests: XCTestCase { XCTAssertFalse(service.overviewData.unverifiedSessions.isEmpty) XCTAssertFalse(service.overviewData.inactiveSessions.isEmpty) XCTAssertFalse(service.overviewData.otherSessions.isEmpty) + + XCTAssertEqual(service.sessionInfos.count, 4) } // MARK: - Private From 7ab38c15efcfd600a92eb3fae19dd246eebe6e1e Mon Sep 17 00:00:00 2001 From: Aleksandrs Proskurins Date: Wed, 5 Oct 2022 16:24:57 +0300 Subject: [PATCH 02/15] Changelog --- changelog.d/6801.wip | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/6801.wip diff --git a/changelog.d/6801.wip b/changelog.d/6801.wip new file mode 100644 index 000000000..f3050d719 --- /dev/null +++ b/changelog.d/6801.wip @@ -0,0 +1 @@ +Device manager: Unverified sessions screen. From a586ed0c335b366afa70f9078e5ba1660df045ec Mon Sep 17 00:00:00 2001 From: Aleksandrs Proskurins Date: Wed, 5 Oct 2022 16:59:15 +0300 Subject: [PATCH 03/15] Renamed sessionsInfo to sessionInfos --- .../UserSessionsFlowCoordinator.swift | 12 +++++----- .../UserOtherSessionsCoordinator.swift | 4 ++-- .../MockUserOtherSessionsScreenState.swift | 2 +- .../UserOtherSessionsViewModelTests.swift | 8 +++---- .../UserOtherSessionsViewModel.swift | 22 +++++++++---------- .../UserSessionsOverviewCoordinator.swift | 8 +++---- .../UserSessionsOverviewViewModelTests.swift | 2 +- .../UserSessionsOverviewModels.swift | 4 ++-- .../UserSessionsOverviewViewModel.swift | 2 +- 9 files changed, 32 insertions(+), 32 deletions(-) diff --git a/RiotSwiftUI/Modules/UserSessions/Coordinator/UserSessionsFlowCoordinator.swift b/RiotSwiftUI/Modules/UserSessions/Coordinator/UserSessionsFlowCoordinator.swift index 5ad3cbbdd..4f38ff3cd 100644 --- a/RiotSwiftUI/Modules/UserSessions/Coordinator/UserSessionsFlowCoordinator.swift +++ b/RiotSwiftUI/Modules/UserSessions/Coordinator/UserSessionsFlowCoordinator.swift @@ -55,8 +55,8 @@ final class UserSessionsFlowCoordinator: Coordinator, Presentable { switch result { case let .openSessionOverview(sessionInfo: sessionInfo): self.openSessionOverview(sessionInfo: sessionInfo) - case let .openOtherSessions(sessionsInfo: sessionsInfo, filter: filter): - self.openOtherSessions(sessionsInfo: sessionsInfo, + case let .openOtherSessions(sessionInfos: sessionInfos, filter: filter): + self.openOtherSessions(sessionInfos: sessionInfos, filterBy: filter, title: VectorL10n.userOtherSessionSecurityRecommendationTitle) } @@ -92,8 +92,8 @@ final class UserSessionsFlowCoordinator: Coordinator, Presentable { return UserSessionOverviewCoordinator(parameters: parameters) } - private func openOtherSessions(sessionsInfo: [UserSessionInfo], filterBy filter: OtherUserSessionsFilter, title: String) { - let coordinator = createOtherSessionsCoordinator(sessionsInfo: sessionsInfo, + private func openOtherSessions(sessionInfos: [UserSessionInfo], filterBy filter: OtherUserSessionsFilter, title: String) { + let coordinator = createOtherSessionsCoordinator(sessionInfos: sessionInfos, filterBy: filter, title: title) coordinator.completion = { [weak self] result in @@ -106,10 +106,10 @@ final class UserSessionsFlowCoordinator: Coordinator, Presentable { pushScreen(with: coordinator) } - private func createOtherSessionsCoordinator(sessionsInfo: [UserSessionInfo], + private func createOtherSessionsCoordinator(sessionInfos: [UserSessionInfo], filterBy filter: OtherUserSessionsFilter, title: String) -> UserOtherSessionsCoordinator { - let parameters = UserOtherSessionsCoordinatorParameters(sessionsInfo: sessionsInfo, + let parameters = UserOtherSessionsCoordinatorParameters(sessionInfos: sessionInfos, filter: filter, title: title) return UserOtherSessionsCoordinator(parameters: parameters) diff --git a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Coordinator/UserOtherSessionsCoordinator.swift b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Coordinator/UserOtherSessionsCoordinator.swift index fd7fa8932..c118001d3 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Coordinator/UserOtherSessionsCoordinator.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Coordinator/UserOtherSessionsCoordinator.swift @@ -18,7 +18,7 @@ import CommonKit import SwiftUI struct UserOtherSessionsCoordinatorParameters { - let sessionsInfo: [UserSessionInfo] + let sessionInfos: [UserSessionInfo] let filter: OtherUserSessionsFilter let title: String } @@ -38,7 +38,7 @@ final class UserOtherSessionsCoordinator: Coordinator, Presentable { init(parameters: UserOtherSessionsCoordinatorParameters) { self.parameters = parameters - let viewModel = UserOtherSessionsViewModel(sessionsInfo: parameters.sessionsInfo, + let viewModel = UserOtherSessionsViewModel(sessionInfos: parameters.sessionInfos, filter: parameters.filter, title: parameters.title) let view = UserOtherSessions(viewModel: viewModel.context) diff --git a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/MockUserOtherSessionsScreenState.swift b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/MockUserOtherSessionsScreenState.swift index 788311fa9..3e1821406 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/MockUserOtherSessionsScreenState.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/MockUserOtherSessionsScreenState.swift @@ -40,7 +40,7 @@ enum MockUserOtherSessionsScreenState: MockScreenState, CaseIterable { /// Generate the view struct for the screen state. var screenView: ([Any], AnyView) { - let viewModel = UserOtherSessionsViewModel(sessionsInfo: inactiveSessions(), + let viewModel = UserOtherSessionsViewModel(sessionInfos: inactiveSessions(), filter: .inactive, title: VectorL10n.userOtherSessionSecurityRecommendationTitle) diff --git a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Test/Unit/UserOtherSessionsViewModelTests.swift b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Test/Unit/UserOtherSessionsViewModelTests.swift index 43bf5c358..57c2c6a7d 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Test/Unit/UserOtherSessionsViewModelTests.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Test/Unit/UserOtherSessionsViewModelTests.swift @@ -23,7 +23,7 @@ class UserOtherSessionsViewModelTests: XCTestCase { func test_whenUserOtherSessionSelectedProcessed_completionWithShowUserSessionOverviewCalled() { let expectedUserSessionInfo = createUserSessionInfo(sessionId: "session 2") - let sut = UserOtherSessionsViewModel(sessionsInfo: [createUserSessionInfo(sessionId: "session 1"), + let sut = UserOtherSessionsViewModel(sessionInfos: [createUserSessionInfo(sessionId: "session 1"), expectedUserSessionInfo], filter: .inactive, title: "Title") @@ -37,15 +37,15 @@ class UserOtherSessionsViewModelTests: XCTestCase { } func test_whenModelCreated_withInactiveFilter_viewStateIsCorrect() { - let sessionsInfo = [createUserSessionInfo(sessionId: "session 1"), createUserSessionInfo(sessionId: "session 2")] - let sut = UserOtherSessionsViewModel(sessionsInfo: sessionsInfo, + let sessionInfos = [createUserSessionInfo(sessionId: "session 1"), createUserSessionInfo(sessionId: "session 2")] + let sut = UserOtherSessionsViewModel(sessionInfos: sessionInfos, filter: .inactive, title: "Title") let expectedHeader = UserOtherSessionsHeaderViewData(title: VectorL10n.userSessionsOverviewSecurityRecommendationsInactiveTitle, subtitle: VectorL10n.userSessionsOverviewSecurityRecommendationsInactiveInfo, iconName: Asset.Images.userOtherSessionsInactive.name) - let expectedItems = sessionsInfo.filter { !$0.isActive }.asViewData() + let expectedItems = sessionInfos.filter { !$0.isActive }.asViewData() let expectedState = UserOtherSessionsViewState(title: "Title", sections: [.sessionItems(header: expectedHeader, items: expectedItems)]) XCTAssertEqual(sut.state, expectedState) diff --git a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/UserOtherSessionsViewModel.swift b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/UserOtherSessionsViewModel.swift index d08ba3426..56a6a44ea 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/UserOtherSessionsViewModel.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/UserOtherSessionsViewModel.swift @@ -27,14 +27,14 @@ enum OtherUserSessionsFilter { class UserOtherSessionsViewModel: UserOtherSessionsViewModelType, UserOtherSessionsViewModelProtocol { var completion: ((UserOtherSessionsViewModelResult) -> Void)? - private let sessionsInfo: [UserSessionInfo] + private let sessionInfos: [UserSessionInfo] - init(sessionsInfo: [UserSessionInfo], + init(sessionInfos: [UserSessionInfo], filter: OtherUserSessionsFilter, title: String) { - self.sessionsInfo = sessionsInfo + self.sessionInfos = sessionInfos super.init(initialViewState: UserOtherSessionsViewState(title: title, sections: [])) - updateViewState(sessionsInfo: sessionsInfo, filter: filter) + updateViewState(sessionInfos: sessionInfos, filter: filter) } // MARK: - Public @@ -42,7 +42,7 @@ class UserOtherSessionsViewModel: UserOtherSessionsViewModelType, UserOtherSessi override func process(viewAction: UserOtherSessionsViewAction) { switch viewAction { case let .userOtherSessionSelected(sessionId: sessionId): - guard let session = sessionsInfo.first(where: {$0.id == sessionId}) else { + guard let session = sessionInfos.first(where: {$0.id == sessionId}) else { assertionFailure("Session should exist in the array.") return } @@ -52,20 +52,20 @@ class UserOtherSessionsViewModel: UserOtherSessionsViewModelType, UserOtherSessi // MARK: - Private - private func updateViewState(sessionsInfo: [UserSessionInfo], filter: OtherUserSessionsFilter) { - let sectionItems = filterSessions(sessionsInfo: sessionsInfo, by: filter).asViewData() + private func updateViewState(sessionInfos: [UserSessionInfo], filter: OtherUserSessionsFilter) { + let sectionItems = filterSessions(sessionInfos: sessionInfos, by: filter).asViewData() let sectionHeader = createHeaderData(filter: filter) state.sections = [.sessionItems(header: sectionHeader, items: sectionItems)] } - private func filterSessions(sessionsInfo: [UserSessionInfo], by filter: OtherUserSessionsFilter) -> [UserSessionInfo] { + private func filterSessions(sessionInfos: [UserSessionInfo], by filter: OtherUserSessionsFilter) -> [UserSessionInfo] { switch filter { case .all: - return sessionsInfo.filter { !$0.isCurrent } + return sessionInfos.filter { !$0.isCurrent } case .inactive: - return sessionsInfo.filter { !$0.isActive } + return sessionInfos.filter { !$0.isActive } case .unverified: - return sessionsInfo.filter { !$0.isVerified } + return sessionInfos.filter { !$0.isVerified } } } diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Coordinator/UserSessionsOverviewCoordinator.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Coordinator/UserSessionsOverviewCoordinator.swift index 443088ad0..981ffc212 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Coordinator/UserSessionsOverviewCoordinator.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Coordinator/UserSessionsOverviewCoordinator.swift @@ -53,8 +53,8 @@ final class UserSessionsOverviewCoordinator: Coordinator, Presentable { MXLog.debug("[UserSessionsOverviewCoordinator] UserSessionsOverviewViewModel did complete with result: \(result).") switch result { - case let .showOtherSessions(sessionsInfo: sessionsInfo, filter: filter): - self.showOtherSessions(sessionsInfo: sessionsInfo, filterBy: filter) + case let .showOtherSessions(sessionInfos: sessionInfos, filter: filter): + self.showOtherSessions(sessionInfos: sessionInfos, filterBy: filter) case .verifyCurrentSession: self.startVerifyCurrentSession() case let .showCurrentSessionOverview(sessionInfo): @@ -84,8 +84,8 @@ final class UserSessionsOverviewCoordinator: Coordinator, Presentable { loadingIndicator = nil } - private func showOtherSessions(sessionsInfo: [UserSessionInfo], filterBy filter: OtherUserSessionsFilter) { - completion?(.openOtherSessions(sessionsInfo: sessionsInfo, filter: filter)) + private func showOtherSessions(sessionInfos: [UserSessionInfo], filterBy filter: OtherUserSessionsFilter) { + completion?(.openOtherSessions(sessionInfos: sessionInfos, filter: filter)) } private func startVerifyCurrentSession() { diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Test/Unit/UserSessionsOverviewViewModelTests.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Test/Unit/UserSessionsOverviewViewModelTests.swift index 8768f0fcd..815f2dcc5 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Test/Unit/UserSessionsOverviewViewModelTests.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Test/Unit/UserSessionsOverviewViewModelTests.swift @@ -51,7 +51,7 @@ class UserSessionsOverviewViewModelTests: XCTestCase { XCTAssertEqual(result, .verifyCurrentSession) viewModel.process(viewAction: .viewAllInactiveSessions) - XCTAssertEqual(result, .showOtherSessions(sessionsInfo: [], filter: .inactive)) + XCTAssertEqual(result, .showOtherSessions(sessionInfos: [], filter: .inactive)) } func testShowSessionDetails() { diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/UserSessionsOverviewModels.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/UserSessionsOverviewModels.swift index 4f8786768..7085a377a 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/UserSessionsOverviewModels.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/UserSessionsOverviewModels.swift @@ -20,13 +20,13 @@ import Foundation enum UserSessionsOverviewCoordinatorResult { case openSessionOverview(sessionInfo: UserSessionInfo) - case openOtherSessions(sessionsInfo: [UserSessionInfo], filter: OtherUserSessionsFilter) + case openOtherSessions(sessionInfos: [UserSessionInfo], filter: OtherUserSessionsFilter) } // MARK: View model enum UserSessionsOverviewViewModelResult: Equatable { - case showOtherSessions(sessionsInfo: [UserSessionInfo], filter: OtherUserSessionsFilter) + case showOtherSessions(sessionInfos: [UserSessionInfo], filter: OtherUserSessionsFilter) case verifyCurrentSession case showCurrentSessionOverview(sessionInfo: UserSessionInfo) case showUserSessionOverview(sessionInfo: UserSessionInfo) diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/UserSessionsOverviewViewModel.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/UserSessionsOverviewViewModel.swift index e0f3b481c..d52b29514 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/UserSessionsOverviewViewModel.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/UserSessionsOverviewViewModel.swift @@ -94,7 +94,7 @@ class UserSessionsOverviewViewModel: UserSessionsOverviewViewModelType, UserSess } private func showSessions(filteredBy filter: OtherUserSessionsFilter) { - completion?(.showOtherSessions(sessionsInfo: userSessionsOverviewService.sessionInfos, + completion?(.showOtherSessions(sessionInfos: userSessionsOverviewService.sessionInfos, filter: filter)) } } From 7d9b040eeb97873d933186fad3ef2bcf220023b4 Mon Sep 17 00:00:00 2001 From: Aleksandrs Proskurins Date: Thu, 6 Oct 2022 10:23:33 +0300 Subject: [PATCH 04/15] UI tests --- .../MockUserOtherSessionsScreenState.swift | 49 ++++++++++++++++++- .../Test/UI/UserOtherSessionsUITests.swift | 13 +++++ .../UserOtherSessionsViewModel.swift | 10 +++- .../UserSessionListItemViewDataFactory.swift | 4 +- 4 files changed, 71 insertions(+), 5 deletions(-) diff --git a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/MockUserOtherSessionsScreenState.swift b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/MockUserOtherSessionsScreenState.swift index 3e1821406..ffcc4f003 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/MockUserOtherSessionsScreenState.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/MockUserOtherSessionsScreenState.swift @@ -25,6 +25,7 @@ enum MockUserOtherSessionsScreenState: MockScreenState, CaseIterable { // mock that screen. case inactiveSessions + case unverifiedSessions /// The associated screen var screenType: Any.Type { @@ -34,15 +35,23 @@ enum MockUserOtherSessionsScreenState: MockScreenState, CaseIterable { /// A list of screen state definitions static var allCases: [MockUserOtherSessionsScreenState] { // Each of the presence statuses - [.inactiveSessions] + [.inactiveSessions, .unverifiedSessions] } /// Generate the view struct for the screen state. var screenView: ([Any], AnyView) { - let viewModel = UserOtherSessionsViewModel(sessionInfos: inactiveSessions(), + let viewModel: UserOtherSessionsViewModel + switch self { + case .inactiveSessions: + viewModel = UserOtherSessionsViewModel(sessionInfos: inactiveSessions(), filter: .inactive, title: VectorL10n.userOtherSessionSecurityRecommendationTitle) + case .unverifiedSessions: + viewModel = UserOtherSessionsViewModel(sessionInfos: unverifiedSessions(), + filter: .unverified, + title: VectorL10n.userOtherSessionSecurityRecommendationTitle) + } // can simulate service and viewModel actions here if needs be. @@ -118,4 +127,40 @@ enum MockUserOtherSessionsScreenState: MockScreenState, CaseIterable { isActive: false, isCurrent: false)] } + + private func unverifiedSessions() -> [UserSessionInfo] { + [UserSessionInfo(id: "0", + name: "iOS", + deviceType: .mobile, + isVerified: false, + lastSeenIP: "10.0.0.10", + lastSeenTimestamp: nil, + applicationName: nil, + applicationVersion: nil, + applicationURL: nil, + deviceModel: nil, + deviceOS: nil, + lastSeenIPLocation: nil, + clientName: nil, + clientVersion: nil, + isActive: true, + isCurrent: true), + UserSessionInfo(id: "1", + name: "macOS", + deviceType: .desktop, + isVerified: false, + lastSeenIP: "1.0.0.1", + lastSeenTimestamp: Date().timeIntervalSince1970 - 8000000, + applicationName: nil, + applicationVersion: nil, + applicationURL: nil, + deviceModel: nil, + deviceOS: nil, + lastSeenIPLocation: nil, + clientName: nil, + clientVersion: nil, + isActive: true, + isCurrent: false) + ] + } } diff --git a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Test/UI/UserOtherSessionsUITests.swift b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Test/UI/UserOtherSessionsUITests.swift index e5ae0f0c1..719c31190 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Test/UI/UserOtherSessionsUITests.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Test/UI/UserOtherSessionsUITests.swift @@ -31,4 +31,17 @@ class UserOtherSessionsUITests: MockScreenTestCase { XCTAssertTrue(app.buttons["RiotSwiftUI Mobile: iOS, Inactive for 90+ days"].exists) } + + func test_whenOtherSessionsWithUnverifiedSessionFilterPresented_correctHeaderDisplayed() { + app.goToScreenWithIdentifier(MockUserOtherSessionsScreenState.unverifiedSessions.title) + + XCTAssertTrue(app.staticTexts[VectorL10n.userSessionsOverviewSecurityRecommendationsUnverifiedTitle].exists) + XCTAssertTrue(app.staticTexts[VectorL10n.userOtherSessionUnverifiedSessionsHeaderSubtitle].exists) + } + + func test_whenOtherSessionsWithUnverifiedSessionFilterPresented_correctItemsDisplayed() { + app.goToScreenWithIdentifier(MockUserOtherSessionsScreenState.unverifiedSessions.title) + + XCTAssertTrue(app.buttons["RiotSwiftUI Mobile: iOS, Unverified · Your current session"].exists) + } } diff --git a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/UserOtherSessionsViewModel.swift b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/UserOtherSessionsViewModel.swift index 56a6a44ea..4673bb7a0 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/UserOtherSessionsViewModel.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/UserOtherSessionsViewModel.swift @@ -53,11 +53,19 @@ class UserOtherSessionsViewModel: UserOtherSessionsViewModelType, UserOtherSessi // MARK: - Private private func updateViewState(sessionInfos: [UserSessionInfo], filter: OtherUserSessionsFilter) { - let sectionItems = filterSessions(sessionInfos: sessionInfos, by: filter).asViewData() + let sectionItems = createSectionItems(sessionInfos: sessionInfos, filter: filter) let sectionHeader = createHeaderData(filter: filter) state.sections = [.sessionItems(header: sectionHeader, items: sectionItems)] } + private func createSectionItems(sessionInfos: [UserSessionInfo], filter: OtherUserSessionsFilter) -> [UserSessionListItemViewData] { + filterSessions(sessionInfos: sessionInfos, by: filter) + .map { + UserSessionListItemViewDataFactory().create(from: $0, + highlightSessionDetails: filter == .unverified && $0.isCurrent) + } + } + private func filterSessions(sessionInfos: [UserSessionInfo], by filter: OtherUserSessionsFilter) -> [UserSessionInfo] { switch filter { case .all: diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItemViewDataFactory.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItemViewDataFactory.swift index 2eb9a62b8..8adb72c3c 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItemViewDataFactory.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItemViewDataFactory.swift @@ -18,7 +18,7 @@ import Foundation struct UserSessionListItemViewDataFactory { - func create(from sessionInfo: UserSessionInfo) -> UserSessionListItemViewData { + func create(from sessionInfo: UserSessionInfo, highlightSessionDetails: Bool = false) -> UserSessionListItemViewData { let sessionName = UserSessionNameFormatter.sessionName(deviceType: sessionInfo.deviceType, sessionDisplayName: sessionInfo.name) let sessionDetails = buildSessionDetails(sessionInfo: sessionInfo) @@ -27,7 +27,7 @@ struct UserSessionListItemViewDataFactory { return UserSessionListItemViewData(sessionId: sessionInfo.id, sessionName: sessionName, sessionDetails: sessionDetails, - highlightSessionDetails: sessionInfo.isCurrent, + highlightSessionDetails: highlightSessionDetails, deviceAvatarViewData: deviceAvatarViewData, sessionDetailsIcon: getSessionDetailsIcon(isActive: sessionInfo.isActive)) } From c7232d0bea6a5070c4247c1718109bc2cff72ac8 Mon Sep 17 00:00:00 2001 From: Gil Eluard Date: Thu, 6 Oct 2022 11:40:12 +0200 Subject: [PATCH 05/15] Check enabled field in notification settings push toggles --- .../MatrixKit/Models/Account/MXKAccount.h | 9 ++ .../MatrixKit/Models/Account/MXKAccount.m | 148 +++++++++++++++++- .../UserSessionOverviewService.swift | 4 + changelog.d/6814.change | 1 + 4 files changed, 154 insertions(+), 8 deletions(-) create mode 100644 changelog.d/6814.change diff --git a/Riot/Modules/MatrixKit/Models/Account/MXKAccount.h b/Riot/Modules/MatrixKit/Models/Account/MXKAccount.h index 665f45ace..fd28f9939 100644 --- a/Riot/Modules/MatrixKit/Models/Account/MXKAccount.h +++ b/Riot/Modules/MatrixKit/Models/Account/MXKAccount.h @@ -306,6 +306,15 @@ typedef BOOL (^MXKAccountOnCertificateChange)(MXKAccount *mxAccount, NSData *cer */ - (void)load3PIDs:(void (^)(void))success failure:(void (^)(NSError *error))failure; +/** + Loads the pusher instance linked to this account. + This method must be called to refresh self.pushNotificationServiceIsActive + + @param success A block object called when the operation succeeds. + @param failure A block object called when the operation fails. + */ +- (void)loadCurrentPusher:(nullable void (^)(void))success failure:(nullable void (^)(NSError *error))failure; + /** Load the current device information for this account. This method must be called to refresh self.device. diff --git a/Riot/Modules/MatrixKit/Models/Account/MXKAccount.m b/Riot/Modules/MatrixKit/Models/Account/MXKAccount.m index 5e7582808..371721209 100644 --- a/Riot/Modules/MatrixKit/Models/Account/MXKAccount.m +++ b/Riot/Modules/MatrixKit/Models/Account/MXKAccount.m @@ -86,6 +86,8 @@ static NSArray *initialSyncSilentErrorsHTTPStatusCodes; // Observe NSCurrentLocaleDidChangeNotification to refresh MXRoomSummaries on time formatting change. id NSCurrentLocaleDidChangeNotificationObserver; + + MXPusher *currentPusher; } /// Will be true if the session is not in a pauseable state or we requested for the session to pause but not finished yet. Will be reverted to false again after `resume` called. @@ -149,6 +151,7 @@ static NSArray *initialSyncSilentErrorsHTTPStatusCodes; // Refresh device information [self loadDeviceInformation:nil failure:nil]; + [self loadCurrentPusher:nil failure:nil]; [self registerAccountDataDidChangeIdentityServerNotification]; [self registerIdentityServiceDidChangeAccessTokenNotification]; @@ -184,6 +187,7 @@ static NSArray *initialSyncSilentErrorsHTTPStatusCodes; // Refresh device information [self loadDeviceInformation:nil failure:nil]; + [self loadCurrentPusher:nil failure:nil]; } return self; @@ -303,6 +307,12 @@ static NSArray *initialSyncSilentErrorsHTTPStatusCodes; - (BOOL)pushNotificationServiceIsActive { + if (currentPusher && currentPusher.enabled) + { + MXLogDebug(@"[MXKAccount][Push] pushNotificationServiceIsActive: currentPusher.enabled %@", currentPusher.enabled); + return currentPusher.enabled.boolValue; + } + BOOL pushNotificationServiceIsActive = ([[MXKAccountManager sharedManager] isAPNSAvailable] && self.hasPusherForPushNotifications && mxSession); MXLogDebug(@"[MXKAccount][Push] pushNotificationServiceIsActive: %@", @(pushNotificationServiceIsActive)); @@ -317,7 +327,44 @@ static NSArray *initialSyncSilentErrorsHTTPStatusCodes; if (enable) { - if ([[MXKAccountManager sharedManager] isAPNSAvailable]) + if (currentPusher && currentPusher.enabled && !currentPusher.enabled.boolValue) + { + [self.mxSession.matrixRestClient setPusherWithPushkey:currentPusher.pushkey + kind:currentPusher.kind + appId:currentPusher.appId + appDisplayName:currentPusher.appDisplayName + deviceDisplayName:currentPusher.deviceDisplayName + profileTag:currentPusher.profileTag + lang:currentPusher.lang + data:currentPusher.data.JSONDictionary + append:NO + enabled:enable + success:^{ + + MXLogDebug(@"[MXKAccount][Push] enablePushNotifications: remotely enabled Push: Success"); + [self loadCurrentPusher:^{ + if (success) + { + success(); + } + } failure:^(NSError *error) { + + MXLogWarning(@"[MXKAccount][Push] enablePushNotifications: load current pusher failed with error: %@", error); + if (failure) + { + failure(error); + } + }]; + } failure:^(NSError *error) { + + MXLogWarning(@"[MXKAccount][Push] enablePushNotifications: remotely enable push failed with error: %@", error); + if (failure) + { + failure(error); + } + }]; + } + else if ([[MXKAccountManager sharedManager] isAPNSAvailable]) { MXLogDebug(@"[MXKAccount][Push] enablePushNotifications: Enable Push for %@ account", self.mxCredentials.userId); @@ -354,7 +401,7 @@ static NSArray *initialSyncSilentErrorsHTTPStatusCodes; } } } - else if (self.hasPusherForPushNotifications) + else if (self.hasPusherForPushNotifications || currentPusher) { MXLogDebug(@"[MXKAccount][Push] enablePushNotifications: Disable APNS for %@ account", self.mxCredentials.userId); @@ -626,6 +673,60 @@ static NSArray *initialSyncSilentErrorsHTTPStatusCodes; }]; } +- (void)loadCurrentPusher:(void (^)(void))success failure:(void (^)(NSError *error))failure +{ + if (!self.mxSession.myDeviceId) + { + return; + } + + [self.mxSession supportedMatrixVersions:^(MXMatrixVersions *matrixVersions) { + if (!matrixVersions.supportsRemotelyTogglingPushNotifications) + { + MXLogDebug(@"[MXKAccount] loadPusher: remotely toggling push notifications not supported"); + + if (success) + { + success(); + } + + return; + } + + [self.mxSession.matrixRestClient pushers:^(NSArray *pushers) { + MXPusher *ownPusher; + for (MXPusher *pusher in pushers) + { + if ([pusher.deviceId isEqualToString:self.mxSession.myDeviceId]) + { + ownPusher = pusher; + } + } + + self->currentPusher = ownPusher; + + if (success) + { + success(); + } + } failure:^(NSError *error) { + MXLogWarning(@"[MXKAccount] loadPusher: get pushers failed due to error %@", error); + + if (failure) + { + failure(error); + } + }]; + } failure:^(NSError *error) { + MXLogWarning(@"[MXKAccount] loadPusher: supportedMatrixVersions failed due to error %@", error); + + if (failure) + { + failure(error); + } + }]; +} + - (void)loadDeviceInformation:(void (^)(void))success failure:(void (^)(NSError *error))failure { if (self.mxCredentials.deviceId) @@ -773,7 +874,9 @@ static NSArray *initialSyncSilentErrorsHTTPStatusCodes; [MXKContactManager.sharedManager validateSyncLocalContactsStateForSession:self.mxSession]; // Refresh pusher state - [self refreshAPNSPusher]; + [self loadCurrentPusher:^{ + [self refreshAPNSPusher]; + } failure:nil]; [self refreshPushKitPusher]; // Launch server sync @@ -1106,6 +1209,12 @@ static NSArray *initialSyncSilentErrorsHTTPStatusCodes; - (void)refreshAPNSPusher { MXLogDebug(@"[MXKAccount][Push] refreshAPNSPusher"); + + if (currentPusher) + { + MXLogDebug(@"[MXKAccount][Push] refreshAPNSPusher aborted as a pusher has been found"); + return; + } // Check the conditions required to run the pusher if (self.pushNotificationServiceIsActive) @@ -1165,12 +1274,35 @@ static NSArray *initialSyncSilentErrorsHTTPStatusCodes; self->_hasPusherForPushNotifications = enabled; [[MXKAccountManager sharedManager] saveAccounts]; - if (success) + if (enabled) { - success(); + [self loadCurrentPusher:^{ + if (success) + { + success(); + } + + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKAccountAPNSActivityDidChangeNotification object:self.mxCredentials.userId]; + } failure:^(NSError *error) { + if (success) + { + success(); + } + + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKAccountAPNSActivityDidChangeNotification object:self.mxCredentials.userId]; + }]; + } + else + { + self->currentPusher = nil; + + if (success) + { + success(); + } + + [[NSNotificationCenter defaultCenter] postNotificationName:kMXKAccountAPNSActivityDidChangeNotification object:self.mxCredentials.userId]; } - - [[NSNotificationCenter defaultCenter] postNotificationName:kMXKAccountAPNSActivityDidChangeNotification object:self.mxCredentials.userId]; } failure:^(NSError *error) { @@ -1415,7 +1547,7 @@ static NSArray *initialSyncSilentErrorsHTTPStatusCodes; MXRestClient *restCli = self.mxRestClient; - [restCli setPusherWithPushkey:b64Token kind:kind appId:appId appDisplayName:appDisplayName deviceDisplayName:[[UIDevice currentDevice] name] profileTag:profileTag lang:deviceLang data:pushData append:append success:success failure:failure]; + [restCli setPusherWithPushkey:b64Token kind:kind appId:appId appDisplayName:appDisplayName deviceDisplayName:[[UIDevice currentDevice] name] profileTag:profileTag lang:deviceLang data:pushData append:append enabled:enabled success:success failure:failure]; } #pragma mark - InApp notifications diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/Service/MatrixSDK/UserSessionOverviewService.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/Service/MatrixSDK/UserSessionOverviewService.swift index 794a2e291..0b7e93e03 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/Service/MatrixSDK/UserSessionOverviewService.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/Service/MatrixSDK/UserSessionOverviewService.swift @@ -91,6 +91,10 @@ class UserSessionOverviewService: UserSessionOverviewServiceProtocol { switch response { case .success: + if let account = MXKAccountManager.shared().activeAccounts.first, account.device?.deviceId == pusher.deviceId { + account.loadCurrentPusher(nil) + } + self.checkPusher() case .failure(let error): MXLog.warning("[UserSessionOverviewService] togglePusher failed due to error: \(error)") diff --git a/changelog.d/6814.change b/changelog.d/6814.change new file mode 100644 index 000000000..1e99cbebc --- /dev/null +++ b/changelog.d/6814.change @@ -0,0 +1 @@ +Check enabled field in notification settings push toggles From 65a1f99c2efc40943e61f1525bcaa2bae29add77 Mon Sep 17 00:00:00 2001 From: Andy Uhnak Date: Fri, 30 Sep 2022 12:06:46 +0100 Subject: [PATCH 06/15] Refactor verification manager, request, transactions --- Riot/Modules/Application/LegacyAppDelegate.m | 28 +++++++++---------- .../Common/KeyVerificationCoordinator.swift | 4 +-- ...rificationCoordinatorBridgePresenter.swift | 2 +- .../Common/KeyVerificationFlow.swift | 2 +- ...eviceVerificationIncomingCoordinator.swift | 2 +- .../DeviceVerificationIncomingViewModel.swift | 8 +++--- ...erificationSelfVerifyWaitCoordinator.swift | 2 +- ...icationSelfVerifyWaitCoordinatorType.swift | 2 +- ...yVerificationSelfVerifyWaitViewModel.swift | 8 +++--- ...ificationSelfVerifyWaitViewModelType.swift | 2 +- .../DeviceVerificationStartViewModel.swift | 6 ++-- .../Modules/Room/DataSources/RoomDataSource.m | 8 +++--- 12 files changed, 36 insertions(+), 38 deletions(-) diff --git a/Riot/Modules/Application/LegacyAppDelegate.m b/Riot/Modules/Application/LegacyAppDelegate.m index 718669fa4..040e243f5 100644 --- a/Riot/Modules/Application/LegacyAppDelegate.m +++ b/Riot/Modules/Application/LegacyAppDelegate.m @@ -3599,7 +3599,7 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni usingBlock:^(NSNotification *notif) { NSObject *object = notif.userInfo[MXKeyVerificationManagerNotificationTransactionKey]; - if ([object isKindOfClass:MXIncomingSASTransaction.class]) + if ([object conformsToProtocol:@protocol(MXSASTransaction)] && ((id)object).isIncoming) { [self checkPendingIncomingKeyVerificationsInSession:mxSession]; } @@ -3630,9 +3630,9 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni for (id transaction in transactions) { - if (transaction.isIncoming) + if ([transaction conformsToProtocol:@protocol(MXSASTransaction)] && transaction.isIncoming) { - MXIncomingSASTransaction *incomingTransaction = (MXIncomingSASTransaction*)transaction; + id incomingTransaction = (id)transaction; if (incomingTransaction.state == MXSASTransactionStateIncomingShowAccept) { [self presentIncomingKeyVerification:incomingTransaction inSession:mxSession]; @@ -3676,7 +3676,7 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni return presented; } -- (BOOL)presentIncomingKeyVerification:(MXIncomingSASTransaction*)transaction inSession:(MXSession*)mxSession +- (BOOL)presentIncomingKeyVerification:(id)transaction inSession:(MXSession*)mxSession { MXLogDebug(@"[AppDelegate][MXKeyVerification] presentIncomingKeyVerification: %@", transaction); @@ -3768,14 +3768,14 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni - (void)registerNewRequestNotificationForSession:(MXSession*)session { - MXKeyVerificationManager *keyverificationManager = session.crypto.keyVerificationManager; + id keyVerificationManager = session.crypto.keyVerificationManager; - if (!keyverificationManager) + if (!keyVerificationManager) { return; } - [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(keyVerificationNewRequestNotification:) name:MXKeyVerificationManagerNewRequestNotification object:keyverificationManager]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(keyVerificationNewRequestNotification:) name:MXKeyVerificationManagerNewRequestNotification object:keyVerificationManager]; } - (void)keyVerificationNewRequestNotification:(NSNotification *)notification @@ -3800,28 +3800,26 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni id keyVerificationRequest = userInfo[MXKeyVerificationManagerNotificationRequestKey]; - if ([keyVerificationRequest isKindOfClass:MXKeyVerificationByDMRequest.class]) + if (keyVerificationRequest.transport == MXKeyVerificationTransportDirectMessage) { - MXKeyVerificationByDMRequest *keyVerificationByDMRequest = (MXKeyVerificationByDMRequest*)keyVerificationRequest; - - if (!keyVerificationByDMRequest.isFromMyUser && keyVerificationByDMRequest.state == MXKeyVerificationRequestStatePending) + if (!keyVerificationRequest.isFromMyUser && keyVerificationRequest.state == MXKeyVerificationRequestStatePending) { MXKAccount *currentAccount = [MXKAccountManager sharedManager].activeAccounts.firstObject; MXSession *session = currentAccount.mxSession; - MXRoom *room = [currentAccount.mxSession roomWithRoomId:keyVerificationByDMRequest.roomId]; + MXRoom *room = [currentAccount.mxSession roomWithRoomId:keyVerificationRequest.roomId]; if (!room) { MXLogDebug(@"[AppDelegate][KeyVerification] keyVerificationRequestDidChangeNotification: Unknown room"); return; } - NSString *sender = keyVerificationByDMRequest.otherUser; + NSString *sender = keyVerificationRequest.otherUser; [room state:^(MXRoomState *roomState) { NSString *senderName = [roomState.members memberName:sender]; - [self presentNewKeyVerificationRequestAlertForSession:session senderName:senderName senderId:sender request:keyVerificationByDMRequest]; + [self presentNewKeyVerificationRequestAlertForSession:session senderName:senderName senderId:sender request:keyVerificationRequest]; }]; } } @@ -3858,7 +3856,7 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni // This happens when they or our user do not have cross-signing enabled MXLogDebug(@"[AppDelegate][KeyVerification] keyVerificationNewRequestNotification: Device verification from other user %@:%@", keyVerificationRequest.otherUser, keyVerificationRequest.otherDevice); - NSString *myUserId = ((MXKeyVerificationByToDeviceRequest*)keyVerificationRequest).to; + NSString *myUserId = keyVerificationRequest.myUserId; NSString *userId = keyVerificationRequest.otherUser; MXKAccount *account = [[MXKAccountManager sharedManager] accountForUserId:myUserId]; if (account) diff --git a/Riot/Modules/KeyVerification/Common/KeyVerificationCoordinator.swift b/Riot/Modules/KeyVerification/Common/KeyVerificationCoordinator.swift index d7b31c693..51dd86b69 100644 --- a/Riot/Modules/KeyVerification/Common/KeyVerificationCoordinator.swift +++ b/Riot/Modules/KeyVerification/Common/KeyVerificationCoordinator.swift @@ -240,7 +240,7 @@ final class KeyVerificationCoordinator: KeyVerificationCoordinatorType { } } - private func showIncoming(otherUser: MXUser, transaction: MXIncomingSASTransaction) { + private func showIncoming(otherUser: MXUser, transaction: MXSASTransaction) { let coordinator = DeviceVerificationIncomingCoordinator(session: self.session, otherUser: otherUser, transaction: transaction) coordinator.delegate = self coordinator.start() @@ -429,7 +429,7 @@ extension KeyVerificationCoordinator: KeyVerificationSelfVerifyWaitCoordinatorDe self.showVerifyByScanning(keyVerificationRequest: keyVerificationRequest, animated: true) } - func keyVerificationSelfVerifyWaitCoordinator(_ coordinator: KeyVerificationSelfVerifyWaitCoordinatorType, didAcceptIncomingSASTransaction incomingSASTransaction: MXIncomingSASTransaction) { + func keyVerificationSelfVerifyWaitCoordinator(_ coordinator: KeyVerificationSelfVerifyWaitCoordinatorType, didAcceptIncomingSASTransaction incomingSASTransaction: MXSASTransaction) { self.showVerifyBySAS(transaction: incomingSASTransaction, animated: true) } diff --git a/Riot/Modules/KeyVerification/Common/KeyVerificationCoordinatorBridgePresenter.swift b/Riot/Modules/KeyVerification/Common/KeyVerificationCoordinatorBridgePresenter.swift index f07ea75a2..6b81e87ae 100644 --- a/Riot/Modules/KeyVerification/Common/KeyVerificationCoordinatorBridgePresenter.swift +++ b/Riot/Modules/KeyVerification/Common/KeyVerificationCoordinatorBridgePresenter.swift @@ -74,7 +74,7 @@ final class KeyVerificationCoordinatorBridgePresenter: NSObject { self.present(coordinator: keyVerificationCoordinator, from: viewController, animated: animated) } - func present(from viewController: UIViewController, incomingTransaction: MXIncomingSASTransaction, animated: Bool) { + func present(from viewController: UIViewController, incomingTransaction: MXSASTransaction, animated: Bool) { MXLog.debug("[KeyVerificationCoordinatorBridgePresenter] Present incoming verification from \(viewController)") diff --git a/Riot/Modules/KeyVerification/Common/KeyVerificationFlow.swift b/Riot/Modules/KeyVerification/Common/KeyVerificationFlow.swift index 1d7d4612f..57cd6e30e 100644 --- a/Riot/Modules/KeyVerification/Common/KeyVerificationFlow.swift +++ b/Riot/Modules/KeyVerification/Common/KeyVerificationFlow.swift @@ -28,5 +28,5 @@ enum KeyVerificationFlow { case verifyDevice(userId: String, deviceId: String) case completeSecurity(_ isNewSignIn: Bool) case incomingRequest(_ request: MXKeyVerificationRequest) - case incomingSASTransaction(_ transaction: MXIncomingSASTransaction) + case incomingSASTransaction(_ transaction: MXSASTransaction) } diff --git a/Riot/Modules/KeyVerification/Device/Incoming/DeviceVerificationIncomingCoordinator.swift b/Riot/Modules/KeyVerification/Device/Incoming/DeviceVerificationIncomingCoordinator.swift index 2240255d9..f55f74970 100644 --- a/Riot/Modules/KeyVerification/Device/Incoming/DeviceVerificationIncomingCoordinator.swift +++ b/Riot/Modules/KeyVerification/Device/Incoming/DeviceVerificationIncomingCoordinator.swift @@ -38,7 +38,7 @@ final class DeviceVerificationIncomingCoordinator: DeviceVerificationIncomingCoo // MARK: - Setup - init(session: MXSession, otherUser: MXUser, transaction: MXIncomingSASTransaction) { + init(session: MXSession, otherUser: MXUser, transaction: MXSASTransaction) { self.session = session let deviceVerificationIncomingViewModel = DeviceVerificationIncomingViewModel(session: self.session, otherUser: otherUser, transaction: transaction) diff --git a/Riot/Modules/KeyVerification/Device/Incoming/DeviceVerificationIncomingViewModel.swift b/Riot/Modules/KeyVerification/Device/Incoming/DeviceVerificationIncomingViewModel.swift index 69a324221..2ea84bbee 100644 --- a/Riot/Modules/KeyVerification/Device/Incoming/DeviceVerificationIncomingViewModel.swift +++ b/Riot/Modules/KeyVerification/Device/Incoming/DeviceVerificationIncomingViewModel.swift @@ -25,7 +25,7 @@ final class DeviceVerificationIncomingViewModel: DeviceVerificationIncomingViewM // MARK: Private private let session: MXSession - private let transaction: MXIncomingSASTransaction + private let transaction: MXSASTransaction // MARK: Public @@ -41,7 +41,7 @@ final class DeviceVerificationIncomingViewModel: DeviceVerificationIncomingViewM // MARK: - Setup - init(session: MXSession, otherUser: MXUser, transaction: MXIncomingSASTransaction) { + init(session: MXSession, otherUser: MXUser, transaction: MXSASTransaction) { self.session = session self.transaction = transaction self.userId = otherUser.userId @@ -83,7 +83,7 @@ final class DeviceVerificationIncomingViewModel: DeviceVerificationIncomingViewM // MARK: - MXKeyVerificationTransactionDidChange - private func registerTransactionDidStateChangeNotification(transaction: MXIncomingSASTransaction) { + private func registerTransactionDidStateChangeNotification(transaction: MXSASTransaction) { NotificationCenter.default.addObserver(self, selector: #selector(transactionDidStateChange(notification:)), name: NSNotification.Name.MXKeyVerificationTransactionDidChange, object: transaction) } @@ -92,7 +92,7 @@ final class DeviceVerificationIncomingViewModel: DeviceVerificationIncomingViewM } @objc private func transactionDidStateChange(notification: Notification) { - guard let transaction = notification.object as? MXIncomingSASTransaction else { + guard let transaction = notification.object as? MXSASTransaction, transaction.isIncoming else { return } diff --git a/Riot/Modules/KeyVerification/Device/SelfVerifyWait/KeyVerificationSelfVerifyWaitCoordinator.swift b/Riot/Modules/KeyVerification/Device/SelfVerifyWait/KeyVerificationSelfVerifyWaitCoordinator.swift index 94b3a3eca..88c2537db 100644 --- a/Riot/Modules/KeyVerification/Device/SelfVerifyWait/KeyVerificationSelfVerifyWaitCoordinator.swift +++ b/Riot/Modules/KeyVerification/Device/SelfVerifyWait/KeyVerificationSelfVerifyWaitCoordinator.swift @@ -68,7 +68,7 @@ extension KeyVerificationSelfVerifyWaitCoordinator: KeyVerificationSelfVerifyWai self.delegate?.keyVerificationSelfVerifyWaitCoordinator(self, didAcceptKeyVerificationRequest: keyVerificationRequest) } - func keyVerificationSelfVerifyWaitViewModel(_ viewModel: KeyVerificationSelfVerifyWaitViewModelType, didAcceptIncomingSASTransaction incomingSASTransaction: MXIncomingSASTransaction) { + func keyVerificationSelfVerifyWaitViewModel(_ viewModel: KeyVerificationSelfVerifyWaitViewModelType, didAcceptIncomingSASTransaction incomingSASTransaction: MXSASTransaction) { self.delegate?.keyVerificationSelfVerifyWaitCoordinator(self, didAcceptIncomingSASTransaction: incomingSASTransaction) } diff --git a/Riot/Modules/KeyVerification/Device/SelfVerifyWait/KeyVerificationSelfVerifyWaitCoordinatorType.swift b/Riot/Modules/KeyVerification/Device/SelfVerifyWait/KeyVerificationSelfVerifyWaitCoordinatorType.swift index ba1b6c410..8724493ea 100644 --- a/Riot/Modules/KeyVerification/Device/SelfVerifyWait/KeyVerificationSelfVerifyWaitCoordinatorType.swift +++ b/Riot/Modules/KeyVerification/Device/SelfVerifyWait/KeyVerificationSelfVerifyWaitCoordinatorType.swift @@ -20,7 +20,7 @@ import Foundation protocol KeyVerificationSelfVerifyWaitCoordinatorDelegate: AnyObject { func keyVerificationSelfVerifyWaitCoordinator(_ coordinator: KeyVerificationSelfVerifyWaitCoordinatorType, didAcceptKeyVerificationRequest keyVerificationRequest: MXKeyVerificationRequest) - func keyVerificationSelfVerifyWaitCoordinator(_ coordinator: KeyVerificationSelfVerifyWaitCoordinatorType, didAcceptIncomingSASTransaction incomingSASTransaction: MXIncomingSASTransaction) + func keyVerificationSelfVerifyWaitCoordinator(_ coordinator: KeyVerificationSelfVerifyWaitCoordinatorType, didAcceptIncomingSASTransaction incomingSASTransaction: MXSASTransaction) func keyVerificationSelfVerifyWaitCoordinatorDidCancel(_ coordinator: KeyVerificationSelfVerifyWaitCoordinatorType) func keyVerificationSelfVerifyWaitCoordinator(_ coordinator: KeyVerificationSelfVerifyWaitCoordinatorType, wantsToRecoverSecretsWith secretsRecoveryMode: SecretsRecoveryMode) } diff --git a/Riot/Modules/KeyVerification/Device/SelfVerifyWait/KeyVerificationSelfVerifyWaitViewModel.swift b/Riot/Modules/KeyVerification/Device/SelfVerifyWait/KeyVerificationSelfVerifyWaitViewModel.swift index 822436f6b..29c312bfc 100644 --- a/Riot/Modules/KeyVerification/Device/SelfVerifyWait/KeyVerificationSelfVerifyWaitViewModel.swift +++ b/Riot/Modules/KeyVerification/Device/SelfVerifyWait/KeyVerificationSelfVerifyWaitViewModel.swift @@ -181,7 +181,7 @@ final class KeyVerificationSelfVerifyWaitViewModel: KeyVerificationSelfVerifyWai @objc private func keyVerificationManagerNewRequestNotification(notification: Notification) { - guard let userInfo = notification.userInfo, let keyVerificationRequest = userInfo[MXKeyVerificationManagerNotificationRequestKey] as? MXKeyVerificationByToDeviceRequest else { + guard let userInfo = notification.userInfo, let keyVerificationRequest = userInfo[MXKeyVerificationManagerNotificationRequestKey] as? MXKeyVerificationRequest, keyVerificationRequest.transport == .toDevice else { return } @@ -242,14 +242,14 @@ final class KeyVerificationSelfVerifyWaitViewModel: KeyVerificationSelfVerifyWai } @objc private func transactionDidStateChange(notification: Notification) { - guard let sasTransaction = notification.object as? MXIncomingSASTransaction, - sasTransaction.otherUserId == self.session.myUserId else { + guard let sasTransaction = notification.object as? MXSASTransaction, + sasTransaction.isIncoming, sasTransaction.otherUserId == self.session.myUserId else { return } self.sasTransactionDidStateChange(sasTransaction) } - private func sasTransactionDidStateChange(_ transaction: MXIncomingSASTransaction) { + private func sasTransactionDidStateChange(_ transaction: MXSASTransaction) { switch transaction.state { case MXSASTransactionStateIncomingShowAccept: transaction.accept() diff --git a/Riot/Modules/KeyVerification/Device/SelfVerifyWait/KeyVerificationSelfVerifyWaitViewModelType.swift b/Riot/Modules/KeyVerification/Device/SelfVerifyWait/KeyVerificationSelfVerifyWaitViewModelType.swift index 264a73b83..66027c9e8 100644 --- a/Riot/Modules/KeyVerification/Device/SelfVerifyWait/KeyVerificationSelfVerifyWaitViewModelType.swift +++ b/Riot/Modules/KeyVerification/Device/SelfVerifyWait/KeyVerificationSelfVerifyWaitViewModelType.swift @@ -24,7 +24,7 @@ protocol KeyVerificationSelfVerifyWaitViewModelViewDelegate: AnyObject { protocol KeyVerificationSelfVerifyWaitViewModelCoordinatorDelegate: AnyObject { func keyVerificationSelfVerifyWaitViewModel(_ viewModel: KeyVerificationSelfVerifyWaitViewModelType, didAcceptKeyVerificationRequest keyVerificationRequest: MXKeyVerificationRequest) - func keyVerificationSelfVerifyWaitViewModel(_ viewModel: KeyVerificationSelfVerifyWaitViewModelType, didAcceptIncomingSASTransaction incomingSASTransaction: MXIncomingSASTransaction) + func keyVerificationSelfVerifyWaitViewModel(_ viewModel: KeyVerificationSelfVerifyWaitViewModelType, didAcceptIncomingSASTransaction incomingSASTransaction: MXSASTransaction) func keyVerificationSelfVerifyWaitViewModelDidCancel(_ viewModel: KeyVerificationSelfVerifyWaitViewModelType) func keyVerificationSelfVerifyWaitViewModel(_ viewModel: KeyVerificationSelfVerifyWaitViewModelType, wantsToRecoverSecretsWith secretsRecoveryMode: SecretsRecoveryMode) } diff --git a/Riot/Modules/KeyVerification/Device/Start/DeviceVerificationStartViewModel.swift b/Riot/Modules/KeyVerification/Device/Start/DeviceVerificationStartViewModel.swift index 694cb3474..4a8d0e66f 100644 --- a/Riot/Modules/KeyVerification/Device/Start/DeviceVerificationStartViewModel.swift +++ b/Riot/Modules/KeyVerification/Device/Start/DeviceVerificationStartViewModel.swift @@ -72,7 +72,7 @@ final class DeviceVerificationStartViewModel: DeviceVerificationStartViewModelTy guard let sself = self else { return } - guard let sasTransaction: MXOutgoingSASTransaction = transaction as? MXOutgoingSASTransaction else { + guard let sasTransaction = transaction as? MXSASTransaction, !sasTransaction.isIncoming else { return } @@ -100,7 +100,7 @@ final class DeviceVerificationStartViewModel: DeviceVerificationStartViewModelTy // MARK: - MXKeyVerificationTransactionDidChange - private func registerTransactionDidStateChangeNotification(transaction: MXOutgoingSASTransaction) { + private func registerTransactionDidStateChangeNotification(transaction: MXSASTransaction) { NotificationCenter.default.addObserver(self, selector: #selector(transactionDidStateChange(notification:)), name: NSNotification.Name.MXKeyVerificationTransactionDidChange, object: transaction) } @@ -109,7 +109,7 @@ final class DeviceVerificationStartViewModel: DeviceVerificationStartViewModelTy } @objc private func transactionDidStateChange(notification: Notification) { - guard let transaction = notification.object as? MXOutgoingSASTransaction else { + guard let transaction = notification.object as? MXSASTransaction, !transaction.isIncoming else { return } diff --git a/Riot/Modules/Room/DataSources/RoomDataSource.m b/Riot/Modules/Room/DataSources/RoomDataSource.m index ed48741db..8c409730a 100644 --- a/Riot/Modules/Room/DataSources/RoomDataSource.m +++ b/Riot/Modules/Room/DataSources/RoomDataSource.m @@ -725,13 +725,13 @@ const CGFloat kTypingCellHeight = 24; { id notificationObject = notification.object; - if ([notificationObject isKindOfClass:MXKeyVerificationByDMRequest.class]) + if ([notificationObject conformsToProtocol:@protocol(MXKeyVerificationRequest)]) { - MXKeyVerificationByDMRequest *keyVerificationByDMRequest = (MXKeyVerificationByDMRequest*)notificationObject; + id keyVerificationRequest = (id)notificationObject; - if ([keyVerificationByDMRequest.roomId isEqualToString:self.roomId]) + if (keyVerificationRequest.transport == MXKeyVerificationTransportDirectMessage && [keyVerificationRequest.roomId isEqualToString:self.roomId]) { - RoomBubbleCellData *roomBubbleCellData = [self roomBubbleCellDataForEventId:keyVerificationByDMRequest.eventId]; + RoomBubbleCellData *roomBubbleCellData = [self roomBubbleCellDataForEventId:keyVerificationRequest.requestId]; roomBubbleCellData.isKeyVerificationOperationPending = NO; roomBubbleCellData.keyVerification = nil; From d560c8d61f5873d3f241547392a0dc353cd7ec83 Mon Sep 17 00:00:00 2001 From: Aleksandrs Proskurins Date: Thu, 6 Oct 2022 13:11:59 +0300 Subject: [PATCH 07/15] Update RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Test/UI/UserOtherSessionsUITests.swift Co-authored-by: Doug <6060466+pixlwave@users.noreply.github.com> --- .../UserOtherSessions/Test/UI/UserOtherSessionsUITests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Test/UI/UserOtherSessionsUITests.swift b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Test/UI/UserOtherSessionsUITests.swift index 719c31190..e9d5e2893 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Test/UI/UserOtherSessionsUITests.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Test/UI/UserOtherSessionsUITests.swift @@ -39,7 +39,7 @@ class UserOtherSessionsUITests: MockScreenTestCase { XCTAssertTrue(app.staticTexts[VectorL10n.userOtherSessionUnverifiedSessionsHeaderSubtitle].exists) } - func test_whenOtherSessionsWithUnverifiedSessionFilterPresented_correctItemsDisplayed() { + func test_whenOtherSessionsWithUnverifiedSessionFilterPresented_correctItemsDisplayed() { app.goToScreenWithIdentifier(MockUserOtherSessionsScreenState.unverifiedSessions.title) XCTAssertTrue(app.buttons["RiotSwiftUI Mobile: iOS, Unverified · Your current session"].exists) From 8a6c8b1212de77c6d6f333b13b0f00d93636edc7 Mon Sep 17 00:00:00 2001 From: Aleksandrs Proskurins Date: Thu, 6 Oct 2022 13:16:10 +0300 Subject: [PATCH 08/15] Renamed sortAndConvertDevices method --- .../Service/MatrixSDK/UserSessionsOverviewService.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/MatrixSDK/UserSessionsOverviewService.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/MatrixSDK/UserSessionsOverviewService.swift index fbb5c9a4a..36d07f427 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/MatrixSDK/UserSessionsOverviewService.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/MatrixSDK/UserSessionsOverviewService.swift @@ -43,7 +43,7 @@ class UserSessionsOverviewService: UserSessionsOverviewServiceProtocol { dataProvider.devices { response in switch response { case .success(let devices): - self.sessionInfos = self.sortAndConvertDevices(devices: devices) + self.sessionInfos = self.sortedSessionInfos(from: devices) self.overviewData = self.sessionsOverviewData(from: self.sessionInfos) completion(.success(self.overviewData)) case .failure(let error): @@ -81,7 +81,7 @@ class UserSessionsOverviewService: UserSessionsOverviewServiceProtocol { return sessionInfo(from: device, isCurrentSession: true) } - private func sortAndConvertDevices(devices: [MXDevice]) -> [UserSessionInfo] { + private func sortedSessionInfos(from devices: [MXDevice]) -> [UserSessionInfo] { devices .sorted { $0.lastSeenTs > $1.lastSeenTs } .map { sessionInfo(from: $0, isCurrentSession: $0.deviceId == dataProvider.myDeviceId) } From 9e678e77957d40965717e87397531e3e5fd03cb7 Mon Sep 17 00:00:00 2001 From: Andy Uhnak Date: Mon, 3 Oct 2022 17:56:58 +0100 Subject: [PATCH 09/15] Incoming verification requests with Crypto V2 --- ...erificationVerifyByScanningViewModel.swift | 39 ++++++++++++------- .../Modules/Room/DataSources/RoomDataSource.m | 1 + changelog.d/6809.change | 1 + 3 files changed, 28 insertions(+), 13 deletions(-) create mode 100644 changelog.d/6809.change diff --git a/Riot/Modules/KeyVerification/Common/Verify/Scanning/KeyVerificationVerifyByScanningViewModel.swift b/Riot/Modules/KeyVerification/Common/Verify/Scanning/KeyVerificationVerifyByScanningViewModel.swift index fe54a2467..284ea3276 100644 --- a/Riot/Modules/KeyVerification/Common/Verify/Scanning/KeyVerificationVerifyByScanningViewModel.swift +++ b/Riot/Modules/KeyVerification/Common/Verify/Scanning/KeyVerificationVerifyByScanningViewModel.swift @@ -102,7 +102,7 @@ final class KeyVerificationVerifyByScanningViewModel: KeyVerificationVerifyBySca self.update(viewState: .loaded(viewData: viewData)) - self.registerTransactionDidStateChangeNotification() + self.registerDidStateChangeNotification() } private func canShowScanAction(from verificationMethods: [String]) -> Bool { @@ -112,7 +112,7 @@ final class KeyVerificationVerifyByScanningViewModel: KeyVerificationVerifyBySca private func cancel() { self.cancelQRCodeTransaction() self.keyVerificationRequest.cancel(with: MXTransactionCancelCode.user(), success: nil, failure: nil) - self.unregisterTransactionDidStateChangeNotification() + self.unregisterDidStateChangeNotification() self.coordinatorDelegate?.keyVerificationVerifyByScanningViewModelDidCancel(self) } @@ -148,7 +148,7 @@ final class KeyVerificationVerifyByScanningViewModel: KeyVerificationVerifyBySca return } - self.unregisterTransactionDidStateChangeNotification() + self.unregisterDidStateChangeNotification() self.coordinatorDelegate?.keyVerificationVerifyByScanningViewModel(self, didScanOtherQRCodeData: scannedQRCodeData, withTransaction: qrCodeTransaction) } @@ -176,7 +176,7 @@ final class KeyVerificationVerifyByScanningViewModel: KeyVerificationVerifyBySca // Check due to legacy implementation of key verification which could pass incorrect type of transaction if keyVerificationTransaction is MXIncomingSASTransaction { MXLog.debug("[KeyVerificationVerifyByScanningViewModel] SAS transaction should be outgoing") - self.unregisterTransactionDidStateChangeNotification() + self.unregisterDidStateChangeNotification() self.update(viewState: .error(KeyVerificationVerifyByScanningViewModelError.unknown)) } @@ -191,14 +191,27 @@ final class KeyVerificationVerifyByScanningViewModel: KeyVerificationVerifyBySca // MARK: - MXKeyVerificationTransactionDidChange - private func registerTransactionDidStateChangeNotification() { + private func registerDidStateChangeNotification() { + NotificationCenter.default.addObserver(self, selector: #selector(requestDidStateChange(notification:)), name: .MXKeyVerificationRequestDidChange, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(transactionDidStateChange(notification:)), name: .MXKeyVerificationTransactionDidChange, object: nil) } - private func unregisterTransactionDidStateChangeNotification() { + private func unregisterDidStateChangeNotification() { + NotificationCenter.default.removeObserver(self, name: .MXKeyVerificationRequestDidChange, object: nil) NotificationCenter.default.removeObserver(self, name: .MXKeyVerificationTransactionDidChange, object: nil) } + @objc private func requestDidStateChange(notification: Notification) { + guard let request = notification.object as? MXKeyVerificationRequest else { + return + } + + if request.state == MXKeyVerificationRequestStateCancelled, let reason = request.reasonCancelCode { + self.unregisterDidStateChangeNotification() + self.update(viewState: .cancelled(cancelCode: reason, verificationKind: verificationKind)) + } + } + @objc private func transactionDidStateChange(notification: Notification) { guard let transaction = notification.object as? MXKeyVerificationTransaction else { return @@ -219,19 +232,19 @@ final class KeyVerificationVerifyByScanningViewModel: KeyVerificationVerifyBySca private func sasTransactionDidStateChange(_ transaction: MXSASTransaction) { switch transaction.state { case MXSASTransactionStateShowSAS: - self.unregisterTransactionDidStateChangeNotification() + self.unregisterDidStateChangeNotification() self.coordinatorDelegate?.keyVerificationVerifyByScanningViewModel(self, didStartSASVerificationWithTransaction: transaction) case MXSASTransactionStateCancelled: guard let reason = transaction.reasonCancelCode else { return } - self.unregisterTransactionDidStateChangeNotification() + self.unregisterDidStateChangeNotification() self.update(viewState: .cancelled(cancelCode: reason, verificationKind: verificationKind)) case MXSASTransactionStateCancelledByMe: guard let reason = transaction.reasonCancelCode else { return } - self.unregisterTransactionDidStateChangeNotification() + self.unregisterDidStateChangeNotification() self.update(viewState: .cancelledByMe(reason)) default: break @@ -242,22 +255,22 @@ final class KeyVerificationVerifyByScanningViewModel: KeyVerificationVerifyBySca switch transaction.state { case .verified: // Should not happen - self.unregisterTransactionDidStateChangeNotification() + self.unregisterDidStateChangeNotification() self.coordinatorDelegate?.keyVerificationVerifyByScanningViewModelDidCancel(self) case .qrScannedByOther: - self.unregisterTransactionDidStateChangeNotification() + self.unregisterDidStateChangeNotification() self.coordinatorDelegate?.keyVerificationVerifyByScanningViewModel(self, qrCodeDidScannedByOtherWithTransaction: transaction) case .cancelled: guard let reason = transaction.reasonCancelCode else { return } - self.unregisterTransactionDidStateChangeNotification() + self.unregisterDidStateChangeNotification() self.update(viewState: .cancelled(cancelCode: reason, verificationKind: verificationKind)) case .cancelledByMe: guard let reason = transaction.reasonCancelCode else { return } - self.unregisterTransactionDidStateChangeNotification() + self.unregisterDidStateChangeNotification() self.update(viewState: .cancelledByMe(reason)) default: break diff --git a/Riot/Modules/Room/DataSources/RoomDataSource.m b/Riot/Modules/Room/DataSources/RoomDataSource.m index 8c409730a..842623818 100644 --- a/Riot/Modules/Room/DataSources/RoomDataSource.m +++ b/Riot/Modules/Room/DataSources/RoomDataSource.m @@ -866,6 +866,7 @@ const CGFloat kTypingCellHeight = 24; } __block MXHTTPOperation *operation = [self.mxSession.crypto.keyVerificationManager keyVerificationFromKeyVerificationEvent:event + roomId:self.roomId success:^(MXKeyVerification * _Nonnull keyVerification) { BOOL shouldRefreshCells = bubbleCellData.isKeyVerificationOperationPending || bubbleCellData.keyVerification == nil; diff --git a/changelog.d/6809.change b/changelog.d/6809.change new file mode 100644 index 000000000..e6ac64490 --- /dev/null +++ b/changelog.d/6809.change @@ -0,0 +1 @@ +CryptoV2: Incoming verification requests From 13cbfaf0a99f29cf377de45a93a97232624f1093 Mon Sep 17 00:00:00 2001 From: Gil Eluard Date: Thu, 6 Oct 2022 12:46:03 +0200 Subject: [PATCH 10/15] Check enabled field in notification settings push toggles - Update after review --- Riot/Modules/MatrixKit/Models/Account/MXKAccount.m | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Riot/Modules/MatrixKit/Models/Account/MXKAccount.m b/Riot/Modules/MatrixKit/Models/Account/MXKAccount.m index 371721209..128e9b161 100644 --- a/Riot/Modules/MatrixKit/Models/Account/MXKAccount.m +++ b/Riot/Modules/MatrixKit/Models/Account/MXKAccount.m @@ -677,6 +677,11 @@ static NSArray *initialSyncSilentErrorsHTTPStatusCodes; { if (!self.mxSession.myDeviceId) { + MXLogWarning(@"[MXKAccount] loadPusher: device ID not found"); + if (failure) + { + failure([NSError errorWithDomain:kMXKAccountErrorDomain code:0 userInfo:nil]); + } return; } From 033920e63fd2cc123a0162d141afeeb733aa3d2d Mon Sep 17 00:00:00 2001 From: Stefan Ceriu Date: Wed, 5 Oct 2022 17:21:48 +0300 Subject: [PATCH 11/15] Add rendezvous service (MSC3886) and ECDH X25519 AES 256 based secure channel creation establishing implementation and simple tests. --- .../Rendezvous/MockRendezvousTransport.swift | 57 ++++++ .../Modules/Rendezvous/RendezvousModels.swift | 37 ++++ .../Rendezvous/RendezvousService.swift | 170 ++++++++++++++++++ .../Rendezvous/RendezvousTransport.swift | 149 +++++++++++++++ .../RendezvousTransportProtocol.swift | 33 ++++ RiotTests/RendezvousServiceTests.swift | 62 +++++++ 6 files changed, 508 insertions(+) create mode 100644 Riot/Modules/Rendezvous/MockRendezvousTransport.swift create mode 100644 Riot/Modules/Rendezvous/RendezvousModels.swift create mode 100644 Riot/Modules/Rendezvous/RendezvousService.swift create mode 100644 Riot/Modules/Rendezvous/RendezvousTransport.swift create mode 100644 Riot/Modules/Rendezvous/RendezvousTransportProtocol.swift create mode 100644 RiotTests/RendezvousServiceTests.swift diff --git a/Riot/Modules/Rendezvous/MockRendezvousTransport.swift b/Riot/Modules/Rendezvous/MockRendezvousTransport.swift new file mode 100644 index 000000000..2761ea989 --- /dev/null +++ b/Riot/Modules/Rendezvous/MockRendezvousTransport.swift @@ -0,0 +1,57 @@ +// +// Copyright 2022 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 + +class MockRendezvousTransport: RendezvousTransportProtocol { + var rendezvousURL: URL? + + private var currentPayload: Data? + + func create(body: T) async -> Result<(), RendezvousTransportError> { + guard let url = URL(string: "rendezvous.mock/1234") else { + fatalError() + } + + rendezvousURL = url + + guard let encodedBody = try? JSONEncoder().encode(body) else { + fatalError() + } + + currentPayload = encodedBody + + return .success(()) + } + + func get() async -> Result { + guard let data = currentPayload else { + fatalError() + } + + return .success(data) + } + + func send(body: T) async -> Result<(), RendezvousTransportError> { + guard let encodedBody = try? JSONEncoder().encode(body) else { + fatalError() + } + + currentPayload = encodedBody + + return .success(()) + } +} diff --git a/Riot/Modules/Rendezvous/RendezvousModels.swift b/Riot/Modules/Rendezvous/RendezvousModels.swift new file mode 100644 index 000000000..af4dbc50f --- /dev/null +++ b/Riot/Modules/Rendezvous/RendezvousModels.swift @@ -0,0 +1,37 @@ +// +// Copyright 2022 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 RendezvousPayload: Codable { + var rendezvous: RendezvousDetails + var user: String +} + +struct RendezvousDetails: Codable { + var transport: RendezvousTransportDetails? + var algorithm: String + var key: String +} + +struct RendezvousTransportDetails: Codable { + var type: String + var uri: String +} + +struct RendezvousMessage: Codable { + var combined: String +} diff --git a/Riot/Modules/Rendezvous/RendezvousService.swift b/Riot/Modules/Rendezvous/RendezvousService.swift new file mode 100644 index 000000000..370703ea1 --- /dev/null +++ b/Riot/Modules/Rendezvous/RendezvousService.swift @@ -0,0 +1,170 @@ +// +// Copyright 2022 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 +import CryptoKit +import Combine + +enum RendezvousServiceError: Error { + case invalidInterlocutorKey + case decodingError + case internalError + case channelNotReady + case transportError(RendezvousTransportError) +} + +enum RendezvousServiceCallback { + case error(RendezvousServiceError) +} + +enum RendezvousChannelAlgorithm: String { + case ECDH_V1 = "m.rendezvous.v1.x25519-aes-sha256" +} + +@MainActor +class RendezvousService { + private let transport: RendezvousTransportProtocol + private let privateKey: Curve25519.KeyAgreement.PrivateKey + + private var interlocutorPublicKey: Curve25519.KeyAgreement.PublicKey? + private var symmetricKey: SymmetricKey? + + init(transport: RendezvousTransportProtocol) { + self.transport = transport + self.privateKey = Curve25519.KeyAgreement.PrivateKey() + } + + func createRendezvous() async -> Result<(), RendezvousServiceError> { + let publicKeyString = self.privateKey.publicKey.rawRepresentation.base64EncodedString() + let payload = RendezvousDetails(algorithm: RendezvousChannelAlgorithm.ECDH_V1.rawValue, + key: publicKeyString) + + switch await transport.create(body: payload) { + case .failure(let transportError): + return .failure(.transportError(transportError)) + case .success: + return .success(()) + } + } + + func waitForInterlocutor() async -> Result<(), RendezvousServiceError> { + switch await transport.get() { + case .failure(let error): + return .failure(.transportError(error)) + case .success(let data): + guard let response = try? JSONDecoder().decode(RendezvousDetails.self, from: data) else { + return .failure(.decodingError) + } + + guard let interlocutorPublicKeyData = Data(base64Encoded: response.key), + let interlocutorPublicKey = try? Curve25519.KeyAgreement.PublicKey(rawRepresentation: interlocutorPublicKeyData) else { + return .failure(.invalidInterlocutorKey) + } + + self.interlocutorPublicKey = interlocutorPublicKey + + guard let sharedSecret = try? privateKey.sharedSecretFromKeyAgreement(with: interlocutorPublicKey) else { + return .failure(.internalError) + } + + self.symmetricKey = generateSymmetricKeyFrom(sharedSecret: sharedSecret) + + return .success(()) + } + } + + func joinRendezvous() async -> Result<(), RendezvousServiceError> { + guard case let .success(data) = await transport.get() else { + return .failure(.internalError) + } + + guard let response = try? JSONDecoder().decode(RendezvousDetails.self, from: data) else { + return .failure(.decodingError) + } + + guard let interlocutorPublicKeyData = Data(base64Encoded: response.key), + let interlocutorPublicKey = try? Curve25519.KeyAgreement.PublicKey(rawRepresentation: interlocutorPublicKeyData) else { + return .failure(.invalidInterlocutorKey) + } + + let publicKeyString = self.privateKey.publicKey.rawRepresentation.base64EncodedString() + let payload = RendezvousDetails(algorithm: RendezvousChannelAlgorithm.ECDH_V1.rawValue, + key: publicKeyString) + + guard case .success = await transport.send(body: payload) else { + return .failure(.internalError) + } + + // Channel established + guard let sharedSecret = try? privateKey.sharedSecretFromKeyAgreement(with: interlocutorPublicKey) else { + return .failure(.internalError) + } + + self.symmetricKey = generateSymmetricKeyFrom(sharedSecret: sharedSecret) + + return .success(()) + } + + func send(data: Data) async -> Result<(), RendezvousServiceError> { + guard let symmetricKey = symmetricKey else { + return .failure(.channelNotReady) + } + + guard let sealedBox = try? AES.GCM.seal(data, using: symmetricKey), + let combinedData = sealedBox.combined else { + return .failure(.internalError) + } + + let body = RendezvousMessage(combined: combinedData.base64EncodedString()) + + switch await transport.send(body: body) { + case .failure(let transportError): + return .failure(.transportError(transportError)) + case .success: + return .success(()) + } + } + + func receive() async -> Result { + guard let symmetricKey = symmetricKey else { + return .failure(.channelNotReady) + } + + switch await transport.get() { + case.failure(let transportError): + return .failure(.transportError(transportError)) + case .success(let data): + guard let response = try? JSONDecoder().decode(RendezvousMessage.self, from: data) else { + return .failure(.decodingError) + } + + guard let combinedData = Data(base64Encoded: response.combined), + let sealedBox = try? AES.GCM.SealedBox(combined: combinedData), + let messageData = try? AES.GCM.open(sealedBox, using: symmetricKey) else { + return .failure(.decodingError) + } + + return .success(messageData) + } + } + + // MARK: - Private + + private func generateSymmetricKeyFrom(sharedSecret: SharedSecret) -> SymmetricKey { + let salt = Data(repeating: 0, count: 8) + return sharedSecret.hkdfDerivedSymmetricKey(using: SHA256.self, salt: salt, sharedInfo: Data(), outputByteCount: 32) + } +} diff --git a/Riot/Modules/Rendezvous/RendezvousTransport.swift b/Riot/Modules/Rendezvous/RendezvousTransport.swift new file mode 100644 index 000000000..6ea923d63 --- /dev/null +++ b/Riot/Modules/Rendezvous/RendezvousTransport.swift @@ -0,0 +1,149 @@ +// +// Copyright 2022 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 + +class RendezvousTransport: RendezvousTransportProtocol { + private let baseURL: URL + + private var currentEtag: String? + + private(set) var rendezvousURL: URL? { + didSet { + self.currentEtag = nil + } + } + + init(baseURL: URL, rendezvousURL: URL? = nil) { + self.baseURL = baseURL + self.rendezvousURL = rendezvousURL + } + + func get() async -> Result { + guard let url = rendezvousURL else { + return .failure(.rendezvousURLInvalid) + } + + // Keep trying until resource changed + while true { + var request = URLRequest(url: url) + request.httpMethod = "GET" + + request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData + if let etag = currentEtag { + request.addValue(etag, forHTTPHeaderField: "If-None-Match") + } + + // Newer swift concurrency api unavailable due to iOS 14 support + let result: Result = await withCheckedContinuation { continuation in + URLSession.shared.dataTask(with: request) { data, response, error in + guard let data = data, + let response = response, + let httpURLResponse = response as? HTTPURLResponse else { + continuation.resume(returning: .failure(.networkError)) + return + } + + // Return empty data from here if unchanged so that the external while can continue + if httpURLResponse.statusCode == 404 { + continuation.resume(returning: .failure(.rendezvousCancelled)) + } else if httpURLResponse.statusCode == 304 { + continuation.resume(returning: .success(nil)) + } else if httpURLResponse.statusCode == 200 { + if httpURLResponse.allHeaderFields["Content-Type"] as? String != "application/json" { + continuation.resume(returning: .success(nil)) + } else { + if let etag = httpURLResponse.allHeaderFields["Etag"] as? String { + self.currentEtag = etag + } + + continuation.resume(returning: .success(data)) + } + } + }.resume() + } + + switch result { + case .failure(let error): + return .failure(error) + case .success(let data): + guard let data = data else { + continue + } + + return .success(data) + } + } + } + + func create(body: T) async -> Result<(), RendezvousTransportError> { + switch await send(body: body, url: baseURL, usingMethod: "POST") { + case .failure(let error): + return .failure(error) + case .success(let response): + guard let rendezvousIdentifier = response.allHeaderFields["Location"] as? String else { + return .failure(.networkError) + } + + rendezvousURL = baseURL.appendingPathComponent(rendezvousIdentifier) + + return .success(()) + } + } + + func send(body: T) async -> Result<(), RendezvousTransportError> { + guard let url = rendezvousURL else { + return .failure(.rendezvousURLInvalid) + } + + switch await send(body: body, url: url, usingMethod: "PUT") { + case .failure(let error): + return .failure(error) + case .success: + return .success(()) + } + } + + // MARK: - Private + + private func send(body: T, url: URL, usingMethod method: String) async -> Result { + guard let body = try? JSONEncoder().encode(body) else { + return .failure(.encodingError) + } + + var request = URLRequest(url: url) + request.httpMethod = method + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + + request.httpBody = body + + return await withCheckedContinuation { continuation in + URLSession.shared.dataTask(with: request) { data, response, error in + guard let httpURLResponse = response as? HTTPURLResponse else { + continuation.resume(returning: .failure(.networkError)) + return + } + + if let etag = httpURLResponse.allHeaderFields["Etag"] as? String { + self.currentEtag = etag + } + + continuation.resume(returning: .success(httpURLResponse)) + }.resume() + } + } +} diff --git a/Riot/Modules/Rendezvous/RendezvousTransportProtocol.swift b/Riot/Modules/Rendezvous/RendezvousTransportProtocol.swift new file mode 100644 index 000000000..6aea032b4 --- /dev/null +++ b/Riot/Modules/Rendezvous/RendezvousTransportProtocol.swift @@ -0,0 +1,33 @@ +// +// Copyright 2022 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 + +enum RendezvousTransportError: Error { + case rendezvousURLInvalid + case encodingError + case networkError + case rendezvousCancelled +} + +@MainActor +protocol RendezvousTransportProtocol { + var rendezvousURL: URL? { get } + + func create(body: T) async -> Result<(), RendezvousTransportError> + func get() async -> Result + func send(body: T) async -> Result<(), RendezvousTransportError> +} diff --git a/RiotTests/RendezvousServiceTests.swift b/RiotTests/RendezvousServiceTests.swift new file mode 100644 index 000000000..cd3a9b0dd --- /dev/null +++ b/RiotTests/RendezvousServiceTests.swift @@ -0,0 +1,62 @@ +// +// Copyright 2022 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 XCTest +@testable import Element + +@MainActor +class RendezvousServiceTests: XCTestCase { + func testEnd2End() async { + let mockTransport = MockRendezvousTransport() + + let aliceService = RendezvousService(transport: mockTransport) + + guard case .success = await aliceService.createRendezvous() else { + XCTFail("Rendezvous creation failed") + return + } + + XCTAssertNotNil(mockTransport.rendezvousURL) + + let bobService = RendezvousService(transport: mockTransport) + + guard case .success = await bobService.joinRendezvous() else { + XCTFail("Bob failed to join") + return + } + + guard case .success = await aliceService.waitForInterlocutor() else { + XCTFail("Alice failed to establish connection") + return + } + + guard let messageData = "Hello from alice".data(using: .utf8) else { + fatalError() + } + + guard case .success = await aliceService.send(data: messageData) else { + XCTFail("Alice failed to send message") + return + } + + guard case .success(let data) = await bobService.receive() else { + XCTFail("Bob failed to receive message") + return + } + + XCTAssertEqual(messageData, data) + } +} From 6e79b6901919a85816e59726a42d57df95946b57 Mon Sep 17 00:00:00 2001 From: Stefan Ceriu Date: Thu, 6 Oct 2022 14:43:36 +0300 Subject: [PATCH 12/15] Implement cross platform AES encryption support; add documentation --- .../Modules/Rendezvous/RendezvousModels.swift | 3 +- .../Rendezvous/RendezvousService.swift | 64 +++++++++++++++---- .../Rendezvous/RendezvousTransport.swift | 13 ++-- .../RendezvousTransportProtocol.swift | 10 +++ 4 files changed, 70 insertions(+), 20 deletions(-) diff --git a/Riot/Modules/Rendezvous/RendezvousModels.swift b/Riot/Modules/Rendezvous/RendezvousModels.swift index af4dbc50f..24edbf1cf 100644 --- a/Riot/Modules/Rendezvous/RendezvousModels.swift +++ b/Riot/Modules/Rendezvous/RendezvousModels.swift @@ -33,5 +33,6 @@ struct RendezvousTransportDetails: Codable { } struct RendezvousMessage: Codable { - var combined: String + var iv: String + var ciphertext: String } diff --git a/Riot/Modules/Rendezvous/RendezvousService.swift b/Riot/Modules/Rendezvous/RendezvousService.swift index 370703ea1..84583a583 100644 --- a/Riot/Modules/Rendezvous/RendezvousService.swift +++ b/Riot/Modules/Rendezvous/RendezvousService.swift @@ -26,14 +26,12 @@ enum RendezvousServiceError: Error { case transportError(RendezvousTransportError) } -enum RendezvousServiceCallback { - case error(RendezvousServiceError) -} - +/// Algorithm name as per MSC3903 enum RendezvousChannelAlgorithm: String { - case ECDH_V1 = "m.rendezvous.v1.x25519-aes-sha256" + case ECDH_V1 = "m.rendezvous.v1.curve25519-aes-sha256" } +/// Allows communication through a secure channel. Based on MSC3886 and MSC3903 @MainActor class RendezvousService { private let transport: RendezvousTransportProtocol @@ -47,6 +45,7 @@ class RendezvousService { self.privateKey = Curve25519.KeyAgreement.PrivateKey() } + /// Creates a new rendezvous endpoint and publishes the creator's public key func createRendezvous() async -> Result<(), RendezvousServiceError> { let publicKeyString = self.privateKey.publicKey.rawRepresentation.base64EncodedString() let payload = RendezvousDetails(algorithm: RendezvousChannelAlgorithm.ECDH_V1.rawValue, @@ -60,6 +59,8 @@ class RendezvousService { } } + /// After creation we need to wait for the pair to publish its public key as well + /// At the end of this a symmetric key will be available for encryption func waitForInterlocutor() async -> Result<(), RendezvousServiceError> { switch await transport.get() { case .failure(let error): @@ -86,6 +87,8 @@ class RendezvousService { } } + /// Joins an existing rendezvous and publishes the joiner's public key + /// At the end of this a symmetric key will be available for encryption func joinRendezvous() async -> Result<(), RendezvousServiceError> { guard case let .success(data) = await transport.get() else { return .failure(.internalError) @@ -100,6 +103,8 @@ class RendezvousService { return .failure(.invalidInterlocutorKey) } + self.interlocutorPublicKey = interlocutorPublicKey + let publicKeyString = self.privateKey.publicKey.rawRepresentation.base64EncodedString() let payload = RendezvousDetails(algorithm: RendezvousChannelAlgorithm.ECDH_V1.rawValue, key: publicKeyString) @@ -118,17 +123,28 @@ class RendezvousService { return .success(()) } + /// Send arbitrary data over the secure channel + /// This will use the previously generated symmetric key to AES encrypt the payload + /// - Parameter data: the data to be encrypted and sent + /// - Returns: nothing if succeeded or a RendezvousServiceError failure func send(data: Data) async -> Result<(), RendezvousServiceError> { guard let symmetricKey = symmetricKey else { return .failure(.channelNotReady) } - - guard let sealedBox = try? AES.GCM.seal(data, using: symmetricKey), - let combinedData = sealedBox.combined else { + + // Generate a custom random 256 bit nonce/iv as per MSC3903. The default one is 96 bit. + guard let nonce = try? AES.GCM.Nonce(data: generateRandomData(ofLength: 32)), + let sealedBox = try? AES.GCM.seal(data, using: symmetricKey, nonce: nonce) else { return .failure(.internalError) } - let body = RendezvousMessage(combined: combinedData.base64EncodedString()) + // The resulting cipher text needs to contain both the message and the authentication tag + // in order to play nicely with other platforms + var ciphertext = sealedBox.ciphertext + ciphertext.append(contentsOf: sealedBox.tag) + + let body = RendezvousMessage(iv: Data(nonce).base64EncodedString(), + ciphertext: ciphertext.base64EncodedString()) switch await transport.send(body: body) { case .failure(let transportError): @@ -138,6 +154,9 @@ class RendezvousService { } } + + /// Waits for and returns newly available rendezvous channel data + /// - Returns: The unencrypted data or a RendezvousServiceError func receive() async -> Result { guard let symmetricKey = symmetricKey else { return .failure(.channelNotReady) @@ -151,8 +170,17 @@ class RendezvousService { return .failure(.decodingError) } - guard let combinedData = Data(base64Encoded: response.combined), - let sealedBox = try? AES.GCM.SealedBox(combined: combinedData), + guard let ciphertextData = Data(base64Encoded: response.ciphertext), + let nonceData = Data(base64Encoded: response.iv), + let nonce = try? AES.GCM.Nonce(data: nonceData) else { + return .failure(.decodingError) + } + + // Split the ciphertext into the message and authentication tag data + let messageData = ciphertextData.dropLast(16) // The last 16 bytes are the tag + let tagData = ciphertextData.dropFirst(messageData.count) + + guard let sealedBox = try? AES.GCM.SealedBox(nonce: nonce, ciphertext: messageData, tag: tagData), let messageData = try? AES.GCM.open(sealedBox, using: symmetricKey) else { return .failure(.decodingError) } @@ -164,7 +192,21 @@ class RendezvousService { // MARK: - Private private func generateSymmetricKeyFrom(sharedSecret: SharedSecret) -> SymmetricKey { + // MSC3903 asks for a 8 zero byte salt when deriving the keys let salt = Data(repeating: 0, count: 8) return sharedSecret.hkdfDerivedSymmetricKey(using: SHA256.self, salt: salt, sharedInfo: Data(), outputByteCount: 32) } + + private func generateRandomData(ofLength length: Int) -> Data { + var data = Data(count: length) + _ = data.withUnsafeMutableBytes { pointer -> Int32 in + if let baseAddress = pointer.baseAddress { + return SecRandomCopyBytes(kSecRandomDefault, length, baseAddress) + } + + return 0 + } + + return data + } } diff --git a/Riot/Modules/Rendezvous/RendezvousTransport.swift b/Riot/Modules/Rendezvous/RendezvousTransport.swift index 6ea923d63..40b7db2cb 100644 --- a/Riot/Modules/Rendezvous/RendezvousTransport.swift +++ b/Riot/Modules/Rendezvous/RendezvousTransport.swift @@ -63,15 +63,12 @@ class RendezvousTransport: RendezvousTransportProtocol { } else if httpURLResponse.statusCode == 304 { continuation.resume(returning: .success(nil)) } else if httpURLResponse.statusCode == 200 { - if httpURLResponse.allHeaderFields["Content-Type"] as? String != "application/json" { - continuation.resume(returning: .success(nil)) - } else { - if let etag = httpURLResponse.allHeaderFields["Etag"] as? String { - self.currentEtag = etag - } - - continuation.resume(returning: .success(data)) + // The resouce changed, update the etag + if let etag = httpURLResponse.allHeaderFields["Etag"] as? String { + self.currentEtag = etag } + + continuation.resume(returning: .success(data)) } }.resume() } diff --git a/Riot/Modules/Rendezvous/RendezvousTransportProtocol.swift b/Riot/Modules/Rendezvous/RendezvousTransportProtocol.swift index 6aea032b4..4c608ace8 100644 --- a/Riot/Modules/Rendezvous/RendezvousTransportProtocol.swift +++ b/Riot/Modules/Rendezvous/RendezvousTransportProtocol.swift @@ -23,11 +23,21 @@ enum RendezvousTransportError: Error { case rendezvousCancelled } +/// HTTP based MSC3886 channel implementation @MainActor protocol RendezvousTransportProtocol { + /// The current rendezvous endpoint. + /// Automatically assigned after a successful creation var rendezvousURL: URL? { get } + /// Creates a new rendezvous point containing the body + /// - Parameter body: arbitrary data to publish on the rendevous + /// - Returns:a transport error in case of failure func create(body: T) async -> Result<(), RendezvousTransportError> + + /// Waits for and returns newly availalbe rendezvous data func get() async -> Result + + /// Publishes new rendezvous data func send(body: T) async -> Result<(), RendezvousTransportError> } From 84eaf326d91ff63e99c5cee9fab0871d9a33ca5f Mon Sep 17 00:00:00 2001 From: Stefan Ceriu Date: Thu, 6 Oct 2022 16:07:38 +0300 Subject: [PATCH 13/15] Add changelog --- changelog.d/pr-6806.feature | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/pr-6806.feature diff --git a/changelog.d/pr-6806.feature b/changelog.d/pr-6806.feature new file mode 100644 index 000000000..a5308aeef --- /dev/null +++ b/changelog.d/pr-6806.feature @@ -0,0 +1 @@ +Added RendezvousService and secure channel establishment implementation \ No newline at end of file From 19afad1f184ad98e4f92d3eeb76b451b411265e7 Mon Sep 17 00:00:00 2001 From: ismailgulek Date: Thu, 6 Oct 2022 18:05:46 +0300 Subject: [PATCH 14/15] Login with QR UI components (#6790) * Display QR button on login screen if HS supports * Create start screen * Add build flag * Connect start screen to the login * QR display screen * Move `LabelledDividerView` into separate file * Show display QR screen on button tap * Add swift concurreny to CameraAccessManager * Introduce `QRLoginServiceProtocol` * Use new service in screens * Introduce scan QR code screen * Remove hardcoded service availability * Remove unnecessary import * Add confirmation screen * Add loading screen * Fix ZXingObjc targets * Add failure screen * Add strings * Various UI tweaks, navigation according to the service state * Fix tests * Add string for invalid QR error * Add QR login service mode --- Config/BuildSettings.swift | 3 + Podfile | 2 +- .../Contents.json | 12 + .../Secure connection.svg | 11 + .../exclamation_circle.imageset/Contents.json | 12 + .../exclamation_circle.svg | 3 + Riot/Assets/en.lproj/Vector.strings | 32 +++ Riot/Categories/MXRestClient+Async.swift | 9 + Riot/Generated/Images.swift | 2 + Riot/Generated/Strings.swift | 104 +++++++ Riot/Modules/Camera/CameraAccessManager.swift | 16 ++ Riot/Modules/QRCode/QRCodeGenerator.swift | 12 +- .../AuthenticationHomeserverViewData.swift | 6 + .../MatrixSDK/AuthenticationRestClient.swift | 4 + .../MatrixSDK/AuthenticationService.swift | 6 +- .../MatrixSDK/AuthenticationState.swift | 4 + .../Login/AuthenticationLoginModels.swift | 6 + .../Login/AuthenticationLoginViewModel.swift | 2 + .../AuthenticationLoginCoordinator.swift | 24 ++ .../View/AuthenticationLoginScreen.swift | 19 ++ .../QRLogin/Common/Models/QRLoginCode.swift | 39 +++ .../Service/MatrixSDK/QRLoginService.swift | 184 ++++++++++++ .../Service/Mock/MockQRLoginService.swift | 81 ++++++ .../Service/QRLoginServiceProtocol.swift | 97 +++++++ .../Common/Views/LabelledDivider.swift | 63 ++++ .../AuthenticationQRLoginConfirmModels.swift | 37 +++ ...uthenticationQRLoginConfirmViewModel.swift | 56 ++++ ...ationQRLoginConfirmViewModelProtocol.swift | 22 ++ ...henticationQRLoginConfirmCoordinator.swift | 102 +++++++ ...henticationQRLoginConfirmScreenState.swift | 50 ++++ .../AuthenticationQRLoginConfirmUITests.swift | 37 +++ ...ticationQRLoginConfirmViewModelTests.swift | 53 ++++ .../AuthenticationQRLoginConfirmScreen.swift | 135 +++++++++ .../AuthenticationQRLoginDisplayModels.swift | 36 +++ ...uthenticationQRLoginDisplayViewModel.swift | 64 +++++ ...ationQRLoginDisplayViewModelProtocol.swift | 22 ++ ...henticationQRLoginDisplayCoordinator.swift | 103 +++++++ ...henticationQRLoginDisplayScreenState.swift | 50 ++++ .../AuthenticationQRLoginDisplayUITests.swift | 32 +++ ...ticationQRLoginDisplayViewModelTests.swift | 41 +++ .../AuthenticationQRLoginDisplayScreen.swift | 148 ++++++++++ .../AuthenticationQRLoginFailureModels.swift | 38 +++ ...uthenticationQRLoginFailureViewModel.swift | 82 ++++++ ...ationQRLoginFailureViewModelProtocol.swift | 22 ++ ...henticationQRLoginFailureCoordinator.swift | 103 +++++++ ...henticationQRLoginFailureScreenState.swift | 61 ++++ .../AuthenticationQRLoginFailureUITests.swift | 61 ++++ ...ticationQRLoginFailureViewModelTests.swift | 53 ++++ .../AuthenticationQRLoginFailureScreen.swift | 124 ++++++++ .../AuthenticationQRLoginLoadingModels.swift | 35 +++ ...uthenticationQRLoginLoadingViewModel.swift | 72 +++++ ...ationQRLoginLoadingViewModelProtocol.swift | 22 ++ ...henticationQRLoginLoadingCoordinator.swift | 101 +++++++ ...henticationQRLoginLoadingScreenState.swift | 61 ++++ .../AuthenticationQRLoginLoadingUITests.swift | 30 ++ ...ticationQRLoginLoadingViewModelTests.swift | 41 +++ .../AuthenticationQRLoginLoadingScreen.swift | 97 +++++++ .../AuthenticationQRLoginScanModels.swift | 53 ++++ .../AuthenticationQRLoginScanViewModel.swift | 75 +++++ ...ticationQRLoginScanViewModelProtocol.swift | 22 ++ ...AuthenticationQRLoginScanCoordinator.swift | 130 +++++++++ ...AuthenticationQRLoginScanScreenState.swift | 61 ++++ .../UI/AuthenticationQRLoginScanUITests.swift | 53 ++++ ...henticationQRLoginScanViewModelTests.swift | 53 ++++ .../AuthenticationQRLoginScanScreen.swift | 212 ++++++++++++++ .../AuthenticationQRLoginStartModels.swift | 35 +++ .../AuthenticationQRLoginStartViewModel.swift | 49 ++++ ...icationQRLoginStartViewModelProtocol.swift | 22 ++ ...uthenticationQRLoginStartCoordinator.swift | 269 ++++++++++++++++++ ...uthenticationQRLoginStartScreenState.swift | 50 ++++ .../AuthenticationQRLoginStartUITests.swift | 35 +++ ...enticationQRLoginStartViewModelTests.swift | 53 ++++ .../AuthenticationQRLoginStartScreen.swift | 157 ++++++++++ .../Modules/Common/Mock/MockAppScreens.swift | 6 + .../Util/PrimaryActionButtonStyle.swift | 7 +- .../Util/SecondaryActionButtonStyle.swift | 7 +- RiotSwiftUI/target.yml | 2 + RiotSwiftUI/targetUITests.yml | 2 + .../Mocks/MockAuthenticationRestClient.swift | 6 + 79 files changed, 4094 insertions(+), 9 deletions(-) create mode 100644 Riot/Assets/Images.xcassets/Authentication/authentication_qrlogin_confirm_icon.imageset/Contents.json create mode 100644 Riot/Assets/Images.xcassets/Authentication/authentication_qrlogin_confirm_icon.imageset/Secure connection.svg create mode 100644 Riot/Assets/Images.xcassets/Common/exclamation_circle.imageset/Contents.json create mode 100644 Riot/Assets/Images.xcassets/Common/exclamation_circle.imageset/exclamation_circle.svg create mode 100644 RiotSwiftUI/Modules/Authentication/QRLogin/Common/Models/QRLoginCode.swift create mode 100644 RiotSwiftUI/Modules/Authentication/QRLogin/Common/Service/MatrixSDK/QRLoginService.swift create mode 100644 RiotSwiftUI/Modules/Authentication/QRLogin/Common/Service/Mock/MockQRLoginService.swift create mode 100644 RiotSwiftUI/Modules/Authentication/QRLogin/Common/Service/QRLoginServiceProtocol.swift create mode 100644 RiotSwiftUI/Modules/Authentication/QRLogin/Common/Views/LabelledDivider.swift create mode 100644 RiotSwiftUI/Modules/Authentication/QRLogin/Confirm/AuthenticationQRLoginConfirmModels.swift create mode 100644 RiotSwiftUI/Modules/Authentication/QRLogin/Confirm/AuthenticationQRLoginConfirmViewModel.swift create mode 100644 RiotSwiftUI/Modules/Authentication/QRLogin/Confirm/AuthenticationQRLoginConfirmViewModelProtocol.swift create mode 100644 RiotSwiftUI/Modules/Authentication/QRLogin/Confirm/Coordinator/AuthenticationQRLoginConfirmCoordinator.swift create mode 100644 RiotSwiftUI/Modules/Authentication/QRLogin/Confirm/MockAuthenticationQRLoginConfirmScreenState.swift create mode 100644 RiotSwiftUI/Modules/Authentication/QRLogin/Confirm/Test/UI/AuthenticationQRLoginConfirmUITests.swift create mode 100644 RiotSwiftUI/Modules/Authentication/QRLogin/Confirm/Test/Unit/AuthenticationQRLoginConfirmViewModelTests.swift create mode 100644 RiotSwiftUI/Modules/Authentication/QRLogin/Confirm/View/AuthenticationQRLoginConfirmScreen.swift create mode 100644 RiotSwiftUI/Modules/Authentication/QRLogin/Display/AuthenticationQRLoginDisplayModels.swift create mode 100644 RiotSwiftUI/Modules/Authentication/QRLogin/Display/AuthenticationQRLoginDisplayViewModel.swift create mode 100644 RiotSwiftUI/Modules/Authentication/QRLogin/Display/AuthenticationQRLoginDisplayViewModelProtocol.swift create mode 100644 RiotSwiftUI/Modules/Authentication/QRLogin/Display/Coordinator/AuthenticationQRLoginDisplayCoordinator.swift create mode 100644 RiotSwiftUI/Modules/Authentication/QRLogin/Display/MockAuthenticationQRLoginDisplayScreenState.swift create mode 100644 RiotSwiftUI/Modules/Authentication/QRLogin/Display/Test/UI/AuthenticationQRLoginDisplayUITests.swift create mode 100644 RiotSwiftUI/Modules/Authentication/QRLogin/Display/Test/Unit/AuthenticationQRLoginDisplayViewModelTests.swift create mode 100644 RiotSwiftUI/Modules/Authentication/QRLogin/Display/View/AuthenticationQRLoginDisplayScreen.swift create mode 100644 RiotSwiftUI/Modules/Authentication/QRLogin/Failure/AuthenticationQRLoginFailureModels.swift create mode 100644 RiotSwiftUI/Modules/Authentication/QRLogin/Failure/AuthenticationQRLoginFailureViewModel.swift create mode 100644 RiotSwiftUI/Modules/Authentication/QRLogin/Failure/AuthenticationQRLoginFailureViewModelProtocol.swift create mode 100644 RiotSwiftUI/Modules/Authentication/QRLogin/Failure/Coordinator/AuthenticationQRLoginFailureCoordinator.swift create mode 100644 RiotSwiftUI/Modules/Authentication/QRLogin/Failure/MockAuthenticationQRLoginFailureScreenState.swift create mode 100644 RiotSwiftUI/Modules/Authentication/QRLogin/Failure/Test/UI/AuthenticationQRLoginFailureUITests.swift create mode 100644 RiotSwiftUI/Modules/Authentication/QRLogin/Failure/Test/Unit/AuthenticationQRLoginFailureViewModelTests.swift create mode 100644 RiotSwiftUI/Modules/Authentication/QRLogin/Failure/View/AuthenticationQRLoginFailureScreen.swift create mode 100644 RiotSwiftUI/Modules/Authentication/QRLogin/Loading/AuthenticationQRLoginLoadingModels.swift create mode 100644 RiotSwiftUI/Modules/Authentication/QRLogin/Loading/AuthenticationQRLoginLoadingViewModel.swift create mode 100644 RiotSwiftUI/Modules/Authentication/QRLogin/Loading/AuthenticationQRLoginLoadingViewModelProtocol.swift create mode 100644 RiotSwiftUI/Modules/Authentication/QRLogin/Loading/Coordinator/AuthenticationQRLoginLoadingCoordinator.swift create mode 100644 RiotSwiftUI/Modules/Authentication/QRLogin/Loading/MockAuthenticationQRLoginLoadingScreenState.swift create mode 100644 RiotSwiftUI/Modules/Authentication/QRLogin/Loading/Test/UI/AuthenticationQRLoginLoadingUITests.swift create mode 100644 RiotSwiftUI/Modules/Authentication/QRLogin/Loading/Test/Unit/AuthenticationQRLoginLoadingViewModelTests.swift create mode 100644 RiotSwiftUI/Modules/Authentication/QRLogin/Loading/View/AuthenticationQRLoginLoadingScreen.swift create mode 100644 RiotSwiftUI/Modules/Authentication/QRLogin/Scan/AuthenticationQRLoginScanModels.swift create mode 100644 RiotSwiftUI/Modules/Authentication/QRLogin/Scan/AuthenticationQRLoginScanViewModel.swift create mode 100644 RiotSwiftUI/Modules/Authentication/QRLogin/Scan/AuthenticationQRLoginScanViewModelProtocol.swift create mode 100644 RiotSwiftUI/Modules/Authentication/QRLogin/Scan/Coordinator/AuthenticationQRLoginScanCoordinator.swift create mode 100644 RiotSwiftUI/Modules/Authentication/QRLogin/Scan/MockAuthenticationQRLoginScanScreenState.swift create mode 100644 RiotSwiftUI/Modules/Authentication/QRLogin/Scan/Test/UI/AuthenticationQRLoginScanUITests.swift create mode 100644 RiotSwiftUI/Modules/Authentication/QRLogin/Scan/Test/Unit/AuthenticationQRLoginScanViewModelTests.swift create mode 100644 RiotSwiftUI/Modules/Authentication/QRLogin/Scan/View/AuthenticationQRLoginScanScreen.swift create mode 100644 RiotSwiftUI/Modules/Authentication/QRLogin/Start/AuthenticationQRLoginStartModels.swift create mode 100644 RiotSwiftUI/Modules/Authentication/QRLogin/Start/AuthenticationQRLoginStartViewModel.swift create mode 100644 RiotSwiftUI/Modules/Authentication/QRLogin/Start/AuthenticationQRLoginStartViewModelProtocol.swift create mode 100644 RiotSwiftUI/Modules/Authentication/QRLogin/Start/Coordinator/AuthenticationQRLoginStartCoordinator.swift create mode 100644 RiotSwiftUI/Modules/Authentication/QRLogin/Start/MockAuthenticationQRLoginStartScreenState.swift create mode 100644 RiotSwiftUI/Modules/Authentication/QRLogin/Start/Test/UI/AuthenticationQRLoginStartUITests.swift create mode 100644 RiotSwiftUI/Modules/Authentication/QRLogin/Start/Test/Unit/AuthenticationQRLoginStartViewModelTests.swift create mode 100644 RiotSwiftUI/Modules/Authentication/QRLogin/Start/View/AuthenticationQRLoginStartScreen.swift diff --git a/Config/BuildSettings.swift b/Config/BuildSettings.swift index 39de0496a..f7c62e118 100644 --- a/Config/BuildSettings.swift +++ b/Config/BuildSettings.swift @@ -420,4 +420,7 @@ final class BuildSettings: NSObject { // MARK: - New App Layout static let newAppLayoutEnabled = true + + // MARK: - QR Login + static let enableQRLogin = false } diff --git a/Podfile b/Podfile index e74068a7e..dc2805ba1 100644 --- a/Podfile +++ b/Podfile @@ -61,6 +61,7 @@ end def import_SwiftUI_pods pod 'Introspect', '~> 0.1' pod 'DSBottomSheet', '~> 0.3' + pod 'ZXingObjC', '~> 3.6.5' end abstract_target 'RiotPods' do @@ -92,7 +93,6 @@ abstract_target 'RiotPods' do pod 'UICollectionViewRightAlignedLayout', '~> 0.0.3' pod 'UICollectionViewLeftAlignedLayout', '~> 1.0.2' pod 'KTCenterFlowLayout', '~> 1.3.1' - pod 'ZXingObjC', '~> 3.6.5' pod 'FlowCommoniOS', '~> 1.12.0' pod 'ReadMoreTextView', '~> 3.0.1' pod 'SwiftBase32', '~> 0.9.0' diff --git a/Riot/Assets/Images.xcassets/Authentication/authentication_qrlogin_confirm_icon.imageset/Contents.json b/Riot/Assets/Images.xcassets/Authentication/authentication_qrlogin_confirm_icon.imageset/Contents.json new file mode 100644 index 000000000..3c15fd8e3 --- /dev/null +++ b/Riot/Assets/Images.xcassets/Authentication/authentication_qrlogin_confirm_icon.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "Secure connection.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Riot/Assets/Images.xcassets/Authentication/authentication_qrlogin_confirm_icon.imageset/Secure connection.svg b/Riot/Assets/Images.xcassets/Authentication/authentication_qrlogin_confirm_icon.imageset/Secure connection.svg new file mode 100644 index 000000000..ffbcb3b30 --- /dev/null +++ b/Riot/Assets/Images.xcassets/Authentication/authentication_qrlogin_confirm_icon.imageset/Secure connection.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/Riot/Assets/Images.xcassets/Common/exclamation_circle.imageset/Contents.json b/Riot/Assets/Images.xcassets/Common/exclamation_circle.imageset/Contents.json new file mode 100644 index 000000000..f6d56c99d --- /dev/null +++ b/Riot/Assets/Images.xcassets/Common/exclamation_circle.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "exclamation_circle.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Riot/Assets/Images.xcassets/Common/exclamation_circle.imageset/exclamation_circle.svg b/Riot/Assets/Images.xcassets/Common/exclamation_circle.imageset/exclamation_circle.svg new file mode 100644 index 000000000..5d23e58d5 --- /dev/null +++ b/Riot/Assets/Images.xcassets/Common/exclamation_circle.imageset/exclamation_circle.svg @@ -0,0 +1,3 @@ + + + diff --git a/Riot/Assets/en.lproj/Vector.strings b/Riot/Assets/en.lproj/Vector.strings index 4b69f1379..24ee63da6 100644 --- a/Riot/Assets/en.lproj/Vector.strings +++ b/Riot/Assets/en.lproj/Vector.strings @@ -156,6 +156,7 @@ "authentication_login_username" = "Username / Email / Phone"; "authentication_login_forgot_password" = "Forgot password"; "authentication_server_info_title_login" = "Where your conversations live"; +"authentication_login_with_qr" = "Sign in with QR code"; "authentication_server_selection_login_title" = "Connect to homeserver"; "authentication_server_selection_login_message" = "What is the address of your server?"; @@ -211,6 +212,37 @@ "authentication_recaptcha_title" = "Are you a human?"; +"authentication_qr_login_start_title" = "Scan QR code"; +"authentication_qr_login_start_subtitle" = "Use the camera on this device to scan the QR code shown on your other device:"; +"authentication_qr_login_start_step1" = "Open Element on your other device"; +"authentication_qr_login_start_step2" = "Go to Settings -> Security & Privacy"; +"authentication_qr_login_start_step3" = "Select ‘Link a device’"; +"authentication_qr_login_start_step4" = "Select ‘Show QR code on this device’"; +"authentication_qr_login_start_need_alternative" = "Need an alternative method?"; +"authentication_qr_login_start_display_qr" = "Show QR code on this device"; + +"authentication_qr_login_display_title" = "Link a device"; +"authentication_qr_login_display_subtitle" = "Scan the QR code below with your device that’s signed out."; +"authentication_qr_login_display_step1" = "Open Element on your other device"; +"authentication_qr_login_display_step2" = "Select ‘Sign in with QR code’"; + +"authentication_qr_login_scan_title" = "Scan QR code"; +"authentication_qr_login_scan_subtitle" = "Position the QR code in the square below"; + +"authentication_qr_login_confirm_title" = "Secure connection established"; +"authentication_qr_login_confirm_subtitle" = "Confirm that the code below matches with your other device:"; +"authentication_qr_login_confirm_alert" = "Please ensure that you know the origin of this code. By linking devices, you will provide someone with full access to your account."; + +"authentication_qr_login_loading_connecting_device" = "Connecting to device"; +"authentication_qr_login_loading_waiting_signin" = "Waiting for device to sign in."; +"authentication_qr_login_loading_signed_in" = "You are now signed in on your other device."; + +"authentication_qr_login_failure_title" = "Linking failed"; +"authentication_qr_login_failure_invalid_qr" = "QR code is invalid."; +"authentication_qr_login_failure_request_denied" = "The request was denied on the other device."; +"authentication_qr_login_failure_request_timed_out" = "The linking wasn’t completed in the required time."; +"authentication_qr_login_failure_retry" = "Try again"; + // MARK: Password Validation "password_validation_info_header" = "Your password should meet the criteria below:"; "password_validation_error_header" = "Given password does not meet the criteria below:"; diff --git a/Riot/Categories/MXRestClient+Async.swift b/Riot/Categories/MXRestClient+Async.swift index d72bb9ce1..cbc5205a8 100644 --- a/Riot/Categories/MXRestClient+Async.swift +++ b/Riot/Categories/MXRestClient+Async.swift @@ -155,6 +155,15 @@ extension MXRestClient { changePassword(from: oldPassword, to: newPassword, logoutDevices: logoutDevices, completion: completion) } } + + // MARK: - Versions + + /// An async version of `supportedMatrixVersions(completion:)`. + func supportedMatrixVersions() async throws -> MXMatrixVersions { + try await getResponse({ completion in + supportedMatrixVersions(completion: completion) + }) + } // MARK: - Private diff --git a/Riot/Generated/Images.swift b/Riot/Generated/Images.swift index eb71d450c..24d2164be 100644 --- a/Riot/Generated/Images.swift +++ b/Riot/Generated/Images.swift @@ -36,6 +36,7 @@ internal class Asset: NSObject { internal static let authenticationEmailIcon = ImageAsset(name: "authentication_email_icon") internal static let authenticationMsisdnIcon = ImageAsset(name: "authentication_msisdn_icon") internal static let authenticationPasswordIcon = ImageAsset(name: "authentication_password_icon") + internal static let authenticationQrloginConfirmIcon = ImageAsset(name: "authentication_qrlogin_confirm_icon") internal static let authenticationRecaptchaIcon = ImageAsset(name: "authentication_recaptcha_icon") internal static let authenticationRevealPassword = ImageAsset(name: "authentication_reveal_password") internal static let authenticationServerSelectionIcon = ImageAsset(name: "authentication_server_selection_icon") @@ -79,6 +80,7 @@ internal class Asset: NSObject { internal static let coachMark = ImageAsset(name: "coach_mark") internal static let disclosureIcon = ImageAsset(name: "disclosure_icon") internal static let errorIcon = ImageAsset(name: "error_icon") + internal static let exclamationCircle = ImageAsset(name: "exclamation_circle") internal static let faceidIcon = ImageAsset(name: "faceid_icon") internal static let filterOff = ImageAsset(name: "filter_off") internal static let filterOn = ImageAsset(name: "filter_on") diff --git a/Riot/Generated/Strings.swift b/Riot/Generated/Strings.swift index 874702332..44fb49fa0 100644 --- a/Riot/Generated/Strings.swift +++ b/Riot/Generated/Strings.swift @@ -739,6 +739,110 @@ public class VectorL10n: NSObject { public static var authenticationLoginUsername: String { return VectorL10n.tr("Vector", "authentication_login_username") } + /// Sign in with QR code + public static var authenticationLoginWithQr: String { + return VectorL10n.tr("Vector", "authentication_login_with_qr") + } + /// Please ensure that you know the origin of this code. By linking devices, you will provide someone with full access to your account. + public static var authenticationQrLoginConfirmAlert: String { + return VectorL10n.tr("Vector", "authentication_qr_login_confirm_alert") + } + /// Confirm that the code below matches with your other device: + public static var authenticationQrLoginConfirmSubtitle: String { + return VectorL10n.tr("Vector", "authentication_qr_login_confirm_subtitle") + } + /// Secure connection established + public static var authenticationQrLoginConfirmTitle: String { + return VectorL10n.tr("Vector", "authentication_qr_login_confirm_title") + } + /// Open Element on your other device + public static var authenticationQrLoginDisplayStep1: String { + return VectorL10n.tr("Vector", "authentication_qr_login_display_step1") + } + /// Select ‘Sign in with QR code’ + public static var authenticationQrLoginDisplayStep2: String { + return VectorL10n.tr("Vector", "authentication_qr_login_display_step2") + } + /// Scan the QR code below with your device that’s signed out. + public static var authenticationQrLoginDisplaySubtitle: String { + return VectorL10n.tr("Vector", "authentication_qr_login_display_subtitle") + } + /// Link a device + public static var authenticationQrLoginDisplayTitle: String { + return VectorL10n.tr("Vector", "authentication_qr_login_display_title") + } + /// QR code is invalid. + public static var authenticationQrLoginFailureInvalidQr: String { + return VectorL10n.tr("Vector", "authentication_qr_login_failure_invalid_qr") + } + /// The request was denied on the other device. + public static var authenticationQrLoginFailureRequestDenied: String { + return VectorL10n.tr("Vector", "authentication_qr_login_failure_request_denied") + } + /// The linking wasn’t completed in the required time. + public static var authenticationQrLoginFailureRequestTimedOut: String { + return VectorL10n.tr("Vector", "authentication_qr_login_failure_request_timed_out") + } + /// Try again + public static var authenticationQrLoginFailureRetry: String { + return VectorL10n.tr("Vector", "authentication_qr_login_failure_retry") + } + /// Linking failed + public static var authenticationQrLoginFailureTitle: String { + return VectorL10n.tr("Vector", "authentication_qr_login_failure_title") + } + /// Connecting to device + public static var authenticationQrLoginLoadingConnectingDevice: String { + return VectorL10n.tr("Vector", "authentication_qr_login_loading_connecting_device") + } + /// You are now signed in on your other device. + public static var authenticationQrLoginLoadingSignedIn: String { + return VectorL10n.tr("Vector", "authentication_qr_login_loading_signed_in") + } + /// Waiting for device to sign in. + public static var authenticationQrLoginLoadingWaitingSignin: String { + return VectorL10n.tr("Vector", "authentication_qr_login_loading_waiting_signin") + } + /// Position the QR code in the square below + public static var authenticationQrLoginScanSubtitle: String { + return VectorL10n.tr("Vector", "authentication_qr_login_scan_subtitle") + } + /// Scan QR code + public static var authenticationQrLoginScanTitle: String { + return VectorL10n.tr("Vector", "authentication_qr_login_scan_title") + } + /// Show QR code on this device + public static var authenticationQrLoginStartDisplayQr: String { + return VectorL10n.tr("Vector", "authentication_qr_login_start_display_qr") + } + /// Need an alternative method? + public static var authenticationQrLoginStartNeedAlternative: String { + return VectorL10n.tr("Vector", "authentication_qr_login_start_need_alternative") + } + /// Open Element on your other device + public static var authenticationQrLoginStartStep1: String { + return VectorL10n.tr("Vector", "authentication_qr_login_start_step1") + } + /// Go to Settings -> Security & Privacy + public static var authenticationQrLoginStartStep2: String { + return VectorL10n.tr("Vector", "authentication_qr_login_start_step2") + } + /// Select ‘Link a device’ + public static var authenticationQrLoginStartStep3: String { + return VectorL10n.tr("Vector", "authentication_qr_login_start_step3") + } + /// Select ‘Show QR code on this device’ + public static var authenticationQrLoginStartStep4: String { + return VectorL10n.tr("Vector", "authentication_qr_login_start_step4") + } + /// Use the camera on this device to scan the QR code shown on your other device: + public static var authenticationQrLoginStartSubtitle: String { + return VectorL10n.tr("Vector", "authentication_qr_login_start_subtitle") + } + /// Scan QR code + public static var authenticationQrLoginStartTitle: String { + return VectorL10n.tr("Vector", "authentication_qr_login_start_title") + } /// Are you a human? public static var authenticationRecaptchaTitle: String { return VectorL10n.tr("Vector", "authentication_recaptcha_title") diff --git a/Riot/Modules/Camera/CameraAccessManager.swift b/Riot/Modules/Camera/CameraAccessManager.swift index 93cacaafc..628bbc227 100644 --- a/Riot/Modules/Camera/CameraAccessManager.swift +++ b/Riot/Modules/Camera/CameraAccessManager.swift @@ -48,6 +48,22 @@ final class CameraAccessManager { break } } + + /// Checks and requests the camera access if needed. Returns `true` if granted, otherwise `false`. + func requestCameraAccessIfNeeded() async -> Bool { + let authStatus = AVCaptureDevice.authorizationStatus(for: .video) + + switch authStatus { + case .authorized: + return true + case .notDetermined: + return await AVCaptureDevice.requestAccess(for: .video) + case .denied, .restricted: + return false + @unknown default: + return false + } + } // MARK: - Private diff --git a/Riot/Modules/QRCode/QRCodeGenerator.swift b/Riot/Modules/QRCode/QRCodeGenerator.swift index 4ac8e6f6e..982722dac 100644 --- a/Riot/Modules/QRCode/QRCodeGenerator.swift +++ b/Riot/Modules/QRCode/QRCodeGenerator.swift @@ -16,13 +16,17 @@ import Foundation import ZXingObjC +import UIKit final class QRCodeGenerator { enum Error: Swift.Error { case cannotCreateImage } - func generateCode(from data: Data, with size: CGSize) throws -> UIImage { + func generateCode(from data: Data, + with size: CGSize, + onColor: UIColor = .black, + offColor: UIColor = .white) throws -> UIImage { let writer = ZXMultiFormatWriter() let endodedString = String(data: data, encoding: .isoLatin1) let scale = UIScreen.main.scale @@ -33,8 +37,10 @@ final class QRCodeGenerator { height: Int32(size.height * scale), hints: ZXEncodeHints() ) - - guard let cgImage = ZXImage(matrix: bitMatrix).cgimage else { + + guard let cgImage = ZXImage(matrix: bitMatrix, + on: onColor.cgColor, + offColor: offColor.cgColor).cgimage else { throw Error.cannotCreateImage } diff --git a/RiotSwiftUI/Modules/Authentication/Common/AuthenticationHomeserverViewData.swift b/RiotSwiftUI/Modules/Authentication/Common/AuthenticationHomeserverViewData.swift index 7952e7db3..3a611f45a 100644 --- a/RiotSwiftUI/Modules/Authentication/Common/AuthenticationHomeserverViewData.swift +++ b/RiotSwiftUI/Modules/Authentication/Common/AuthenticationHomeserverViewData.swift @@ -24,6 +24,8 @@ struct AuthenticationHomeserverViewData: Equatable { let showLoginForm: Bool /// Whether or not to display the username and password text fields during registration. let showRegistrationForm: Bool + /// Whether or not to display the QR login button during login. + let showQRLogin: Bool /// The supported SSO login options. let ssoIdentityProviders: [SSOIdentityProvider] } @@ -36,6 +38,7 @@ extension AuthenticationHomeserverViewData { AuthenticationHomeserverViewData(address: "matrix.org", showLoginForm: true, showRegistrationForm: true, + showQRLogin: false, ssoIdentityProviders: [ SSOIdentityProvider(id: "1", name: "Apple", brand: "apple", iconURL: nil), SSOIdentityProvider(id: "2", name: "Facebook", brand: "facebook", iconURL: nil), @@ -50,6 +53,7 @@ extension AuthenticationHomeserverViewData { AuthenticationHomeserverViewData(address: "example.com", showLoginForm: true, showRegistrationForm: true, + showQRLogin: false, ssoIdentityProviders: []) } @@ -58,6 +62,7 @@ extension AuthenticationHomeserverViewData { AuthenticationHomeserverViewData(address: "company.com", showLoginForm: false, showRegistrationForm: false, + showQRLogin: false, ssoIdentityProviders: [SSOIdentityProvider(id: "test", name: "SAML", brand: nil, iconURL: nil)]) } @@ -66,6 +71,7 @@ extension AuthenticationHomeserverViewData { AuthenticationHomeserverViewData(address: "company.com", showLoginForm: false, showRegistrationForm: false, + showQRLogin: false, ssoIdentityProviders: []) } } diff --git a/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationRestClient.swift b/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationRestClient.swift index 33d20f17b..8991bf4a2 100644 --- a/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationRestClient.swift +++ b/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationRestClient.swift @@ -48,6 +48,10 @@ protocol AuthenticationRestClient: AnyObject { func forgetPassword(for email: String, clientSecret: String, sendAttempt: UInt) async throws -> String func resetPassword(parameters: CheckResetPasswordParameters) async throws func resetPassword(parameters: [String: Any]) async throws + + // MARK: Versions + + func supportedMatrixVersions() async throws -> MXMatrixVersions } extension MXRestClient: AuthenticationRestClient { } diff --git a/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationService.swift b/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationService.swift index 492d39834..9df083fb4 100644 --- a/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationService.swift +++ b/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationService.swift @@ -259,10 +259,14 @@ class AuthenticationService: NSObject { } let loginFlow = try await getLoginFlowResult(client: client) + + let supportsQRLogin = try await QRLoginService(client: client, + mode: .notAuthenticated).isServiceAvailable() let homeserver = AuthenticationState.Homeserver(address: loginFlow.homeserverAddress, addressFromUser: homeserverAddress, - preferredLoginMode: loginFlow.loginMode) + preferredLoginMode: loginFlow.loginMode, + supportsQRLogin: supportsQRLogin) return (client, homeserver) } diff --git a/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationState.swift b/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationState.swift index e2a48e315..38f1939f4 100644 --- a/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationState.swift +++ b/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationState.swift @@ -52,6 +52,9 @@ struct AuthenticationState { /// The preferred login mode for the server var preferredLoginMode: LoginMode = .unknown + + /// Flag indicating whether the homeserver supports logging in via a QR code. + var supportsQRLogin = false /// The response returned when querying the homeserver for registration flows. var registrationFlow: RegistrationResult? @@ -67,6 +70,7 @@ struct AuthenticationState { AuthenticationHomeserverViewData(address: displayableAddress, showLoginForm: preferredLoginMode.supportsPasswordFlow, showRegistrationForm: registrationFlow != nil && !needsRegistrationFallback, + showQRLogin: supportsQRLogin, ssoIdentityProviders: preferredLoginMode.ssoIdentityProviders ?? []) } diff --git a/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginModels.swift b/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginModels.swift index e273d0d16..eadd28e68 100644 --- a/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginModels.swift +++ b/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginModels.swift @@ -31,6 +31,8 @@ enum AuthenticationLoginViewModelResult: CustomStringConvertible { case continueWithSSO(SSOIdentityProvider) /// Continue using the fallback page case fallback + /// Continue with QR login + case qrLogin /// A string representation of the result, ignoring any associated values that could leak PII. var description: String { @@ -47,6 +49,8 @@ enum AuthenticationLoginViewModelResult: CustomStringConvertible { return "continueWithSSO: \(provider)" case .fallback: return "fallback" + case .qrLogin: + return "qrLogin" } } } @@ -99,6 +103,8 @@ enum AuthenticationLoginViewAction { case fallback /// Continue using the supplied SSO provider. case continueWithSSO(SSOIdentityProvider) + /// Continue using QR login + case qrLogin } enum AuthenticationLoginErrorType: Hashable { diff --git a/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginViewModel.swift b/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginViewModel.swift index 6c5274d62..f1180c1d1 100644 --- a/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginViewModel.swift +++ b/RiotSwiftUI/Modules/Authentication/Login/AuthenticationLoginViewModel.swift @@ -50,6 +50,8 @@ class AuthenticationLoginViewModel: AuthenticationLoginViewModelType, Authentica Task { await callback?(.fallback) } case .continueWithSSO(let provider): Task { await callback?(.continueWithSSO(provider)) } + case .qrLogin: + Task { await callback?(.qrLogin) } } } diff --git a/RiotSwiftUI/Modules/Authentication/Login/Coordinator/AuthenticationLoginCoordinator.swift b/RiotSwiftUI/Modules/Authentication/Login/Coordinator/AuthenticationLoginCoordinator.swift index 596e1cad7..4a45130ea 100644 --- a/RiotSwiftUI/Modules/Authentication/Login/Coordinator/AuthenticationLoginCoordinator.swift +++ b/RiotSwiftUI/Modules/Authentication/Login/Coordinator/AuthenticationLoginCoordinator.swift @@ -126,6 +126,8 @@ final class AuthenticationLoginCoordinator: Coordinator, Presentable { self.callback?(.continueWithSSO(identityProvider)) case .fallback: self.callback?(.fallback) + case .qrLogin: + self.showQRLoginScreen() } } } @@ -282,6 +284,28 @@ final class AuthenticationLoginCoordinator: Coordinator, Presentable { navigationRouter.present(modalRouter, animated: true) } + + /// Shows the QR login screen. + @MainActor private func showQRLoginScreen() { + MXLog.debug("[AuthenticationLoginCoordinator] showQRLoginScreen") + + let service = QRLoginService(client: parameters.authenticationService.client, + mode: .notAuthenticated) + let parameters = AuthenticationQRLoginStartCoordinatorParameters(navigationRouter: navigationRouter, + qrLoginService: service) + let coordinator = AuthenticationQRLoginStartCoordinator(parameters: parameters) + coordinator.callback = { [weak self, weak coordinator] _ in + guard let self = self, let coordinator = coordinator else { return } + self.remove(childCoordinator: coordinator) + } + + coordinator.start() + add(childCoordinator: coordinator) + + navigationRouter.push(coordinator, animated: true) { [weak self] in + self?.remove(childCoordinator: coordinator) + } + } /// Updates the view model to reflect any changes made to the homeserver. @MainActor private func updateViewModel() { diff --git a/RiotSwiftUI/Modules/Authentication/Login/View/AuthenticationLoginScreen.swift b/RiotSwiftUI/Modules/Authentication/Login/View/AuthenticationLoginScreen.swift index 3ff67aaf2..03798ce49 100644 --- a/RiotSwiftUI/Modules/Authentication/Login/View/AuthenticationLoginScreen.swift +++ b/RiotSwiftUI/Modules/Authentication/Login/View/AuthenticationLoginScreen.swift @@ -50,6 +50,10 @@ struct AuthenticationLoginScreen: View { if viewModel.viewState.homeserver.showLoginForm { loginForm } + + if viewModel.viewState.homeserver.showQRLogin { + qrLoginButton + } if viewModel.viewState.homeserver.showLoginForm, viewModel.viewState.showSSOButtons { Text(VectorL10n.or) @@ -129,6 +133,16 @@ struct AuthenticationLoginScreen: View { .accessibilityIdentifier("nextButton") } } + + /// A QR login button that can be used for login. + var qrLoginButton: some View { + Button(action: qrLogin) { + Text(VectorL10n.authenticationLoginWithQr) + } + .buttonStyle(SecondaryActionButtonStyle(font: theme.fonts.bodySB)) + .padding(.vertical) + .accessibilityIdentifier("qrLoginButton") + } /// A list of SSO buttons that can be used for login. var ssoButtons: some View { @@ -174,6 +188,11 @@ struct AuthenticationLoginScreen: View { func fallback() { viewModel.send(viewAction: .fallback) } + + /// Sends the `qrLogin` view action. + func qrLogin() { + viewModel.send(viewAction: .qrLogin) + } } // MARK: - Previews diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Common/Models/QRLoginCode.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Common/Models/QRLoginCode.swift new file mode 100644 index 000000000..ce28652d2 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Common/Models/QRLoginCode.swift @@ -0,0 +1,39 @@ +// +// Copyright 2022 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 QRLoginCode: Codable { + var user: String? + var initiator: QRLoginDataInitiatorDevice? + var rendezvous: QRLoginRendezvous? +} + +enum QRLoginDataInitiatorDevice: String, Codable { + case new = "new_device" + case existing = "existing_device" +} + +struct QRLoginRendezvous: Codable { + var transport: QRLoginRendezvousTransportDetails + var algorithm: String? + var key: String? +} + +struct QRLoginRendezvousTransportDetails: Codable { + var type: String + var uri: String? +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Common/Service/MatrixSDK/QRLoginService.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Common/Service/MatrixSDK/QRLoginService.swift new file mode 100644 index 000000000..478315a1a --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Common/Service/MatrixSDK/QRLoginService.swift @@ -0,0 +1,184 @@ +// +// Copyright 2022 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 Combine +import Foundation +import MatrixSDK +import SwiftUI +import ZXingObjC + +// MARK: - QRLoginService + +class QRLoginService: NSObject, QRLoginServiceProtocol { + private let client: AuthenticationRestClient + private var isCameraReady = false + private lazy var zxCapture = ZXCapture() + + private let cameraAccessManager = CameraAccessManager() + + init(client: AuthenticationRestClient, + mode: QRLoginServiceMode, + state: QRLoginServiceState = .initial) { + self.client = client + self.mode = mode + self.state = state + super.init() + } + + // MARK: QRLoginServiceProtocol + + let mode: QRLoginServiceMode + + var state: QRLoginServiceState { + didSet { + if state != oldValue { + callbacks.send(.didUpdateState) + } + } + } + + let callbacks = PassthroughSubject() + + func isServiceAvailable() async throws -> Bool { + guard BuildSettings.enableQRLogin else { + return false + } + return try await client.supportedMatrixVersions().supportsQRLogin + } + + func generateQRCode() async throws -> QRLoginCode { + let transport = QRLoginRendezvousTransportDetails(type: "http.v1", + uri: "") + let rendezvous = QRLoginRendezvous(transport: transport, + algorithm: "m.rendezvous.v1.curve25519-aes-sha256", + key: "") + return QRLoginCode(user: client.credentials.userId, + initiator: .new, + rendezvous: rendezvous) + } + + func scannerView() -> AnyView { + let frame = UIScreen.main.bounds + let view = UIView(frame: frame) + zxCapture.layer.frame = frame + view.layer.addSublayer(zxCapture.layer) + return AnyView(ViewWrapper(view: view)) + } + + func startScanning() { + Task { @MainActor in + if cameraAccessManager.isCameraAvailable { + let granted = await cameraAccessManager.requestCameraAccessIfNeeded() + if granted { + state = .scanningQR + zxCapture.delegate = self + zxCapture.camera = zxCapture.back() + zxCapture.start() + } else { + state = .failed(error: .noCameraAccess) + } + } else { + state = .failed(error: .noCameraAvailable) + } + } + } + + func stopScanning(destroy: Bool) { + guard zxCapture.running else { + return + } + + if destroy { + zxCapture.hard_stop() + } else { + zxCapture.stop() + } + } + + func processScannedQR(_ data: Data) { + state = .connectingToDevice + do { + let code = try JSONDecoder().decode(QRLoginCode.self, from: data) + MXLog.debug("[QRLoginService] processScannedQR: \(code)") + // TODO: implement + } catch { + state = .failed(error: .invalidQR) + } + } + + func confirmCode() { + switch state { + case .waitingForConfirmation(let code): + // TODO: implement + break + default: + return + } + } + + func restart() { + state = .initial + } + + func reset() { + stopScanning(destroy: false) + state = .initial + } + + deinit { + stopScanning(destroy: true) + } + + // MARK: Private +} + +// MARK: - ZXCaptureDelegate + +extension QRLoginService: ZXCaptureDelegate { + func captureCameraIsReady(_ capture: ZXCapture!) { + isCameraReady = true + } + + func captureResult(_ capture: ZXCapture!, result: ZXResult!) { + guard isCameraReady, + let result = result, + result.barcodeFormat == kBarcodeFormatQRCode else { + return + } + + stopScanning(destroy: false) + + if let bytes = result.resultMetadata.object(forKey: kResultMetadataTypeByteSegments.rawValue) as? NSArray, + let byteArray = bytes.firstObject as? ZXByteArray { + let data = Data(bytes: UnsafeRawPointer(byteArray.array), count: Int(byteArray.length)) + + callbacks.send(.didScanQR(data)) + } + } +} + +// MARK: - ViewWrapper + +private struct ViewWrapper: UIViewRepresentable { + var view: UIView + + func makeUIView(context: Context) -> some UIView { + view + } + + func updateUIView(_ uiView: UIViewType, context: Context) { } +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Common/Service/Mock/MockQRLoginService.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Common/Service/Mock/MockQRLoginService.swift new file mode 100644 index 000000000..f7b46f222 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Common/Service/Mock/MockQRLoginService.swift @@ -0,0 +1,81 @@ +// +// Copyright 2022 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 Combine +import Foundation +import SwiftUI + +class MockQRLoginService: QRLoginServiceProtocol { + init(withState state: QRLoginServiceState = .initial, + mode: QRLoginServiceMode = .notAuthenticated) { + self.state = state + self.mode = mode + } + + // MARK: - QRLoginServiceProtocol + + let mode: QRLoginServiceMode + + var state: QRLoginServiceState { + didSet { + if state != oldValue { + callbacks.send(.didUpdateState) + } + } + } + + let callbacks = PassthroughSubject() + + func isServiceAvailable() async throws -> Bool { + true + } + + func generateQRCode() async throws -> QRLoginCode { + let transport = QRLoginRendezvousTransportDetails(type: "http.v1", + uri: "https://matrix.org") + let rendezvous = QRLoginRendezvous(transport: transport, + algorithm: "m.rendezvous.v1.curve25519-aes-sha256", + key: "") + return QRLoginCode(user: "@mock:matrix.org", + initiator: .new, + rendezvous: rendezvous) + } + + func scannerView() -> AnyView { + AnyView(Color.red) + } + + func startScanning() { } + + func stopScanning(destroy: Bool) { } + + func processScannedQR(_ data: Data) { + state = .connectingToDevice + state = .waitingForConfirmation("28E-1B9-D0F-896") + } + + func confirmCode() { + state = .waitingForRemoteSignIn + } + + func restart() { + state = .initial + } + + func reset() { + state = .initial + } +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Common/Service/QRLoginServiceProtocol.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Common/Service/QRLoginServiceProtocol.swift new file mode 100644 index 000000000..a85470d1d --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Common/Service/QRLoginServiceProtocol.swift @@ -0,0 +1,97 @@ +// +// Copyright 2022 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 Combine +import Foundation +import SwiftUI + +// MARK: - QRLoginServiceMode + +enum QRLoginServiceMode { + case authenticated + case notAuthenticated +} + +// MARK: - QRLoginServiceError + +enum QRLoginServiceError: Error, Equatable { + case noCameraAccess + case noCameraAvailable + case invalidQR + case requestDenied + case requestTimedOut +} + +// MARK: - QRLoginServiceState + +enum QRLoginServiceState: Equatable { + case initial + case scanningQR + case connectingToDevice + case waitingForConfirmation(_ code: String) + case waitingForRemoteSignIn + case failed(error: QRLoginServiceError) + case completed + + static func == (lhs: QRLoginServiceState, rhs: QRLoginServiceState) -> Bool { + switch (lhs, rhs) { + case (.initial, .initial): + return true + case (.scanningQR, .scanningQR): + return true + case (.connectingToDevice, .connectingToDevice): + return true + case (let .waitingForConfirmation(code1), let .waitingForConfirmation(code2)): + return code1 == code2 + case (.waitingForRemoteSignIn, .waitingForRemoteSignIn): + return true + case (let .failed(error1), let .failed(error2)): + return error1 == error2 + case (.completed, .completed): + return true + default: + return false + } + } +} + +// MARK: - QRLoginServiceCallback + +enum QRLoginServiceCallback { + case didScanQR(Data) + case didUpdateState +} + +// MARK: - QRLoginServiceProtocol + +protocol QRLoginServiceProtocol { + var mode: QRLoginServiceMode { get } + var state: QRLoginServiceState { get } + var callbacks: PassthroughSubject { get } + func isServiceAvailable() async throws -> Bool + func generateQRCode() async throws -> QRLoginCode + + // MARK: QR Scanner + + func scannerView() -> AnyView + func startScanning() + func stopScanning(destroy: Bool) + func processScannedQR(_ data: Data) + + func confirmCode() + func restart() + func reset() +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Common/Views/LabelledDivider.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Common/Views/LabelledDivider.swift new file mode 100644 index 000000000..8c4f581ac --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Common/Views/LabelledDivider.swift @@ -0,0 +1,63 @@ +// +// Copyright 2022 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 +import SwiftUI + +struct LabelledDivider: View { + @Environment(\.theme) private var theme + + let label: String + let font: Font? // theme.fonts.subheadline by default + let labelColor: Color? // theme.colors.primaryContent by default + let lineColor: Color? // theme.colors.quinaryContent by default + + init(label: String, + font: Font? = nil, + labelColor: Color? = nil, + lineColor: Color? = nil) { + self.label = label + self.font = font + self.labelColor = labelColor + self.lineColor = lineColor + } + + var body: some View { + HStack { + line + Text(label) + .foregroundColor(labelColor ?? theme.colors.primaryContent) + .font(font ?? theme.fonts.subheadline) + .fixedSize() + line + } + } + + var line: some View { + VStack { Divider().background(lineColor ?? theme.colors.quinaryContent) } + } +} + +// MARK: - Previews + +struct LabelledDivider_Previews: PreviewProvider { + static var previews: some View { + LabelledDivider(label: "Label") + .theme(.light).preferredColorScheme(.light) + LabelledDivider(label: "Label") + .theme(.dark).preferredColorScheme(.dark) + } +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Confirm/AuthenticationQRLoginConfirmModels.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Confirm/AuthenticationQRLoginConfirmModels.swift new file mode 100644 index 000000000..91e9b4590 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Confirm/AuthenticationQRLoginConfirmModels.swift @@ -0,0 +1,37 @@ +// +// Copyright 2021 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 + +// MARK: - Coordinator + +// MARK: View model + +enum AuthenticationQRLoginConfirmViewModelResult { + case confirm + case cancel +} + +// MARK: View + +struct AuthenticationQRLoginConfirmViewState: BindableState { + var confirmationCode: String? +} + +enum AuthenticationQRLoginConfirmViewAction { + case confirm + case cancel +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Confirm/AuthenticationQRLoginConfirmViewModel.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Confirm/AuthenticationQRLoginConfirmViewModel.swift new file mode 100644 index 000000000..96e500ef2 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Confirm/AuthenticationQRLoginConfirmViewModel.swift @@ -0,0 +1,56 @@ +// +// Copyright 2021 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 SwiftUI + +typealias AuthenticationQRLoginConfirmViewModelType = StateStoreViewModel + +class AuthenticationQRLoginConfirmViewModel: AuthenticationQRLoginConfirmViewModelType, AuthenticationQRLoginConfirmViewModelProtocol { + // MARK: - Properties + + // MARK: Private + + private let qrLoginService: QRLoginServiceProtocol + + // MARK: Public + + var callback: ((AuthenticationQRLoginConfirmViewModelResult) -> Void)? + + // MARK: - Setup + + init(qrLoginService: QRLoginServiceProtocol) { + self.qrLoginService = qrLoginService + super.init(initialViewState: AuthenticationQRLoginConfirmViewState()) + + switch qrLoginService.state { + case .waitingForConfirmation(let code): + state.confirmationCode = code + default: + break + } + } + + // MARK: - Public + + override func process(viewAction: AuthenticationQRLoginConfirmViewAction) { + switch viewAction { + case .confirm: + callback?(.confirm) + case .cancel: + callback?(.cancel) + } + } +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Confirm/AuthenticationQRLoginConfirmViewModelProtocol.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Confirm/AuthenticationQRLoginConfirmViewModelProtocol.swift new file mode 100644 index 000000000..9e46d9661 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Confirm/AuthenticationQRLoginConfirmViewModelProtocol.swift @@ -0,0 +1,22 @@ +// +// Copyright 2021 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 + +protocol AuthenticationQRLoginConfirmViewModelProtocol { + var callback: ((AuthenticationQRLoginConfirmViewModelResult) -> Void)? { get set } + var context: AuthenticationQRLoginConfirmViewModelType.Context { get } +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Confirm/Coordinator/AuthenticationQRLoginConfirmCoordinator.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Confirm/Coordinator/AuthenticationQRLoginConfirmCoordinator.swift new file mode 100644 index 000000000..7b09d9454 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Confirm/Coordinator/AuthenticationQRLoginConfirmCoordinator.swift @@ -0,0 +1,102 @@ +// +// Copyright 2021 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 CommonKit +import SwiftUI + +struct AuthenticationQRLoginConfirmCoordinatorParameters { + let navigationRouter: NavigationRouterType + let qrLoginService: QRLoginServiceProtocol +} + +enum AuthenticationQRLoginConfirmCoordinatorResult { + /// Login with QR done + case done +} + +final class AuthenticationQRLoginConfirmCoordinator: Coordinator, Presentable { + // MARK: - Properties + + // MARK: Private + + private let parameters: AuthenticationQRLoginConfirmCoordinatorParameters + private let onboardingQRLoginConfirmHostingController: VectorHostingController + private var onboardingQRLoginConfirmViewModel: AuthenticationQRLoginConfirmViewModelProtocol + + private var indicatorPresenter: UserIndicatorTypePresenterProtocol + private var loadingIndicator: UserIndicator? + + private var navigationRouter: NavigationRouterType { parameters.navigationRouter } + + // MARK: Public + + // Must be used only internally + var childCoordinators: [Coordinator] = [] + var callback: ((AuthenticationQRLoginConfirmCoordinatorResult) -> Void)? + + // MARK: - Setup + + init(parameters: AuthenticationQRLoginConfirmCoordinatorParameters) { + self.parameters = parameters + let viewModel = AuthenticationQRLoginConfirmViewModel(qrLoginService: parameters.qrLoginService) + let view = AuthenticationQRLoginConfirmScreen(context: viewModel.context) + onboardingQRLoginConfirmViewModel = viewModel + + onboardingQRLoginConfirmHostingController = VectorHostingController(rootView: view) + onboardingQRLoginConfirmHostingController.vc_removeBackTitle() + onboardingQRLoginConfirmHostingController.enableNavigationBarScrollEdgeAppearance = true + + indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: onboardingQRLoginConfirmHostingController) + } + + // MARK: - Public + + func start() { + MXLog.debug("[AuthenticationQRLoginConfirmCoordinator] did start.") + onboardingQRLoginConfirmViewModel.callback = { [weak self] result in + guard let self = self else { return } + MXLog.debug("[AuthenticationQRLoginConfirmCoordinator] AuthenticationQRLoginConfirmViewModel did complete with result: \(result).") + + switch result { + case .confirm: + self.parameters.qrLoginService.confirmCode() + case .cancel: + self.parameters.qrLoginService.reset() + } + } + } + + func toPresentable() -> UIViewController { + onboardingQRLoginConfirmHostingController + } + + /// Stops any ongoing activities in the coordinator. + func stop() { + stopLoading() + } + + // MARK: - Private + + /// Show an activity indicator whilst loading. + private func startLoading() { + loadingIndicator = indicatorPresenter.present(.loading(label: VectorL10n.loading, isInteractionBlocking: true)) + } + + /// Hide the currently displayed activity indicator. + private func stopLoading() { + loadingIndicator = nil + } +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Confirm/MockAuthenticationQRLoginConfirmScreenState.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Confirm/MockAuthenticationQRLoginConfirmScreenState.swift new file mode 100644 index 000000000..d97929f7b --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Confirm/MockAuthenticationQRLoginConfirmScreenState.swift @@ -0,0 +1,50 @@ +// +// Copyright 2021 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 +import SwiftUI + +/// Using an enum for the screen allows you define the different state cases with +/// the relevant associated data for each case. +enum MockAuthenticationQRLoginConfirmScreenState: MockScreenState, CaseIterable { + // A case for each state you want to represent + // with specific, minimal associated data that will allow you + // mock that screen. + case `default` + + /// The associated screen + var screenType: Any.Type { + AuthenticationQRLoginConfirmScreen.self + } + + /// A list of screen state definitions + static var allCases: [MockAuthenticationQRLoginConfirmScreenState] { + // Each of the presence statuses + [.default] + } + + /// Generate the view struct for the screen state. + var screenView: ([Any], AnyView) { + let viewModel = AuthenticationQRLoginConfirmViewModel(qrLoginService: MockQRLoginService(withState: .waitingForConfirmation("28E-1B9-D0F-896"))) + + // can simulate service and viewModel actions here if needs be. + + return ( + [self, viewModel], + AnyView(AuthenticationQRLoginConfirmScreen(context: viewModel.context)) + ) + } +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Confirm/Test/UI/AuthenticationQRLoginConfirmUITests.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Confirm/Test/UI/AuthenticationQRLoginConfirmUITests.swift new file mode 100644 index 000000000..81c2ac3ba --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Confirm/Test/UI/AuthenticationQRLoginConfirmUITests.swift @@ -0,0 +1,37 @@ +// +// Copyright 2021 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 RiotSwiftUI +import XCTest + +class AuthenticationQRLoginConfirmUITests: MockScreenTestCase { + func testDefault() { + app.goToScreenWithIdentifier(MockAuthenticationQRLoginConfirmScreenState.default.title) + + XCTAssertTrue(app.staticTexts["titleLabel"].exists) + XCTAssertTrue(app.staticTexts["subtitleLabel"].exists) + XCTAssertTrue(app.staticTexts["confirmationCodeLabel"].exists) + XCTAssertTrue(app.staticTexts["alertText"].exists) + + let confirmButton = app.buttons["confirmButton"] + XCTAssertTrue(confirmButton.exists) + XCTAssertTrue(confirmButton.isEnabled) + + let cancelButton = app.buttons["cancelButton"] + XCTAssertTrue(cancelButton.exists) + XCTAssertTrue(cancelButton.isEnabled) + } +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Confirm/Test/Unit/AuthenticationQRLoginConfirmViewModelTests.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Confirm/Test/Unit/AuthenticationQRLoginConfirmViewModelTests.swift new file mode 100644 index 000000000..ebddb2774 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Confirm/Test/Unit/AuthenticationQRLoginConfirmViewModelTests.swift @@ -0,0 +1,53 @@ +// +// Copyright 2021 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 XCTest + +@testable import RiotSwiftUI + +class AuthenticationQRLoginConfirmViewModelTests: XCTestCase { + var viewModel: AuthenticationQRLoginConfirmViewModelProtocol! + var context: AuthenticationQRLoginConfirmViewModelType.Context! + + override func setUpWithError() throws { + viewModel = AuthenticationQRLoginConfirmViewModel(qrLoginService: MockQRLoginService(withState: .waitingForConfirmation("28E-1B9-D0F-896"))) + context = viewModel.context + } + + func testConfirm() { + var result: AuthenticationQRLoginConfirmViewModelResult? + + viewModel.callback = { callbackResult in + result = callbackResult + } + + context.send(viewAction: .confirm) + + XCTAssertEqual(result, .confirm) + } + + func testCancel() { + var result: AuthenticationQRLoginConfirmViewModelResult? + + viewModel.callback = { callbackResult in + result = callbackResult + } + + context.send(viewAction: .cancel) + + XCTAssertEqual(result, .cancel) + } +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Confirm/View/AuthenticationQRLoginConfirmScreen.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Confirm/View/AuthenticationQRLoginConfirmScreen.swift new file mode 100644 index 000000000..2011d5df6 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Confirm/View/AuthenticationQRLoginConfirmScreen.swift @@ -0,0 +1,135 @@ +// +// Copyright 2021 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 SwiftUI + +/// The screen shown to a new user to select their use case for the app. +struct AuthenticationQRLoginConfirmScreen: View { + // MARK: - Properties + + // MARK: Private + + @Environment(\.theme) private var theme + @ScaledMetric private var iconSize = 70.0 + + // MARK: Public + + @ObservedObject var context: AuthenticationQRLoginConfirmViewModel.Context + + var body: some View { + GeometryReader { geometry in + VStack(alignment: .leading, spacing: 0) { + ScrollView { + titleContent + .padding(.top, OnboardingMetrics.topPaddingToNavigationBar) + codeView + } + .readableFrame() + + footerContent + .padding(.bottom, geometry.safeAreaInsets.bottom > 0 ? 20 : 36) + } + .padding(.horizontal, 16) + } + .background(theme.colors.background.ignoresSafeArea()) + } + + /// The screen's title and instructions. + var titleContent: some View { + VStack(spacing: 16) { + Image(Asset.Images.authenticationQrloginConfirmIcon.name) + .frame(width: iconSize, height: iconSize) + .padding(.bottom, 16) + + Text(VectorL10n.authenticationQrLoginConfirmTitle) + .font(theme.fonts.title3SB) + .multilineTextAlignment(.center) + .foregroundColor(theme.colors.primaryContent) + .accessibilityIdentifier("titleLabel") + + Text(VectorL10n.authenticationQrLoginConfirmSubtitle) + .font(theme.fonts.body) + .multilineTextAlignment(.center) + .foregroundColor(theme.colors.secondaryContent) + .padding(.bottom, 24) + .accessibilityIdentifier("subtitleLabel") + } + } + + @ViewBuilder + var codeView: some View { + if let code = context.viewState.confirmationCode { + Text(code) + .multilineTextAlignment(.center) + .font(theme.fonts.title1) + .foregroundColor(theme.colors.primaryContent) + .padding(.top, 80) + .accessibilityIdentifier("confirmationCodeLabel") + } + } + + /// The screen's footer. + var footerContent: some View { + VStack(spacing: 16) { + Text(VectorL10n.authenticationQrLoginConfirmAlert) + .padding(10) + .multilineTextAlignment(.center) + .font(theme.fonts.body) + .foregroundColor(theme.colors.alert) + .shapedBorder(color: theme.colors.alert, borderWidth: 1, shape: RoundedRectangle(cornerRadius: 8)) + .fixedSize(horizontal: false, vertical: true) + .padding(.bottom, 12) + .accessibilityIdentifier("alertText") + + Button(action: confirm) { + Text(VectorL10n.confirm) + } + .buttonStyle(PrimaryActionButtonStyle(font: theme.fonts.bodySB)) + .accessibilityIdentifier("confirmButton") + + Button(action: cancel) { + Text(VectorL10n.cancel) + } + .buttonStyle(SecondaryActionButtonStyle(font: theme.fonts.bodySB)) + .accessibilityIdentifier("cancelButton") + } + } + + /// Sends the `confirm` view action. + func confirm() { + context.send(viewAction: .confirm) + } + + /// Sends the `cancel` view action. + func cancel() { + context.send(viewAction: .cancel) + } +} + +// MARK: - Previews + +struct AuthenticationQRLoginConfirm_Previews: PreviewProvider { + static let stateRenderer = MockAuthenticationQRLoginConfirmScreenState.stateRenderer + + static var previews: some View { + stateRenderer.screenGroup(addNavigation: true) + .theme(.light).preferredColorScheme(.light) + .navigationViewStyle(.stack) + stateRenderer.screenGroup(addNavigation: true) + .theme(.dark).preferredColorScheme(.dark) + .navigationViewStyle(.stack) + } +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Display/AuthenticationQRLoginDisplayModels.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Display/AuthenticationQRLoginDisplayModels.swift new file mode 100644 index 000000000..8c2bf963f --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Display/AuthenticationQRLoginDisplayModels.swift @@ -0,0 +1,36 @@ +// +// Copyright 2021 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 +import UIKit + +// MARK: - Coordinator + +// MARK: View model + +enum AuthenticationQRLoginDisplayViewModelResult { + case cancel +} + +// MARK: View + +struct AuthenticationQRLoginDisplayViewState: BindableState { + var qrImage: UIImage? +} + +enum AuthenticationQRLoginDisplayViewAction { + case cancel +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Display/AuthenticationQRLoginDisplayViewModel.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Display/AuthenticationQRLoginDisplayViewModel.swift new file mode 100644 index 000000000..bfad16c61 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Display/AuthenticationQRLoginDisplayViewModel.swift @@ -0,0 +1,64 @@ +// +// Copyright 2021 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 SwiftUI + +typealias AuthenticationQRLoginDisplayViewModelType = StateStoreViewModel + +class AuthenticationQRLoginDisplayViewModel: AuthenticationQRLoginDisplayViewModelType, AuthenticationQRLoginDisplayViewModelProtocol { + // MARK: - Properties + + // MARK: Private + + private let qrLoginService: QRLoginServiceProtocol + + // MARK: Public + + var callback: ((AuthenticationQRLoginDisplayViewModelResult) -> Void)? + + // MARK: - Setup + + init(qrLoginService: QRLoginServiceProtocol) { + self.qrLoginService = qrLoginService + super.init(initialViewState: AuthenticationQRLoginDisplayViewState()) + + Task { @MainActor in + let generator = QRCodeGenerator() + let qrData = try await qrLoginService.generateQRCode() + guard let jsonString = qrData.jsonString, + let data = jsonString.data(using: .isoLatin1) else { + return + } + + do { + state.qrImage = try generator.generateCode(from: data, + with: CGSize(width: 240, height: 240), + offColor: .clear) + } catch { + // MXLog.error("[AuthenticationQRLoginDisplayViewModel] failed to generate QR", context: error) + } + } + } + + // MARK: - Public + + override func process(viewAction: AuthenticationQRLoginDisplayViewAction) { + switch viewAction { + case .cancel: + callback?(.cancel) + } + } +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Display/AuthenticationQRLoginDisplayViewModelProtocol.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Display/AuthenticationQRLoginDisplayViewModelProtocol.swift new file mode 100644 index 000000000..eada8791b --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Display/AuthenticationQRLoginDisplayViewModelProtocol.swift @@ -0,0 +1,22 @@ +// +// Copyright 2021 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 + +protocol AuthenticationQRLoginDisplayViewModelProtocol { + var callback: ((AuthenticationQRLoginDisplayViewModelResult) -> Void)? { get set } + var context: AuthenticationQRLoginDisplayViewModelType.Context { get } +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Display/Coordinator/AuthenticationQRLoginDisplayCoordinator.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Display/Coordinator/AuthenticationQRLoginDisplayCoordinator.swift new file mode 100644 index 000000000..3e45357bf --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Display/Coordinator/AuthenticationQRLoginDisplayCoordinator.swift @@ -0,0 +1,103 @@ +// +// Copyright 2021 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 CommonKit +import SwiftUI + +struct AuthenticationQRLoginDisplayCoordinatorParameters { + let navigationRouter: NavigationRouterType + let qrLoginService: QRLoginServiceProtocol +} + +enum AuthenticationQRLoginDisplayCoordinatorResult { + /// Login with QR done + case done +} + +final class AuthenticationQRLoginDisplayCoordinator: Coordinator, Presentable { + // MARK: - Properties + + // MARK: Private + + private let parameters: AuthenticationQRLoginDisplayCoordinatorParameters + private let onboardingQRLoginDisplayHostingController: VectorHostingController + private var onboardingQRLoginDisplayViewModel: AuthenticationQRLoginDisplayViewModelProtocol + + private var indicatorPresenter: UserIndicatorTypePresenterProtocol + private var loadingIndicator: UserIndicator? + private var navigationRouter: NavigationRouterType { parameters.navigationRouter } + + // MARK: Public + + // Must be used only internally + var childCoordinators: [Coordinator] = [] + var callback: ((AuthenticationQRLoginDisplayCoordinatorResult) -> Void)? + + // MARK: - Setup + + init(parameters: AuthenticationQRLoginDisplayCoordinatorParameters) { + self.parameters = parameters + let viewModel = AuthenticationQRLoginDisplayViewModel(qrLoginService: parameters.qrLoginService) + let view = AuthenticationQRLoginDisplayScreen(context: viewModel.context) + onboardingQRLoginDisplayViewModel = viewModel + + onboardingQRLoginDisplayHostingController = VectorHostingController(rootView: view) + onboardingQRLoginDisplayHostingController.vc_removeBackTitle() + onboardingQRLoginDisplayHostingController.enableNavigationBarScrollEdgeAppearance = true + + indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: onboardingQRLoginDisplayHostingController) + } + + // MARK: - Public + + func start() { + MXLog.debug("[AuthenticationQRLoginDisplayCoordinator] did start.") + onboardingQRLoginDisplayViewModel.callback = { [weak self] result in + guard let self = self else { return } + MXLog.debug("[AuthenticationQRLoginDisplayCoordinator] AuthenticationQRLoginDisplayViewModel did complete with result: \(result).") + + switch result { + case .cancel: + self.navigationRouter.popModule(animated: true) + } + } + } + + func toPresentable() -> UIViewController { + onboardingQRLoginDisplayHostingController + } + + /// Stops any ongoing activities in the coordinator. + func stop() { + stopLoading() + } + + // MARK: - Private + + private func showScanQRScreen() { } + + private func showDisplayQRScreen() { } + + /// Show an activity indicator whilst loading. + private func startLoading() { + loadingIndicator = indicatorPresenter.present(.loading(label: VectorL10n.loading, isInteractionBlocking: true)) + } + + /// Hide the currently displayed activity indicator. + private func stopLoading() { + loadingIndicator = nil + } +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Display/MockAuthenticationQRLoginDisplayScreenState.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Display/MockAuthenticationQRLoginDisplayScreenState.swift new file mode 100644 index 000000000..f5802fca1 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Display/MockAuthenticationQRLoginDisplayScreenState.swift @@ -0,0 +1,50 @@ +// +// Copyright 2021 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 +import SwiftUI + +/// Using an enum for the screen allows you define the different state cases with +/// the relevant associated data for each case. +enum MockAuthenticationQRLoginDisplayScreenState: MockScreenState, CaseIterable { + // A case for each state you want to represent + // with specific, minimal associated data that will allow you + // mock that screen. + case `default` + + /// The associated screen + var screenType: Any.Type { + AuthenticationQRLoginDisplayScreen.self + } + + /// A list of screen state definitions + static var allCases: [MockAuthenticationQRLoginDisplayScreenState] { + // Each of the presence statuses + [.default] + } + + /// Generate the view struct for the screen state. + var screenView: ([Any], AnyView) { + let viewModel = AuthenticationQRLoginDisplayViewModel(qrLoginService: MockQRLoginService()) + + // can simulate service and viewModel actions here if needs be. + + return ( + [self, viewModel], + AnyView(AuthenticationQRLoginDisplayScreen(context: viewModel.context)) + ) + } +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Display/Test/UI/AuthenticationQRLoginDisplayUITests.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Display/Test/UI/AuthenticationQRLoginDisplayUITests.swift new file mode 100644 index 000000000..2d3e5ca5b --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Display/Test/UI/AuthenticationQRLoginDisplayUITests.swift @@ -0,0 +1,32 @@ +// +// Copyright 2021 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 RiotSwiftUI +import XCTest + +class AuthenticationQRLoginDisplayUITests: MockScreenTestCase { + func testDefault() { + app.goToScreenWithIdentifier(MockAuthenticationQRLoginDisplayScreenState.default.title) + + XCTAssertTrue(app.staticTexts["titleLabel"].exists) + XCTAssertTrue(app.staticTexts["subtitleLabel"].exists) + XCTAssertTrue(app.images["qrImageView"].exists) + + let displayQRButton = app.buttons["cancelButton"] + XCTAssertTrue(displayQRButton.exists) + XCTAssertTrue(displayQRButton.isEnabled) + } +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Display/Test/Unit/AuthenticationQRLoginDisplayViewModelTests.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Display/Test/Unit/AuthenticationQRLoginDisplayViewModelTests.swift new file mode 100644 index 000000000..fb43aabb0 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Display/Test/Unit/AuthenticationQRLoginDisplayViewModelTests.swift @@ -0,0 +1,41 @@ +// +// Copyright 2021 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 XCTest + +@testable import RiotSwiftUI + +class AuthenticationQRLoginDisplayViewModelTests: XCTestCase { + var viewModel: AuthenticationQRLoginDisplayViewModelProtocol! + var context: AuthenticationQRLoginDisplayViewModelType.Context! + + override func setUpWithError() throws { + viewModel = AuthenticationQRLoginDisplayViewModel(qrLoginService: MockQRLoginService()) + context = viewModel.context + } + + func testCancel() { + var result: AuthenticationQRLoginDisplayViewModelResult? + + viewModel.callback = { callbackResult in + result = callbackResult + } + + context.send(viewAction: .cancel) + + XCTAssertEqual(result, .cancel) + } +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Display/View/AuthenticationQRLoginDisplayScreen.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Display/View/AuthenticationQRLoginDisplayScreen.swift new file mode 100644 index 000000000..a81b4c4ac --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Display/View/AuthenticationQRLoginDisplayScreen.swift @@ -0,0 +1,148 @@ +// +// Copyright 2021 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 SwiftUI + +/// The screen shown to a new user to select their use case for the app. +struct AuthenticationQRLoginDisplayScreen: View { + // MARK: - Properties + + // MARK: Private + + @Environment(\.theme) private var theme + + // MARK: Public + + @ObservedObject var context: AuthenticationQRLoginDisplayViewModel.Context + + var body: some View { + GeometryReader { geometry in + VStack(alignment: .leading, spacing: 0) { + ScrollView { + titleContent + .padding(.top, OnboardingMetrics.topPaddingToNavigationBar) + stepsView + qrView + } + .readableFrame() + + footerContent + .padding(.bottom, geometry.safeAreaInsets.bottom > 0 ? 20 : 36) + } + .padding(.horizontal, 16) + } + .background(theme.colors.background.ignoresSafeArea()) + } + + /// The screen's title and instructions. + var titleContent: some View { + VStack(spacing: 24) { + Text(VectorL10n.authenticationQrLoginDisplayTitle) + .font(theme.fonts.title2B) + .multilineTextAlignment(.center) + .foregroundColor(theme.colors.primaryContent) + .accessibilityIdentifier("titleLabel") + + Text(VectorL10n.authenticationQrLoginDisplaySubtitle) + .font(theme.fonts.body) + .multilineTextAlignment(.center) + .foregroundColor(theme.colors.secondaryContent) + .padding(.bottom, 24) + .accessibilityIdentifier("subtitleLabel") + } + } + + /// The screen's footer. + var footerContent: some View { + VStack(spacing: 8) { + Button(action: cancel) { + Text(VectorL10n.cancel) + } + .buttonStyle(SecondaryActionButtonStyle(font: theme.fonts.bodySB)) + .accessibilityIdentifier("cancelButton") + } + } + + /// The buttons used to select a use case for the app. + var stepsView: some View { + VStack(alignment: .leading, spacing: 12) { + ForEach(steps) { step in + HStack { + Text(String(step.id)) + .font(theme.fonts.caption2SB) + .foregroundColor(theme.colors.accent) + .padding(6) + .shapedBorder(color: theme.colors.accent, borderWidth: 1, shape: Circle()) + .offset(x: 1, y: 0) + Text(step.description) + .foregroundColor(theme.colors.primaryContent) + .font(theme.fonts.subheadline) + Spacer() + } + } + } + } + + @ViewBuilder + var qrView: some View { + if let qrImage = context.viewState.qrImage { + VStack { + Image(uiImage: qrImage) + .resizable() + .renderingMode(.template) + .foregroundColor(theme.colors.primaryContent) + .scaledToFit() + .accessibilityIdentifier("qrImageView") + } + .aspectRatio(1, contentMode: .fit) + .shapedBorder(color: theme.colors.quinaryContent, + borderWidth: 1, + shape: RoundedRectangle(cornerRadius: 8)) + .padding(1) + .padding(.top, 16) + } + } + + private let steps = [ + QRLoginDisplayStep(id: 1, description: VectorL10n.authenticationQrLoginDisplayStep1), + QRLoginDisplayStep(id: 2, description: VectorL10n.authenticationQrLoginDisplayStep2) + ] + + /// Sends the `cancel` view action. + func cancel() { + context.send(viewAction: .cancel) + } +} + +// MARK: - Previews + +struct AuthenticationQRLoginDisplay_Previews: PreviewProvider { + static let stateRenderer = MockAuthenticationQRLoginDisplayScreenState.stateRenderer + + static var previews: some View { + stateRenderer.screenGroup(addNavigation: true) + .theme(.light).preferredColorScheme(.light) + .navigationViewStyle(.stack) + stateRenderer.screenGroup(addNavigation: true) + .theme(.dark).preferredColorScheme(.dark) + .navigationViewStyle(.stack) + } +} + +private struct QRLoginDisplayStep: Identifiable { + let id: Int + let description: String +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Failure/AuthenticationQRLoginFailureModels.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Failure/AuthenticationQRLoginFailureModels.swift new file mode 100644 index 000000000..5395facdd --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Failure/AuthenticationQRLoginFailureModels.swift @@ -0,0 +1,38 @@ +// +// Copyright 2021 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 + +// MARK: - Coordinator + +// MARK: View model + +enum AuthenticationQRLoginFailureViewModelResult { + case retry + case cancel +} + +// MARK: View + +struct AuthenticationQRLoginFailureViewState: BindableState { + var retryButtonVisible: Bool + var failureText: String? +} + +enum AuthenticationQRLoginFailureViewAction { + case retry + case cancel +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Failure/AuthenticationQRLoginFailureViewModel.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Failure/AuthenticationQRLoginFailureViewModel.swift new file mode 100644 index 000000000..0e363e549 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Failure/AuthenticationQRLoginFailureViewModel.swift @@ -0,0 +1,82 @@ +// +// Copyright 2021 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 SwiftUI + +typealias AuthenticationQRLoginFailureViewModelType = StateStoreViewModel + +class AuthenticationQRLoginFailureViewModel: AuthenticationQRLoginFailureViewModelType, AuthenticationQRLoginFailureViewModelProtocol { + // MARK: - Properties + + // MARK: Private + + private let qrLoginService: QRLoginServiceProtocol + + // MARK: Public + + var callback: ((AuthenticationQRLoginFailureViewModelResult) -> Void)? + + // MARK: - Setup + + init(qrLoginService: QRLoginServiceProtocol) { + self.qrLoginService = qrLoginService + super.init(initialViewState: AuthenticationQRLoginFailureViewState(retryButtonVisible: false)) + + updateFailureText(for: qrLoginService.state) + qrLoginService.callbacks.sink { [weak self] callback in + guard let self = self else { return } + switch callback { + case .didUpdateState: + self.updateFailureText(for: qrLoginService.state) + default: + break + } + } + .store(in: &cancellables) + } + + private func updateFailureText(for state: QRLoginServiceState) { + switch state { + case .failed(let error): + switch error { + case .invalidQR: + self.state.failureText = VectorL10n.authenticationQrLoginFailureInvalidQr + self.state.retryButtonVisible = true + case .requestDenied: + self.state.failureText = VectorL10n.authenticationQrLoginFailureRequestDenied + self.state.retryButtonVisible = false + case .requestTimedOut: + self.state.failureText = VectorL10n.authenticationQrLoginFailureRequestTimedOut + self.state.retryButtonVisible = true + default: + break + } + default: + break + } + } + + // MARK: - Public + + override func process(viewAction: AuthenticationQRLoginFailureViewAction) { + switch viewAction { + case .retry: + callback?(.retry) + case .cancel: + callback?(.cancel) + } + } +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Failure/AuthenticationQRLoginFailureViewModelProtocol.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Failure/AuthenticationQRLoginFailureViewModelProtocol.swift new file mode 100644 index 000000000..13955611f --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Failure/AuthenticationQRLoginFailureViewModelProtocol.swift @@ -0,0 +1,22 @@ +// +// Copyright 2021 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 + +protocol AuthenticationQRLoginFailureViewModelProtocol { + var callback: ((AuthenticationQRLoginFailureViewModelResult) -> Void)? { get set } + var context: AuthenticationQRLoginFailureViewModelType.Context { get } +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Failure/Coordinator/AuthenticationQRLoginFailureCoordinator.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Failure/Coordinator/AuthenticationQRLoginFailureCoordinator.swift new file mode 100644 index 000000000..88d7ba391 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Failure/Coordinator/AuthenticationQRLoginFailureCoordinator.swift @@ -0,0 +1,103 @@ +// +// Copyright 2021 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 CommonKit +import SwiftUI + +struct AuthenticationQRLoginFailureCoordinatorParameters { + let navigationRouter: NavigationRouterType + let qrLoginService: QRLoginServiceProtocol +} + +enum AuthenticationQRLoginFailureCoordinatorResult { + /// Login with QR done + case done +} + +final class AuthenticationQRLoginFailureCoordinator: Coordinator, Presentable { + // MARK: - Properties + + // MARK: Private + + private let parameters: AuthenticationQRLoginFailureCoordinatorParameters + private let onboardingQRLoginFailureHostingController: VectorHostingController + private var onboardingQRLoginFailureViewModel: AuthenticationQRLoginFailureViewModelProtocol + + private var indicatorPresenter: UserIndicatorTypePresenterProtocol + private var loadingIndicator: UserIndicator? + + private var navigationRouter: NavigationRouterType { parameters.navigationRouter } + private var qrLoginService: QRLoginServiceProtocol { parameters.qrLoginService } + + // MARK: Public + + // Must be used only internally + var childCoordinators: [Coordinator] = [] + var callback: ((AuthenticationQRLoginFailureCoordinatorResult) -> Void)? + + // MARK: - Setup + + init(parameters: AuthenticationQRLoginFailureCoordinatorParameters) { + self.parameters = parameters + let viewModel = AuthenticationQRLoginFailureViewModel(qrLoginService: parameters.qrLoginService) + let view = AuthenticationQRLoginFailureScreen(context: viewModel.context) + onboardingQRLoginFailureViewModel = viewModel + + onboardingQRLoginFailureHostingController = VectorHostingController(rootView: view) + onboardingQRLoginFailureHostingController.vc_removeBackTitle() + onboardingQRLoginFailureHostingController.enableNavigationBarScrollEdgeAppearance = true + + indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: onboardingQRLoginFailureHostingController) + } + + // MARK: - Public + + func start() { + MXLog.debug("[AuthenticationQRLoginFailureCoordinator] did start.") + onboardingQRLoginFailureViewModel.callback = { [weak self] result in + guard let self = self else { return } + MXLog.debug("[AuthenticationQRLoginFailureCoordinator] AuthenticationQRLoginFailureViewModel did complete with result: \(result).") + + switch result { + case .retry: + self.qrLoginService.restart() + case .cancel: + self.qrLoginService.reset() + } + } + } + + func toPresentable() -> UIViewController { + onboardingQRLoginFailureHostingController + } + + /// Stops any ongoing activities in the coordinator. + func stop() { + stopFailure() + } + + // MARK: - Private + + /// Show an activity indicator whilst loading. + private func startFailure() { + loadingIndicator = indicatorPresenter.present(.loading(label: VectorL10n.loading, isInteractionBlocking: true)) + } + + /// Hide the currently displayed activity indicator. + private func stopFailure() { + loadingIndicator = nil + } +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Failure/MockAuthenticationQRLoginFailureScreenState.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Failure/MockAuthenticationQRLoginFailureScreenState.swift new file mode 100644 index 000000000..5747c86bb --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Failure/MockAuthenticationQRLoginFailureScreenState.swift @@ -0,0 +1,61 @@ +// +// Copyright 2021 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 +import SwiftUI + +/// Using an enum for the screen allows you define the different state cases with +/// the relevant associated data for each case. +enum MockAuthenticationQRLoginFailureScreenState: MockScreenState, CaseIterable { + // A case for each state you want to represent + // with specific, minimal associated data that will allow you + // mock that screen. + case invalidQR + case requestDenied + case requestTimedOut + + /// The associated screen + var screenType: Any.Type { + AuthenticationQRLoginFailureScreen.self + } + + /// A list of screen state definitions + static var allCases: [MockAuthenticationQRLoginFailureScreenState] { + // Each of the presence statuses + [.invalidQR, .requestDenied, .requestTimedOut] + } + + /// Generate the view struct for the screen state. + var screenView: ([Any], AnyView) { + let viewModel: AuthenticationQRLoginFailureViewModel + + switch self { + case .invalidQR: + viewModel = .init(qrLoginService: MockQRLoginService(withState: .failed(error: .invalidQR))) + case .requestDenied: + viewModel = .init(qrLoginService: MockQRLoginService(withState: .failed(error: .requestDenied))) + case .requestTimedOut: + viewModel = .init(qrLoginService: MockQRLoginService(withState: .failed(error: .requestTimedOut))) + } + + // can simulate service and viewModel actions here if needs be. + + return ( + [self, viewModel], + AnyView(AuthenticationQRLoginFailureScreen(context: viewModel.context)) + ) + } +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Failure/Test/UI/AuthenticationQRLoginFailureUITests.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Failure/Test/UI/AuthenticationQRLoginFailureUITests.swift new file mode 100644 index 000000000..829349d78 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Failure/Test/UI/AuthenticationQRLoginFailureUITests.swift @@ -0,0 +1,61 @@ +// +// Copyright 2021 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 RiotSwiftUI +import XCTest + +class AuthenticationQRLoginFailureUITests: MockScreenTestCase { + func testInvalidQR() { + app.goToScreenWithIdentifier(MockAuthenticationQRLoginFailureScreenState.invalidQR.title) + + XCTAssertTrue(app.staticTexts["failureLabel"].exists) + + let retryButton = app.buttons["retryButton"] + XCTAssertTrue(retryButton.exists) + XCTAssertTrue(retryButton.isEnabled) + + let cancelButton = app.buttons["cancelButton"] + XCTAssertTrue(cancelButton.exists) + XCTAssertTrue(cancelButton.isEnabled) + } + + func testRequestDenied() { + app.goToScreenWithIdentifier(MockAuthenticationQRLoginFailureScreenState.requestDenied.title) + + XCTAssertTrue(app.staticTexts["failureLabel"].exists) + + let retryButton = app.buttons["retryButton"] + XCTAssertFalse(retryButton.exists) + + let cancelButton = app.buttons["cancelButton"] + XCTAssertTrue(cancelButton.exists) + XCTAssertTrue(cancelButton.isEnabled) + } + + func testRequestTimedOut() { + app.goToScreenWithIdentifier(MockAuthenticationQRLoginFailureScreenState.requestTimedOut.title) + + XCTAssertTrue(app.staticTexts["failureLabel"].exists) + + let retryButton = app.buttons["retryButton"] + XCTAssertTrue(retryButton.exists) + XCTAssertTrue(retryButton.isEnabled) + + let cancelButton = app.buttons["cancelButton"] + XCTAssertTrue(cancelButton.exists) + XCTAssertTrue(cancelButton.isEnabled) + } +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Failure/Test/Unit/AuthenticationQRLoginFailureViewModelTests.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Failure/Test/Unit/AuthenticationQRLoginFailureViewModelTests.swift new file mode 100644 index 000000000..e5cb4e5c1 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Failure/Test/Unit/AuthenticationQRLoginFailureViewModelTests.swift @@ -0,0 +1,53 @@ +// +// Copyright 2021 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 XCTest + +@testable import RiotSwiftUI + +class AuthenticationQRLoginFailureViewModelTests: XCTestCase { + var viewModel: AuthenticationQRLoginFailureViewModelProtocol! + var context: AuthenticationQRLoginFailureViewModelType.Context! + + override func setUpWithError() throws { + viewModel = AuthenticationQRLoginFailureViewModel(qrLoginService: MockQRLoginService(withState: .failed(error: .requestTimedOut))) + context = viewModel.context + } + + func testRetry() { + var result: AuthenticationQRLoginFailureViewModelResult? + + viewModel.callback = { callbackResult in + result = callbackResult + } + + context.send(viewAction: .retry) + + XCTAssertEqual(result, .retry) + } + + func testCancel() { + var result: AuthenticationQRLoginFailureViewModelResult? + + viewModel.callback = { callbackResult in + result = callbackResult + } + + context.send(viewAction: .cancel) + + XCTAssertEqual(result, .cancel) + } +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Failure/View/AuthenticationQRLoginFailureScreen.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Failure/View/AuthenticationQRLoginFailureScreen.swift new file mode 100644 index 000000000..16488fe41 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Failure/View/AuthenticationQRLoginFailureScreen.swift @@ -0,0 +1,124 @@ +// +// Copyright 2021 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 SwiftUI + +/// The screen shown to a new user to select their use case for the app. +struct AuthenticationQRLoginFailureScreen: View { + // MARK: - Properties + + // MARK: Private + + @Environment(\.theme) private var theme + @ScaledMetric private var iconSize = 70.0 + + // MARK: Public + + @ObservedObject var context: AuthenticationQRLoginFailureViewModel.Context + + var body: some View { + GeometryReader { geometry in + VStack(alignment: .leading, spacing: 0) { + ScrollView { + titleContent + .padding(.top, OnboardingMetrics.topPaddingToNavigationBar) + } + .readableFrame() + + footerContent + .padding(.bottom, geometry.safeAreaInsets.bottom > 0 ? 20 : 36) + } + .padding(.horizontal, 16) + } + .background(theme.colors.background.ignoresSafeArea()) + } + + /// The screen's title and instructions. + var titleContent: some View { + VStack(spacing: 16) { + ZStack { + Circle() + .fill(theme.colors.alert) + Image(Asset.Images.exclamationCircle.name) + .resizable() + .renderingMode(.template) + .foregroundColor(.white) + .aspectRatio(1.0, contentMode: .fit) + .padding(15) + } + .frame(width: iconSize, height: iconSize) + .padding(.bottom, 16) + + Text(VectorL10n.authenticationQrLoginFailureTitle) + .font(theme.fonts.title3SB) + .multilineTextAlignment(.center) + .foregroundColor(theme.colors.primaryContent) + .accessibilityIdentifier("titleLabel") + + if let failureText = context.viewState.failureText { + Text(failureText) + .font(theme.fonts.body) + .multilineTextAlignment(.center) + .foregroundColor(theme.colors.secondaryContent) + .accessibilityIdentifier("failureLabel") + } + } + } + + /// The screen's footer. + var footerContent: some View { + VStack(spacing: 16) { + if context.viewState.retryButtonVisible { + Button(action: retry) { + Text(VectorL10n.authenticationQrLoginFailureRetry) + } + .buttonStyle(PrimaryActionButtonStyle(font: theme.fonts.bodySB)) + .accessibilityIdentifier("retryButton") + } + + Button(action: cancel) { + Text(VectorL10n.cancel) + } + .buttonStyle(SecondaryActionButtonStyle(font: theme.fonts.bodySB)) + .accessibilityIdentifier("cancelButton") + } + } + + /// Sends the `retry` view action. + func retry() { + context.send(viewAction: .retry) + } + + /// Sends the `cancel` view action. + func cancel() { + context.send(viewAction: .cancel) + } +} + +// MARK: - Previews + +struct AuthenticationQRLoginFailure_Previews: PreviewProvider { + static let stateRenderer = MockAuthenticationQRLoginFailureScreenState.stateRenderer + + static var previews: some View { + stateRenderer.screenGroup(addNavigation: true) + .theme(.light).preferredColorScheme(.light) + .navigationViewStyle(.stack) + stateRenderer.screenGroup(addNavigation: true) + .theme(.dark).preferredColorScheme(.dark) + .navigationViewStyle(.stack) + } +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Loading/AuthenticationQRLoginLoadingModels.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Loading/AuthenticationQRLoginLoadingModels.swift new file mode 100644 index 000000000..3ba87311c --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Loading/AuthenticationQRLoginLoadingModels.swift @@ -0,0 +1,35 @@ +// +// Copyright 2021 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 + +// MARK: - Coordinator + +// MARK: View model + +enum AuthenticationQRLoginLoadingViewModelResult { + case cancel +} + +// MARK: View + +struct AuthenticationQRLoginLoadingViewState: BindableState { + var loadingText: String? +} + +enum AuthenticationQRLoginLoadingViewAction { + case cancel +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Loading/AuthenticationQRLoginLoadingViewModel.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Loading/AuthenticationQRLoginLoadingViewModel.swift new file mode 100644 index 000000000..e49032c1d --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Loading/AuthenticationQRLoginLoadingViewModel.swift @@ -0,0 +1,72 @@ +// +// Copyright 2021 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 SwiftUI + +typealias AuthenticationQRLoginLoadingViewModelType = StateStoreViewModel + +class AuthenticationQRLoginLoadingViewModel: AuthenticationQRLoginLoadingViewModelType, AuthenticationQRLoginLoadingViewModelProtocol { + // MARK: - Properties + + // MARK: Private + + private let qrLoginService: QRLoginServiceProtocol + + // MARK: Public + + var callback: ((AuthenticationQRLoginLoadingViewModelResult) -> Void)? + + // MARK: - Setup + + init(qrLoginService: QRLoginServiceProtocol) { + self.qrLoginService = qrLoginService + super.init(initialViewState: AuthenticationQRLoginLoadingViewState()) + + updateLoadingText(for: qrLoginService.state) + qrLoginService.callbacks.sink { [weak self] callback in + guard let self = self else { return } + switch callback { + case .didUpdateState: + self.updateLoadingText(for: qrLoginService.state) + default: + break + } + } + .store(in: &cancellables) + } + + private func updateLoadingText(for state: QRLoginServiceState) { + switch state { + case .connectingToDevice: + self.state.loadingText = VectorL10n.authenticationQrLoginLoadingConnectingDevice + case .waitingForRemoteSignIn: + self.state.loadingText = VectorL10n.authenticationQrLoginLoadingWaitingSignin + case .completed: + self.state.loadingText = VectorL10n.authenticationQrLoginLoadingSignedIn + default: + break + } + } + + // MARK: - Public + + override func process(viewAction: AuthenticationQRLoginLoadingViewAction) { + switch viewAction { + case .cancel: + callback?(.cancel) + } + } +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Loading/AuthenticationQRLoginLoadingViewModelProtocol.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Loading/AuthenticationQRLoginLoadingViewModelProtocol.swift new file mode 100644 index 000000000..392dfb36b --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Loading/AuthenticationQRLoginLoadingViewModelProtocol.swift @@ -0,0 +1,22 @@ +// +// Copyright 2021 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 + +protocol AuthenticationQRLoginLoadingViewModelProtocol { + var callback: ((AuthenticationQRLoginLoadingViewModelResult) -> Void)? { get set } + var context: AuthenticationQRLoginLoadingViewModelType.Context { get } +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Loading/Coordinator/AuthenticationQRLoginLoadingCoordinator.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Loading/Coordinator/AuthenticationQRLoginLoadingCoordinator.swift new file mode 100644 index 000000000..e518e93d4 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Loading/Coordinator/AuthenticationQRLoginLoadingCoordinator.swift @@ -0,0 +1,101 @@ +// +// Copyright 2021 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 CommonKit +import SwiftUI + +struct AuthenticationQRLoginLoadingCoordinatorParameters { + let navigationRouter: NavigationRouterType + let qrLoginService: QRLoginServiceProtocol +} + +enum AuthenticationQRLoginLoadingCoordinatorResult { + /// Login with QR done + case done +} + +final class AuthenticationQRLoginLoadingCoordinator: Coordinator, Presentable { + // MARK: - Properties + + // MARK: Private + + private let parameters: AuthenticationQRLoginLoadingCoordinatorParameters + private let onboardingQRLoginLoadingHostingController: VectorHostingController + private var onboardingQRLoginLoadingViewModel: AuthenticationQRLoginLoadingViewModelProtocol + + private var indicatorPresenter: UserIndicatorTypePresenterProtocol + private var loadingIndicator: UserIndicator? + + private var navigationRouter: NavigationRouterType { parameters.navigationRouter } + private var qrLoginService: QRLoginServiceProtocol { parameters.qrLoginService } + + // MARK: Public + + // Must be used only internally + var childCoordinators: [Coordinator] = [] + var callback: ((AuthenticationQRLoginLoadingCoordinatorResult) -> Void)? + + // MARK: - Setup + + init(parameters: AuthenticationQRLoginLoadingCoordinatorParameters) { + self.parameters = parameters + let viewModel = AuthenticationQRLoginLoadingViewModel(qrLoginService: parameters.qrLoginService) + let view = AuthenticationQRLoginLoadingScreen(context: viewModel.context) + onboardingQRLoginLoadingViewModel = viewModel + + onboardingQRLoginLoadingHostingController = VectorHostingController(rootView: view) + onboardingQRLoginLoadingHostingController.vc_removeBackTitle() + onboardingQRLoginLoadingHostingController.enableNavigationBarScrollEdgeAppearance = true + + indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: onboardingQRLoginLoadingHostingController) + } + + // MARK: - Public + + func start() { + MXLog.debug("[AuthenticationQRLoginLoadingCoordinator] did start.") + onboardingQRLoginLoadingViewModel.callback = { [weak self] result in + guard let self = self else { return } + MXLog.debug("[AuthenticationQRLoginLoadingCoordinator] AuthenticationQRLoginLoadingViewModel did complete with result: \(result).") + + switch result { + case .cancel: + self.qrLoginService.reset() + } + } + } + + func toPresentable() -> UIViewController { + onboardingQRLoginLoadingHostingController + } + + /// Stops any ongoing activities in the coordinator. + func stop() { + stopLoading() + } + + // MARK: - Private + + /// Show an activity indicator whilst loading. + private func startLoading() { + loadingIndicator = indicatorPresenter.present(.loading(label: VectorL10n.loading, isInteractionBlocking: true)) + } + + /// Hide the currently displayed activity indicator. + private func stopLoading() { + loadingIndicator = nil + } +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Loading/MockAuthenticationQRLoginLoadingScreenState.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Loading/MockAuthenticationQRLoginLoadingScreenState.swift new file mode 100644 index 000000000..6bf6cbab6 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Loading/MockAuthenticationQRLoginLoadingScreenState.swift @@ -0,0 +1,61 @@ +// +// Copyright 2021 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 +import SwiftUI + +/// Using an enum for the screen allows you define the different state cases with +/// the relevant associated data for each case. +enum MockAuthenticationQRLoginLoadingScreenState: MockScreenState, CaseIterable { + // A case for each state you want to represent + // with specific, minimal associated data that will allow you + // mock that screen. + case connectingToDevice + case waitingForRemoteSignIn + case completed + + /// The associated screen + var screenType: Any.Type { + AuthenticationQRLoginLoadingScreen.self + } + + /// A list of screen state definitions + static var allCases: [MockAuthenticationQRLoginLoadingScreenState] { + // Each of the presence statuses + [.connectingToDevice, .waitingForRemoteSignIn, .completed] + } + + /// Generate the view struct for the screen state. + var screenView: ([Any], AnyView) { + let viewModel: AuthenticationQRLoginLoadingViewModel + + switch self { + case .connectingToDevice: + viewModel = .init(qrLoginService: MockQRLoginService(withState: .connectingToDevice)) + case .waitingForRemoteSignIn: + viewModel = .init(qrLoginService: MockQRLoginService(withState: .waitingForRemoteSignIn)) + case .completed: + viewModel = .init(qrLoginService: MockQRLoginService(withState: .completed)) + } + + // can simulate service and viewModel actions here if needs be. + + return ( + [self, viewModel], + AnyView(AuthenticationQRLoginLoadingScreen(context: viewModel.context)) + ) + } +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Loading/Test/UI/AuthenticationQRLoginLoadingUITests.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Loading/Test/UI/AuthenticationQRLoginLoadingUITests.swift new file mode 100644 index 000000000..29da264ad --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Loading/Test/UI/AuthenticationQRLoginLoadingUITests.swift @@ -0,0 +1,30 @@ +// +// Copyright 2021 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 RiotSwiftUI +import XCTest + +class AuthenticationQRLoginLoadingUITests: MockScreenTestCase { + func testCommon() { + app.goToScreenWithIdentifier(MockAuthenticationQRLoginLoadingScreenState.connectingToDevice.title) + + XCTAssertTrue(app.staticTexts["loadingLabel"].exists) + + let cancelButton = app.buttons["cancelButton"] + XCTAssertTrue(cancelButton.exists) + XCTAssertTrue(cancelButton.isEnabled) + } +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Loading/Test/Unit/AuthenticationQRLoginLoadingViewModelTests.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Loading/Test/Unit/AuthenticationQRLoginLoadingViewModelTests.swift new file mode 100644 index 000000000..e2bf22d3b --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Loading/Test/Unit/AuthenticationQRLoginLoadingViewModelTests.swift @@ -0,0 +1,41 @@ +// +// Copyright 2021 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 XCTest + +@testable import RiotSwiftUI + +class AuthenticationQRLoginLoadingViewModelTests: XCTestCase { + var viewModel: AuthenticationQRLoginLoadingViewModelProtocol! + var context: AuthenticationQRLoginLoadingViewModelType.Context! + + override func setUpWithError() throws { + viewModel = AuthenticationQRLoginLoadingViewModel(qrLoginService: MockQRLoginService(withState: .connectingToDevice)) + context = viewModel.context + } + + func testCancel() { + var result: AuthenticationQRLoginLoadingViewModelResult? + + viewModel.callback = { callbackResult in + result = callbackResult + } + + context.send(viewAction: .cancel) + + XCTAssertEqual(result, .cancel) + } +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Loading/View/AuthenticationQRLoginLoadingScreen.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Loading/View/AuthenticationQRLoginLoadingScreen.swift new file mode 100644 index 000000000..d2c4193c5 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Loading/View/AuthenticationQRLoginLoadingScreen.swift @@ -0,0 +1,97 @@ +// +// Copyright 2021 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 SwiftUI + +/// The screen shown to a new user to select their use case for the app. +struct AuthenticationQRLoginLoadingScreen: View { + // MARK: - Properties + + // MARK: Private + + @Environment(\.theme) private var theme + + // MARK: Public + + @ObservedObject var context: AuthenticationQRLoginLoadingViewModel.Context + + var body: some View { + GeometryReader { geometry in + VStack(alignment: .leading, spacing: 0) { + ScrollView { + loadingText + .padding(.top, 60) + loader + } + .readableFrame() + + footerContent + .padding(.bottom, geometry.safeAreaInsets.bottom > 0 ? 20 : 36) + } + .padding(.horizontal, 16) + } + .background(theme.colors.background.ignoresSafeArea()) + } + + @ViewBuilder + var loadingText: some View { + if let code = context.viewState.loadingText { + Text(code) + .multilineTextAlignment(.center) + .font(theme.fonts.body) + .foregroundColor(theme.colors.primaryContent) + .accessibilityIdentifier("loadingLabel") + } + } + + @ViewBuilder + var loader: some View { + ProgressView() + .padding(.top, 64) + .accessibilityIdentifier("loader") + } + + /// The screen's footer. + var footerContent: some View { + VStack(spacing: 8) { + Button(action: cancel) { + Text(VectorL10n.cancel) + } + .buttonStyle(SecondaryActionButtonStyle(font: theme.fonts.bodySB)) + .accessibilityIdentifier("cancelButton") + } + } + + /// Sends the `cancel` view action. + func cancel() { + context.send(viewAction: .cancel) + } +} + +// MARK: - Previews + +struct AuthenticationQRLoginLoading_Previews: PreviewProvider { + static let stateRenderer = MockAuthenticationQRLoginLoadingScreenState.stateRenderer + + static var previews: some View { + stateRenderer.screenGroup(addNavigation: true) + .theme(.light).preferredColorScheme(.light) + .navigationViewStyle(.stack) + stateRenderer.screenGroup(addNavigation: true) + .theme(.dark).preferredColorScheme(.dark) + .navigationViewStyle(.stack) + } +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Scan/AuthenticationQRLoginScanModels.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Scan/AuthenticationQRLoginScanModels.swift new file mode 100644 index 000000000..e668aa23c --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Scan/AuthenticationQRLoginScanModels.swift @@ -0,0 +1,53 @@ +// +// Copyright 2021 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 +import SwiftUI + +// MARK: - Coordinator + +// MARK: View model + +enum AuthenticationQRLoginScanViewModelResult: Equatable { + case goToSettings + case displayQR + case qrScanned(Data) + + static func == (lhs: AuthenticationQRLoginScanViewModelResult, rhs: AuthenticationQRLoginScanViewModelResult) -> Bool { + switch (lhs, rhs) { + case (.goToSettings, .goToSettings): + return true + case (.displayQR, .displayQR): + return true + case (let .qrScanned(data1), let .qrScanned(data2)): + return data1 == data2 + default: + return false + } + } +} + +// MARK: View + +struct AuthenticationQRLoginScanViewState: BindableState { + var serviceState: QRLoginServiceState + var scannerView: AnyView? +} + +enum AuthenticationQRLoginScanViewAction { + case goToSettings + case displayQR +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Scan/AuthenticationQRLoginScanViewModel.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Scan/AuthenticationQRLoginScanViewModel.swift new file mode 100644 index 000000000..255fe55bd --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Scan/AuthenticationQRLoginScanViewModel.swift @@ -0,0 +1,75 @@ +// +// Copyright 2021 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 Combine +import SwiftUI + +typealias AuthenticationQRLoginScanViewModelType = StateStoreViewModel + +class AuthenticationQRLoginScanViewModel: AuthenticationQRLoginScanViewModelType, AuthenticationQRLoginScanViewModelProtocol { + // MARK: - Properties + + // MARK: Private + + private let qrLoginService: QRLoginServiceProtocol + + // MARK: Public + + var callback: ((AuthenticationQRLoginScanViewModelResult) -> Void)? + + // MARK: - Setup + + init(qrLoginService: QRLoginServiceProtocol) { + self.qrLoginService = qrLoginService + super.init(initialViewState: AuthenticationQRLoginScanViewState(serviceState: .initial)) + + qrLoginService.callbacks.sink { callback in + switch callback { + case .didUpdateState: + self.processServiceState(qrLoginService.state) + case .didScanQR(let data): + self.callback?(.qrScanned(data)) + } + } + .store(in: &cancellables) + + processServiceState(qrLoginService.state) + qrLoginService.startScanning() + } + + // MARK: - Public + + override func process(viewAction: AuthenticationQRLoginScanViewAction) { + switch viewAction { + case .goToSettings: + callback?(.goToSettings) + case .displayQR: + callback?(.displayQR) + } + } + + // MARK: - Private + + private func processServiceState(_ state: QRLoginServiceState) { + switch state { + case .scanningQR: + self.state.scannerView = qrLoginService.scannerView() + default: + break + } + self.state.serviceState = state + } +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Scan/AuthenticationQRLoginScanViewModelProtocol.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Scan/AuthenticationQRLoginScanViewModelProtocol.swift new file mode 100644 index 000000000..dbe36c270 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Scan/AuthenticationQRLoginScanViewModelProtocol.swift @@ -0,0 +1,22 @@ +// +// Copyright 2021 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 + +protocol AuthenticationQRLoginScanViewModelProtocol { + var callback: ((AuthenticationQRLoginScanViewModelResult) -> Void)? { get set } + var context: AuthenticationQRLoginScanViewModelType.Context { get } +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Scan/Coordinator/AuthenticationQRLoginScanCoordinator.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Scan/Coordinator/AuthenticationQRLoginScanCoordinator.swift new file mode 100644 index 000000000..15ec5c728 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Scan/Coordinator/AuthenticationQRLoginScanCoordinator.swift @@ -0,0 +1,130 @@ +// +// Copyright 2021 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 CommonKit +import SwiftUI + +struct AuthenticationQRLoginScanCoordinatorParameters { + let navigationRouter: NavigationRouterType + let qrLoginService: QRLoginServiceProtocol +} + +enum AuthenticationQRLoginScanCoordinatorResult { + /// Login with QR done + case done +} + +final class AuthenticationQRLoginScanCoordinator: Coordinator, Presentable { + // MARK: - Properties + + // MARK: Private + + private let parameters: AuthenticationQRLoginScanCoordinatorParameters + private let onboardingQRLoginScanHostingController: VectorHostingController + private var onboardingQRLoginScanViewModel: AuthenticationQRLoginScanViewModelProtocol + + private var indicatorPresenter: UserIndicatorTypePresenterProtocol + private var loadingIndicator: UserIndicator? + + private var navigationRouter: NavigationRouterType { parameters.navigationRouter } + private var qrLoginService: QRLoginServiceProtocol { parameters.qrLoginService } + + // MARK: Public + + // Must be used only internally + var childCoordinators: [Coordinator] = [] + var callback: ((AuthenticationQRLoginScanCoordinatorResult) -> Void)? + + // MARK: - Setup + + init(parameters: AuthenticationQRLoginScanCoordinatorParameters) { + self.parameters = parameters + let viewModel = AuthenticationQRLoginScanViewModel(qrLoginService: parameters.qrLoginService) + let view = AuthenticationQRLoginScanScreen(context: viewModel.context) + onboardingQRLoginScanViewModel = viewModel + + onboardingQRLoginScanHostingController = VectorHostingController(rootView: view) + onboardingQRLoginScanHostingController.vc_removeBackTitle() + onboardingQRLoginScanHostingController.enableNavigationBarScrollEdgeAppearance = true + + indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: onboardingQRLoginScanHostingController) + } + + // MARK: - Public + + func start() { + MXLog.debug("[AuthenticationQRLoginScanCoordinator] did start.") + onboardingQRLoginScanViewModel.callback = { [weak self] result in + guard let self = self else { return } + MXLog.debug("[AuthenticationQRLoginScanCoordinator] AuthenticationQRLoginScanViewModel did complete with result: \(result).") + + switch result { + case .goToSettings: + self.goToSettings() + case .displayQR: + self.showDisplayQRScreen() + case .qrScanned(let data): + self.qrLoginService.stopScanning(destroy: false) + self.qrLoginService.processScannedQR(data) + } + } + } + + func toPresentable() -> UIViewController { + onboardingQRLoginScanHostingController + } + + /// Stops any ongoing activities in the coordinator. + func stop() { + stopLoading() + } + + // MARK: - Private + + private func goToSettings() { + UIApplication.shared.vc_openSettings() + } + + /// Shows the display QR screen. + private func showDisplayQRScreen() { + MXLog.debug("[AuthenticationQRLoginScanCoordinator] showDisplayQRScreen") + + let parameters = AuthenticationQRLoginDisplayCoordinatorParameters(navigationRouter: navigationRouter, + qrLoginService: qrLoginService) + let coordinator = AuthenticationQRLoginDisplayCoordinator(parameters: parameters) + coordinator.callback = { [weak self, weak coordinator] _ in + guard let self = self, let coordinator = coordinator else { return } + self.remove(childCoordinator: coordinator) + } + + coordinator.start() + add(childCoordinator: coordinator) + + navigationRouter.push(coordinator, animated: true) { [weak self] in + self?.remove(childCoordinator: coordinator) + } + } + + /// Show an activity indicator whilst loading. + private func startLoading() { + loadingIndicator = indicatorPresenter.present(.loading(label: VectorL10n.loading, isInteractionBlocking: true)) + } + + /// Hide the currently displayed activity indicator. + private func stopLoading() { + loadingIndicator = nil + } +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Scan/MockAuthenticationQRLoginScanScreenState.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Scan/MockAuthenticationQRLoginScanScreenState.swift new file mode 100644 index 000000000..bcd59cc3c --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Scan/MockAuthenticationQRLoginScanScreenState.swift @@ -0,0 +1,61 @@ +// +// Copyright 2021 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 +import SwiftUI + +/// Using an enum for the screen allows you define the different state cases with +/// the relevant associated data for each case. +enum MockAuthenticationQRLoginScanScreenState: MockScreenState, CaseIterable { + // A case for each state you want to represent + // with specific, minimal associated data that will allow you + // mock that screen. + case scanning + case noCameraAvailable + case noCameraAccess + + /// The associated screen + var screenType: Any.Type { + AuthenticationQRLoginScanScreen.self + } + + /// A list of screen state definitions + static var allCases: [MockAuthenticationQRLoginScanScreenState] { + // Each of the presence statuses + [.scanning, .noCameraAvailable, .noCameraAccess] + } + + /// Generate the view struct for the screen state. + var screenView: ([Any], AnyView) { + let viewModel: AuthenticationQRLoginScanViewModel + + switch self { + case .scanning: + viewModel = .init(qrLoginService: MockQRLoginService(withState: .scanningQR)) + case .noCameraAvailable: + viewModel = .init(qrLoginService: MockQRLoginService(withState: .failed(error: .noCameraAvailable))) + case .noCameraAccess: + viewModel = .init(qrLoginService: MockQRLoginService(withState: .failed(error: .noCameraAccess))) + } + + // can simulate service and viewModel actions here if needs be. + + return ( + [self, viewModel], + AnyView(AuthenticationQRLoginScanScreen(context: viewModel.context)) + ) + } +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Scan/Test/UI/AuthenticationQRLoginScanUITests.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Scan/Test/UI/AuthenticationQRLoginScanUITests.swift new file mode 100644 index 000000000..1326a774b --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Scan/Test/UI/AuthenticationQRLoginScanUITests.swift @@ -0,0 +1,53 @@ +// +// Copyright 2021 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 RiotSwiftUI +import XCTest + +class AuthenticationQRLoginScanUITests: MockScreenTestCase { + func testScanning() { + app.goToScreenWithIdentifier(MockAuthenticationQRLoginScanScreenState.scanning.title) + + XCTAssertTrue(app.staticTexts["titleLabel"].exists) + XCTAssertTrue(app.staticTexts["subtitleLabel"].exists) + } + + func testNoCameraAvailable() { + app.goToScreenWithIdentifier(MockAuthenticationQRLoginScanScreenState.noCameraAvailable.title) + + XCTAssertTrue(app.staticTexts["titleLabel"].exists) + XCTAssertTrue(app.staticTexts["subtitleLabel"].exists) + + let displayQRButton = app.buttons["displayQRButton"] + XCTAssertTrue(displayQRButton.exists) + XCTAssertTrue(displayQRButton.isEnabled) + } + + func testNoCameraAccess() { + app.goToScreenWithIdentifier(MockAuthenticationQRLoginScanScreenState.noCameraAccess.title) + + XCTAssertTrue(app.staticTexts["titleLabel"].exists) + XCTAssertTrue(app.staticTexts["subtitleLabel"].exists) + + let openSettingsButton = app.buttons["openSettingsButton"] + XCTAssertTrue(openSettingsButton.exists) + XCTAssertTrue(openSettingsButton.isEnabled) + + let displayQRButton = app.buttons["displayQRButton"] + XCTAssertTrue(displayQRButton.exists) + XCTAssertTrue(displayQRButton.isEnabled) + } +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Scan/Test/Unit/AuthenticationQRLoginScanViewModelTests.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Scan/Test/Unit/AuthenticationQRLoginScanViewModelTests.swift new file mode 100644 index 000000000..0c530dce2 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Scan/Test/Unit/AuthenticationQRLoginScanViewModelTests.swift @@ -0,0 +1,53 @@ +// +// Copyright 2021 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 XCTest + +@testable import RiotSwiftUI + +class AuthenticationQRLoginScanViewModelTests: XCTestCase { + var viewModel: AuthenticationQRLoginScanViewModelProtocol! + var context: AuthenticationQRLoginScanViewModelType.Context! + + override func setUpWithError() throws { + viewModel = AuthenticationQRLoginScanViewModel(qrLoginService: MockQRLoginService()) + context = viewModel.context + } + + func testGoToSettings() { + var result: AuthenticationQRLoginScanViewModelResult? + + viewModel.callback = { callbackResult in + result = callbackResult + } + + context.send(viewAction: .goToSettings) + + XCTAssertEqual(result, .goToSettings) + } + + func testDisplayQR() { + var result: AuthenticationQRLoginScanViewModelResult? + + viewModel.callback = { callbackResult in + result = callbackResult + } + + context.send(viewAction: .displayQR) + + XCTAssertEqual(result, .displayQR) + } +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Scan/View/AuthenticationQRLoginScanScreen.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Scan/View/AuthenticationQRLoginScanScreen.swift new file mode 100644 index 000000000..51e7cb276 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Scan/View/AuthenticationQRLoginScanScreen.swift @@ -0,0 +1,212 @@ +// +// Copyright 2021 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 SwiftUI + +/// The screen shown to a new user to select their use case for the app. +struct AuthenticationQRLoginScanScreen: View { + // MARK: - Properties + + // MARK: Private + + @Environment(\.theme) private var theme + @ScaledMetric private var iconSize = 70.0 + private let overlayBgColor = Color.black.opacity(0.4) + + // MARK: Public + + @ObservedObject var context: AuthenticationQRLoginScanViewModel.Context + + var body: some View { + switch context.viewState.serviceState { + case .scanningQR: + scanningBody + case .failed(let error): + switch error { + case .noCameraAvailable, .noCameraAccess: + errorBody(for: error) + default: + EmptyView() + } + default: + EmptyView() + } + } + + var scanningBody: some View { + ZStack { + if let scannerView = context.viewState.scannerView { + scannerView + .frame(maxWidth: .infinity) + .background(Color.black) + } + overlayView + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .ignoresSafeArea() + } + + var overlayView: some View { + GeometryReader { geometry in + VStack(spacing: 0) { + VStack { + Spacer() + scanningTitleContent + .padding(.horizontal, 40) + Spacer() + .frame(height: 16) + } + .frame(height: additionalViewHeight(in: geometry)) + .frame(maxWidth: .infinity) + .background(overlayBgColor) + + HStack(spacing: 0) { + overlayBgColor + .frame(width: 40) + Spacer() + overlayBgColor + .frame(width: 40) + } + .frame(maxWidth: .infinity) + + overlayBgColor + .frame(height: additionalViewHeight(in: geometry)) + } + } + .ignoresSafeArea() + } + + /// The screen's title and instructions. + var scanningTitleContent: some View { + VStack(spacing: 24) { + Text(VectorL10n.authenticationQrLoginScanTitle) + .font(theme.fonts.title1B) + .multilineTextAlignment(.center) + .foregroundColor(.white) + .accessibilityIdentifier("titleLabel") + + Text(VectorL10n.authenticationQrLoginScanSubtitle) + .font(theme.fonts.bodySB) + .multilineTextAlignment(.center) + .foregroundColor(.white) + .padding(.bottom, 24) + .accessibilityIdentifier("subtitleLabel") + } + } + + func errorBody(for error: QRLoginServiceError) -> some View { + GeometryReader { geometry in + VStack(alignment: .leading, spacing: 0) { + ScrollView { + errorTitleContent(for: error) + .padding(.top, OnboardingMetrics.topPaddingToNavigationBar) + } + .readableFrame() + + errorFooterContent(for: error) + .padding(.bottom, geometry.safeAreaInsets.bottom > 0 ? 20 : 36) + } + .padding(.horizontal, 16) + } + .background(theme.colors.background.ignoresSafeArea()) + } + + /// The screen's title and instructions on error. + func errorTitleContent(for error: QRLoginServiceError) -> some View { + VStack(spacing: 16) { + ZStack { + Circle() + .fill(theme.colors.accent) + Image(Asset.Images.camera.name) + .resizable() + .renderingMode(.template) + .foregroundColor(.white) + .aspectRatio(1.0, contentMode: .fit) + .padding(14) + } + .frame(width: iconSize, height: iconSize) + .padding(.bottom, 16) + + Text(VectorL10n.authenticationQrLoginStartTitle) + .font(theme.fonts.title2B) + .multilineTextAlignment(.center) + .foregroundColor(theme.colors.primaryContent) + .accessibilityIdentifier("titleLabel") + + Text(error == .noCameraAccess ? VectorL10n.cameraAccessNotGranted(AppInfo.current.displayName) : VectorL10n.cameraUnavailable) + .font(theme.fonts.body) + .multilineTextAlignment(.center) + .foregroundColor(theme.colors.secondaryContent) + .padding(.bottom, 24) + .accessibilityIdentifier("subtitleLabel") + } + } + + /// The screen's footer on error. + func errorFooterContent(for error: QRLoginServiceError) -> some View { + VStack(spacing: 12) { + if error == .noCameraAccess { + Button(action: goToSettings) { + Text(VectorL10n.settings) + } + .buttonStyle(PrimaryActionButtonStyle(font: theme.fonts.bodySB)) + .padding(.bottom, 8) + .accessibilityIdentifier("openSettingsButton") + } + + LabelledDivider(label: VectorL10n.authenticationQrLoginStartNeedAlternative) + + Button(action: displayQR) { + Text(VectorL10n.authenticationQrLoginStartDisplayQr) + } + .buttonStyle(SecondaryActionButtonStyle(font: theme.fonts.bodySB)) + .accessibilityIdentifier("displayQRButton") + } + } + + /// Sends the `goToSettings` view action. + func goToSettings() { + context.send(viewAction: .goToSettings) + } + + /// Sends the `displayQR` view action. + func displayQR() { + context.send(viewAction: .displayQR) + } + + func squareSize(in geometry: GeometryProxy) -> CGFloat { + geometry.size.width - 80 + } + + func additionalViewHeight(in geometry: GeometryProxy) -> CGFloat { + (geometry.size.height - squareSize(in: geometry)) / 2 + } +} + +// MARK: - Previews + +struct AuthenticationQRLoginScan_Previews: PreviewProvider { + static let stateRenderer = MockAuthenticationQRLoginScanScreenState.stateRenderer + + static var previews: some View { + stateRenderer.screenGroup(addNavigation: true) + .theme(.light).preferredColorScheme(.light) + .navigationViewStyle(.stack) + stateRenderer.screenGroup(addNavigation: true) + .theme(.dark).preferredColorScheme(.dark) + .navigationViewStyle(.stack) + } +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Start/AuthenticationQRLoginStartModels.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Start/AuthenticationQRLoginStartModels.swift new file mode 100644 index 000000000..bd0e05daf --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Start/AuthenticationQRLoginStartModels.swift @@ -0,0 +1,35 @@ +// +// Copyright 2021 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 + +// MARK: - Coordinator + +// MARK: View model + +enum AuthenticationQRLoginStartViewModelResult { + case scanQR + case displayQR +} + +// MARK: View + +struct AuthenticationQRLoginStartViewState: BindableState { } + +enum AuthenticationQRLoginStartViewAction { + case scanQR + case displayQR +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Start/AuthenticationQRLoginStartViewModel.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Start/AuthenticationQRLoginStartViewModel.swift new file mode 100644 index 000000000..cab038f1d --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Start/AuthenticationQRLoginStartViewModel.swift @@ -0,0 +1,49 @@ +// +// Copyright 2021 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 SwiftUI + +typealias AuthenticationQRLoginStartViewModelType = StateStoreViewModel + +class AuthenticationQRLoginStartViewModel: AuthenticationQRLoginStartViewModelType, AuthenticationQRLoginStartViewModelProtocol { + // MARK: - Properties + + // MARK: Private + + private let qrLoginService: QRLoginServiceProtocol + + // MARK: Public + + var callback: ((AuthenticationQRLoginStartViewModelResult) -> Void)? + + // MARK: - Setup + + init(qrLoginService: QRLoginServiceProtocol) { + self.qrLoginService = qrLoginService + super.init(initialViewState: AuthenticationQRLoginStartViewState()) + } + + // MARK: - Public + + override func process(viewAction: AuthenticationQRLoginStartViewAction) { + switch viewAction { + case .scanQR: + callback?(.scanQR) + case .displayQR: + callback?(.displayQR) + } + } +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Start/AuthenticationQRLoginStartViewModelProtocol.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Start/AuthenticationQRLoginStartViewModelProtocol.swift new file mode 100644 index 000000000..9d69a1bd3 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Start/AuthenticationQRLoginStartViewModelProtocol.swift @@ -0,0 +1,22 @@ +// +// Copyright 2021 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 + +protocol AuthenticationQRLoginStartViewModelProtocol { + var callback: ((AuthenticationQRLoginStartViewModelResult) -> Void)? { get set } + var context: AuthenticationQRLoginStartViewModelType.Context { get } +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Start/Coordinator/AuthenticationQRLoginStartCoordinator.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Start/Coordinator/AuthenticationQRLoginStartCoordinator.swift new file mode 100644 index 000000000..3149e1121 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Start/Coordinator/AuthenticationQRLoginStartCoordinator.swift @@ -0,0 +1,269 @@ +// +// Copyright 2021 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 Combine +import CommonKit +import SwiftUI + +struct AuthenticationQRLoginStartCoordinatorParameters { + let navigationRouter: NavigationRouterType + let qrLoginService: QRLoginServiceProtocol +} + +enum AuthenticationQRLoginStartCoordinatorResult { + /// Login with QR done + case done +} + +final class AuthenticationQRLoginStartCoordinator: Coordinator, Presentable { + // MARK: - Properties + + // MARK: Private + + private let parameters: AuthenticationQRLoginStartCoordinatorParameters + private let onboardingQRLoginStartHostingController: VectorHostingController + private var onboardingQRLoginStartViewModel: AuthenticationQRLoginStartViewModelProtocol + + private var indicatorPresenter: UserIndicatorTypePresenterProtocol + private var loadingIndicator: UserIndicator? + private var cancellables = Set() + + private var navigationRouter: NavigationRouterType { parameters.navigationRouter } + private var qrLoginService: QRLoginServiceProtocol { parameters.qrLoginService } + + // MARK: Public + + // Must be used only internally + var childCoordinators: [Coordinator] = [] + var callback: ((AuthenticationQRLoginStartCoordinatorResult) -> Void)? + + // MARK: - Setup + + init(parameters: AuthenticationQRLoginStartCoordinatorParameters) { + self.parameters = parameters + let viewModel = AuthenticationQRLoginStartViewModel(qrLoginService: parameters.qrLoginService) + let view = AuthenticationQRLoginStartScreen(context: viewModel.context) + onboardingQRLoginStartViewModel = viewModel + + onboardingQRLoginStartHostingController = VectorHostingController(rootView: view) + onboardingQRLoginStartHostingController.vc_removeBackTitle() + onboardingQRLoginStartHostingController.enableNavigationBarScrollEdgeAppearance = true + + indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: onboardingQRLoginStartHostingController) + } + + // MARK: - Public + + func start() { + MXLog.debug("[AuthenticationQRLoginStartCoordinator] did start.") + onboardingQRLoginStartViewModel.callback = { [weak self] result in + guard let self = self else { return } + MXLog.debug("[AuthenticationQRLoginStartCoordinator] AuthenticationQRLoginStartViewModel did complete with result: \(result).") + + switch result { + case .scanQR: + self.showScanQRScreen() + case .displayQR: + self.showDisplayQRScreen() + } + } + + qrLoginService.callbacks.sink { [weak self] callback in + guard let self = self else { return } + switch callback { + case .didUpdateState: + self.processServiceState(self.qrLoginService.state) + default: + break + } + } + .store(in: &cancellables) + } + + func toPresentable() -> UIViewController { + onboardingQRLoginStartHostingController + } + + /// Stops any ongoing activities in the coordinator. + func stop() { + stopLoading() + } + + // MARK: - Private + + private func processServiceState(_ state: QRLoginServiceState) { + switch state { + case .initial: + removeAllChildren() + case .connectingToDevice, .waitingForRemoteSignIn, .completed: + showLoadingScreenIfNeeded() + case .waitingForConfirmation: + showConfirmationScreenIfNeeded() + case .failed(let error): + switch error { + case .noCameraAccess, .noCameraAvailable: + // handled in scanning screen + break + default: + showFailureScreenIfNeeded() + } + default: + break + } + } + + private func removeAllChildren(animated: Bool = true) { + MXLog.debug("[AuthenticationQRLoginStartCoordinator] removeAllChildren") + + guard !childCoordinators.isEmpty else { + return + } + + for coordinator in childCoordinators.reversed() { + remove(childCoordinator: coordinator) + } + + navigationRouter.popToModule(self, animated: animated) + } + + /// Shows the scan QR screen. + private func showScanQRScreen() { + MXLog.debug("[AuthenticationQRLoginStartCoordinator] showScanQRScreen") + + let parameters = AuthenticationQRLoginScanCoordinatorParameters(navigationRouter: navigationRouter, + qrLoginService: qrLoginService) + let coordinator = AuthenticationQRLoginScanCoordinator(parameters: parameters) + coordinator.callback = { [weak self, weak coordinator] _ in + guard let self = self, let coordinator = coordinator else { return } + self.remove(childCoordinator: coordinator) + } + + coordinator.start() + add(childCoordinator: coordinator) + + navigationRouter.push(coordinator, animated: true) { [weak self] in + self?.remove(childCoordinator: coordinator) + } + } + + /// Shows the display QR screen. + private func showDisplayQRScreen() { + MXLog.debug("[AuthenticationQRLoginStartCoordinator] showDisplayQRScreen") + + let parameters = AuthenticationQRLoginDisplayCoordinatorParameters(navigationRouter: navigationRouter, + qrLoginService: qrLoginService) + let coordinator = AuthenticationQRLoginDisplayCoordinator(parameters: parameters) + coordinator.callback = { [weak self, weak coordinator] _ in + guard let self = self, let coordinator = coordinator else { return } + self.remove(childCoordinator: coordinator) + } + + coordinator.start() + add(childCoordinator: coordinator) + + navigationRouter.push(coordinator, animated: true) { [weak self] in + self?.remove(childCoordinator: coordinator) + } + } + + /// Shows the loading screen. + private func showLoadingScreenIfNeeded() { + MXLog.debug("[AuthenticationQRLoginStartCoordinator] showLoadingScreenIfNeeded") + + if let lastCoordinator = childCoordinators.last, + lastCoordinator is AuthenticationQRLoginLoadingCoordinator { + // if the last screen is loading, do nothing. It'll be updated by the service state. + return + } + + let parameters = AuthenticationQRLoginLoadingCoordinatorParameters(navigationRouter: navigationRouter, + qrLoginService: qrLoginService) + let coordinator = AuthenticationQRLoginLoadingCoordinator(parameters: parameters) + coordinator.callback = { [weak self, weak coordinator] _ in + guard let self = self, let coordinator = coordinator else { return } + self.remove(childCoordinator: coordinator) + } + + coordinator.start() + add(childCoordinator: coordinator) + + navigationRouter.push(coordinator, animated: true) { [weak self] in + self?.remove(childCoordinator: coordinator) + } + } + + /// Shows the confirmation screen. + private func showConfirmationScreenIfNeeded() { + MXLog.debug("[AuthenticationQRLoginStartCoordinator] showConfirmationScreenIfNeeded") + + if let lastCoordinator = childCoordinators.last, + lastCoordinator is AuthenticationQRLoginConfirmCoordinator { + // if the last screen is confirmation, do nothing. It'll be updated by the service state. + return + } + + let parameters = AuthenticationQRLoginConfirmCoordinatorParameters(navigationRouter: navigationRouter, + qrLoginService: qrLoginService) + let coordinator = AuthenticationQRLoginConfirmCoordinator(parameters: parameters) + coordinator.callback = { [weak self, weak coordinator] _ in + guard let self = self, let coordinator = coordinator else { return } + self.remove(childCoordinator: coordinator) + } + + coordinator.start() + add(childCoordinator: coordinator) + + navigationRouter.push(coordinator, animated: true) { [weak self] in + self?.remove(childCoordinator: coordinator) + } + } + + /// Shows the failure screen. + private func showFailureScreenIfNeeded() { + MXLog.debug("[AuthenticationQRLoginStartCoordinator] showFailureScreenIfNeeded") + + if let lastCoordinator = childCoordinators.last, + lastCoordinator is AuthenticationQRLoginFailureCoordinator { + // if the last screen is failure, do nothing. It'll be updated by the service state. + return + } + + let parameters = AuthenticationQRLoginFailureCoordinatorParameters(navigationRouter: navigationRouter, + qrLoginService: qrLoginService) + let coordinator = AuthenticationQRLoginFailureCoordinator(parameters: parameters) + coordinator.callback = { [weak self, weak coordinator] _ in + guard let self = self, let coordinator = coordinator else { return } + self.remove(childCoordinator: coordinator) + } + + coordinator.start() + add(childCoordinator: coordinator) + + navigationRouter.push(coordinator, animated: true) { [weak self] in + self?.remove(childCoordinator: coordinator) + } + } + + /// Show an activity indicator whilst loading. + private func startLoading() { + loadingIndicator = indicatorPresenter.present(.loading(label: VectorL10n.loading, isInteractionBlocking: true)) + } + + /// Hide the currently displayed activity indicator. + private func stopLoading() { + loadingIndicator = nil + } +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Start/MockAuthenticationQRLoginStartScreenState.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Start/MockAuthenticationQRLoginStartScreenState.swift new file mode 100644 index 000000000..02bc3bb7a --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Start/MockAuthenticationQRLoginStartScreenState.swift @@ -0,0 +1,50 @@ +// +// Copyright 2021 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 +import SwiftUI + +/// Using an enum for the screen allows you define the different state cases with +/// the relevant associated data for each case. +enum MockAuthenticationQRLoginStartScreenState: MockScreenState, CaseIterable { + // A case for each state you want to represent + // with specific, minimal associated data that will allow you + // mock that screen. + case `default` + + /// The associated screen + var screenType: Any.Type { + AuthenticationQRLoginStartScreen.self + } + + /// A list of screen state definitions + static var allCases: [MockAuthenticationQRLoginStartScreenState] { + // Each of the presence statuses + [.default] + } + + /// Generate the view struct for the screen state. + var screenView: ([Any], AnyView) { + let viewModel = AuthenticationQRLoginStartViewModel(qrLoginService: MockQRLoginService()) + + // can simulate service and viewModel actions here if needs be. + + return ( + [self, viewModel], + AnyView(AuthenticationQRLoginStartScreen(context: viewModel.context)) + ) + } +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Start/Test/UI/AuthenticationQRLoginStartUITests.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Start/Test/UI/AuthenticationQRLoginStartUITests.swift new file mode 100644 index 000000000..918d123ac --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Start/Test/UI/AuthenticationQRLoginStartUITests.swift @@ -0,0 +1,35 @@ +// +// Copyright 2021 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 RiotSwiftUI +import XCTest + +class AuthenticationQRLoginStartUITests: MockScreenTestCase { + func testDefault() { + app.goToScreenWithIdentifier(MockAuthenticationQRLoginStartScreenState.default.title) + + XCTAssertTrue(app.staticTexts["titleLabel"].exists) + XCTAssertTrue(app.staticTexts["subtitleLabel"].exists) + + let scanQRButton = app.buttons["scanQRButton"] + XCTAssertTrue(scanQRButton.exists) + XCTAssertTrue(scanQRButton.isEnabled) + + let displayQRButton = app.buttons["displayQRButton"] + XCTAssertTrue(displayQRButton.exists) + XCTAssertTrue(displayQRButton.isEnabled) + } +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Start/Test/Unit/AuthenticationQRLoginStartViewModelTests.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Start/Test/Unit/AuthenticationQRLoginStartViewModelTests.swift new file mode 100644 index 000000000..d5dd83040 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Start/Test/Unit/AuthenticationQRLoginStartViewModelTests.swift @@ -0,0 +1,53 @@ +// +// Copyright 2021 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 XCTest + +@testable import RiotSwiftUI + +class AuthenticationQRLoginStartViewModelTests: XCTestCase { + var viewModel: AuthenticationQRLoginStartViewModelProtocol! + var context: AuthenticationQRLoginStartViewModelType.Context! + + override func setUpWithError() throws { + viewModel = AuthenticationQRLoginStartViewModel(qrLoginService: MockQRLoginService()) + context = viewModel.context + } + + func testScanQR() { + var result: AuthenticationQRLoginStartViewModelResult? + + viewModel.callback = { callbackResult in + result = callbackResult + } + + context.send(viewAction: .scanQR) + + XCTAssertEqual(result, .scanQR) + } + + func testDisplayQR() { + var result: AuthenticationQRLoginStartViewModelResult? + + viewModel.callback = { callbackResult in + result = callbackResult + } + + context.send(viewAction: .displayQR) + + XCTAssertEqual(result, .displayQR) + } +} diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Start/View/AuthenticationQRLoginStartScreen.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Start/View/AuthenticationQRLoginStartScreen.swift new file mode 100644 index 000000000..84064b318 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Start/View/AuthenticationQRLoginStartScreen.swift @@ -0,0 +1,157 @@ +// +// Copyright 2021 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 SwiftUI + +/// The screen shown to a new user to select their use case for the app. +struct AuthenticationQRLoginStartScreen: View { + // MARK: - Properties + + // MARK: Private + + @Environment(\.theme) private var theme + @ScaledMetric private var iconSize = 70.0 + + // MARK: Public + + @ObservedObject var context: AuthenticationQRLoginStartViewModel.Context + + var body: some View { + GeometryReader { geometry in + VStack(alignment: .leading, spacing: 0) { + ScrollView { + titleContent + .padding(.top, OnboardingMetrics.topPaddingToNavigationBar) + stepsView + } + .readableFrame() + + footerContent + .padding(.bottom, geometry.safeAreaInsets.bottom > 0 ? 20 : 36) + } + .padding(.horizontal, 16) + } + .background(theme.colors.background.ignoresSafeArea()) + } + + /// The screen's title and instructions. + var titleContent: some View { + VStack(spacing: 16) { + ZStack { + Circle() + .fill(theme.colors.accent) + Image(Asset.Images.camera.name) + .resizable() + .renderingMode(.template) + .foregroundColor(.white) + .aspectRatio(1.0, contentMode: .fit) + .padding(14) + } + .frame(width: iconSize, height: iconSize) + .padding(.bottom, 16) + + Text(VectorL10n.authenticationQrLoginStartTitle) + .font(theme.fonts.title2B) + .multilineTextAlignment(.center) + .foregroundColor(theme.colors.primaryContent) + .accessibilityIdentifier("titleLabel") + + Text(VectorL10n.authenticationQrLoginStartSubtitle) + .font(theme.fonts.body) + .multilineTextAlignment(.center) + .foregroundColor(theme.colors.secondaryContent) + .padding(.bottom, 24) + .accessibilityIdentifier("subtitleLabel") + } + } + + /// The screen's footer. + var footerContent: some View { + VStack(spacing: 12) { + Button(action: scanQR) { + Text(VectorL10n.authenticationQrLoginStartTitle) + } + .buttonStyle(PrimaryActionButtonStyle(font: theme.fonts.bodySB)) + .padding(.bottom, 8) + .accessibilityIdentifier("scanQRButton") + + LabelledDivider(label: VectorL10n.authenticationQrLoginStartNeedAlternative) + + Button(action: displayQR) { + Text(VectorL10n.authenticationQrLoginStartDisplayQr) + } + .buttonStyle(SecondaryActionButtonStyle(font: theme.fonts.bodySB)) + .accessibilityIdentifier("displayQRButton") + } + } + + /// The buttons used to select a use case for the app. + var stepsView: some View { + VStack(alignment: .leading, spacing: 12) { + ForEach(steps) { step in + HStack { + Text(String(step.id)) + .font(theme.fonts.caption2SB) + .foregroundColor(theme.colors.accent) + .padding(6) + .shapedBorder(color: theme.colors.accent, borderWidth: 1, shape: Circle()) + .offset(x: 1, y: 0) + Text(step.description) + .foregroundColor(theme.colors.primaryContent) + .font(theme.fonts.subheadline) + Spacer() + } + } + } + } + + private let steps = [ + QRLoginStartStep(id: 1, description: VectorL10n.authenticationQrLoginStartStep1), + QRLoginStartStep(id: 2, description: VectorL10n.authenticationQrLoginStartStep2), + QRLoginStartStep(id: 3, description: VectorL10n.authenticationQrLoginStartStep3), + QRLoginStartStep(id: 4, description: VectorL10n.authenticationQrLoginStartStep4) + ] + + /// Sends the `scanQR` view action. + func scanQR() { + context.send(viewAction: .scanQR) + } + + /// Sends the `displayQR` view action. + func displayQR() { + context.send(viewAction: .displayQR) + } +} + +// MARK: - Previews + +struct AuthenticationQRLoginStart_Previews: PreviewProvider { + static let stateRenderer = MockAuthenticationQRLoginStartScreenState.stateRenderer + + static var previews: some View { + stateRenderer.screenGroup(addNavigation: true) + .theme(.light).preferredColorScheme(.light) + .navigationViewStyle(.stack) + stateRenderer.screenGroup(addNavigation: true) + .theme(.dark).preferredColorScheme(.dark) + .navigationViewStyle(.stack) + } +} + +private struct QRLoginStartStep: Identifiable { + let id: Int + let description: String +} diff --git a/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift b/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift index 3deb51ccb..878c8e674 100644 --- a/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift +++ b/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift @@ -35,6 +35,12 @@ enum MockAppScreens { MockAuthenticationForgotPasswordScreenState.self, MockAuthenticationChoosePasswordScreenState.self, MockAuthenticationSoftLogoutScreenState.self, + MockAuthenticationQRLoginStartScreenState.self, + MockAuthenticationQRLoginDisplayScreenState.self, + MockAuthenticationQRLoginScanScreenState.self, + MockAuthenticationQRLoginConfirmScreenState.self, + MockAuthenticationQRLoginLoadingScreenState.self, + MockAuthenticationQRLoginFailureScreenState.self, MockOnboardingCelebrationScreenState.self, MockOnboardingAvatarScreenState.self, MockOnboardingDisplayNameScreenState.self, diff --git a/RiotSwiftUI/Modules/Common/Util/PrimaryActionButtonStyle.swift b/RiotSwiftUI/Modules/Common/Util/PrimaryActionButtonStyle.swift index 07a07f49a..57b4c8fb4 100644 --- a/RiotSwiftUI/Modules/Common/Util/PrimaryActionButtonStyle.swift +++ b/RiotSwiftUI/Modules/Common/Util/PrimaryActionButtonStyle.swift @@ -19,8 +19,11 @@ import SwiftUI struct PrimaryActionButtonStyle: ButtonStyle { @Environment(\.theme) private var theme @Environment(\.isEnabled) private var isEnabled - + + /// `theme.colors.accent` by default var customColor: Color? + /// `theme.colors.body` by default + var font: Font? private var fontColor: Color { // Always white unless disabled with a dark theme. @@ -36,7 +39,7 @@ struct PrimaryActionButtonStyle: ButtonStyle { .padding(12.0) .frame(maxWidth: .infinity) .foregroundColor(fontColor) - .font(theme.fonts.body) + .font(font ?? theme.fonts.body) .background(backgroundColor.opacity(backgroundOpacity(when: configuration.isPressed))) .cornerRadius(8.0) } diff --git a/RiotSwiftUI/Modules/Common/Util/SecondaryActionButtonStyle.swift b/RiotSwiftUI/Modules/Common/Util/SecondaryActionButtonStyle.swift index 917ad1997..98b31a02b 100644 --- a/RiotSwiftUI/Modules/Common/Util/SecondaryActionButtonStyle.swift +++ b/RiotSwiftUI/Modules/Common/Util/SecondaryActionButtonStyle.swift @@ -19,15 +19,18 @@ import SwiftUI struct SecondaryActionButtonStyle: ButtonStyle { @Environment(\.theme) private var theme @Environment(\.isEnabled) private var isEnabled - + + /// `theme.colors.accent` by default var customColor: Color? + /// `theme.fonts.body` by default + var font: Font? func makeBody(configuration: Self.Configuration) -> some View { configuration.label .padding(12.0) .frame(maxWidth: .infinity) .foregroundColor(customColor ?? theme.colors.accent) - .font(theme.fonts.body) + .font(font ?? theme.fonts.body) .background(RoundedRectangle(cornerRadius: 8) .strokeBorder() .foregroundColor(customColor ?? theme.colors.accent)) diff --git a/RiotSwiftUI/target.yml b/RiotSwiftUI/target.yml index 3e3635e47..3c2853f72 100644 --- a/RiotSwiftUI/target.yml +++ b/RiotSwiftUI/target.yml @@ -57,9 +57,11 @@ targets: - path: ../Riot/Categories/UISearchBar.swift - path: ../Riot/Categories/UIView.swift - path: ../Riot/Categories/UIApplication.swift + - path: ../Riot/Categories/Codable.swift - path: ../Riot/Assets/en.lproj/Vector.strings - path: ../Riot/Modules/Analytics/AnalyticsScreen.swift - path: ../Riot/Modules/LocationSharing/LocationAuthorizationStatus.swift + - path: ../Riot/Modules/QRCode/QRCodeGenerator.swift - path: ../Riot/Assets/en.lproj/Untranslated.strings buildPhase: resources - path: ../Riot/Assets/Images.xcassets diff --git a/RiotSwiftUI/targetUITests.yml b/RiotSwiftUI/targetUITests.yml index f43d9d4ad..166660384 100644 --- a/RiotSwiftUI/targetUITests.yml +++ b/RiotSwiftUI/targetUITests.yml @@ -66,9 +66,11 @@ targets: - path: ../Riot/Categories/UISearchBar.swift - path: ../Riot/Categories/UIView.swift - path: ../Riot/Categories/UIApplication.swift + - path: ../Riot/Categories/Codable.swift - path: ../Riot/Assets/en.lproj/Vector.strings - path: ../Riot/Modules/Analytics/AnalyticsScreen.swift - path: ../Riot/Modules/LocationSharing/LocationAuthorizationStatus.swift + - path: ../Riot/Modules/QRCode/QRCodeGenerator.swift - path: ../Riot/Assets/en.lproj/Untranslated.strings buildPhase: resources - path: ../Riot/Assets/Images.xcassets diff --git a/RiotTests/Modules/Authentication/Mocks/MockAuthenticationRestClient.swift b/RiotTests/Modules/Authentication/Mocks/MockAuthenticationRestClient.swift index 3dff46b7b..2971a0aa5 100644 --- a/RiotTests/Modules/Authentication/Mocks/MockAuthenticationRestClient.swift +++ b/RiotTests/Modules/Authentication/Mocks/MockAuthenticationRestClient.swift @@ -208,4 +208,10 @@ class MockAuthenticationRestClient: AuthenticationRestClient { func resetPassword(parameters: [String : Any]) async throws { throw MockError.unhandled } + + // MARK: Versions + + func supportedMatrixVersions() async throws -> MXMatrixVersions { + throw MockError.unhandled + } } From 1ee8c9ca195aa344627b5b11c1eac286fc6c6d2e Mon Sep 17 00:00:00 2001 From: ismailgulek Date: Fri, 7 Oct 2022 12:58:26 +0300 Subject: [PATCH 15/15] QR login from device manager (#6818) * Add link device button into the sessions overview screen * Run Swift format * Fix tests * Fix a crash in tests * Fix PR remark --- Config/CommonConfiguration.swift | 2 +- Riot/Assets/en.lproj/Vector.strings | 1 + Riot/Generated/Strings.swift | 4 ++ ...tiveUserSessionLastActivityFormatter.swift | 2 +- .../Common/View/UserSessionCardView.swift | 30 +++++----- .../UserSessionsFlowCoordinator.swift | 20 ++++++- .../UserOtherSessionsCoordinator.swift | 1 - .../MockUserOtherSessionsScreenState.swift | 12 ++-- .../Test/UI/UserOtherSessionsUITests.swift | 3 +- .../UserOtherSessionsViewModelTests.swift | 13 ++-- .../UserOtherSessionsModels.swift | 2 + .../UserOtherSessionsViewModel.swift | 3 +- .../View/UserOtherSessions.swift | 2 - .../View/UserOtherSessionsHeaderView.swift | 5 +- .../MockUserSessionDetailsScreenState.swift | 60 +++++++++---------- .../UserSessionOverviewService.swift | 29 +++++---- .../Mock/MockUserSessionOverviewService.swift | 8 +-- .../UserSessionOverviewViewModelTests.swift | 1 - .../UserSessionOverviewViewModel.swift | 2 +- .../UserSessionsOverviewCoordinator.swift | 3 +- .../MatrixSDK/UserSessionsDataProvider.swift | 6 ++ .../UserSessionsDataProviderProtocol.swift | 2 + .../UserSessionsOverviewService.swift | 22 ++++--- .../MockUserSessionsOverviewService.swift | 20 ++++--- .../UserSessionsOverviewServiceProtocol.swift | 1 + .../Test/UI/UserSessionsOverviewUITests.swift | 21 +++++++ .../UserSessionsOverviewViewModelTests.swift | 5 ++ .../UserSessionsOverviewModels.swift | 5 ++ .../UserSessionsOverviewViewModel.swift | 5 +- .../View/UserSessionListItemViewData.swift | 1 - .../UserSessionListItemViewDataFactory.swift | 3 +- .../View/UserSessionsOverview.swift | 45 +++++++++++--- .../UserSessionsOverviewServiceTests.swift | 10 ++++ 33 files changed, 223 insertions(+), 126 deletions(-) diff --git a/Config/CommonConfiguration.swift b/Config/CommonConfiguration.swift index d304f7fbe..a89427c3a 100644 --- a/Config/CommonConfiguration.swift +++ b/Config/CommonConfiguration.swift @@ -172,7 +172,7 @@ class CommonConfiguration: NSObject, Configurable { func setupSettingsWhenLoaded(for matrixSession: MXSession) { // Do not warn for unknown devices. We have cross-signing now - matrixSession.crypto.warnOnUnknowDevices = false + matrixSession.crypto?.warnOnUnknowDevices = false } } diff --git a/Riot/Assets/en.lproj/Vector.strings b/Riot/Assets/en.lproj/Vector.strings index 24ee63da6..415b5b52e 100644 --- a/Riot/Assets/en.lproj/Vector.strings +++ b/Riot/Assets/en.lproj/Vector.strings @@ -2407,6 +2407,7 @@ To enable access, tap Settings> Location and select Always"; "user_sessions_overview_other_sessions_section_info" = "For best security, verify your sessions and sign out from any session that you don’t recognize or use anymore."; "user_sessions_overview_current_session_section_title" = "Current session"; +"user_sessions_overview_link_device" = "Link a device"; "user_sessions_view_all_action" = "View all (%d)"; diff --git a/Riot/Generated/Strings.swift b/Riot/Generated/Strings.swift index 44fb49fa0..0d4e4ac19 100644 --- a/Riot/Generated/Strings.swift +++ b/Riot/Generated/Strings.swift @@ -8747,6 +8747,10 @@ public class VectorL10n: NSObject { public static var userSessionsOverviewCurrentSessionSectionTitle: String { return VectorL10n.tr("Vector", "user_sessions_overview_current_session_section_title") } + /// Link a device + public static var userSessionsOverviewLinkDevice: String { + return VectorL10n.tr("Vector", "user_sessions_overview_link_device") + } /// For best security, verify your sessions and sign out from any session that you don’t recognize or use anymore. public static var userSessionsOverviewOtherSessionsSectionInfo: String { return VectorL10n.tr("Vector", "user_sessions_overview_other_sessions_section_info") diff --git a/RiotSwiftUI/Modules/UserSessions/Common/InactiveUserSessionLastActivityFormatter.swift b/RiotSwiftUI/Modules/UserSessions/Common/InactiveUserSessionLastActivityFormatter.swift index f44ef9204..3faef040d 100644 --- a/RiotSwiftUI/Modules/UserSessions/Common/InactiveUserSessionLastActivityFormatter.swift +++ b/RiotSwiftUI/Modules/UserSessions/Common/InactiveUserSessionLastActivityFormatter.swift @@ -1,4 +1,4 @@ -// +// // Copyright 2022 New Vector Ltd // // Licensed under the Apache License, Version 2.0 (the "License"); diff --git a/RiotSwiftUI/Modules/UserSessions/Common/View/UserSessionCardView.swift b/RiotSwiftUI/Modules/UserSessions/Common/View/UserSessionCardView.swift index 8fa03b02c..44ec039fc 100644 --- a/RiotSwiftUI/Modules/UserSessions/Common/View/UserSessionCardView.swift +++ b/RiotSwiftUI/Modules/UserSessions/Common/View/UserSessionCardView.swift @@ -139,21 +139,21 @@ struct UserSessionCardViewPreview: View { init(isCurrent: Bool = false) { let sessionInfo = UserSessionInfo(id: "alice", - name: "iOS", - deviceType: .mobile, - isVerified: false, - lastSeenIP: "10.0.0.10", - lastSeenTimestamp: nil, - applicationName: "Element iOS", - applicationVersion: "1.0.0", - applicationURL: nil, - deviceModel: nil, - deviceOS: "iOS 15.5", - lastSeenIPLocation: nil, - clientName: "Element", - clientVersion: "1.0.0", - isActive: true, - isCurrent: isCurrent) + name: "iOS", + deviceType: .mobile, + isVerified: false, + lastSeenIP: "10.0.0.10", + lastSeenTimestamp: nil, + applicationName: "Element iOS", + applicationVersion: "1.0.0", + applicationURL: nil, + deviceModel: nil, + deviceOS: "iOS 15.5", + lastSeenIPLocation: nil, + clientName: "Element", + clientVersion: "1.0.0", + isActive: true, + isCurrent: isCurrent) viewData = UserSessionCardViewData(sessionInfo: sessionInfo) } diff --git a/RiotSwiftUI/Modules/UserSessions/Coordinator/UserSessionsFlowCoordinator.swift b/RiotSwiftUI/Modules/UserSessions/Coordinator/UserSessionsFlowCoordinator.swift index 32453a5de..bfd30c522 100644 --- a/RiotSwiftUI/Modules/UserSessions/Coordinator/UserSessionsFlowCoordinator.swift +++ b/RiotSwiftUI/Modules/UserSessions/Coordinator/UserSessionsFlowCoordinator.swift @@ -41,7 +41,7 @@ final class UserSessionsFlowCoordinator: Coordinator, Presentable { init(parameters: UserSessionsFlowCoordinatorParameters) { self.parameters = parameters - self.navigationRouter = parameters.router + navigationRouter = parameters.router errorPresenter = MXKErrorAlertPresentation() indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: parameters.router.toPresentable()) } @@ -75,6 +75,8 @@ final class UserSessionsFlowCoordinator: Coordinator, Presentable { self.openOtherSessions(sessionInfos: sessionInfos, filterBy: filter, title: VectorL10n.userOtherSessionSecurityRecommendationTitle) + case .linkDevice: + self.openQRLoginScreen() } } return coordinator @@ -105,6 +107,21 @@ final class UserSessionsFlowCoordinator: Coordinator, Presentable { } pushScreen(with: coordinator) } + + /// Shows the QR login screen. + private func openQRLoginScreen() { + let service = QRLoginService(client: parameters.session.matrixRestClient, + mode: .authenticated) + let parameters = AuthenticationQRLoginStartCoordinatorParameters(navigationRouter: navigationRouter, + qrLoginService: service) + let coordinator = AuthenticationQRLoginStartCoordinator(parameters: parameters) + coordinator.callback = { [weak self, weak coordinator] _ in + guard let self = self, let coordinator = coordinator else { return } + self.remove(childCoordinator: coordinator) + } + + pushScreen(with: coordinator) + } private func createUserSessionOverviewCoordinator(sessionInfo: UserSessionInfo) -> UserSessionOverviewCoordinator { let parameters = UserSessionOverviewCoordinatorParameters(session: parameters.session, @@ -135,7 +152,6 @@ final class UserSessionsFlowCoordinator: Coordinator, Presentable { return UserOtherSessionsCoordinator(parameters: parameters) } - /// Shows a confirmation dialog to the user to sign out of a session. private func showLogoutConfirmation(for sessionInfo: UserSessionInfo) { // Use a UIAlertController as we don't have confirmationDialog in SwiftUI on iOS 14. diff --git a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Coordinator/UserOtherSessionsCoordinator.swift b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Coordinator/UserOtherSessionsCoordinator.swift index c118001d3..607a87aa9 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Coordinator/UserOtherSessionsCoordinator.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Coordinator/UserOtherSessionsCoordinator.swift @@ -24,7 +24,6 @@ struct UserOtherSessionsCoordinatorParameters { } final class UserOtherSessionsCoordinator: Coordinator, Presentable { - private let parameters: UserOtherSessionsCoordinatorParameters private let userOtherSessionsHostingController: UIViewController private var userOtherSessionsViewModel: UserOtherSessionsViewModelProtocol diff --git a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/MockUserOtherSessionsScreenState.swift b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/MockUserOtherSessionsScreenState.swift index ffcc4f003..fd4493b62 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/MockUserOtherSessionsScreenState.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/MockUserOtherSessionsScreenState.swift @@ -40,7 +40,6 @@ enum MockUserOtherSessionsScreenState: MockScreenState, CaseIterable { /// Generate the view struct for the screen state. var screenView: ([Any], AnyView) { - let viewModel: UserOtherSessionsViewModel switch self { case .inactiveSessions: @@ -83,7 +82,7 @@ enum MockUserOtherSessionsScreenState: MockScreenState, CaseIterable { deviceType: .desktop, isVerified: true, lastSeenIP: "1.0.0.1", - lastSeenTimestamp: Date().timeIntervalSince1970 - 8000000, + lastSeenTimestamp: Date().timeIntervalSince1970 - 8_000_000, applicationName: nil, applicationVersion: nil, applicationURL: nil, @@ -99,7 +98,7 @@ enum MockUserOtherSessionsScreenState: MockScreenState, CaseIterable { deviceType: .web, isVerified: true, lastSeenIP: "2.0.0.2", - lastSeenTimestamp: Date().timeIntervalSince1970 - 9000000, + lastSeenTimestamp: Date().timeIntervalSince1970 - 9_000_000, applicationName: nil, applicationVersion: nil, applicationURL: nil, @@ -115,7 +114,7 @@ enum MockUserOtherSessionsScreenState: MockScreenState, CaseIterable { deviceType: .mobile, isVerified: false, lastSeenIP: "3.0.0.3", - lastSeenTimestamp: Date().timeIntervalSince1970 - 10000000, + lastSeenTimestamp: Date().timeIntervalSince1970 - 10_000_000, applicationName: nil, applicationVersion: nil, applicationURL: nil, @@ -150,7 +149,7 @@ enum MockUserOtherSessionsScreenState: MockScreenState, CaseIterable { deviceType: .desktop, isVerified: false, lastSeenIP: "1.0.0.1", - lastSeenTimestamp: Date().timeIntervalSince1970 - 8000000, + lastSeenTimestamp: Date().timeIntervalSince1970 - 8_000_000, applicationName: nil, applicationVersion: nil, applicationURL: nil, @@ -160,7 +159,6 @@ enum MockUserOtherSessionsScreenState: MockScreenState, CaseIterable { clientName: nil, clientVersion: nil, isActive: true, - isCurrent: false) - ] + isCurrent: false)] } } diff --git a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Test/UI/UserOtherSessionsUITests.swift b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Test/UI/UserOtherSessionsUITests.swift index e9d5e2893..6f7363847 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Test/UI/UserOtherSessionsUITests.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Test/UI/UserOtherSessionsUITests.swift @@ -18,12 +18,11 @@ import RiotSwiftUI import XCTest class UserOtherSessionsUITests: MockScreenTestCase { - func test_whenOtherSessionsWithInactiveSessionFilterPresented_correctHeaderDisplayed() { app.goToScreenWithIdentifier(MockUserOtherSessionsScreenState.inactiveSessions.title) XCTAssertTrue(app.staticTexts[VectorL10n.userSessionsOverviewSecurityRecommendationsInactiveTitle].exists) - XCTAssertTrue(app.staticTexts[VectorL10n.userSessionsOverviewSecurityRecommendationsInactiveInfo].exists) + XCTAssertTrue(app.staticTexts[VectorL10n.userSessionsOverviewSecurityRecommendationsInactiveInfo].exists) } func test_whenOtherSessionsWithInactiveSessionFilterPresented_correctItemsDisplayed() { diff --git a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Test/Unit/UserOtherSessionsViewModelTests.swift b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Test/Unit/UserOtherSessionsViewModelTests.swift index 57c2c6a7d..fcd77020a 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Test/Unit/UserOtherSessionsViewModelTests.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Test/Unit/UserOtherSessionsViewModelTests.swift @@ -19,14 +19,12 @@ import XCTest @testable import RiotSwiftUI class UserOtherSessionsViewModelTests: XCTestCase { - - func test_whenUserOtherSessionSelectedProcessed_completionWithShowUserSessionOverviewCalled() { let expectedUserSessionInfo = createUserSessionInfo(sessionId: "session 2") let sut = UserOtherSessionsViewModel(sessionInfos: [createUserSessionInfo(sessionId: "session 1"), - expectedUserSessionInfo], - filter: .inactive, - title: "Title") + expectedUserSessionInfo], + filter: .inactive, + title: "Title") var modelResult: UserOtherSessionsViewModelResult? sut.completion = { result in @@ -39,8 +37,8 @@ class UserOtherSessionsViewModelTests: XCTestCase { func test_whenModelCreated_withInactiveFilter_viewStateIsCorrect() { let sessionInfos = [createUserSessionInfo(sessionId: "session 1"), createUserSessionInfo(sessionId: "session 2")] let sut = UserOtherSessionsViewModel(sessionInfos: sessionInfos, - filter: .inactive, - title: "Title") + filter: .inactive, + title: "Title") let expectedHeader = UserOtherSessionsHeaderViewData(title: VectorL10n.userSessionsOverviewSecurityRecommendationsInactiveTitle, subtitle: VectorL10n.userSessionsOverviewSecurityRecommendationsInactiveInfo, @@ -51,7 +49,6 @@ class UserOtherSessionsViewModelTests: XCTestCase { XCTAssertEqual(sut.state, expectedState) } - private func createUserSessionInfo(sessionId: String) -> UserSessionInfo { UserSessionInfo(id: sessionId, name: "iOS", diff --git a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/UserOtherSessionsModels.swift b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/UserOtherSessionsModels.swift index 3cda39e33..53679d990 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/UserOtherSessionsModels.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/UserOtherSessionsModels.swift @@ -17,6 +17,7 @@ import Foundation // MARK: - Coordinator + enum UserOtherSessionsCoordinatorResult { case openSessionDetails(sessionInfo: UserSessionInfo) } @@ -38,6 +39,7 @@ enum UserOtherSessionsSection: Hashable, Identifiable { var id: Self { self } + case sessionItems(header: UserOtherSessionsHeaderViewData, items: [UserSessionListItemViewData]) } diff --git a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/UserOtherSessionsViewModel.swift b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/UserOtherSessionsViewModel.swift index 4673bb7a0..706093f8b 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/UserOtherSessionsViewModel.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/UserOtherSessionsViewModel.swift @@ -25,7 +25,6 @@ enum OtherUserSessionsFilter { } class UserOtherSessionsViewModel: UserOtherSessionsViewModelType, UserOtherSessionsViewModelProtocol { - var completion: ((UserOtherSessionsViewModelResult) -> Void)? private let sessionInfos: [UserSessionInfo] @@ -42,7 +41,7 @@ class UserOtherSessionsViewModel: UserOtherSessionsViewModelType, UserOtherSessi override func process(viewAction: UserOtherSessionsViewAction) { switch viewAction { case let .userOtherSessionSelected(sessionId: sessionId): - guard let session = sessionInfos.first(where: {$0.id == sessionId}) else { + guard let session = sessionInfos.first(where: { $0.id == sessionId }) else { assertionFailure("Session should exist in the array.") return } diff --git a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/View/UserOtherSessions.swift b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/View/UserOtherSessions.swift index 320a04598..6bcc7d034 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/View/UserOtherSessions.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/View/UserOtherSessions.swift @@ -17,7 +17,6 @@ import SwiftUI struct UserOtherSessions: View { - @Environment(\.theme) private var theme @ObservedObject var viewModel: UserOtherSessionsViewModel.Context @@ -57,7 +56,6 @@ struct UserOtherSessions: View { // MARK: - Previews struct UserOtherSessions_Previews: PreviewProvider { - static let stateRenderer = MockUserOtherSessionsScreenState.stateRenderer static var previews: some View { diff --git a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/View/UserOtherSessionsHeaderView.swift b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/View/UserOtherSessionsHeaderView.swift index 83ba4ce51..c3d1e4dbf 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/View/UserOtherSessionsHeaderView.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/View/UserOtherSessionsHeaderView.swift @@ -23,7 +23,6 @@ struct UserOtherSessionsHeaderViewData: Hashable { } struct UserOtherSessionsHeaderView: View { - private var backgroundShape: RoundedRectangle { RoundedRectangle(cornerRadius: 8) } @@ -33,7 +32,7 @@ struct UserOtherSessionsHeaderView: View { let viewData: UserOtherSessionsHeaderViewData var body: some View { - HStack (alignment: .top, spacing: 0) { + HStack(alignment: .top, spacing: 0) { if let iconName = viewData.iconName { Image(iconName) .frame(width: 40, height: 40) @@ -63,12 +62,10 @@ struct UserOtherSessionsHeaderView: View { // MARK: - Previews struct UserOtherSessionsHeaderView_Previews: PreviewProvider { - private static let inactiveSessionViewData = UserOtherSessionsHeaderViewData(title: VectorL10n.userSessionsOverviewSecurityRecommendationsInactiveTitle, subtitle: VectorL10n.userSessionsOverviewSecurityRecommendationsInactiveInfo, iconName: Asset.Images.userOtherSessionsInactive.name) - static var previews: some View { Group { UserOtherSessionsHeaderView(viewData: self.inactiveSessionViewData) diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/MockUserSessionDetailsScreenState.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/MockUserSessionDetailsScreenState.swift index ea23cde61..51b8e883a 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/MockUserSessionDetailsScreenState.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/MockUserSessionDetailsScreenState.swift @@ -42,38 +42,38 @@ enum MockUserSessionDetailsScreenState: MockScreenState, CaseIterable { switch self { case .allSections: sessionInfo = UserSessionInfo(id: "alice", - name: "iOS", - deviceType: .mobile, - isVerified: false, - lastSeenIP: "10.0.0.10", - lastSeenTimestamp: nil, - applicationName: "Element iOS", - applicationVersion: "1.0.0", - applicationURL: nil, - deviceModel: nil, - deviceOS: "iOS 15.5", - lastSeenIPLocation: nil, - clientName: "Element", - clientVersion: "1.0.0", - isActive: true, - isCurrent: true) + name: "iOS", + deviceType: .mobile, + isVerified: false, + lastSeenIP: "10.0.0.10", + lastSeenTimestamp: nil, + applicationName: "Element iOS", + applicationVersion: "1.0.0", + applicationURL: nil, + deviceModel: nil, + deviceOS: "iOS 15.5", + lastSeenIPLocation: nil, + clientName: "Element", + clientVersion: "1.0.0", + isActive: true, + isCurrent: true) case .sessionSectionOnly: sessionInfo = UserSessionInfo(id: "3", - name: "Android", - deviceType: .mobile, - isVerified: false, - lastSeenIP: "3.0.0.3", - lastSeenTimestamp: Date().timeIntervalSince1970 - 10, - applicationName: "Element Android", - applicationVersion: "1.0.0", - applicationURL: nil, - deviceModel: nil, - deviceOS: "Android 4.0", - lastSeenIPLocation: nil, - clientName: "Element", - clientVersion: "1.0.0", - isActive: true, - isCurrent: false) + name: "Android", + deviceType: .mobile, + isVerified: false, + lastSeenIP: "3.0.0.3", + lastSeenTimestamp: Date().timeIntervalSince1970 - 10, + applicationName: "Element Android", + applicationVersion: "1.0.0", + applicationURL: nil, + deviceModel: nil, + deviceOS: "Android 4.0", + lastSeenIPLocation: nil, + clientName: "Element", + clientVersion: "1.0.0", + isActive: true, + isCurrent: false) } let viewModel = UserSessionDetailsViewModel(sessionInfo: sessionInfo) diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/Service/MatrixSDK/UserSessionOverviewService.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/Service/MatrixSDK/UserSessionOverviewService.swift index 0b7e93e03..857eef371 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/Service/MatrixSDK/UserSessionOverviewService.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/Service/MatrixSDK/UserSessionOverviewService.swift @@ -18,7 +18,6 @@ import Combine import MatrixSDK class UserSessionOverviewService: UserSessionOverviewServiceProtocol { - // MARK: - Members private(set) var pusherEnabledSubject: CurrentValueSubject @@ -36,10 +35,10 @@ class UserSessionOverviewService: UserSessionOverviewServiceProtocol { init(session: MXSession, sessionInfo: UserSessionInfo) { self.session = session self.sessionInfo = sessionInfo - self.pusherEnabledSubject = CurrentValueSubject(nil) - self.remotelyTogglingPushersAvailableSubject = CurrentValueSubject(false) + pusherEnabledSubject = CurrentValueSubject(nil) + remotelyTogglingPushersAvailableSubject = CurrentValueSubject(false) - self.localNotificationSettings = session.accountData.localNotificationSettingsForDevice(withId: sessionInfo.id) + localNotificationSettings = session.accountData.localNotificationSettingsForDevice(withId: sessionInfo.id) if let localNotificationSettings = localNotificationSettings, let isSilenced = localNotificationSettings[kMXAccountDataIsSilencedKey] as? Bool { remotelyTogglingPushersAvailableSubject.send(true) @@ -69,7 +68,7 @@ class UserSessionOverviewService: UserSessionOverviewServiceProtocol { // MARK: - Private private func toggle(_ pusher: MXPusher, enabled: Bool) { - guard self.remotelyTogglingPushersAvailableSubject.value else { + guard remotelyTogglingPushersAvailableSubject.value else { MXLog.warning("[UserSessionOverviewService] toggle pusher canceled: remotely toggling pushers not available") return } @@ -77,16 +76,16 @@ class UserSessionOverviewService: UserSessionOverviewServiceProtocol { MXLog.debug("[UserSessionOverviewService] remotely toggling pusher") let data = pusher.data.jsonDictionary() as? [String: Any] ?? [:] - self.session.matrixRestClient.setPusher(pushKey: pusher.pushkey, - kind: MXPusherKind(value: pusher.kind), - appId: pusher.appId, - appDisplayName:pusher.appDisplayName, - deviceDisplayName: pusher.deviceDisplayName, - profileTag: pusher.profileTag ?? "", - lang: pusher.lang, - data: data, - append: false, - enabled: enabled) { [weak self] response in + session.matrixRestClient.setPusher(pushKey: pusher.pushkey, + kind: MXPusherKind(value: pusher.kind), + appId: pusher.appId, + appDisplayName: pusher.appDisplayName, + deviceDisplayName: pusher.deviceDisplayName, + profileTag: pusher.profileTag ?? "", + lang: pusher.lang, + data: data, + append: false, + enabled: enabled) { [weak self] response in guard let self = self else { return } switch response { diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/Service/Mock/MockUserSessionOverviewService.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/Service/Mock/MockUserSessionOverviewService.swift index ccd6f63dd..f5447e6cb 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/Service/Mock/MockUserSessionOverviewService.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/Service/Mock/MockUserSessionOverviewService.swift @@ -18,18 +18,16 @@ import Combine import Foundation class MockUserSessionOverviewService: UserSessionOverviewServiceProtocol { - - var pusherEnabledSubject: CurrentValueSubject var remotelyTogglingPushersAvailableSubject: CurrentValueSubject init(pusherEnabled: Bool? = nil, remotelyTogglingPushersAvailable: Bool = true) { - self.pusherEnabledSubject = CurrentValueSubject(pusherEnabled) - self.remotelyTogglingPushersAvailableSubject = CurrentValueSubject(remotelyTogglingPushersAvailable) + pusherEnabledSubject = CurrentValueSubject(pusherEnabled) + remotelyTogglingPushersAvailableSubject = CurrentValueSubject(remotelyTogglingPushersAvailable) } func togglePushNotifications() { - guard let enabled = pusherEnabledSubject.value, self.remotelyTogglingPushersAvailableSubject.value else { + guard let enabled = pusherEnabledSubject.value, remotelyTogglingPushersAvailableSubject.value else { return } diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/Test/Unit/UserSessionOverviewViewModelTests.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/Test/Unit/UserSessionOverviewViewModelTests.swift index 6f1859dd2..6a51a3a9e 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/Test/Unit/UserSessionOverviewViewModelTests.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/Test/Unit/UserSessionOverviewViewModelTests.swift @@ -20,7 +20,6 @@ import XCTest @testable import RiotSwiftUI class UserSessionOverviewViewModelTests: XCTestCase { - func test_whenVerifyCurrentSessionProcessed_completionWithVerifyCurrentSessionCalled() { let sut = UserSessionOverviewViewModel(sessionInfo: createUserSessionInfo(), service: MockUserSessionOverviewService()) diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/UserSessionOverviewViewModel.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/UserSessionOverviewViewModel.swift index 5596c703f..08c0218a1 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/UserSessionOverviewViewModel.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/UserSessionOverviewViewModel.swift @@ -67,7 +67,7 @@ class UserSessionOverviewViewModel: UserSessionOverviewViewModelType, UserSessio case .viewSessionDetails: completion?(.showSessionDetails(sessionInfo: sessionInfo)) case .togglePushNotifications: - self.state.showLoadingIndicator = true + state.showLoadingIndicator = true service.togglePushNotifications() case .renameSession: completion?(.renameSession(sessionInfo)) diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Coordinator/UserSessionsOverviewCoordinator.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Coordinator/UserSessionsOverviewCoordinator.swift index 30cc83767..c3117f9ba 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Coordinator/UserSessionsOverviewCoordinator.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Coordinator/UserSessionsOverviewCoordinator.swift @@ -69,6 +69,8 @@ final class UserSessionsOverviewCoordinator: Coordinator, Presentable { self.showCurrentSessionOverview(sessionInfo: sessionInfo) case let .showUserSessionOverview(sessionInfo): self.showUserSessionOverview(sessionInfo: sessionInfo) + case .linkDevice: + self.completion?(.linkDevice) } } } @@ -107,5 +109,4 @@ final class UserSessionsOverviewCoordinator: Coordinator, Presentable { private func showUserSessionOverview(sessionInfo: UserSessionInfo) { completion?(.openSessionOverview(sessionInfo: sessionInfo)) } - } diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/MatrixSDK/UserSessionsDataProvider.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/MatrixSDK/UserSessionsDataProvider.swift index a8d36cc4e..9b3f145fc 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/MatrixSDK/UserSessionsDataProvider.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/MatrixSDK/UserSessionsDataProvider.swift @@ -47,4 +47,10 @@ class UserSessionsDataProvider: UserSessionsDataProviderProtocol { func accountData(for eventType: String) -> [AnyHashable: Any]? { session.accountData.accountData(forEventType: eventType) } + + func qrLoginAvailable() async throws -> Bool { + let service = QRLoginService(client: session.matrixRestClient, + mode: .authenticated) + return try await service.isServiceAvailable() + } } diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/MatrixSDK/UserSessionsDataProviderProtocol.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/MatrixSDK/UserSessionsDataProviderProtocol.swift index e97310a40..2f07e3794 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/MatrixSDK/UserSessionsDataProviderProtocol.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/MatrixSDK/UserSessionsDataProviderProtocol.swift @@ -29,4 +29,6 @@ protocol UserSessionsDataProviderProtocol { func device(withDeviceId deviceId: String, ofUser userId: String) -> MXDeviceInfo? func accountData(for eventType: String) -> [AnyHashable: Any]? + + func qrLoginAvailable() async throws -> Bool } diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/MatrixSDK/UserSessionsOverviewService.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/MatrixSDK/UserSessionsOverviewService.swift index 36d07f427..a0dda3222 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/MatrixSDK/UserSessionsOverviewService.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/MatrixSDK/UserSessionsOverviewService.swift @@ -32,7 +32,8 @@ class UserSessionsOverviewService: UserSessionsOverviewServiceProtocol { overviewData = UserSessionsOverviewData(currentSession: nil, unverifiedSessions: [], inactiveSessions: [], - otherSessions: []) + otherSessions: [], + linkDeviceEnabled: false) sessionInfos = [] setupInitialOverviewData() } @@ -44,8 +45,12 @@ class UserSessionsOverviewService: UserSessionsOverviewServiceProtocol { switch response { case .success(let devices): self.sessionInfos = self.sortedSessionInfos(from: devices) - self.overviewData = self.sessionsOverviewData(from: self.sessionInfos) - completion(.success(self.overviewData)) + Task { @MainActor in + let linkDeviceEnabled = try? await self.dataProvider.qrLoginAvailable() + self.overviewData = self.sessionsOverviewData(from: self.sessionInfos, + linkDeviceEnabled: linkDeviceEnabled ?? false) + completion(.success(self.overviewData)) + } case .failure(let error): completion(.failure(error)) } @@ -59,7 +64,7 @@ class UserSessionsOverviewService: UserSessionsOverviewServiceProtocol { return overviewData.otherSessions.first(where: { $0.id == sessionId }) } - + // MARK: - Private private func setupInitialOverviewData() { @@ -70,7 +75,8 @@ class UserSessionsOverviewService: UserSessionsOverviewServiceProtocol { overviewData = UserSessionsOverviewData(currentSession: currentSessionInfo, unverifiedSessions: currentSessionInfo.isVerified ? [] : [currentSessionInfo], inactiveSessions: currentSessionInfo.isActive ? [] : [currentSessionInfo], - otherSessions: []) + otherSessions: [], + linkDeviceEnabled: false) } private func getCurrentSessionInfo() -> UserSessionInfo? { @@ -87,11 +93,13 @@ class UserSessionsOverviewService: UserSessionsOverviewServiceProtocol { .map { sessionInfo(from: $0, isCurrentSession: $0.deviceId == dataProvider.myDeviceId) } } - private func sessionsOverviewData(from allSessions: [UserSessionInfo]) -> UserSessionsOverviewData { + private func sessionsOverviewData(from allSessions: [UserSessionInfo], + linkDeviceEnabled: Bool) -> UserSessionsOverviewData { UserSessionsOverviewData(currentSession: allSessions.filter(\.isCurrent).first, unverifiedSessions: allSessions.filter { !$0.isVerified }, inactiveSessions: allSessions.filter { !$0.isActive }, - otherSessions: allSessions.filter { !$0.isCurrent }) + otherSessions: allSessions.filter { !$0.isCurrent }, + linkDeviceEnabled: linkDeviceEnabled) } private func sessionInfo(from device: MXDevice, isCurrentSession: Bool) -> UserSessionInfo { diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/Mock/MockUserSessionsOverviewService.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/Mock/MockUserSessionsOverviewService.swift index 53c8df8b7..95b56511e 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/Mock/MockUserSessionsOverviewService.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/Mock/MockUserSessionsOverviewService.swift @@ -17,7 +17,6 @@ import Foundation class MockUserSessionsOverviewService: UserSessionsOverviewServiceProtocol { - enum Mode { case currentSessionUnverified case currentSessionVerified @@ -37,7 +36,8 @@ class MockUserSessionsOverviewService: UserSessionsOverviewServiceProtocol { overviewData = UserSessionsOverviewData(currentSession: nil, unverifiedSessions: [], inactiveSessions: [], - otherSessions: []) + otherSessions: [], + linkDeviceEnabled: false) } func updateOverviewData(completion: @escaping (Result) -> Void) { @@ -49,24 +49,28 @@ class MockUserSessionsOverviewService: UserSessionsOverviewServiceProtocol { overviewData = UserSessionsOverviewData(currentSession: currentSession, unverifiedSessions: [], inactiveSessions: [], - otherSessions: []) + otherSessions: [], + linkDeviceEnabled: false) case .onlyUnverifiedSessions: overviewData = UserSessionsOverviewData(currentSession: currentSession, unverifiedSessions: unverifiedSessions + [currentSession], inactiveSessions: [], - otherSessions: unverifiedSessions) + otherSessions: unverifiedSessions, + linkDeviceEnabled: false) case .onlyInactiveSessions: overviewData = UserSessionsOverviewData(currentSession: currentSession, unverifiedSessions: [], inactiveSessions: inactiveSessions, - otherSessions: inactiveSessions) + otherSessions: inactiveSessions, + linkDeviceEnabled: false) default: let otherSessions = unverifiedSessions + inactiveSessions + buildSessions(verified: true, active: true) overviewData = UserSessionsOverviewData(currentSession: currentSession, unverifiedSessions: unverifiedSessions, inactiveSessions: inactiveSessions, - otherSessions: otherSessions) + otherSessions: otherSessions, + linkDeviceEnabled: true) } completion(.success(overviewData)) @@ -75,7 +79,7 @@ class MockUserSessionsOverviewService: UserSessionsOverviewServiceProtocol { func sessionForIdentifier(_ sessionId: String) -> UserSessionInfo? { overviewData.otherSessions.first { $0.id == sessionId } } - + // MARK: - Private private var currentSession: UserSessionInfo { @@ -103,7 +107,7 @@ class MockUserSessionsOverviewService: UserSessionsOverviewServiceProtocol { deviceType: .desktop, isVerified: verified, lastSeenIP: "1.0.0.1", - lastSeenTimestamp: Date().timeIntervalSince1970 - 8000000, + lastSeenTimestamp: Date().timeIntervalSince1970 - 8_000_000, applicationName: "Element MacOS", applicationVersion: "1.0.0", applicationURL: nil, diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/UserSessionsOverviewServiceProtocol.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/UserSessionsOverviewServiceProtocol.swift index 3f69b814b..ac7a98b87 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/UserSessionsOverviewServiceProtocol.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/UserSessionsOverviewServiceProtocol.swift @@ -21,6 +21,7 @@ struct UserSessionsOverviewData { let unverifiedSessions: [UserSessionInfo] let inactiveSessions: [UserSessionInfo] let otherSessions: [UserSessionInfo] + let linkDeviceEnabled: Bool } protocol UserSessionsOverviewServiceProtocol { diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Test/UI/UserSessionsOverviewUITests.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Test/UI/UserSessionsOverviewUITests.swift index f05203cb7..a92c3a716 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Test/UI/UserSessionsOverviewUITests.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Test/UI/UserSessionsOverviewUITests.swift @@ -23,6 +23,8 @@ class UserSessionsOverviewUITests: MockScreenTestCase { XCTAssertTrue(app.buttons["userSessionCardVerifyButton"].exists) XCTAssertTrue(app.staticTexts["userSessionCardViewDetails"].exists) + + verifyLinkDeviceButtonStatus(true) } func testCurrentSessionVerified() { @@ -30,6 +32,8 @@ class UserSessionsOverviewUITests: MockScreenTestCase { XCTAssertFalse(app.buttons["userSessionCardVerifyButton"].exists) XCTAssertTrue(app.staticTexts["userSessionCardViewDetails"].exists) + + verifyLinkDeviceButtonStatus(true) } func testOnlyUnverifiedSessions() { @@ -37,6 +41,8 @@ class UserSessionsOverviewUITests: MockScreenTestCase { XCTAssertTrue(app.staticTexts["userSessionsOverviewSecurityRecommendationsSection"].exists) XCTAssertTrue(app.staticTexts["userSessionsOverviewOtherSection"].exists) + + verifyLinkDeviceButtonStatus(false) } func testOnlyInactiveSessions() { @@ -44,6 +50,8 @@ class UserSessionsOverviewUITests: MockScreenTestCase { XCTAssertTrue(app.staticTexts["userSessionsOverviewSecurityRecommendationsSection"].exists) XCTAssertTrue(app.staticTexts["userSessionsOverviewOtherSection"].exists) + + verifyLinkDeviceButtonStatus(false) } func testNoOtherSessions() { @@ -51,5 +59,18 @@ class UserSessionsOverviewUITests: MockScreenTestCase { XCTAssertFalse(app.staticTexts["userSessionsOverviewSecurityRecommendationsSection"].exists) XCTAssertFalse(app.staticTexts["userSessionsOverviewOtherSection"].exists) + + verifyLinkDeviceButtonStatus(false) + } + + func verifyLinkDeviceButtonStatus(_ enabled: Bool) { + if enabled { + let linkDeviceButton = app.buttons["linkDeviceButton"] + XCTAssertTrue(linkDeviceButton.exists) + XCTAssertTrue(linkDeviceButton.isEnabled) + } else { + let linkDeviceButton = app.buttons["linkDeviceButton"] + XCTAssertFalse(linkDeviceButton.exists) + } } } diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Test/Unit/UserSessionsOverviewViewModelTests.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Test/Unit/UserSessionsOverviewViewModelTests.swift index 815f2dcc5..4a6115f11 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Test/Unit/UserSessionsOverviewViewModelTests.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Test/Unit/UserSessionsOverviewViewModelTests.swift @@ -27,6 +27,7 @@ class UserSessionsOverviewViewModelTests: XCTestCase { XCTAssertTrue(viewModel.state.unverifiedSessionsViewData.isEmpty) XCTAssertTrue(viewModel.state.inactiveSessionsViewData.isEmpty) XCTAssertTrue(viewModel.state.otherSessionsViewData.isEmpty) + XCTAssertFalse(viewModel.state.linkDeviceButtonVisible) } func testLoadOnDidAppear() { @@ -37,6 +38,7 @@ class UserSessionsOverviewViewModelTests: XCTestCase { XCTAssertFalse(viewModel.state.unverifiedSessionsViewData.isEmpty) XCTAssertFalse(viewModel.state.inactiveSessionsViewData.isEmpty) XCTAssertFalse(viewModel.state.otherSessionsViewData.isEmpty) + XCTAssertTrue(viewModel.state.linkDeviceButtonVisible) } func testSimpleActionProcessing() { @@ -52,6 +54,9 @@ class UserSessionsOverviewViewModelTests: XCTestCase { viewModel.process(viewAction: .viewAllInactiveSessions) XCTAssertEqual(result, .showOtherSessions(sessionInfos: [], filter: .inactive)) + + viewModel.process(viewAction: .linkDevice) + XCTAssertEqual(result, .linkDevice) } func testShowSessionDetails() { diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/UserSessionsOverviewModels.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/UserSessionsOverviewModels.swift index 601521000..b8fadf8ee 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/UserSessionsOverviewModels.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/UserSessionsOverviewModels.swift @@ -23,6 +23,7 @@ enum UserSessionsOverviewCoordinatorResult { case logoutOfSession(UserSessionInfo) case openSessionOverview(sessionInfo: UserSessionInfo) case openOtherSessions(sessionInfos: [UserSessionInfo], filter: OtherUserSessionsFilter) + case linkDevice } // MARK: View model @@ -34,6 +35,7 @@ enum UserSessionsOverviewViewModelResult: Equatable { case logoutOfSession(UserSessionInfo) case showCurrentSessionOverview(sessionInfo: UserSessionInfo) case showUserSessionOverview(sessionInfo: UserSessionInfo) + case linkDevice } // MARK: View @@ -48,6 +50,8 @@ struct UserSessionsOverviewViewState: BindableState { var otherSessionsViewData = [UserSessionListItemViewData]() var showLoadingIndicator = false + + var linkDeviceButtonVisible = false } enum UserSessionsOverviewViewAction { @@ -60,4 +64,5 @@ enum UserSessionsOverviewViewAction { case viewAllInactiveSessions case viewAllOtherSessions case tapUserSession(_ sessionId: String) + case linkDevice } diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/UserSessionsOverviewViewModel.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/UserSessionsOverviewViewModel.swift index 4e9cb90ea..22a530f27 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/UserSessionsOverviewViewModel.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/UserSessionsOverviewViewModel.swift @@ -70,6 +70,8 @@ class UserSessionsOverviewViewModel: UserSessionsOverviewViewModelType, UserSess return } completion?(.showUserSessionOverview(sessionInfo: session)) + case .linkDevice: + completion?(.linkDevice) } } @@ -83,6 +85,7 @@ class UserSessionsOverviewViewModel: UserSessionsOverviewViewModelType, UserSess if let currentSessionInfo = userSessionsViewData.currentSession { state.currentSessionViewData = UserSessionCardViewData(sessionInfo: currentSessionInfo) } + state.linkDeviceButtonVisible = userSessionsViewData.linkDeviceEnabled } private func loadData() { @@ -113,6 +116,6 @@ class UserSessionsOverviewViewModel: UserSessionsOverviewViewModelType, UserSess extension Collection where Element == UserSessionInfo { func asViewData() -> [UserSessionListItemViewData] { - map { UserSessionListItemViewDataFactory().create(from: $0)} + map { UserSessionListItemViewDataFactory().create(from: $0) } } } diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItemViewData.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItemViewData.swift index 00c24061b..6cddefda2 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItemViewData.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItemViewData.swift @@ -18,7 +18,6 @@ import Foundation /// View data for UserSessionListItem struct UserSessionListItemViewData: Identifiable, Hashable { - var id: String { sessionId } diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItemViewDataFactory.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItemViewDataFactory.swift index 8adb72c3c..ad1afc32f 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItemViewDataFactory.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItemViewDataFactory.swift @@ -17,7 +17,6 @@ import Foundation struct UserSessionListItemViewDataFactory { - func create(from sessionInfo: UserSessionInfo, highlightSessionDetails: Bool = false) -> UserSessionListItemViewData { let sessionName = UserSessionNameFormatter.sessionName(deviceType: sessionInfo.deviceType, sessionDisplayName: sessionInfo.name) @@ -41,7 +40,7 @@ struct UserSessionListItemViewDataFactory { } private func inactiveSessionDetails(sessionInfo: UserSessionInfo) -> String { - if let lastActivityDate = sessionInfo.lastSeenTimestamp { + if let lastActivityDate = sessionInfo.lastSeenTimestamp { let lastActivityDateString = InactiveUserSessionLastActivityFormatter.lastActivityDateString(from: lastActivityDate) return VectorL10n.userInactiveSessionItemWithDate(lastActivityDateString) } diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionsOverview.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionsOverview.swift index 18f9ad91f..66fd8b253 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionsOverview.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionsOverview.swift @@ -22,15 +22,25 @@ struct UserSessionsOverview: View { @ObservedObject var viewModel: UserSessionsOverviewViewModel.Context var body: some View { - ScrollView { - if hasSecurityRecommendations { - securityRecommendationsSection - } - - currentSessionsSection - - if !viewModel.viewState.otherSessionsViewData.isEmpty { - otherSessionsSection + GeometryReader { geometry in + VStack(alignment: .leading, spacing: 0) { + ScrollView { + if hasSecurityRecommendations { + securityRecommendationsSection + } + + currentSessionsSection + + if !viewModel.viewState.otherSessionsViewData.isEmpty { + otherSessionsSection + } + } + .readableFrame() + + if viewModel.viewState.linkDeviceButtonVisible { + linkDeviceView + .padding(.bottom, geometry.safeAreaInsets.bottom > 0 ? 20 : 36) + } } } .background(theme.colors.system.ignoresSafeArea()) @@ -158,6 +168,23 @@ struct UserSessionsOverview: View { } .accessibilityIdentifier("userSessionsOverviewOtherSection") } + + /// The footer view containing link device button. + var linkDeviceView: some View { + VStack { + Button { + viewModel.send(viewAction: .linkDevice) + } label: { + Text(VectorL10n.userSessionsOverviewLinkDevice) + } + .buttonStyle(PrimaryActionButtonStyle(font: theme.fonts.bodySB)) + .padding(.top, 28) + .padding(.bottom, 12) + .padding(.horizontal, 16) + .accessibilityIdentifier("linkDeviceButton") + } + .background(theme.colors.system.ignoresSafeArea()) + } } // MARK: - Previews diff --git a/RiotTests/UserSessionsOverviewServiceTests.swift b/RiotTests/UserSessionsOverviewServiceTests.swift index 1c3fb7516..14d5d064e 100644 --- a/RiotTests/UserSessionsOverviewServiceTests.swift +++ b/RiotTests/UserSessionsOverviewServiceTests.swift @@ -32,6 +32,7 @@ class UserSessionsOverviewServiceTests: XCTestCase { XCTAssertTrue(service.overviewData.currentSession?.isActive ?? false) XCTAssertFalse(service.overviewData.unverifiedSessions.isEmpty) XCTAssertTrue(service.overviewData.inactiveSessions.isEmpty) + XCTAssertFalse(service.overviewData.linkDeviceEnabled) XCTAssertEqual(service.sessionForIdentifier(currentDeviceId), service.overviewData.currentSession) } @@ -45,6 +46,7 @@ class UserSessionsOverviewServiceTests: XCTestCase { XCTAssertTrue(service.overviewData.currentSession?.isActive ?? false) XCTAssertTrue(service.overviewData.unverifiedSessions.isEmpty) XCTAssertTrue(service.overviewData.inactiveSessions.isEmpty) + XCTAssertFalse(service.overviewData.linkDeviceEnabled) } func testWithAllSessionsVerified() { @@ -57,6 +59,7 @@ class UserSessionsOverviewServiceTests: XCTestCase { XCTAssertTrue(service.overviewData.unverifiedSessions.isEmpty) XCTAssertTrue(service.overviewData.inactiveSessions.isEmpty) XCTAssertFalse(service.overviewData.otherSessions.isEmpty) + XCTAssertTrue(service.overviewData.linkDeviceEnabled) XCTAssertEqual(service.sessionInfos.count, 2) } @@ -71,6 +74,7 @@ class UserSessionsOverviewServiceTests: XCTestCase { XCTAssertFalse(service.overviewData.unverifiedSessions.isEmpty) XCTAssertTrue(service.overviewData.inactiveSessions.isEmpty) XCTAssertFalse(service.overviewData.otherSessions.isEmpty) + XCTAssertTrue(service.overviewData.linkDeviceEnabled) XCTAssertEqual(service.sessionInfos.count, 3) } @@ -85,6 +89,7 @@ class UserSessionsOverviewServiceTests: XCTestCase { XCTAssertTrue(service.overviewData.unverifiedSessions.isEmpty) XCTAssertFalse(service.overviewData.inactiveSessions.isEmpty) XCTAssertFalse(service.overviewData.otherSessions.isEmpty) + XCTAssertTrue(service.overviewData.linkDeviceEnabled) XCTAssertEqual(service.sessionInfos.count, 3) } @@ -99,6 +104,7 @@ class UserSessionsOverviewServiceTests: XCTestCase { XCTAssertFalse(service.overviewData.unverifiedSessions.isEmpty) XCTAssertFalse(service.overviewData.inactiveSessions.isEmpty) XCTAssertFalse(service.overviewData.otherSessions.isEmpty) + XCTAssertTrue(service.overviewData.linkDeviceEnabled) XCTAssertEqual(service.sessionInfos.count, 4) } @@ -179,6 +185,10 @@ private class MockUserSessionsDataProvider: UserSessionsDataProviderProtocol { func accountData(for eventType: String) -> [AnyHashable : Any]? { [:] } + + func qrLoginAvailable() async throws -> Bool { + true + } // MARK: - Private