From b904c688e623a449e20e3141a646ef562d9e1762 Mon Sep 17 00:00:00 2001 From: Graeme Arthur <2030310+graeme@users.noreply.github.com> Date: Mon, 16 Dec 2024 16:49:08 +0100 Subject: [PATCH 01/12] Switch sync accounts UI --- .../Preferences/Model/SyncPreferences.swift | 32 +++++++++++++++++-- .../SyncUI/Resources/Localizable.xcstrings | 24 ++++++++++++++ .../ViewModels/ManagementDialogModel.swift | 10 ++++++ .../ViewModels/ManagementViewModel.swift | 4 +-- .../SyncUI/Views/ManagementDialog.swift | 12 +++++++ .../Sources/SyncUI/internal/UserText.swift | 4 +++ 6 files changed, 82 insertions(+), 4 deletions(-) diff --git a/DuckDuckGo/Preferences/Model/SyncPreferences.swift b/DuckDuckGo/Preferences/Model/SyncPreferences.swift index 307fdf4237..921cad6e6d 100644 --- a/DuckDuckGo/Preferences/Model/SyncPreferences.swift +++ b/DuckDuckGo/Preferences/Model/SyncPreferences.swift @@ -577,8 +577,12 @@ extension SyncPreferences: ManagementDialogModelDelegate { do { try await loginAndShowPresentedDialog(recoveryKey, isRecovery: fromRecoveryScreen) } catch { - managementDialogModel.syncErrorMessage = SyncErrorMessage(type: .unableToMergeTwoAccounts, description: "") - PixelKit.fire(DebugEvent(GeneralPixel.syncLoginExistingAccountError(error: error))) + if case SyncError.accountAlreadyExists = error { + managementDialogModel.shouldShowSwitchAccountsMessage = true + PixelKit.fire(DebugEvent(GeneralPixel.syncLoginExistingAccountError(error: error))) + } else { + managementDialogModel.syncErrorMessage = SyncErrorMessage(type: .unableToSyncToOtherDevice) + } } } else if let connectKey = syncCode.connect { do { @@ -755,4 +759,28 @@ extension SyncPreferences: ManagementDialogModelDelegate { func recoveryCodePasted(_ code: String, fromRecoveryScreen: Bool) { recoverDevice(recoveryCode: code, fromRecoveryScreen: fromRecoveryScreen) } + + func switchSync(recoveryCode: String) { + guard let recoveryKey = try? SyncCode.decodeBase64String(recoveryCode).recovery else { + return + } + Task { [weak self] in + guard let self else { return } + do { + try await syncService.disconnect() + } catch { + // TODO: Send sync_user_switched_logout_error pixel + } + + do { + let device = deviceInfo() + let registeredDevices = try await syncService.login(recoveryKey, deviceName: device.name, deviceType: device.type) + await mapDevices(registeredDevices) + } catch { + // TODO: Send sync_user_switched_login_error pixel + } + // TODO: Send sync_user_switched_account_pixel + } + } + } diff --git a/LocalPackages/SyncUI/Sources/SyncUI/Resources/Localizable.xcstrings b/LocalPackages/SyncUI/Sources/SyncUI/Resources/Localizable.xcstrings index dc53430490..c7878d193f 100644 --- a/LocalPackages/SyncUI/Sources/SyncUI/Resources/Localizable.xcstrings +++ b/LocalPackages/SyncUI/Sources/SyncUI/Resources/Localizable.xcstrings @@ -299,6 +299,30 @@ } } }, + "alert.sync-switch-account-button" : { + "comment" : "Switch account button in alert\nSwitch account title in alert", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Switch Account" + } + } + } + }, + "alert.sync-switch-account-message" : { + "comment" : "Description for switching sync accounts when there's two", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "This device is already synced, are you sure you want to sync it with a different back up or device? Switching won't remove any data already synced to this." + } + } + } + }, "alert.unable-to-authenticate-device" : { "comment" : "Description for unable to authenticate error", "extractionState" : "extracted_with_value", diff --git a/LocalPackages/SyncUI/Sources/SyncUI/ViewModels/ManagementDialogModel.swift b/LocalPackages/SyncUI/Sources/SyncUI/ViewModels/ManagementDialogModel.swift index 39d22256e3..3c7c6f66d6 100644 --- a/LocalPackages/SyncUI/Sources/SyncUI/ViewModels/ManagementDialogModel.swift +++ b/LocalPackages/SyncUI/Sources/SyncUI/ViewModels/ManagementDialogModel.swift @@ -33,6 +33,7 @@ public protocol ManagementDialogModelDelegate: AnyObject { func enterRecoveryCodePressed() func copyCode() func openSystemPasswordSettings() + func switchSync(recoveryCode: String) } public final class ManagementDialogModel: ObservableObject { @@ -42,6 +43,9 @@ public final class ManagementDialogModel: ObservableObject { @Published public var shouldShowErrorMessage: Bool = false @Published public var syncErrorMessage: SyncErrorMessage? + @Published public var shouldShowSwitchAccountsMessage: Bool = false + + private(set) public var codeToSwitchTo: String? public weak var delegate: ManagementDialogModelDelegate? @@ -60,5 +64,11 @@ public final class ManagementDialogModel: ObservableObject { currentDialog = nil } + public func switchSyncAccounts(recoveryCode: String) { + shouldShowSwitchAccountsMessage = false + delegate?.switchSync(recoveryCode: recoveryCode) + endFlow() + } + private var shouldShowErrorMessageCancellable: AnyCancellable? } diff --git a/LocalPackages/SyncUI/Sources/SyncUI/ViewModels/ManagementViewModel.swift b/LocalPackages/SyncUI/Sources/SyncUI/ViewModels/ManagementViewModel.swift index 52c8b61dff..4b52d2e0a0 100644 --- a/LocalPackages/SyncUI/Sources/SyncUI/ViewModels/ManagementViewModel.swift +++ b/LocalPackages/SyncUI/Sources/SyncUI/ViewModels/ManagementViewModel.swift @@ -140,8 +140,8 @@ public struct SyncErrorMessage { var type: SyncErrorType var errorDescription: String - public init(type: SyncErrorType, description: String) { + public init(type: SyncErrorType, description: String? = nil) { self.type = type - self.errorDescription = description + self.errorDescription = description ?? type.description } } diff --git a/LocalPackages/SyncUI/Sources/SyncUI/Views/ManagementDialog.swift b/LocalPackages/SyncUI/Sources/SyncUI/Views/ManagementDialog.swift index fb8fa0b623..a6693a7f01 100644 --- a/LocalPackages/SyncUI/Sources/SyncUI/Views/ManagementDialog.swift +++ b/LocalPackages/SyncUI/Sources/SyncUI/Views/ManagementDialog.swift @@ -70,6 +70,18 @@ public struct ManagementDialog: View { } ) } + .alert(isPresented: $model.shouldShowSwitchAccountsMessage) { + Alert( + title: Text(UserText.syncAlertSwitchAccountTitle), + message: Text(UserText.syncAlertSwitchAccountMessage), + primaryButton: .default(Text(UserText.syncAlertSwitchAccountButton)) { + model.switchSyncAccounts(recoveryCode: recoveryCodeModel.recoveryCode) + }, + secondaryButton: .cancel { + model.endFlow() + } + ) + } } @ViewBuilder var content: some View { diff --git a/LocalPackages/SyncUI/Sources/SyncUI/internal/UserText.swift b/LocalPackages/SyncUI/Sources/SyncUI/internal/UserText.swift index 0bff100318..9d18b0a0d0 100644 --- a/LocalPackages/SyncUI/Sources/SyncUI/internal/UserText.swift +++ b/LocalPackages/SyncUI/Sources/SyncUI/internal/UserText.swift @@ -203,6 +203,10 @@ enum UserText { static let invalidCodeDescription = NSLocalizedString("alert.invalid-code-description", bundle: Bundle.module, value: "Sorry, this code is invalid. Please make sure it was entered correctly.", comment: "Description for invalid code error") static let unableCreateRecoveryPdfDescription = NSLocalizedString("alert.unable-to-create-recovery-pdf-description", bundle: Bundle.module, value: "Unable to create the recovery PDF.", comment: "Description for unable to create recovery pdf error") + public static let syncAlertSwitchAccountTitle = NSLocalizedString("alert.sync-switch-account-button", value: "Switch to a different Sync?", comment: "Switch account title in alert") + public static let syncAlertSwitchAccountMessage = NSLocalizedString("alert.sync-switch-account-message", value: "This device is already synced, are you sure you want to sync it with a different back up or device? Switching won't remove any data already synced to this.", comment: "Description for switching sync accounts when there's two") + public static let syncAlertSwitchAccountButton = NSLocalizedString("alert.sync-switch-account-button", value: "Switch Account", comment: "Switch account button in alert") + static let fetchFaviconsOnboardingTitle = NSLocalizedString("prefrences.sync.fetch-favicons-onboarding-title", bundle: Bundle.module, value: "Download Missing Icons?", comment: "Title for fetch favicons onboarding dialog") static let fetchFaviconsOnboardingMessage = NSLocalizedString("prefrences.sync.fetch-favicons-onboarding-message", bundle: Bundle.module, value: "Do you want this device to automatically download icons for any new bookmarks synced from your other devices? This will expose the download to your network any time a bookmark is synced.", comment: "Text for fetch favicons onboarding dialog") static let keepFaviconsUpdated = NSLocalizedString("prefrences.sync.keep-favicons-updated", bundle: Bundle.module, value: "Keep Bookmarks Icons Updated", comment: "Title of the confirmation button for favicons fetching") From 67ffec997749d6325375329aac7c1d8819b52779 Mon Sep 17 00:00:00 2001 From: Graeme Arthur <2030310+graeme@users.noreply.github.com> Date: Tue, 17 Dec 2024 10:25:18 +0100 Subject: [PATCH 02/12] Don't prompt to switch when 1 device --- DuckDuckGo/Preferences/Model/SyncPreferences.swift | 14 +++++++++++--- .../SyncUI/ViewModels/ManagementDialogModel.swift | 6 +++--- .../Sources/SyncUI/Views/ManagementDialog.swift | 2 +- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/DuckDuckGo/Preferences/Model/SyncPreferences.swift b/DuckDuckGo/Preferences/Model/SyncPreferences.swift index 921cad6e6d..67e44a7bde 100644 --- a/DuckDuckGo/Preferences/Model/SyncPreferences.swift +++ b/DuckDuckGo/Preferences/Model/SyncPreferences.swift @@ -578,7 +578,12 @@ extension SyncPreferences: ManagementDialogModelDelegate { try await loginAndShowPresentedDialog(recoveryKey, isRecovery: fromRecoveryScreen) } catch { if case SyncError.accountAlreadyExists = error { - managementDialogModel.shouldShowSwitchAccountsMessage = true + if devices.count > 1 { + managementDialogModel.shouldShowSwitchAccountsMessage = true + } else { + switchAccounts(recoveryKey: recoveryKey) + managementDialogModel.endFlow() + } PixelKit.fire(DebugEvent(GeneralPixel.syncLoginExistingAccountError(error: error))) } else { managementDialogModel.syncErrorMessage = SyncErrorMessage(type: .unableToSyncToOtherDevice) @@ -760,10 +765,14 @@ extension SyncPreferences: ManagementDialogModelDelegate { recoverDevice(recoveryCode: code, fromRecoveryScreen: fromRecoveryScreen) } - func switchSync(recoveryCode: String) { + func switchAccounts(recoveryCode: String) { guard let recoveryKey = try? SyncCode.decodeBase64String(recoveryCode).recovery else { return } + switchAccounts(recoveryKey: recoveryKey) + } + + func switchAccounts(recoveryKey: SyncCode.RecoveryKey) { Task { [weak self] in guard let self else { return } do { @@ -782,5 +791,4 @@ extension SyncPreferences: ManagementDialogModelDelegate { // TODO: Send sync_user_switched_account_pixel } } - } diff --git a/LocalPackages/SyncUI/Sources/SyncUI/ViewModels/ManagementDialogModel.swift b/LocalPackages/SyncUI/Sources/SyncUI/ViewModels/ManagementDialogModel.swift index 3c7c6f66d6..4522ee2352 100644 --- a/LocalPackages/SyncUI/Sources/SyncUI/ViewModels/ManagementDialogModel.swift +++ b/LocalPackages/SyncUI/Sources/SyncUI/ViewModels/ManagementDialogModel.swift @@ -33,7 +33,7 @@ public protocol ManagementDialogModelDelegate: AnyObject { func enterRecoveryCodePressed() func copyCode() func openSystemPasswordSettings() - func switchSync(recoveryCode: String) + func switchAccounts(recoveryCode: String) } public final class ManagementDialogModel: ObservableObject { @@ -64,9 +64,9 @@ public final class ManagementDialogModel: ObservableObject { currentDialog = nil } - public func switchSyncAccounts(recoveryCode: String) { + public func switchAccounts(recoveryCode: String) { shouldShowSwitchAccountsMessage = false - delegate?.switchSync(recoveryCode: recoveryCode) + delegate?.switchAccounts(recoveryCode: recoveryCode) endFlow() } diff --git a/LocalPackages/SyncUI/Sources/SyncUI/Views/ManagementDialog.swift b/LocalPackages/SyncUI/Sources/SyncUI/Views/ManagementDialog.swift index a6693a7f01..7b4a743ff1 100644 --- a/LocalPackages/SyncUI/Sources/SyncUI/Views/ManagementDialog.swift +++ b/LocalPackages/SyncUI/Sources/SyncUI/Views/ManagementDialog.swift @@ -75,7 +75,7 @@ public struct ManagementDialog: View { title: Text(UserText.syncAlertSwitchAccountTitle), message: Text(UserText.syncAlertSwitchAccountMessage), primaryButton: .default(Text(UserText.syncAlertSwitchAccountButton)) { - model.switchSyncAccounts(recoveryCode: recoveryCodeModel.recoveryCode) + model.switchAccounts(recoveryCode: recoveryCodeModel.recoveryCode) }, secondaryButton: .cancel { model.endFlow() From 6cddd98cf85797b34f6b31ea0de98b1a8724087d Mon Sep 17 00:00:00 2001 From: Graeme Arthur <2030310+graeme@users.noreply.github.com> Date: Tue, 17 Dec 2024 16:40:27 +0100 Subject: [PATCH 03/12] Fix another typo FML --- .../SyncUI/Sources/SyncUI/Resources/Localizable.xcstrings | 2 +- LocalPackages/SyncUI/Sources/SyncUI/internal/UserText.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/LocalPackages/SyncUI/Sources/SyncUI/Resources/Localizable.xcstrings b/LocalPackages/SyncUI/Sources/SyncUI/Resources/Localizable.xcstrings index c7878d193f..cba81239c9 100644 --- a/LocalPackages/SyncUI/Sources/SyncUI/Resources/Localizable.xcstrings +++ b/LocalPackages/SyncUI/Sources/SyncUI/Resources/Localizable.xcstrings @@ -318,7 +318,7 @@ "en" : { "stringUnit" : { "state" : "new", - "value" : "This device is already synced, are you sure you want to sync it with a different back up or device? Switching won't remove any data already synced to this." + "value" : "This device is already synced, are you sure you want to sync it with a different back up or device? Switching won't remove any data already synced to this device." } } } diff --git a/LocalPackages/SyncUI/Sources/SyncUI/internal/UserText.swift b/LocalPackages/SyncUI/Sources/SyncUI/internal/UserText.swift index 9d18b0a0d0..d700ed362c 100644 --- a/LocalPackages/SyncUI/Sources/SyncUI/internal/UserText.swift +++ b/LocalPackages/SyncUI/Sources/SyncUI/internal/UserText.swift @@ -204,7 +204,7 @@ enum UserText { static let unableCreateRecoveryPdfDescription = NSLocalizedString("alert.unable-to-create-recovery-pdf-description", bundle: Bundle.module, value: "Unable to create the recovery PDF.", comment: "Description for unable to create recovery pdf error") public static let syncAlertSwitchAccountTitle = NSLocalizedString("alert.sync-switch-account-button", value: "Switch to a different Sync?", comment: "Switch account title in alert") - public static let syncAlertSwitchAccountMessage = NSLocalizedString("alert.sync-switch-account-message", value: "This device is already synced, are you sure you want to sync it with a different back up or device? Switching won't remove any data already synced to this.", comment: "Description for switching sync accounts when there's two") + public static let syncAlertSwitchAccountMessage = NSLocalizedString("alert.sync-switch-account-message", value: "This device is already synced, are you sure you want to sync it with a different back up or device? Switching won't remove any data already synced to this device.", comment: "Description for switching sync accounts when there's two") public static let syncAlertSwitchAccountButton = NSLocalizedString("alert.sync-switch-account-button", value: "Switch Account", comment: "Switch account button in alert") static let fetchFaviconsOnboardingTitle = NSLocalizedString("prefrences.sync.fetch-favicons-onboarding-title", bundle: Bundle.module, value: "Download Missing Icons?", comment: "Title for fetch favicons onboarding dialog") From 25407c11cac586ed8218795a159e9ae814fb7368 Mon Sep 17 00:00:00 2001 From: Graeme Arthur <2030310+graeme@users.noreply.github.com> Date: Tue, 17 Dec 2024 18:00:06 +0100 Subject: [PATCH 04/12] Another typo --- .../SyncUI/Resources/Localizable.xcstrings | 16 ++++++++++++++-- .../Sources/SyncUI/internal/UserText.swift | 2 +- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/LocalPackages/SyncUI/Sources/SyncUI/Resources/Localizable.xcstrings b/LocalPackages/SyncUI/Sources/SyncUI/Resources/Localizable.xcstrings index cba81239c9..cba624580d 100644 --- a/LocalPackages/SyncUI/Sources/SyncUI/Resources/Localizable.xcstrings +++ b/LocalPackages/SyncUI/Sources/SyncUI/Resources/Localizable.xcstrings @@ -300,13 +300,13 @@ } }, "alert.sync-switch-account-button" : { - "comment" : "Switch account button in alert\nSwitch account title in alert", + "comment" : "Switch account title in alert", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Switch Account" + "value" : "Switch to a different Sync?" } } } @@ -323,6 +323,18 @@ } } }, + "alert.sync-switch-sync-button" : { + "comment" : "Switch account button in alert", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Switch Sync" + } + } + } + }, "alert.unable-to-authenticate-device" : { "comment" : "Description for unable to authenticate error", "extractionState" : "extracted_with_value", diff --git a/LocalPackages/SyncUI/Sources/SyncUI/internal/UserText.swift b/LocalPackages/SyncUI/Sources/SyncUI/internal/UserText.swift index d700ed362c..30a81365ba 100644 --- a/LocalPackages/SyncUI/Sources/SyncUI/internal/UserText.swift +++ b/LocalPackages/SyncUI/Sources/SyncUI/internal/UserText.swift @@ -205,7 +205,7 @@ enum UserText { public static let syncAlertSwitchAccountTitle = NSLocalizedString("alert.sync-switch-account-button", value: "Switch to a different Sync?", comment: "Switch account title in alert") public static let syncAlertSwitchAccountMessage = NSLocalizedString("alert.sync-switch-account-message", value: "This device is already synced, are you sure you want to sync it with a different back up or device? Switching won't remove any data already synced to this device.", comment: "Description for switching sync accounts when there's two") - public static let syncAlertSwitchAccountButton = NSLocalizedString("alert.sync-switch-account-button", value: "Switch Account", comment: "Switch account button in alert") + public static let syncAlertSwitchAccountButton = NSLocalizedString("alert.sync-switch-sync-button", value: "Switch Sync", comment: "Switch account button in alert") static let fetchFaviconsOnboardingTitle = NSLocalizedString("prefrences.sync.fetch-favicons-onboarding-title", bundle: Bundle.module, value: "Download Missing Icons?", comment: "Title for fetch favicons onboarding dialog") static let fetchFaviconsOnboardingMessage = NSLocalizedString("prefrences.sync.fetch-favicons-onboarding-message", bundle: Bundle.module, value: "Do you want this device to automatically download icons for any new bookmarks synced from your other devices? This will expose the download to your network any time a bookmark is synced.", comment: "Text for fetch favicons onboarding dialog") From e2d9a2dd3a8cc72237c427225de7c27b38e487a7 Mon Sep 17 00:00:00 2001 From: Graeme Arthur <2030310+graeme@users.noreply.github.com> Date: Thu, 19 Dec 2024 13:54:55 +0100 Subject: [PATCH 05/12] Add tests --- DuckDuckGo.xcodeproj/project.pbxproj | 12 ++ .../Preferences/Model/SyncPreferences.swift | 20 +-- UnitTests/Common/CombineTestHelpers.swift | 95 +++++++++++++++ UnitTests/Sync/Mocks/MockDDGSyncing.swift | 9 +- UnitTests/Sync/SyncPreferencesTests.swift | 114 +++++++++++++++++- 5 files changed, 239 insertions(+), 11 deletions(-) create mode 100644 UnitTests/Common/CombineTestHelpers.swift diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index a7372a325d..54a78969d2 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -3141,6 +3141,11 @@ EEE11C5F2C7F54AD000ABD7E /* AutofillLoginImportState.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEE11C5D2C7F54AD000ABD7E /* AutofillLoginImportState.swift */; }; EEE50C292C38249C003DD7FF /* OptionalExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEE50C282C38249C003DD7FF /* OptionalExtension.swift */; }; EEE50C2A2C38249C003DD7FF /* OptionalExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEE50C282C38249C003DD7FF /* OptionalExtension.swift */; }; + EEEFA3402D142AB1006A3F8A /* CombineTestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEEFA33F2D142AA6006A3F8A /* CombineTestHelpers.swift */; }; + EEEFA3412D142AB1006A3F8A /* CombineTestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEEFA33F2D142AA6006A3F8A /* CombineTestHelpers.swift */; }; + EEEFA3422D142AB1006A3F8A /* CombineTestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEEFA33F2D142AA6006A3F8A /* CombineTestHelpers.swift */; }; + EEEFA3432D142AB1006A3F8A /* CombineTestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEEFA33F2D142AA6006A3F8A /* CombineTestHelpers.swift */; }; + EEEFA3442D142AB1006A3F8A /* CombineTestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEEFA33F2D142AA6006A3F8A /* CombineTestHelpers.swift */; }; EEF12E6F2A2111880023E6BF /* MacPacketTunnelProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEF12E6D2A2111880023E6BF /* MacPacketTunnelProvider.swift */; }; EEF53E182950CED5002D78F4 /* JSAlertViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEF53E172950CED5002D78F4 /* JSAlertViewModelTests.swift */; }; F10C99422C7E20A1005568B4 /* Logger+DBPBackgroundAgent.swift in Sources */ = {isa = PBXBuildFile; fileRef = F17E7DDB2C7C7F8100907A84 /* Logger+DBPBackgroundAgent.swift */; }; @@ -4977,6 +4982,7 @@ EEE0E1CE2C32F6530058E148 /* mock_login_data_large.csv */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = mock_login_data_large.csv; sourceTree = ""; }; EEE11C5D2C7F54AD000ABD7E /* AutofillLoginImportState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutofillLoginImportState.swift; sourceTree = ""; }; EEE50C282C38249C003DD7FF /* OptionalExtension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OptionalExtension.swift; sourceTree = ""; }; + EEEFA33F2D142AA6006A3F8A /* CombineTestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CombineTestHelpers.swift; sourceTree = ""; }; EEF12E6D2A2111880023E6BF /* MacPacketTunnelProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacPacketTunnelProvider.swift; sourceTree = ""; }; EEF53E172950CED5002D78F4 /* JSAlertViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSAlertViewModelTests.swift; sourceTree = ""; }; F118EA7C2BEA2B8700F77634 /* DefaultSubscriptionFeatureAvailability+DefaultInitializer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "DefaultSubscriptionFeatureAvailability+DefaultInitializer.swift"; sourceTree = ""; }; @@ -7602,6 +7608,7 @@ 85F69B3A25EDE7F800978E59 /* Common */ = { isa = PBXGroup; children = ( + EEEFA33F2D142AA6006A3F8A /* CombineTestHelpers.swift */, 4B723E1726B000DC00E14D75 /* TemporaryFileCreator.swift */, 4BBF09222830812900EE1418 /* FileSystemDSL.swift */, 4BBF0924283083EC00EE1418 /* FileSystemDSLTests.swift */, @@ -12325,6 +12332,7 @@ 3706FE13293F661700E42796 /* ConfigurationStorageTests.swift in Sources */, 3706FE15293F661700E42796 /* PrivacyIconViewModelTests.swift in Sources */, 56A0543C2C20878E007D8FAB /* DuckSchemeHandlerTests.swift in Sources */, + EEEFA3442D142AB1006A3F8A /* CombineTestHelpers.swift in Sources */, 370270C12C78EB13002E44E4 /* HomePageSettingsModelTests.swift in Sources */, B68412212B6A30680092F66A /* StringExtensionTests.swift in Sources */, 1D8C2FEE2B70F5D0005E4BBD /* MockViewSnapshotRenderer.swift in Sources */, @@ -12595,6 +12603,7 @@ B630E80229C887ED00363609 /* NSErrorAdditionalInfo.swift in Sources */, 3706FEA3293F662100E42796 /* CoreDataEncryptionTests.swift in Sources */, B60C6F8929B1CAB7007BFAA8 /* TestRunHelperInitializer.m in Sources */, + EEEFA3422D142AB1006A3F8A /* CombineTestHelpers.swift in Sources */, 560C6ECE2CCA5BCB00D411E2 /* ContextualDaxDialogFactoryIntegrationTests.swift in Sources */, 560C6ED92CCA5CE700D411E2 /* FindInView.swift in Sources */, B60C6F8B29B1CAC0007BFAA8 /* FileManagerTempDirReplacement.swift in Sources */, @@ -12644,6 +12653,7 @@ 1D8B7D6A2A38BF050045C6F6 /* FireproofDomainsStoreMock.swift in Sources */, B630E7FF29C887ED00363609 /* NSErrorAdditionalInfo.swift in Sources */, 7BA4727D26F01BC400EAA165 /* CoreDataTestUtilities.swift in Sources */, + EEEFA3432D142AB1006A3F8A /* CombineTestHelpers.swift in Sources */, 560C6ECD2CCA5BCB00D411E2 /* ContextualDaxDialogFactoryIntegrationTests.swift in Sources */, 560C6ED82CCA5CE700D411E2 /* FindInView.swift in Sources */, B60C6F8829B1CAB6007BFAA8 /* TestRunHelperInitializer.m in Sources */, @@ -12897,6 +12907,7 @@ 9D84E4492CD4EE780046CD8B /* TestNavigationDelegate.swift in Sources */, 9D84E4442CD4EE600046CD8B /* EncryptionKeyStoreMock.swift in Sources */, 9D0668C92CD4F04600D6C9EA /* FireproofDomainsStoreMock.swift in Sources */, + EEEFA3402D142AB1006A3F8A /* CombineTestHelpers.swift in Sources */, 9D84E44A2CD4EE7C0046CD8B /* TestRunHelperInitializer.m in Sources */, 9D0668CC2CD4F06900D6C9EA /* FindInView.swift in Sources */, ); @@ -14127,6 +14138,7 @@ 4BA1A6FE258C5C1300F6F690 /* EncryptedValueTransformerTests.swift in Sources */, 85F69B3C25EDE81F00978E59 /* URLExtensionTests.swift in Sources */, 4B9292BA2667103100AD2C21 /* BookmarkNodePathTests.swift in Sources */, + EEEFA3412D142AB1006A3F8A /* CombineTestHelpers.swift in Sources */, 562532A02BC069180034D316 /* ZoomPopoverViewModelTests.swift in Sources */, 4B9292C02667103100AD2C21 /* BookmarkManagedObjectTests.swift in Sources */, 373A1AB228451ED400586521 /* BookmarksHTMLImporterTests.swift in Sources */, diff --git a/DuckDuckGo/Preferences/Model/SyncPreferences.swift b/DuckDuckGo/Preferences/Model/SyncPreferences.swift index 67e44a7bde..369e2fe1fc 100644 --- a/DuckDuckGo/Preferences/Model/SyncPreferences.swift +++ b/DuckDuckGo/Preferences/Model/SyncPreferences.swift @@ -578,13 +578,7 @@ extension SyncPreferences: ManagementDialogModelDelegate { try await loginAndShowPresentedDialog(recoveryKey, isRecovery: fromRecoveryScreen) } catch { if case SyncError.accountAlreadyExists = error { - if devices.count > 1 { - managementDialogModel.shouldShowSwitchAccountsMessage = true - } else { - switchAccounts(recoveryKey: recoveryKey) - managementDialogModel.endFlow() - } - PixelKit.fire(DebugEvent(GeneralPixel.syncLoginExistingAccountError(error: error))) + handleAccountAlreadyExists(recoveryKey) } else { managementDialogModel.syncErrorMessage = SyncErrorMessage(type: .unableToSyncToOtherDevice) } @@ -765,6 +759,16 @@ extension SyncPreferences: ManagementDialogModelDelegate { recoverDevice(recoveryCode: code, fromRecoveryScreen: fromRecoveryScreen) } + private func handleAccountAlreadyExists(_ recoveryKey: SyncCode.RecoveryKey) { + if devices.count > 1 { + managementDialogModel.shouldShowSwitchAccountsMessage = true + } else { + switchAccounts(recoveryKey: recoveryKey) + managementDialogModel.endFlow() + } + PixelKit.fire(DebugEvent(GeneralPixel.syncLoginExistingAccountError(error: SyncError.accountAlreadyExists))) + } + func switchAccounts(recoveryCode: String) { guard let recoveryKey = try? SyncCode.decodeBase64String(recoveryCode).recovery else { return @@ -772,7 +776,7 @@ extension SyncPreferences: ManagementDialogModelDelegate { switchAccounts(recoveryKey: recoveryKey) } - func switchAccounts(recoveryKey: SyncCode.RecoveryKey) { + private func switchAccounts(recoveryKey: SyncCode.RecoveryKey) { Task { [weak self] in guard let self else { return } do { diff --git a/UnitTests/Common/CombineTestHelpers.swift b/UnitTests/Common/CombineTestHelpers.swift new file mode 100644 index 0000000000..e67cd1df06 --- /dev/null +++ b/UnitTests/Common/CombineTestHelpers.swift @@ -0,0 +1,95 @@ +// +// CombineTestHelpers.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest +import Combine + +/* + Code based on snippet from https://www.swiftbysundell.com/articles/unit-testing-combine-based-swift-code/ + */ +public extension XCTestCase { + func waitForPublisher( + _ publisher: T, + timeout: TimeInterval = 10, + waitForFinish: Bool = true, + file: StaticString = #file, + line: UInt = #line + ) async throws -> T.Output { + // This time, we use Swift's Result type to keep track + // of the result of our Combine pipeline: + var result: Result? + let expectation = self.expectation(description: "Awaiting publisher") + expectation.assertForOverFulfill = false + + let cancellable = publisher.sink( + receiveCompletion: { completion in + switch completion { + case .failure(let error): + result = .failure(error) + case .finished: + break + } + + expectation.fulfill() + }, + receiveValue: { value in + result = .success(value) + if !waitForFinish { + expectation.fulfill() + } + } + ) + + // Just like before, we await the expectation that we + // created at the top of our test, and once done, we + // also cancel our cancellable to avoid getting any + // unused variable warnings: + await fulfillment(of: [expectation]) + cancellable.cancel() + + // Here we pass the original file and line number that + // our utility was called at, to tell XCTest to report + // any encountered errors at that original call site: + let unwrappedResult = try XCTUnwrap( + result, + "Awaited publisher did not produce any output", + file: file, + line: line + ) + + return try unwrappedResult.get() + } + + @discardableResult + func waitForPublisher( + _ publisher: T, + timeout: TimeInterval = 10, + file: StaticString = #file, + line: UInt = #line, + toEmit value: T.Output + ) async throws -> T.Output where T.Output: Equatable { + try await waitForPublisher( + publisher.first { + value == $0 + }, + waitForFinish: false + ) + } +} + + diff --git a/UnitTests/Sync/Mocks/MockDDGSyncing.swift b/UnitTests/Sync/Mocks/MockDDGSyncing.swift index e400ec9a8a..8bfe0971ce 100644 --- a/UnitTests/Sync/Mocks/MockDDGSyncing.swift +++ b/UnitTests/Sync/Mocks/MockDDGSyncing.swift @@ -24,7 +24,7 @@ import TestUtils class MockDDGSyncing: DDGSyncing { - let registeredDevices = [RegisteredDevice(id: "1", name: "Device 1", type: "desktop"), RegisteredDevice(id: "2", name: "Device 2", type: "mobile"), RegisteredDevice(id: "3", name: "Device 1", type: "desktop")] + var registeredDevices = [RegisteredDevice(id: "1", name: "Device 1", type: "desktop"), RegisteredDevice(id: "2", name: "Device 2", type: "mobile"), RegisteredDevice(id: "3", name: "Device 1", type: "desktop")] var disconnectCalled = false var dataProvidersSource: DataProvidersSource? @@ -67,8 +67,13 @@ class MockDDGSyncing: DDGSyncing { func createAccount(deviceName: String, deviceType: String) async throws { } + var stubLogin: [RegisteredDevice] = [] + lazy var spyLogin: (SyncCode.RecoveryKey, String, String) throws -> [RegisteredDevice] = { _, _, _ in + return self.stubLogin + } + func login(_ recoveryKey: SyncCode.RecoveryKey, deviceName: String, deviceType: String) async throws -> [RegisteredDevice] { - return [] + return try spyLogin(recoveryKey, deviceName, deviceType) } func remoteConnect() throws -> RemoteConnecting { diff --git a/UnitTests/Sync/SyncPreferencesTests.swift b/UnitTests/Sync/SyncPreferencesTests.swift index f1b1d40fa5..9687421eae 100644 --- a/UnitTests/Sync/SyncPreferencesTests.swift +++ b/UnitTests/Sync/SyncPreferencesTests.swift @@ -46,7 +46,7 @@ final class SyncPreferencesTests: XCTestCase { var appearancePreferences: AppearancePreferences! var syncPreferences: SyncPreferences! var pausedStateManager: MockSyncPausedStateManaging! - var testRecoveryCode = "some code" + var testRecoveryCode = "eyJyZWNvdmVyeSI6eyJ1c2VyX2lkIjoiMDZGODhFNzEtNDFBRS00RTUxLUE2UkRtRkEwOTcwMDE5QkYwIiwicHJpbWFyeV9rZXkiOiI1QTk3U3dsQVI5RjhZakJaU09FVXBzTktnSnJEYnE3aWxtUmxDZVBWazgwPSJ9fQ==" var cancellables: Set! var bookmarksDatabase: CoreDataDatabase! @@ -266,6 +266,118 @@ final class SyncPreferencesTests: XCTestCase { XCTAssertNil(syncPreferences.syncPausedButtonAction) } + func test_recoverDevice_accountAlreadyExists_oneDevice_disconnectsThenLogsInAgain() async { + // Must have an account to prevent devices being cleared + setUpWithSingleDevice(id: "1") + let firstLoginCalledExpectation = XCTestExpectation(description: "Login Called Once") + let secondLoginCalledExpectation = XCTestExpectation(description: "Login Called Again") + + ddgSyncing.spyLogin = { [weak self] _, _, _ in + self?.ddgSyncing.spyLogin = { [weak self] _, _, _ in + guard let self else { return [] } + // Assert disconnect before returning from login to ensure correct order + XCTAssert(ddgSyncing.disconnectCalled) + secondLoginCalledExpectation.fulfill() + return [RegisteredDevice(id: "1", name: "iPhone", type: "iPhone"), RegisteredDevice(id: "2", name: "Macbook Pro", type: "Macbook Pro")] + } + firstLoginCalledExpectation.fulfill() + throw SyncError.accountAlreadyExists + } + + syncPreferences.recoverDevice(recoveryCode: testRecoveryCode, fromRecoveryScreen: false) + + await fulfillment(of: [firstLoginCalledExpectation, secondLoginCalledExpectation], timeout: 5.0) + } + + func test_recoverDevice_accountAlreadyExists_oneDevice_updatesDevicesWithReturnedDevices() async throws { + // Must have an account to prevent devices being cleared + setUpWithSingleDevice(id: "1") + + ddgSyncing.spyLogin = { [weak self] _, _, _ in + self?.ddgSyncing.spyLogin = { _, _, _ in + return [RegisteredDevice(id: "1", name: "iPhone", type: "iPhone"), RegisteredDevice(id: "2", name: "Macbook Pro", type: "Macbook Pro")] + } + throw SyncError.accountAlreadyExists + } + + syncPreferences.recoverDevice(recoveryCode: testRecoveryCode, fromRecoveryScreen: false) + + let deviceIDsPublisher = syncPreferences.$devices.map { $0.map { $0.id } } + _ = try await waitForPublisher(deviceIDsPublisher, timeout: 15.0, toEmit: ["1", "2"]) + } + + func test_recoverDevice_accountAlreadyExists_oneDevice_endsFlow() async throws { + setUpWithSingleDevice(id: "1") + // Removal of currentDialog indicates end of flow + managementDialogModel.currentDialog = .enterRecoveryCode(code: "") + let loginCalledExpectation = XCTestExpectation(description: "Login Called Once") + + ddgSyncing.spyLogin = { [weak self] _, _, _ in + self?.ddgSyncing.spyLogin = { _, _, _ in + return [RegisteredDevice(id: "1", name: "iPhone", type: "iPhone"), RegisteredDevice(id: "2", name: "Macbook Pro", type: "Macbook Pro")] + } + loginCalledExpectation.fulfill() + throw SyncError.accountAlreadyExists + } + + syncPreferences.recoverDevice(recoveryCode: testRecoveryCode, fromRecoveryScreen: false) + await fulfillment(of: [loginCalledExpectation], timeout: 5.0) + + XCTAssertNil(managementDialogModel.currentDialog) + } + + func test_recoverDevice_accountAlreadyExists_twoOrMoreDevices_showsAccountSwitchingMessage() async throws { + // Must have an account to prevent devices being cleared + ddgSyncing.account = SyncAccount(deviceId: "1", deviceName: "", deviceType: "", userId: "", primaryKey: Data(), secretKey: Data(), token: nil, state: .active) + syncPreferences.devices = [SyncDevice(RegisteredDevice(id: "1", name: "iPhone", type: "iPhone")), SyncDevice(RegisteredDevice(id: "2", name: "iPhone", type: "iPhone"))] + + let loginCalledExpectation = XCTestExpectation(description: "Login Called Again") + + ddgSyncing.spyLogin = { _, _, _ in + loginCalledExpectation.fulfill() + throw SyncError.accountAlreadyExists + } + + syncPreferences.recoverDevice(recoveryCode: testRecoveryCode, fromRecoveryScreen: false) + + await fulfillment(of: [loginCalledExpectation], timeout: 5.0) + + XCTAssert(managementDialogModel.shouldShowSwitchAccountsMessage) + } + + func test_switchAccounts_disconnectsThenLogsInAgain() async throws { + let loginCalledExpectation = XCTestExpectation(description: "Login Called Again") + + ddgSyncing.spyLogin = { [weak self] _, _, _ in + guard let self else { return [] } + // Assert disconnect before returning from login to ensure correct order + XCTAssert(ddgSyncing.disconnectCalled) + loginCalledExpectation.fulfill() + return [RegisteredDevice(id: "1", name: "iPhone", type: "iPhone"), RegisteredDevice(id: "2", name: "Macbook Pro", type: "Macbook Pro")] + } + + syncPreferences.switchAccounts(recoveryCode: testRecoveryCode) + + await fulfillment(of: [loginCalledExpectation], timeout: 5.0) + } + + func test_switchAccounts_updatesDevicesWithReturnedDevices() async throws { + setUpWithSingleDevice(id: "1") + + ddgSyncing.spyLogin = { _, _, _ in + return [RegisteredDevice(id: "1", name: "iPhone", type: "iPhone"), RegisteredDevice(id: "2", name: "Macbook Pro", type: "Macbook Pro")] + } + + syncPreferences.switchAccounts(recoveryCode: testRecoveryCode) + let deviceIDsPublisher = syncPreferences.$devices.map { $0.map { $0.id } } + try await waitForPublisher(deviceIDsPublisher, toEmit: ["1", "2"]) + } + + private func setUpWithSingleDevice(id: String) { + ddgSyncing.account = SyncAccount(deviceId: id, deviceName: "iPhone", deviceType: "iPhone", userId: "", primaryKey: Data(), secretKey: Data(), token: nil, state: .active) + ddgSyncing.registeredDevices = [RegisteredDevice(id: id, name: "iPhone", type: "iPhone")] + syncPreferences.devices = [SyncDevice(RegisteredDevice(id: id, name: "iPhone", type: "iPhone"))] + } } class CapturingScheduler: Scheduling { From 0d487b360fa3079dbd2dba5dbd51fdd548b140e4 Mon Sep 17 00:00:00 2001 From: Graeme Arthur <2030310+graeme@users.noreply.github.com> Date: Thu, 19 Dec 2024 14:34:19 +0100 Subject: [PATCH 06/12] Add Pixels --- DuckDuckGo.xcodeproj/project.pbxproj | 6 ++++++ .../Preferences/Model/SyncPreferences.swift | 14 +++++++++---- .../ViewModels/ManagementDialogModel.swift | 21 ++++++++++++------- .../SyncUI/Views/ManagementDialog.swift | 2 +- UnitTests/Sync/SyncPreferencesTests.swift | 4 ++-- 5 files changed, 33 insertions(+), 14 deletions(-) diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 54a78969d2..ddc49819ec 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -3146,6 +3146,8 @@ EEEFA3422D142AB1006A3F8A /* CombineTestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEEFA33F2D142AA6006A3F8A /* CombineTestHelpers.swift */; }; EEEFA3432D142AB1006A3F8A /* CombineTestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEEFA33F2D142AA6006A3F8A /* CombineTestHelpers.swift */; }; EEEFA3442D142AB1006A3F8A /* CombineTestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEEFA33F2D142AA6006A3F8A /* CombineTestHelpers.swift */; }; + EEEFA3462D145328006A3F8A /* SyncPixels.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEEFA3452D145322006A3F8A /* SyncPixels.swift */; }; + EEEFA3472D145328006A3F8A /* SyncPixels.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEEFA3452D145322006A3F8A /* SyncPixels.swift */; }; EEF12E6F2A2111880023E6BF /* MacPacketTunnelProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEF12E6D2A2111880023E6BF /* MacPacketTunnelProvider.swift */; }; EEF53E182950CED5002D78F4 /* JSAlertViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEF53E172950CED5002D78F4 /* JSAlertViewModelTests.swift */; }; F10C99422C7E20A1005568B4 /* Logger+DBPBackgroundAgent.swift in Sources */ = {isa = PBXBuildFile; fileRef = F17E7DDB2C7C7F8100907A84 /* Logger+DBPBackgroundAgent.swift */; }; @@ -4983,6 +4985,7 @@ EEE11C5D2C7F54AD000ABD7E /* AutofillLoginImportState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutofillLoginImportState.swift; sourceTree = ""; }; EEE50C282C38249C003DD7FF /* OptionalExtension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OptionalExtension.swift; sourceTree = ""; }; EEEFA33F2D142AA6006A3F8A /* CombineTestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CombineTestHelpers.swift; sourceTree = ""; }; + EEEFA3452D145322006A3F8A /* SyncPixels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncPixels.swift; sourceTree = ""; }; EEF12E6D2A2111880023E6BF /* MacPacketTunnelProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacPacketTunnelProvider.swift; sourceTree = ""; }; EEF53E172950CED5002D78F4 /* JSAlertViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSAlertViewModelTests.swift; sourceTree = ""; }; F118EA7C2BEA2B8700F77634 /* DefaultSubscriptionFeatureAvailability+DefaultInitializer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "DefaultSubscriptionFeatureAvailability+DefaultInitializer.swift"; sourceTree = ""; }; @@ -5863,6 +5866,7 @@ 3775913429AB99DA00E26367 /* Sync */ = { isa = PBXGroup; children = ( + EEEFA3452D145322006A3F8A /* SyncPixels.swift */, C1935A162C88F9AA001AD72D /* Promotion */, 566B73672BECBF4400FF1959 /* Utilities */, 377D801A2AB47FA1002AF251 /* SettingSyncHandlers */, @@ -11390,6 +11394,7 @@ 56BA1E8B2BB1CB5B001CF69F /* CertificateTrustEvaluator.swift in Sources */, B6E1491129A5C30A00AAFBE8 /* FBProtectionTabExtension.swift in Sources */, 1EC711502D033D200009EB5C /* UserText+NetworkProtection+Shared.swift in Sources */, + EEEFA3472D145328006A3F8A /* SyncPixels.swift in Sources */, 3706FAC4293F65D500E42796 /* PrintingUserScript.swift in Sources */, 1D01A3D92B88DF8B00FE8150 /* PreferencesSyncView.swift in Sources */, 9D9AE86C2AA76D1B0026E7DC /* LoginItemsManager.swift in Sources */, @@ -13748,6 +13753,7 @@ B69B503F2726A12500758A2B /* LocalStatisticsStore.swift in Sources */, B689ECD526C247DB006FB0C5 /* BackForwardListItem.swift in Sources */, B69B50572727D16900758A2B /* AtbAndVariantCleanup.swift in Sources */, + EEEFA3462D145328006A3F8A /* SyncPixels.swift in Sources */, AA3D531527A1ED9300074EC1 /* FeedbackWindow.swift in Sources */, B6685E3F29A606190043D2EE /* WorkspaceProtocol.swift in Sources */, B6ABC5962B4861D4008343B9 /* FocusableTextField.swift in Sources */, diff --git a/DuckDuckGo/Preferences/Model/SyncPreferences.swift b/DuckDuckGo/Preferences/Model/SyncPreferences.swift index 369e2fe1fc..2193c7da59 100644 --- a/DuckDuckGo/Preferences/Model/SyncPreferences.swift +++ b/DuckDuckGo/Preferences/Model/SyncPreferences.swift @@ -762,6 +762,7 @@ extension SyncPreferences: ManagementDialogModelDelegate { private func handleAccountAlreadyExists(_ recoveryKey: SyncCode.RecoveryKey) { if devices.count > 1 { managementDialogModel.shouldShowSwitchAccountsMessage = true + PixelKit.fire(SyncSwitchAccountPixelKitEvent.syncAskUserToSwitchAccount.withoutMacPrefix) } else { switchAccounts(recoveryKey: recoveryKey) managementDialogModel.endFlow() @@ -769,7 +770,8 @@ extension SyncPreferences: ManagementDialogModelDelegate { PixelKit.fire(DebugEvent(GeneralPixel.syncLoginExistingAccountError(error: SyncError.accountAlreadyExists))) } - func switchAccounts(recoveryCode: String) { + func userConfirmedSwitchAccounts(recoveryCode: String) { + PixelKit.fire(SyncSwitchAccountPixelKitEvent.syncUserAcceptedSwitchingAccount.withoutMacPrefix) guard let recoveryKey = try? SyncCode.decodeBase64String(recoveryCode).recovery else { return } @@ -782,7 +784,7 @@ extension SyncPreferences: ManagementDialogModelDelegate { do { try await syncService.disconnect() } catch { - // TODO: Send sync_user_switched_logout_error pixel + PixelKit.fire(SyncSwitchAccountPixelKitEvent.syncUserSwitchedLogoutError.withoutMacPrefix) } do { @@ -790,9 +792,13 @@ extension SyncPreferences: ManagementDialogModelDelegate { let registeredDevices = try await syncService.login(recoveryKey, deviceName: device.name, deviceType: device.type) await mapDevices(registeredDevices) } catch { - // TODO: Send sync_user_switched_login_error pixel + PixelKit.fire(SyncSwitchAccountPixelKitEvent.syncUserSwitchedLoginError.withoutMacPrefix) } - // TODO: Send sync_user_switched_account_pixel + PixelKit.fire(SyncSwitchAccountPixelKitEvent.syncUserSwitchedAccount.withoutMacPrefix) } } + + func switchAccountsCancelled() { + PixelKit.fire(SyncSwitchAccountPixelKitEvent.syncUserCancelledSwitchingAccount.withoutMacPrefix) + } } diff --git a/LocalPackages/SyncUI/Sources/SyncUI/ViewModels/ManagementDialogModel.swift b/LocalPackages/SyncUI/Sources/SyncUI/ViewModels/ManagementDialogModel.swift index 4522ee2352..cc3d602a79 100644 --- a/LocalPackages/SyncUI/Sources/SyncUI/ViewModels/ManagementDialogModel.swift +++ b/LocalPackages/SyncUI/Sources/SyncUI/ViewModels/ManagementDialogModel.swift @@ -33,7 +33,8 @@ public protocol ManagementDialogModelDelegate: AnyObject { func enterRecoveryCodePressed() func copyCode() func openSystemPasswordSettings() - func switchAccounts(recoveryCode: String) + func userConfirmedSwitchAccounts(recoveryCode: String) + func switchAccountsCancelled() } public final class ManagementDialogModel: ObservableObject { @@ -59,16 +60,22 @@ public final class ManagementDialogModel: ObservableObject { } public func endFlow() { + if shouldShowSwitchAccountsMessage { + delegate?.switchAccountsCancelled() + } + doEndFlow() + } + + public func userConfirmedSwitchAccounts(recoveryCode: String) { + delegate?.userConfirmedSwitchAccounts(recoveryCode: recoveryCode) + doEndFlow() + } + + private func doEndFlow() { syncErrorMessage?.type.onButtonPressed(delegate: delegate) syncErrorMessage = nil currentDialog = nil } - public func switchAccounts(recoveryCode: String) { - shouldShowSwitchAccountsMessage = false - delegate?.switchAccounts(recoveryCode: recoveryCode) - endFlow() - } - private var shouldShowErrorMessageCancellable: AnyCancellable? } diff --git a/LocalPackages/SyncUI/Sources/SyncUI/Views/ManagementDialog.swift b/LocalPackages/SyncUI/Sources/SyncUI/Views/ManagementDialog.swift index 7b4a743ff1..847c9892a2 100644 --- a/LocalPackages/SyncUI/Sources/SyncUI/Views/ManagementDialog.swift +++ b/LocalPackages/SyncUI/Sources/SyncUI/Views/ManagementDialog.swift @@ -75,7 +75,7 @@ public struct ManagementDialog: View { title: Text(UserText.syncAlertSwitchAccountTitle), message: Text(UserText.syncAlertSwitchAccountMessage), primaryButton: .default(Text(UserText.syncAlertSwitchAccountButton)) { - model.switchAccounts(recoveryCode: recoveryCodeModel.recoveryCode) + model.userConfirmedSwitchAccounts(recoveryCode: recoveryCodeModel.recoveryCode) }, secondaryButton: .cancel { model.endFlow() diff --git a/UnitTests/Sync/SyncPreferencesTests.swift b/UnitTests/Sync/SyncPreferencesTests.swift index 9687421eae..9962480dcc 100644 --- a/UnitTests/Sync/SyncPreferencesTests.swift +++ b/UnitTests/Sync/SyncPreferencesTests.swift @@ -356,7 +356,7 @@ final class SyncPreferencesTests: XCTestCase { return [RegisteredDevice(id: "1", name: "iPhone", type: "iPhone"), RegisteredDevice(id: "2", name: "Macbook Pro", type: "Macbook Pro")] } - syncPreferences.switchAccounts(recoveryCode: testRecoveryCode) + syncPreferences.userConfirmedSwitchAccounts(recoveryCode: testRecoveryCode) await fulfillment(of: [loginCalledExpectation], timeout: 5.0) } @@ -368,7 +368,7 @@ final class SyncPreferencesTests: XCTestCase { return [RegisteredDevice(id: "1", name: "iPhone", type: "iPhone"), RegisteredDevice(id: "2", name: "Macbook Pro", type: "Macbook Pro")] } - syncPreferences.switchAccounts(recoveryCode: testRecoveryCode) + syncPreferences.userConfirmedSwitchAccounts(recoveryCode: testRecoveryCode) let deviceIDsPublisher = syncPreferences.$devices.map { $0.map { $0.id } } try await waitForPublisher(deviceIDsPublisher, toEmit: ["1", "2"]) } From 3922e7cd78295dce782f5b89a98832dfcfa35cf0 Mon Sep 17 00:00:00 2001 From: Graeme Arthur <2030310+graeme@users.noreply.github.com> Date: Thu, 19 Dec 2024 14:37:12 +0100 Subject: [PATCH 07/12] Add forgotten file --- DuckDuckGo/Sync/SyncPixels.swift | 51 ++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 DuckDuckGo/Sync/SyncPixels.swift diff --git a/DuckDuckGo/Sync/SyncPixels.swift b/DuckDuckGo/Sync/SyncPixels.swift new file mode 100644 index 0000000000..3d2750eca6 --- /dev/null +++ b/DuckDuckGo/Sync/SyncPixels.swift @@ -0,0 +1,51 @@ +// +// SyncPixels.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// 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 PixelKit + +enum SyncSwitchAccountPixelKitEvent: PixelKitEventV2 { + case syncAskUserToSwitchAccount + case syncUserAcceptedSwitchingAccount + case syncUserCancelledSwitchingAccount + case syncUserSwitchedAccount + case syncUserSwitchedLogoutError + case syncUserSwitchedLoginError + + var name: String { + switch self { + case .syncAskUserToSwitchAccount: return "sync_ask_user_to_switch_account" + case .syncUserAcceptedSwitchingAccount: return "sync_user_accepted_switching_account" + case .syncUserCancelledSwitchingAccount: return "sync_user_cancelled_switching_account" + case .syncUserSwitchedAccount: return "sync_user_switched_account" + case .syncUserSwitchedLogoutError: return "sync_user_switched_logout_error" + case .syncUserSwitchedLoginError: return "sync_user_switched_login_error" + } + } + + var parameters: [String: String]? { + nil + } + + var error: (any Error)? { + nil + } + + var withoutMacPrefix: NonStandardEvent { + NonStandardEvent(self) + } +} From ce3497feff96d85537a91003827943d82e793ad9 Mon Sep 17 00:00:00 2001 From: Graeme Arthur <2030310+graeme@users.noreply.github.com> Date: Thu, 19 Dec 2024 14:37:30 +0100 Subject: [PATCH 08/12] SwiftLint --- UnitTests/Common/CombineTestHelpers.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/UnitTests/Common/CombineTestHelpers.swift b/UnitTests/Common/CombineTestHelpers.swift index e67cd1df06..97b10e299e 100644 --- a/UnitTests/Common/CombineTestHelpers.swift +++ b/UnitTests/Common/CombineTestHelpers.swift @@ -91,5 +91,3 @@ public extension XCTestCase { ) } } - - From 332097fc1719c5d769f44e326d69fdb753f44862 Mon Sep 17 00:00:00 2001 From: Anya Mallon Date: Fri, 20 Dec 2024 10:54:43 +0100 Subject: [PATCH 09/12] First translations --- .../SyncUI/Resources/Localizable.xcstrings | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/LocalPackages/SyncUI/Sources/SyncUI/Resources/Localizable.xcstrings b/LocalPackages/SyncUI/Sources/SyncUI/Resources/Localizable.xcstrings index cba624580d..5727b2c0cb 100644 --- a/LocalPackages/SyncUI/Sources/SyncUI/Resources/Localizable.xcstrings +++ b/LocalPackages/SyncUI/Sources/SyncUI/Resources/Localizable.xcstrings @@ -315,11 +315,59 @@ "comment" : "Description for switching sync accounts when there's two", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dieses Gerät ist bereits synchronisiert. Bist du sicher, dass du es mit einem anderen Back-up oder Gerät synchronisieren möchtest? Beim Wechsel werden keine bereits mit diesem Gerät synchronisierten Daten entfernt." + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "This device is already synced, are you sure you want to sync it with a different back up or device? Switching won't remove any data already synced to this device." } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Este dispositivo ya está sincronizado. ¿Seguro que deseas sincronizarlo con una copia de seguridad o con un dispositivo diferente? Cambiar no eliminará ningún dato ya sincronizado en este dispositivo." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cet appareil est déjà synchronisé. Voulez-vous vraiment le synchroniser avec une autre sauvegarde ou un autre appareil ? Ce changement ne supprimera aucune donnée déjà synchronisée sur cet appareil." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Questo dispositivo è già sincronizzato. Vuoi davvero sincronizzarlo con un backup o un dispositivo diverso? L'operazione non rimuoverà alcun dato già sincronizzato su questo dispositivo." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dit apparaat is al gesynchroniseerd, weet je zeker dat je het met een andere back-up of een ander apparaat wilt synchroniseren? Als je overschakelt, worden er geen gegevens verwijderd die al met dit apparaat zijn gesynchroniseerd." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "To urządzenie jest już synchronizowane, czy na pewno chcesz je synchronizować z inną kopią zapasową lub urządzeniem? Przełączenie nie usunie żadnych danych już zsynchronizowanych z tym urządzeniem." + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Este dispositivo já está sincronizado, tens a certeza de que queres sincronizá-lo com uma cópia de segurança ou um dispositivo diferente? A mudança não vai remover nenhum dado já sincronizado com este dispositivo." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Это устройство уже синхронизировано. Действительно синхронизировать его с другой резервной копией или устройством? Переключение не приведет к удалению данных, синхронизированных с этим устройством ранее." + } } } }, From 12519f9f34e093b4a17882dfa85eebf051d51997 Mon Sep 17 00:00:00 2001 From: Anya Mallon Date: Fri, 20 Dec 2024 11:23:08 +0100 Subject: [PATCH 10/12] Second set of translations --- .../SyncUI/Resources/Localizable.xcstrings | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/LocalPackages/SyncUI/Sources/SyncUI/Resources/Localizable.xcstrings b/LocalPackages/SyncUI/Sources/SyncUI/Resources/Localizable.xcstrings index 5727b2c0cb..b03a99e273 100644 --- a/LocalPackages/SyncUI/Sources/SyncUI/Resources/Localizable.xcstrings +++ b/LocalPackages/SyncUI/Sources/SyncUI/Resources/Localizable.xcstrings @@ -303,11 +303,59 @@ "comment" : "Switch account title in alert", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zu einer anderen Synchronisierung wechseln?" + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "Switch to a different Sync?" } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "¿Cambiar a una sincronización diferente?" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Passer à une autre synchronisation ?" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vuoi passare a una sincronizzazione diversa?" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Overschakelen naar een andere synchronisatie?" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Przełączyć na inną synchronizację?" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mudar para uma sincronização diferente?" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Переключиться на другую синхронизацию?" + } } } }, @@ -375,11 +423,59 @@ "comment" : "Switch account button in alert", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Synchronisierung wechseln" + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "Switch Sync" } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cambiar sincronización" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Changer de synchronisation" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cambia sincronizzazione" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Schakelen tussen synchronisatie" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Przełącz synchronizację" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mudar sincronização" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Переключить синхронизацию" + } } } }, From aaae1d0c24a84abb99762bebea0444b43c5034e0 Mon Sep 17 00:00:00 2001 From: Graeme Arthur <2030310+graeme@users.noreply.github.com> Date: Fri, 20 Dec 2024 11:33:28 +0100 Subject: [PATCH 11/12] Make sure flow isnt ended before switch finishes --- .../Preferences/Model/SyncPreferences.swift | 40 ++++++++++--------- .../ViewModels/ManagementDialogModel.swift | 1 - 2 files changed, 21 insertions(+), 20 deletions(-) diff --git a/DuckDuckGo/Preferences/Model/SyncPreferences.swift b/DuckDuckGo/Preferences/Model/SyncPreferences.swift index 2193c7da59..0ebdd3a67b 100644 --- a/DuckDuckGo/Preferences/Model/SyncPreferences.swift +++ b/DuckDuckGo/Preferences/Model/SyncPreferences.swift @@ -764,8 +764,10 @@ extension SyncPreferences: ManagementDialogModelDelegate { managementDialogModel.shouldShowSwitchAccountsMessage = true PixelKit.fire(SyncSwitchAccountPixelKitEvent.syncAskUserToSwitchAccount.withoutMacPrefix) } else { - switchAccounts(recoveryKey: recoveryKey) - managementDialogModel.endFlow() + Task { @MainActor in + await switchAccounts(recoveryKey: recoveryKey) + managementDialogModel.endFlow() + } } PixelKit.fire(DebugEvent(GeneralPixel.syncLoginExistingAccountError(error: SyncError.accountAlreadyExists))) } @@ -775,27 +777,27 @@ extension SyncPreferences: ManagementDialogModelDelegate { guard let recoveryKey = try? SyncCode.decodeBase64String(recoveryCode).recovery else { return } - switchAccounts(recoveryKey: recoveryKey) + Task { + await switchAccounts(recoveryKey: recoveryKey) + managementDialogModel.endFlow() + } } - private func switchAccounts(recoveryKey: SyncCode.RecoveryKey) { - Task { [weak self] in - guard let self else { return } - do { - try await syncService.disconnect() - } catch { - PixelKit.fire(SyncSwitchAccountPixelKitEvent.syncUserSwitchedLogoutError.withoutMacPrefix) - } + private func switchAccounts(recoveryKey: SyncCode.RecoveryKey) async { + do { + try await syncService.disconnect() + } catch { + PixelKit.fire(SyncSwitchAccountPixelKitEvent.syncUserSwitchedLogoutError.withoutMacPrefix) + } - do { - let device = deviceInfo() - let registeredDevices = try await syncService.login(recoveryKey, deviceName: device.name, deviceType: device.type) - await mapDevices(registeredDevices) - } catch { - PixelKit.fire(SyncSwitchAccountPixelKitEvent.syncUserSwitchedLoginError.withoutMacPrefix) - } - PixelKit.fire(SyncSwitchAccountPixelKitEvent.syncUserSwitchedAccount.withoutMacPrefix) + do { + let device = deviceInfo() + let registeredDevices = try await syncService.login(recoveryKey, deviceName: device.name, deviceType: device.type) + await mapDevices(registeredDevices) + } catch { + PixelKit.fire(SyncSwitchAccountPixelKitEvent.syncUserSwitchedLoginError.withoutMacPrefix) } + PixelKit.fire(SyncSwitchAccountPixelKitEvent.syncUserSwitchedAccount.withoutMacPrefix) } func switchAccountsCancelled() { diff --git a/LocalPackages/SyncUI/Sources/SyncUI/ViewModels/ManagementDialogModel.swift b/LocalPackages/SyncUI/Sources/SyncUI/ViewModels/ManagementDialogModel.swift index cc3d602a79..92cb4e5574 100644 --- a/LocalPackages/SyncUI/Sources/SyncUI/ViewModels/ManagementDialogModel.swift +++ b/LocalPackages/SyncUI/Sources/SyncUI/ViewModels/ManagementDialogModel.swift @@ -68,7 +68,6 @@ public final class ManagementDialogModel: ObservableObject { public func userConfirmedSwitchAccounts(recoveryCode: String) { delegate?.userConfirmedSwitchAccounts(recoveryCode: recoveryCode) - doEndFlow() } private func doEndFlow() { From 52a0d4e10cbac5caab9c6381e1faf46f1ee31612 Mon Sep 17 00:00:00 2001 From: Graeme Arthur <2030310+graeme@users.noreply.github.com> Date: Fri, 20 Dec 2024 11:44:19 +0100 Subject: [PATCH 12/12] back up -> backup --- .../SyncUI/Resources/Localizable.xcstrings | 18 +++++++++--------- .../Sources/SyncUI/internal/UserText.swift | 2 +- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/LocalPackages/SyncUI/Sources/SyncUI/Resources/Localizable.xcstrings b/LocalPackages/SyncUI/Sources/SyncUI/Resources/Localizable.xcstrings index b03a99e273..11162398d5 100644 --- a/LocalPackages/SyncUI/Sources/SyncUI/Resources/Localizable.xcstrings +++ b/LocalPackages/SyncUI/Sources/SyncUI/Resources/Localizable.xcstrings @@ -365,55 +365,55 @@ "localizations" : { "de" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Dieses Gerät ist bereits synchronisiert. Bist du sicher, dass du es mit einem anderen Back-up oder Gerät synchronisieren möchtest? Beim Wechsel werden keine bereits mit diesem Gerät synchronisierten Daten entfernt." } }, "en" : { "stringUnit" : { "state" : "new", - "value" : "This device is already synced, are you sure you want to sync it with a different back up or device? Switching won't remove any data already synced to this device." + "value" : "This device is already synced, are you sure you want to sync it with a different backup or device? Switching won't remove any data already synced to this device." } }, "es" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Este dispositivo ya está sincronizado. ¿Seguro que deseas sincronizarlo con una copia de seguridad o con un dispositivo diferente? Cambiar no eliminará ningún dato ya sincronizado en este dispositivo." } }, "fr" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Cet appareil est déjà synchronisé. Voulez-vous vraiment le synchroniser avec une autre sauvegarde ou un autre appareil ? Ce changement ne supprimera aucune donnée déjà synchronisée sur cet appareil." } }, "it" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Questo dispositivo è già sincronizzato. Vuoi davvero sincronizzarlo con un backup o un dispositivo diverso? L'operazione non rimuoverà alcun dato già sincronizzato su questo dispositivo." } }, "nl" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Dit apparaat is al gesynchroniseerd, weet je zeker dat je het met een andere back-up of een ander apparaat wilt synchroniseren? Als je overschakelt, worden er geen gegevens verwijderd die al met dit apparaat zijn gesynchroniseerd." } }, "pl" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "To urządzenie jest już synchronizowane, czy na pewno chcesz je synchronizować z inną kopią zapasową lub urządzeniem? Przełączenie nie usunie żadnych danych już zsynchronizowanych z tym urządzeniem." } }, "pt" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Este dispositivo já está sincronizado, tens a certeza de que queres sincronizá-lo com uma cópia de segurança ou um dispositivo diferente? A mudança não vai remover nenhum dado já sincronizado com este dispositivo." } }, "ru" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Это устройство уже синхронизировано. Действительно синхронизировать его с другой резервной копией или устройством? Переключение не приведет к удалению данных, синхронизированных с этим устройством ранее." } } diff --git a/LocalPackages/SyncUI/Sources/SyncUI/internal/UserText.swift b/LocalPackages/SyncUI/Sources/SyncUI/internal/UserText.swift index 30a81365ba..962c1fee0b 100644 --- a/LocalPackages/SyncUI/Sources/SyncUI/internal/UserText.swift +++ b/LocalPackages/SyncUI/Sources/SyncUI/internal/UserText.swift @@ -204,7 +204,7 @@ enum UserText { static let unableCreateRecoveryPdfDescription = NSLocalizedString("alert.unable-to-create-recovery-pdf-description", bundle: Bundle.module, value: "Unable to create the recovery PDF.", comment: "Description for unable to create recovery pdf error") public static let syncAlertSwitchAccountTitle = NSLocalizedString("alert.sync-switch-account-button", value: "Switch to a different Sync?", comment: "Switch account title in alert") - public static let syncAlertSwitchAccountMessage = NSLocalizedString("alert.sync-switch-account-message", value: "This device is already synced, are you sure you want to sync it with a different back up or device? Switching won't remove any data already synced to this device.", comment: "Description for switching sync accounts when there's two") + public static let syncAlertSwitchAccountMessage = NSLocalizedString("alert.sync-switch-account-message", value: "This device is already synced, are you sure you want to sync it with a different backup or device? Switching won't remove any data already synced to this device.", comment: "Description for switching sync accounts when there's two") public static let syncAlertSwitchAccountButton = NSLocalizedString("alert.sync-switch-sync-button", value: "Switch Sync", comment: "Switch account button in alert") static let fetchFaviconsOnboardingTitle = NSLocalizedString("prefrences.sync.fetch-favicons-onboarding-title", bundle: Bundle.module, value: "Download Missing Icons?", comment: "Title for fetch favicons onboarding dialog")