#1899, #1900, #1901 - Implement chat backup setting screens

This commit is contained in:
Stefan Ceriu
2023-10-19 15:34:10 +03:00
committed by GitHub
parent 15b5b44422
commit c8b9d95122
70 changed files with 2293 additions and 48 deletions
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "secure-backup-icon.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}
@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7 14C6.45 14 5.97917 13.8042 5.5875 13.4125C5.19583 13.0208 5 12.55 5 12C5 11.45 5.19583 10.9792 5.5875 10.5875C5.97917 10.1958 6.45 10 7 10C7.55 10 8.02083 10.1958 8.4125 10.5875C8.80417 10.9792 9 11.45 9 12C9 12.55 8.80417 13.0208 8.4125 13.4125C8.02083 13.8042 7.55 14 7 14ZM7 18C5.33333 18 3.91667 17.4167 2.75 16.25C1.58333 15.0833 1 13.6667 1 12C1 10.3333 1.58333 8.91667 2.75 7.75C3.91667 6.58333 5.33333 6 7 6C8.11667 6 9.12917 6.275 10.0375 6.825C10.9458 7.375 11.6667 8.1 12.2 9H20.575C20.7083 9 20.8375 9.025 20.9625 9.075C21.0875 9.125 21.2 9.2 21.3 9.3L23.3 11.3C23.4 11.4 23.4708 11.5083 23.5125 11.625C23.5542 11.7417 23.575 11.8667 23.575 12C23.575 12.1333 23.5542 12.2583 23.5125 12.375C23.4708 12.4917 23.4 12.6 23.3 12.7L20.125 15.875C20.0417 15.9583 19.9417 16.025 19.825 16.075C19.7083 16.125 19.5917 16.1583 19.475 16.175C19.3583 16.1917 19.2417 16.1833 19.125 16.15C19.0083 16.1167 18.9 16.0583 18.8 15.975L17.5 15L16.075 16.075C15.9917 16.1417 15.9 16.1917 15.8 16.225C15.7 16.2583 15.6 16.275 15.5 16.275C15.4 16.275 15.2958 16.2583 15.1875 16.225C15.0792 16.1917 14.9833 16.1417 14.9 16.075L13.375 15H12.2C11.6667 15.9 10.9458 16.625 10.0375 17.175C9.12917 17.725 8.11667 18 7 18ZM7 16C7.93333 16 8.75417 15.7167 9.4625 15.15C10.1708 14.5833 10.6417 13.8667 10.875 13H14L15.45 14.025L17.5 12.5L19.275 13.875L21.15 12L20.15 11H10.875C10.6417 10.1333 10.1708 9.41667 9.4625 8.85C8.75417 8.28333 7.93333 8 7 8C5.9 8 4.95833 8.39167 4.175 9.175C3.39167 9.95833 3 10.9 3 12C3 13.1 3.39167 14.0417 4.175 14.825C4.95833 15.6083 5.9 16 7 16Z" fill="#03152B" fill-opacity="0.5"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "secure-backup-off.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}
@@ -0,0 +1,3 @@
<svg width="42" height="42" viewBox="0 0 42 42" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M34.65 38.4129L21.6125 25.3754C20.7667 27.3879 19.476 28.9118 17.7406 29.9473C16.0052 30.9827 14.175 31.5004 12.25 31.5004C9.33333 31.5004 6.85417 30.4796 4.8125 28.4379C2.77083 26.3962 1.75 23.9171 1.75 21.0004C1.75 19.0754 2.24583 17.2598 3.2375 15.5535C4.22917 13.8473 5.73125 12.4983 7.74375 11.5066L3.5875 7.35039C3.325 7.08789 3.19375 6.78164 3.19375 6.43164C3.19375 6.08164 3.325 5.77539 3.5875 5.51289C3.85 5.25039 4.16354 5.11914 4.52813 5.11914C4.89271 5.11914 5.20625 5.25039 5.46875 5.51289L36.5313 36.5316C36.7938 36.7941 36.9177 37.1077 36.9031 37.4723C36.8885 37.8368 36.75 38.1504 36.4875 38.4129C36.225 38.6754 35.9188 38.8066 35.5688 38.8066C35.2188 38.8066 34.9125 38.6754 34.65 38.4129ZM39.725 20.9129C39.725 21.0879 39.6958 21.2483 39.6375 21.3941C39.5792 21.54 39.4771 21.6858 39.3313 21.8316L34.7813 26.3816C34.6354 26.5275 34.4969 26.6296 34.3656 26.6879C34.2344 26.7462 34.0667 26.7754 33.8625 26.7754C33.6583 26.7754 33.4906 26.7462 33.3594 26.6879C33.2281 26.6296 33.0896 26.5275 32.9438 26.3816L30.1875 23.6254L28.7438 25.0691L21.4375 17.7191H36.5313C36.7063 17.7191 36.8667 17.7483 37.0125 17.8066C37.1583 17.865 37.3042 17.9671 37.45 18.1129L39.3313 19.9941C39.4771 20.14 39.5792 20.2858 39.6375 20.4316C39.6958 20.5775 39.725 20.7379 39.725 20.9129ZM12.25 26.6441C13.825 26.6441 15.1156 26.1337 16.1219 25.1129C17.1281 24.0921 17.7042 22.94 17.85 21.6566L16.8875 20.6723C16.2458 20.016 15.5313 19.3014 14.7438 18.5285C13.9563 17.7556 13.2417 17.041 12.6 16.3848L11.6375 15.4004C10.3542 15.5171 9.19479 16.0858 8.15938 17.1066C7.12396 18.1275 6.60625 19.4254 6.60625 21.0004C6.60625 22.5754 7.15313 23.9098 8.24688 25.0035C9.34063 26.0973 10.675 26.6441 12.25 26.6441Z" fill="#1B1D22"/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

@@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "secure-backup-on.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}
@@ -0,0 +1,3 @@
<svg width="42" height="42" viewBox="0 0 42 42" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.25 26.6437C10.675 26.6437 9.34063 26.0969 8.24688 25.0031C7.15313 23.9094 6.60625 22.575 6.60625 21C6.60625 19.425 7.15313 18.0906 8.24688 16.9969C9.34063 15.9031 10.675 15.3562 12.25 15.3562C13.825 15.3562 15.1594 15.9031 16.2531 16.9969C17.3469 18.0906 17.8938 19.425 17.8938 21C17.8938 22.575 17.3469 23.9094 16.2531 25.0031C15.1594 26.0969 13.825 26.6437 12.25 26.6437ZM12.25 31.5C14.6125 31.5 16.6906 30.8292 18.4844 29.4875C20.2781 28.1458 21.4521 26.3812 22.0063 24.1937H23.4063L25.5938 26.3812C25.7396 26.5271 25.8854 26.6292 26.0313 26.6875C26.1771 26.7458 26.3375 26.775 26.5125 26.775C26.6875 26.775 26.8479 26.7458 26.9938 26.6875C27.1396 26.6292 27.2854 26.5271 27.4313 26.3812L30.1875 23.625L32.9438 26.3812C33.0896 26.5271 33.2354 26.6292 33.3813 26.6875C33.5271 26.7458 33.6875 26.775 33.8625 26.775C34.0375 26.775 34.1979 26.7458 34.3438 26.6875C34.4896 26.6292 34.6354 26.5271 34.7813 26.3812L39.3313 21.8313C39.4771 21.6854 39.5792 21.5396 39.6375 21.3938C39.6958 21.2479 39.725 21.0875 39.725 20.9125C39.725 20.7375 39.6958 20.5771 39.6375 20.4312C39.5792 20.2854 39.4771 20.1396 39.3313 19.9937L37.45 18.1125C37.3042 17.9667 37.1583 17.8646 37.0125 17.8063C36.8667 17.7479 36.7063 17.7188 36.5313 17.7188H22.0063C21.3354 15.6188 20.1615 13.8906 18.4844 12.5344C16.8073 11.1781 14.7292 10.5 12.25 10.5C9.33333 10.5 6.85417 11.5208 4.8125 13.5625C2.77083 15.6042 1.75 18.0833 1.75 21C1.75 23.9167 2.77083 26.3958 4.8125 28.4375C6.85417 30.4792 9.33333 31.5 12.25 31.5Z" fill="#1B1D22"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

