mirror of
https://github.com/vector-im/element-ios.git
synced 2024-09-28 23:32:41 +00:00
Merge branch 'develop' into johannes/session-name-trumps-device-type-name
This commit is contained in:
commit
03b0cd80da
138 changed files with 5272 additions and 258 deletions
|
@ -420,4 +420,7 @@ final class BuildSettings: NSObject {
|
|||
|
||||
// MARK: - New App Layout
|
||||
static let newAppLayoutEnabled = true
|
||||
|
||||
// MARK: - QR Login
|
||||
static let enableQRLogin = false
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
|
2
Podfile
2
Podfile
|
@ -61,6 +61,7 @@ end
|
|||
def import_SwiftUI_pods
|
||||
pod 'Introspect', '~> 0.1'
|
||||
pod 'DSBottomSheet', '~> 0.3'
|
||||
pod 'ZXingObjC', '~> 3.6.5'
|
||||
end
|
||||
|
||||
abstract_target 'RiotPods' do
|
||||
|
@ -92,7 +93,6 @@ abstract_target 'RiotPods' do
|
|||
pod 'UICollectionViewRightAlignedLayout', '~> 0.0.3'
|
||||
pod 'UICollectionViewLeftAlignedLayout', '~> 1.0.2'
|
||||
pod 'KTCenterFlowLayout', '~> 1.3.1'
|
||||
pod 'ZXingObjC', '~> 3.6.5'
|
||||
pod 'FlowCommoniOS', '~> 1.12.0'
|
||||
pod 'ReadMoreTextView', '~> 3.0.1'
|
||||
pod 'SwiftBase32', '~> 0.9.0'
|
||||
|
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "Secure connection.svg",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
|
@ -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 |
12
Riot/Assets/Images.xcassets/Common/exclamation_circle.imageset/Contents.json
vendored
Normal file
12
Riot/Assets/Images.xcassets/Common/exclamation_circle.imageset/Contents.json
vendored
Normal file
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "exclamation_circle.svg",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
3
Riot/Assets/Images.xcassets/Common/exclamation_circle.imageset/exclamation_circle.svg
vendored
Normal file
3
Riot/Assets/Images.xcassets/Common/exclamation_circle.imageset/exclamation_circle.svg
vendored
Normal 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 |
12
Riot/Assets/Images.xcassets/DeviceManager/user_other_sessions_unverified.imageset/Contents.json
vendored
Normal file
12
Riot/Assets/Images.xcassets/DeviceManager/user_other_sessions_unverified.imageset/Contents.json
vendored
Normal file
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "user_other_sessions_unverified.svg",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
|
@ -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 |
|
@ -156,6 +156,7 @@
|
|||
"authentication_login_username" = "Username / Email / Phone";
|
||||
"authentication_login_forgot_password" = "Forgot password";
|
||||
"authentication_server_info_title_login" = "Where your conversations live";
|
||||
"authentication_login_with_qr" = "Sign in with QR code";
|
||||
|
||||
"authentication_server_selection_login_title" = "Connect to homeserver";
|
||||
"authentication_server_selection_login_message" = "What is the address of your server?";
|
||||
|
@ -211,6 +212,37 @@
|
|||
|
||||
"authentication_recaptcha_title" = "Are you a human?";
|
||||
|
||||
"authentication_qr_login_start_title" = "Scan QR code";
|
||||
"authentication_qr_login_start_subtitle" = "Use the camera on this device to scan the QR code shown on your other device:";
|
||||
"authentication_qr_login_start_step1" = "Open Element on your other device";
|
||||
"authentication_qr_login_start_step2" = "Go to Settings -> Security & Privacy";
|
||||
"authentication_qr_login_start_step3" = "Select ‘Link a device’";
|
||||
"authentication_qr_login_start_step4" = "Select ‘Show QR code on this device’";
|
||||
"authentication_qr_login_start_need_alternative" = "Need an alternative method?";
|
||||
"authentication_qr_login_start_display_qr" = "Show QR code on this device";
|
||||
|
||||
"authentication_qr_login_display_title" = "Link a device";
|
||||
"authentication_qr_login_display_subtitle" = "Scan the QR code below with your device that’s signed out.";
|
||||
"authentication_qr_login_display_step1" = "Open Element on your other device";
|
||||
"authentication_qr_login_display_step2" = "Select ‘Sign in with QR code’";
|
||||
|
||||
"authentication_qr_login_scan_title" = "Scan QR code";
|
||||
"authentication_qr_login_scan_subtitle" = "Position the QR code in the square below";
|
||||
|
||||
"authentication_qr_login_confirm_title" = "Secure connection established";
|
||||
"authentication_qr_login_confirm_subtitle" = "Confirm that the code below matches with your other device:";
|
||||
"authentication_qr_login_confirm_alert" = "Please ensure that you know the origin of this code. By linking devices, you will provide someone with full access to your account.";
|
||||
|
||||
"authentication_qr_login_loading_connecting_device" = "Connecting to device";
|
||||
"authentication_qr_login_loading_waiting_signin" = "Waiting for device to sign in.";
|
||||
"authentication_qr_login_loading_signed_in" = "You are now signed in on your other device.";
|
||||
|
||||
"authentication_qr_login_failure_title" = "Linking failed";
|
||||
"authentication_qr_login_failure_invalid_qr" = "QR code is invalid.";
|
||||
"authentication_qr_login_failure_request_denied" = "The request was denied on the other device.";
|
||||
"authentication_qr_login_failure_request_timed_out" = "The linking wasn’t completed in the required time.";
|
||||
"authentication_qr_login_failure_retry" = "Try again";
|
||||
|
||||
// MARK: Password Validation
|
||||
"password_validation_info_header" = "Your password should meet the criteria below:";
|
||||
"password_validation_error_header" = "Given password does not meet the criteria below:";
|
||||
|
@ -2375,6 +2407,7 @@ To enable access, tap Settings> Location and select Always";
|
|||
"user_sessions_overview_other_sessions_section_info" = "For best security, verify your sessions and sign out from any session that you don’t recognize or use anymore.";
|
||||
|
||||
"user_sessions_overview_current_session_section_title" = "Current session";
|
||||
"user_sessions_overview_link_device" = "Link a device";
|
||||
|
||||
"user_sessions_view_all_action" = "View all (%d)";
|
||||
|
||||
|
@ -2393,6 +2426,8 @@ To enable access, tap Settings> Location and select Always";
|
|||
"user_session_push_notifications_message" = "When turned on, this session will receive push notifications.";
|
||||
|
||||
"user_other_session_security_recommendation_title" = "Security recommendation";
|
||||
"user_other_session_unverified_sessions_header_subtitle" = "Verify your sessions for enhanced secure messaging or sign out from those you don’t recognize or use anymore.";
|
||||
"user_other_session_unverified_current_session_details" = "%@ · Your current session";
|
||||
|
||||
// First item is client name and second item is session display name
|
||||
"user_session_name" = "%@: %@";
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -739,6 +739,110 @@ public class VectorL10n: NSObject {
|
|||
public static var authenticationLoginUsername: String {
|
||||
return VectorL10n.tr("Vector", "authentication_login_username")
|
||||
}
|
||||
/// Sign in with QR code
|
||||
public static var authenticationLoginWithQr: String {
|
||||
return VectorL10n.tr("Vector", "authentication_login_with_qr")
|
||||
}
|
||||
/// Please ensure that you know the origin of this code. By linking devices, you will provide someone with full access to your account.
|
||||
public static var authenticationQrLoginConfirmAlert: String {
|
||||
return VectorL10n.tr("Vector", "authentication_qr_login_confirm_alert")
|
||||
}
|
||||
/// Confirm that the code below matches with your other device:
|
||||
public static var authenticationQrLoginConfirmSubtitle: String {
|
||||
return VectorL10n.tr("Vector", "authentication_qr_login_confirm_subtitle")
|
||||
}
|
||||
/// Secure connection established
|
||||
public static var authenticationQrLoginConfirmTitle: String {
|
||||
return VectorL10n.tr("Vector", "authentication_qr_login_confirm_title")
|
||||
}
|
||||
/// Open Element on your other device
|
||||
public static var authenticationQrLoginDisplayStep1: String {
|
||||
return VectorL10n.tr("Vector", "authentication_qr_login_display_step1")
|
||||
}
|
||||
/// Select ‘Sign in with QR code’
|
||||
public static var authenticationQrLoginDisplayStep2: String {
|
||||
return VectorL10n.tr("Vector", "authentication_qr_login_display_step2")
|
||||
}
|
||||
/// Scan the QR code below with your device that’s signed out.
|
||||
public static var authenticationQrLoginDisplaySubtitle: String {
|
||||
return VectorL10n.tr("Vector", "authentication_qr_login_display_subtitle")
|
||||
}
|
||||
/// Link a device
|
||||
public static var authenticationQrLoginDisplayTitle: String {
|
||||
return VectorL10n.tr("Vector", "authentication_qr_login_display_title")
|
||||
}
|
||||
/// QR code is invalid.
|
||||
public static var authenticationQrLoginFailureInvalidQr: String {
|
||||
return VectorL10n.tr("Vector", "authentication_qr_login_failure_invalid_qr")
|
||||
}
|
||||
/// The request was denied on the other device.
|
||||
public static var authenticationQrLoginFailureRequestDenied: String {
|
||||
return VectorL10n.tr("Vector", "authentication_qr_login_failure_request_denied")
|
||||
}
|
||||
/// The linking wasn’t completed in the required time.
|
||||
public static var authenticationQrLoginFailureRequestTimedOut: String {
|
||||
return VectorL10n.tr("Vector", "authentication_qr_login_failure_request_timed_out")
|
||||
}
|
||||
/// Try again
|
||||
public static var authenticationQrLoginFailureRetry: String {
|
||||
return VectorL10n.tr("Vector", "authentication_qr_login_failure_retry")
|
||||
}
|
||||
/// Linking failed
|
||||
public static var authenticationQrLoginFailureTitle: String {
|
||||
return VectorL10n.tr("Vector", "authentication_qr_login_failure_title")
|
||||
}
|
||||
/// Connecting to device
|
||||
public static var authenticationQrLoginLoadingConnectingDevice: String {
|
||||
return VectorL10n.tr("Vector", "authentication_qr_login_loading_connecting_device")
|
||||
}
|
||||
/// You are now signed in on your other device.
|
||||
public static var authenticationQrLoginLoadingSignedIn: String {
|
||||
return VectorL10n.tr("Vector", "authentication_qr_login_loading_signed_in")
|
||||
}
|
||||
/// Waiting for device to sign in.
|
||||
public static var authenticationQrLoginLoadingWaitingSignin: String {
|
||||
return VectorL10n.tr("Vector", "authentication_qr_login_loading_waiting_signin")
|
||||
}
|
||||
/// Position the QR code in the square below
|
||||
public static var authenticationQrLoginScanSubtitle: String {
|
||||
return VectorL10n.tr("Vector", "authentication_qr_login_scan_subtitle")
|
||||
}
|
||||
/// Scan QR code
|
||||
public static var authenticationQrLoginScanTitle: String {
|
||||
return VectorL10n.tr("Vector", "authentication_qr_login_scan_title")
|
||||
}
|
||||
/// Show QR code on this device
|
||||
public static var authenticationQrLoginStartDisplayQr: String {
|
||||
return VectorL10n.tr("Vector", "authentication_qr_login_start_display_qr")
|
||||
}
|
||||
/// Need an alternative method?
|
||||
public static var authenticationQrLoginStartNeedAlternative: String {
|
||||
return VectorL10n.tr("Vector", "authentication_qr_login_start_need_alternative")
|
||||
}
|
||||
/// Open Element on your other device
|
||||
public static var authenticationQrLoginStartStep1: String {
|
||||
return VectorL10n.tr("Vector", "authentication_qr_login_start_step1")
|
||||
}
|
||||
/// Go to Settings -> Security & Privacy
|
||||
public static var authenticationQrLoginStartStep2: String {
|
||||
return VectorL10n.tr("Vector", "authentication_qr_login_start_step2")
|
||||
}
|
||||
/// Select ‘Link a device’
|
||||
public static var authenticationQrLoginStartStep3: String {
|
||||
return VectorL10n.tr("Vector", "authentication_qr_login_start_step3")
|
||||
}
|
||||
/// Select ‘Show QR code on this device’
|
||||
public static var authenticationQrLoginStartStep4: String {
|
||||
return VectorL10n.tr("Vector", "authentication_qr_login_start_step4")
|
||||
}
|
||||
/// Use the camera on this device to scan the QR code shown on your other device:
|
||||
public static var authenticationQrLoginStartSubtitle: String {
|
||||
return VectorL10n.tr("Vector", "authentication_qr_login_start_subtitle")
|
||||
}
|
||||
/// Scan QR code
|
||||
public static var authenticationQrLoginStartTitle: String {
|
||||
return VectorL10n.tr("Vector", "authentication_qr_login_start_title")
|
||||
}
|
||||
/// Are you a human?
|
||||
public static var authenticationRecaptchaTitle: String {
|
||||
return VectorL10n.tr("Vector", "authentication_recaptcha_title")
|
||||
|
@ -8507,6 +8611,14 @@ public class VectorL10n: NSObject {
|
|||
public static var userOtherSessionSecurityRecommendationTitle: String {
|
||||
return VectorL10n.tr("Vector", "user_other_session_security_recommendation_title")
|
||||
}
|
||||
/// %@ · Your current session
|
||||
public static func userOtherSessionUnverifiedCurrentSessionDetails(_ p1: String) -> String {
|
||||
return VectorL10n.tr("Vector", "user_other_session_unverified_current_session_details", p1)
|
||||
}
|
||||
/// Verify your sessions for enhanced secure messaging or sign out from those you don’t recognize or use anymore.
|
||||
public static var userOtherSessionUnverifiedSessionsHeaderSubtitle: String {
|
||||
return VectorL10n.tr("Vector", "user_other_session_unverified_sessions_header_subtitle")
|
||||
}
|
||||
/// Name
|
||||
public static var userSessionDetailsApplicationName: String {
|
||||
return VectorL10n.tr("Vector", "user_session_details_application_name")
|
||||
|
@ -8631,6 +8743,10 @@ public class VectorL10n: NSObject {
|
|||
public static var userSessionsOverviewCurrentSessionSectionTitle: String {
|
||||
return VectorL10n.tr("Vector", "user_sessions_overview_current_session_section_title")
|
||||
}
|
||||
/// Link a device
|
||||
public static var userSessionsOverviewLinkDevice: String {
|
||||
return VectorL10n.tr("Vector", "user_sessions_overview_link_device")
|
||||
}
|
||||
/// For best security, verify your sessions and sign out from any session that you don’t recognize or use anymore.
|
||||
public static var userSessionsOverviewOtherSessionsSectionInfo: String {
|
||||
return VectorL10n.tr("Vector", "user_sessions_overview_other_sessions_section_info")
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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)")
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
57
Riot/Modules/Rendezvous/MockRendezvousTransport.swift
Normal file
57
Riot/Modules/Rendezvous/MockRendezvousTransport.swift
Normal 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(())
|
||||
}
|
||||
}
|
38
Riot/Modules/Rendezvous/RendezvousModels.swift
Normal file
38
Riot/Modules/Rendezvous/RendezvousModels.swift
Normal 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
|
||||
}
|
212
Riot/Modules/Rendezvous/RendezvousService.swift
Normal file
212
Riot/Modules/Rendezvous/RendezvousService.swift
Normal 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
|
||||
}
|
||||
}
|
146
Riot/Modules/Rendezvous/RendezvousTransport.swift
Normal file
146
Riot/Modules/Rendezvous/RendezvousTransport.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
43
Riot/Modules/Rendezvous/RendezvousTransportProtocol.swift
Normal file
43
Riot/Modules/Rendezvous/RendezvousTransportProtocol.swift
Normal 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>
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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: [])
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 { }
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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 ?? [])
|
||||
}
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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?
|
||||
}
|
|
@ -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) { }
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 }
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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))
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 }
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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))
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 }
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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))
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 }
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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))
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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 }
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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))
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 }
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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))
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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
Loading…
Reference in a new issue