Merge branch 'develop' into johannes/session-name-trumps-device-type-name

This commit is contained in:
Johannes Marbach 2022-10-07 13:27:16 +02:00
commit 03b0cd80da
138 changed files with 5272 additions and 258 deletions

View file

@ -420,4 +420,7 @@ final class BuildSettings: NSObject {
// MARK: - New App Layout
static let newAppLayoutEnabled = true
// MARK: - QR Login
static let enableQRLogin = false
}

View file

@ -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
}
}

View file

@ -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'

View file

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "Secure connection.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -0,0 +1,11 @@
<svg width="70" height="70" viewBox="0 0 70 70" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="70" height="70" rx="35" fill="#0DBD8B"/>
<g clip-path="url(#clip0_432_24662)">
<path d="M61.6666 34.9997C61.6666 33.533 60.4666 32.333 59 32.333H53.48C52.1733 23.293 44.4133 16.333 35 16.333C25.5866 16.333 17.8266 23.293 16.52 32.333H11C9.53331 32.333 8.33331 33.533 8.33331 34.9997C8.33331 36.4663 9.53331 37.6663 11 37.6663H16.52C17.8266 46.7063 25.5866 53.6663 35 53.6663C44.4133 53.6663 52.1733 46.7063 53.48 37.6663H59C60.4666 37.6663 61.6666 36.4663 61.6666 34.9997ZM43 41.6663C43 43.133 41.8 44.333 40.3333 44.333H29.6666C28.2 44.333 27 43.133 27 41.6663V33.6663C27 32.1997 28.2 30.9997 29.6666 30.9997V28.333C29.6666 25.1063 32.5466 22.5197 35.9066 23.0797C38.52 23.5063 40.3333 25.9597 40.3333 28.6263V30.9997C41.8 30.9997 43 32.1997 43 33.6663V41.6663ZM37 37.6663C37 38.7597 36.0933 39.6663 35 39.6663C33.9066 39.6663 33 38.7597 33 37.6663C33 36.573 33.9066 35.6663 35 35.6663C36.0933 35.6663 37 36.573 37 37.6663ZM37.6666 28.333V30.9997H32.3333V28.333C32.3333 26.8663 33.5333 25.6663 35 25.6663C36.4666 25.6663 37.6666 26.8663 37.6666 28.333Z" fill="white"/>
</g>
<defs>
<clipPath id="clip0_432_24662">
<rect width="64" height="64" fill="white" transform="translate(3 3)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View file

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "exclamation_circle.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -0,0 +1,3 @@
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M20 40C31.0457 40 40 31.0457 40 20C40 8.9543 31.0457 0 20 0C8.9543 0 0 8.9543 0 20C0 31.0457 8.9543 40 20 40ZM18.0843 14.6883C18.0072 13.8089 18.6552 13.0374 19.5346 12.9757C20.3986 12.914 21.17 13.562 21.2626 14.4414V14.6883L20.7689 20.8597C20.7226 21.4306 20.2443 21.8626 19.6735 21.8626H19.5809C19.0409 21.8163 18.6243 21.3997 18.578 20.8597L18.0843 14.6883ZM21.015 24.8868C21.015 25.6366 20.4071 26.2445 19.6573 26.2445C18.9075 26.2445 18.2996 25.6366 18.2996 24.8868C18.2996 24.1369 18.9075 23.5291 19.6573 23.5291C20.4071 23.5291 21.015 24.1369 21.015 24.8868Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 735 B

View file

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "user_other_sessions_unverified.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -0,0 +1,5 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.21228 17.436C9.12341 17.4591 9.05182 17.4763 9 17.4883C8.94819 17.4763 8.87659 17.4591 8.78772 17.436C8.58344 17.3827 8.28861 17.2983 7.9335 17.175C7.22166 16.9279 6.27643 16.5277 5.33499 15.9158C3.45846 14.696 1.625 12.6603 1.625 9.30375V2.68082L9 0.521L16.375 2.68082V9.30375C16.375 12.6603 14.5415 14.696 12.665 15.9158C11.7236 16.5277 10.7783 16.9279 10.0665 17.175C9.71139 17.2983 9.41656 17.3827 9.21228 17.436Z" fill="#FF4B55" stroke="white"/>
<path d="M1.125 9.30375V2.30625L9 0L16.875 2.30625V9.30375C16.875 16.4587 9 18 9 18C9 18 1.125 16.4587 1.125 9.30375Z" fill="#FF4B55"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.13964 6.05402C8.09745 5.57308 8.45183 5.15121 8.93276 5.11746C9.40526 5.08371 9.82714 5.43808 9.87776 5.91902V6.05402L9.60776 9.42902C9.58245 9.74121 9.32089 9.97746 9.0087 9.97746H8.95808C8.66276 9.95215 8.43495 9.72433 8.40964 9.42902L8.13964 6.05402ZM9.74171 11.6303C9.74171 12.0404 9.40929 12.3728 8.99921 12.3728C8.58914 12.3728 8.25671 12.0404 8.25671 11.6303C8.25671 11.2202 8.58914 10.8878 8.99921 10.8878C9.40929 10.8878 9.74171 11.2202 9.74171 11.6303Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -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 thats 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 wasnt 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 dont 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 dont 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" = "%@: %@";

View file

@ -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

View file

@ -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")

View file