@@ -68,6 +68,8 @@
"action_share" = "Share";
"action_share_link" = "Share link";
"action_sign_in_again" = "Sign in again";
"action_signout" = "Sign out";
"action_signout_anyway" = "Sign out anyway";
"action_skip" = "Skip";
"action_start" = "Start";
"action_start_chat" = "Start chat";
@@ -83,6 +85,7 @@
"common_analytics" = "Analytics";
"common_audio" = "Audio";
"common_bubbles" = "Bubbles";
"common_chat_backup" = "Chat backup";
"common_copyright" = "Copyright";
"common_creating_room" = "Creating room…";
"common_current_user_left_room" = "Left room";
@@ -119,6 +122,7 @@
"common_privacy_policy" = "Privacy policy";
"common_reaction" = "Reaction";
"common_reactions" = "Reactions";
"common_recovery_key" = "Recovery key";
"common_refreshing" = "Refreshing…";
"common_replying_to" = "Replying to %1$@";
"common_report_a_bug" = "Report a bug";
@@ -128,7 +132,6 @@
"common_room_name_placeholder" = "e.g. your project name";
"common_search_for_someone" = "Search for someone";
"common_search_results" = "Search results";
"common_secure_backup" = "Secure backup";
"common_security" = "Security";
"common_sending" = "Sending…";
"common_server_not_supported" = "Server not supported";
@@ -234,6 +237,9 @@
"room_timeline_beginning_of_room" = "This is the beginning of %1$@.";
"room_timeline_beginning_of_room_no_name" = "This is the beginning of this conversation.";
"room_timeline_read_marker_title" = "New";
"screen_advanced_settings_element_call_base_url" = "Custom Element Call base URL";
"screen_advanced_settings_element_call_base_url_description" = "Set a custom base URL for Element Call.";
"screen_advanced_settings_element_call_base_url_validation_error" = "Invalid URL, please make sure you include the protocol (http/https) and the correct address.";
"screen_account_provider_change" = "Change account provider";
"screen_account_provider_form_hint" = "Homeserver address";
"screen_account_provider_form_notice" = "Enter a search term or a domain address.";
@@ -275,6 +281,15 @@
"screen_change_server_form_notice" = "You can only connect to an existing server that supports sliding sync. Your homeserver admin will need to configure it. %1$@";
"screen_change_server_subtitle" = "What is the address of your server?";
"screen_change_server_title" = "Select your server";
"screen_chat_backup_key_backup_action_disable" = "Turn off backup";
"screen_chat_backup_key_backup_action_enable" = "Turn on backup";
"screen_chat_backup_key_backup_description" = "Backup ensures that you don't lose your message history. %1$@.";
"screen_chat_backup_key_backup_title" = "Backup";
"screen_chat_backup_recovery_action_change" = "Change recovery key";
"screen_chat_backup_recovery_action_confirm" = "Confirm recovery key";
"screen_chat_backup_recovery_action_confirm_description" = "Your chat backup is currently out of sync.";
"screen_chat_backup_recovery_action_setup" = "Set up recovery";
"screen_chat_backup_recovery_action_setup_description" = "Get access to your encrypted messages if you lose all your devices or are signed out of %1$@ everywhere.";
"screen_create_poll_add_option_btn" = "Add option";
"screen_create_poll_anonymous_desc" = "Show results only after poll ends";
"screen_create_poll_anonymous_headline" = "Hide votes";
@@ -306,6 +321,13 @@
"screen_invites_decline_direct_chat_title" = "Decline chat";
"screen_invites_empty_list" = "No Invites";
"screen_invites_invited_you" = "%1$@ (%2$@) invited you";
"screen_key_backup_disable_confirmation_action_turn_off" = "Turn off";
"screen_key_backup_disable_confirmation_description" = "You will lose your encrypted messages if you are signed out of all devices.";
"screen_key_backup_disable_confirmation_title" = "Are you sure you want to turn off backup?";
"screen_key_backup_disable_description" = "Turning off backup will remove your current encryption key backup and turn off other security features. In this case, you will:";
"screen_key_backup_disable_description_point_1" = "Not have encrypted message history on new devices";
"screen_key_backup_disable_description_point_2" = "Lose access to your encrypted messages if you are signed out of %1$@ everywhere";
"screen_key_backup_disable_title" = "Are you sure you want to turn off backup?";
"screen_login_error_deactivated_account" = "This account has been deactivated.";
"screen_login_error_invalid_credentials" = "Incorrect username and/or password";
"screen_login_error_invalid_user_id" = "This is not a valid user identifier. Expected format: ‘@user:homeserver.org’";
@@ -349,6 +371,27 @@
"screen_onboarding_welcome_message" = "Welcome to the fastest Element ever. Supercharged for speed and simplicity.";
"screen_onboarding_welcome_subtitle" = "Welcome to %1$@. Supercharged, for speed and simplicity.";
"screen_onboarding_welcome_title" = "Be in your element";
"screen_recovery_key_change_description" = "Get a new recovery key if you've lost your existing one. After changing your recovery key, your old one will no longer work.";
"screen_recovery_key_change_generate_key" = "Generate a new recovery key";
"screen_recovery_key_change_generate_key_description" = "Make sure you can store your recovery key somewhere safe";
"screen_recovery_key_change_success" = "Recovery key changed";
"screen_recovery_key_change_title" = "Change recovery key?";
"screen_recovery_key_confirm_description" = "Enter your recovery key to confirm access to your chat backup.";
"screen_recovery_key_confirm_key_description" = "Enter the 48 character code.";
"screen_recovery_key_confirm_key_placeholder" = "Enter...";
"screen_recovery_key_confirm_success" = "Recovery key confirmed";
"screen_recovery_key_confirm_title" = "Confirm your recovery key";
"screen_recovery_key_save_action" = "Save recovery key";
"screen_recovery_key_save_description" = "Write down your recovery key somewhere safe or save it in a password manager.";
"screen_recovery_key_save_key_description" = "Tap to copy recovery key";
"screen_recovery_key_save_title" = "Save your recovery key";
"screen_recovery_key_setup_confirmation_description" = "You will not be able to access your new recovery key after this step.";
"screen_recovery_key_setup_confirmation_title" = "Have you saved your recovery key?";
"screen_recovery_key_setup_description" = "Your chat backup is protected by a recovery key. If you need a new recovery key after setup you can recreate by selecting ‘Change recovery key’.";
"screen_recovery_key_setup_generate_key" = "Generate your recovery key";
"screen_recovery_key_setup_generate_key_description" = "Make sure you can store your recovery key somewhere safe";
"screen_recovery_key_setup_success" = "Recovery setup successful";
"screen_recovery_key_setup_title" = "Set up recovery";
"screen_report_content_block_user_hint" = "Check if you want to hide all current and future messages from this user";
"screen_room_attachment_source_camera" = "Camera";
"screen_room_attachment_source_camera_photo" = "Take photo";
@@ -445,9 +488,13 @@
"screen_signed_out_reason_3" = "Your server’s administrator has invalidated your access";
"screen_signed_out_subtitle" = "You might have been signed out for one of the reasons listed below. Please sign in again to continue using %@.";
"screen_signed_out_title" = "You’re signed out";
"screen_signout_backing_up_subtitle" = "Please wait for this to complete before signing out.";
"screen_signout_backing_up_title" = "Your keys are still being backed up";
"screen_signout_confirmation_dialog_content" = "Are you sure you want to sign out?";
"screen_signout_confirmation_dialog_title" = "Sign out";
"screen_signout_in_progress_dialog_content" = "Signing out…";
"screen_signout_last_session_subtitle" = "You are about to sign out of your last session. If you sign out now, you might lose access to your encrypted messages.";
"screen_signout_last_session_title" = "Have you saved your recovery key?";
"screen_start_chat_error_starting_chat" = "An error occurred when trying to start a chat";
"screen_view_location_title" = "Location";
"screen_waitlist_message" = "There's a high demand for %1$@ on %2$@ at the moment. Come back to the app in a few days and try again.\n\nThanks for your patience!";
@@ -44,6 +44,7 @@ final class AppSettings {
case mentionsEnabled
case appLockFlowEnabled
case elementCallEnabled
case chatBackupEnabled
}
private static var suiteName: String = InfoPlistReader.main.appGroupIdentifier
@@ -116,6 +117,8 @@ final class AppSettings {
let privacyURL: URL = "https://element.io/privacy"
/// An email address that should be used for support requests.
let supportEmailAddress = "support@element.io"
// A URL where users can go read more about the chat backup.
let chatBackupDetailsURL: URL = "https://element.io/help#encryption"
// MARK: - Security
@@ -276,4 +279,7 @@ final class AppSettings {
@UserPreference(key: UserDefaultsKeys.elementCallEnabled, defaultValue: false, storageType: .userDefaults(store))
var elementCallEnabled
@UserPreference(key: UserDefaultsKeys.chatBackupEnabled, defaultValue: false, storageType: .userDefaults(store))
var chatBackupEnabled
}
@@ -334,6 +334,7 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
appLockService: appLockService,
bugReportService: bugReportService,
notificationSettings: userSession.clientProxy.notificationSettings,
secureBackupController: userSession.clientProxy.secureBackupController,
appSettings: appSettings)
let settingsScreenCoordinator = SettingsScreenCoordinator(parameters: parameters)
+3
View File
@@ -69,6 +69,9 @@ internal enum Asset {
internal static let mediaPause = ImageAsset(name: "images/media-pause")
internal static let mediaPlay = ImageAsset(name: "images/media-play")
internal static let microphone = ImageAsset(name: "images/microphone")
internal static let secureBackupIcon = ImageAsset(name: "images/secure-backup-icon")
internal static let secureBackupOff = ImageAsset(name: "images/secure-backup-off")
internal static let secureBackupOn = ImageAsset(name: "images/secure-backup-on")
internal static let addReaction = ImageAsset(name: "images/add-reaction")
internal static let copy = ImageAsset(name: "images/copy")
internal static let editOutline = ImageAsset(name: "images/edit-outline")
+102 -2
View File
@@ -152,6 +152,10 @@ public enum L10n {
public static var actionShareLink: String { return L10n.tr("Localizable", "action_share_link") }
/// Sign in again
public static var actionSignInAgain: String { return L10n.tr("Localizable", "action_sign_in_again") }
/// Sign out
public static var actionSignout: String { return L10n.tr("Localizable", "action_signout") }
/// Sign out anyway
public static var actionSignoutAnyway: String { return L10n.tr("Localizable", "action_signout_anyway") }
/// Skip
public static var actionSkip: String { return L10n.tr("Localizable", "action_skip") }
/// Start
@@ -180,6 +184,8 @@ public enum L10n {
public static var commonAudio: String { return L10n.tr("Localizable", "common_audio") }
/// Bubbles
public static var commonBubbles: String { return L10n.tr("Localizable", "common_bubbles") }
/// Chat backup
public static var commonChatBackup: String { return L10n.tr("Localizable", "common_chat_backup") }
/// Copyright
public static var commonCopyright: String { return L10n.tr("Localizable", "common_copyright") }
/// Creating room…
@@ -272,6 +278,8 @@ public enum L10n {
public static var commonReaction: String { return L10n.tr("Localizable", "common_reaction") }
/// Reactions
public static var commonReactions: String { return L10n.tr("Localizable", "common_reactions") }
/// Recovery key
public static var commonRecoveryKey: String { return L10n.tr("Localizable", "common_recovery_key") }
/// Refreshing…
public static var commonRefreshing: String { return L10n.tr("Localizable", "common_refreshing") }
/// Replying to %1$@
@@ -292,8 +300,6 @@ public enum L10n {
public static var commonSearchForSomeone: String { return L10n.tr("Localizable", "common_search_for_someone") }
/// Search results
public static var commonSearchResults: String { return L10n.tr("Localizable", "common_search_results") }
/// Secure backup
public static var commonSecureBackup: String { return L10n.tr("Localizable", "common_secure_backup") }
/// Security
public static var commonSecurity: String { return L10n.tr("Localizable", "common_security") }
/// Sending…
@@ -592,6 +598,12 @@ public enum L10n {
public static var screenAdvancedSettingsDeveloperMode: String { return L10n.tr("Localizable", "screen_advanced_settings_developer_mode") }
/// Enable to have access to features and functionality for developers.
public static var screenAdvancedSettingsDeveloperModeDescription: String { return L10n.tr("Localizable", "screen_advanced_settings_developer_mode_description") }
/// Custom Element Call base URL
public static var screenAdvancedSettingsElementCallBaseUrl: String { return L10n.tr("Localizable", "screen_advanced_settings_element_call_base_url") }
/// Set a custom base URL for Element Call.
public static var screenAdvancedSettingsElementCallBaseUrlDescription: String { return L10n.tr("Localizable", "screen_advanced_settings_element_call_base_url_description") }
/// Invalid URL, please make sure you include the protocol (http/https) and the correct address.
public static var screenAdvancedSettingsElementCallBaseUrlValidationError: String { return L10n.tr("Localizable", "screen_advanced_settings_element_call_base_url_validation_error") }
/// Disable the rich text editor to type Markdown manually.
public static var screenAdvancedSettingsRichTextEditorDescription: String { return L10n.tr("Localizable", "screen_advanced_settings_rich_text_editor_description") }
/// We won't record or profile any personal data
@@ -670,6 +682,28 @@ public enum L10n {
public static var screenChangeServerSubtitle: String { return L10n.tr("Localizable", "screen_change_server_subtitle") }
/// Select your server
public static var screenChangeServerTitle: String { return L10n.tr("Localizable", "screen_change_server_title") }
/// Turn off backup
public static var screenChatBackupKeyBackupActionDisable: String { return L10n.tr("Localizable", "screen_chat_backup_key_backup_action_disable") }
/// Turn on backup
public static var screenChatBackupKeyBackupActionEnable: String { return L10n.tr("Localizable", "screen_chat_backup_key_backup_action_enable") }
/// Backup ensures that you don't lose your message history. %1$@.
public static func screenChatBackupKeyBackupDescription(_ p1: Any) -> String {
return L10n.tr("Localizable", "screen_chat_backup_key_backup_description", String(describing: p1))
}
/// Backup
public static var screenChatBackupKeyBackupTitle: String { return L10n.tr("Localizable", "screen_chat_backup_key_backup_title") }
/// Change recovery key
public static var screenChatBackupRecoveryActionChange: String { return L10n.tr("Localizable", "screen_chat_backup_recovery_action_change") }
/// Confirm recovery key
public static var screenChatBackupRecoveryActionConfirm: String { return L10n.tr("Localizable", "screen_chat_backup_recovery_action_confirm") }
/// Your chat backup is currently out of sync.
public static var screenChatBackupRecoveryActionConfirmDescription: String { return L10n.tr("Localizable", "screen_chat_backup_recovery_action_confirm_description") }
/// Set up recovery
public static var screenChatBackupRecoveryActionSetup: String { return L10n.tr("Localizable", "screen_chat_backup_recovery_action_setup") }
/// Get access to your encrypted messages if you lose all your devices or are signed out of %1$@ everywhere.
public static func screenChatBackupRecoveryActionSetupDescription(_ p1: Any) -> String {
return L10n.tr("Localizable", "screen_chat_backup_recovery_action_setup_description", String(describing: p1))
}
/// Add option
public static var screenCreatePollAddOptionBtn: String { return L10n.tr("Localizable", "screen_create_poll_add_option_btn") }
/// Show results only after poll ends
@@ -754,6 +788,22 @@ public enum L10n {
public static func screenInvitesInvitedYou(_ p1: Any, _ p2: Any) -> String {
return L10n.tr("Localizable", "screen_invites_invited_you", String(describing: p1), String(describing: p2))
}
/// Turn off
public static var screenKeyBackupDisableConfirmationActionTurnOff: String { return L10n.tr("Localizable", "screen_key_backup_disable_confirmation_action_turn_off") }
/// You will lose your encrypted messages if you are signed out of all devices.
public static var screenKeyBackupDisableConfirmationDescription: String { return L10n.tr("Localizable", "screen_key_backup_disable_confirmation_description") }
/// Are you sure you want to turn off backup?
public static var screenKeyBackupDisableConfirmationTitle: String { return L10n.tr("Localizable", "screen_key_backup_disable_confirmation_title") }
/// Turning off backup will remove your current encryption key backup and turn off other security features. In this case, you will:
public static var screenKeyBackupDisableDescription: String { return L10n.tr("Localizable", "screen_key_backup_disable_description") }
/// Not have encrypted message history on new devices
public static var screenKeyBackupDisableDescriptionPoint1: String { return L10n.tr("Localizable", "screen_key_backup_disable_description_point_1") }
/// Lose access to your encrypted messages if you are signed out of %1$@ everywhere
public static func screenKeyBackupDisableDescriptionPoint2(_ p1: Any) -> String {
return L10n.tr("Localizable", "screen_key_backup_disable_description_point_2", String(describing: p1))
}
/// Are you sure you want to turn off backup?
public static var screenKeyBackupDisableTitle: String { return L10n.tr("Localizable", "screen_key_backup_disable_title") }
/// This account has been deactivated.
public static var screenLoginErrorDeactivatedAccount: String { return L10n.tr("Localizable", "screen_login_error_deactivated_account") }
/// Incorrect username and/or password
@@ -850,6 +900,48 @@ public enum L10n {
}
/// Be in your element
public static var screenOnboardingWelcomeTitle: String { return L10n.tr("Localizable", "screen_onboarding_welcome_title") }
/// Get a new recovery key if you've lost your existing one. After changing your recovery key, your old one will no longer work.
public static var screenRecoveryKeyChangeDescription: String { return L10n.tr("Localizable", "screen_recovery_key_change_description") }
/// Generate a new recovery key
public static var screenRecoveryKeyChangeGenerateKey: String { return L10n.tr("Localizable", "screen_recovery_key_change_generate_key") }
/// Make sure you can store your recovery key somewhere safe
public static var screenRecoveryKeyChangeGenerateKeyDescription: String { return L10n.tr("Localizable", "screen_recovery_key_change_generate_key_description") }
/// Recovery key changed
public static var screenRecoveryKeyChangeSuccess: String { return L10n.tr("Localizable", "screen_recovery_key_change_success") }
/// Change recovery key?
public static var screenRecoveryKeyChangeTitle: String { return L10n.tr("Localizable", "screen_recovery_key_change_title") }
/// Enter your recovery key to confirm access to your chat backup.
public static var screenRecoveryKeyConfirmDescription: String { return L10n.tr("Localizable", "screen_recovery_key_confirm_description") }
/// Enter the 48 character code.
public static var screenRecoveryKeyConfirmKeyDescription: String { return L10n.tr("Localizable", "screen_recovery_key_confirm_key_description") }
/// Enter...
public static var screenRecoveryKeyConfirmKeyPlaceholder: String { return L10n.tr("Localizable", "screen_recovery_key_confirm_key_placeholder") }
/// Recovery key confirmed
public static var screenRecoveryKeyConfirmSuccess: String { return L10n.tr("Localizable", "screen_recovery_key_confirm_success") }
/// Confirm your recovery key
public static var screenRecoveryKeyConfirmTitle: String { return L10n.tr("Localizable", "screen_recovery_key_confirm_title") }
/// Save recovery key
public static var screenRecoveryKeySaveAction: String { return L10n.tr("Localizable", "screen_recovery_key_save_action") }
/// Write down your recovery key somewhere safe or save it in a password manager.
public static var screenRecoveryKeySaveDescription: String { return L10n.tr("Localizable", "screen_recovery_key_save_description") }
/// Tap to copy recovery key
public static var screenRecoveryKeySaveKeyDescription: String { return L10n.tr("Localizable", "screen_recovery_key_save_key_description") }
/// Save your recovery key
public static var screenRecoveryKeySaveTitle: String { return L10n.tr("Localizable", "screen_recovery_key_save_title") }
/// You will not be able to access your new recovery key after this step.
public static var screenRecoveryKeySetupConfirmationDescription: String { return L10n.tr("Localizable", "screen_recovery_key_setup_confirmation_description") }
/// Have you saved your recovery key?
public static var screenRecoveryKeySetupConfirmationTitle: String { return L10n.tr("Localizable", "screen_recovery_key_setup_confirmation_title") }
/// Your chat backup is protected by a recovery key. If you need a new recovery key after setup you can recreate by selecting ‘Change recovery key’.
public static var screenRecoveryKeySetupDescription: String { return L10n.tr("Localizable", "screen_recovery_key_setup_description") }
/// Generate your recovery key
public static var screenRecoveryKeySetupGenerateKey: String { return L10n.tr("Localizable", "screen_recovery_key_setup_generate_key") }
/// Make sure you can store your recovery key somewhere safe
public static var screenRecoveryKeySetupGenerateKeyDescription: String { return L10n.tr("Localizable", "screen_recovery_key_setup_generate_key_description") }
/// Recovery setup successful
public static var screenRecoveryKeySetupSuccess: String { return L10n.tr("Localizable", "screen_recovery_key_setup_success") }
/// Set up recovery
public static var screenRecoveryKeySetupTitle: String { return L10n.tr("Localizable", "screen_recovery_key_setup_title") }
/// Block user
public static var screenReportContentBlockUser: String { return L10n.tr("Localizable", "screen_report_content_block_user") }
/// Check if you want to hide all current and future messages from this user
@@ -1064,6 +1156,10 @@ public enum L10n {
}
/// You’re signed out
public static var screenSignedOutTitle: String { return L10n.tr("Localizable", "screen_signed_out_title") }
/// Please wait for this to complete before signing out.
public static var screenSignoutBackingUpSubtitle: String { return L10n.tr("Localizable", "screen_signout_backing_up_subtitle") }
/// Your keys are still being backed up
public static var screenSignoutBackingUpTitle: String { return L10n.tr("Localizable", "screen_signout_backing_up_title") }
/// Are you sure you want to sign out?
public static var screenSignoutConfirmationDialogContent: String { return L10n.tr("Localizable", "screen_signout_confirmation_dialog_content") }
/// Sign out
@@ -1072,6 +1168,10 @@ public enum L10n {
public static var screenSignoutConfirmationDialogTitle: String { return L10n.tr("Localizable", "screen_signout_confirmation_dialog_title") }
/// Signing out…
public static var screenSignoutInProgressDialogContent: String { return L10n.tr("Localizable", "screen_signout_in_progress_dialog_content") }
/// You are about to sign out of your last session. If you sign out now, you might lose access to your encrypted messages.
public static var screenSignoutLastSessionSubtitle: String { return L10n.tr("Localizable", "screen_signout_last_session_subtitle") }
/// Have you saved your recovery key?
public static var screenSignoutLastSessionTitle: String { return L10n.tr("Localizable", "screen_signout_last_session_title") }
/// Sign out
public static var screenSignoutPreferenceItem: String { return L10n.tr("Localizable", "screen_signout_preference_item") }
/// An error occurred when trying to start a chat
@@ -2241,6 +2241,91 @@ class RoomTimelineProviderMock: RoomTimelineProviderProtocol {
var underlyingBackPaginationState: BackPaginationStatus!
}
class SecureBackupControllerMock: SecureBackupControllerProtocol {
var recoveryKeyState: CurrentValuePublisher<SecureBackupRecoveryKeyState, Never> {
get { return underlyingRecoveryKeyState }
set(value) { underlyingRecoveryKeyState = value }
}
var underlyingRecoveryKeyState: CurrentValuePublisher<SecureBackupRecoveryKeyState, Never>!
var keyBackupState: CurrentValuePublisher<SecureBackupKeyBackupState, Never> {
get { return underlyingKeyBackupState }
set(value) { underlyingKeyBackupState = value }
}
var underlyingKeyBackupState: CurrentValuePublisher<SecureBackupKeyBackupState, Never>!
//MARK: - enableBackup
var enableBackupCallsCount = 0
var enableBackupCalled: Bool {
return enableBackupCallsCount > 0
}
var enableBackupReturnValue: Result<Void, SecureBackupControllerError>!
var enableBackupClosure: (() async -> Result<Void, SecureBackupControllerError>)?
func enableBackup() async -> Result<Void, SecureBackupControllerError> {
enableBackupCallsCount += 1
if let enableBackupClosure = enableBackupClosure {
return await enableBackupClosure()
} else {
return enableBackupReturnValue
}
}
//MARK: - disableBackup
var disableBackupCallsCount = 0
var disableBackupCalled: Bool {
return disableBackupCallsCount > 0
}
var disableBackupReturnValue: Result<Void, SecureBackupControllerError>!
var disableBackupClosure: (() async -> Result<Void, SecureBackupControllerError>)?
func disableBackup() async -> Result<Void, SecureBackupControllerError> {
disableBackupCallsCount += 1
if let disableBackupClosure = disableBackupClosure {
return await disableBackupClosure()
} else {
return disableBackupReturnValue
}
}
//MARK: - generateRecoveryKey
var generateRecoveryKeyCallsCount = 0
var generateRecoveryKeyCalled: Bool {
return generateRecoveryKeyCallsCount > 0
}
var generateRecoveryKeyReturnValue: Result<String, SecureBackupControllerError>!
var generateRecoveryKeyClosure: (() async -> Result<String, SecureBackupControllerError>)?
func generateRecoveryKey() async -> Result<String, SecureBackupControllerError> {
generateRecoveryKeyCallsCount += 1
if let generateRecoveryKeyClosure = generateRecoveryKeyClosure {
return await generateRecoveryKeyClosure()
} else {
return generateRecoveryKeyReturnValue
}
}
//MARK: - confirmRecoveryKey
var confirmRecoveryKeyCallsCount = 0
var confirmRecoveryKeyCalled: Bool {
return confirmRecoveryKeyCallsCount > 0
}
var confirmRecoveryKeyReceivedKey: String?
var confirmRecoveryKeyReceivedInvocations: [String] = []
var confirmRecoveryKeyReturnValue: Result<Void, SecureBackupControllerError>!
var confirmRecoveryKeyClosure: ((String) async -> Result<Void, SecureBackupControllerError>)?
func confirmRecoveryKey(_ key: String) async -> Result<Void, SecureBackupControllerError> {
confirmRecoveryKeyCallsCount += 1
confirmRecoveryKeyReceivedKey = key
confirmRecoveryKeyReceivedInvocations.append(key)
if let confirmRecoveryKeyClosure = confirmRecoveryKeyClosure {
return await confirmRecoveryKeyClosure(key)
} else {
return confirmRecoveryKeyReturnValue
}
}
}
class SessionVerificationControllerProxyMock: SessionVerificationControllerProxyProtocol {
var callbacks: PassthroughSubject<SessionVerificationControllerProxyCallback, Never> {
get { return underlyingCallbacks }
@@ -170,6 +170,7 @@ struct A11yIdentifiers {
struct SettingsScreen {
let done = "settings-done"
let account = "settings-account"
let secureBackup = "settings-secure_backup"
let notifications = "settings-notifications"
let analytics = "settings-analytics"
let reportBug = "settings-report_bug"
@@ -0,0 +1,87 @@
//
// Copyright 2023 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Compound
import SwiftUI
struct BadgeView: View {
let size: Double
var body: some View {
Circle()
.fill(.compound.iconCriticalPrimary)
.frame(width: size, height: size)
}
}
struct BadgeViewModifier: ViewModifier {
let size: Double
func body(content: Content) -> some View {
content.mask {
Rectangle()
.fill(.white)
.overlay(alignment: .topTrailing) {
Circle()
.fill(.black)
.frame(width: maskSize, height: maskSize)
.offset(maskOffset)
}
.compositingGroup()
.luminanceToAlpha()
}
.overlay(alignment: .topTrailing) {
BadgeView(size: size)
}
}
private var maskSize: Double {
size * 1.25
}
private var maskOffset: CGSize {
.init(width: (maskSize - size) / 2, height: -(maskSize - size) / 2)
}
}
extension View {
@ViewBuilder
func overlayBadge(_ size: Double, isBadged: Bool = true) -> some View {
if isBadged {
modifier(BadgeViewModifier(size: size))
} else {
self
}
}
}
struct BadgeView_Previews: PreviewProvider {
static let circleGradient = LinearGradient(colors: [.green, .orange],
startPoint: .topLeading,
endPoint: .bottomTrailing)
static let screenGradient = LinearGradient(colors: [.pink, .blue],
startPoint: .top,
endPoint: .bottom)
static var previews: some View {
Circle()
.fill(circleGradient)
.saturation(2.0)
.frame(width: 100, height: 100)
.overlayBadge(40)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background { screenGradient.opacity(0.3).ignoresSafeArea() }
}
}
@@ -16,8 +16,8 @@
import SwiftUI
/// An image that is styled for use as the screen icon in the onboarding flow.
struct AuthenticationIconImage: View {
/// An image that is styled for use as the main/top/hero screen icon.
struct HeroImage: View {
/// The icon that is shown.
let image: Image
/// The amount of padding between the icon and the borders. Defaults to 16.
@@ -41,11 +41,11 @@ struct AuthenticationIconImage: View {
// MARK: - Previews
struct AuthenticationIconImage_Previews: PreviewProvider, TestablePreview {
struct HeroImage_Previews: PreviewProvider, TestablePreview {
static var previews: some View {
HStack(spacing: 20) {
AuthenticationIconImage(image: Image(asset: Asset.Images.serverSelectionIcon), insets: 19)
AuthenticationIconImage(image: Image(systemName: "hourglass"))
HeroImage(image: Image(asset: Asset.Images.serverSelectionIcon), insets: 19)
HeroImage(image: Image(systemName: "hourglass"))
}
}
}
@@ -42,7 +42,7 @@ struct AnalyticsPromptScreen: View {
private var header: some View {
VStack(spacing: 8) {
AuthenticationIconImage(image: Image(systemName: "chart.bar"))
HeroImage(image: Image(systemName: "chart.bar"))
.symbolVariant(.fill)
.padding(.bottom, 8)
@@ -25,7 +25,7 @@ struct AppLockScreen: View {
var body: some View {
FullscreenDialog {
VStack(spacing: 8) {
AuthenticationIconImage(image: Image(systemSymbol: .lock))
HeroImage(image: Image(systemSymbol: .lock))
.symbolVariant(.fill)
.padding(.bottom, 8)
@@ -52,7 +52,7 @@ struct LoginScreen: View {
/// The header containing the title and icon.
var header: some View {
VStack(spacing: 8) {
AuthenticationIconImage(image: Image(systemName: "lock.fill"))
HeroImage(image: Image(systemName: "lock.fill"))
.padding(.bottom, 8)
Text(L10n.screenLoginTitleWithHomeserver(context.viewState.homeserver.address))
@@ -35,7 +35,7 @@ struct ServerConfirmationScreen: View {
/// The main content of the view to be shown in a scroll view.
var header: some View {
VStack(spacing: 8) {
AuthenticationIconImage(image: Image(systemName: "person.crop.circle.fill"))
HeroImage(image: Image(systemName: "person.crop.circle.fill"))
.padding(.bottom, 8)
Text(context.viewState.title)
@@ -40,7 +40,7 @@ struct ServerSelectionScreen: View {
/// The title, message and icon at the top of the screen.
var header: some View {
VStack(spacing: 8) {
AuthenticationIconImage(image: Image(asset: Asset.Images.serverSelectionIcon), insets: 19)
HeroImage(image: Image(asset: Asset.Images.serverSelectionIcon), insets: 19)
.padding(.bottom, 8)
Text(L10n.screenChangeServerTitle)
@@ -71,6 +71,8 @@ struct HomeScreenViewState: BindableState {
var userDisplayName: String?
var userAvatarURL: URL?
var showSessionVerificationBanner = false
var showUserMenuBadge = false
var showSettingsMenuOptionBadge = false
var rooms: [HomeScreenRoom] = []
var roomListMode: HomeScreenRoomListMode = .skeletons
@@ -53,7 +53,7 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
roomSummaryProvider = userSession.clientProxy.roomSummaryProvider
inviteSummaryProvider = userSession.clientProxy.inviteSummaryProvider
super.init(initialViewState: HomeScreenViewState(userID: userSession.userID),
super.init(initialViewState: .init(userID: userSession.userID),
imageProvider: userSession.mediaProvider)
userSession.callbacks
@@ -80,6 +80,19 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
.weakAssign(to: \.state.userDisplayName, on: self)
.store(in: &cancellables)
userSession.clientProxy.secureBackupController.recoveryKeyState
.receive(on: DispatchQueue.main)
.map { state in
state == .unknown || state == .disabled
}
.sink { [weak self] requiresSecureBackupSetup in
guard let self else { return }
state.showUserMenuBadge = requiresSecureBackupSetup && appSettings.chatBackupEnabled
state.showSettingsMenuOptionBadge = requiresSecureBackupSetup && appSettings.chatBackupEnabled
}
.store(in: &cancellables)
selectedRoomPublisher
.weakAssign(to: \.state.selectedRoomID, on: self)
.store(in: &cancellables)
@@ -18,6 +18,7 @@ import SwiftUI
struct HomeScreenUserMenuButton: View {
@State private var showingLogoutConfirmation = false
@Environment(\.colorScheme) var colorScheme
@ObservedObject var context: HomeScreenViewModel.Context
@@ -27,7 +28,11 @@ struct HomeScreenUserMenuButton: View {
Button {
context.send(viewAction: .userMenu(action: .settings))
} label: {
Label(L10n.commonSettings, systemImage: "gearshape")
Label {
Text(L10n.commonSettings)
} icon: {
settingsIconImage
}
}
.accessibilityIdentifier(A11yIdentifiers.homeScreen.settings)
}
@@ -55,6 +60,8 @@ struct HomeScreenUserMenuButton: View {
avatarSize: .user(on: .home),
imageProvider: context.imageProvider)
.accessibilityIdentifier(A11yIdentifiers.homeScreen.userAvatar)
.overlayBadge(10, isBadged: context.viewState.showUserMenuBadge)
.compositingGroup()
}
.alert(L10n.screenSignoutConfirmationDialogTitle,
isPresented: $showingLogoutConfirmation) {
@@ -67,4 +74,24 @@ struct HomeScreenUserMenuButton: View {
}
.accessibilityLabel(L10n.a11yUserMenu)
}
// MARK: - Private
/// Menu doesn't render composed views. Trick it into showing a badge.
private var settingsIconImage: Image? {
let settingsIcon = Image(systemSymbol: .gearshape)
.resizable()
.frame(width: 100, height: 100)
.overlayBadge(40, isBadged: context.viewState.showSettingsMenuOptionBadge)
.colorScheme(colorScheme)
.padding()
let renderer = ImageRenderer(content: settingsIcon)
guard let image = renderer.uiImage else {
return nil
}
return Image(uiImage: image)
}
}
@@ -88,7 +88,7 @@ class RoomDetailsEditScreenViewModel: RoomDetailsEditScreenViewModelType, RoomDe
case .success(.image):
state.localMedia = try? mediaResult.get()
case .failure, .success:
userIndicatorController?.alertInfo = .init(id: .init(), title: L10n.commonError, message: L10n.errorUnknown)
userIndicatorController?.alertInfo = .init(id: .init())
}
}
}
@@ -0,0 +1,62 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Combine
import SwiftUI
struct SecureBackupKeyBackupScreenCoordinatorParameters {
let secureBackupController: SecureBackupControllerProtocol
weak var userIndicatorController: UserIndicatorControllerProtocol?
}
enum SecureBackupKeyBackupScreenCoordinatorAction {
case done
}
final class SecureBackupKeyBackupScreenCoordinator: CoordinatorProtocol {
private let parameters: SecureBackupKeyBackupScreenCoordinatorParameters
private var viewModel: SecureBackupKeyBackupScreenViewModelProtocol
private let actionsSubject: PassthroughSubject<SecureBackupKeyBackupScreenCoordinatorAction, Never> = .init()
private var cancellables = Set<AnyCancellable>()
var actions: AnyPublisher<SecureBackupKeyBackupScreenCoordinatorAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
init(parameters: SecureBackupKeyBackupScreenCoordinatorParameters) {
self.parameters = parameters
viewModel = SecureBackupKeyBackupScreenViewModel(secureBackupController: parameters.secureBackupController,
userIndicatorController: parameters.userIndicatorController)
}
func start() {
viewModel.actions.sink { [weak self] action in
MXLog.info("Coordinator: received view model action: \(action)")
guard let self else { return }
switch action {
case .done:
self.actionsSubject.send(.done)
}
}
.store(in: &cancellables)
}
func toPresentable() -> AnyView {
AnyView(SecureBackupKeyBackupScreen(context: viewModel.context))
}
}
@@ -0,0 +1,41 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
enum SecureBackupKeyBackupScreenViewModelAction {
case done
}
enum SecureBackupKeyBackupScreenViewMode {
case disableBackup
}
struct SecureBackupKeyBackupScreenViewState: BindableState {
let mode: SecureBackupKeyBackupScreenViewMode
var bindings = SecureBackupKeyBackupScreenViewStateBindings()
}
struct SecureBackupKeyBackupScreenViewStateBindings {
var alertInfo: AlertInfo<UUID>?
}
var alertInfo: AlertInfo<UUID>?
enum SecureBackupKeyBackupScreenViewAction {
case cancel
case toggleBackup
}
@@ -0,0 +1,107 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Combine
import SwiftUI
typealias SecureBackupKeyBackupScreenViewModelType = StateStoreViewModel<SecureBackupKeyBackupScreenViewState, SecureBackupKeyBackupScreenViewAction>
class SecureBackupKeyBackupScreenViewModel: SecureBackupKeyBackupScreenViewModelType, SecureBackupKeyBackupScreenViewModelProtocol {
private let secureBackupController: SecureBackupControllerProtocol
private var actionsSubject: PassthroughSubject<SecureBackupKeyBackupScreenViewModelAction, Never> = .init()
var actions: AnyPublisher<SecureBackupKeyBackupScreenViewModelAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
init(secureBackupController: SecureBackupControllerProtocol, userIndicatorController: UserIndicatorControllerProtocol?) {
self.secureBackupController = secureBackupController
super.init(initialViewState: .init(mode: secureBackupController.keyBackupState.value.viewMode))
secureBackupController.keyBackupState
.receive(on: DispatchQueue.main)
.sink { [weak userIndicatorController] state in
let loadingIndicatorIdentifier = "SecureBackupKeyBackupScreenLoading"
switch state {
case .disabling, .enabling, .unknown:
userIndicatorController?.submitIndicator(.init(id: loadingIndicatorIdentifier, type: .modal, title: L10n.commonLoading, persistent: true))
default:
userIndicatorController?.retractIndicatorWithId(loadingIndicatorIdentifier)
}
}
.store(in: &cancellables)
}
// MARK: - Public
override func process(viewAction: SecureBackupKeyBackupScreenViewAction) {
MXLog.info("View model: received view action: \(viewAction)")
switch viewAction {
case .cancel:
actionsSubject.send(.done)
case .toggleBackup:
guard secureBackupController.keyBackupState.value == .enabled else {
fatalError("Tried disabling backup when not enabled")
}
state.bindings.alertInfo = .init(id: .init(),
title: L10n.screenKeyBackupDisableConfirmationTitle,
message: L10n.screenKeyBackupDisableConfirmationDescription,
primaryButton: .init(title: L10n.screenKeyBackupDisableConfirmationActionTurnOff, role: .destructive) { [weak self] in
self?.disableBackup()
},
secondaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil))
}
}
// MARK: - Private
private func enableBackup() {
Task {
switch await secureBackupController.enableBackup() {
case .success:
actionsSubject.send(.done)
case .failure(let error):
MXLog.error("Failed enabling key backup with error: \(error)")
state.bindings.alertInfo = .init(id: .init())
}
}
}
private func disableBackup() {
Task {
switch await secureBackupController.disableBackup() {
case .success:
actionsSubject.send(.done)
case .failure(let error):
MXLog.error("Failed disabling key backup with error: \(error)")
state.bindings.alertInfo = .init(id: .init())
}
}
}
}
extension SecureBackupKeyBackupState {
var viewMode: SecureBackupKeyBackupScreenViewMode {
guard self == .enabled else {
fatalError("Invalid key backup state")
}
return .disableBackup
}
}
@@ -0,0 +1,23 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Combine
@MainActor
protocol SecureBackupKeyBackupScreenViewModelProtocol {
var actions: AnyPublisher<SecureBackupKeyBackupScreenViewModelAction, Never> { get }
var context: SecureBackupKeyBackupScreenViewModelType.Context { get }
}
@@ -0,0 +1,117 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Combine
import Compound
import SwiftUI
struct SecureBackupKeyBackupScreen: View {
@ObservedObject var context: SecureBackupKeyBackupScreenViewModel.Context
@ScaledMetric private var iconSize = 70
var body: some View {
mainContent
.padding()
.interactiveDismissDisabled()
.toolbar { toolbar }
.background(Color.compound.bgCanvasDefault.ignoresSafeArea())
.alert(item: $context.alertInfo)
}
@ViewBuilder
private var mainContent: some View {
switch context.viewState.mode {
case .disableBackup:
disableBackupSection
}
}
private var disableBackupSection: some View {
VStack(spacing: 16) {
HeroImage(image: Image(asset: Asset.Images.secureBackupOff))
Text(L10n.screenKeyBackupDisableTitle)
.foregroundColor(.compound.textPrimary)
.font(.compound.headingMDBold)
.multilineTextAlignment(.center)
Text(L10n.screenKeyBackupDisableDescription)
.foregroundColor(.compound.textSecondary)
.font(.compound.bodyMD)
.multilineTextAlignment(.center)
VStack(alignment: .leading) {
Label {
Text(L10n.screenKeyBackupDisableDescriptionPoint1)
.foregroundColor(.compound.textSecondary)
.font(.compound.bodyMD)
} icon: {
CompoundIcon(\.close)
.foregroundColor(.compound.iconCriticalPrimary)
}
Label {
Text(L10n.screenKeyBackupDisableDescriptionPoint2(InfoPlistReader.main.bundleDisplayName))
.foregroundColor(.compound.textSecondary)
.font(.compound.bodyMD)
} icon: {
CompoundIcon(\.close)
.foregroundColor(.compound.iconCriticalPrimary)
}
}
Spacer()
Button(role: .destructive) {
context.send(viewAction: .toggleBackup)
} label: {
Text(L10n.screenChatBackupKeyBackupActionDisable)
}
.buttonStyle(.compound(.primary))
}
}
@ToolbarContentBuilder
private var toolbar: some ToolbarContent {
ToolbarItem(placement: .cancellationAction) {
Button(L10n.actionCancel) {
context.send(viewAction: .cancel)
}
}
}
}
// MARK: - Previews
struct SecureBackupKeyBackupScreen_Previews: PreviewProvider, TestablePreview {
static let setupViewModel = viewModel(keyBackupState: .enabled)
static var previews: some View {
NavigationStack {
SecureBackupKeyBackupScreen(context: setupViewModel.context)
}
.previewDisplayName("Set up")
}
static func viewModel(keyBackupState: SecureBackupKeyBackupState) -> SecureBackupKeyBackupScreenViewModelType {
let backupController = SecureBackupControllerMock()
backupController.underlyingKeyBackupState = CurrentValueSubject<SecureBackupKeyBackupState, Never>(keyBackupState).asCurrentValuePublisher()
return SecureBackupKeyBackupScreenViewModel(secureBackupController: backupController,
userIndicatorController: nil)
}
}
@@ -0,0 +1,74 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Combine
import SwiftUI
struct SecureBackupRecoveryKeyScreenCoordinatorParameters {
let secureBackupController: SecureBackupControllerProtocol
weak var userIndicatorController: UserIndicatorControllerProtocol?
}
enum SecureBackupRecoveryKeyScreenCoordinatorAction {
case cancel
case recoverySetUp
case recoveryChanged
case recoveryFixed
}
final class SecureBackupRecoveryKeyScreenCoordinator: CoordinatorProtocol {
private let parameters: SecureBackupRecoveryKeyScreenCoordinatorParameters
private var viewModel: SecureBackupRecoveryKeyScreenViewModelProtocol
private let actionsSubject: PassthroughSubject<SecureBackupRecoveryKeyScreenCoordinatorAction, Never> = .init()
private var cancellables = Set<AnyCancellable>()
var actions: AnyPublisher<SecureBackupRecoveryKeyScreenCoordinatorAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
init(parameters: SecureBackupRecoveryKeyScreenCoordinatorParameters) {
self.parameters = parameters
viewModel = SecureBackupRecoveryKeyScreenViewModel(secureBackupController: parameters.secureBackupController,
userIndicatorController: parameters.userIndicatorController)
}
func start() {
viewModel.actions.sink { [weak self] action in
MXLog.info("Coordinator: received view model action: \(action)")
guard let self else { return }
switch action {
case .cancel:
self.actionsSubject.send(.cancel)
case .done(let mode):
switch mode {
case .setupRecovery:
self.actionsSubject.send(.recoverySetUp)
case .changeRecovery:
self.actionsSubject.send(.recoveryChanged)
case .fixRecovery:
self.actionsSubject.send(.recoveryFixed)
}
}
}
.store(in: &cancellables)
}
func toPresentable() -> AnyView {
AnyView(SecureBackupRecoveryKeyScreen(context: viewModel.context))
}
}
@@ -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 Foundation
enum SecureBackupRecoveryKeyScreenViewModelAction {
case done(mode: SecureBackupRecoveryKeyScreenViewMode)
case cancel
}
enum SecureBackupRecoveryKeyScreenViewMode {
case setupRecovery
case changeRecovery
case fixRecovery
}
struct SecureBackupRecoveryKeyScreenViewState: BindableState {
let mode: SecureBackupRecoveryKeyScreenViewMode
var recoveryKey: String?
var doneButtonEnabled = false
var bindings: SecureBackupRecoveryKeyScreenViewBindings
var title: String {
switch mode {
case .setupRecovery:
return recoveryKey == nil ? L10n.screenRecoveryKeySetupTitle : L10n.screenRecoveryKeySaveTitle
case .changeRecovery:
return recoveryKey == nil ? L10n.screenRecoveryKeyChangeTitle : L10n.screenRecoveryKeySaveTitle
case .fixRecovery:
return L10n.screenRecoveryKeyConfirmTitle
}
}
var subtitle: String {
switch mode {
case .setupRecovery:
return recoveryKey == nil ? L10n.screenRecoveryKeySetupDescription : L10n.screenRecoveryKeySaveDescription
case .changeRecovery:
return recoveryKey == nil ? L10n.screenRecoveryKeyChangeDescription : L10n.screenRecoveryKeySaveDescription
case .fixRecovery:
return L10n.screenRecoveryKeyConfirmDescription
}
}
var recoveryKeySubtitle: String {
switch mode {
case .setupRecovery:
return recoveryKey == nil ? L10n.screenRecoveryKeySetupGenerateKeyDescription : L10n.screenRecoveryKeySaveKeyDescription
case .changeRecovery:
return recoveryKey == nil ? L10n.screenRecoveryKeyChangeGenerateKeyDescription : L10n.screenRecoveryKeySaveKeyDescription
case .fixRecovery:
return L10n.screenRecoveryKeyConfirmKeyDescription
}
}
}
struct SecureBackupRecoveryKeyScreenViewBindings {
var confirmationRecoveryKey = ""
var alertInfo: AlertInfo<UUID>?
}
enum SecureBackupRecoveryKeyScreenViewAction {
case generateKey
case copyKey
case keySaved
case confirmKey
case done
case cancel
}
@@ -0,0 +1,111 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Combine
import SwiftUI
typealias SecureBackupRecoveryKeyScreenViewModelType = StateStoreViewModel<SecureBackupRecoveryKeyScreenViewState, SecureBackupRecoveryKeyScreenViewAction>
class SecureBackupRecoveryKeyScreenViewModel: SecureBackupRecoveryKeyScreenViewModelType, SecureBackupRecoveryKeyScreenViewModelProtocol {
private let secureBackupController: SecureBackupControllerProtocol
private weak var userIndicatorController: UserIndicatorControllerProtocol?
private var actionsSubject: PassthroughSubject<SecureBackupRecoveryKeyScreenViewModelAction, Never> = .init()
var actions: AnyPublisher<SecureBackupRecoveryKeyScreenViewModelAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
init(secureBackupController: SecureBackupControllerProtocol, userIndicatorController: UserIndicatorControllerProtocol?) {
self.secureBackupController = secureBackupController
self.userIndicatorController = userIndicatorController
super.init(initialViewState: .init(mode: secureBackupController.recoveryKeyState.value.viewMode, bindings: .init()))
secureBackupController.recoveryKeyState
.receive(on: DispatchQueue.main)
.sink(receiveValue: { [weak userIndicatorController] state in
let loadingIndicatorIdentifier = "SecureBackupRecoveryKeyScreenLoading"
switch state {
case .settingUp:
userIndicatorController?.submitIndicator(.init(id: loadingIndicatorIdentifier, type: .modal, title: L10n.commonLoading, persistent: true))
default:
userIndicatorController?.retractIndicatorWithId(loadingIndicatorIdentifier)
}
})
.store(in: &cancellables)
}
// MARK: - Public
override func process(viewAction: SecureBackupRecoveryKeyScreenViewAction) {
MXLog.info("View model: received view action: \(viewAction)")
switch viewAction {
case .generateKey:
Task {
switch await secureBackupController.generateRecoveryKey() {
case .success(let key):
state.recoveryKey = key
case .failure(let error):
MXLog.error("Failed generating recovery key with error: \(error)")
state.bindings.alertInfo = .init(id: .init())
}
}
case .copyKey:
UIPasteboard.general.string = state.recoveryKey
userIndicatorController?.submitIndicator(.init(title: "Copied recovery key"))
state.doneButtonEnabled = true
case .keySaved:
state.doneButtonEnabled = true
case .confirmKey:
Task {
switch await secureBackupController.confirmRecoveryKey(state.bindings.confirmationRecoveryKey) {
case .success:
actionsSubject.send(.done(mode: context.viewState.mode))
case .failure(let error):
MXLog.error("Failed confirming recovery key with error: \(error)")
state.bindings.alertInfo = .init(id: .init())
}
}
case .cancel:
actionsSubject.send(.cancel)
case .done:
state.bindings.alertInfo = .init(id: .init(),
title: L10n.screenRecoveryKeySetupConfirmationTitle,
message: L10n.screenRecoveryKeySetupConfirmationDescription,
primaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil),
secondaryButton: .init(title: L10n.actionContinue, action: { [weak self] in
guard let self else { return }
actionsSubject.send(.done(mode: context.viewState.mode))
}))
}
}
}
extension SecureBackupRecoveryKeyState {
var viewMode: SecureBackupRecoveryKeyScreenViewMode {
switch self {
case .disabled:
return .setupRecovery
case .enabled:
return .changeRecovery
case .incomplete:
return .fixRecovery
default:
fatalError("Invalid recovery state")
}
}
}
@@ -0,0 +1,23 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Combine
@MainActor
protocol SecureBackupRecoveryKeyScreenViewModelProtocol {
var actions: AnyPublisher<SecureBackupRecoveryKeyScreenViewModelAction, Never> { get }
var context: SecureBackupRecoveryKeyScreenViewModelType.Context { get }
}
@@ -0,0 +1,213 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Combine
import Compound
import SwiftUI
struct SecureBackupRecoveryKeyScreen: View {
@ObservedObject var context: SecureBackupRecoveryKeyScreenViewModel.Context
@ScaledMetric private var iconSize = 70
var body: some View {
mainContent
.padding()
.interactiveDismissDisabled()
.toolbar { toolbar }
.toolbar(.visible, for: .navigationBar)
.background(Color.compound.bgCanvasDefault.ignoresSafeArea())
.alert(item: $context.alertInfo)
}
@ViewBuilder
private var mainContent: some View {
VStack(spacing: 48) {
switch context.viewState.mode {
case .setupRecovery, .changeRecovery:
header
newRecoveryKeySection
case .fixRecovery:
header
confirmRecoveryKeySection
}
}
}
@ToolbarContentBuilder
private var toolbar: some ToolbarContent {
if context.viewState.recoveryKey == nil {
ToolbarItem(placement: .cancellationAction) {
Button(L10n.actionCancel) {
context.send(viewAction: .cancel)
}
}
}
}
private var header: some View {
VStack(spacing: 16) {
HeroImage(image: Image(asset: Asset.Images.secureBackupOn))
Text(context.viewState.title)
.foregroundColor(.compound.textPrimary)
.font(.compound.headingMDBold)
.multilineTextAlignment(.center)
Text(context.viewState.subtitle)
.foregroundColor(.compound.textSecondary)
.font(.compound.bodyMD)
.multilineTextAlignment(.center)
}
}
@ViewBuilder
private var newRecoveryKeySection: some View {
generateRecoveryKeySection
Spacer()
if let recoveryKey = context.viewState.recoveryKey {
ShareLink(item: recoveryKey) {
Label(L10n.screenRecoveryKeySaveAction, icon: \.download)
}
.buttonStyle(.compound(.primary))
.simultaneousGesture(TapGesture().onEnded { _ in
context.send(viewAction: .keySaved)
})
}
Button {
context.send(viewAction: .done)
} label: {
Text(L10n.actionDone)
}
.buttonStyle(.compound(.primary))
.disabled(context.viewState.recoveryKey == nil || context.viewState.doneButtonEnabled == false)
}
private var generateRecoveryKeySection: some View {
VStack(alignment: .leading) {
Text(L10n.commonRecoveryKey)
.foregroundColor(.compound.textPrimary)
.font(.compound.bodySM)
HStack {
if context.viewState.recoveryKey == nil {
Button(generateButtonTitle) {
context.send(viewAction: .generateKey)
}
.font(.compound.bodyLGSemibold)
} else {
HStack(alignment: .top) {
Text(context.viewState.recoveryKey ?? "")
.foregroundColor(.compound.textPrimary)
.font(.compound.bodyLG)
Spacer()
Button {
context.send(viewAction: .copyKey)
} label: {
Image(asset: Asset.Images.copy)
}
.tint(.compound.iconSecondary)
.accessibilityLabel(L10n.actionCopy)
}
}
}
.frame(maxWidth: .infinity)
.padding()
.background(Color.compound.bgSubtleSecondaryLevel0)
.clipShape(RoundedRectangle(cornerRadius: 8))
HStack(alignment: .top) {
if context.viewState.recoveryKey == nil {
CompoundIcon(\.info, size: .small, relativeTo: .compound.bodySM)
}
Text(context.viewState.recoveryKeySubtitle)
.foregroundColor(.compound.textSecondary)
.font(.compound.bodySM)
}
}
}
private var generateButtonTitle: String {
context.viewState.mode == .setupRecovery ? L10n.screenRecoveryKeySetupGenerateKey : L10n.screenRecoveryKeyChangeGenerateKey
}
@ViewBuilder
private var confirmRecoveryKeySection: some View {
VStack(alignment: .leading) {
Text(L10n.commonRecoveryKey)
.foregroundColor(.compound.textPrimary)
.font(.compound.bodySM)
TextField(L10n.screenRecoveryKeyConfirmKeyPlaceholder, text: $context.confirmationRecoveryKey, axis: .vertical)
.frame(maxWidth: .infinity)
.padding()
.background(Color.compound.bgSubtleSecondaryLevel0)
.clipShape(RoundedRectangle(cornerRadius: 8))
Text(context.viewState.recoveryKeySubtitle)
.foregroundColor(.compound.textSecondary)
.font(.compound.bodySM)
Spacer()
Button {
context.send(viewAction: .confirmKey)
} label: {
Text(L10n.actionConfirm)
}
.buttonStyle(.compound(.primary))
.disabled(context.confirmationRecoveryKey.isEmpty)
}
}
}
// MARK: - Previews
struct SecureBackupRecoveryKeyScreen_Previews: PreviewProvider, TestablePreview {
static let setupViewModel = viewModel(recoveryKeyState: .enabled)
static let notSetUpViewModel = viewModel(recoveryKeyState: .disabled)
static let incompleteViewModel = viewModel(recoveryKeyState: .incomplete)
static var previews: some View {
NavigationStack {
SecureBackupRecoveryKeyScreen(context: notSetUpViewModel.context)
}
.previewDisplayName("Not set up")
NavigationStack {
SecureBackupRecoveryKeyScreen(context: setupViewModel.context)
}
.previewDisplayName("Set up")
NavigationStack {
SecureBackupRecoveryKeyScreen(context: incompleteViewModel.context)
}
.previewDisplayName("Incomplete")
}
static func viewModel(recoveryKeyState: SecureBackupRecoveryKeyState) -> SecureBackupRecoveryKeyScreenViewModelType {
let backupController = SecureBackupControllerMock()
backupController.underlyingRecoveryKeyState = CurrentValueSubject<SecureBackupRecoveryKeyState, Never>(recoveryKeyState).asCurrentValuePublisher()
return SecureBackupRecoveryKeyScreenViewModel(secureBackupController: backupController, userIndicatorController: nil)
}
}
@@ -0,0 +1,116 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Combine
import SwiftUI
struct SecureBackupScreenCoordinatorParameters {
let appSettings: AppSettings
let secureBackupController: SecureBackupControllerProtocol
weak var navigationStackCoordinator: NavigationStackCoordinator?
weak var userIndicatorController: UserIndicatorControllerProtocol?
}
enum SecureBackupScreenCoordinatorAction { }
final class SecureBackupScreenCoordinator: CoordinatorProtocol {
private let parameters: SecureBackupScreenCoordinatorParameters
private var viewModel: SecureBackupScreenViewModelProtocol
private let actionsSubject: PassthroughSubject<SecureBackupScreenCoordinatorAction, Never> = .init()
private var cancellables = Set<AnyCancellable>()
var actions: AnyPublisher<SecureBackupScreenCoordinatorAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
init(parameters: SecureBackupScreenCoordinatorParameters) {
self.parameters = parameters
viewModel = SecureBackupScreenViewModel(secureBackupController: parameters.secureBackupController,
userIndicatorController: parameters.userIndicatorController,
chatBackupDetailsURL: parameters.appSettings.chatBackupDetailsURL)
}
func start() {
viewModel.actions.sink { [weak self] action in
guard let self else { return }
switch action {
case .recoveryKey:
let navigationStackCoordinator = NavigationStackCoordinator()
let userIndicatorController = UserIndicatorController(rootCoordinator: navigationStackCoordinator)
let recoveryKeyCoordinator = SecureBackupRecoveryKeyScreenCoordinator(parameters: .init(secureBackupController: parameters.secureBackupController,
userIndicatorController: userIndicatorController))
recoveryKeyCoordinator.actions.sink { [weak self] action in
guard let self else { return }
parameters.navigationStackCoordinator?.setSheetCoordinator(nil)
switch action {
case .cancel:
break
case .recoverySetUp:
showSuccessIndicator(title: L10n.screenRecoveryKeySetupSuccess)
case .recoveryChanged:
showSuccessIndicator(title: L10n.screenRecoveryKeyChangeSuccess)
case .recoveryFixed:
showSuccessIndicator(title: L10n.screenRecoveryKeyConfirmSuccess)
}
}
.store(in: &cancellables)
navigationStackCoordinator.setRootCoordinator(recoveryKeyCoordinator, animated: true)
parameters.navigationStackCoordinator?.setSheetCoordinator(userIndicatorController)
case .keyBackup:
let navigationStackCoordinator = NavigationStackCoordinator()
let userIndicatorController = UserIndicatorController(rootCoordinator: navigationStackCoordinator)
let keyBackupCoordinator = SecureBackupKeyBackupScreenCoordinator(parameters: .init(secureBackupController: parameters.secureBackupController,
userIndicatorController: userIndicatorController))
keyBackupCoordinator.actions.sink { [weak self] action in
switch action {
case .done:
self?.parameters.navigationStackCoordinator?.setSheetCoordinator(nil)
}
}
.store(in: &cancellables)
navigationStackCoordinator.setRootCoordinator(keyBackupCoordinator, animated: true)
parameters.navigationStackCoordinator?.setSheetCoordinator(userIndicatorController)
}
}
.store(in: &cancellables)
}
func toPresentable() -> AnyView {
AnyView(SecureBackupScreen(context: viewModel.context))
}
// MARK: - Private
private func showSuccessIndicator(title: String) {
parameters.userIndicatorController?.submitIndicator(.init(id: .init(),
type: .modal(progress: .none, interactiveDismissDisabled: false, allowsInteraction: false),
title: title,
iconName: "checkmark",
persistent: false))
}
}
@@ -0,0 +1,38 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
enum SecureBackupScreenViewModelAction {
case recoveryKey
case keyBackup
}
struct SecureBackupScreenViewState: BindableState {
let chatBackupDetailsURL: URL
var recoveryKeyState = SecureBackupRecoveryKeyState.unknown
var keyBackupState = SecureBackupKeyBackupState.unknown
var bindings = SecureBackupScreenViewStateBindings()
}
struct SecureBackupScreenViewStateBindings {
var alertInfo: AlertInfo<UUID>?
}
enum SecureBackupScreenViewAction {
case recoveryKey
case keyBackup
}
@@ -0,0 +1,85 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Combine
import SwiftUI
typealias SecureBackupScreenViewModelType = StateStoreViewModel<SecureBackupScreenViewState, SecureBackupScreenViewAction>
class SecureBackupScreenViewModel: SecureBackupScreenViewModelType, SecureBackupScreenViewModelProtocol {
private let secureBackupController: SecureBackupControllerProtocol
private weak var userIndicatorController: UserIndicatorControllerProtocol?
private var actionsSubject: PassthroughSubject<SecureBackupScreenViewModelAction, Never> = .init()
var actions: AnyPublisher<SecureBackupScreenViewModelAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
init(secureBackupController: SecureBackupControllerProtocol,
userIndicatorController: UserIndicatorControllerProtocol?,
chatBackupDetailsURL: URL) {
self.secureBackupController = secureBackupController
self.userIndicatorController = userIndicatorController
super.init(initialViewState: .init(chatBackupDetailsURL: chatBackupDetailsURL))
secureBackupController.recoveryKeyState
.receive(on: DispatchQueue.main)
.weakAssign(to: \.state.recoveryKeyState, on: self)
.store(in: &cancellables)
secureBackupController.keyBackupState
.receive(on: DispatchQueue.main)
.weakAssign(to: \.state.keyBackupState, on: self)
.store(in: &cancellables)
}
// MARK: - Public
override func process(viewAction: SecureBackupScreenViewAction) {
switch viewAction {
case .recoveryKey:
actionsSubject.send(.recoveryKey)
case .keyBackup:
switch secureBackupController.keyBackupState.value {
case .disabled:
enableKeyBackup()
case .enabled:
actionsSubject.send(.keyBackup)
default:
break
}
}
}
// MARK: - Private
private func enableKeyBackup() {
Task {
let loadingIndicatorIdentifier = "SecureBackupScreenLoading"
userIndicatorController?.submitIndicator(.init(id: loadingIndicatorIdentifier, type: .modal, title: L10n.commonLoading, persistent: true))
switch await secureBackupController.enableBackup() {
case .success:
break
case .failure(let error):
MXLog.error("Failed enabling key backup with error: \(error)")
state.bindings.alertInfo = .init(id: .init())
}
userIndicatorController?.retractIndicatorWithId(loadingIndicatorIdentifier)
}
}
}
@@ -0,0 +1,23 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Combine
@MainActor
protocol SecureBackupScreenViewModelProtocol {
var actions: AnyPublisher<SecureBackupScreenViewModelAction, Never> { get }
var context: SecureBackupScreenViewModelType.Context { get }
}
@@ -0,0 +1,168 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Combine
import Compound
import SwiftUI
struct SecureBackupScreen: View {
@ObservedObject var context: SecureBackupScreenViewModel.Context
var body: some View {
Form {
if context.viewState.keyBackupState != .enabled {
keyBackupSection
} else {
keyBackupSection
recoveryKeySection
}
}
.compoundList()
.navigationTitle(L10n.commonChatBackup)
.navigationBarTitleDisplayMode(.inline)
.alert(item: $context.alertInfo)
}
// MARK: - Private
@ViewBuilder
private var keyBackupSection: some View {
Section {
ListRow(kind: .custom {
VStack(alignment: .leading, spacing: 2) {
Text(L10n.screenChatBackupKeyBackupTitle)
.font(.compound.bodyLGSemibold)
.foregroundColor(.compound.textPrimary)
Text(keyBackupDescriptionWithLearnMoreLink)
.font(.compound.bodySM)
.foregroundColor(.compound.textSecondary)
}
.padding(.horizontal, ListRowPadding.horizontal)
.padding(.vertical, ListRowPadding.vertical)
.accessibilityElement(children: .combine)
})
keyBackupButton
}
}
private var keyBackupDescriptionWithLearnMoreLink: AttributedString {
let linkPlaceholder = "{link}"
var description = AttributedString(L10n.screenChatBackupKeyBackupDescription(linkPlaceholder))
var linkString = AttributedString(L10n.actionLearnMore)
linkString.link = context.viewState.chatBackupDetailsURL
linkString.bold()
description.replace(linkPlaceholder, with: linkString)
return description
}
@ViewBuilder
private var keyBackupButton: some View {
switch context.viewState.keyBackupState {
case .enabled, .disabling:
ListRow(label: .plain(title: L10n.screenChatBackupKeyBackupActionDisable, role: .destructive), kind: .navigationLink {
context.send(viewAction: .keyBackup)
})
case .disabled, .enabling:
ListRow(label: .plain(title: L10n.screenChatBackupKeyBackupActionEnable), kind: .navigationLink {
context.send(viewAction: .keyBackup)
})
default:
ListRow(label: .plain(title: L10n.commonLoading), details: .isWaiting(true), kind: .label)
}
}
@ViewBuilder
private var recoveryKeySection: some View {
Section {
switch context.viewState.recoveryKeyState {
case .enabled:
ListRow(label: .plain(title: L10n.screenChatBackupRecoveryActionChange),
kind: .navigationLink { context.send(viewAction: .recoveryKey) })
case .disabled:
ListRow(label: .plain(title: L10n.screenChatBackupRecoveryActionSetup),
details: .icon(BadgeView(size: 10)),
kind: .navigationLink { context.send(viewAction: .recoveryKey) })
case .incomplete:
ListRow(label: .plain(title: L10n.screenChatBackupRecoveryActionConfirm),
kind: .navigationLink { context.send(viewAction: .recoveryKey) })
default:
ListRow(label: .plain(title: L10n.commonLoading), details: .isWaiting(true), kind: .label)
}
} footer: {
recoveryKeySectionFooter
.compoundFormSectionFooter()
}
}
@ViewBuilder
private var recoveryKeySectionFooter: some View {
switch context.viewState.recoveryKeyState {
case .disabled:
Text(L10n.screenChatBackupRecoveryActionSetupDescription(InfoPlistReader.main.bundleDisplayName))
case .incomplete:
Text(L10n.screenChatBackupRecoveryActionConfirmDescription)
default:
EmptyView()
}
}
}
// MARK: - Previews
struct SecureBackupScreen_Previews: PreviewProvider, TestablePreview {
static let bothSetupViewModel = viewModel(keyBackupState: .enabled, recoveryKeyState: .enabled)
static let onlyKeyBackupSetUpViewModel = viewModel(keyBackupState: .enabled, recoveryKeyState: .disabled)
static let keyBackupDisabledViewModel = viewModel(keyBackupState: .disabled, recoveryKeyState: .disabled)
static let recoveryIncompleteViewModel = viewModel(keyBackupState: .enabled, recoveryKeyState: .incomplete)
static var previews: some View {
Group {
NavigationStack {
SecureBackupScreen(context: bothSetupViewModel.context)
}
.previewDisplayName("Both setup")
NavigationStack {
SecureBackupScreen(context: onlyKeyBackupSetUpViewModel.context)
}
.previewDisplayName("Only key backup setup")
NavigationStack {
SecureBackupScreen(context: keyBackupDisabledViewModel.context)
}
.previewDisplayName("Key backup disabled")
NavigationStack {
SecureBackupScreen(context: recoveryIncompleteViewModel.context)
}
.previewDisplayName("Recovery incomplete")
}
.snapshot(delay: 0.25)
}
static func viewModel(keyBackupState: SecureBackupKeyBackupState,
recoveryKeyState: SecureBackupRecoveryKeyState) -> SecureBackupScreenViewModelType {
let backupController = SecureBackupControllerMock()
backupController.underlyingKeyBackupState = CurrentValueSubject<SecureBackupKeyBackupState, Never>(keyBackupState).asCurrentValuePublisher()
backupController.underlyingRecoveryKeyState = CurrentValueSubject<SecureBackupRecoveryKeyState, Never>(recoveryKeyState).asCurrentValuePublisher()
return SecureBackupScreenViewModel(secureBackupController: backupController,
userIndicatorController: nil,
chatBackupDetailsURL: .sharedPublicDirectory)
}
}
@@ -71,7 +71,7 @@ struct SessionVerificationScreen: View {
@ViewBuilder
private var screenHeader: some View {
VStack(spacing: 0) {
AuthenticationIconImage(image: Image(systemName: headerImageName))
HeroImage(image: Image(systemName: headerImageName))
.padding(.bottom, 16)
Text(context.viewState.title ?? "")
@@ -53,6 +53,7 @@ protocol DeveloperOptionsProtocol: AnyObject {
var mentionsEnabled: Bool { get set }
var appLockFlowEnabled: Bool { get set }
var elementCallEnabled: Bool { get set }
var chatBackupEnabled: Bool { get set }
}
extension AppSettings: DeveloperOptionsProtocol { }
@@ -31,6 +31,18 @@ struct DeveloperOptionsScreen: View {
}
}
Section("Security") {
Toggle(isOn: $context.chatBackupEnabled) {
Text("Chat backup")
Text("Requires app reboot")
}
Toggle(isOn: $context.appLockFlowEnabled) {
Text("PIN/Biometric lock")
Text("Resets on reboot")
}
}
Section("Timeline") {
Toggle(isOn: $context.shouldCollapseRoomStateEvents) {
Text("Collapse room state events")
@@ -52,7 +64,7 @@ struct DeveloperOptionsScreen: View {
}
Toggle(isOn: $context.elementCallEnabled) {
Text("Elemement Call")
Text("Element Call")
}
}
@@ -67,13 +79,6 @@ struct DeveloperOptionsScreen: View {
Text("Enable voice messages")
}
}
Section("Security") {
Toggle(isOn: $context.appLockFlowEnabled) {
Text("PIN/Biometric lock")
Text("Resets on reboot")
}
}
Section {
Button {
@@ -24,6 +24,7 @@ struct SettingsScreenCoordinatorParameters {
let appLockService: AppLockServiceProtocol
let bugReportService: BugReportServiceProtocol
let notificationSettings: NotificationSettingsProxyProtocol
let secureBackupController: SecureBackupControllerProtocol
let appSettings: AppSettings
}
@@ -49,7 +50,8 @@ final class SettingsScreenCoordinator: CoordinatorProtocol {
init(parameters: SettingsScreenCoordinatorParameters) {
self.parameters = parameters
viewModel = SettingsScreenViewModel(userSession: parameters.userSession, appSettings: parameters.appSettings)
viewModel = SettingsScreenViewModel(userSession: parameters.userSession,
appSettings: parameters.appSettings)
viewModel.actions
.sink { [weak self] action in
@@ -71,7 +73,9 @@ final class SettingsScreenCoordinator: CoordinatorProtocol {
case .about:
presentLegalInformationScreen()
case .sessionVerification:
verifySession()
presentSessionVerificationScreen()
case .secureBackup:
presentSecureBackupScreen()
case .accountSessionsList:
presentAccountSessionsListURL()
case .notifications:
@@ -172,7 +176,7 @@ final class SettingsScreenCoordinator: CoordinatorProtocol {
parameters.navigationStackCoordinator?.push(LegalInformationScreenCoordinator(appSettings: parameters.appSettings))
}
private func verifySession() {
private func presentSessionVerificationScreen() {
guard let sessionVerificationController = parameters.userSession.sessionVerificationController else {
fatalError("The sessionVerificationController should aways be valid at this point")
}
@@ -196,6 +200,15 @@ final class SettingsScreenCoordinator: CoordinatorProtocol {
}
}
private func presentSecureBackupScreen() {
let coordinator = SecureBackupScreenCoordinator(parameters: .init(appSettings: parameters.appSettings,
secureBackupController: parameters.userSession.clientProxy.secureBackupController,
navigationStackCoordinator: parameters.navigationStackCoordinator,
userIndicatorController: parameters.userIndicatorController))
parameters.navigationStackCoordinator?.push(coordinator)
}
private func presentNotificationSettings() {
let notificationParameters = NotificationSettingsScreenCoordinatorParameters(navigationStackCoordinator: parameters.navigationStackCoordinator,
userSession: parameters.userSession,
@@ -26,6 +26,7 @@ enum SettingsScreenViewModelAction {
case reportBug
case about
case sessionVerification
case secureBackup
case accountSessionsList
case notifications
case advancedSettings
@@ -40,7 +41,9 @@ struct SettingsScreenViewState: BindableState {
var accountSessionsListURL: URL?
var userAvatarURL: URL?
var userDisplayName: String?
var showSessionVerificationSection: Bool
var isSessionVerified: Bool
var chatBackupEnabled = false
var showSecureBackupBadge = false
var showAppLockSettings: Bool
var showDeveloperOptions: Bool
@@ -57,6 +60,7 @@ enum SettingsScreenViewAction {
case reportBug
case about
case sessionVerification
case secureBackup
case accountSessionsList
case notifications
case developerOptions
@@ -33,16 +33,16 @@ class SettingsScreenViewModel: SettingsScreenViewModelType, SettingsScreenViewMo
self.userSession = userSession
self.appSettings = appSettings
var showSessionVerificationSection = false
var isSessionVerified = true
if let sessionVerificationController = userSession.sessionVerificationController {
showSessionVerificationSection = !sessionVerificationController.isVerified
isSessionVerified = sessionVerificationController.isVerified
}
super.init(initialViewState: .init(deviceID: userSession.deviceID,
userID: userSession.userID,
accountProfileURL: userSession.clientProxy.accountURL(action: .profile),
accountSessionsListURL: userSession.clientProxy.accountURL(action: .sessionsList),
showSessionVerificationSection: showSessionVerificationSection,
isSessionVerified: isSessionVerified,
showAppLockSettings: appSettings.appLockFlowEnabled,
showDeveloperOptions: appSettings.canShowDeveloperOptions),
imageProvider: userSession.mediaProvider)
@@ -56,11 +56,24 @@ class SettingsScreenViewModel: SettingsScreenViewModelType, SettingsScreenViewMo
.receive(on: DispatchQueue.main)
.weakAssign(to: \.state.userDisplayName, on: self)
.store(in: &cancellables)
appSettings.$appLockFlowEnabled
.weakAssign(to: \.state.showAppLockSettings, on: self)
.store(in: &cancellables)
appSettings.$chatBackupEnabled
.weakAssign(to: \.state.chatBackupEnabled, on: self)
.store(in: &cancellables)
userSession.clientProxy.secureBackupController.recoveryKeyState
.receive(on: DispatchQueue.main)
.sink { [weak self] state in
guard let self else { return }
self.state.showSecureBackupBadge = (state == .unknown || state == .disabled) && appSettings.chatBackupEnabled
}
.store(in: &cancellables)
Task {
await userSession.clientProxy.loadUserAvatarURL()
await userSession.clientProxy.loadUserDisplayName()
@@ -71,9 +84,9 @@ class SettingsScreenViewModel: SettingsScreenViewModelType, SettingsScreenViewMo
.sink { [weak self] callback in
switch callback {
case .sessionVerificationNeeded:
self?.state.showSessionVerificationSection = true
self?.state.isSessionVerified = false
case .didVerifySession:
self?.state.showSessionVerificationSection = false
self?.state.isSessionVerified = true
default:
break
}
@@ -101,6 +114,8 @@ class SettingsScreenViewModel: SettingsScreenViewModelType, SettingsScreenViewMo
actionsSubject.send(.logout)
case .sessionVerification:
actionsSubject.send(.sessionVerification)
case .secureBackup:
actionsSubject.send(.secureBackup)
case .notifications:
actionsSubject.send(.notifications)
case .accountSessionsList:
@@ -27,9 +27,7 @@ struct SettingsScreen: View {
Form {
userSection
if context.viewState.showSessionVerificationSection {
sessionVerificationSection
}
accountSecuritySection
mainSection
@@ -86,11 +84,22 @@ struct SettingsScreen: View {
}
}
private var sessionVerificationSection: some View {
Section {
ListRow(label: .default(title: L10n.actionCompleteVerification,
systemIcon: .checkmarkShield),
kind: .button { context.send(viewAction: .sessionVerification) })
@ViewBuilder
private var accountSecuritySection: some View {
if !context.viewState.isSessionVerified || context.viewState.chatBackupEnabled {
Section {
if !context.viewState.isSessionVerified {
ListRow(label: .default(title: L10n.actionCompleteVerification,
systemIcon: .checkmarkShield),
kind: .button { context.send(viewAction: .sessionVerification) })
} else if context.viewState.chatBackupEnabled {
ListRow(label: .default(title: L10n.commonChatBackup,
icon: Image(asset: Asset.Images.secureBackupIcon)),
details: context.viewState.showSecureBackupBadge ? .icon(secureBackupBadge) : nil,
kind: .navigationLink { context.send(viewAction: .secureBackup) })
.accessibilityIdentifier(A11yIdentifiers.settingsScreen.secureBackup)
}
}
}
}
@@ -222,6 +231,13 @@ struct SettingsScreen: View {
.accessibilityIdentifier(A11yIdentifiers.settingsScreen.done)
}
}
@ViewBuilder
private var secureBackupBadge: some View {
if context.viewState.showSecureBackupBadge {
BadgeView(size: 10)
}
}
}
private extension TimelineStyle {
@@ -45,6 +45,8 @@ class ClientProxy: ClientProxyProtocol {
let notificationSettings: NotificationSettingsProxyProtocol
let secureBackupController: SecureBackupControllerProtocol
private let roomListRecencyOrderingAllowedEventTypes = ["m.room.message", "m.room.encrypted", "m.sticker"]
private var loadCachedAvatarURLTask: Task<Void, Never>?
@@ -93,6 +95,8 @@ class ClientProxy: ClientProxyProtocol {
notificationSettings = NotificationSettingsProxy(notificationSettings: client.getNotificationSettings(),
backgroundTaskService: backgroundTaskService)
secureBackupController = SecureBackupController()
client.setDelegate(delegate: ClientDelegateWrapper { [weak self] isSoftLogout in
self?.hasEncounteredAuthError = true
@@ -95,6 +95,8 @@ protocol ClientProxyProtocol: AnyObject, MediaLoaderProtocol {
var notificationSettings: NotificationSettingsProxyProtocol { get }
var secureBackupController: SecureBackupControllerProtocol { get }
func startSync()
func stopSync()
@@ -39,6 +39,13 @@ class MockClientProxy: ClientProxyProtocol {
var userDisplayName: CurrentValuePublisher<String?, Never> { CurrentValueSubject<String?, Never>("User display name").asCurrentValuePublisher() }
var notificationSettings: NotificationSettingsProxyProtocol = NotificationSettingsProxyMock(with: .init())
lazy var secureBackupController: SecureBackupControllerProtocol = {
let secureBackupController = SecureBackupControllerMock()
secureBackupController.underlyingRecoveryKeyState = .init(CurrentValueSubject<SecureBackupRecoveryKeyState, Never>(.disabled))
secureBackupController.underlyingKeyBackupState = .init(CurrentValueSubject<SecureBackupKeyBackupState, Never>(.enabled))
return secureBackupController
}()
init(userID: String, deviceID: String? = nil, roomSummaryProvider: RoomSummaryProviderProtocol? = MockRoomSummaryProvider()) {
self.userID = userID
@@ -0,0 +1,79 @@
//
// Copyright 2023 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Combine
import Foundation
class SecureBackupController: SecureBackupControllerProtocol {
private let recoveryKeyStateSubject = CurrentValueSubject<SecureBackupRecoveryKeyState, Never>(.disabled)
private let keyBackupStateSubject = CurrentValueSubject<SecureBackupKeyBackupState, Never>(.enabled)
var recoveryKeyState: CurrentValuePublisher<SecureBackupRecoveryKeyState, Never> {
recoveryKeyStateSubject.asCurrentValuePublisher()
}
var keyBackupState: CurrentValuePublisher<SecureBackupKeyBackupState, Never> {
keyBackupStateSubject.asCurrentValuePublisher()
}
func enableBackup() async -> Result<Void, SecureBackupControllerError> {
keyBackupStateSubject.send(.enabling)
try? await Task.sleep(for: .seconds(1))
keyBackupStateSubject.send(.enabled)
return .success(())
}
func disableBackup() async -> Result<Void, SecureBackupControllerError> {
keyBackupStateSubject.send(.disabling)
try? await Task.sleep(for: .seconds(1))
keyBackupStateSubject.send(.disabled)
return .success(())
}
static var wohoo = 0
func generateRecoveryKey() async -> Result<String, SecureBackupControllerError> {
recoveryKeyStateSubject.send(.settingUp)
try? await Task.sleep(for: .seconds(1))
if Self.wohoo > 0, Self.wohoo % 2 == 1 {
recoveryKeyStateSubject.send(.incomplete)
} else {
recoveryKeyStateSubject.send(.enabled)
}
Self.wohoo += 1
return .success(UUID().uuidString)
}
func confirmRecoveryKey(_ key: String) async -> Result<Void, SecureBackupControllerError> {
recoveryKeyStateSubject.send(.settingUp)
try? await Task.sleep(for: .seconds(1))
recoveryKeyStateSubject.send(.enabled)
return .success(())
}
}
@@ -0,0 +1,65 @@
//
// Copyright 2023 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Combine
import Foundation
enum SecureBackupRecoveryKeyState {
case unknown
case disabled
case enabled
/// Recovery is not set up properly, the user will need to re-enter it so we can cleanup
/// https://github.com/vector-im/element-meta/issues/2107
case incomplete
case settingUp
}
enum SecureBackupKeyBackupState {
case unknown
case enabling
case enabled
case disabling
case disabled
}
enum SecureBackupControllerError: Error {
case failedEnablingKeyBackup
case failedDisablingKeyBackup
case failedGeneratingRecoveryKey
case failedConfirmingRecoveryKey
}
// sourcery: AutoMockable
protocol SecureBackupControllerProtocol {
var recoveryKeyState: CurrentValuePublisher<SecureBackupRecoveryKeyState, Never> { get }
var keyBackupState: CurrentValuePublisher<SecureBackupKeyBackupState, Never> { get }
func enableBackup() async -> Result<Void, SecureBackupControllerError>
func disableBackup() async -> Result<Void, SecureBackupControllerError>
func generateRecoveryKey() async -> Result<String, SecureBackupControllerError>
func confirmRecoveryKey(_ key: String) async -> Result<Void, SecureBackupControllerError>
}
extension SecureBackupControllerMock {
convenience init(keyBackupState: SecureBackupKeyBackupState, recoveryKeyState: SecureBackupRecoveryKeyState) {
self.init()
underlyingKeyBackupState = CurrentValueSubject<SecureBackupKeyBackupState, Never>(keyBackupState).asCurrentValuePublisher()
underlyingRecoveryKeyState = CurrentValueSubject<SecureBackupRecoveryKeyState, Never>(recoveryKeyState).asCurrentValuePublisher()
}
}
@@ -191,6 +191,7 @@ class MockScreen: Identifiable {
appSettings: ServiceLocator.shared.settings),
bugReportService: BugReportServiceMock(),
notificationSettings: NotificationSettingsProxyMock(with: .init()),
secureBackupController: SecureBackupControllerMock(),
appSettings: ServiceLocator.shared.settings))
navigationStackCoordinator.setRootCoordinator(coordinator)
return navigationStackCoordinator