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))