diff --git a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/EnterCodeViewModel.kt b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/EnterCodeViewModel.kt index 7a505bca3fa2..9514d5c9e37d 100644 --- a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/EnterCodeViewModel.kt +++ b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/EnterCodeViewModel.kt @@ -91,10 +91,12 @@ class EnterCodeViewModel @Inject constructor( private suspend fun authFlow( pastedCode: String, ) { - val userSignedIn = syncAccountRepository.isSignedIn() + val previousPrimaryKey = syncAccountRepository.getAccountInfo().primaryKey when (val result = syncAccountRepository.processCode(pastedCode)) { is Result.Success -> { - val commandSuccess = if (userSignedIn) { + val postProcessCodePK = syncAccountRepository.getAccountInfo().primaryKey + val userSwitchedAccount = previousPrimaryKey.isNotBlank() && previousPrimaryKey != postProcessCodePK + val commandSuccess = if (userSwitchedAccount) { syncPixels.fireUserSwitchedAccount() SwitchAccountSuccess } else { diff --git a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncWithAnotherActivityViewModel.kt b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncWithAnotherActivityViewModel.kt index 0c186be3ea44..ccb2badda2c1 100644 --- a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncWithAnotherActivityViewModel.kt +++ b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncWithAnotherActivityViewModel.kt @@ -41,7 +41,6 @@ import com.duckduckgo.sync.impl.getOrNull import com.duckduckgo.sync.impl.onFailure import com.duckduckgo.sync.impl.onSuccess import com.duckduckgo.sync.impl.pixels.SyncPixels -import com.duckduckgo.sync.impl.ui.EnterCodeViewModel.Command import com.duckduckgo.sync.impl.ui.SyncWithAnotherActivityViewModel.Command.AskToSwitchAccount import com.duckduckgo.sync.impl.ui.SyncWithAnotherActivityViewModel.Command.FinishWithError import com.duckduckgo.sync.impl.ui.SyncWithAnotherActivityViewModel.Command.LoginSuccess @@ -136,15 +135,17 @@ class SyncWithAnotherActivityViewModel @Inject constructor( fun onQRCodeScanned(qrCode: String) { viewModelScope.launch(dispatchers.io()) { - val userSignedIn = syncAccountRepository.isSignedIn() + val previousPrimaryKey = syncAccountRepository.getAccountInfo().primaryKey when (val result = syncAccountRepository.processCode(qrCode)) { is Error -> { emitError(result, qrCode) } is Success -> { + val postProcessCodePK = syncAccountRepository.getAccountInfo().primaryKey syncPixels.fireLoginPixel() - val commandSuccess = if (userSignedIn) { + val userSwitchedAccount = previousPrimaryKey.isNotBlank() && previousPrimaryKey != postProcessCodePK + val commandSuccess = if (userSwitchedAccount) { syncPixels.fireUserSwitchedAccount() SwitchAccountSuccess } else { diff --git a/sync/sync-impl/src/test/java/com/duckduckgo/sync/SyncAccountFixtures.kt b/sync/sync-impl/src/test/java/com/duckduckgo/sync/SyncAccountFixtures.kt new file mode 100644 index 000000000000..fda32de53a37 --- /dev/null +++ b/sync/sync-impl/src/test/java/com/duckduckgo/sync/SyncAccountFixtures.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2025 DuckDuckGo + * + * 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. + */ + +package com.duckduckgo.sync + +import com.duckduckgo.sync.impl.AccountInfo + +object SyncAccountFixtures { + val accountA = AccountInfo( + userId = "userIdA", + primaryKey = "primaryKeyA", + deviceName = "deviceNameA", + deviceId = "deviceIdA", + isSignedIn = true, + secretKey = "secretKeyA", + ) + + val accountB = AccountInfo( + userId = "userIdB", + primaryKey = "primaryKeyB", + deviceName = "deviceNameB", + deviceId = "deviceIdB", + isSignedIn = true, + secretKey = "secretKeyB", + ) + + val noAccount = AccountInfo() +} diff --git a/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/ui/EnterCodeViewModelTest.kt b/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/ui/EnterCodeViewModelTest.kt index 4979289f791b..bab80c41f5cc 100644 --- a/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/ui/EnterCodeViewModelTest.kt +++ b/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/ui/EnterCodeViewModelTest.kt @@ -21,6 +21,9 @@ import app.cash.turbine.test import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory import com.duckduckgo.feature.toggles.api.Toggle.State +import com.duckduckgo.sync.SyncAccountFixtures.accountA +import com.duckduckgo.sync.SyncAccountFixtures.accountB +import com.duckduckgo.sync.SyncAccountFixtures.noAccount import com.duckduckgo.sync.TestSyncFixtures.jsonConnectKeyEncoded import com.duckduckgo.sync.TestSyncFixtures.jsonRecoveryKeyEncoded import com.duckduckgo.sync.impl.AccountErrorCodes.ALREADY_SIGNED_IN @@ -83,6 +86,7 @@ internal class EnterCodeViewModelTest { @Test fun whenUserClicksOnPasteCodeThenClipboardIsPasted() = runTest { + whenever(syncAccountRepository.getAccountInfo()).thenReturn(noAccount) whenever(clipboard.pasteFromClipboard()).thenReturn(jsonRecoveryKeyEncoded) testee.onPasteCodeClicked() @@ -92,8 +96,12 @@ internal class EnterCodeViewModelTest { @Test fun whenUserClicksOnPasteCodeWithRecoveryCodeThenProcessCode() = runTest { + whenever(syncAccountRepository.getAccountInfo()).thenReturn(noAccount) whenever(clipboard.pasteFromClipboard()).thenReturn(jsonRecoveryKeyEncoded) - whenever(syncAccountRepository.processCode(jsonRecoveryKeyEncoded)).thenReturn(Success(true)) + whenever(syncAccountRepository.processCode(jsonRecoveryKeyEncoded)).thenAnswer { + whenever(syncAccountRepository.getAccountInfo()).thenReturn(accountA) + Success(true) + } testee.onPasteCodeClicked() @@ -106,8 +114,12 @@ internal class EnterCodeViewModelTest { @Test fun whenUserClicksOnPasteCodeWithConnectCodeThenProcessCode() = runTest { + whenever(syncAccountRepository.getAccountInfo()).thenReturn(noAccount) whenever(clipboard.pasteFromClipboard()).thenReturn(jsonConnectKeyEncoded) - whenever(syncAccountRepository.processCode(jsonConnectKeyEncoded)).thenReturn(Success(true)) + whenever(syncAccountRepository.processCode(jsonConnectKeyEncoded)).thenAnswer { + whenever(syncAccountRepository.getAccountInfo()).thenReturn(accountA) + Success(true) + } testee.onPasteCodeClicked() @@ -120,6 +132,7 @@ internal class EnterCodeViewModelTest { @Test fun whenPastedInvalidCodeThenAuthStateError() = runTest { + whenever(syncAccountRepository.getAccountInfo()).thenReturn(noAccount) whenever(clipboard.pasteFromClipboard()).thenReturn("invalid code") whenever(syncAccountRepository.processCode("invalid code")).thenReturn(Error(code = INVALID_CODE.code)) @@ -135,6 +148,7 @@ internal class EnterCodeViewModelTest { @Test fun whenProcessCodeButUserSignedInThenShowError() = runTest { syncFeature.seamlessAccountSwitching().setRawStoredState(State(false)) + whenever(syncAccountRepository.getAccountInfo()).thenReturn(accountA) whenever(clipboard.pasteFromClipboard()).thenReturn(jsonRecoveryKeyEncoded) whenever(syncAccountRepository.processCode(jsonRecoveryKeyEncoded)).thenReturn(Error(code = ALREADY_SIGNED_IN.code)) @@ -149,6 +163,7 @@ internal class EnterCodeViewModelTest { @Test fun whenProcessCodeButUserSignedInThenOfferToSwitchAccount() = runTest { + whenever(syncAccountRepository.getAccountInfo()).thenReturn(accountA) whenever(clipboard.pasteFromClipboard()).thenReturn(jsonRecoveryKeyEncoded) whenever(syncAccountRepository.processCode(jsonRecoveryKeyEncoded)).thenReturn(Error(code = ALREADY_SIGNED_IN.code)) @@ -163,7 +178,11 @@ internal class EnterCodeViewModelTest { @Test fun whenUserAcceptsToSwitchAccountThenPerformAction() = runTest { - whenever(syncAccountRepository.logoutAndJoinNewAccount(jsonRecoveryKeyEncoded)).thenReturn(Success(true)) + whenever(syncAccountRepository.getAccountInfo()).thenReturn(accountA) + whenever(syncAccountRepository.logoutAndJoinNewAccount(jsonRecoveryKeyEncoded)).thenAnswer { + whenever(syncAccountRepository.getAccountInfo()).thenReturn(accountB) + Success(true) + } testee.onUserAcceptedJoiningNewAccount(jsonRecoveryKeyEncoded) @@ -175,9 +194,12 @@ internal class EnterCodeViewModelTest { } @Test - fun whenSignedInUserScansRecoveryCodeAndLoginSucceedsThenReturnSwitchAccount() = runTest { - whenever(syncAccountRepository.isSignedIn()).thenReturn(true) - whenever(syncAccountRepository.processCode(jsonRecoveryKeyEncoded)).thenReturn(Success(true)) + fun whenSignedInUserProcessCodeSucceedsAndAccountChangedThenReturnSwitchAccount() = runTest { + whenever(syncAccountRepository.getAccountInfo()).thenReturn(accountA) + whenever(syncAccountRepository.processCode(jsonRecoveryKeyEncoded)).thenAnswer { + whenever(syncAccountRepository.getAccountInfo()).thenReturn(accountB) + Success(true) + } whenever(clipboard.pasteFromClipboard()).thenReturn(jsonRecoveryKeyEncoded) testee.commands().test { @@ -190,8 +212,11 @@ internal class EnterCodeViewModelTest { @Test fun whenSignedOutUserScansRecoveryCodeAndLoginSucceedsThenReturnLoginSuccess() = runTest { - whenever(syncAccountRepository.isSignedIn()).thenReturn(false) - whenever(syncAccountRepository.processCode(jsonRecoveryKeyEncoded)).thenReturn(Success(true)) + whenever(syncAccountRepository.getAccountInfo()).thenReturn(noAccount) + whenever(syncAccountRepository.processCode(jsonRecoveryKeyEncoded)).thenAnswer { + whenever(syncAccountRepository.getAccountInfo()).thenReturn(accountA) + Success(true) + } whenever(clipboard.pasteFromClipboard()).thenReturn(jsonRecoveryKeyEncoded) testee.commands().test { @@ -204,6 +229,7 @@ internal class EnterCodeViewModelTest { @Test fun whenProcessCodeAndLoginFailsThenShowError() = runTest { + whenever(syncAccountRepository.getAccountInfo()).thenReturn(noAccount) whenever(clipboard.pasteFromClipboard()).thenReturn(jsonRecoveryKeyEncoded) whenever(syncAccountRepository.processCode(jsonRecoveryKeyEncoded)).thenReturn(Error(code = LOGIN_FAILED.code)) @@ -218,6 +244,7 @@ internal class EnterCodeViewModelTest { @Test fun whenProcessCodeAndConnectFailsThenShowError() = runTest { + whenever(syncAccountRepository.getAccountInfo()).thenReturn(noAccount) whenever(clipboard.pasteFromClipboard()).thenReturn(jsonConnectKeyEncoded) whenever(syncAccountRepository.processCode(jsonConnectKeyEncoded)).thenReturn(Error(code = CONNECT_FAILED.code)) @@ -232,6 +259,7 @@ internal class EnterCodeViewModelTest { @Test fun whenProcessCodeAndCreateAccountFailsThenShowError() = runTest { + whenever(syncAccountRepository.getAccountInfo()).thenReturn(noAccount) whenever(clipboard.pasteFromClipboard()).thenReturn(jsonRecoveryKeyEncoded) whenever(syncAccountRepository.processCode(jsonRecoveryKeyEncoded)).thenReturn(Error(code = CREATE_ACCOUNT_FAILED.code)) @@ -246,6 +274,7 @@ internal class EnterCodeViewModelTest { @Test fun whenProcessCodeAndGenericErrorThenDoNothing() = runTest { + whenever(syncAccountRepository.getAccountInfo()).thenReturn(noAccount) whenever(clipboard.pasteFromClipboard()).thenReturn(jsonRecoveryKeyEncoded) whenever(syncAccountRepository.processCode(jsonRecoveryKeyEncoded)).thenReturn(Error(code = GENERIC_ERROR.code)) diff --git a/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/ui/SyncWithAnotherDeviceViewModelTest.kt b/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/ui/SyncWithAnotherDeviceViewModelTest.kt index 9fd327b4edd6..f799c354dcfa 100644 --- a/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/ui/SyncWithAnotherDeviceViewModelTest.kt +++ b/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/ui/SyncWithAnotherDeviceViewModelTest.kt @@ -21,6 +21,9 @@ import app.cash.turbine.test import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory import com.duckduckgo.feature.toggles.api.Toggle.State +import com.duckduckgo.sync.SyncAccountFixtures.accountA +import com.duckduckgo.sync.SyncAccountFixtures.accountB +import com.duckduckgo.sync.SyncAccountFixtures.noAccount import com.duckduckgo.sync.TestSyncFixtures import com.duckduckgo.sync.TestSyncFixtures.jsonRecoveryKeyEncoded import com.duckduckgo.sync.impl.AccountErrorCodes.ALREADY_SIGNED_IN @@ -133,6 +136,7 @@ class SyncWithAnotherDeviceViewModelTest { @Test fun whenUserScansRecoveryCodeButSignedInThenCommandIsError() = runTest { syncFeature.seamlessAccountSwitching().setRawStoredState(State(false)) + whenever(syncRepository.getAccountInfo()).thenReturn(accountA) whenever(syncRepository.processCode(jsonRecoveryKeyEncoded)).thenReturn(Result.Error(code = ALREADY_SIGNED_IN.code)) testee.commands().test { testee.onQRCodeScanned(jsonRecoveryKeyEncoded) @@ -145,6 +149,7 @@ class SyncWithAnotherDeviceViewModelTest { @Test fun whenUserScansRecoveryCodeButSignedInThenCommandIsAskToSwitchAccount() = runTest { + whenever(syncRepository.getAccountInfo()).thenReturn(accountA) whenever(syncRepository.processCode(jsonRecoveryKeyEncoded)).thenReturn(Result.Error(code = ALREADY_SIGNED_IN.code)) testee.commands().test { testee.onQRCodeScanned(jsonRecoveryKeyEncoded) @@ -157,7 +162,11 @@ class SyncWithAnotherDeviceViewModelTest { @Test fun whenUserAcceptsToSwitchAccountThenPerformAction() = runTest { - whenever(syncRepository.logoutAndJoinNewAccount(jsonRecoveryKeyEncoded)).thenReturn(Success(true)) + whenever(syncRepository.getAccountInfo()).thenReturn(accountA) + whenever(syncRepository.logoutAndJoinNewAccount(jsonRecoveryKeyEncoded)).thenAnswer { + whenever(syncRepository.getAccountInfo()).thenReturn(accountB) + Success(true) + } testee.onUserAcceptedJoiningNewAccount(jsonRecoveryKeyEncoded) @@ -170,8 +179,11 @@ class SyncWithAnotherDeviceViewModelTest { @Test fun whenSignedInUserScansRecoveryCodeAndLoginSucceedsThenReturnSwitchAccount() = runTest { - whenever(syncRepository.isSignedIn()).thenReturn(true) - whenever(syncRepository.processCode(jsonRecoveryKeyEncoded)).thenReturn(Success(true)) + whenever(syncRepository.getAccountInfo()).thenReturn(accountA) + whenever(syncRepository.processCode(jsonRecoveryKeyEncoded)).thenAnswer { + whenever(syncRepository.getAccountInfo()).thenReturn(accountB) + Success(true) + } testee.commands().test { testee.onQRCodeScanned(jsonRecoveryKeyEncoded) @@ -184,8 +196,11 @@ class SyncWithAnotherDeviceViewModelTest { @Test fun whenSignedOutUserScansRecoveryCodeAndLoginSucceedsThenReturnLoginSuccess() = runTest { - whenever(syncRepository.isSignedIn()).thenReturn(false) - whenever(syncRepository.processCode(jsonRecoveryKeyEncoded)).thenReturn(Success(true)) + whenever(syncRepository.getAccountInfo()).thenReturn(noAccount) + whenever(syncRepository.processCode(jsonRecoveryKeyEncoded)).thenAnswer { + whenever(syncRepository.getAccountInfo()).thenReturn(accountB) + Success(true) + } testee.commands().test { testee.onQRCodeScanned(jsonRecoveryKeyEncoded) @@ -198,6 +213,7 @@ class SyncWithAnotherDeviceViewModelTest { @Test fun whenUserScansRecoveryQRCodeAndConnectDeviceFailsThenCommandIsError() = runTest { + whenever(syncRepository.getAccountInfo()).thenReturn(noAccount) whenever(syncRepository.processCode(jsonRecoveryKeyEncoded)).thenReturn(Result.Error(code = LOGIN_FAILED.code)) testee.commands().test { testee.onQRCodeScanned(jsonRecoveryKeyEncoded)