diff --git a/sync/sync-impl/lint-baseline.xml b/sync/sync-impl/lint-baseline.xml index b258727a2108..96f067463a0d 100644 --- a/sync/sync-impl/lint-baseline.xml +++ b/sync/sync-impl/lint-baseline.xml @@ -34,6 +34,17 @@ message="Defined here, included via layout/activity_sync.xml => layout/view_sync_disabled.xml defines @+id/syncSetupOtherPlatforms"/> + + + + @@ -118,7 +129,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> diff --git a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/SyncAccountRepository.kt b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/SyncAccountRepository.kt index 5fc75636701b..4e413bb1d965 100644 --- a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/SyncAccountRepository.kt +++ b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/SyncAccountRepository.kt @@ -74,8 +74,11 @@ class AppSyncAccountRepository @Inject constructor( private val syncPixels: SyncPixels, @AppCoroutineScope private val appCoroutineScope: CoroutineScope, private val dispatcherProvider: DispatcherProvider, + private val syncFeature: SyncFeature, ) : SyncAccountRepository { + private val connectedDevicesCached: MutableList = mutableListOf() + override fun isSyncSupported(): Boolean { return syncStore.isEncryptionSupported() } @@ -109,8 +112,14 @@ class AppSyncAccountRepository @Inject constructor( private fun login(recoveryCode: RecoveryCode): Result { if (isSignedIn()) { - return Error(code = ALREADY_SIGNED_IN.code, reason = "Already signed in", payload = recoveryCode) + val allowSwitchAccount = syncFeature.seamlessAccountSwitching().isEnabled() + val error = Error(code = ALREADY_SIGNED_IN.code, reason = "Already signed in", payload = recoveryCode) .alsoFireAlreadySignedInErrorPixel() + if (allowSwitchAccount && connectedDevicesCached.size == 1) { + logout(syncStore.deviceId.orEmpty()).takeIf { it is Error }?.let { return it } + } else { + return error + } } val primaryKey = recoveryCode.primaryKey @@ -288,6 +297,7 @@ class AppSyncAccountRepository @Inject constructor( return when (val result = syncApi.getDevices(token)) { is Error -> { + connectedDevicesCached.clear() result.alsoFireAccountErrorPixel().copy(code = GENERIC_ERROR.code) } @@ -315,6 +325,11 @@ class AppSyncAccountRepository @Inject constructor( } }.sortedWith { a, b -> if (a.thisDevice) -1 else 1 + }.also { + connectedDevicesCached.apply { + clear() + addAll(it) + } }, ) } 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 7ca9026cadf4..4aeda7425aeb 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 @@ -35,6 +35,7 @@ import com.duckduckgo.sync.impl.SyncAccountRepository import com.duckduckgo.sync.impl.SyncFeature import com.duckduckgo.sync.impl.pixels.SyncPixels import com.duckduckgo.sync.impl.ui.EnterCodeViewModel.Command.AskToSwitchAccount +import com.duckduckgo.sync.impl.ui.EnterCodeViewModel.Command.LoginSuccess import com.duckduckgo.sync.impl.ui.EnterCodeViewModel.Command.ShowError import com.duckduckgo.sync.impl.ui.EnterCodeViewModel.Command.SwitchAccountSuccess import javax.inject.* @@ -90,9 +91,12 @@ class EnterCodeViewModel @Inject constructor( private suspend fun authFlow( pastedCode: String, ) { - val result = syncAccountRepository.processCode(pastedCode) - when (result) { - is Result.Success -> command.send(Command.LoginSuccess) + val userSignedIn = syncAccountRepository.isSignedIn() + when (val result = syncAccountRepository.processCode(pastedCode)) { + is Result.Success -> { + val commandSuccess = if (userSignedIn) SwitchAccountSuccess else LoginSuccess + command.send(commandSuccess) + } is Result.Error -> { processError(result, pastedCode) } 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 46ee532a5673..92432510ec65 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 @@ -136,6 +136,7 @@ class SyncWithAnotherActivityViewModel @Inject constructor( fun onQRCodeScanned(qrCode: String) { viewModelScope.launch(dispatchers.io()) { + val userSignedIn = syncAccountRepository.isSignedIn() when (val result = syncAccountRepository.processCode(qrCode)) { is Error -> { emitError(result, qrCode) @@ -143,7 +144,8 @@ class SyncWithAnotherActivityViewModel @Inject constructor( is Success -> { syncPixels.fireLoginPixel() - command.send(LoginSuccess) + val commandSuccess = if (userSignedIn) SwitchAccountSuccess else LoginSuccess + command.send(commandSuccess) } } } diff --git a/sync/sync-impl/src/test/java/com/duckduckgo/sync/TestSyncFixtures.kt b/sync/sync-impl/src/test/java/com/duckduckgo/sync/TestSyncFixtures.kt index 9bbdc1f225a7..120d523b784c 100644 --- a/sync/sync-impl/src/test/java/com/duckduckgo/sync/TestSyncFixtures.kt +++ b/sync/sync-impl/src/test/java/com/duckduckgo/sync/TestSyncFixtures.kt @@ -140,7 +140,8 @@ object TestSyncFixtures { ) val loginSuccessResponse: Response = Response.success(loginResponseBody) - val listOfDevices = listOf(Device(deviceId = deviceId, deviceName = deviceName, jwIat = "", deviceType = deviceFactor)) + val aDevice = Device(deviceId = deviceId, deviceName = deviceName, jwIat = "", deviceType = deviceFactor) + val listOfDevices = listOf(aDevice) val deviceResponse = DeviceResponse(DeviceEntries(listOfDevices)) val getDevicesBodySuccessResponse: Response = Response.success(deviceResponse) val getDevicesBodyErrorResponse: Response = Response.error( diff --git a/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/AppSyncAccountRepositoryTest.kt b/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/AppSyncAccountRepositoryTest.kt index 9fd80aba10b7..09c5d17cfc96 100644 --- a/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/AppSyncAccountRepositoryTest.kt +++ b/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/AppSyncAccountRepositoryTest.kt @@ -18,6 +18,9 @@ package com.duckduckgo.sync.impl import androidx.test.ext.junit.runners.AndroidJUnit4 import com.duckduckgo.common.utils.DefaultDispatcherProvider +import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory +import com.duckduckgo.feature.toggles.api.Toggle.State +import com.duckduckgo.sync.TestSyncFixtures.aDevice import com.duckduckgo.sync.TestSyncFixtures.accountCreatedFailDupUser import com.duckduckgo.sync.TestSyncFixtures.accountCreatedSuccess import com.duckduckgo.sync.TestSyncFixtures.accountKeys @@ -65,9 +68,8 @@ import com.duckduckgo.sync.impl.AccountErrorCodes.INVALID_CODE import com.duckduckgo.sync.impl.AccountErrorCodes.LOGIN_FAILED import com.duckduckgo.sync.impl.Result.Error import com.duckduckgo.sync.impl.Result.Success -import com.duckduckgo.sync.impl.pixels.* +import com.duckduckgo.sync.impl.pixels.SyncPixels import com.duckduckgo.sync.store.SyncStore -import java.lang.RuntimeException import kotlinx.coroutines.test.TestScope import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -94,13 +96,25 @@ class AppSyncAccountRepositoryTest { private var syncStore: SyncStore = mock() private var syncEngine: SyncEngine = mock() private var syncPixels: SyncPixels = mock() + private val syncFeature = FakeFeatureToggleFactory.create(SyncFeature::class.java).apply { + this.seamlessAccountSwitching().setRawStoredState(State(true)) + } private lateinit var syncRepo: SyncAccountRepository @Before fun before() { - syncRepo = - AppSyncAccountRepository(syncDeviceIds, nativeLib, syncApi, syncStore, syncEngine, syncPixels, TestScope(), DefaultDispatcherProvider()) + syncRepo = AppSyncAccountRepository( + syncDeviceIds, + nativeLib, + syncApi, + syncStore, + syncEngine, + syncPixels, + TestScope(), + DefaultDispatcherProvider(), + syncFeature, + ) } @Test @@ -231,6 +245,39 @@ class AppSyncAccountRepositoryTest { ) } + @Test + fun whenSignedInAndProcessRecoveryCodeIfAllowSwitchAccountTrueThenSwitchAccountIfOnly1DeviceConnected() { + givenAuthenticatedDevice() + givenAccountWithConnectedDevices(1) + doAnswer { + givenUnauthenticatedDevice() // simulate logout locally + logoutSuccess + }.`when`(syncApi).logout(token, deviceId) + prepareForLoginSuccess() + + val result = syncRepo.processCode(jsonRecoveryKeyEncoded) + + verify(syncApi).logout(token, deviceId) + verify(syncApi).login(userId, hashedPassword, deviceId, deviceName, deviceFactor) + + assertTrue(result is Success) + } + + @Test + fun whenSignedInAndProcessRecoveryCodeIfAllowSwitchAccountTrueThenReturnErrorIfMultipleDevicesConnected() { + givenAuthenticatedDevice() + givenAccountWithConnectedDevices(2) + doAnswer { + givenUnauthenticatedDevice() // simulate logout locally + logoutSuccess + }.`when`(syncApi).logout(token, deviceId) + prepareForLoginSuccess() + + val result = syncRepo.processCode(jsonRecoveryKeyEncoded) + + assertEquals((result as Error).code, ALREADY_SIGNED_IN.code) + } + @Test fun whenLogoutAndJoinNewAccountSucceedsThenReturnSuccess() { givenAuthenticatedDevice() @@ -573,4 +620,15 @@ class AppSyncAccountRepositoryTest { EncryptResult(0, it.arguments.first() as String) } } + + private fun givenAccountWithConnectedDevices(size: Int) { + prepareForEncryption() + val listOfDevices = mutableListOf() + for (i in 0 until size) { + listOfDevices.add(aDevice.copy(deviceId = "device$i")) + } + whenever(syncApi.getDevices(anyString())).thenReturn(Success(listOfDevices)) + + syncRepo.getConnectedDevices() as Success + } } 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 eed3a058b789..4979289f791b 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 @@ -47,6 +47,7 @@ import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.mockito.kotlin.mock +import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever @@ -173,6 +174,34 @@ internal class EnterCodeViewModelTest { } } + @Test + fun whenSignedInUserScansRecoveryCodeAndLoginSucceedsThenReturnSwitchAccount() = runTest { + whenever(syncAccountRepository.isSignedIn()).thenReturn(true) + whenever(syncAccountRepository.processCode(jsonRecoveryKeyEncoded)).thenReturn(Success(true)) + whenever(clipboard.pasteFromClipboard()).thenReturn(jsonRecoveryKeyEncoded) + + testee.commands().test { + testee.onPasteCodeClicked() + val command = awaitItem() + assertTrue(command is SwitchAccountSuccess) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun whenSignedOutUserScansRecoveryCodeAndLoginSucceedsThenReturnLoginSuccess() = runTest { + whenever(syncAccountRepository.isSignedIn()).thenReturn(false) + whenever(syncAccountRepository.processCode(jsonRecoveryKeyEncoded)).thenReturn(Success(true)) + whenever(clipboard.pasteFromClipboard()).thenReturn(jsonRecoveryKeyEncoded) + + testee.commands().test { + testee.onPasteCodeClicked() + val command = awaitItem() + assertTrue(command is LoginSuccess) + cancelAndIgnoreRemainingEvents() + } + } + @Test fun whenProcessCodeAndLoginFailsThenShowError() = runTest { whenever(clipboard.pasteFromClipboard()).thenReturn(jsonRecoveryKeyEncoded) 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 a3a516abb3bd..9fd327b4edd6 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 @@ -144,7 +144,7 @@ class SyncWithAnotherDeviceViewModelTest { } @Test - fun whenUserScansRecoveryCodeButSignedInThenCommandIsAskToSwithAccount() = runTest { + fun whenUserScansRecoveryCodeButSignedInThenCommandIsAskToSwitchAccount() = runTest { whenever(syncRepository.processCode(jsonRecoveryKeyEncoded)).thenReturn(Result.Error(code = ALREADY_SIGNED_IN.code)) testee.commands().test { testee.onQRCodeScanned(jsonRecoveryKeyEncoded) @@ -168,6 +168,34 @@ class SyncWithAnotherDeviceViewModelTest { } } + @Test + fun whenSignedInUserScansRecoveryCodeAndLoginSucceedsThenReturnSwitchAccount() = runTest { + whenever(syncRepository.isSignedIn()).thenReturn(true) + whenever(syncRepository.processCode(jsonRecoveryKeyEncoded)).thenReturn(Success(true)) + + testee.commands().test { + testee.onQRCodeScanned(jsonRecoveryKeyEncoded) + val command = awaitItem() + assertTrue(command is SwitchAccountSuccess) + verify(syncPixels, times(1)).fireLoginPixel() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun whenSignedOutUserScansRecoveryCodeAndLoginSucceedsThenReturnLoginSuccess() = runTest { + whenever(syncRepository.isSignedIn()).thenReturn(false) + whenever(syncRepository.processCode(jsonRecoveryKeyEncoded)).thenReturn(Success(true)) + + testee.commands().test { + testee.onQRCodeScanned(jsonRecoveryKeyEncoded) + val command = awaitItem() + assertTrue(command is LoginSuccess) + verify(syncPixels, times(1)).fireLoginPixel() + cancelAndIgnoreRemainingEvents() + } + } + @Test fun whenUserScansRecoveryQRCodeAndConnectDeviceFailsThenCommandIsError() = runTest { whenever(syncRepository.processCode(jsonRecoveryKeyEncoded)).thenReturn(Result.Error(code = LOGIN_FAILED.code))