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/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/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/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 0b5f402fc..415b5b52e 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:";
@@ -2375,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)";
@@ -2393,6 +2426,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/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 4908bfb7d..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")
@@ -106,6 +108,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 ee81b2b73..26e3c76ee 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")
@@ -8507,6 +8611,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")
@@ -8631,6 +8743,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/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/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/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/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/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/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..128e9b161 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,65 @@ static NSArray *initialSyncSilentErrorsHTTPStatusCodes;
}];
}
+- (void)loadCurrentPusher:(void (^)(void))success failure:(void (^)(NSError *error))failure
+{
+ if (!self.mxSession.myDeviceId)
+ {
+ MXLogWarning(@"[MXKAccount] loadPusher: device ID not found");
+ if (failure)
+ {
+ failure([NSError errorWithDomain:kMXKAccountErrorDomain code:0 userInfo:nil]);
+ }
+ 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 +879,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 +1214,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 +1279,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 +1552,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/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/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..24edbf1cf
--- /dev/null
+++ b/Riot/Modules/Rendezvous/RendezvousModels.swift
@@ -0,0 +1,38 @@
+//
+// 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 iv: String
+ var ciphertext: String
+}
diff --git a/Riot/Modules/Rendezvous/RendezvousService.swift b/Riot/Modules/Rendezvous/RendezvousService.swift
new file mode 100644
index 000000000..84583a583
--- /dev/null
+++ b/Riot/Modules/Rendezvous/RendezvousService.swift
@@ -0,0 +1,212 @@
+//
+// 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)
+}
+
+/// Algorithm name as per MSC3903
+enum RendezvousChannelAlgorithm: String {
+ 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
+ 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()
+ }
+
+ /// 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,
+ key: publicKeyString)
+
+ switch await transport.create(body: payload) {
+ case .failure(let transportError):
+ return .failure(.transportError(transportError))
+ case .success:
+ return .success(())
+ }
+ }
+
+ /// 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):
+ 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(())
+ }
+ }
+
+ /// 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)
+ }
+
+ 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
+
+ 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(())
+ }
+
+ /// 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)
+ }
+
+ // 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)
+ }
+
+ // 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):
+ return .failure(.transportError(transportError))
+ case .success:
+ return .success(())
+ }
+ }
+
+
+ /// 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)
+ }
+
+ 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 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)
+ }
+
+ return .success(messageData)
+ }
+ }
+
+ // 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
new file mode 100644
index 000000000..40b7db2cb
--- /dev/null
+++ b/Riot/Modules/Rendezvous/RendezvousTransport.swift
@@ -0,0 +1,146 @@
+//
+// 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 {
+ // The resouce changed, update the etag
+ 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..4c608ace8
--- /dev/null
+++ b/Riot/Modules/Rendezvous/RendezvousTransportProtocol.swift
@@ -0,0 +1,43 @@
+//
+// 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
+}
+
+/// 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>
+}
diff --git a/Riot/Modules/Room/DataSources/RoomDataSource.m b/Riot/Modules/Room/DataSources/RoomDataSource.m
index ed48741db..842623818 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;
@@ -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/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/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 583217637..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())
}
@@ -71,10 +71,12 @@ final class UserSessionsFlowCoordinator: Coordinator, Presentable {
self.showLogoutConfirmation(for: sessionInfo)
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)
+ 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,
@@ -112,8 +129,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
@@ -126,16 +143,15 @@ 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)
}
-
/// 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 fd7fa8932..607a87aa9 100644
--- a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Coordinator/UserOtherSessionsCoordinator.swift
+++ b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Coordinator/UserOtherSessionsCoordinator.swift
@@ -18,13 +18,12 @@ import CommonKit
import SwiftUI
struct UserOtherSessionsCoordinatorParameters {
- let sessionsInfo: [UserSessionInfo]
+ let sessionInfos: [UserSessionInfo]
let filter: OtherUserSessionsFilter
let title: String
}
final class UserOtherSessionsCoordinator: Coordinator, Presentable {
-
private let parameters: UserOtherSessionsCoordinatorParameters
private let userOtherSessionsHostingController: UIViewController
private var userOtherSessionsViewModel: UserOtherSessionsViewModelProtocol
@@ -38,7 +37,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..fd4493b62 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,22 @@ 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(sessionsInfo: 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.
@@ -74,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,
@@ -90,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,
@@ -106,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,
@@ -118,4 +126,39 @@ 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 - 8_000_000,
+ 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..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() {
@@ -31,4 +30,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/Test/Unit/UserOtherSessionsViewModelTests.swift b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Test/Unit/UserOtherSessionsViewModelTests.swift
index 43bf5c358..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(sessionsInfo: [createUserSessionInfo(sessionId: "session 1"),
- expectedUserSessionInfo],
- filter: .inactive,
- title: "Title")
+ let sut = UserOtherSessionsViewModel(sessionInfos: [createUserSessionInfo(sessionId: "session 1"),
+ expectedUserSessionInfo],
+ filter: .inactive,
+ title: "Title")
var modelResult: UserOtherSessionsViewModelResult?
sut.completion = { result in
@@ -37,21 +35,20 @@ class UserOtherSessionsViewModelTests: XCTestCase {
}
func test_whenModelCreated_withInactiveFilter_viewStateIsCorrect() {
- let sessionsInfo = [createUserSessionInfo(sessionId: "session 1"), createUserSessionInfo(sessionId: "session 2")]
- let sut = UserOtherSessionsViewModel(sessionsInfo: sessionsInfo,
- filter: .inactive,
- title: "Title")
+ 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)
}
-
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 a3c399791..706093f8b 100644
--- a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/UserOtherSessionsViewModel.swift
+++ b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/UserOtherSessionsViewModel.swift
@@ -25,16 +25,15 @@ 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 +41,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 +51,28 @@ 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 = createSectionItems(sessionInfos: sessionInfos, filter: filter)
let sectionHeader = createHeaderData(filter: filter)
state.sections = [.sessionItems(header: sectionHeader, items: sectionItems)]
}
- private func filterSessions(sessionsInfo: [UserSessionInfo], by filter: OtherUserSessionsFilter) -> [UserSessionInfo] {
+ 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:
- 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 }
}
}
@@ -81,11 +88,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/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 d6a2b344b..c3d1e4dbf 100644
--- a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/View/UserOtherSessionsHeaderView.swift
+++ b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/View/UserOtherSessionsHeaderView.swift
@@ -18,12 +18,11 @@ import SwiftUI
struct UserOtherSessionsHeaderViewData: Hashable {
var title: String?
- var subtitle: String
+ let subtitle: String
var iconName: String?
}
struct UserOtherSessionsHeaderView: View {
-
private var backgroundShape: RoundedRectangle {
RoundedRectangle(cornerRadius: 8)
}
@@ -33,10 +32,9 @@ 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)
- .foregroundColor(.red)
.frame(width: 40, height: 40)
.background(theme.colors.background)
.clipShape(backgroundShape)
@@ -64,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 794a2e291..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,20 +76,24 @@ 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 {
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/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 7c9e64a38..c3117f9ba 100644
--- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Coordinator/UserSessionsOverviewCoordinator.swift
+++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Coordinator/UserSessionsOverviewCoordinator.swift
@@ -57,8 +57,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 .renameSession(let sessionInfo):
@@ -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)
}
}
}
@@ -92,8 +94,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() {
@@ -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 273072ea7..a0dda3222 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
@@ -31,8 +32,9 @@ class UserSessionsOverviewService: UserSessionsOverviewServiceProtocol {
overviewData = UserSessionsOverviewData(currentSession: nil,
unverifiedSessions: [],
inactiveSessions: [],
- otherSessions: [])
-
+ otherSessions: [],
+ linkDeviceEnabled: false)
+ sessionInfos = []
setupInitialOverviewData()
}
@@ -42,8 +44,13 @@ class UserSessionsOverviewService: UserSessionsOverviewServiceProtocol {
dataProvider.devices { response in
switch response {
case .success(let devices):
- self.overviewData = self.sessionsOverviewData(from: devices)
- completion(.success(self.overviewData))
+ self.sessionInfos = self.sortedSessionInfos(from: devices)
+ 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))
}
@@ -57,7 +64,7 @@ class UserSessionsOverviewService: UserSessionsOverviewServiceProtocol {
return overviewData.otherSessions.first(where: { $0.id == sessionId })
}
-
+
// MARK: - Private
private func setupInitialOverviewData() {
@@ -68,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? {
@@ -78,16 +86,20 @@ class UserSessionsOverviewService: UserSessionsOverviewServiceProtocol {
}
return sessionInfo(from: device, isCurrentSession: true)
}
-
- private func sessionsOverviewData(from devices: [MXDevice]) -> UserSessionsOverviewData {
- let allSessions = devices
+
+ private func sortedSessionInfos(from 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],
+ linkDeviceEnabled: Bool) -> UserSessionsOverviewData {
+ UserSessionsOverviewData(currentSession: allSessions.filter(\.isCurrent).first,
+ unverifiedSessions: allSessions.filter { !$0.isVerified },
+ inactiveSessions: allSessions.filter { !$0.isActive },
+ 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 5a87dd27b..95b56511e 100644
--- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/Mock/MockUserSessionsOverviewService.swift
+++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/Mock/MockUserSessionsOverviewService.swift
@@ -28,6 +28,7 @@ class MockUserSessionsOverviewService: UserSessionsOverviewServiceProtocol {
private let mode: Mode
var overviewData: UserSessionsOverviewData
+ var sessionInfos = [UserSessionInfo]()
init(mode: Mode = .currentSessionUnverified) {
self.mode = mode
@@ -35,7 +36,8 @@ class MockUserSessionsOverviewService: UserSessionsOverviewServiceProtocol {
overviewData = UserSessionsOverviewData(currentSession: nil,
unverifiedSessions: [],
inactiveSessions: [],
- otherSessions: [])
+ otherSessions: [],
+ linkDeviceEnabled: false)
}
func updateOverviewData(completion: @escaping (Result) -> Void) {
@@ -47,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))
@@ -73,7 +79,7 @@ class MockUserSessionsOverviewService: UserSessionsOverviewServiceProtocol {
func sessionForIdentifier(_ sessionId: String) -> UserSessionInfo? {
overviewData.otherSessions.first { $0.id == sessionId }
}
-
+
// MARK: - Private
private var currentSession: UserSessionInfo {
@@ -101,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 b1bc5f001..ac7a98b87 100644
--- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/UserSessionsOverviewServiceProtocol.swift
+++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/UserSessionsOverviewServiceProtocol.swift
@@ -21,10 +21,12 @@ struct UserSessionsOverviewData {
let unverifiedSessions: [UserSessionInfo]
let inactiveSessions: [UserSessionInfo]
let otherSessions: [UserSessionInfo]
+ let linkDeviceEnabled: Bool
}
protocol UserSessionsOverviewServiceProtocol {
var overviewData: UserSessionsOverviewData { get }
+ var sessionInfos: [UserSessionInfo] { get }
func updateOverviewData(completion: @escaping (Result) -> Void) -> Void
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 8768f0fcd..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() {
@@ -51,7 +53,10 @@ class UserSessionsOverviewViewModelTests: XCTestCase {
XCTAssertEqual(result, .verifyCurrentSession)
viewModel.process(viewAction: .viewAllInactiveSessions)
- XCTAssertEqual(result, .showOtherSessions(sessionsInfo: [], filter: .inactive))
+ 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 dd4d045a7..b8fadf8ee 100644
--- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/UserSessionsOverviewModels.swift
+++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/UserSessionsOverviewModels.swift
@@ -22,18 +22,20 @@ enum UserSessionsOverviewCoordinatorResult {
case renameSession(UserSessionInfo)
case logoutOfSession(UserSessionInfo)
case openSessionOverview(sessionInfo: UserSessionInfo)
- case openOtherSessions(sessionsInfo: [UserSessionInfo], filter: OtherUserSessionsFilter)
+ case openOtherSessions(sessionInfos: [UserSessionInfo], filter: OtherUserSessionsFilter)
+ case linkDevice
}
// MARK: View model
enum UserSessionsOverviewViewModelResult: Equatable {
- case showOtherSessions(sessionsInfo: [UserSessionInfo], filter: OtherUserSessionsFilter)
+ case showOtherSessions(sessionInfos: [UserSessionInfo], filter: OtherUserSessionsFilter)
case verifyCurrentSession
case renameSession(UserSessionInfo)
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 7827553ac..22a530f27 100644
--- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/UserSessionsOverviewViewModel.swift
+++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/UserSessionsOverviewViewModel.swift
@@ -58,8 +58,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:
@@ -71,6 +70,8 @@ class UserSessionsOverviewViewModel: UserSessionsOverviewViewModelType, UserSess
return
}
completion?(.showUserSessionOverview(sessionInfo: session))
+ case .linkDevice:
+ completion?(.linkDevice)
}
}
@@ -84,6 +85,7 @@ class UserSessionsOverviewViewModel: UserSessionsOverviewViewModelType, UserSess
if let currentSessionInfo = userSessionsViewData.currentSession {
state.currentSessionViewData = UserSessionCardViewData(sessionInfo: currentSessionInfo)
}
+ state.linkDeviceButtonVisible = userSessionsViewData.linkDeviceEnabled
}
private func loadData() {
@@ -107,13 +109,13 @@ class UserSessionsOverviewViewModel: UserSessionsOverviewViewModelType, UserSess
}
private func showSessions(filteredBy filter: OtherUserSessionsFilter) {
- completion?(.showOtherSessions(sessionsInfo: userSessionsOverviewService.overviewData.otherSessions,
+ completion?(.showOtherSessions(sessionInfos: userSessionsOverviewService.sessionInfos,
filter: filter))
}
}
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/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..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
}
@@ -29,6 +28,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..ad1afc32f 100644
--- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItemViewDataFactory.swift
+++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItemViewDataFactory.swift
@@ -17,50 +17,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, highlightSessionDetails: Bool = false) -> 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: highlightSessionDetails,
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/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/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
+ }
}
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)
+ }
+}
diff --git a/RiotTests/UserSessionsOverviewServiceTests.swift b/RiotTests/UserSessionsOverviewServiceTests.swift
index d52ca31d9..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,9 @@ 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)
}
func testWithSomeUnverifiedSessions() {
@@ -69,6 +74,9 @@ 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)
}
func testWithSomeInactiveSessions() {
@@ -81,6 +89,9 @@ 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)
}
func testWithSomeUnverifiedAndInactiveSessions() {
@@ -93,6 +104,9 @@ 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)
}
// MARK: - Private
@@ -171,6 +185,10 @@ private class MockUserSessionsDataProvider: UserSessionsDataProviderProtocol {
func accountData(for eventType: String) -> [AnyHashable : Any]? {
[:]
}
+
+ func qrLoginAvailable() async throws -> Bool {
+ true
+ }
// MARK: - Private
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.
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
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
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