Skip to content

Commit

Permalink
auto switch for single account
Browse files Browse the repository at this point in the history
  • Loading branch information
cmonfortep committed Nov 15, 2024
1 parent 95ba72c commit 5e3ae88
Show file tree
Hide file tree
Showing 8 changed files with 161 additions and 13 deletions.
15 changes: 13 additions & 2 deletions sync/sync-impl/lint-baseline.xml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,17 @@
message="Defined here, included via layout/activity_sync.xml => layout/view_sync_disabled.xml defines @+id/syncSetupOtherPlatforms"/>
</issue>

<issue
id="DenyListedApi"
message="If you find yourself using this API in production, you&apos;re doing something wrong!!"
errorLine1=" this.seamlessAccountSwitching().setRawStoredState(State(true))"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/test/java/com/duckduckgo/sync/impl/AppSyncAccountRepositoryTest.kt"
line="100"
column="9"/>
</issue>

<issue
id="DenyListedApi"
message="No one remembers what these constants map to. Use the API level integer value directly since it&apos;s self-defining."
Expand Down Expand Up @@ -107,7 +118,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/test/java/com/duckduckgo/sync/impl/ui/EnterCodeViewModelTest.kt"
line="62"
line="63"
column="9"/>
</issue>

Expand All @@ -118,7 +129,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/test/java/com/duckduckgo/sync/impl/ui/EnterCodeViewModelTest.kt"
line="136"
line="137"
column="9"/>
</issue>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ConnectedDevice> = mutableListOf()

override fun isSyncSupported(): Boolean {
return syncStore.isEncryptionSupported()
}
Expand Down Expand Up @@ -109,8 +112,14 @@ class AppSyncAccountRepository @Inject constructor(

private fun login(recoveryCode: RecoveryCode): Result<Boolean> {
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
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -315,6 +325,11 @@ class AppSyncAccountRepository @Inject constructor(
}
}.sortedWith { a, b ->
if (a.thisDevice) -1 else 1
}.also {
connectedDevicesCached.apply {
clear()
addAll(it)
}
},
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.*
Expand Down Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,14 +136,16 @@ 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)
}

is Success -> {
syncPixels.fireLoginPixel()
command.send(LoginSuccess)
val commandSuccess = if (userSignedIn) SwitchAccountSuccess else LoginSuccess
command.send(commandSuccess)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,8 @@ object TestSyncFixtures {
)
val loginSuccessResponse: Response<LoginResponse> = 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<DeviceResponse> = Response.success(deviceResponse)
val getDevicesBodyErrorResponse: Response<DeviceResponse> = Response.error(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -573,4 +620,15 @@ class AppSyncAccountRepositoryTest {
EncryptResult(0, it.arguments.first() as String)
}
}

private fun givenAccountWithConnectedDevices(size: Int) {
prepareForEncryption()
val listOfDevices = mutableListOf<Device>()
for (i in 0 until size) {
listOfDevices.add(aDevice.copy(deviceId = "device$i"))
}
whenever(syncApi.getDevices(anyString())).thenReturn(Success(listOfDevices))

syncRepo.getConnectedDevices() as Success
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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))
Expand Down

0 comments on commit 5e3ae88

Please sign in to comment.