diff --git a/Config/BuildSettings.swift b/Config/BuildSettings.swift index cccba278d..a08546787 100644 --- a/Config/BuildSettings.swift +++ b/Config/BuildSettings.swift @@ -385,6 +385,8 @@ final class BuildSettings: NSObject { // MARK: - Onboarding static let onboardingShowAccountPersonalization = false + static let onboardingEnableNewAuthenticationFlow = false + static let onboardingHostYourOwnServerLink = URL(string: "https://element.io/contact-sales")! // MARK: - Unified Search static let unifiedSearchScreenShowPublicDirectory = true diff --git a/DesignKit/Source/ColorValues.swift b/DesignKit/Source/ColorValues.swift index 5694a5503..338d1cfe8 100644 --- a/DesignKit/Source/ColorValues.swift +++ b/DesignKit/Source/ColorValues.swift @@ -46,5 +46,7 @@ public struct ColorValues: Colors { public let background: UIColor + public let ems: UIColor + public let namesAndAvatars: [UIColor] } diff --git a/DesignKit/Source/Colors.swift b/DesignKit/Source/Colors.swift index d7c885e59..bf3e9abd3 100644 --- a/DesignKit/Source/Colors.swift +++ b/DesignKit/Source/Colors.swift @@ -55,7 +55,7 @@ public protocol Colors { /// Separating line var separator: ColorType { get } - // Cards, tiles + /// Cards, tiles var tile: ColorType { get } /// Top navigation background on iOS @@ -64,6 +64,9 @@ public protocol Colors { /// Background UI color var background: ColorType { get } + /// Global color: The EMS brand's purple colour. + var ems: ColorType { get } + /// - Names in chat timeline /// - Avatars default states that include first name letter var namesAndAvatars: [ColorType] { get } diff --git a/DesignKit/Source/ColorsSwiftUI.swift b/DesignKit/Source/ColorsSwiftUI.swift index 701aee537..b685ac0d7 100644 --- a/DesignKit/Source/ColorsSwiftUI.swift +++ b/DesignKit/Source/ColorsSwiftUI.swift @@ -47,6 +47,8 @@ public struct ColorSwiftUI: Colors { public let background: Color + public var ems: Color + public let namesAndAvatars: [Color] init(values: ColorValues) { @@ -62,6 +64,7 @@ public struct ColorSwiftUI: Colors { tile = Color(values.tile) navigation = Color(values.navigation) background = Color(values.background) + ems = Color(values.ems) namesAndAvatars = values.namesAndAvatars.map({ Color($0) }) } } diff --git a/DesignKit/Variants/Colors/Dark/DarkColors.swift b/DesignKit/Variants/Colors/Dark/DarkColors.swift index b6b0ba5ed..24678fcd0 100644 --- a/DesignKit/Variants/Colors/Dark/DarkColors.swift +++ b/DesignKit/Variants/Colors/Dark/DarkColors.swift @@ -33,6 +33,7 @@ public class DarkColors { tile: UIColor(rgb:0x394049), navigation: UIColor(rgb:0x21262C), background: UIColor(rgb:0x15191E), + ems: UIColor(rgb: 0x7E69FF), namesAndAvatars: [ UIColor(rgb:0x368BD6), UIColor(rgb:0xAC3BA8), diff --git a/DesignKit/Variants/Colors/Light/LightColors.swift b/DesignKit/Variants/Colors/Light/LightColors.swift index 2e7d8147a..332a24162 100644 --- a/DesignKit/Variants/Colors/Light/LightColors.swift +++ b/DesignKit/Variants/Colors/Light/LightColors.swift @@ -34,6 +34,7 @@ public class LightColors { tile: UIColor(rgb:0xF3F8FD), navigation: UIColor(rgb:0xF4F6FA), background: UIColor(rgb:0xFFFFFF), + ems: UIColor(rgb: 0x7E69FF), namesAndAvatars: [ UIColor(rgb:0x368BD6), UIColor(rgb:0xAC3BA8), diff --git a/Riot/Assets/Images.xcassets/Authentication/authentication_server_selection_ems_logo.imageset/Contents.json b/Riot/Assets/Images.xcassets/Authentication/authentication_server_selection_ems_logo.imageset/Contents.json new file mode 100644 index 000000000..181ccf902 --- /dev/null +++ b/Riot/Assets/Images.xcassets/Authentication/authentication_server_selection_ems_logo.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "authentication_server_selection_ems_logo.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Riot/Assets/Images.xcassets/Authentication/authentication_server_selection_ems_logo.imageset/authentication_server_selection_ems_logo.svg b/Riot/Assets/Images.xcassets/Authentication/authentication_server_selection_ems_logo.imageset/authentication_server_selection_ems_logo.svg new file mode 100644 index 000000000..4d40f5a47 --- /dev/null +++ b/Riot/Assets/Images.xcassets/Authentication/authentication_server_selection_ems_logo.imageset/authentication_server_selection_ems_logo.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/Riot/Assets/Images.xcassets/Authentication/authentication_server_selection_icon.imageset/Contents.json b/Riot/Assets/Images.xcassets/Authentication/authentication_server_selection_icon.imageset/Contents.json new file mode 100644 index 000000000..fec295dec --- /dev/null +++ b/Riot/Assets/Images.xcassets/Authentication/authentication_server_selection_icon.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "authentication_server_selection_icon.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Riot/Assets/Images.xcassets/Authentication/authentication_server_selection_icon.imageset/authentication_server_selection_icon.svg b/Riot/Assets/Images.xcassets/Authentication/authentication_server_selection_icon.imageset/authentication_server_selection_icon.svg new file mode 100644 index 000000000..17b23458e --- /dev/null +++ b/Riot/Assets/Images.xcassets/Authentication/authentication_server_selection_icon.imageset/authentication_server_selection_icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Riot/Assets/Images.xcassets/Authentication/authentication_sso_icon_apple.imageset/Contents.json b/Riot/Assets/Images.xcassets/Authentication/authentication_sso_icon_apple.imageset/Contents.json new file mode 100644 index 000000000..eddbc14dc --- /dev/null +++ b/Riot/Assets/Images.xcassets/Authentication/authentication_sso_icon_apple.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "authentication_sso_apple.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Riot/Assets/Images.xcassets/Authentication/authentication_sso_icon_apple.imageset/authentication_sso_apple.svg b/Riot/Assets/Images.xcassets/Authentication/authentication_sso_icon_apple.imageset/authentication_sso_apple.svg new file mode 100644 index 000000000..2d1d3b3d8 --- /dev/null +++ b/Riot/Assets/Images.xcassets/Authentication/authentication_sso_icon_apple.imageset/authentication_sso_apple.svg @@ -0,0 +1,3 @@ + + + diff --git a/Riot/Assets/Images.xcassets/Authentication/authentication_sso_icon_facebook.imageset/Contents.json b/Riot/Assets/Images.xcassets/Authentication/authentication_sso_icon_facebook.imageset/Contents.json new file mode 100644 index 000000000..c087db709 --- /dev/null +++ b/Riot/Assets/Images.xcassets/Authentication/authentication_sso_icon_facebook.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "authentication_sso_facebook.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Riot/Assets/Images.xcassets/Authentication/authentication_sso_icon_facebook.imageset/authentication_sso_facebook.svg b/Riot/Assets/Images.xcassets/Authentication/authentication_sso_icon_facebook.imageset/authentication_sso_facebook.svg new file mode 100644 index 000000000..21a4b1fe2 --- /dev/null +++ b/Riot/Assets/Images.xcassets/Authentication/authentication_sso_icon_facebook.imageset/authentication_sso_facebook.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/Riot/Assets/Images.xcassets/Authentication/authentication_sso_icon_github.imageset/Contents.json b/Riot/Assets/Images.xcassets/Authentication/authentication_sso_icon_github.imageset/Contents.json new file mode 100644 index 000000000..3fa20bfef --- /dev/null +++ b/Riot/Assets/Images.xcassets/Authentication/authentication_sso_icon_github.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "authentication_sso_github.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Riot/Assets/Images.xcassets/Authentication/authentication_sso_icon_github.imageset/authentication_sso_github.svg b/Riot/Assets/Images.xcassets/Authentication/authentication_sso_icon_github.imageset/authentication_sso_github.svg new file mode 100644 index 000000000..91ad466f1 --- /dev/null +++ b/Riot/Assets/Images.xcassets/Authentication/authentication_sso_icon_github.imageset/authentication_sso_github.svg @@ -0,0 +1,3 @@ + + + diff --git a/Riot/Assets/Images.xcassets/Authentication/authentication_sso_icon_gitlab.imageset/Contents.json b/Riot/Assets/Images.xcassets/Authentication/authentication_sso_icon_gitlab.imageset/Contents.json new file mode 100644 index 000000000..80f0a4dc6 --- /dev/null +++ b/Riot/Assets/Images.xcassets/Authentication/authentication_sso_icon_gitlab.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "authentication_sso_gitlab.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Riot/Assets/Images.xcassets/Authentication/authentication_sso_icon_gitlab.imageset/authentication_sso_gitlab.svg b/Riot/Assets/Images.xcassets/Authentication/authentication_sso_icon_gitlab.imageset/authentication_sso_gitlab.svg new file mode 100644 index 000000000..885be976d --- /dev/null +++ b/Riot/Assets/Images.xcassets/Authentication/authentication_sso_icon_gitlab.imageset/authentication_sso_gitlab.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/Riot/Assets/Images.xcassets/Authentication/authentication_sso_icon_google.imageset/Contents.json b/Riot/Assets/Images.xcassets/Authentication/authentication_sso_icon_google.imageset/Contents.json new file mode 100644 index 000000000..7fb05cc39 --- /dev/null +++ b/Riot/Assets/Images.xcassets/Authentication/authentication_sso_icon_google.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "authentication_sso_google.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Riot/Assets/Images.xcassets/Authentication/authentication_sso_icon_google.imageset/authentication_sso_google.svg b/Riot/Assets/Images.xcassets/Authentication/authentication_sso_icon_google.imageset/authentication_sso_google.svg new file mode 100644 index 000000000..e402ec590 --- /dev/null +++ b/Riot/Assets/Images.xcassets/Authentication/authentication_sso_icon_google.imageset/authentication_sso_google.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Riot/Assets/Images.xcassets/Authentication/authentication_sso_icon_twitter.imageset/Contents.json b/Riot/Assets/Images.xcassets/Authentication/authentication_sso_icon_twitter.imageset/Contents.json new file mode 100644 index 000000000..3ebd5a43f --- /dev/null +++ b/Riot/Assets/Images.xcassets/Authentication/authentication_sso_icon_twitter.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "authentication_sso_twitter.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Riot/Assets/Images.xcassets/Authentication/authentication_sso_icon_twitter.imageset/authentication_sso_twitter.svg b/Riot/Assets/Images.xcassets/Authentication/authentication_sso_icon_twitter.imageset/authentication_sso_twitter.svg new file mode 100644 index 000000000..f1bf030ce --- /dev/null +++ b/Riot/Assets/Images.xcassets/Authentication/authentication_sso_icon_twitter.imageset/authentication_sso_twitter.svg @@ -0,0 +1,3 @@ + + + diff --git a/Riot/Assets/en.lproj/Untranslated.strings b/Riot/Assets/en.lproj/Untranslated.strings index 30d8ca51b..c1cde0786 100644 --- a/Riot/Assets/en.lproj/Untranslated.strings +++ b/Riot/Assets/en.lproj/Untranslated.strings @@ -20,6 +20,28 @@ "image_picker_action_files" = "Choose from files"; +// MARK: Onboarding Authentication WIP +"authentication_registration_title" = "Create your account"; +"authentication_registration_message" = "We’ll need some info to get you set up."; +"authentication_registration_server_title" = "Choose your server to store your data"; +"authentication_registration_matrix_description" = "Join millions for free on the largest public server"; +"authentication_registration_username" = "Username"; +"authentication_registration_password" = "Password"; +"authentication_registration_username_footer" = "You can’t change this later"; +"authentication_registration_password_footer" = "Must be 8 characters or more"; + +"authentication_server_selection_title" = "Choose your server"; +"authentication_server_selection_message" = "What is the address of your server? A server is like a home for all your data."; +"authentication_server_selection_server_url" = "Server URL"; +"authentication_server_selection_server_footer" = "You can only connect to a server that has already been set up"; +"authentication_server_selection_ems_title" = "Want to host your own server?"; +/* This string will be followed by authentication_server_selection_ems_link on the next line. */ +"authentication_server_selection_ems_message" = "Element Matrix Services (EMS) is a robust and reliable hosting service for fast, secure real time communication. Find out how on"; +"authentication_server_selection_ems_link" = "element.io/ems"; +"authentication_server_selection_ems_button" = "Get in touch"; +"authentication_server_selection_generic_error" = "Cannot find a server at this URL, please check it is correct."; + +// MARK: Spaces WIP "spaces_feature_not_available" = "This feature isn't available here. For now, you can do this with %@ on your computer."; "leave_space_action" = "Leave space"; diff --git a/Riot/Assets/en.lproj/Vector.strings b/Riot/Assets/en.lproj/Vector.strings index 02f413d78..5424d7666 100644 --- a/Riot/Assets/en.lproj/Vector.strings +++ b/Riot/Assets/en.lproj/Vector.strings @@ -76,6 +76,7 @@ "error" = "Error"; "suggest" = "Suggest"; "edit" = "Edit"; +"confirm" = "Confirm"; // Activities "loading" = "Loading"; @@ -2157,6 +2158,9 @@ Tap the + to start adding people."; "location_sharing_live_timer_selector_short" = "for 15 minutes"; "location_sharing_live_timer_selector_medium" = "for 1 hour"; "location_sharing_live_timer_selector_long" = "for 8 hours"; +"location_sharing_live_no_user_locations_error_title" = "No user locations available"; +"location_sharing_live_stop_sharing_error" = "Fail to stop sharing location"; +"location_sharing_live_stop_sharing_progress" = "Stop location sharing"; // MARK: - MatrixKit diff --git a/Riot/Categories/MXBeaconInfoSummary.swift b/Riot/Categories/MXBeaconInfoSummary.swift new file mode 100644 index 000000000..3d47a466c --- /dev/null +++ b/Riot/Categories/MXBeaconInfoSummary.swift @@ -0,0 +1,26 @@ +// +// 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 MatrixSDK + +extension MXBeaconInfoSummaryProtocol { + + /// Indicate true if a beacon info summary can be displayed on a map + var isDisplayable: Bool { + return self.isActive && self.lastBeacon != nil + } +} diff --git a/Riot/Categories/MXLocationService.swift b/Riot/Categories/MXLocationService.swift new file mode 100644 index 000000000..0c3dc7379 --- /dev/null +++ b/Riot/Categories/MXLocationService.swift @@ -0,0 +1,35 @@ +// +// 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 MatrixSDK + +extension MXLocationService { + + public func isSomeoneSharingDisplayableLocation(inRoomWithId roomId: String) -> Bool { + return self.getDisplayableBeaconInfoSummaries(inRoomWithId: roomId).isEmpty == false + } + + /// Get beacon info summaries that can be shown on a map + func getDisplayableBeaconInfoSummaries(inRoomWithId roomId: String) -> [MXBeaconInfoSummaryProtocol] { + + let liveBeaconInfoSummaries = self.getLiveBeaconInfoSummaries(inRoomWithId: roomId) + + return liveBeaconInfoSummaries.filter { beaconInfoSummary in + return beaconInfoSummary.isDisplayable + } + } +} diff --git a/Riot/Categories/MXSession.swift b/Riot/Categories/MXSession.swift new file mode 100644 index 000000000..10df98141 --- /dev/null +++ b/Riot/Categories/MXSession.swift @@ -0,0 +1,28 @@ +// +// 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 + +extension MXSession { + + func avatarInput(for userId: String) -> AvatarInput { + let user = self.user(withUserId: userId) + + return AvatarInput(mxContentUri: user?.avatarUrl, + matrixItemId: userId, + displayName: user?.displayname) + } +} diff --git a/Riot/Generated/Images.swift b/Riot/Generated/Images.swift index 9f91925e1..83e645684 100644 --- a/Riot/Generated/Images.swift +++ b/Riot/Generated/Images.swift @@ -30,6 +30,14 @@ internal class Asset: NSObject { internal static let socialLoginButtonGitlab = ImageAsset(name: "social_login_button_gitlab") internal static let socialLoginButtonGoogle = ImageAsset(name: "social_login_button_google") internal static let socialLoginButtonTwitter = ImageAsset(name: "social_login_button_twitter") + internal static let authenticationServerSelectionEmsLogo = ImageAsset(name: "authentication_server_selection_ems_logo") + internal static let authenticationServerSelectionIcon = ImageAsset(name: "authentication_server_selection_icon") + internal static let authenticationSsoIconApple = ImageAsset(name: "authentication_sso_icon_apple") + internal static let authenticationSsoIconFacebook = ImageAsset(name: "authentication_sso_icon_facebook") + internal static let authenticationSsoIconGithub = ImageAsset(name: "authentication_sso_icon_github") + internal static let authenticationSsoIconGitlab = ImageAsset(name: "authentication_sso_icon_gitlab") + internal static let authenticationSsoIconGoogle = ImageAsset(name: "authentication_sso_icon_google") + internal static let authenticationSsoIconTwitter = ImageAsset(name: "authentication_sso_icon_twitter") internal static let callAudioMuteOffIcon = ImageAsset(name: "call_audio_mute_off_icon") internal static let callAudioMuteOnIcon = ImageAsset(name: "call_audio_mute_on_icon") internal static let callAudioRouteBuiltin = ImageAsset(name: "call_audio_route_builtin") diff --git a/Riot/Generated/Strings.swift b/Riot/Generated/Strings.swift index d07c7c4f0..b30de7848 100644 --- a/Riot/Generated/Strings.swift +++ b/Riot/Generated/Strings.swift @@ -847,6 +847,10 @@ public class VectorL10n: NSObject { public static var collapse: String { return VectorL10n.tr("Vector", "collapse") } + /// Confirm + public static var confirm: String { + return VectorL10n.tr("Vector", "confirm") + } /// Local Contacts public static var contactLocalContacts: String { return VectorL10n.tr("Vector", "contact_local_contacts") @@ -2799,10 +2803,22 @@ public class VectorL10n: NSObject { public static var locationSharingLiveMapCalloutTitle: String { return VectorL10n.tr("Vector", "location_sharing_live_map_callout_title") } + /// No user locations available + public static var locationSharingLiveNoUserLocationsErrorTitle: String { + return VectorL10n.tr("Vector", "location_sharing_live_no_user_locations_error_title") + } /// Share live location public static var locationSharingLiveShareTitle: String { return VectorL10n.tr("Vector", "location_sharing_live_share_title") } + /// Fail to stop sharing location + public static var locationSharingLiveStopSharingError: String { + return VectorL10n.tr("Vector", "location_sharing_live_stop_sharing_error") + } + /// Stop location sharing + public static var locationSharingLiveStopSharingProgress: String { + return VectorL10n.tr("Vector", "location_sharing_live_stop_sharing_progress") + } /// Live until %@ public static func locationSharingLiveTimerIncoming(_ p1: String) -> String { return VectorL10n.tr("Vector", "location_sharing_live_timer_incoming", p1) diff --git a/Riot/Generated/UntranslatedStrings.swift b/Riot/Generated/UntranslatedStrings.swift index 87b69348c..c681cd91d 100644 --- a/Riot/Generated/UntranslatedStrings.swift +++ b/Riot/Generated/UntranslatedStrings.swift @@ -10,6 +10,74 @@ import Foundation // swiftlint:disable function_parameter_count identifier_name line_length type_body_length public extension VectorL10n { + /// Join millions for free on the largest public server + static var authenticationRegistrationMatrixDescription: String { + return VectorL10n.tr("Untranslated", "authentication_registration_matrix_description") + } + /// We’ll need some info to get you set up. + static var authenticationRegistrationMessage: String { + return VectorL10n.tr("Untranslated", "authentication_registration_message") + } + /// Password + static var authenticationRegistrationPassword: String { + return VectorL10n.tr("Untranslated", "authentication_registration_password") + } + /// Must be 8 characters or more + static var authenticationRegistrationPasswordFooter: String { + return VectorL10n.tr("Untranslated", "authentication_registration_password_footer") + } + /// Choose your server to store your data + static var authenticationRegistrationServerTitle: String { + return VectorL10n.tr("Untranslated", "authentication_registration_server_title") + } + /// Create your account + static var authenticationRegistrationTitle: String { + return VectorL10n.tr("Untranslated", "authentication_registration_title") + } + /// Username + static var authenticationRegistrationUsername: String { + return VectorL10n.tr("Untranslated", "authentication_registration_username") + } + /// You can’t change this later + static var authenticationRegistrationUsernameFooter: String { + return VectorL10n.tr("Untranslated", "authentication_registration_username_footer") + } + /// Get in touch + static var authenticationServerSelectionEmsButton: String { + return VectorL10n.tr("Untranslated", "authentication_server_selection_ems_button") + } + /// element.io/ems + static var authenticationServerSelectionEmsLink: String { + return VectorL10n.tr("Untranslated", "authentication_server_selection_ems_link") + } + /// Element Matrix Services (EMS) is a robust and reliable hosting service for fast, secure real time communication. Find out how on + static var authenticationServerSelectionEmsMessage: String { + return VectorL10n.tr("Untranslated", "authentication_server_selection_ems_message") + } + /// Want to host your own server? + static var authenticationServerSelectionEmsTitle: String { + return VectorL10n.tr("Untranslated", "authentication_server_selection_ems_title") + } + /// Cannot find a server at this URL, please check it is correct. + static var authenticationServerSelectionGenericError: String { + return VectorL10n.tr("Untranslated", "authentication_server_selection_generic_error") + } + /// What is the address of your server? A server is like a home for all your data. + static var authenticationServerSelectionMessage: String { + return VectorL10n.tr("Untranslated", "authentication_server_selection_message") + } + /// You can only connect to a server that has already been set up + static var authenticationServerSelectionServerFooter: String { + return VectorL10n.tr("Untranslated", "authentication_server_selection_server_footer") + } + /// Server URL + static var authenticationServerSelectionServerUrl: String { + return VectorL10n.tr("Untranslated", "authentication_server_selection_server_url") + } + /// Choose your server + static var authenticationServerSelectionTitle: String { + return VectorL10n.tr("Untranslated", "authentication_server_selection_title") + } /// Choose from files static var imagePickerActionFiles: String { return VectorL10n.tr("Untranslated", "image_picker_action_files") diff --git a/Riot/Modules/Authentication/AuthenticationCoordinatorProtocol.swift b/Riot/Modules/Authentication/AuthenticationCoordinatorProtocol.swift index 54696e503..59db49bb1 100644 --- a/Riot/Modules/Authentication/AuthenticationCoordinatorProtocol.swift +++ b/Riot/Modules/Authentication/AuthenticationCoordinatorProtocol.swift @@ -18,12 +18,6 @@ import Foundation -struct AuthenticationCoordinatorParameters { - let navigationRouter: NavigationRouterType - /// Whether or not the coordinator should show the loading spinner, key verification etc. - let canPresentAdditionalScreens: Bool -} - enum AuthenticationCoordinatorResult { /// The user has authenticated but key verification is yet to happen. The session value is /// for a fresh session that still needs to load, sync etc before being ready. diff --git a/Riot/Modules/Authentication/AuthenticationCoordinator.swift b/Riot/Modules/Authentication/LegacyAuthenticationCoordinator.swift similarity index 50% rename from Riot/Modules/Authentication/AuthenticationCoordinator.swift rename to Riot/Modules/Authentication/LegacyAuthenticationCoordinator.swift index 3bcae3348..8c546c8c5 100644 --- a/Riot/Modules/Authentication/AuthenticationCoordinator.swift +++ b/Riot/Modules/Authentication/LegacyAuthenticationCoordinator.swift @@ -18,8 +18,14 @@ import UIKit -/// A coordinator that handles authentication, verification and setting a PIN. -final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtocol { +struct LegacyAuthenticationCoordinatorParameters { + let navigationRouter: NavigationRouterType + /// Whether or not the coordinator should show the loading spinner, key verification etc. + let canPresentAdditionalScreens: Bool +} + +/// A coordinator that handles authentication, verification and setting a PIN using the old UIViewController flow for iOS 12 & 13. +final class LegacyAuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtocol { // MARK: - Properties @@ -30,10 +36,8 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc private let authenticationViewController: AuthenticationViewController private var canPresentAdditionalScreens: Bool private var isWaitingToPresentCompleteSecurity = false - private let crossSigningService = CrossSigningService() + private var verificationListener: SessionVerificationListener? - /// The password entered, for use when setting up cross-signing. - private var password: String? /// The session created when successfully authenticated. private var session: MXSession? @@ -52,7 +56,7 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc // MARK: - Setup - init(parameters: AuthenticationCoordinatorParameters) { + init(parameters: LegacyAuthenticationCoordinatorParameters) { self.navigationRouter = parameters.navigationRouter self.canPresentAdditionalScreens = parameters.canPresentAdditionalScreens @@ -121,7 +125,7 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc private func presentCompleteSecurity() { guard let session = session else { - MXLog.error("[AuthenticationCoordinator] presentCompleteSecurity: Unable to present security due to missing session.") + MXLog.error("[LegacyAuthenticationCoordinator] presentCompleteSecurity: Unable to present security due to missing session.") authenticationDidComplete() return } @@ -141,119 +145,49 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc private func authenticationDidComplete() { completion?(.didComplete) } - - private func registerSessionStateChangeNotification(for session: MXSession) { - NotificationCenter.default.addObserver(self, selector: #selector(sessionStateDidChange), name: .mxSessionStateDidChange, object: session) - } - - private func unregisterSessionStateChangeNotification() { - NotificationCenter.default.removeObserver(self, name: .mxSessionStateDidChange, object: nil) - } - - @objc private func sessionStateDidChange(_ notification: Notification) { - guard let session = notification.object as? MXSession else { - MXLog.error("[AuthenticationCoordinator] sessionStateDidChange: Missing session in the notification") - return - } - - if session.state == .storeDataReady { - if let crypto = session.crypto, crypto.crossSigning != nil { - // Do not make key share requests while the "Complete security" is not complete. - // If the device is self-verified, the SDK will restore the existing key backup. - // Then, it will re-enable outgoing key share requests - crypto.setOutgoingKeyRequestsEnabled(false, onComplete: nil) - } - } else if session.state == .running { - unregisterSessionStateChangeNotification() - - if let crypto = session.crypto, let crossSigning = crypto.crossSigning { - crossSigning.refreshState { [weak self] stateUpdated in - guard let self = self else { return } - - MXLog.debug("[AuthenticationCoordinator] sessionStateDidChange: crossSigning.state: \(crossSigning.state)") - - switch crossSigning.state { - case .notBootstrapped: - // TODO: This is still not sure we want to disable the automatic cross-signing bootstrap - // if the admin disabled e2e by default. - // Do like riot-web for the moment - if session.vc_homeserverConfiguration().encryption.isE2EEByDefaultEnabled { - // Bootstrap cross-signing on user's account - // We do it for both registration and new login as long as cross-signing does not exist yet - if let password = self.password, !password.isEmpty { - MXLog.debug("[AuthenticationCoordinator] sessionStateDidChange: Bootstrap with password") - - crossSigning.setup(withPassword: password) { - MXLog.debug("[AuthenticationCoordinator] sessionStateDidChange: Bootstrap succeeded") - self.authenticationDidComplete() - } failure: { error in - MXLog.error("[AuthenticationCoordinator] sessionStateDidChange: Bootstrap failed. Error: \(error)") - crypto.setOutgoingKeyRequestsEnabled(true, onComplete: nil) - self.authenticationDidComplete() - } - } else { - // Try to setup cross-signing without authentication parameters in case if a grace period is enabled - self.crossSigningService.setupCrossSigningWithoutAuthentication(for: session) { - MXLog.debug("[AuthenticationCoordinator] sessionStateDidChange: Bootstrap succeeded without credentials") - self.authenticationDidComplete() - } failure: { error in - MXLog.error("[AuthenticationCoordinator] sessionStateDidChange: Do not know how to bootstrap cross-signing. Skip it.") - crypto.setOutgoingKeyRequestsEnabled(true, onComplete: nil) - self.authenticationDidComplete() - } - } - } else { - crypto.setOutgoingKeyRequestsEnabled(true, onComplete: nil) - self.authenticationDidComplete() - } - case .crossSigningExists: - guard self.canPresentAdditionalScreens else { - MXLog.debug("[AuthenticationCoordinator] sessionStateDidChange: Delaying presentCompleteSecurity during onboarding.") - self.isWaitingToPresentCompleteSecurity = true - return - } - - MXLog.debug("[AuthenticationCoordinator] sessionStateDidChange: Complete security") - self.presentCompleteSecurity() - default: - MXLog.debug("[AuthenticationCoordinator] sessionStateDidChange: Nothing to do") - - crypto.setOutgoingKeyRequestsEnabled(true, onComplete: nil) - self.authenticationDidComplete() - } - } failure: { [weak self] error in - MXLog.error("[AuthenticationCoordinator] sessionStateDidChange: Fail to refresh crypto state with error: \(error)") - crypto.setOutgoingKeyRequestsEnabled(true, onComplete: nil) - self?.authenticationDidComplete() - } - } else { - authenticationDidComplete() - } - } - } } // MARK: - AuthenticationViewControllerDelegate -extension AuthenticationCoordinator: AuthenticationViewControllerDelegate { +extension LegacyAuthenticationCoordinator: AuthenticationViewControllerDelegate { func authenticationViewController(_ authenticationViewController: AuthenticationViewController!, didLoginWith session: MXSession!, andPassword password: String!) { - registerSessionStateChangeNotification(for: session) - self.session = session - self.password = password if canPresentAdditionalScreens { showLoadingAnimation() } + + let verificationListener = SessionVerificationListener(session: session, password: password) + verificationListener.completion = { [weak self] result in + guard let self = self else { return } + switch result { + case .needsVerification: + guard self.canPresentAdditionalScreens else { + MXLog.debug("[LegacyAuthenticationCoordinator] Delaying presentCompleteSecurity during onboarding.") + self.isWaitingToPresentCompleteSecurity = true + return + } + + MXLog.debug("[LegacyAuthenticationCoordinator] Complete security") + self.presentCompleteSecurity() + case .authenticationIsComplete: + self.authenticationDidComplete() + } + } + + verificationListener.start() + self.verificationListener = verificationListener + + completion?(.didLogin(session: session, authenticationType: authenticationViewController.authType)) } } // MARK: - KeyVerificationCoordinatorDelegate -extension AuthenticationCoordinator: KeyVerificationCoordinatorDelegate { +extension LegacyAuthenticationCoordinator: KeyVerificationCoordinatorDelegate { func keyVerificationCoordinatorDidComplete(_ coordinator: KeyVerificationCoordinatorType, otherUserId: String, otherDeviceId: String) { if let crypto = session?.crypto, !crypto.backup.hasPrivateKeyInCryptoStore || !crypto.backup.enabled { - MXLog.debug("[AuthenticationCoordinator][MXKeyVerification] requestAllPrivateKeys: Request key backup private keys") + MXLog.debug("[LegacyAuthenticationCoordinator][MXKeyVerification] requestAllPrivateKeys: Request key backup private keys") crypto.setOutgoingKeyRequestsEnabled(true, onComplete: nil) } @@ -270,7 +204,7 @@ extension AuthenticationCoordinator: KeyVerificationCoordinatorDelegate { } // MARK: - UIAdaptivePresentationControllerDelegate -extension AuthenticationCoordinator: UIAdaptivePresentationControllerDelegate { +extension LegacyAuthenticationCoordinator: UIAdaptivePresentationControllerDelegate { func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool { // Prevent Key Verification from using swipe to dismiss return false diff --git a/Riot/Modules/Onboarding/AuthenticationCoordinator.swift b/Riot/Modules/Onboarding/AuthenticationCoordinator.swift new file mode 100644 index 000000000..bd2409670 --- /dev/null +++ b/Riot/Modules/Onboarding/AuthenticationCoordinator.swift @@ -0,0 +1,354 @@ +// File created from ScreenTemplate +// $ createScreen.sh Onboarding Authentication +/* + 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 UIKit + +@available(iOS 14.0, *) +struct AuthenticationCoordinatorParameters { + let navigationRouter: NavigationRouterType + /// The screen that should be shown when starting the flow. + let initialScreen: AuthenticationCoordinator.EntryPoint + /// Whether or not the coordinator should show the loading spinner, key verification etc. + let canPresentAdditionalScreens: Bool +} + +/// A coordinator that handles authentication, verification and setting a PIN. +@available(iOS 14.0, *) +final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtocol { + + enum EntryPoint { + case registration + case selectServerForRegistration + case login + } + + // MARK: - Properties + + // MARK: Private + + private let navigationRouter: NavigationRouterType + private let authenticationService = AuthenticationService.shared + + private let initialScreen: EntryPoint + private var canPresentAdditionalScreens: Bool + private var isWaitingToPresentCompleteSecurity = false + + private var verificationListener: SessionVerificationListener? + + /// The password entered, for use when setting up cross-signing. + private var password: String? + /// The session created when successfully authenticated. + private var session: MXSession? + + // MARK: Public + + // Must be used only internally + var childCoordinators: [Coordinator] = [] + var completion: ((AuthenticationCoordinatorResult) -> Void)? + + // MARK: - Setup + + init(parameters: AuthenticationCoordinatorParameters) { + self.navigationRouter = parameters.navigationRouter + self.initialScreen = parameters.initialScreen + self.canPresentAdditionalScreens = parameters.canPresentAdditionalScreens + + super.init() + } + + // MARK: - Public + + func start() { + Task { + do { + let flow: AuthenticationFlow = initialScreen == .login ? .login : .registration + try await authenticationService.startFlow(flow, for: authenticationService.state.homeserver.address) + } catch { + MXLog.error("[AuthenticationCoordinator] start: Failed to start") + await MainActor.run { displayError(error) } + return + } + + await MainActor.run { + switch initialScreen { + case .registration: + showRegistrationScreen() + case .selectServerForRegistration: + showServerSelectionScreen() + case .login: + showLoginScreen() + } + } + } + } + + func toPresentable() -> UIViewController { + navigationRouter.toPresentable() + } + + func presentPendingScreensIfNecessary() { + canPresentAdditionalScreens = true + + showLoadingAnimation() + + if isWaitingToPresentCompleteSecurity { + isWaitingToPresentCompleteSecurity = false + presentCompleteSecurity() + } + } + + // MARK: - Private + + /// Presents an alert on top of the navigation router, using the supplied error's `localizedDescription`. + @MainActor func displayError(_ error: Error) { + let alert = UIAlertController(title: VectorL10n.error, + message: error.localizedDescription, + preferredStyle: .alert) + + alert.addAction(UIAlertAction(title: VectorL10n.ok, style: .default)) + + toPresentable().present(alert, animated: true) + } + + // MARK: - Registration + + /// Pushes the server selection screen into the flow (other screens may also present it modally later). + @MainActor private func showServerSelectionScreen() { + MXLog.debug("[AuthenticationCoordinator] showServerSelectionScreen") + let parameters = AuthenticationServerSelectionCoordinatorParameters(authenticationService: authenticationService, + hasModalPresentation: false) + let coordinator = AuthenticationServerSelectionCoordinator(parameters: parameters) + coordinator.completion = { [weak self, weak coordinator] result in + guard let self = self, let coordinator = coordinator else { return } + self.serverSelectionCoordinator(coordinator, didCompleteWith: result) + } + + coordinator.start() + add(childCoordinator: coordinator) + + if navigationRouter.modules.isEmpty { + navigationRouter.setRootModule(coordinator, popCompletion: nil) + } else { + navigationRouter.push(coordinator, animated: true) { [weak self] in + self?.remove(childCoordinator: coordinator) + } + } + } + + @available(iOS 14.0, *) + /// Shows the next screen in the flow after the server selection screen. + @MainActor private func serverSelectionCoordinator(_ coordinator: AuthenticationServerSelectionCoordinator, + didCompleteWith result: AuthenticationServerSelectionCoordinatorResult) { + switch result { + case .updated: + showRegistrationScreen() + case .dismiss: + MXLog.failure("[AuthenticationCoordinator] AuthenticationServerSelectionScreen is requesting dismiss when part of a stack.") + } + } + + /// Shows the registration screen. + @MainActor private func showRegistrationScreen() { + MXLog.debug("[AuthenticationCoordinator] showRegistrationScreen") + let homeserver = authenticationService.state.homeserver + let parameters = AuthenticationRegistrationCoordinatorParameters(navigationRouter: navigationRouter, + authenticationService: authenticationService, + registrationFlow: homeserver.registrationFlow, + loginMode: homeserver.preferredLoginMode) + let coordinator = AuthenticationRegistrationCoordinator(parameters: parameters) + coordinator.completion = { [weak self, weak coordinator] result in + guard let self = self, let coordinator = coordinator else { return } + self.registrationCoordinator(coordinator, didCompleteWith: result) + } + + coordinator.start() + add(childCoordinator: coordinator) + + if navigationRouter.modules.isEmpty { + navigationRouter.setRootModule(coordinator, popCompletion: nil) + } else { + navigationRouter.push(coordinator, animated: true) { [weak self] in + self?.remove(childCoordinator: coordinator) + } + } + } + + /// Displays the next view in the flow after the registration screen. + @available(iOS 14.0, *) + @MainActor private func registrationCoordinator(_ coordinator: AuthenticationRegistrationCoordinator, + didCompleteWith result: AuthenticationRegistrationCoordinatorResult) { + switch result { + case .selectServer: + showServerSelectionScreen() + case .completed(let result): + handleRegistrationResult(result) + } + } + + /// Shows the login screen. + @MainActor private func showLoginScreen() { + MXLog.debug("[AuthenticationCoordinator] showLoginScreen") + + } + + // MARK: - Registration Handlers + /// Determines the next screen to show from the flow result and pushes it. + func handleRegistrationResult(_ result: RegistrationResult) { + switch result { + case .success(let mxSession): + onSessionCreated(session: mxSession, isAccountCreated: true) + case .flowResponse(let flowResult): + // TODO + break + } + } + + /// Handles the creation of a new session following on from a successful authentication. + func onSessionCreated(session: MXSession, isAccountCreated: Bool) { + self.session = session + // self.password = password + + if canPresentAdditionalScreens { + showLoadingAnimation() + } + + let verificationListener = SessionVerificationListener(session: session, password: password) + + verificationListener.completion = { [weak self] result in + guard let self = self else { return } + switch result { + case .needsVerification: + guard self.canPresentAdditionalScreens else { + MXLog.debug("[AuthenticationCoordinator] Delaying presentCompleteSecurity during onboarding.") + self.isWaitingToPresentCompleteSecurity = true + return + } + + MXLog.debug("[AuthenticationCoordinator] Complete security") + self.presentCompleteSecurity() + case .authenticationIsComplete: + self.authenticationDidComplete() + } + } + + verificationListener.start() + self.verificationListener = verificationListener + + completion?(.didLogin(session: session, authenticationType: isAccountCreated ? .register : .login)) + } + + // MARK: - Additional Screens + + /// Replace the contents of the navigation router with a loading animation. + private func showLoadingAnimation() { + let loadingViewController = LaunchLoadingViewController() + loadingViewController.modalPresentationStyle = .fullScreen + + // Replace the navigation stack with the loading animation + // as there is nothing to navigate back to. + navigationRouter.setRootModule(loadingViewController) + } + + /// Present the key verification screen modally. + private func presentCompleteSecurity() { + guard let session = session else { + MXLog.error("[LegacyAuthenticationCoordinator] presentCompleteSecurity: Unable to present security due to missing session.") + authenticationDidComplete() + return + } + + let isNewSignIn = true + let cancellable = !session.vc_homeserverConfiguration().encryption.isSecureBackupRequired + let keyVerificationCoordinator = KeyVerificationCoordinator(session: session, flow: .completeSecurity(isNewSignIn), cancellable: cancellable) + + keyVerificationCoordinator.delegate = self + let presentable = keyVerificationCoordinator.toPresentable() + presentable.presentationController?.delegate = self + navigationRouter.present(presentable, animated: true) + keyVerificationCoordinator.start() + add(childCoordinator: keyVerificationCoordinator) + } + + /// Complete the authentication flow. + private func authenticationDidComplete() { + completion?(.didComplete) + } +} + +// MARK: - KeyVerificationCoordinatorDelegate +@available(iOS 14.0, *) +extension AuthenticationCoordinator: KeyVerificationCoordinatorDelegate { + func keyVerificationCoordinatorDidComplete(_ coordinator: KeyVerificationCoordinatorType, otherUserId: String, otherDeviceId: String) { + if let crypto = session?.crypto, + !crypto.backup.hasPrivateKeyInCryptoStore || !crypto.backup.enabled { + MXLog.debug("[LegacyAuthenticationCoordinator][MXKeyVerification] requestAllPrivateKeys: Request key backup private keys") + crypto.setOutgoingKeyRequestsEnabled(true, onComplete: nil) + } + + navigationRouter.dismissModule(animated: true) { [weak self] in + self?.authenticationDidComplete() + } + } + + func keyVerificationCoordinatorDidCancel(_ coordinator: KeyVerificationCoordinatorType) { + navigationRouter.dismissModule(animated: true) { [weak self] in + self?.authenticationDidComplete() + } + } +} + +// MARK: - UIAdaptivePresentationControllerDelegate +@available(iOS 14.0, *) +extension AuthenticationCoordinator: UIAdaptivePresentationControllerDelegate { + func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool { + // Prevent Key Verification from using swipe to dismiss + return false + } +} + + + +// MARK: - Unused conformances +@available(iOS 14.0, *) +extension AuthenticationCoordinator { + var customServerFieldsVisible: Bool { + get { false } + set { /* no-op */ } + } + + func update(authenticationType: MXKAuthenticationType) { + // unused + } + + func update(externalRegistrationParameters: [AnyHashable: Any]) { + // unused + } + + func update(softLogoutCredentials: MXCredentials) { + // unused + } + + func updateHomeserver(_ homeserver: String?, andIdentityServer identityServer: String?) { + // unused + } + + func continueSSOLogin(withToken loginToken: String, transactionID: String) -> Bool { + #warning("To be implemented elsewhere") + return false + } +} diff --git a/Riot/Modules/Onboarding/OnboardingCoordinator.swift b/Riot/Modules/Onboarding/OnboardingCoordinator.swift index 2b5753816..0d3d68906 100644 --- a/Riot/Modules/Onboarding/OnboardingCoordinator.swift +++ b/Riot/Modules/Onboarding/OnboardingCoordinator.swift @@ -56,8 +56,9 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol { } // Keep a strong ref as we need to init authVC early to preload its view private let authenticationCoordinator: AuthenticationCoordinatorProtocol + #warning("This might be removable when SSO comes through the AuthenticationService?") /// A boolean to prevent authentication being shown when already in progress. - private var isShowingAuthentication = false + private var isShowingLegacyAuthentication = false // MARK: Screen results private var splashScreenResult: OnboardingSplashScreenViewModelResult? @@ -87,8 +88,8 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol { self.parameters = parameters // Preload the authVC (it is *really* slow to load in realtime) - let authenticationParameters = AuthenticationCoordinatorParameters(navigationRouter: parameters.router, canPresentAdditionalScreens: false) - authenticationCoordinator = AuthenticationCoordinator(parameters: authenticationParameters) + let authenticationParameters = LegacyAuthenticationCoordinatorParameters(navigationRouter: parameters.router, canPresentAdditionalScreens: false) + authenticationCoordinator = LegacyAuthenticationCoordinator(parameters: authenticationParameters) super.init() } @@ -100,7 +101,7 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol { if #available(iOS 14.0, *), parameters.softLogoutCredentials == nil, BuildSettings.authScreenShowRegister { showSplashScreen() } else { - showAuthenticationScreen() + showLegacyAuthenticationScreen() } } @@ -124,7 +125,7 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol { /// When SSO login succeeded, when SFSafariViewController is used, continue login with success parameters. func continueSSOLogin(withToken loginToken: String, transactionID: String) -> Bool { - guard isShowingAuthentication else { return false } + guard isShowingLegacyAuthentication else { return false } return authenticationCoordinator.continueSSOLogin(withToken: loginToken, transactionID: transactionID) } @@ -159,7 +160,7 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol { case .register: showUseCaseSelectionScreen() case .login: - showAuthenticationScreen() + showLegacyAuthenticationScreen() } } @@ -190,16 +191,50 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol { @available(iOS 14.0, *) private func useCaseSelectionCoordinator(_ coordinator: OnboardingUseCaseSelectionCoordinator, didCompleteWith result: OnboardingUseCaseViewModelResult) { useCaseResult = result - showAuthenticationScreen() + + guard BuildSettings.onboardingEnableNewAuthenticationFlow else { + showLegacyAuthenticationScreen() + return + } + + if result == .customServer { + beginAuthentication(with: .selectServerForRegistration) + } else { + beginAuthentication(with: .registration) + } } // MARK: - Authentication - /// Show the authentication screen. Any parameters that have been set in previous screens are be applied. - private func showAuthenticationScreen() { - guard !isShowingAuthentication else { return } + /// Show the authentication flow, starting at the specified initial screen. + @available(iOS 14.0, *) + private func beginAuthentication(with initialScreen: AuthenticationCoordinator.EntryPoint) { + MXLog.debug("[OnboardingCoordinator] beginAuthentication") - MXLog.debug("[OnboardingCoordinator] showAuthenticationScreen") + let parameters = AuthenticationCoordinatorParameters(navigationRouter: navigationRouter, + initialScreen: initialScreen, + canPresentAdditionalScreens: false) + let coordinator = AuthenticationCoordinator(parameters: parameters) + coordinator.completion = { [weak self, weak coordinator] result in + guard let self = self, let coordinator = coordinator else { return } + + switch result { + case .didLogin(let session, let authenticationType): + self.authenticationCoordinator(coordinator, didLoginWith: session, and: authenticationType) + case .didComplete: + self.authenticationCoordinatorDidComplete(coordinator) + } + } + + add(childCoordinator: coordinator) + coordinator.start() + } + + /// Show the legacy authentication screen. Any parameters that have been set in previous screens are be applied. + private func showLegacyAuthenticationScreen() { + guard !isShowingLegacyAuthentication else { return } + + MXLog.debug("[OnboardingCoordinator] showLegacyAuthenticationScreen") let coordinator = authenticationCoordinator coordinator.completion = { [weak self, weak coordinator] result in @@ -239,13 +274,13 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol { } else { navigationRouter.push(coordinator, animated: true) { [weak self] in self?.remove(childCoordinator: coordinator) - self?.isShowingAuthentication = false + self?.isShowingLegacyAuthentication = false } } - isShowingAuthentication = true + isShowingLegacyAuthentication = true } - /// Displays the next view in the flow after the authentication screen, + /// Displays the next view in the flow after the authentication screens, /// whilst crypto and the rest of the app is launching in the background. private func authenticationCoordinator(_ coordinator: AuthenticationCoordinatorProtocol, didLoginWith session: MXSession, @@ -295,9 +330,9 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol { } } - /// Displays the next view in the flow after the authentication screen. + /// Completes the onboarding flow if possible, otherwise waits for any remaining screens. private func authenticationCoordinatorDidComplete(_ coordinator: AuthenticationCoordinatorProtocol) { - isShowingAuthentication = false + isShowingLegacyAuthentication = false // Handle the chosen use case where applicable if authenticationType == .register, @@ -519,7 +554,7 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol { } guard authenticationFinished else { - MXLog.debug("[OnboardingCoordinator] Allowing AuthenticationCoordinator to display any remaining screens.") + MXLog.debug("[OnboardingCoordinator] Allowing LegacyAuthenticationCoordinator to display any remaining screens.") authenticationCoordinator.presentPendingScreensIfNecessary() return } diff --git a/Riot/Modules/Onboarding/OnboardingCoordinatorBridgePresenter.swift b/Riot/Modules/Onboarding/OnboardingCoordinatorBridgePresenter.swift index 32f433936..2286bc046 100644 --- a/Riot/Modules/Onboarding/OnboardingCoordinatorBridgePresenter.swift +++ b/Riot/Modules/Onboarding/OnboardingCoordinatorBridgePresenter.swift @@ -61,15 +61,7 @@ final class OnboardingCoordinatorBridgePresenter: NSObject { // MARK: - Public func present(from viewController: UIViewController, animated: Bool) { - let onboardingCoordinatorParameters = OnboardingCoordinatorParameters(softLogoutCredentials: parameters.softLogoutCredentials) - - let onboardingCoordinator = OnboardingCoordinator(parameters: onboardingCoordinatorParameters) - onboardingCoordinator.completion = { [weak self] in - self?.completion?() - } - if let externalRegistrationParameters = parameters.externalRegistrationParameters { - onboardingCoordinator.update(externalRegistrationParameters: externalRegistrationParameters) - } + let onboardingCoordinator = makeOnboardingCoordinator() let presentable = onboardingCoordinator.toPresentable() presentable.modalPresentationStyle = .fullScreen @@ -86,16 +78,7 @@ final class OnboardingCoordinatorBridgePresenter: NSObject { let navigationRouter = NavigationRouterStore.shared.navigationRouter(for: navigationController) - let onboardingCoordinatorParameters = OnboardingCoordinatorParameters(router: navigationRouter, - softLogoutCredentials: parameters.softLogoutCredentials) - - let onboardingCoordinator = OnboardingCoordinator(parameters: onboardingCoordinatorParameters) - onboardingCoordinator.completion = { [weak self] in - self?.completion?() - } - if let externalRegistrationParameters = parameters.externalRegistrationParameters { - onboardingCoordinator.update(externalRegistrationParameters: externalRegistrationParameters) - } + let onboardingCoordinator = makeOnboardingCoordinator(navigationRouter: navigationRouter) onboardingCoordinator.start() // Will trigger the view controller push @@ -148,4 +131,22 @@ final class OnboardingCoordinatorBridgePresenter: NSObject { } } } + + // MARK: - Private + + /// Makes an `OnboardingCoordinator` using the supplied navigation router, or creating one if needed. + private func makeOnboardingCoordinator(navigationRouter: NavigationRouterType? = nil) -> OnboardingCoordinator { + let onboardingCoordinatorParameters = OnboardingCoordinatorParameters(router: navigationRouter, + softLogoutCredentials: parameters.softLogoutCredentials) + + let onboardingCoordinator = OnboardingCoordinator(parameters: onboardingCoordinatorParameters) + onboardingCoordinator.completion = { [weak self] in + self?.completion?() + } + if let externalRegistrationParameters = parameters.externalRegistrationParameters { + onboardingCoordinator.update(externalRegistrationParameters: externalRegistrationParameters) + } + + return onboardingCoordinator + } } diff --git a/Riot/Modules/Onboarding/SessionVerificationListener.swift b/Riot/Modules/Onboarding/SessionVerificationListener.swift new file mode 100644 index 000000000..f8973d45a --- /dev/null +++ b/Riot/Modules/Onboarding/SessionVerificationListener.swift @@ -0,0 +1,140 @@ +// +// 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 + +/// An object that will listen for the cross-signing state of a new session +/// determining whether or not verification needs to be performed. +class SessionVerificationListener { + enum Result { + case needsVerification + case authenticationIsComplete + } + + // MARK: - Properties + + /// The completion handler called once the cross-signing state has been determined. + var completion: ((Result) -> Void)? + /// The session being used + private let session: MXSession + /// The session's password (if used), for boot-strapping the cross-signing. + private let password: String? + /// The cross-signing service. + private let crossSigningService = CrossSigningService() + + // MARK: - Setup + + /// Creates a new listener object. + /// - Parameter session: The session to listen to. + /// - Parameter password: The password used for the session (optional). + init(session: MXSession, password: String?) { + self.session = session + self.password = password + } + + // MARK: - Public + + /// Start listening for the cross-signing state of the supplied session. + func start() { + registerSessionStateChangeNotification(for: session) + } + + // MARK: - Private + + private func registerSessionStateChangeNotification(for session: MXSession) { + NotificationCenter.default.addObserver(self, selector: #selector(sessionStateDidChange), name: .mxSessionStateDidChange, object: session) + } + + private func unregisterSessionStateChangeNotification() { + NotificationCenter.default.removeObserver(self, name: .mxSessionStateDidChange, object: nil) + } + + @objc private func sessionStateDidChange(_ notification: Notification) { + guard let session = notification.object as? MXSession else { + MXLog.error("[SessionVerificationListener] sessionStateDidChange: Missing session in the notification") + return + } + + if session.state == .storeDataReady { + if let crypto = session.crypto, crypto.crossSigning != nil { + // Do not make key share requests while the "Complete security" is not complete. + // If the device is self-verified, the SDK will restore the existing key backup. + // Then, it will re-enable outgoing key share requests + crypto.setOutgoingKeyRequestsEnabled(false, onComplete: nil) + } + } else if session.state == .running { + unregisterSessionStateChangeNotification() + + if let crypto = session.crypto, let crossSigning = crypto.crossSigning { + crossSigning.refreshState { [weak self] stateUpdated in + guard let self = self else { return } + + MXLog.debug("[SessionVerificationListener] sessionStateDidChange: crossSigning.state: \(crossSigning.state)") + + switch crossSigning.state { + case .notBootstrapped: + // TODO: This is still not sure we want to disable the automatic cross-signing bootstrap + // if the admin disabled e2e by default. + // Do like riot-web for the moment + if session.vc_homeserverConfiguration().encryption.isE2EEByDefaultEnabled { + // Bootstrap cross-signing on user's account + // We do it for both registration and new login as long as cross-signing does not exist yet + if let password = self.password, !password.isEmpty { + MXLog.debug("[SessionVerificationListener] sessionStateDidChange: Bootstrap with password") + + crossSigning.setup(withPassword: password) { + MXLog.debug("[SessionVerificationListener] sessionStateDidChange: Bootstrap succeeded") + self.completion?(.authenticationIsComplete) + } failure: { error in + MXLog.error("[SessionVerificationListener] sessionStateDidChange: Bootstrap failed. Error: \(error)") + crypto.setOutgoingKeyRequestsEnabled(true, onComplete: nil) + self.completion?(.authenticationIsComplete) + } + } else { + // Try to setup cross-signing without authentication parameters in case if a grace period is enabled + self.crossSigningService.setupCrossSigningWithoutAuthentication(for: session) { + MXLog.debug("[SessionVerificationListener] sessionStateDidChange: Bootstrap succeeded without credentials") + self.completion?(.authenticationIsComplete) + } failure: { error in + MXLog.error("[SessionVerificationListener] sessionStateDidChange: Do not know how to bootstrap cross-signing. Skip it.") + crypto.setOutgoingKeyRequestsEnabled(true, onComplete: nil) + self.completion?(.authenticationIsComplete) + } + } + } else { + crypto.setOutgoingKeyRequestsEnabled(true, onComplete: nil) + self.completion?(.authenticationIsComplete) + } + case .crossSigningExists: + MXLog.debug("[SessionVerificationListener] sessionStateDidChange: Needs verification") + self.completion?(.needsVerification) + default: + MXLog.debug("[SessionVerificationListener] sessionStateDidChange: Nothing to do") + + crypto.setOutgoingKeyRequestsEnabled(true, onComplete: nil) + self.completion?(.authenticationIsComplete) + } + } failure: { [weak self] error in + MXLog.error("[SessionVerificationListener] sessionStateDidChange: Fail to refresh crypto state with error: \(error)") + crypto.setOutgoingKeyRequestsEnabled(true, onComplete: nil) + self?.completion?(.authenticationIsComplete) + } + } else { + completion?(.authenticationIsComplete) + } + } + } +} diff --git a/Riot/Modules/Room/CellData/RoomBubbleCellData.m b/Riot/Modules/Room/CellData/RoomBubbleCellData.m index c290e8f3b..1f8170dbe 100644 --- a/Riot/Modules/Room/CellData/RoomBubbleCellData.m +++ b/Riot/Modules/Room/CellData/RoomBubbleCellData.m @@ -1344,7 +1344,7 @@ NSString *const URLPreviewDidUpdateNotification = @"URLPreviewDidUpdateNotificat - (void)updateBeaconInfoSummaryWithEventId:(NSString *)eventId { - MXBeaconInfoSummary *beaconInfoSummary = [self.mxSession.aggregations.beaconAggregations beaconInfoSummaryFor:eventId inRoomWithId:self.roomId]; + id beaconInfoSummary = [self.mxSession.aggregations.beaconAggregations beaconInfoSummaryFor:eventId inRoomWithId:self.roomId]; self.beaconInfoSummary = beaconInfoSummary; } diff --git a/Riot/Modules/Room/MXKRoomViewController.m b/Riot/Modules/Room/MXKRoomViewController.m index ba1dd3e21..a8d79f084 100644 --- a/Riot/Modules/Room/MXKRoomViewController.m +++ b/Riot/Modules/Room/MXKRoomViewController.m @@ -3045,22 +3045,6 @@ [self promptUserToResendEvent:selectedEvent.eventId]; } } - else if ([actionIdentifier isEqualToString:kMXKRoomBubbleCellStopShareButtonPressed]) - { - MXEvent *selectedEvent = userInfo[kMXKRoomBubbleCellEventKey]; - if (selectedEvent) - { - // TODO: - Implement stop live location action - } - } - else if ([actionIdentifier isEqualToString:kMXKRoomBubbleCellRetryShareButtonPressed]) - { - MXEvent *selectedEvent = userInfo[kMXKRoomBubbleCellEventKey]; - if (selectedEvent) - { - // TODO: - Implement retry live location action - } - } } #pragma mark - Clipboard diff --git a/Riot/Modules/Room/RoomCoordinator.swift b/Riot/Modules/Room/RoomCoordinator.swift index bdb355b23..b5203ad1f 100644 --- a/Riot/Modules/Room/RoomCoordinator.swift +++ b/Riot/Modules/Room/RoomCoordinator.swift @@ -32,6 +32,7 @@ final class RoomCoordinator: NSObject, RoomCoordinatorProtocol { private let userIndicatorStore: UserIndicatorStore private var selectedEventId: String? private var loadingCancel: UserIndicatorCancel? + private var locationSharingIndicatorCancel: UserIndicatorCancel? // Used for location sharing advertizements private var roomDataSourceManager: MXKRoomDataSourceManager { return MXKRoomDataSourceManager.sharedManager(forMatrixSession: self.parameters.session) @@ -248,6 +249,87 @@ final class RoomCoordinator: NSObject, RoomCoordinatorProtocol { completion?() } + private func showLiveLocationViewer() { + guard let roomId = self.roomId else { + return + } + + self.showLiveLocationViewer(for: roomId) + } + + private func showLiveLocationViewer(for roomId: String) { + + guard let mxSession = self.mxSession, let navigationRouter = self.navigationRouter else { + return + } + + guard mxSession.locationService.isSomeoneSharingDisplayableLocation(inRoomWithId: roomId) else { + return + } + + let parameters = LiveLocationSharingViewerCoordinatorParameters(session: mxSession, roomId: roomId, navigationRouter: nil) + + let coordinator = LiveLocationSharingViewerCoordinator(parameters: parameters) + + coordinator.completion = { [weak self, weak coordinator] in + guard let self = self, let coordinator = coordinator else { + return + } + + self.navigationRouter?.dismissModule(animated: true, completion: nil) + self.remove(childCoordinator: coordinator) + } + + add(childCoordinator: coordinator) + + navigationRouter.present(coordinator, animated: true) + coordinator.start() + } + + private func stopLiveLocationSharing(forBeaconInfoEventId beaconInfoEventId: String? = nil, inRoomWithId roomId: String) { + guard let session = self.mxSession else { + return + } + + let errorHandler: (Error) -> Void = { error in + + let viewController = self.roomViewController + + viewController.errorPresenter.presentError(from: viewController, title: VectorL10n.error, message: VectorL10n.locationSharingLiveStopSharingError, animated: true) { + } + } + + // TODO: Handle loading state on the banner by replacing stop button with a spinner + self.showLocationSharingIndicator(withMessage: VectorL10n.locationSharingLiveStopSharingProgress) + + if let beaconInfoEventId = beaconInfoEventId { + session.locationService.stopUserLocationSharing(withBeaconInfoEventId: beaconInfoEventId, roomId: roomId) { + [weak self] response in + + self?.hideLocationSharingIndicator() + + switch response { + case .success: + break + case .failure(let error): + errorHandler(error) + } + } + } else { + session.locationService.stopUserLocationSharing(inRoomWithId: roomId) { [weak self] response in + + self?.hideLocationSharingIndicator() + + switch response { + case .success: + break + case .failure(let error): + errorHandler(error) + } + } + } + } + private func showLocationCoordinatorWithEvent(_ event: MXEvent, bubbleData: MXKRoomBubbleCellDataStoring) { guard #available(iOS 14.0, *) else { return @@ -371,6 +453,19 @@ final class RoomCoordinator: NSObject, RoomCoordinatorProtocol { loadingCancel?() loadingCancel = nil } + + private func showLocationSharingIndicator(withMessage message: String) { + guard locationSharingIndicatorCancel == nil else { + return + } + + locationSharingIndicatorCancel = userIndicatorStore.present(type: .loading(label: message, isInteractionBlocking: false)) + } + + private func hideLocationSharingIndicator() { + locationSharingIndicatorCancel?() + locationSharingIndicatorCancel = nil + } } // MARK: - RoomIdentifiable @@ -449,6 +544,15 @@ extension RoomCoordinator: RoomViewControllerDelegate { showLocationCoordinatorWithEvent(event, bubbleData: bubbleData) } + func roomViewController(_ roomViewController: RoomViewController, didRequestLiveLocationPresentationForBubbleData bubbleData: MXKRoomBubbleCellDataStoring) { + + guard let roomId = bubbleData.roomId else { + return + } + + showLiveLocationViewer(for: roomId) + } + func roomViewController(_ roomViewController: RoomViewController, locationShareActivityViewControllerFor event: MXEvent) -> UIActivityViewController? { guard let location = event.location else { return nil @@ -494,11 +598,17 @@ extension RoomCoordinator: RoomViewControllerDelegate { } func roomViewControllerDidTapLiveLocationSharingBanner(_ roomViewController: RoomViewController) { - // TODO: + + showLiveLocationViewer() } - func roomViewControllerDidStopLiveLocationSharing(_ roomViewController: RoomViewController) { - // TODO: + func roomViewControllerDidStopLiveLocationSharing(_ roomViewController: RoomViewController, beaconInfoEventId: String?) { + + guard let roomId = self.roomId else { + return + } + + self.stopLiveLocationSharing(forBeaconInfoEventId: beaconInfoEventId, inRoomWithId: roomId) } func threadsCoordinator(for roomViewController: RoomViewController, threadId: String?) -> ThreadsCoordinatorBridgePresenter? { diff --git a/Riot/Modules/Room/RoomViewController+LocationSharing.swift b/Riot/Modules/Room/RoomViewController+LocationSharing.swift index d0a69b2c4..ffc9bf57d 100644 --- a/Riot/Modules/Room/RoomViewController+LocationSharing.swift +++ b/Riot/Modules/Room/RoomViewController+LocationSharing.swift @@ -53,7 +53,7 @@ import Foundation guard let self = self else { return } - self.delegate?.roomViewControllerDidStopLiveLocationSharing(self) + self.delegate?.roomViewControllerDidStopLiveLocationSharing(self, beaconInfoEventId: nil) } self.topBannersStackView?.addArrangedSubview(bannerView) diff --git a/Riot/Modules/Room/RoomViewController.h b/Riot/Modules/Room/RoomViewController.h index fd7b168ed..bbe43cf99 100644 --- a/Riot/Modules/Room/RoomViewController.h +++ b/Riot/Modules/Room/RoomViewController.h @@ -70,6 +70,9 @@ extern NSNotificationName const RoomGroupCallTileTappedNotification; // Remove Jitsi widget container @property (weak, nonatomic, nullable) IBOutlet UIView *removeJitsiWidgetContainer; +// Error presenter +@property (nonatomic, strong, readonly) MXKErrorAlertPresentation *errorPresenter; + /** Preview data for a room invitation received by email, or a link to a room. */ @@ -264,6 +267,10 @@ handleUniversalLinkWithParameters:(UniversalLinkParameters*)parameters; didRequestLocationPresentationForEvent:(MXEvent *)event bubbleData:(id)bubbleData; +/// Ask the coordinator to present the live location sharing viewer. +- (void)roomViewController:(RoomViewController *)roomViewController +didRequestLiveLocationPresentationForBubbleData:(id)bubbleData; + - (nullable UIActivityViewController *)roomViewController:(RoomViewController *)roomViewController locationShareActivityViewControllerForEvent:(MXEvent *)event; @@ -296,7 +303,7 @@ didRequestEditForPollWithStartEvent:(MXEvent *)startEvent; - (void)roomViewControllerDidStopLoading:(RoomViewController *)roomViewController; /// User tap live location sharing stop action -- (void)roomViewControllerDidStopLiveLocationSharing:(RoomViewController *)roomViewController; +- (void)roomViewControllerDidStopLiveLocationSharing:(RoomViewController *)roomViewController beaconInfoEventId:(nullable NSString*)beaconInfoEventId; /// User tap live location sharing banner - (void)roomViewControllerDidTapLiveLocationSharingBanner:(RoomViewController *)roomViewController; diff --git a/Riot/Modules/Room/RoomViewController.m b/Riot/Modules/Room/RoomViewController.m index bcd360307..86f2a071b 100644 --- a/Riot/Modules/Room/RoomViewController.m +++ b/Riot/Modules/Room/RoomViewController.m @@ -3162,6 +3162,26 @@ static CGSize kThreadListBarButtonItemImageSize; [self mention:roomMember]; } } + else if ([actionIdentifier isEqualToString:kMXKRoomBubbleCellStopShareButtonPressed]) + { + NSString *beaconInfoEventId; + + if ([bubbleData isKindOfClass:[RoomBubbleCellData class]]) + { + RoomBubbleCellData *roomBubbleCellData = (RoomBubbleCellData*)bubbleData; + beaconInfoEventId = roomBubbleCellData.beaconInfoSummary.id; + } + + [self.delegate roomViewControllerDidStopLiveLocationSharing:self beaconInfoEventId:beaconInfoEventId]; + } + else if ([actionIdentifier isEqualToString:kMXKRoomBubbleCellRetryShareButtonPressed]) + { + MXEvent *selectedEvent = userInfo[kMXKRoomBubbleCellEventKey]; + if (selectedEvent) + { + // TODO: - Implement retry live location action + } + } else if ([actionIdentifier isEqualToString:kMXKRoomBubbleCellTapOnMessageTextView] || [actionIdentifier isEqualToString:kMXKRoomBubbleCellTapOnContentView]) { // Retrieve the tapped event @@ -3172,6 +3192,10 @@ static CGSize kThreadListBarButtonItemImageSize; { [self cancelEventSelection]; } + else if (bubbleData.tag == RoomBubbleCellDataTagLiveLocation) + { + [self.delegate roomViewController:self didRequestLiveLocationPresentationForBubbleData:bubbleData]; + } else if (tappedEvent) { if (tappedEvent.eventType == MXEventTypeRoomCreate) diff --git a/Riot/Modules/Spaces/SpaceRoomList/ExploreRoomCoordinator.swift b/Riot/Modules/Spaces/SpaceRoomList/ExploreRoomCoordinator.swift index 1ccf521fb..8e2a72808 100644 --- a/Riot/Modules/Spaces/SpaceRoomList/ExploreRoomCoordinator.swift +++ b/Riot/Modules/Spaces/SpaceRoomList/ExploreRoomCoordinator.swift @@ -422,6 +422,10 @@ extension ExploreRoomCoordinator: RoomViewControllerDelegate { // TODO: } + func roomViewController(_ roomViewController: RoomViewController, didRequestLiveLocationPresentationForBubbleData bubbleData: MXKRoomBubbleCellDataStoring) { + // TODO: + } + func roomViewController(_ roomViewController: RoomViewController, didRequestLocationPresentationFor event: MXEvent, bubbleData: MXKRoomBubbleCellDataStoring) { // TODO: } @@ -474,7 +478,7 @@ extension ExploreRoomCoordinator: RoomViewControllerDelegate { // TODO: } - func roomViewControllerDidStopLiveLocationSharing(_ roomViewController: RoomViewController) { + func roomViewControllerDidStopLiveLocationSharing(_ roomViewController: RoomViewController, beaconInfoEventId: String?) { // TODO: } diff --git a/RiotSwiftUI/Modules/Authentication/Common/AuthenticationModels.swift b/RiotSwiftUI/Modules/Authentication/Common/AuthenticationModels.swift index d5b769da2..75fcac428 100644 --- a/RiotSwiftUI/Modules/Authentication/Common/AuthenticationModels.swift +++ b/RiotSwiftUI/Modules/Authentication/Common/AuthenticationModels.swift @@ -23,22 +23,40 @@ enum AuthenticationFlow { } /// Errors that can be thrown from `AuthenticationService`. -enum AuthenticationError: String, Error { +enum AuthenticationError: String, LocalizedError { /// A failure to convert a struct into a dictionary. case dictionaryError case invalidHomeserver case loginFlowNotCalled - case missingRegistrationWizard case missingMXRestClient + + var errorDescription: String? { + switch self { + case .invalidHomeserver: + return VectorL10n.authenticationServerSelectionGenericError + default: + return VectorL10n.errorCommonMessage + } + } } /// Errors that can be thrown from `RegistrationWizard` -enum RegistrationError: String, Error { +enum RegistrationError: String, LocalizedError { + case registrationDisabled case createAccountNotCalled case missingThreePIDData case missingThreePIDURL case threePIDValidationFailure case threePIDClientFailure + + var errorDescription: String? { + switch self { + case .registrationDisabled: + return VectorL10n.loginErrorRegistrationIsNotSupported + default: + return VectorL10n.errorCommonMessage + } + } } /// Errors that can be thrown from `LoginWizard` diff --git a/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationPendingData.swift b/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationPendingData.swift new file mode 100644 index 000000000..2aa4bc499 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationPendingData.swift @@ -0,0 +1,43 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +/// This class holds all pending data when creating a session, either by login or by register +class AuthenticationPendingData { + let homeserverAddress: String + + // MARK: - Common + + var clientSecret = UUID().uuidString + var sendAttempt: UInt = 0 + + // MARK: - For login + + // var resetPasswordData: ResetPasswordData? + + // MARK: - For registration + + var currentSession: String? + var isRegistrationStarted = false + var currentThreePIDData: ThreePIDData? + + // MARK: - Setup + + init(homeserverAddress: String) { + self.homeserverAddress = homeserverAddress + } +} diff --git a/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationService.swift b/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationService.swift index 0a09407f0..38021f43e 100644 --- a/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationService.swift +++ b/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationService.swift @@ -48,21 +48,14 @@ class AuthenticationService: NSObject { // MARK: - Setup override init() { - if let homeserverURL = URL(string: RiotSettings.shared.homeserverUrlString) { - // Use the same homeserver that was last used. - state = AuthenticationState(flow: .login, homeserverAddress: RiotSettings.shared.homeserverUrlString) - client = MXRestClient(homeServer: homeserverURL, unrecognizedCertificateHandler: nil) - - } else if let homeserverURL = URL(string: BuildSettings.serverConfigDefaultHomeserverUrlString) { - // Fall back to the default homeserver if the stored one is invalid. - state = AuthenticationState(flow: .login, homeserverAddress: BuildSettings.serverConfigDefaultHomeserverUrlString) - client = MXRestClient(homeServer: homeserverURL, unrecognizedCertificateHandler: nil) - - } else { + guard let homeserverURL = URL(string: BuildSettings.serverConfigDefaultHomeserverUrlString) else { MXLog.failure("[AuthenticationService]: Failed to create URL from default homeserver URL string.") fatalError("Invalid default homeserver URL string.") } + state = AuthenticationState(flow: .login, homeserverAddress: BuildSettings.serverConfigDefaultHomeserverUrlString) + client = MXRestClient(homeServer: homeserverURL, unrecognizedCertificateHandler: nil) + super.init() } @@ -97,22 +90,25 @@ class AuthenticationService: NSObject { let loginFlows = try await loginFlow(for: homeserverAddress) - // Valid Homeserver, add it to the history. - // Note: we add what the user has input, as the data can contain a different value. - RiotSettings.shared.homeserverUrlString = homeserverAddress - state.homeserver = .init(address: loginFlows.homeserverAddress, - addressFromUser: homeserverAddress, - preferredLoginMode: loginFlows.loginMode, - loginModeSupportedTypes: loginFlows.supportedLoginTypes) + addressFromUser: homeserverAddress, + preferredLoginMode: loginFlows.loginMode, + loginModeSupportedTypes: loginFlows.supportedLoginTypes) let loginWizard = LoginWizard() self.loginWizard = loginWizard if flow == .registration { - let registrationWizard = RegistrationWizard(client: client) - state.homeserver.registrationFlow = try await registrationWizard.registrationFlow() - self.registrationWizard = registrationWizard + do { + let registrationWizard = RegistrationWizard(client: client) + state.homeserver.registrationFlow = try await registrationWizard.registrationFlow() + self.registrationWizard = registrationWizard + } catch { + guard state.homeserver.preferredLoginMode.hasSSO, error as? RegistrationError == .registrationDisabled else { + throw error + } + // Continue without throwing when registration is disabled but SSO is available. + } } state.flow = flow @@ -182,6 +178,7 @@ class AuthenticationService: NSObject { let homeserverAddress = HomeserverAddress.sanitized(homeserverAddress) guard var homeserverURL = URL(string: homeserverAddress) else { + MXLog.error("[AuthenticationService] Unable to create a URL from the supplied homeserver address when calling loginFlow.") throw AuthenticationError.invalidHomeserver } @@ -207,7 +204,10 @@ class AuthenticationService: NSObject { /// This method is used to get the flows for a server after a soft-logout. /// - Parameter session: The MXSession where a soft-logout has occurred. private func loginFlow(for session: MXSession) async throws -> LoginFlowResult { - guard let client = session.matrixRestClient else { throw AuthenticationError.missingMXRestClient } + guard let client = session.matrixRestClient else { + MXLog.error("[AuthenticationService] loginFlow called on a session that doesn't have a matrixRestClient.") + throw AuthenticationError.missingMXRestClient + } let state = AuthenticationState(flow: .login, homeserverAddress: client.homeserver) let loginFlow = try await getLoginFlowResult(client: session.matrixRestClient) diff --git a/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/RegistrationModels.swift b/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/RegistrationModels.swift index e234aa26f..d3ca60c0b 100644 --- a/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/RegistrationModels.swift +++ b/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/RegistrationModels.swift @@ -47,6 +47,7 @@ struct RegistrationParameters: Codable { let jsonData = try JSONEncoder().encode(self) let object = try JSONSerialization.jsonObject(with: jsonData) guard let dictionary = object as? [String: Any] else { + MXLog.error("[RegistrationParameters] dictionary: Unexpected type decoded \(type(of: object)). Expected a Dictionary.") throw AuthenticationError.dictionaryError } diff --git a/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/RegistrationWizard.swift b/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/RegistrationWizard.swift index c3e714eca..1315f2101 100644 --- a/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/RegistrationWizard.swift +++ b/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/RegistrationWizard.swift @@ -74,7 +74,16 @@ class RegistrationWizard { /// See `AuthenticationService.getFallbackUrl` func registrationFlow() async throws -> RegistrationResult { let parameters = RegistrationParameters() - return try await performRegistrationRequest(parameters: parameters) + + do { + let result = try await performRegistrationRequest(parameters: parameters) + return result + } catch { + // Map M_FORBIDDEN into a registration error. + guard let mxError = MXError(nsError: error), mxError.errcode == kMXErrCodeStringForbidden else { throw error } + MXLog.warning("[RegistrationWizard] Registration is disabled for the selected server.") + throw RegistrationError.registrationDisabled + } } /// Can be call to check is the desired username is available for registration on the current homeserver. @@ -94,7 +103,7 @@ class RegistrationWizard { password: String?, initialDeviceDisplayName: String?) async throws -> RegistrationResult { let parameters = RegistrationParameters(username: username, password: password, initialDeviceDisplayName: initialDeviceDisplayName) - let result = try await performRegistrationRequest(parameters: parameters) + let result = try await performRegistrationRequest(parameters: parameters, isCreatingAccount: true) state.isRegistrationStarted = true return result } @@ -104,6 +113,7 @@ class RegistrationWizard { /// - Parameter response: The response from ReCaptcha func performReCaptcha(response: String) async throws -> RegistrationResult { guard let session = state.currentSession else { + MXLog.error("[RegistrationWizard] performReCaptcha: Missing authentication session, createAccount hasn't been called.") throw RegistrationError.createAccountNotCalled } @@ -114,6 +124,7 @@ class RegistrationWizard { /// Perform the "m.login.terms" stage. func acceptTerms() async throws -> RegistrationResult { guard let session = state.currentSession else { + MXLog.error("[RegistrationWizard] acceptTerms: Missing authentication session, createAccount hasn't been called.") throw RegistrationError.createAccountNotCalled } @@ -124,6 +135,7 @@ class RegistrationWizard { /// Perform the "m.login.dummy" stage. func dummy() async throws -> RegistrationResult { guard let session = state.currentSession else { + MXLog.error("[RegistrationWizard] dummy: Missing authentication session, createAccount hasn't been called.") throw RegistrationError.createAccountNotCalled } @@ -143,6 +155,7 @@ class RegistrationWizard { /// Ask the homeserver to send again the current threePID (email or msisdn). func sendAgainThreePID() async throws -> RegistrationResult { guard let threePID = state.currentThreePIDData?.threePID else { + MXLog.error("[RegistrationWizard] sendAgainThreePID: Missing authentication session, createAccount hasn't been called.") throw RegistrationError.createAccountNotCalled } return try await sendThreePID(threePID: threePID) @@ -160,6 +173,7 @@ class RegistrationWizard { func checkIfEmailHasBeenValidated(delay: TimeInterval) async throws -> RegistrationResult { MXLog.failure("The delay on this method is no longer available. Move this to the object handling the polling.") guard let parameters = state.currentThreePIDData?.registrationParameters else { + MXLog.error("[RegistrationWizard] checkIfEmailHasBeenValidated: The current 3pid data hasn't been stored in the state.") throw RegistrationError.missingThreePIDData } @@ -170,10 +184,12 @@ class RegistrationWizard { private func validateThreePid(code: String) async throws -> RegistrationResult { guard let threePIDData = state.currentThreePIDData else { + MXLog.error("[RegistrationWizard] validateThreePid: There is no third party ID data stored in the state.") throw RegistrationError.missingThreePIDData } guard let submitURL = threePIDData.registrationResponse.submitURL else { + MXLog.error("[RegistrationWizard] validateThreePid: The third party ID data doesn't contain a submitURL.") throw RegistrationError.missingThreePIDURL } @@ -184,9 +200,11 @@ class RegistrationWizard { #warning("Seems odd to pass a nil baseURL and then the url as the path, yet this is how MXK3PID works") guard let httpClient = MXHTTPClient(baseURL: nil, andOnUnrecognizedCertificateBlock: nil) else { + MXLog.error("[RegistrationWizard] validateThreePid: Failed to create an MXHTTPClient.") throw RegistrationError.threePIDClientFailure } guard try await httpClient.validateThreePIDCode(submitURL: submitURL, validationBody: validationBody) else { + MXLog.error("[RegistrationWizard] validateThreePid: Third party ID validation failed.") throw RegistrationError.threePIDValidationFailure } @@ -197,6 +215,7 @@ class RegistrationWizard { private func sendThreePID(threePID: RegisterThreePID) async throws -> RegistrationResult { guard let session = state.currentSession else { + MXLog.error("[RegistrationWizard] sendThreePID: Missing authentication session, createAccount hasn't been called.") throw RegistrationError.createAccountNotCalled } @@ -223,7 +242,7 @@ class RegistrationWizard { return try await performRegistrationRequest(parameters: parameters) } - private func performRegistrationRequest(parameters: RegistrationParameters) async throws -> RegistrationResult { + private func performRegistrationRequest(parameters: RegistrationParameters, isCreatingAccount: Bool = false) async throws -> RegistrationResult { do { let response = try await client.register(parameters: parameters) let credentials = MXCredentials(loginResponse: response, andDefaultCredentials: client.credentials) @@ -237,7 +256,20 @@ class RegistrationWizard { else { throw error } state.currentSession = authenticationSession.session - return .flowResponse(authenticationSession.flowResult) + let flowResult = authenticationSession.flowResult + + if isCreatingAccount || isRegistrationStarted { + return try await handleMandatoryDummyStage(flowResult: flowResult) + } + + return .flowResponse(flowResult) } } + + /// Checks for a mandatory dummy stage and handles it automatically when possible. + private func handleMandatoryDummyStage(flowResult: FlowResult) async throws -> RegistrationResult { + // If the dummy stage is mandatory, do the dummy stage now + guard flowResult.missingStages.contains(where: { $0.isDummyAndMandatory }) else { return .flowResponse(flowResult) } + return try await dummy() + } } diff --git a/RiotSwiftUI/Modules/Authentication/Registration/AuthenticationRegistrationModels.swift b/RiotSwiftUI/Modules/Authentication/Registration/AuthenticationRegistrationModels.swift new file mode 100644 index 000000000..da72ac4eb --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/Registration/AuthenticationRegistrationModels.swift @@ -0,0 +1,123 @@ +// +// 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: View model + +enum AuthenticationRegistrationViewModelResult { + /// The user would like to select another server. + case selectServer + /// Validate the supplied username with the homeserver. + case validateUsername(String) + /// Create an account using the supplied credentials. + case createAccount(username: String, password: String) +} + +// MARK: View + +struct AuthenticationRegistrationViewState: BindableState { + /// The address of the homeserver. + var homeserverAddress: String + /// Whether or not to show the username and password text fields with the next button + var showRegistrationForm: Bool + /// An array containing the available SSO options for login. + var ssoIdentityProviders: [SSOIdentityProvider] + /// View state that can be bound to from SwiftUI. + var bindings: AuthenticationRegistrationBindings + /// Whether or not the username field has been edited yet. + /// + /// This is used to delay showing an error state until the user has tried 1 username. + var hasEditedUsername = false + /// Whether or not the password field has been edited yet. + /// + /// This is used to delay showing an error state until the user has tried 1 password. + var hasEditedPassword = false + + /// An error message to be shown in the username text field footer. + var usernameErrorMessage: String? + + /// The message to show in the username text field footer. + var usernameFooterMessage: String { + usernameErrorMessage ?? VectorL10n.authenticationRegistrationUsernameFooter + } + + /// A description that can be shown for the currently selected homeserver. + var serverDescription: String? { + guard homeserverAddress == "matrix.org" else { return nil } + return VectorL10n.authenticationRegistrationMatrixDescription + } + + /// Whether to show any SSO buttons. + var showSSOButtons: Bool { + !ssoIdentityProviders.isEmpty + } + + /// Whether the current `username` is valid. + var isUsernameValid: Bool { + !bindings.username.isEmpty && usernameErrorMessage == nil + } + + /// Whether the current `password` is valid. + var isPasswordValid: Bool { + bindings.password.count >= 8 + } + + /// `true` if it is possible to continue, otherwise `false`. + var hasValidCredentials: Bool { + isUsernameValid && isPasswordValid + } +} + +struct AuthenticationRegistrationBindings { + /// The username input by the user. + var username = "" + /// The password input by the user. + var password = "" + /// Information describing the currently displayed alert. + var alertInfo: AlertInfo? +} + +enum AuthenticationRegistrationViewAction { + /// The user would like to select another server. + case selectServer + /// Validate the supplied username with the homeserver. + case validateUsername + /// Allows password validation to take place (sent after editing the password for the first time). + case enablePasswordValidation + /// Clear any errors being shown in the username text field footer. + case clearUsernameError + /// Continue using the input username and password. + case next + /// Login using the supplied SSO provider ID. + case continueWithSSO(id: String) +} + +enum AuthenticationRegistrationErrorType: Hashable { + /// An error to be shown in the username text field footer. + case usernameUnavailable(String) + + /// An error response from the homeserver. + case mxError(String) + /// The current homeserver address isn't valid. + case invalidHomeserver + /// The response from the homeserver was unexpected. + case invalidResponse + /// The homeserver doesn't support registration. + case registrationDisabled + /// An unknown error occurred. + case unknown +} diff --git a/RiotSwiftUI/Modules/Authentication/Registration/AuthenticationRegistrationViewModel.swift b/RiotSwiftUI/Modules/Authentication/Registration/AuthenticationRegistrationViewModel.swift new file mode 100644 index 000000000..9f30db8a0 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/Registration/AuthenticationRegistrationViewModel.swift @@ -0,0 +1,98 @@ +// +// 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 +import Combine + +@available(iOS 14, *) +typealias AuthenticationRegistrationViewModelType = StateStoreViewModel + + +@available(iOS 14, *) +class AuthenticationRegistrationViewModel: AuthenticationRegistrationViewModelType, AuthenticationRegistrationViewModelProtocol { + + // MARK: - Properties + + // MARK: Public + + @MainActor var completion: ((AuthenticationRegistrationViewModelResult) -> Void)? + + // MARK: - Setup + + init(homeserverAddress: String, showRegistrationForm: Bool = true, ssoIdentityProviders: [SSOIdentityProvider]) { + let bindings = AuthenticationRegistrationBindings() + let viewState = AuthenticationRegistrationViewState(homeserverAddress: HomeserverAddress.displayable(homeserverAddress), + showRegistrationForm: showRegistrationForm, + ssoIdentityProviders: ssoIdentityProviders, + bindings: bindings) + + super.init(initialViewState: viewState) + } + + // MARK: - Public + + override func process(viewAction: AuthenticationRegistrationViewAction) { + Task { + await MainActor.run { + switch viewAction { + case .selectServer: + completion?(.selectServer) + case .validateUsername: + state.hasEditedUsername = true + completion?(.validateUsername(state.bindings.username)) + case .enablePasswordValidation: + state.hasEditedPassword = true + case .clearUsernameError: + guard state.usernameErrorMessage != nil else { return } + state.usernameErrorMessage = nil + case .next: + completion?(.createAccount(username: state.bindings.username, password: state.bindings.password)) + case .continueWithSSO(let id): + break + } + } + } + } + + @MainActor func update(homeserverAddress: String, showRegistrationForm: Bool, ssoIdentityProviders: [SSOIdentityProvider]) { + state.homeserverAddress = HomeserverAddress.displayable(homeserverAddress) + state.showRegistrationForm = showRegistrationForm + state.ssoIdentityProviders = ssoIdentityProviders + } + + @MainActor func displayError(_ type: AuthenticationRegistrationErrorType) { + switch type { + case .usernameUnavailable(let message): + state.usernameErrorMessage = message + case .mxError(let message): + state.bindings.alertInfo = AlertInfo(id: type, + title: VectorL10n.error, + message: message) + case .invalidHomeserver, .invalidResponse: + state.bindings.alertInfo = AlertInfo(id: type, + title: VectorL10n.error, + message: VectorL10n.authenticationServerSelectionGenericError) + case .registrationDisabled: + state.bindings.alertInfo = AlertInfo(id: type, + title: VectorL10n.error, + message: VectorL10n.loginErrorRegistrationIsNotSupported) + case .unknown: + state.bindings.alertInfo = AlertInfo(id: type) + } + } +} diff --git a/RiotSwiftUI/Modules/Authentication/Registration/AuthenticationRegistrationViewModelProtocol.swift b/RiotSwiftUI/Modules/Authentication/Registration/AuthenticationRegistrationViewModelProtocol.swift new file mode 100644 index 000000000..e63a5c601 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/Registration/AuthenticationRegistrationViewModelProtocol.swift @@ -0,0 +1,34 @@ +// +// 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 AuthenticationRegistrationViewModelProtocol { + + @MainActor var completion: ((AuthenticationRegistrationViewModelResult) -> Void)? { get set } + @available(iOS 14, *) + var context: AuthenticationRegistrationViewModelType.Context { get } + + /// Update the view with new homeserver information. + /// - Parameters: + /// - homeserverAddress: The homeserver string to be shown to the user. + /// - showRegistrationForm: Whether or not to display the username and password text fields. + /// - ssoIdentityProviders: The supported SSO login options. + @MainActor func update(homeserverAddress: String, showRegistrationForm: Bool, ssoIdentityProviders: [SSOIdentityProvider]) + + /// Display an error to the user. + @MainActor func displayError(_ type: AuthenticationRegistrationErrorType) +} diff --git a/RiotSwiftUI/Modules/Authentication/Registration/Coordinator/AuthenticationRegistrationCoordinator.swift b/RiotSwiftUI/Modules/Authentication/Registration/Coordinator/AuthenticationRegistrationCoordinator.swift new file mode 100644 index 000000000..79dc03de5 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/Registration/Coordinator/AuthenticationRegistrationCoordinator.swift @@ -0,0 +1,246 @@ +// +// 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 +import CommonKit +import MatrixSDK + +@available(iOS 14.0, *) +struct AuthenticationRegistrationCoordinatorParameters { + let navigationRouter: NavigationRouterType + let authenticationService: AuthenticationService + /// The registration flow that is available for the chosen server. + let registrationFlow: RegistrationResult? + /// The login mode to allow SSO buttons to be shown when available. + let loginMode: LoginMode +} + +enum AuthenticationRegistrationCoordinatorResult { + /// The user would like to select another server. + case selectServer + /// The screen completed with the associated registration result. + case completed(RegistrationResult) +} + +@available(iOS 14.0, *) +final class AuthenticationRegistrationCoordinator: Coordinator, Presentable { + + // MARK: - Properties + + // MARK: Private + + private let parameters: AuthenticationRegistrationCoordinatorParameters + private let authenticationRegistrationHostingController: VectorHostingController + private var authenticationRegistrationViewModel: AuthenticationRegistrationViewModelProtocol + + private var currentTask: Task? { + willSet { + currentTask?.cancel() + } + } + + private var navigationRouter: NavigationRouterType { parameters.navigationRouter } + private var indicatorPresenter: UserIndicatorTypePresenterProtocol + private var waitingIndicator: UserIndicator? + + /// The authentication service used for the registration. + var authenticationService: AuthenticationService { parameters.authenticationService } + /// The wizard used to handle the registration flow. May be `nil` when only SSO is supported. + var registrationWizard: RegistrationWizard? + + // MARK: Public + + // Must be used only internally + var childCoordinators: [Coordinator] = [] + @MainActor var completion: ((AuthenticationRegistrationCoordinatorResult) -> Void)? + + // MARK: - Setup + + @MainActor init(parameters: AuthenticationRegistrationCoordinatorParameters) { + self.parameters = parameters + self.registrationWizard = parameters.authenticationService.registrationWizard + + let homeserver = parameters.authenticationService.state.homeserver + let viewModel = AuthenticationRegistrationViewModel(homeserverAddress: homeserver.addressFromUser ?? homeserver.address, + showRegistrationForm: homeserver.registrationFlow != nil, + ssoIdentityProviders: parameters.loginMode.ssoIdentityProviders ?? []) + authenticationRegistrationViewModel = viewModel + + let view = AuthenticationRegistrationScreen(viewModel: viewModel.context) + authenticationRegistrationHostingController = VectorHostingController(rootView: view) + authenticationRegistrationHostingController.vc_removeBackTitle() + authenticationRegistrationHostingController.enableNavigationBarScrollEdgeAppearance = true + + indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: authenticationRegistrationHostingController) + } + + // MARK: - Public + func start() { + Task { + await MainActor.run { + MXLog.debug("[AuthenticationRegistrationCoordinator] did start.") + authenticationRegistrationViewModel.completion = { [weak self] result in + guard let self = self else { return } + MXLog.debug("[AuthenticationRegistrationCoordinator] AuthenticationRegistrationViewModel did complete with result: \(result).") + switch result { + case .selectServer: + self.presentServerSelectionScreen() + case.validateUsername(let username): + self.validateUsername(username) + case .createAccount(let username, let password): + self.createAccount(username: username, password: password) + } + } + } + } + } + + func toPresentable() -> UIViewController { + return self.authenticationRegistrationHostingController + } + + // MARK: - Private + + /// Show a blocking activity indicator whilst saving. + @MainActor private func startLoading(label: String? = nil) { + waitingIndicator = indicatorPresenter.present(.loading(label: label ?? VectorL10n.loading, isInteractionBlocking: true)) + } + + /// Hide the currently displayed activity indicator. + @MainActor private func stopLoading() { + waitingIndicator = nil + } + + /// Asks the homeserver to check the supplied username's format and availability. + @MainActor private func validateUsername(_ username: String) { + guard let registrationWizard = registrationWizard else { + MXLog.failure("[AuthenticationRegistrationCoordinator] The registration wizard was requested before getting the login flow.") + return + } + + currentTask = Task { + do { + _ = try await registrationWizard.registrationAvailable(username: username) + } catch { + guard !Task.isCancelled, let mxError = MXError(nsError: error as NSError) else { return } + if mxError.errcode == kMXErrCodeStringUserInUse + || mxError.errcode == kMXErrCodeStringInvalidUsername + || mxError.errcode == kMXErrCodeStringExclusiveResource { + authenticationRegistrationViewModel.displayError(.usernameUnavailable(mxError.error)) + } + } + } + } + + /// Creates an account on the homeserver with the supplied username and password. + @MainActor private func createAccount(username: String, password: String) { + guard let registrationWizard = registrationWizard else { + MXLog.failure("[AuthenticationRegistrationCoordinator] createAccount: The registration wizard is nil.") + return + } + + // reAuthHelper.data = state.password + let deviceDisplayName = UIDevice.current.isPhone ? VectorL10n.loginMobileDevice : VectorL10n.loginTabletDevice + + startLoading() + + currentTask = Task { [weak self] in + do { + let result = try await registrationWizard.createAccount(username: username, password: password, initialDeviceDisplayName: deviceDisplayName) + + guard !Task.isCancelled else { return } + completion?(.completed(result)) + + self?.stopLoading() + } catch { + self?.stopLoading() + self?.handleError(error) + } + } + } + + /// Processes an error to either update the flow or display it to the user. + @MainActor private func handleError(_ error: Error) { + if let mxError = MXError(nsError: error as NSError) { + authenticationRegistrationViewModel.displayError(.mxError(mxError.error)) + return + } + + if let authenticationError = error as? AuthenticationError { + switch authenticationError { + case .invalidHomeserver: + authenticationRegistrationViewModel.displayError(.invalidHomeserver) + case .dictionaryError: + authenticationRegistrationViewModel.displayError(.unknown) + case .loginFlowNotCalled: + #warning("Reset the flow") + case .missingMXRestClient: + #warning("Forget the soft logout session") + } + return + } + + if let registrationError = error as? RegistrationError { + switch registrationError { + case .registrationDisabled: + authenticationRegistrationViewModel.displayError(.registrationDisabled) + case .createAccountNotCalled, .missingThreePIDData, .missingThreePIDURL, .threePIDClientFailure, .threePIDValidationFailure: + // Shouldn't happen at this stage + authenticationRegistrationViewModel.displayError(.unknown) + } + return + } + + authenticationRegistrationViewModel.displayError(.unknown) + } + + /// Presents the server selection screen as a modal. + @MainActor private func presentServerSelectionScreen() { + MXLog.debug("[AuthenticationCoordinator] showServerSelectionScreen") + let parameters = AuthenticationServerSelectionCoordinatorParameters(authenticationService: authenticationService, + hasModalPresentation: true) + let coordinator = AuthenticationServerSelectionCoordinator(parameters: parameters) + coordinator.completion = { [weak self, weak coordinator] result in + guard let self = self, let coordinator = coordinator else { return } + self.serverSelectionCoordinator(coordinator, didCompleteWith: result) + } + + coordinator.start() + add(childCoordinator: coordinator) + + let modalRouter = NavigationRouter() + modalRouter.setRootModule(coordinator) + + navigationRouter.present(modalRouter, animated: true) + } + + /// Handles the result from the server selection modal, dismissing it after updating the view. + @MainActor private func serverSelectionCoordinator(_ coordinator: AuthenticationServerSelectionCoordinator, + didCompleteWith result: AuthenticationServerSelectionCoordinatorResult) { + if result == .updated { + let homeserver = authenticationService.state.homeserver + authenticationRegistrationViewModel.update(homeserverAddress: homeserver.addressFromUser ?? homeserver.address, + showRegistrationForm: homeserver.registrationFlow != nil, + ssoIdentityProviders: homeserver.preferredLoginMode.ssoIdentityProviders ?? []) + + self.registrationWizard = authenticationService.registrationWizard + } + + navigationRouter.dismissModule(animated: true) { [weak self] in + self?.remove(childCoordinator: coordinator) + } + } +} diff --git a/RiotSwiftUI/Modules/Authentication/Registration/MockAuthenticationRegistrationScreenState.swift b/RiotSwiftUI/Modules/Authentication/Registration/MockAuthenticationRegistrationScreenState.swift new file mode 100644 index 000000000..cc420c7b0 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/Registration/MockAuthenticationRegistrationScreenState.swift @@ -0,0 +1,77 @@ +// +// 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. +@available(iOS 14.0, *) +enum MockAuthenticationRegistrationScreenState: MockScreenState, CaseIterable { + // A case for each state you want to represent + // with specific, minimal associated data that will allow you + // mock that screen. + case matrixDotOrg + case passwordOnly + case passwordWithCredentials + case passwordWithUsernameError + case ssoOnly + + /// The associated screen + var screenType: Any.Type { + AuthenticationRegistrationScreen.self + } + + /// Generate the view struct for the screen state. + var screenView: ([Any], AnyView) { + let viewModel: AuthenticationRegistrationViewModel + switch self { + case .matrixDotOrg: + viewModel = AuthenticationRegistrationViewModel(homeserverAddress: "https://matrix.org", ssoIdentityProviders: [ + SSOIdentityProvider(id: "1", name: "Apple", brand: "apple", iconURL: nil), + SSOIdentityProvider(id: "2", name: "Facebook", brand: "facebook", iconURL: nil), + SSOIdentityProvider(id: "3", name: "GitHub", brand: "github", iconURL: nil), + SSOIdentityProvider(id: "4", name: "GitLab", brand: "gitlab", iconURL: nil), + SSOIdentityProvider(id: "5", name: "Google", brand: "google", iconURL: nil) + ]) + case .passwordOnly: + viewModel = AuthenticationRegistrationViewModel(homeserverAddress: "https://example.com", ssoIdentityProviders: []) + case .passwordWithCredentials: + viewModel = AuthenticationRegistrationViewModel(homeserverAddress: "https://example.com", ssoIdentityProviders: []) + viewModel.context.username = "alice" + viewModel.context.password = "password" + case .passwordWithUsernameError: + viewModel = AuthenticationRegistrationViewModel(homeserverAddress: "https://example.com", ssoIdentityProviders: []) + viewModel.state.hasEditedUsername = true + Task { + await MainActor.run { + viewModel.displayError(.usernameUnavailable(VectorL10n.authInvalidUserName)) + } + } + case .ssoOnly: + viewModel = AuthenticationRegistrationViewModel(homeserverAddress: "https://company.com", + showRegistrationForm: false, + ssoIdentityProviders: [SSOIdentityProvider(id: "test", name: "SAML", brand: nil, iconURL: nil)]) + } + + + // can simulate service and viewModel actions here if needs be. + + return ( + [viewModel], AnyView(AuthenticationRegistrationScreen(viewModel: viewModel.context)) + ) + } +} diff --git a/RiotSwiftUI/Modules/Authentication/Registration/Test/UI/AuthenticationRegistrationUITests.swift b/RiotSwiftUI/Modules/Authentication/Registration/Test/UI/AuthenticationRegistrationUITests.swift new file mode 100644 index 000000000..ce6177ef9 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/Registration/Test/UI/AuthenticationRegistrationUITests.swift @@ -0,0 +1,144 @@ +// +// 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 +import RiotSwiftUI + +@available(iOS 14.0, *) +class AuthenticationRegistrationUITests: MockScreenTest { + + override class var screenType: MockScreenState.Type { + return MockAuthenticationRegistrationScreenState.self + } + + override class func createTest() -> MockScreenTest { + return AuthenticationRegistrationUITests(selector: #selector(verifyAuthenticationRegistrationScreen)) + } + + func verifyAuthenticationRegistrationScreen() throws { + guard let screenState = screenState as? MockAuthenticationRegistrationScreenState else { fatalError("no screen") } + switch screenState { + case .matrixDotOrg: + let state = "matrix.org" + validateRegistrationFormIsVisible(for: state) + validateSSOButtonsAreShown(for: state) + + validateNoErrorsAreShown(for: state) + case .passwordOnly: + let state = "a password only server" + validateRegistrationFormIsVisible(for: state) + validateSSOButtonsAreHidden(for: state) + + validateNextButtonIsDisabled(for: state) + + validateNoErrorsAreShown(for: state) + case .passwordWithCredentials: + let state = "a password only server with credentials entered" + validateRegistrationFormIsVisible(for: state) + validateSSOButtonsAreHidden(for: state) + + validateNextButtonIsEnabled(for: state) + + validateNoErrorsAreShown(for: state) + case .passwordWithUsernameError: + let state = "a password only server with an invalid username" + validateRegistrationFormIsVisible(for: state) + validateSSOButtonsAreHidden(for: state) + + validateNextButtonIsDisabled(for: state) + + validateUsernameError(for: state) + case .ssoOnly: + let state = "an SSO only server" + validateRegistrationFormIsHidden(for: state) + validateSSOButtonsAreShown(for: state) + } + } + + /// Checks that the username and password text fields are shown along with the next button. + func validateRegistrationFormIsVisible(for state: String) { + let usernameTextField = app.textFields.element + let passwordTextField = app.secureTextFields.element + let nextButton = app.buttons["nextButton"] + + XCTAssertTrue(usernameTextField.exists, "Username input should be shown for \(state).") + XCTAssertTrue(passwordTextField.exists, "Password input should be shown for \(state).") + XCTAssertTrue(nextButton.exists, "The next button should be shown for \(state).") + } + + /// Checks that the username and password text fields are hidden along with the next button. + func validateRegistrationFormIsHidden(for state: String) { + let usernameTextField = app.textFields.element + let passwordTextField = app.secureTextFields.element + let nextButton = app.buttons["nextButton"] + + XCTAssertFalse(usernameTextField.exists, "Username input should not be shown for \(state).") + XCTAssertFalse(passwordTextField.exists, "Password input should not be shown for \(state).") + XCTAssertFalse(nextButton.exists, "The next button should not be shown for \(state).") + } + + /// Checks that there is at least one SSO button shown on the screen. + func validateSSOButtonsAreShown(for state: String) { + let ssoButtons = app.buttons.matching(identifier: "ssoButton") + XCTAssertGreaterThan(ssoButtons.count, 0, "There should be at least 1 SSO button shown for \(state).") + } + + /// Checks that no SSO buttons shown on the screen. + func validateSSOButtonsAreHidden(for state: String) { + let ssoButtons = app.buttons.matching(identifier: "ssoButton") + XCTAssertEqual(ssoButtons.count, 0, "There should not be any SSO buttons shown for \(state).") + } + + /// Checks that the next button is shown but is disabled. + func validateNextButtonIsDisabled(for state: String) { + let nextButton = app.buttons["nextButton"] + XCTAssertTrue(nextButton.exists, "The next button should be shown.") + XCTAssertFalse(nextButton.isEnabled, "The next button should be disabled for \(state).") + } + + /// Checks that the next button is shown and is enabled. + func validateNextButtonIsEnabled(for state: String) { + let nextButton = app.buttons["nextButton"] + XCTAssertTrue(nextButton.exists, "The next button should be shown.") + XCTAssertTrue(nextButton.isEnabled, "The next button should be enabled for \(state).") + } + + /// Checks that the username text field footer is showing an error. + func validateUsernameError(for state: String) { + let usernameFooter = textFieldFooter(for: "usernameTextField") + XCTAssertTrue(usernameFooter.exists, "The username footer should be shown for \(state).") + XCTAssertEqual(usernameFooter.label, VectorL10n.authInvalidUserName, "The username footer should be showing an error for \(state).") + } + + /// Checks that neither the username or password text field footers are showing an error. + func validateNoErrorsAreShown(for state: String) { + let usernameFooter = textFieldFooter(for: "usernameTextField") + let passwordFooter = textFieldFooter(for: "passwordTextField") + + XCTAssertTrue(usernameFooter.exists, "The username footer should be shown for \(state).") + XCTAssertTrue(passwordFooter.exists, "The password footer should be shown for \(state).") + XCTAssertEqual(usernameFooter.label, VectorL10n.authenticationRegistrationUsernameFooter, + "The username footer should be showing the default message for \(state).") + XCTAssertEqual(passwordFooter.label, VectorL10n.authenticationRegistrationPasswordFooter, + "The password footer should be showing the default message for \(state).") + } + + /// Gets the text field footer for the supplied identifier. + func textFieldFooter(for identifier: String) -> XCUIElement { + let matches = app.staticTexts.matching(identifier: identifier) + return matches.element(boundBy: matches.count - 1) + } +} diff --git a/RiotSwiftUI/Modules/Authentication/Registration/Test/Unit/AuthenticationRegistrationViewModelTests.swift b/RiotSwiftUI/Modules/Authentication/Registration/Test/Unit/AuthenticationRegistrationViewModelTests.swift new file mode 100644 index 000000000..affe61c8b --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/Registration/Test/Unit/AuthenticationRegistrationViewModelTests.swift @@ -0,0 +1,218 @@ +// +// 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 +import Combine + +@testable import RiotSwiftUI + +@available(iOS 14.0, *) +@MainActor class AuthenticationRegistrationViewModelTests: XCTestCase { + var viewModel: AuthenticationRegistrationViewModelProtocol! + var context: AuthenticationRegistrationViewModelType.Context! + + @MainActor override func setUp() async throws { + viewModel = AuthenticationRegistrationViewModel(homeserverAddress: "", ssoIdentityProviders: []) + context = viewModel.context + } + + func testMatrixDotOrg() { + // Given matrix.org with some SSO providers. + let address = "https://matrix.org" + let ssoProviders = [ + SSOIdentityProvider(id: "apple", name: "Apple", brand: "Apple", iconURL: nil), + SSOIdentityProvider(id: "google", name: "Google", brand: "Google", iconURL: nil), + SSOIdentityProvider(id: "github", name: "Github", brand: "Github", iconURL: nil) + ] + + // When updating the view model with the server. + viewModel.update(homeserverAddress: address, showRegistrationForm: true, ssoIdentityProviders: ssoProviders) + + // Then the form should show the server description along with the username and password fields and the SSO buttons. + XCTAssertEqual(context.viewState.homeserverAddress, "matrix.org", "The homeserver address should have the https scheme stripped away.") + XCTAssertEqual(context.viewState.serverDescription, VectorL10n.authenticationRegistrationMatrixDescription, "A description should be shown for matrix.org.") + XCTAssertTrue(context.viewState.showRegistrationForm, "The username and password section should be shown.") + XCTAssertTrue(context.viewState.showSSOButtons, "The SSO buttons should be shown.") + } + + func testBasicServer() { + // Given a basic server example.com that only supports password registration. + let address = "https://example.com" + + // When updating the view model with the server. + viewModel.update(homeserverAddress: address, showRegistrationForm: true, ssoIdentityProviders: []) + + // Then the form should only show the username and password section. + XCTAssertEqual(context.viewState.homeserverAddress, "example.com", "The homeserver address should have the https scheme stripped away.") + XCTAssertNil(context.viewState.serverDescription, "A description should not be shown when the server isn't matrix.org.") + XCTAssertTrue(context.viewState.showRegistrationForm, "The username and password section should be shown.") + XCTAssertFalse(context.viewState.showSSOButtons, "The SSO buttons should not be shown.") + } + + func testUnsecureServer() { + // Given a server that uses http for communication. + let address = "http://testserver.local" + + // When updating the view model with the server. + viewModel.update(homeserverAddress: address, showRegistrationForm: true, ssoIdentityProviders: []) + + // Then the form should only show the username and password section. + XCTAssertEqual(context.viewState.homeserverAddress, address, "The homeserver address should show the http scheme.") + XCTAssertNil(context.viewState.serverDescription, "A description should not be shown when the server isn't matrix.org.") + } + + func testSSOOnlyServer() { + // Given matrix.org with some SSO providers. + let address = "https://example.com" + let ssoProviders = [ + SSOIdentityProvider(id: "apple", name: "Apple", brand: "Apple", iconURL: nil), + SSOIdentityProvider(id: "google", name: "Google", brand: "Google", iconURL: nil), + SSOIdentityProvider(id: "github", name: "Github", brand: "Github", iconURL: nil) + ] + + // When updating the view model with the server. + viewModel.update(homeserverAddress: address, showRegistrationForm: false, ssoIdentityProviders: ssoProviders) + + // Then the form should show the server description along with the username and password fields and the SSO buttons. + XCTAssertEqual(context.viewState.homeserverAddress, "example.com", "The homeserver address should have the https scheme stripped away.") + XCTAssertNil(context.viewState.serverDescription, "A description should not be shown when the server isn't matrix.org.") + XCTAssertFalse(context.viewState.showRegistrationForm, "The username and password section should not be shown.") + XCTAssertTrue(context.viewState.showSSOButtons, "The SSO buttons should be shown.") + } + + func testUsernameError() async { + // Given a form with a valid username. + context.username = "bob" + XCTAssertNil(context.viewState.usernameErrorMessage, "The shouldn't be a username error when the view model is created.") + XCTAssertEqual(context.viewState.usernameFooterMessage, VectorL10n.authenticationRegistrationUsernameFooter, "The standard footer message should be shown.") + XCTAssertTrue(context.viewState.isUsernameValid, "The username should be valid if there is no error.") + + // When displaying the error as a username error. + let errorMessage = "Username unavailable" + viewModel.displayError(.usernameUnavailable(errorMessage)) + + // Then the error should be shown in the footer. + XCTAssertEqual(context.viewState.usernameErrorMessage, errorMessage, "The error message should be stored.") + XCTAssertEqual(context.viewState.usernameFooterMessage, errorMessage, "The error message should replace the standard footer message.") + XCTAssertFalse(context.viewState.isUsernameValid, "The username should be invalid when an error is shown.") + + + // When clearing the error. + context.send(viewAction: .clearUsernameError) + + // Wait for the action to spawn a Task on the main actor as the Context protocol doesn't support actors. + let task = Task { try await Task.sleep(nanoseconds: 100_000_000) } + _ = await task.result + + // Then the error should be hidden again. + XCTAssertNil(context.viewState.usernameErrorMessage, "The shouldn't be a username error anymore.") + XCTAssertEqual(context.viewState.usernameFooterMessage, VectorL10n.authenticationRegistrationUsernameFooter, "The standard footer message should be shown again.") + XCTAssertTrue(context.viewState.isUsernameValid, "The username should be valid when an error is cleared.") + } + + func testEmptyUsernameWithShortPassword() { + // Given a form with an empty username and password. + XCTAssertTrue(context.password.isEmpty, "The initial value for the password should be empty.") + XCTAssertTrue(context.username.isEmpty, "The initial value for the username should be empty.") + XCTAssertFalse(context.viewState.isPasswordValid, "An empty password should be invalid.") + XCTAssertFalse(context.viewState.isUsernameValid, "An empty username should be invalid.") + XCTAssertFalse(context.viewState.hasValidCredentials, "The credentials should be invalid.") + + // When entering a password of 7 characters without a username. + context.username = "" + context.password = "1234567" + + // Then the credentials should remain invalid. + XCTAssertFalse(context.viewState.isPasswordValid, "A 7-character password should be invalid.") + XCTAssertFalse(context.viewState.isUsernameValid, "An empty username should be invalid.") + XCTAssertFalse(context.viewState.hasValidCredentials, "The credentials should be invalid.") + } + + func testEmptyUsernameWithValidPassword() { + // Given a form with an empty username and password. + XCTAssertTrue(context.password.isEmpty, "The initial value for the password should be empty.") + XCTAssertTrue(context.username.isEmpty, "The initial value for the username should be empty.") + XCTAssertFalse(context.viewState.isPasswordValid, "An empty password should be invalid.") + XCTAssertFalse(context.viewState.isUsernameValid, "An empty username should be invalid.") + XCTAssertFalse(context.viewState.hasValidCredentials, "The credentials should be invalid.") + + // When entering a password of 8 characters without a username. + context.username = "" + context.password = "12345678" + + // Then the password should be valid but the credentials should still be invalid. + XCTAssertTrue(context.viewState.isPasswordValid, "An 8-character password should be valid.") + XCTAssertFalse(context.viewState.isUsernameValid, "An empty username should be invalid.") + XCTAssertFalse(context.viewState.hasValidCredentials, "The credentials should be invalid.") + } + + func testValidUsernameWithEmptyPassword() { + // Given a form with an empty username and password. + XCTAssertTrue(context.password.isEmpty, "The initial value for the password should be empty.") + XCTAssertTrue(context.username.isEmpty, "The initial value for the username should be empty.") + XCTAssertFalse(context.viewState.isPasswordValid, "An empty password should be invalid.") + XCTAssertFalse(context.viewState.isUsernameValid, "An empty username should be invalid.") + XCTAssertFalse(context.viewState.hasValidCredentials, "The credentials should be invalid.") + + // When entering a username without a password. + context.username = "bob" + context.password = "" + + // Then the username should be valid but the credentials should still be invalid. + XCTAssertFalse(context.viewState.isPasswordValid, "An empty password should be invalid.") + XCTAssertTrue(context.viewState.isUsernameValid, "The username should be valid when there is no error.") + XCTAssertFalse(context.viewState.hasValidCredentials, "The credentials should be invalid.") + } + + func testUsernameErrorWithValidPassword() { + // Given a form with an empty username and password. + XCTAssertTrue(context.password.isEmpty, "The initial value for the password should be empty.") + XCTAssertTrue(context.username.isEmpty, "The initial value for the username should be empty.") + XCTAssertFalse(context.viewState.isPasswordValid, "An empty password should be invalid.") + XCTAssertFalse(context.viewState.isUsernameValid, "An empty username should be invalid.") + XCTAssertFalse(context.viewState.hasValidCredentials, "The credentials should be invalid.") + + // When entering a username and password and encountering a username error + context.username = "bob" + context.password = "12345678" + + let errorMessage = "Username unavailable" + viewModel.displayError(.usernameUnavailable(errorMessage)) + + // Then the password should be valid but the credentials should still be invalid. + XCTAssertTrue(context.viewState.isPasswordValid, "An 8-character password should be valid.") + XCTAssertFalse(context.viewState.isUsernameValid, "The username should be invalid when an error is shown.") + XCTAssertFalse(context.viewState.hasValidCredentials, "The credentials should be invalid.") + } + + func testValidCredentials() { + // Given a form with an empty username and password. + XCTAssertTrue(context.password.isEmpty, "The initial value for the password should be empty.") + XCTAssertTrue(context.username.isEmpty, "The initial value for the username should be empty.") + XCTAssertFalse(context.viewState.isPasswordValid, "An empty password should be invalid.") + XCTAssertFalse(context.viewState.isUsernameValid, "An empty username should be invalid.") + XCTAssertFalse(context.viewState.hasValidCredentials, "The credentials should be invalid.") + + // When entering a username and an 8-character password. + context.username = "bob" + context.password = "12345678" + + // Then the credentials should be considered valid. + XCTAssertTrue(context.viewState.isPasswordValid, "An 8-character password should be valid.") + XCTAssertTrue(context.viewState.isUsernameValid, "The username should be valid when there is no error.") + XCTAssertTrue(context.viewState.hasValidCredentials, "The credentials should be valid when the username and password are valid.") + } +} diff --git a/RiotSwiftUI/Modules/Authentication/Registration/View/AuthenticationRegistrationScreen.swift b/RiotSwiftUI/Modules/Authentication/Registration/View/AuthenticationRegistrationScreen.swift new file mode 100644 index 000000000..a9f8efb99 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/Registration/View/AuthenticationRegistrationScreen.swift @@ -0,0 +1,198 @@ +// +// 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 + +@available(iOS 14.0, *) +struct AuthenticationRegistrationScreen: View { + + // MARK: - Properties + + // MARK: Private + + @Environment(\.theme) private var theme: ThemeSwiftUI + + // MARK: Public + + @ObservedObject var viewModel: AuthenticationRegistrationViewModel.Context + + var body: some View { + ScrollView { + VStack(spacing: 0) { + header + .padding(.top, OnboardingMetrics.topPaddingToNavigationBar) + .padding(.bottom, 36) + + serverInfo + .padding(.leading, 12) + + Divider() + .padding(.vertical, 21) + + if viewModel.viewState.showRegistrationForm { + registrationForm + } + + if viewModel.viewState.showRegistrationForm && viewModel.viewState.showSSOButtons { + Text(VectorL10n.or) + .foregroundColor(theme.colors.secondaryContent) + .padding(.top, 16) + } + + if viewModel.viewState.showSSOButtons { + ssoButtons + .padding(.top, 16) + } + + } + .frame(maxWidth: OnboardingMetrics.maxContentWidth) + .frame(maxWidth: .infinity) + .padding(.horizontal, 16) + .padding(.bottom, 16) + } + .accentColor(theme.colors.accent) + .background(theme.colors.background.ignoresSafeArea()) + .alert(item: $viewModel.alertInfo) { $0.alert } + } + + /// The header containing the icon, title and message. + var header: some View { + VStack(spacing: 8) { + Image(Asset.Images.onboardingCongratulationsIcon.name) + .resizable() + .renderingMode(.template) + .foregroundColor(theme.colors.accent) + .frame(width: 90, height: 90) + .background(Circle().foregroundColor(.white).padding(2)) + .padding(.bottom, 8) + .accessibilityHidden(true) + + Text(VectorL10n.authenticationRegistrationTitle) + .font(theme.fonts.title2B) + .multilineTextAlignment(.center) + .foregroundColor(theme.colors.primaryContent) + + Text(VectorL10n.authenticationRegistrationMessage) + .font(theme.fonts.body) + .multilineTextAlignment(.center) + .foregroundColor(theme.colors.secondaryContent) + } + } + + /// The sever information section that includes a button to select a different server. + var serverInfo: some View { + VStack(alignment: .leading, spacing: 4) { + Text(VectorL10n.authenticationRegistrationServerTitle) + .font(theme.fonts.subheadline) + .foregroundColor(theme.colors.secondaryContent) + + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(viewModel.viewState.homeserverAddress) + .font(theme.fonts.body) + .foregroundColor(theme.colors.primaryContent) + + if let serverDescription = viewModel.viewState.serverDescription { + Text(serverDescription) + .font(theme.fonts.caption1) + .foregroundColor(theme.colors.tertiaryContent) + .accessibilityIdentifier("serverDescriptionText") + } + } + + Spacer() + + Button { viewModel.send(viewAction: .selectServer) } label: { + Text(VectorL10n.edit) + .font(theme.fonts.body) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .overlay(RoundedRectangle(cornerRadius: 8).stroke(theme.colors.accent)) + } + } + } + } + + /// The form with text fields for username and password, along with a submit button. + var registrationForm: some View { + VStack(spacing: 21) { + RoundedBorderTextField(title: nil, + placeHolder: VectorL10n.authenticationRegistrationUsername, + text: $viewModel.username, + footerText: viewModel.viewState.usernameFooterMessage, + isError: viewModel.viewState.hasEditedUsername && !viewModel.viewState.isUsernameValid, + isFirstResponder: false, + configuration: UIKitTextInputConfiguration(returnKeyType: .default, + autocapitalizationType: .none, + autocorrectionType: .no), + onEditingChanged: { validateUsername(isEditing: $0) }) + .onChange(of: viewModel.username) { _ in viewModel.send(viewAction: .clearUsernameError) } + .accessibilityIdentifier("usernameTextField") + + RoundedBorderTextField(title: nil, + placeHolder: VectorL10n.authPasswordPlaceholder, + text: $viewModel.password, + footerText: VectorL10n.authenticationRegistrationPasswordFooter, + isError: viewModel.viewState.hasEditedPassword && !viewModel.viewState.isPasswordValid, + isFirstResponder: false, + configuration: UIKitTextInputConfiguration(isSecureTextEntry: true), + onEditingChanged: { validatePassword(isEditing: $0) }) + .accessibilityIdentifier("passwordTextField") + + Button { viewModel.send(viewAction: .next) } label: { + Text(VectorL10n.next) + } + .buttonStyle(PrimaryActionButtonStyle()) + .disabled(!viewModel.viewState.hasValidCredentials) + .accessibilityIdentifier("nextButton") + } + } + + /// A list of SSO buttons that can be used for login. + var ssoButtons: some View { + VStack(spacing: 16) { + ForEach(viewModel.viewState.ssoIdentityProviders) { provider in + AuthenticationSSOButton(provider: provider) { + viewModel.send(viewAction: .continueWithSSO(id: provider.id)) + } + .accessibilityIdentifier("ssoButton") + } + } + } + + /// Validates the username when the text field ends editing. + func validateUsername(isEditing: Bool) { + guard !isEditing, !viewModel.username.isEmpty else { return } + viewModel.send(viewAction: .validateUsername) + } + + /// Enables password validation the first time the user finishes editing the password text field. + func validatePassword(isEditing: Bool) { + guard !viewModel.viewState.hasEditedPassword, !isEditing else { return } + viewModel.send(viewAction: .enablePasswordValidation) + } +} + +// MARK: - Previews + +@available(iOS 15.0, *) +struct AuthenticationRegistration_Previews: PreviewProvider { + static let stateRenderer = MockAuthenticationRegistrationScreenState.stateRenderer + static var previews: some View { + stateRenderer.screenGroup(addNavigation: true) + .navigationViewStyle(.stack) + } +} diff --git a/RiotSwiftUI/Modules/Authentication/Registration/View/AuthenticationSSOButton.swift b/RiotSwiftUI/Modules/Authentication/Registration/View/AuthenticationSSOButton.swift new file mode 100644 index 000000000..743685900 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/Registration/View/AuthenticationSSOButton.swift @@ -0,0 +1,84 @@ +// +// 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 SwiftUI + +@available(iOS 14.0, *) +/// An button that displays the icon and name of an SSO provider. +struct AuthenticationSSOButton: View { + + // MARK: - Constants + + enum Brand: String { + case apple, facebook, github, gitlab, google, twitter + } + + // MARK: - Private + + @Environment(\.theme) private var theme + + // MARK: - Public + + let provider: SSOIdentityProvider + let action: () -> Void + + // MARK: - Views + + var body: some View { + Button(action: action) { + HStack { + icon + .frame(maxWidth: .infinity, alignment: .leading) + + Text(VectorL10n.socialLoginButtonTitleContinue(provider.name)) + .foregroundColor(theme.colors.primaryContent) + .multilineTextAlignment(.center) + .layoutPriority(1) + + icon + .frame(minWidth: 0, maxWidth: .infinity, alignment: .trailing) + .opacity(0) + } + .frame(maxWidth: .infinity) + .contentShape(RoundedRectangle(cornerRadius: 8)) + } + .buttonStyle(SecondaryActionButtonStyle(customColor: theme.colors.quinaryContent)) + } + + @ViewBuilder + var icon: some View { + switch provider.brand { + case Brand.apple.rawValue: + Image(Asset.Images.authenticationSsoIconApple.name) + .renderingMode(.template) + .foregroundColor(theme.colors.primaryContent) + case Brand.facebook.rawValue: + Image(Asset.Images.authenticationSsoIconFacebook.name) + case Brand.github.rawValue: + Image(Asset.Images.authenticationSsoIconGithub.name) + .renderingMode(.template) + .foregroundColor(theme.colors.primaryContent) + case Brand.gitlab.rawValue: + Image(Asset.Images.authenticationSsoIconGitlab.name) + case Brand.google.rawValue: + Image(Asset.Images.authenticationSsoIconGoogle.name) + case Brand.twitter.rawValue: + Image(Asset.Images.authenticationSsoIconTwitter.name) + default: + EmptyView() + } + } +} diff --git a/RiotSwiftUI/Modules/Authentication/ServerSelection/AuthenticationServerSelectionModels.swift b/RiotSwiftUI/Modules/Authentication/ServerSelection/AuthenticationServerSelectionModels.swift new file mode 100644 index 000000000..f163f4b5d --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/ServerSelection/AuthenticationServerSelectionModels.swift @@ -0,0 +1,82 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +// MARK: View model + +enum AuthenticationServerSelectionViewModelResult { + /// The user would like to use the homeserver at the given address. + case confirm(homeserverAddress: String) + /// Dismiss the view without using the entered address. + case dismiss +} + +// MARK: View + +struct AuthenticationServerSelectionViewState: BindableState { + /// View state that can be bound to from SwiftUI. + var bindings: AuthenticationServerSelectionBindings + /// An error message to be shown in the text field footer. + var footerErrorMessage: String? + /// Whether the screen is presented modally or within a navigation stack. + var hasModalPresentation: Bool + + /// The message to show in the text field footer. + var footerMessage: String { + footerErrorMessage ?? VectorL10n.authenticationServerSelectionServerFooter + } + + /// The title shown on the confirm button. + var buttonTitle: String { + hasModalPresentation ? VectorL10n.confirm : VectorL10n.next + } + + /// The text field is showing an error. + var isShowingFooterError: Bool { + footerErrorMessage != nil + } + + /// Whether it is possible to continue when tapping the confirmation button. + var hasValidationError: Bool { + bindings.homeserverAddress.isEmpty || isShowingFooterError + } +} + +struct AuthenticationServerSelectionBindings { + /// The homeserver address input by the user. + var homeserverAddress: String + /// Information describing the currently displayed alert. + var alertInfo: AlertInfo? +} + +enum AuthenticationServerSelectionViewAction { + /// The user would like to use the homeserver at the input address. + case confirm + /// Dismiss the view without using the entered address. + case dismiss + /// Open the EMS link. + case getInTouch + /// Clear any errors shown in the text field footer. + case clearFooterError +} + +enum AuthenticationServerSelectionErrorType: Hashable { + /// An error message to be shown in the text field footer. + case footerMessage(String) + /// An error occurred when trying to open the EMS link + case openURLAlert +} diff --git a/RiotSwiftUI/Modules/Authentication/ServerSelection/AuthenticationServerSelectionViewModel.swift b/RiotSwiftUI/Modules/Authentication/ServerSelection/AuthenticationServerSelectionViewModel.swift new file mode 100644 index 000000000..14fd5d5c9 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/ServerSelection/AuthenticationServerSelectionViewModel.swift @@ -0,0 +1,84 @@ +// +// 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 + +@available(iOS 14, *) +typealias AuthenticationServerSelectionViewModelType = StateStoreViewModel +@available(iOS 14, *) +class AuthenticationServerSelectionViewModel: AuthenticationServerSelectionViewModelType, AuthenticationServerSelectionViewModelProtocol { + + // MARK: - Properties + + // MARK: Private + + // MARK: Public + + var completion: ((AuthenticationServerSelectionViewModelResult) -> Void)? + + // MARK: - Setup + + init(homeserverAddress: String, hasModalPresentation: Bool) { + let bindings = AuthenticationServerSelectionBindings(homeserverAddress: HomeserverAddress.displayable(homeserverAddress)) + super.init(initialViewState: AuthenticationServerSelectionViewState(bindings: bindings, + hasModalPresentation: hasModalPresentation)) + } + + // MARK: - Public + + override func process(viewAction: AuthenticationServerSelectionViewAction) { + Task { + await MainActor.run { + switch viewAction { + case .confirm: + completion?(.confirm(homeserverAddress: state.bindings.homeserverAddress)) + case .dismiss: + completion?(.dismiss) + case .getInTouch: + getInTouch() + case .clearFooterError: + guard state.footerErrorMessage != nil else { return } + withAnimation { state.footerErrorMessage = nil } + } + } + } + } + + @MainActor func displayError(_ type: AuthenticationServerSelectionErrorType) { + switch type { + case .footerMessage(let message): + withAnimation { + state.footerErrorMessage = message + } + case .openURLAlert: + state.bindings.alertInfo = AlertInfo(id: .openURLAlert, title: VectorL10n.roomMessageUnableOpenLinkErrorMessage) + } + } + + // MARK: - Private + + /// Opens the EMS link in the user's browser. + @MainActor private func getInTouch() { + let url = BuildSettings.onboardingHostYourOwnServerLink + + UIApplication.shared.open(url) { [weak self] success in + guard !success, let self = self else { return } + self.displayError(.openURLAlert) + } + } +} diff --git a/RiotSwiftUI/Modules/Authentication/ServerSelection/AuthenticationServerSelectionViewModelProtocol.swift b/RiotSwiftUI/Modules/Authentication/ServerSelection/AuthenticationServerSelectionViewModelProtocol.swift new file mode 100644 index 000000000..4ff73e1c4 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/ServerSelection/AuthenticationServerSelectionViewModelProtocol.swift @@ -0,0 +1,27 @@ +// +// 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 AuthenticationServerSelectionViewModelProtocol { + + @MainActor var completion: ((AuthenticationServerSelectionViewModelResult) -> Void)? { get set } + @available(iOS 14, *) + var context: AuthenticationServerSelectionViewModelType.Context { get } + + /// Displays an error to the user. + @MainActor func displayError(_ type: AuthenticationServerSelectionErrorType) +} diff --git a/RiotSwiftUI/Modules/Authentication/ServerSelection/Coordinator/AuthenticationServerSelectionCoordinator.swift b/RiotSwiftUI/Modules/Authentication/ServerSelection/Coordinator/AuthenticationServerSelectionCoordinator.swift new file mode 100644 index 000000000..6b9401cc5 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/ServerSelection/Coordinator/AuthenticationServerSelectionCoordinator.swift @@ -0,0 +1,139 @@ +// +// 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 +import CommonKit + +@available(iOS 14.0, *) +struct AuthenticationServerSelectionCoordinatorParameters { + let authenticationService: AuthenticationService + /// Whether the screen is presented modally or within a navigation stack. + let hasModalPresentation: Bool +} + +enum AuthenticationServerSelectionCoordinatorResult { + case updated + case dismiss +} + +@available(iOS 14.0, *) +final class AuthenticationServerSelectionCoordinator: Coordinator, Presentable { + + // MARK: - Properties + + // MARK: Private + + private let parameters: AuthenticationServerSelectionCoordinatorParameters + private let authenticationServerSelectionHostingController: VectorHostingController + private var authenticationServerSelectionViewModel: AuthenticationServerSelectionViewModelProtocol + + private var indicatorPresenter: UserIndicatorTypePresenterProtocol + private var loadingIndicator: UserIndicator? + + /// The authentication service that will be updated with the new selection. + var authenticationService: AuthenticationService { parameters.authenticationService } + + // MARK: Public + + // Must be used only internally + var childCoordinators: [Coordinator] = [] + @MainActor var completion: ((AuthenticationServerSelectionCoordinatorResult) -> Void)? + + // MARK: - Setup + + @MainActor init(parameters: AuthenticationServerSelectionCoordinatorParameters) { + self.parameters = parameters + + let homeserver = parameters.authenticationService.state.homeserver + let viewModel = AuthenticationServerSelectionViewModel(homeserverAddress: homeserver.addressFromUser ?? homeserver.address, + hasModalPresentation: parameters.hasModalPresentation) + let view = AuthenticationServerSelectionScreen(viewModel: viewModel.context) + authenticationServerSelectionViewModel = viewModel + authenticationServerSelectionHostingController = VectorHostingController(rootView: view) + authenticationServerSelectionHostingController.vc_removeBackTitle() + authenticationServerSelectionHostingController.enableNavigationBarScrollEdgeAppearance = true + + indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: authenticationServerSelectionHostingController) + } + + // MARK: - Public + + func start() { + Task { + await MainActor.run { + MXLog.debug("[AuthenticationServerSelectionCoordinator] did start.") + authenticationServerSelectionViewModel.completion = { [weak self] result in + guard let self = self else { return } + MXLog.debug("[AuthenticationServerSelectionCoordinator] AuthenticationServerSelectionViewModel did complete with result: \(result).") + + switch result { + case .confirm(let homeserverAddress): + self.useHomeserver(homeserverAddress) + case .dismiss: + self.completion?(.dismiss) + } + } + } + } + } + + func toPresentable() -> UIViewController { + return self.authenticationServerSelectionHostingController + } + + // MARK: - Private + + /// Show an activity indicator whilst loading. + /// - Parameters: + /// - label: The label to show on the indicator. + /// - isInteractionBlocking: Whether the indicator should block any user interaction. + @MainActor private func startLoading(label: String = VectorL10n.loading, isInteractionBlocking: Bool = true) { + loadingIndicator = indicatorPresenter.present(.loading(label: label, isInteractionBlocking: isInteractionBlocking)) + } + + /// Hide the currently displayed activity indicator. + @MainActor private func stopLoading() { + loadingIndicator = nil + } + + /// Updates the login flow using the supplied homeserver address, or shows an error when this isn't possible. + @MainActor private func useHomeserver(_ homeserverAddress: String) { + startLoading() + authenticationService.reset() + + let homeserverAddress = HomeserverAddress.sanitized(homeserverAddress) + + Task { + do { + #warning("The screen should be configuration for .login too.") + try await authenticationService.startFlow(.registration, for: homeserverAddress) + stopLoading() + + completion?(.updated) + } catch { + stopLoading() + + if let error = error as? RegistrationError { + authenticationServerSelectionViewModel.displayError(.footerMessage(error.localizedDescription)) + } else { + // Show the MXError message if possible otherwise use a generic server error + let message = MXError(nsError: error)?.error ?? VectorL10n.authenticationServerSelectionGenericError + authenticationServerSelectionViewModel.displayError(.footerMessage(message)) + } + } + } + } +} diff --git a/RiotSwiftUI/Modules/Authentication/ServerSelection/MockAuthenticationServerSelectionScreenState.swift b/RiotSwiftUI/Modules/Authentication/ServerSelection/MockAuthenticationServerSelectionScreenState.swift new file mode 100644 index 000000000..8cc3c425b --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/ServerSelection/MockAuthenticationServerSelectionScreenState.swift @@ -0,0 +1,66 @@ +// +// 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. +@available(iOS 14.0, *) +enum MockAuthenticationServerSelectionScreenState: MockScreenState, CaseIterable { + // A case for each state you want to represent + // with specific, minimal associated data that will allow you + // mock that screen. + case matrix + case emptyAddress + case invalidAddress + case nonModal + + /// The associated screen + var screenType: Any.Type { + AuthenticationServerSelectionScreen.self + } + + /// Generate the view struct for the screen state. + var screenView: ([Any], AnyView) { + let viewModel: AuthenticationServerSelectionViewModel + switch self { + case .matrix: + viewModel = AuthenticationServerSelectionViewModel(homeserverAddress: "https://matrix.org", + hasModalPresentation: true) + case .emptyAddress: + viewModel = AuthenticationServerSelectionViewModel(homeserverAddress: "", + hasModalPresentation: true) + case .invalidAddress: + viewModel = AuthenticationServerSelectionViewModel(homeserverAddress: "thisisbad", + hasModalPresentation: true) + Task { + await MainActor.run { + viewModel.displayError(.footerMessage(VectorL10n.errorCommonMessage)) + } + } + case .nonModal: + viewModel = AuthenticationServerSelectionViewModel(homeserverAddress: "https://matrix.org", + hasModalPresentation: false) + } + + // can simulate service and viewModel actions here if needs be. + + return ( + [viewModel], AnyView(AuthenticationServerSelectionScreen(viewModel: viewModel.context)) + ) + } +} diff --git a/RiotSwiftUI/Modules/Authentication/ServerSelection/Test/UI/AuthenticationServerSelectionUITests.swift b/RiotSwiftUI/Modules/Authentication/ServerSelection/Test/UI/AuthenticationServerSelectionUITests.swift new file mode 100644 index 000000000..9f6ec2ee5 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/ServerSelection/Test/UI/AuthenticationServerSelectionUITests.swift @@ -0,0 +1,91 @@ +// +// 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 +import RiotSwiftUI + +@available(iOS 14.0, *) +class AuthenticationServerSelectionUITests: MockScreenTest { + + override class var screenType: MockScreenState.Type { + return MockAuthenticationServerSelectionScreenState.self + } + + override class func createTest() -> MockScreenTest { + return AuthenticationServerSelectionUITests(selector: #selector(verifyAuthenticationServerSelectionScreen)) + } + + func verifyAuthenticationServerSelectionScreen() throws { + guard let screenState = screenState as? MockAuthenticationServerSelectionScreenState else { fatalError("no screen") } + switch screenState { + case .matrix: + verifyNormalState() + case .emptyAddress: + verifyEmptyAddress() + case .invalidAddress: + verifyInvalidAddress() + case .nonModal: + verifyNonModalPresentation() + } + } + + func verifyNormalState() { + let serverTextField = app.textFields.element + XCTAssertEqual(serverTextField.value as? String, "https://matrix.org", "The server shown should be matrix.org with the https scheme.") + + let confirmButton = app.buttons["confirmButton"] + XCTAssertEqual(confirmButton.label, VectorL10n.confirm, "The confirm button should say Confirm when in modal presentation.") + XCTAssertTrue(confirmButton.exists, "The confirm button should always be shown.") + XCTAssertTrue(confirmButton.isEnabled, "The confirm button should be enabled when there is an address.") + + let textFieldFooter = app.staticTexts["addressTextField"] + XCTAssertTrue(textFieldFooter.exists) + XCTAssertEqual(textFieldFooter.label, VectorL10n.authenticationServerSelectionServerFooter) + + let dismissButton = app.buttons["dismissButton"] + XCTAssertTrue(dismissButton.exists, "The dismiss button should be shown during modal presentation.") + } + + func verifyEmptyAddress() { + let serverTextField = app.textFields.element + XCTAssertEqual(serverTextField.value as? String, "", "The text field should be empty in this state.") + + let confirmButton = app.buttons["confirmButton"] + XCTAssertTrue(confirmButton.exists, "The confirm button should always be shown.") + XCTAssertFalse(confirmButton.isEnabled, "The confirm button should be disabled when the address is empty.") + } + + func verifyInvalidAddress() { + let serverTextField = app.textFields.element + XCTAssertEqual(serverTextField.value as? String, "thisisbad", "The text field should show the entered server.") + + let confirmButton = app.buttons["confirmButton"] + XCTAssertTrue(confirmButton.exists, "The confirm button should always be shown.") + XCTAssertFalse(confirmButton.isEnabled, "The confirm button should be disabled when there is an error.") + + let textFieldFooter = app.staticTexts["addressTextField"] + XCTAssertTrue(textFieldFooter.exists) + XCTAssertEqual(textFieldFooter.label, VectorL10n.errorCommonMessage) + } + + func verifyNonModalPresentation() { + let dismissButton = app.buttons["dismissButton"] + XCTAssertFalse(dismissButton.exists, "The dismiss button should be hidden when not in modal presentation.") + + let confirmButton = app.buttons["confirmButton"] + XCTAssertEqual(confirmButton.label, VectorL10n.next, "The confirm button should say Next when not in modal presentation.") + } +} diff --git a/RiotSwiftUI/Modules/Authentication/ServerSelection/Test/Unit/AuthenticationServerSelectionViewModelTests.swift b/RiotSwiftUI/Modules/Authentication/ServerSelection/Test/Unit/AuthenticationServerSelectionViewModelTests.swift new file mode 100644 index 000000000..ccc3b3095 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/ServerSelection/Test/Unit/AuthenticationServerSelectionViewModelTests.swift @@ -0,0 +1,59 @@ +// +// 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 + +@available(iOS 14.0, *) +class AuthenticationServerSelectionViewModelTests: XCTestCase { + private enum Constants { + static let counterInitialValue = 0 + } + + var viewModel: AuthenticationServerSelectionViewModelProtocol! + var context: AuthenticationServerSelectionViewModelType.Context! + + override func setUp() async throws { + viewModel = await AuthenticationServerSelectionViewModel(homeserverAddress: "", hasModalPresentation: true) + context = await viewModel.context + } + + @MainActor func testErrorMessage() async { + // Given a new instance of the view model. + XCTAssertNil(context.viewState.footerErrorMessage, "There should not be an error message for a new view model.") + XCTAssertEqual(context.viewState.footerMessage, VectorL10n.authenticationServerSelectionServerFooter, "The standard footer message should be shown.") + + // When an error occurs. + let message = "Unable to contact server." + viewModel.displayError(.footerMessage(message)) + + // Then the footer should now be showing an error. + XCTAssertEqual(context.viewState.footerErrorMessage, message, "The error message should be stored.") + XCTAssertEqual(context.viewState.footerMessage, message, "The error message should be shown.") + + // And when clearing the error. + context.send(viewAction: .clearFooterError) + + // Wait for the action to spawn a Task on the main actor as the Context protocol doesn't support actors. + let task = Task { try await Task.sleep(nanoseconds: 100_000_000) } + _ = await task.result + + // Then the error message should now be removed. + XCTAssertNil(context.viewState.footerErrorMessage, "The error message should have been cleared.") + XCTAssertEqual(context.viewState.footerMessage, VectorL10n.authenticationServerSelectionServerFooter, "The standard footer message should be shown again.") + } +} diff --git a/RiotSwiftUI/Modules/Authentication/ServerSelection/View/AuthenticationServerSelectionScreen.swift b/RiotSwiftUI/Modules/Authentication/ServerSelection/View/AuthenticationServerSelectionScreen.swift new file mode 100644 index 000000000..8974e71e0 --- /dev/null +++ b/RiotSwiftUI/Modules/Authentication/ServerSelection/View/AuthenticationServerSelectionScreen.swift @@ -0,0 +1,190 @@ +// +// 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 + +@available(iOS 14.0, *) +struct AuthenticationServerSelectionScreen: View { + + enum Constants { + static let textFieldID = "textFieldID" + } + + // MARK: - Properties + + // MARK: Private + + @Environment(\.theme) private var theme + + /// The scroll view proxy can be stored here for use in other methods. + @State private var scrollView: ScrollViewProxy? + + // MARK: Public + + @ObservedObject var viewModel: AuthenticationServerSelectionViewModel.Context + + // MARK: Views + + var body: some View { + GeometryReader { geometry in + ScrollView { + ScrollViewReader { reader in + VStack(spacing: 0) { + header + .padding(.top, OnboardingMetrics.topPaddingToNavigationBar) + .padding(.bottom, 36) + + serverForm + + Spacer() + + emsBanner + .padding(.vertical, 16) + } + .frame(maxWidth: OnboardingMetrics.maxContentWidth, minHeight: geometry.size.height) + .frame(maxWidth: .infinity) + .padding(.horizontal, 16) + .onAppear { scrollView = reader } + } + } + } + .ignoresSafeArea(.keyboard) + .background(theme.colors.background.ignoresSafeArea()) + .toolbar { toolbar } + .alert(item: $viewModel.alertInfo) { $0.alert } + .accentColor(theme.colors.accent) + } + + /// The title, message and icon at the top of the screen. + var header: some View { + VStack(spacing: 8) { + Image(Asset.Images.authenticationServerSelectionIcon.name) + .resizable() + .renderingMode(.template) + .foregroundColor(theme.colors.accent) + .frame(width: 90, height: 90) + .background(Circle().foregroundColor(.white).padding(4)) + .padding(.bottom, 8) + .accessibilityHidden(true) + + Text(VectorL10n.authenticationServerSelectionTitle) + .font(theme.fonts.title2B) + .multilineTextAlignment(.center) + .foregroundColor(theme.colors.primaryContent) + + Text(VectorL10n.authenticationServerSelectionMessage) + .font(theme.fonts.body) + .multilineTextAlignment(.center) + .foregroundColor(theme.colors.secondaryContent) + } + } + + /// The text field and confirm button where the user enters a server URL. + var serverForm: some View { + VStack(alignment: .leading, spacing: 12) { + RoundedBorderTextField(title: nil, + placeHolder: VectorL10n.authenticationServerSelectionServerUrl, + text: $viewModel.homeserverAddress, + footerText: viewModel.viewState.footerMessage, + isError: viewModel.viewState.isShowingFooterError, + isFirstResponder: false, + configuration: UIKitTextInputConfiguration(keyboardType: .URL, + returnKeyType: .default, + autocapitalizationType: .none, + autocorrectionType: .no), + onTextChanged: nil, + onEditingChanged: textFieldEditingChangeHandler) + .onChange(of: viewModel.homeserverAddress) { _ in viewModel.send(viewAction: .clearFooterError) } + .id(Constants.textFieldID) + .accessibilityIdentifier("addressTextField") + + Button { viewModel.send(viewAction: .confirm) } label: { + Text(viewModel.viewState.buttonTitle) + } + .buttonStyle(PrimaryActionButtonStyle()) + .disabled(viewModel.viewState.hasValidationError) + .accessibilityIdentifier("confirmButton") + } + } + + /// A banner shown beneath the server form with information about hosting your own server. + var emsBanner: some View { + VStack(spacing: 12) { + Image(Asset.Images.authenticationServerSelectionEmsLogo.name) + .padding(.top, 8) + .accessibilityHidden(true) + + Text(VectorL10n.authenticationServerSelectionEmsTitle) + .font(theme.fonts.title3SB) + .multilineTextAlignment(.center) + .foregroundColor(theme.colors.primaryContent) + + VStack(spacing: 2) { + Text(VectorL10n.authenticationServerSelectionEmsMessage) + .font(theme.fonts.callout) + .multilineTextAlignment(.center) + .foregroundColor(theme.colors.secondaryContent) + Text(VectorL10n.authenticationServerSelectionEmsLink) + .font(theme.fonts.callout) + .multilineTextAlignment(.center) + .foregroundColor(theme.colors.primaryContent) + } + .padding(.bottom, 4) + .accessibilityElement(children: .combine) + + Button { viewModel.send(viewAction: .getInTouch) } label: { + Text(VectorL10n.authenticationServerSelectionEmsButton) + .font(theme.fonts.body) + } + .buttonStyle(PrimaryActionButtonStyle(customColor: theme.colors.ems)) + } + .padding(16) + .background(RoundedRectangle(cornerRadius: 9).foregroundColor(theme.colors.system)) + } + + @ToolbarContentBuilder + var toolbar: some ToolbarContent { + ToolbarItem(placement: .cancellationAction) { + if viewModel.viewState.hasModalPresentation { + Button { viewModel.send(viewAction: .dismiss) } label: { + Text(VectorL10n.cancel) + } + .accessibilityLabel(VectorL10n.cancel) + .accessibilityIdentifier("dismissButton") + } + } + } + + /// Ensures the textfield is on screen when editing starts. + /// + /// This is required due to the `.ignoresSafeArea(.keyboard)` modifier which preserves + /// the spacing between the Next button and the EMS banner when the keyboard appears. + func textFieldEditingChangeHandler(isEditing: Bool) { + guard isEditing else { return } + withAnimation { scrollView?.scrollTo(Constants.textFieldID) } + } +} + +// MARK: - Previews + +@available(iOS 15.0, *) +struct AuthenticationServerSelection_Previews: PreviewProvider { + static let stateRenderer = MockAuthenticationServerSelectionScreenState.stateRenderer + static var previews: some View { + stateRenderer.screenGroup(addNavigation: true) + .navigationViewStyle(.stack) + } +} diff --git a/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift b/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift index 3afc87705..ca153fdbc 100644 --- a/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift +++ b/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift @@ -21,6 +21,8 @@ import Foundation enum MockAppScreens { static let appScreens: [MockScreenState.Type] = [ MockLiveLocationSharingViewerScreenState.self, + MockAuthenticationRegistrationScreenState.self, + MockAuthenticationServerSelectionScreenState.self, MockOnboardingCelebrationScreenState.self, MockOnboardingAvatarScreenState.self, MockOnboardingDisplayNameScreenState.self, diff --git a/RiotSwiftUI/Modules/Common/Util/RoundedBorderTextField.swift b/RiotSwiftUI/Modules/Common/Util/RoundedBorderTextField.swift index 84c75089a..c9f3d73c3 100644 --- a/RiotSwiftUI/Modules/Common/Util/RoundedBorderTextField.swift +++ b/RiotSwiftUI/Modules/Common/Util/RoundedBorderTextField.swift @@ -58,6 +58,7 @@ struct RoundedBorderTextField: View { .font(theme.fonts.callout) .foregroundColor(theme.colors.tertiaryContent) .lineLimit(1) + .accessibilityHidden(true) } if isEnabled { ThemableTextField(placeholder: "", text: $text, configuration: configuration, onEditingChanged: { edit in @@ -66,22 +67,24 @@ struct RoundedBorderTextField: View { }) .makeFirstResponder(isFirstResponder) .showClearButton(text: $text) - .onChange(of: text, perform: { newText in + .onChange(of: text) { newText in onTextChanged?(newText) - }) + } .frame(height: 30) + .accessibilityLabel(text.isEmpty ? placeHolder : "") } else { ThemableTextField(placeholder: "", text: $text, configuration: configuration, onEditingChanged: { edit in self.editing = edit onEditingChanged?(edit) }) .makeFirstResponder(isFirstResponder) - .onChange(of: text, perform: { newText in + .onChange(of: text) { newText in onTextChanged?(newText) - }) + } .frame(height: 30) .allowsHitTesting(false) .opacity(0.5) + .accessibilityLabel(text.isEmpty ? placeHolder : "") } } .padding(EdgeInsets(top: 8, leading: 8, bottom: 8, trailing: text.isEmpty ? 8 : 0)) diff --git a/RiotSwiftUI/Modules/Room/LiveLocationSharingViewer/Coordinator/LiveLocationSharingViewerCoordinator.swift b/RiotSwiftUI/Modules/Room/LiveLocationSharingViewer/Coordinator/LiveLocationSharingViewerCoordinator.swift index 1f9d74002..40f079692 100644 --- a/RiotSwiftUI/Modules/Room/LiveLocationSharingViewer/Coordinator/LiveLocationSharingViewerCoordinator.swift +++ b/RiotSwiftUI/Modules/Room/LiveLocationSharingViewer/Coordinator/LiveLocationSharingViewerCoordinator.swift @@ -18,6 +18,8 @@ import SwiftUI struct LiveLocationSharingViewerCoordinatorParameters { let session: MXSession + let roomId: String + let navigationRouter: NavigationRouterType? } final class LiveLocationSharingViewerCoordinator: Coordinator, Presentable { @@ -27,6 +29,7 @@ final class LiveLocationSharingViewerCoordinator: Coordinator, Presentable { // MARK: Private private let parameters: LiveLocationSharingViewerCoordinatorParameters + private let navigationRouter: NavigationRouterType private let liveLocationSharingViewerHostingController: UIViewController private var liveLocationSharingViewerViewModel: LiveLocationSharingViewerViewModelProtocol @@ -36,6 +39,7 @@ final class LiveLocationSharingViewerCoordinator: Coordinator, Presentable { // Must be used only internally var childCoordinators: [Coordinator] = [] + var completion: (() -> Void)? // MARK: - Setup @@ -44,13 +48,15 @@ final class LiveLocationSharingViewerCoordinator: Coordinator, Presentable { init(parameters: LiveLocationSharingViewerCoordinatorParameters) { self.parameters = parameters - let service = LiveLocationSharingViewerService(session: parameters.session) + let service = LiveLocationSharingViewerService(session: parameters.session, roomId: parameters.roomId) let viewModel = LiveLocationSharingViewerViewModel(mapStyleURL: BuildSettings.tileServerMapStyleURL, service: service) let view = LiveLocationSharingViewer(viewModel: viewModel.context) .addDependency(AvatarService.instantiate(mediaManager: parameters.session.mediaManager)) liveLocationSharingViewerViewModel = viewModel liveLocationSharingViewerHostingController = VectorHostingController(rootView: view) + + navigationRouter = parameters.navigationRouter ?? NavigationRouter() } // MARK: - Public @@ -64,14 +70,21 @@ final class LiveLocationSharingViewerCoordinator: Coordinator, Presentable { self.completion?() case .share(let coordinate): self.presentLocationActivityController(with: coordinate) - case .stopLocationSharing: - self.stopLocationSharing() } } + + let viewController: UIViewController = self.liveLocationSharingViewerHostingController + + if navigationRouter.modules.count > 1 { + navigationRouter.push(viewController, animated: true, popCompletion: nil) + } else { + navigationRouter.setRootModule(viewController) + } } func toPresentable() -> UIViewController { - return self.liveLocationSharingViewerHostingController + return navigationRouter.toPresentable() + .vc_setModalFullScreen(true) // Set fullscreen as DSBottomSheet is not working with modal pan gesture recognizer } func presentLocationActivityController(with coordinate: CLLocationCoordinate2D) { @@ -80,8 +93,4 @@ final class LiveLocationSharingViewerCoordinator: Coordinator, Presentable { self.liveLocationSharingViewerHostingController.present(shareActivityController, animated: true) } - - func stopLocationSharing() { - // TODO: Handle stop location sharing - } } diff --git a/RiotSwiftUI/Modules/Room/LiveLocationSharingViewer/LiveLocationSharingViewerModels.swift b/RiotSwiftUI/Modules/Room/LiveLocationSharingViewer/LiveLocationSharingViewerModels.swift index 018aeee75..5ea241749 100644 --- a/RiotSwiftUI/Modules/Room/LiveLocationSharingViewer/LiveLocationSharingViewerModels.swift +++ b/RiotSwiftUI/Modules/Room/LiveLocationSharingViewer/LiveLocationSharingViewerModels.swift @@ -25,7 +25,6 @@ import CoreLocation enum LiveLocationSharingViewerViewModelResult { case done case share(_ coordinate: CLLocationCoordinate2D) - case stopLocationSharing } // MARK: View diff --git a/RiotSwiftUI/Modules/Room/LiveLocationSharingViewer/LiveLocationSharingViewerViewModel.swift b/RiotSwiftUI/Modules/Room/LiveLocationSharingViewer/LiveLocationSharingViewerViewModel.swift index 9d8a31dc9..c34be9bdb 100644 --- a/RiotSwiftUI/Modules/Room/LiveLocationSharingViewer/LiveLocationSharingViewerViewModel.swift +++ b/RiotSwiftUI/Modules/Room/LiveLocationSharingViewer/LiveLocationSharingViewerViewModel.swift @@ -29,9 +29,11 @@ class LiveLocationSharingViewerViewModel: LiveLocationSharingViewerViewModelType // MARK: Private - private let liveLocationSharingViewerService: LiveLocationSharingViewerServiceProtocol + private var liveLocationSharingViewerService: LiveLocationSharingViewerServiceProtocol private var mapViewErrorAlertInfoBuilder: MapViewErrorAlertInfoBuilder + + private var screenUpdateTimer: Timer? // MARK: Public @@ -53,7 +55,8 @@ class LiveLocationSharingViewerViewModel: LiveLocationSharingViewerViewModelType self.processError(error) }.store(in: &cancellables) - self.update(with: service.usersLiveLocation) + self.setupLocationSharingService() + self.setupScreenUpdateTimer() } // MARK: - Public @@ -63,7 +66,7 @@ class LiveLocationSharingViewerViewModel: LiveLocationSharingViewerViewModelType case .done: completion?(.done) case .stopSharing: - completion?(.stopLocationSharing) + stopUserLocationSharing() case .tapListItem(let userId): self.highlighAnnotation(with: userId) case .share(let userLocationAnnotation): @@ -73,6 +76,34 @@ class LiveLocationSharingViewerViewModel: LiveLocationSharingViewerViewModelType // MARK: - Private + private func setupLocationSharingService() { + self.updateUsersLiveLocation(highlightFirstLocation: true) + + liveLocationSharingViewerService.didUpdateUsersLiveLocation = { [weak self] liveLocations in + self?.update(with: liveLocations, highlightFirstLocation: false) + } + self.liveLocationSharingViewerService.startListeningLiveLocationUpdates() + } + + private func updateUsersLiveLocation(highlightFirstLocation: Bool) { + self.update(with: liveLocationSharingViewerService.usersLiveLocation, highlightFirstLocation: highlightFirstLocation) + } + + private func setupScreenUpdateTimer() { + self.screenUpdateTimer = Timer.scheduledTimer(withTimeInterval: 30, repeats: true) { [weak self] timer in + + self?.updateUsersLiveLocation(highlightFirstLocation: false) + } + } + + private func showNoUserLocationsAlert() { + let alertInfo: AlertInfo = AlertInfo(id: .userLocatingError, title: VectorL10n.locationSharingLiveNoUserLocationsErrorTitle, primaryButton:(VectorL10n.ok, { [weak self] in + self?.completion?(.done) + })) + + state.bindings.alertInfo = alertInfo + } + private func processError(_ error: LocationSharingViewError) { guard state.bindings.alertInfo == nil else { return @@ -154,17 +185,27 @@ class LiveLocationSharingViewerViewModel: LiveLocationSharingViewerViewModelType return LiveLocationListItemViewData(userId: userLiveLocation.userId, isCurrentUser: isCurrentUser, avatarData: userLiveLocation.avatarData, displayName: userLiveLocation.displayName, expirationDate: expirationDate, lastUpdate: userLiveLocation.lastUpdate) } - private func update(with usersLiveLocation: [UserLiveLocation]) { + private func update(with usersLiveLocation: [UserLiveLocation], highlightFirstLocation: Bool) { let annotations: [UserLocationAnnotation] = self.userLocationAnnotations(from: usersLiveLocation) - let highlightedAnnotation = self.getHighlightedAnnotation(from: annotations) + var highlightedAnnotation: UserLocationAnnotation? + + if highlightFirstLocation { + highlightedAnnotation = self.getHighlightedAnnotation(from: annotations) + } let listViewItems = self.listItemsViewData(from: usersLiveLocation) self.state.annotations = annotations self.state.highlightedAnnotation = highlightedAnnotation self.state.listItemsViewData = listViewItems + + if usersLiveLocation.isEmpty { + // Advertize user that there is no locations + // Avoid to let the screen empty + self.showNoUserLocationsAlert() + } } private func highlighAnnotation(with userId: String) { @@ -178,4 +219,23 @@ class LiveLocationSharingViewerViewModel: LiveLocationSharingViewerViewModelType self.state.highlightedAnnotation = foundUserAnnotation } + + private func stopUserLocationSharing() { + + self.state.showLoadingIndicator = true + + self.liveLocationSharingViewerService.stopUserLiveLocationSharing { result in + self.state.showLoadingIndicator = false + + switch result { + case .success: + break + case.failure: + self.state.bindings.alertInfo = AlertInfo(id: .stopLocationSharingError, + title: VectorL10n.error, + message: VectorL10n.locationSharingLiveStopSharingError, + primaryButton: (VectorL10n.ok, nil)) + } + } + } } diff --git a/RiotSwiftUI/Modules/Room/LiveLocationSharingViewer/Service/LiveLocationSharingViewerServiceProtocol.swift b/RiotSwiftUI/Modules/Room/LiveLocationSharingViewer/Service/LiveLocationSharingViewerServiceProtocol.swift index 8d272fb0a..5e23bd64e 100644 --- a/RiotSwiftUI/Modules/Room/LiveLocationSharingViewer/Service/LiveLocationSharingViewerServiceProtocol.swift +++ b/RiotSwiftUI/Modules/Room/LiveLocationSharingViewer/Service/LiveLocationSharingViewerServiceProtocol.swift @@ -21,7 +21,18 @@ import CoreLocation @available(iOS 14.0, *) protocol LiveLocationSharingViewerServiceProtocol { + /// All shared users live location var usersLiveLocation: [UserLiveLocation] { get } + /// Called when users live location are updated (new location, location stopped, …). + var didUpdateUsersLiveLocation: (([UserLiveLocation]) -> Void)? { get set } + func isCurrentUserId(_ userId: String) -> Bool + + func startListeningLiveLocationUpdates() + + func stopListeningLiveLocationUpdates() + + /// Stop current user location sharing + func stopUserLiveLocationSharing(completion: @escaping (Result) -> Void) } diff --git a/RiotSwiftUI/Modules/Room/LiveLocationSharingViewer/Service/MatrixSDK/LiveLocationSharingViewerService.swift b/RiotSwiftUI/Modules/Room/LiveLocationSharingViewer/Service/MatrixSDK/LiveLocationSharingViewerService.swift index 65a7da476..560a4e9c5 100644 --- a/RiotSwiftUI/Modules/Room/LiveLocationSharingViewer/Service/MatrixSDK/LiveLocationSharingViewerService.swift +++ b/RiotSwiftUI/Modules/Room/LiveLocationSharingViewer/Service/MatrixSDK/LiveLocationSharingViewerService.swift @@ -16,6 +16,7 @@ import Foundation import CoreLocation +import MatrixSDK @available(iOS 14.0, *) class LiveLocationSharingViewerService: LiveLocationSharingViewerServiceProtocol { @@ -23,6 +24,8 @@ class LiveLocationSharingViewerService: LiveLocationSharingViewerServiceProtocol // MARK: - Properties private(set) var usersLiveLocation: [UserLiveLocation] = [] + private let roomId: String + private var beaconInfoSummaryListener: Any? // MARK: Private @@ -30,13 +33,83 @@ class LiveLocationSharingViewerService: LiveLocationSharingViewerServiceProtocol // MARK: Public - func isCurrentUserId(_ userId: String) -> Bool { - return false - } + var didUpdateUsersLiveLocation: (([UserLiveLocation]) -> Void)? // MARK: - Setup - init(session: MXSession) { + init(session: MXSession, roomId: String) { self.session = session + self.roomId = roomId + + self.updateUsersLiveLocation(notifyUpdate: false) + } + + // MARK: - Public + + func isCurrentUserId(_ userId: String) -> Bool { + return self.session.myUserId == userId + } + + func startListeningLiveLocationUpdates() { + self.beaconInfoSummaryListener = self.session.aggregations.beaconAggregations.listenToBeaconInfoSummaryUpdateInRoom(withId: self.roomId) { [weak self] _ in + + self?.updateUsersLiveLocation(notifyUpdate: true) + } + } + + func stopListeningLiveLocationUpdates() { + if let listener = beaconInfoSummaryListener { + self.session.aggregations.removeListener(listener) + self.beaconInfoSummaryListener = nil + } + } + + func stopUserLiveLocationSharing(completion: @escaping (Result) -> Void) { + self.session.locationService.stopUserLocationSharing(inRoomWithId: roomId) { response in + + switch response { + case .success: + completion(.success(Void())) + case .failure(let error): + completion(.failure(error)) + } + } + } + + // MARK: - Private + + private func updateUsersLiveLocation(notifyUpdate: Bool) { + let beaconInfoSummaries = self.session.locationService.getDisplayableBeaconInfoSummaries(inRoomWithId: roomId) + self.usersLiveLocation = Self.usersLiveLocation(fromBeaconInfoSummaries: beaconInfoSummaries, session: session) + + if notifyUpdate { + self.didUpdateUsersLiveLocation?(self.usersLiveLocation) + } + } + + class private func usersLiveLocation(fromBeaconInfoSummaries beaconInfoSummaries: [MXBeaconInfoSummaryProtocol], session: MXSession) -> [UserLiveLocation] { + + return beaconInfoSummaries.compactMap { beaconInfoSummary in + + let beaconInfo = beaconInfoSummary.beaconInfo + + guard let lastBeacon = beaconInfoSummary.lastBeacon else { + return nil + } + + let avatarData = session.avatarInput(for: beaconInfoSummary.userId) + + let timestamp = TimeInterval(beaconInfo.timestamp/1000) + let timeout = TimeInterval(beaconInfo.timeout/1000) + let lastUpdate = TimeInterval(lastBeacon.timestamp/1000) + + let coordinate = CLLocationCoordinate2D(latitude: lastBeacon.location.latitude, longitude: lastBeacon.location.longitude) + + return UserLiveLocation(avatarData: avatarData, + timestamp: timestamp, + timeout: timeout, + lastUpdate: lastUpdate, + coordinate: coordinate) + } } } diff --git a/RiotSwiftUI/Modules/Room/LiveLocationSharingViewer/Service/Mock/MockLiveLocationSharingViewerService.swift b/RiotSwiftUI/Modules/Room/LiveLocationSharingViewer/Service/Mock/MockLiveLocationSharingViewerService.swift index c95e5c571..d941ddfdc 100644 --- a/RiotSwiftUI/Modules/Room/LiveLocationSharingViewer/Service/Mock/MockLiveLocationSharingViewerService.swift +++ b/RiotSwiftUI/Modules/Room/LiveLocationSharingViewer/Service/Mock/MockLiveLocationSharingViewerService.swift @@ -21,11 +21,13 @@ import CoreLocation @available(iOS 14.0, *) class MockLiveLocationSharingViewerService: LiveLocationSharingViewerServiceProtocol { + // MARK: Properties + private(set) var usersLiveLocation: [UserLiveLocation] = [] - func isCurrentUserId(_ userId: String) -> Bool { - return "@alice:matrix.org" == userId - } + var didUpdateUsersLiveLocation: (([UserLiveLocation]) -> Void)? + + // MARK: Setup init(generateRandomUsers: Bool = false) { @@ -46,6 +48,26 @@ class MockLiveLocationSharingViewerService: LiveLocationSharingViewerServiceProt self.usersLiveLocation = usersLiveLocation } + // MARK: Public + + func isCurrentUserId(_ userId: String) -> Bool { + return "@alice:matrix.org" == userId + } + + func startListeningLiveLocationUpdates() { + + } + + func stopListeningLiveLocationUpdates() { + + } + + func stopUserLiveLocationSharing(completion: @escaping (Result) -> Void) { + + } + + // MARK: Private + private func createFirstUserLiveLocation() -> UserLiveLocation { let userAvatarData = AvatarInput(mxContentUri: nil, matrixItemId: "@alice:matrix.org", displayName: "Alice") let userCoordinate = CLLocationCoordinate2D(latitude: 51.4932641, longitude: -0.257096) diff --git a/RiotSwiftUI/Modules/Room/LiveLocationSharingViewer/View/LiveLocationListItem.swift b/RiotSwiftUI/Modules/Room/LiveLocationSharingViewer/View/LiveLocationListItem.swift index c5f4b94d6..86d192a0c 100644 --- a/RiotSwiftUI/Modules/Room/LiveLocationSharingViewer/View/LiveLocationListItem.swift +++ b/RiotSwiftUI/Modules/Room/LiveLocationSharingViewer/View/LiveLocationListItem.swift @@ -102,7 +102,7 @@ struct LiveLocationListItem: View { let formatter = DateComponentsFormatter() formatter.unitsStyle = .abbreviated - formatter.allowedUnits = [.hour, .minute] + formatter.allowedUnits = [.hour, .minute, .second] let date = Date(timeIntervalSince1970: timestamp) diff --git a/RiotSwiftUI/Modules/Room/LiveLocationSharingViewer/View/LiveLocationSharingViewer.swift b/RiotSwiftUI/Modules/Room/LiveLocationSharingViewer/View/LiveLocationSharingViewer.swift index 790fbc580..b5d3cb688 100644 --- a/RiotSwiftUI/Modules/Room/LiveLocationSharingViewer/View/LiveLocationSharingViewer.swift +++ b/RiotSwiftUI/Modules/Room/LiveLocationSharingViewer/View/LiveLocationSharingViewer.swift @@ -56,6 +56,13 @@ struct LiveLocationSharingViewer: View { } } .navigationTitle(VectorL10n.locationSharingLiveViewerTitle) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(VectorL10n.cancel) { + viewModel.send(viewAction: .done) + } + } + } .accentColor(theme.colors.accent) .bottomSheet(sheet, if: isBottomSheetVisible) .alert(item: $viewModel.alertInfo) { info in diff --git a/RiotSwiftUI/Modules/Room/LocationSharing/LocationSharingModels.swift b/RiotSwiftUI/Modules/Room/LocationSharing/LocationSharingModels.swift index 2e135b51a..b4ade5e31 100644 --- a/RiotSwiftUI/Modules/Room/LocationSharing/LocationSharingModels.swift +++ b/RiotSwiftUI/Modules/Room/LocationSharing/LocationSharingModels.swift @@ -103,4 +103,5 @@ enum LocationSharingAlertType { case userLocatingError case authorizationError case locationSharingError + case stopLocationSharingError } diff --git a/changelog.d/5648.wip b/changelog.d/5648.wip new file mode 100644 index 000000000..fd0fa5904 --- /dev/null +++ b/changelog.d/5648.wip @@ -0,0 +1 @@ +Authentication: Begin implementing authentication flow with a Service, Registration screen and Server Selection screen. diff --git a/changelog.d/6081.wip b/changelog.d/6081.wip new file mode 100644 index 000000000..77e131168 --- /dev/null +++ b/changelog.d/6081.wip @@ -0,0 +1 @@ +Location sharing: Integrate live location viewer screen with room screen. \ No newline at end of file