@ -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 thats 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 wasnt 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 dont 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 dont recognize or use anymore.
public static var userSessionsOverviewOtherSessionsSectionInfo: String {
return VectorL10n.tr("Vector", "user_sessions_overview_other_sessions_section_info")

View file

@ -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<MXSASTransaction>)object).isIncoming)
{
[self checkPendingIncomingKeyVerificationsInSession:mxSession];
}
@ -3630,9 +3630,9 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni
for (id<MXKeyVerificationTransaction> transaction in transactions)
{
if (transaction.isIncoming)
if ([transaction conformsToProtocol:@protocol(MXSASTransaction)] && transaction.isIncoming)
{
MXIncomingSASTransaction *incomingTransaction = (MXIncomingSASTransaction*)transaction;
id<MXSASTransaction> incomingTransaction = (id<MXSASTransaction>)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<MXSASTransaction>)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<MXKeyVerificationManager> 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<MXKeyVerificationRequest> 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)

View file

@ -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

View file

@ -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)
}

View file

@ -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)")

View file

@ -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)
}

View file

@ -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

View file

@ -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)

View file

@ -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
}

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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()

View file

@ -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)
}

View file

@ -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
}

View file

@ -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.

View file

@ -86,6 +86,8 @@ static NSArray<NSNumber*> *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<NSNumber*> *initialSyncSilentErrorsHTTPStatusCodes;
// Refresh device information
[self loadDeviceInformation:nil failure:nil];
[self loadCurrentPusher:nil failure:nil];
[self registerAccountDataDidChangeIdentityServerNotification];
[self registerIdentityServiceDidChangeAccessTokenNotification];
@ -184,6 +187,7 @@ static NSArray<NSNumber*> *initialSyncSilentErrorsHTTPStatusCodes;
// Refresh device information
[self loadDeviceInformation:nil failure:nil];
[self loadCurrentPusher:nil failure:nil];
}
return self;
@ -303,6 +307,12 @@ static NSArray<NSNumber*> *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<NSNumber*> *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<NSNumber*> *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<NSNumber*> *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<MXPusher *> *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<NSNumber*> *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<NSNumber*> *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<NSNumber*> *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<NSNumber*> *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

View file

@ -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
}

View file

@ -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<T: Encodable>(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<Data, RendezvousTransportError> {
guard let data = currentPayload else {
fatalError()
}
return .success(data)
}
func send<T: Encodable>(body: T) async -> Result<(), RendezvousTransportError> {
guard let encodedBody = try? JSONEncoder().encode(body) else {
fatalError()
}
currentPayload = encodedBody
return .success(())
}
}

View file

@ -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
}

View file

@ -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<Data, RendezvousServiceError> {
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
}
}

View file

@ -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<Data, RendezvousTransportError> {
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<Data?, RendezvousTransportError> = 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<T: Encodable>(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<T: Encodable>(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<T: Encodable>(body: T, url: URL, usingMethod method: String) async -> Result<HTTPURLResponse, RendezvousTransportError> {
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()
}
}
}

View file

@ -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<T: Encodable>(body: T) async -> Result<(), RendezvousTransportError>
/// Waits for and returns newly availalbe rendezvous data
func get() async -> Result<Data, RendezvousTransportError>
/// Publishes new rendezvous data
func send<T: Encodable>(body: T) async -> Result<(), RendezvousTransportError>
}

View file

@ -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<MXKeyVerificationRequest> keyVerificationRequest = (id<MXKeyVerificationRequest>)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;

View file

@ -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: [])
}
}

View file

@ -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 { }

View file

@ -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)
}

View file

@ -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 ?? [])
}

View file

@ -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 {

View file

@ -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) }
}
}

View file

@ -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() {

View file

@ -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

View file

@ -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?
}

View file

@ -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<QRLoginServiceCallback, Never>()
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) { }
}

View file

@ -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<QRLoginServiceCallback, Never>()
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
}
}

View file

@ -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<QRLoginServiceCallback, Never> { 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()
}

View file

@ -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)
}
}

View file

@ -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
}

View file

@ -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<AuthenticationQRLoginConfirmViewState, AuthenticationQRLoginConfirmViewAction>
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)
}
}
}

View file

@ -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 }
}

View file

@ -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
}
}

View file

@ -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))
)
}
}

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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
}

View file

@ -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<AuthenticationQRLoginDisplayViewState, AuthenticationQRLoginDisplayViewAction>
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)
}
}
}

View file

@ -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 }
}

View file

@ -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
}
}

View file

@ -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))
)
}
}

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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<AuthenticationQRLoginFailureViewState, AuthenticationQRLoginFailureViewAction>
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)
}
}
}

View file

@ -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 }
}

View file

@ -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
}
}

View file

@ -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))
)
}
}

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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
}

View file

@ -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<AuthenticationQRLoginLoadingViewState, AuthenticationQRLoginLoadingViewAction>
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)
}
}
}

View file

@ -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 }
}

View file

@ -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
}
}

View file

@ -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))
)
}
}

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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
}

View file

@ -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<AuthenticationQRLoginScanViewState, AuthenticationQRLoginScanViewAction>
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
}
}

View file

@ -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 }
}

View file

@ -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
}
}

View file

@ -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))
)
}
}

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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
}

View file

@ -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<AuthenticationQRLoginStartViewState, AuthenticationQRLoginStartViewAction>
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)
}
}
}

View file

@ -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 }
}

View file

@ -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<AnyCancellable>()
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
}
}

View file

@ -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))
)
}
}

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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
}

View file

@ -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,

View file

@ -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)
}

View file

@ -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))

View file

@ -1,4 +1,4 @@
//
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");

Some files were not shown because too many files have changed in this diff Show more