diff --git a/sync/sync-impl/lint-baseline.xml b/sync/sync-impl/lint-baseline.xml
index 6e9665b4268b..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"/>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
fun pollConnectionKeys(): Result
fun renameDevice(device: ConnectedDevice): Result
+ fun logoutAndJoinNewAccount(stringCode: String): Result
}
@ContributesBinding(AppScope::class)
@@ -73,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()
}
@@ -107,9 +111,20 @@ class AppSyncAccountRepository @Inject constructor(
}
private fun login(recoveryCode: RecoveryCode): Result {
+ var wasUserLogout = false
if (isSignedIn()) {
- return Error(code = ALREADY_SIGNED_IN.code, reason = "Already signed in")
- .alsoFireAlreadySignedInErrorPixel()
+ val allowSwitchAccount = syncFeature.seamlessAccountSwitching().isEnabled()
+ val error = Error(code = ALREADY_SIGNED_IN.code, reason = "Already signed in").alsoFireAlreadySignedInErrorPixel()
+ if (allowSwitchAccount && connectedDevicesCached.size == 1) {
+ val thisDeviceId = syncStore.deviceId.orEmpty()
+ val result = logout(thisDeviceId)
+ if (result is Error) {
+ return result
+ }
+ wasUserLogout = true
+ } else {
+ return error
+ }
}
val primaryKey = recoveryCode.primaryKey
@@ -119,6 +134,9 @@ class AppSyncAccountRepository @Inject constructor(
return performLogin(userId, deviceId, deviceName, primaryKey).onFailure {
it.alsoFireLoginErrorPixel()
+ if (wasUserLogout) {
+ syncPixels.fireUserSwitchedLoginError()
+ }
return it.copy(code = LOGIN_FAILED.code)
}
}
@@ -287,6 +305,7 @@ class AppSyncAccountRepository @Inject constructor(
return when (val result = syncApi.getDevices(token)) {
is Error -> {
+ connectedDevicesCached.clear()
result.alsoFireAccountErrorPixel().copy(code = GENERIC_ERROR.code)
}
@@ -314,6 +333,11 @@ class AppSyncAccountRepository @Inject constructor(
}
}.sortedWith { a, b ->
if (a.thisDevice) -1 else 1
+ }.also {
+ connectedDevicesCached.apply {
+ clear()
+ addAll(it)
+ }
},
)
}
@@ -322,6 +346,23 @@ class AppSyncAccountRepository @Inject constructor(
override fun isSignedIn() = syncStore.isSignedIn()
+ override fun logoutAndJoinNewAccount(stringCode: String): Result {
+ val thisDeviceId = syncStore.deviceId.orEmpty()
+ return when (val result = logout(thisDeviceId)) {
+ is Error -> {
+ syncPixels.fireUserSwitchedLogoutError()
+ result
+ }
+ is Result.Success -> {
+ val loginResult = processCode(stringCode)
+ if (loginResult is Error) {
+ syncPixels.fireUserSwitchedLoginError()
+ }
+ loginResult
+ }
+ }
+ }
+
private fun performCreateAccount(): Result {
val userId = syncDeviceIds.userId()
val account: AccountKeys = kotlin.runCatching {
diff --git a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/SyncFeature.kt b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/SyncFeature.kt
index 9ec266495910..f91c261e0083 100644
--- a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/SyncFeature.kt
+++ b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/SyncFeature.kt
@@ -19,6 +19,7 @@ package com.duckduckgo.sync.impl
import com.duckduckgo.anvil.annotations.ContributesRemoteFeature
import com.duckduckgo.di.scopes.AppScope
import com.duckduckgo.feature.toggles.api.Toggle
+import com.duckduckgo.feature.toggles.api.Toggle.InternalAlwaysEnabled
@ContributesRemoteFeature(
scope = AppScope::class,
@@ -43,4 +44,7 @@ interface SyncFeature {
@Toggle.DefaultValue(true)
fun gzipPatchRequests(): Toggle
+
+ @Toggle.DefaultValue(true)
+ fun seamlessAccountSwitching(): Toggle
}
diff --git a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/pixels/SyncPixelParamRemovalPlugin.kt b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/pixels/SyncPixelParamRemovalPlugin.kt
index 7fca1dd76f17..620cf2b0eb88 100644
--- a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/pixels/SyncPixelParamRemovalPlugin.kt
+++ b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/pixels/SyncPixelParamRemovalPlugin.kt
@@ -35,6 +35,13 @@ class SyncPixelParamRemovalPlugin @Inject constructor() : PixelParamRemovalPlugi
SyncPixelName.SYNC_GET_OTHER_DEVICES_SCREEN_SHOWN.pixelName to PixelParameter.removeAtb(),
SyncPixelName.SYNC_GET_OTHER_DEVICES_LINK_COPIED.pixelName to PixelParameter.removeAtb(),
SyncPixelName.SYNC_GET_OTHER_DEVICES_LINK_SHARED.pixelName to PixelParameter.removeAtb(),
+
+ SyncPixelName.SYNC_ASK_USER_TO_SWITCH_ACCOUNT.pixelName to PixelParameter.removeAtb(),
+ SyncPixelName.SYNC_USER_ACCEPTED_SWITCHING_ACCOUNT.pixelName to PixelParameter.removeAtb(),
+ SyncPixelName.SYNC_USER_CANCELLED_SWITCHING_ACCOUNT.pixelName to PixelParameter.removeAtb(),
+ SyncPixelName.SYNC_USER_SWITCHED_ACCOUNT.pixelName to PixelParameter.removeAtb(),
+ SyncPixelName.SYNC_USER_SWITCHED_LOGOUT_ERROR.pixelName to PixelParameter.removeAtb(),
+ SyncPixelName.SYNC_USER_SWITCHED_LOGIN_ERROR.pixelName to PixelParameter.removeAtb(),
)
}
}
diff --git a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/pixels/SyncPixels.kt b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/pixels/SyncPixels.kt
index 6a9b55c0fb2b..a52493645079 100644
--- a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/pixels/SyncPixels.kt
+++ b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/pixels/SyncPixels.kt
@@ -81,6 +81,13 @@ interface SyncPixels {
feature: SyncableType,
apiError: Error,
)
+
+ fun fireAskUserToSwitchAccount()
+ fun fireUserAcceptedSwitchingAccount()
+ fun fireUserCancelledSwitchingAccount()
+ fun fireUserSwitchedAccount()
+ fun fireUserSwitchedLogoutError()
+ fun fireUserSwitchedLoginError()
}
@ContributesBinding(AppScope::class)
@@ -255,6 +262,30 @@ class RealSyncPixels @Inject constructor(
}
}
+ override fun fireUserSwitchedAccount() {
+ pixel.fire(SyncPixelName.SYNC_USER_SWITCHED_ACCOUNT)
+ }
+
+ override fun fireAskUserToSwitchAccount() {
+ pixel.fire(SyncPixelName.SYNC_ASK_USER_TO_SWITCH_ACCOUNT)
+ }
+
+ override fun fireUserAcceptedSwitchingAccount() {
+ pixel.fire(SyncPixelName.SYNC_USER_ACCEPTED_SWITCHING_ACCOUNT)
+ }
+
+ override fun fireUserCancelledSwitchingAccount() {
+ pixel.fire(SyncPixelName.SYNC_USER_CANCELLED_SWITCHING_ACCOUNT)
+ }
+
+ override fun fireUserSwitchedLoginError() {
+ pixel.fire(SyncPixelName.SYNC_USER_SWITCHED_LOGIN_ERROR)
+ }
+
+ override fun fireUserSwitchedLogoutError() {
+ pixel.fire(SyncPixelName.SYNC_USER_SWITCHED_LOGOUT_ERROR)
+ }
+
companion object {
private const val SYNC_PIXELS_PREF_FILE = "com.duckduckgo.sync.pixels.v1"
}
@@ -302,6 +333,12 @@ enum class SyncPixelName(override val pixelName: String) : Pixel.PixelName {
SYNC_GET_OTHER_DEVICES_SCREEN_SHOWN("sync_get_other_devices"),
SYNC_GET_OTHER_DEVICES_LINK_COPIED("sync_get_other_devices_copy"),
SYNC_GET_OTHER_DEVICES_LINK_SHARED("sync_get_other_devices_share"),
+ SYNC_ASK_USER_TO_SWITCH_ACCOUNT("sync_ask_user_to_switch_account"),
+ SYNC_USER_ACCEPTED_SWITCHING_ACCOUNT("sync_user_accepted_switching_account"),
+ SYNC_USER_CANCELLED_SWITCHING_ACCOUNT("sync_user_cancelled_switching_account"),
+ SYNC_USER_SWITCHED_ACCOUNT("sync_user_switched_account"),
+ SYNC_USER_SWITCHED_LOGOUT_ERROR("sync_user_switched_logout_error"),
+ SYNC_USER_SWITCHED_LOGIN_ERROR("sync_user_switched_login_error"),
}
object SyncPixelParameters {
diff --git a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/EnterCodeActivity.kt b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/EnterCodeActivity.kt
index 7b00d093f98a..63054c345580 100644
--- a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/EnterCodeActivity.kt
+++ b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/EnterCodeActivity.kt
@@ -36,8 +36,10 @@ import com.duckduckgo.sync.impl.ui.EnterCodeViewModel.AuthState
import com.duckduckgo.sync.impl.ui.EnterCodeViewModel.AuthState.Idle
import com.duckduckgo.sync.impl.ui.EnterCodeViewModel.AuthState.Loading
import com.duckduckgo.sync.impl.ui.EnterCodeViewModel.Command
-import com.duckduckgo.sync.impl.ui.EnterCodeViewModel.Command.LoginSucess
+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 com.duckduckgo.sync.impl.ui.EnterCodeViewModel.ViewState
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
@@ -96,7 +98,7 @@ class EnterCodeActivity : DuckDuckGoActivity() {
private fun processCommand(command: Command) {
when (command) {
- LoginSucess -> {
+ LoginSuccess -> {
setResult(RESULT_OK)
finish()
}
@@ -104,6 +106,14 @@ class EnterCodeActivity : DuckDuckGoActivity() {
is ShowError -> {
showError(command)
}
+
+ is AskToSwitchAccount -> askUserToSwitchAccount(command)
+ SwitchAccountSuccess -> {
+ val resultIntent = Intent()
+ resultIntent.putExtra(EXTRA_USER_SWITCHED_ACCOUNT, true)
+ setResult(RESULT_OK, resultIntent)
+ finish()
+ }
}
}
@@ -120,6 +130,26 @@ class EnterCodeActivity : DuckDuckGoActivity() {
).show()
}
+ private fun askUserToSwitchAccount(it: AskToSwitchAccount) {
+ viewModel.onUserAskedToSwitchAccount()
+ TextAlertDialogBuilder(this)
+ .setTitle(R.string.sync_dialog_switch_account_header)
+ .setMessage(R.string.sync_dialog_switch_account_description)
+ .setPositiveButton(R.string.sync_dialog_switch_account_primary_button)
+ .setNegativeButton(R.string.sync_dialog_switch_account_secondary_button)
+ .addEventListener(
+ object : TextAlertDialogBuilder.EventListener() {
+ override fun onPositiveButtonClicked() {
+ viewModel.onUserAcceptedJoiningNewAccount(it.encodedStringCode)
+ }
+
+ override fun onNegativeButtonClicked() {
+ viewModel.onUserCancelledJoiningNewAccount()
+ }
+ },
+ ).show()
+ }
+
companion object {
enum class Code {
RECOVERY_CODE,
@@ -128,6 +158,8 @@ class EnterCodeActivity : DuckDuckGoActivity() {
private const val EXTRA_CODE_TYPE = "codeType"
+ const val EXTRA_USER_SWITCHED_ACCOUNT = "userSwitchedAccount"
+
internal fun intent(context: Context, codeType: Code): Intent {
return Intent(context, EnterCodeActivity::class.java).apply {
putExtra(EXTRA_CODE_TYPE, codeType)
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 03ab613d98a2..7a505bca3fa2 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
@@ -30,8 +30,14 @@ import com.duckduckgo.sync.impl.AccountErrorCodes.LOGIN_FAILED
import com.duckduckgo.sync.impl.Clipboard
import com.duckduckgo.sync.impl.R
import com.duckduckgo.sync.impl.Result
+import com.duckduckgo.sync.impl.Result.Error
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.*
import kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST
import kotlinx.coroutines.channels.Channel
@@ -45,6 +51,8 @@ class EnterCodeViewModel @Inject constructor(
private val syncAccountRepository: SyncAccountRepository,
private val clipboard: Clipboard,
private val dispatchers: DispatcherProvider,
+ private val syncFeature: SyncFeature,
+ private val syncPixels: SyncPixels,
) : ViewModel() {
private val command = Channel(1, DROP_OLDEST)
@@ -66,8 +74,10 @@ class EnterCodeViewModel @Inject constructor(
}
sealed class Command {
- object LoginSucess : Command()
+ data object LoginSuccess : Command()
+ data class AskToSwitchAccount(val encodedStringCode: String) : Command()
data class ShowError(@StringRes val message: Int, val reason: String = "") : Command()
+ data object SwitchAccountSuccess : Command()
}
fun onPasteCodeClicked() {
@@ -81,36 +91,79 @@ class EnterCodeViewModel @Inject constructor(
private suspend fun authFlow(
pastedCode: String,
) {
- val result = syncAccountRepository.processCode(pastedCode)
- when (result) {
- is Result.Success -> command.send(Command.LoginSucess)
+ val userSignedIn = syncAccountRepository.isSignedIn()
+ when (val result = syncAccountRepository.processCode(pastedCode)) {
+ is Result.Success -> {
+ val commandSuccess = if (userSignedIn) {
+ syncPixels.fireUserSwitchedAccount()
+ SwitchAccountSuccess
+ } else {
+ LoginSuccess
+ }
+ command.send(commandSuccess)
+ }
is Result.Error -> {
+ processError(result, pastedCode)
+ }
+ }
+ }
+
+ private suspend fun processError(result: Error, pastedCode: String) {
+ if (result.code == ALREADY_SIGNED_IN.code && syncFeature.seamlessAccountSwitching().isEnabled()) {
+ command.send(AskToSwitchAccount(pastedCode))
+ } else {
+ if (result.code == INVALID_CODE.code) {
+ viewState.value = viewState.value.copy(authState = AuthState.Error)
+ return
+ }
+
+ when (result.code) {
+ ALREADY_SIGNED_IN.code -> R.string.sync_login_authenticated_device_error
+ LOGIN_FAILED.code -> R.string.sync_connect_login_error
+ CONNECT_FAILED.code -> R.string.sync_connect_generic_error
+ CREATE_ACCOUNT_FAILED.code -> R.string.sync_create_account_generic_error
+ INVALID_CODE.code -> R.string.sync_invalid_code_error
+ else -> null
+ }?.let { message ->
+ command.send(
+ ShowError(
+ message = message,
+ reason = result.reason,
+ ),
+ )
+ }
+ }
+ }
+
+ fun onUserAcceptedJoiningNewAccount(encodedStringCode: String) {
+ viewModelScope.launch(dispatchers.io()) {
+ syncPixels.fireUserAcceptedSwitchingAccount()
+ val result = syncAccountRepository.logoutAndJoinNewAccount(encodedStringCode)
+ if (result is Error) {
when (result.code) {
- ALREADY_SIGNED_IN.code -> {
- showError(R.string.sync_login_authenticated_device_error, result.reason)
- }
- LOGIN_FAILED.code -> {
- showError(R.string.sync_connect_login_error, result.reason)
- }
- CONNECT_FAILED.code -> {
- showError(R.string.sync_connect_generic_error, result.reason)
- }
- CREATE_ACCOUNT_FAILED.code -> {
- showError(R.string.sync_create_account_generic_error, result.reason)
- }
- INVALID_CODE.code -> {
- viewState.value = viewState.value.copy(authState = AuthState.Error)
- }
- else -> {}
+ ALREADY_SIGNED_IN.code -> R.string.sync_login_authenticated_device_error
+ LOGIN_FAILED.code -> R.string.sync_connect_login_error
+ CONNECT_FAILED.code -> R.string.sync_connect_generic_error
+ CREATE_ACCOUNT_FAILED.code -> R.string.sync_create_account_generic_error
+ INVALID_CODE.code -> R.string.sync_invalid_code_error
+ else -> null
+ }?.let { message ->
+ command.send(
+ ShowError(message = message, reason = result.reason),
+ )
}
+ } else {
+ syncPixels.fireUserSwitchedAccount()
+ command.send(SwitchAccountSuccess)
}
}
}
- private suspend fun showError(
- message: Int,
- reason: String,
- ) {
- command.send(ShowError(message = message, reason = reason))
+ fun onUserCancelledJoiningNewAccount() {
+ syncPixels.fireUserCancelledSwitchingAccount()
+ }
+
+ fun onUserAskedToSwitchAccount() {
+ syncPixels.fireAskUserToSwitchAccount()
}
}
diff --git a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncActivity.kt b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncActivity.kt
index 5ced0c583f27..4289a04f4979 100644
--- a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncActivity.kt
+++ b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncActivity.kt
@@ -72,6 +72,8 @@ import com.duckduckgo.sync.impl.ui.setup.SetupAccountActivity.Companion.Screen.S
import com.duckduckgo.sync.impl.ui.setup.SyncIntroContract
import com.duckduckgo.sync.impl.ui.setup.SyncIntroContractInput
import com.duckduckgo.sync.impl.ui.setup.SyncWithAnotherDeviceContract
+import com.duckduckgo.sync.impl.ui.setup.SyncWithAnotherDeviceContract.SyncWithAnotherDeviceContractOutput.DeviceConnected
+import com.duckduckgo.sync.impl.ui.setup.SyncWithAnotherDeviceContract.SyncWithAnotherDeviceContractOutput.SwitchAccountSuccess
import com.google.android.material.snackbar.Snackbar
import javax.inject.Inject
import kotlinx.coroutines.flow.launchIn
@@ -134,9 +136,11 @@ class SyncActivity : DuckDuckGoActivity() {
}
}
- private val syncWithAnotherDeviceFlow = registerForActivityResult(SyncWithAnotherDeviceContract()) { resultOk ->
- if (resultOk) {
- viewModel.onDeviceConnected()
+ private val syncWithAnotherDeviceFlow = registerForActivityResult(SyncWithAnotherDeviceContract()) { result ->
+ when (result) {
+ DeviceConnected -> viewModel.onDeviceConnected()
+ SwitchAccountSuccess -> viewModel.onLoginSuccess()
+ else -> {}
}
}
diff --git a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncConnectActivity.kt b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncConnectActivity.kt
index 94309baaf06b..5c03827df8bc 100644
--- a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncConnectActivity.kt
+++ b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncConnectActivity.kt
@@ -39,6 +39,7 @@ import com.duckduckgo.sync.impl.ui.SyncConnectViewModel.Command.ShowError
import com.duckduckgo.sync.impl.ui.SyncConnectViewModel.Command.ShowMessage
import com.duckduckgo.sync.impl.ui.SyncConnectViewModel.ViewState
import com.duckduckgo.sync.impl.ui.setup.EnterCodeContract
+import com.duckduckgo.sync.impl.ui.setup.EnterCodeContract.EnterCodeContractOutput
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
@@ -50,8 +51,8 @@ class SyncConnectActivity : DuckDuckGoActivity() {
private val enterCodeLauncher = registerForActivityResult(
EnterCodeContract(),
- ) { resultOk ->
- if (resultOk) {
+ ) { result ->
+ if (result != EnterCodeContractOutput.Error) {
viewModel.onLoginSuccess()
}
}
diff --git a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncLoginActivity.kt b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncLoginActivity.kt
index ae5cfb12be85..cff4d99d1ef3 100644
--- a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncLoginActivity.kt
+++ b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncLoginActivity.kt
@@ -36,6 +36,7 @@ import com.duckduckgo.sync.impl.ui.SyncLoginViewModel.Command.LoginSucess
import com.duckduckgo.sync.impl.ui.SyncLoginViewModel.Command.ReadTextCode
import com.duckduckgo.sync.impl.ui.SyncLoginViewModel.Command.ShowError
import com.duckduckgo.sync.impl.ui.setup.EnterCodeContract
+import com.duckduckgo.sync.impl.ui.setup.EnterCodeContract.EnterCodeContractOutput
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
@@ -46,8 +47,8 @@ class SyncLoginActivity : DuckDuckGoActivity() {
private val enterCodeLauncher = registerForActivityResult(
EnterCodeContract(),
- ) { resultOk ->
- if (resultOk) {
+ ) { result ->
+ if (result != EnterCodeContractOutput.Error) {
viewModel.onLoginSuccess()
}
}
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 e2d6fd4e871f..0c186be3ea44 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
@@ -36,16 +36,21 @@ import com.duckduckgo.sync.impl.R.string
import com.duckduckgo.sync.impl.Result.Error
import com.duckduckgo.sync.impl.Result.Success
import com.duckduckgo.sync.impl.SyncAccountRepository
+import com.duckduckgo.sync.impl.SyncFeature
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
import com.duckduckgo.sync.impl.ui.SyncWithAnotherActivityViewModel.Command.ReadTextCode
import com.duckduckgo.sync.impl.ui.SyncWithAnotherActivityViewModel.Command.ShowError
import com.duckduckgo.sync.impl.ui.SyncWithAnotherActivityViewModel.Command.ShowMessage
-import javax.inject.*
+import com.duckduckgo.sync.impl.ui.SyncWithAnotherActivityViewModel.Command.SwitchAccountSuccess
+import com.duckduckgo.sync.impl.ui.setup.EnterCodeContract.EnterCodeContractOutput
+import javax.inject.Inject
import kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
@@ -62,6 +67,7 @@ class SyncWithAnotherActivityViewModel @Inject constructor(
private val clipboard: Clipboard,
private val syncPixels: SyncPixels,
private val dispatchers: DispatcherProvider,
+ private val syncFeature: SyncFeature,
) : ViewModel() {
private val command = Channel(1, DROP_OLDEST)
fun commands(): Flow = command.receiveAsFlow()
@@ -111,9 +117,15 @@ class SyncWithAnotherActivityViewModel @Inject constructor(
sealed class Command {
object ReadTextCode : Command()
object LoginSuccess : Command()
+ object SwitchAccountSuccess : Command()
data class ShowMessage(val messageId: Int) : Command()
object FinishWithError : Command()
- data class ShowError(@StringRes val message: Int, val reason: String = "") : Command()
+ data class ShowError(
+ @StringRes val message: Int,
+ val reason: String = "",
+ ) : Command()
+
+ data class AskToSwitchAccount(val encodedStringCode: String) : Command()
}
fun onReadTextCodeClicked() {
@@ -124,32 +136,96 @@ class SyncWithAnotherActivityViewModel @Inject constructor(
fun onQRCodeScanned(qrCode: String) {
viewModelScope.launch(dispatchers.io()) {
+ val userSignedIn = syncAccountRepository.isSignedIn()
when (val result = syncAccountRepository.processCode(qrCode)) {
is Error -> {
- when (result.code) {
- ALREADY_SIGNED_IN.code -> R.string.sync_login_authenticated_device_error
- LOGIN_FAILED.code -> R.string.sync_connect_login_error
- CONNECT_FAILED.code -> R.string.sync_connect_generic_error
- CREATE_ACCOUNT_FAILED.code -> R.string.sync_create_account_generic_error
- INVALID_CODE.code -> R.string.sync_invalid_code_error
- else -> null
- }?.let { message ->
- command.send(ShowError(message = message, reason = result.reason))
- }
+ emitError(result, qrCode)
}
is Success -> {
syncPixels.fireLoginPixel()
- command.send(LoginSuccess)
+ val commandSuccess = if (userSignedIn) {
+ syncPixels.fireUserSwitchedAccount()
+ SwitchAccountSuccess
+ } else {
+ LoginSuccess
+ }
+ command.send(commandSuccess)
}
}
}
}
+ private suspend fun emitError(result: Error, qrCode: String) {
+ if (result.code == ALREADY_SIGNED_IN.code && syncFeature.seamlessAccountSwitching().isEnabled()) {
+ command.send(AskToSwitchAccount(qrCode))
+ } else {
+ when (result.code) {
+ ALREADY_SIGNED_IN.code -> R.string.sync_login_authenticated_device_error
+ LOGIN_FAILED.code -> R.string.sync_connect_login_error
+ CONNECT_FAILED.code -> R.string.sync_connect_generic_error
+ CREATE_ACCOUNT_FAILED.code -> R.string.sync_create_account_generic_error
+ INVALID_CODE.code -> R.string.sync_invalid_code_error
+ else -> null
+ }?.let { message ->
+ command.send(ShowError(message = message, reason = result.reason))
+ }
+ }
+ }
+
fun onLoginSuccess() {
viewModelScope.launch {
syncPixels.fireLoginPixel()
command.send(LoginSuccess)
}
}
+
+ fun onUserAcceptedJoiningNewAccount(encodedStringCode: String) {
+ viewModelScope.launch(dispatchers.io()) {
+ syncPixels.fireUserAcceptedSwitchingAccount()
+ val result = syncAccountRepository.logoutAndJoinNewAccount(encodedStringCode)
+ if (result is Error) {
+ when (result.code) {
+ ALREADY_SIGNED_IN.code -> R.string.sync_login_authenticated_device_error
+ LOGIN_FAILED.code -> R.string.sync_connect_login_error
+ CONNECT_FAILED.code -> R.string.sync_connect_generic_error
+ CREATE_ACCOUNT_FAILED.code -> R.string.sync_create_account_generic_error
+ INVALID_CODE.code -> R.string.sync_invalid_code_error
+ else -> null
+ }?.let { message ->
+ command.send(
+ ShowError(message = message, reason = result.reason),
+ )
+ }
+ } else {
+ syncPixels.fireLoginPixel()
+ syncPixels.fireUserSwitchedAccount()
+ command.send(SwitchAccountSuccess)
+ }
+ }
+ }
+
+ fun onEnterCodeResult(result: EnterCodeContractOutput) {
+ viewModelScope.launch {
+ when (result) {
+ EnterCodeContractOutput.Error -> {}
+ EnterCodeContractOutput.LoginSuccess -> {
+ syncPixels.fireLoginPixel()
+ command.send(LoginSuccess)
+ }
+ EnterCodeContractOutput.SwitchAccountSuccess -> {
+ syncPixels.fireLoginPixel()
+ command.send(SwitchAccountSuccess)
+ }
+ }
+ }
+ }
+
+ fun onUserCancelledJoiningNewAccount() {
+ syncPixels.fireUserCancelledSwitchingAccount()
+ }
+
+ fun onUserAskedToSwitchAccount() {
+ syncPixels.fireAskUserToSwitchAccount()
+ }
}
diff --git a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncWithAnotherDeviceActivity.kt b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncWithAnotherDeviceActivity.kt
index 5faaef44f77d..3a51582e64f9 100644
--- a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncWithAnotherDeviceActivity.kt
+++ b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncWithAnotherDeviceActivity.kt
@@ -32,6 +32,13 @@ import com.duckduckgo.sync.impl.R
import com.duckduckgo.sync.impl.databinding.ActivityConnectSyncBinding
import com.duckduckgo.sync.impl.ui.EnterCodeActivity.Companion.Code.RECOVERY_CODE
import com.duckduckgo.sync.impl.ui.SyncWithAnotherActivityViewModel.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
+import com.duckduckgo.sync.impl.ui.SyncWithAnotherActivityViewModel.Command.ReadTextCode
+import com.duckduckgo.sync.impl.ui.SyncWithAnotherActivityViewModel.Command.ShowError
+import com.duckduckgo.sync.impl.ui.SyncWithAnotherActivityViewModel.Command.ShowMessage
+import com.duckduckgo.sync.impl.ui.SyncWithAnotherActivityViewModel.Command.SwitchAccountSuccess
import com.duckduckgo.sync.impl.ui.SyncWithAnotherActivityViewModel.ViewState
import com.duckduckgo.sync.impl.ui.setup.EnterCodeContract
import com.google.android.material.snackbar.Snackbar
@@ -45,10 +52,8 @@ class SyncWithAnotherDeviceActivity : DuckDuckGoActivity() {
private val enterCodeLauncher = registerForActivityResult(
EnterCodeContract(),
- ) { resultOk ->
- if (resultOk) {
- viewModel.onLoginSuccess()
- }
+ ) { result ->
+ viewModel.onEnterCodeResult(result)
}
override fun onCreate(savedInstanceState: Bundle?) {
@@ -95,20 +100,26 @@ class SyncWithAnotherDeviceActivity : DuckDuckGoActivity() {
private fun processCommand(it: Command) {
when (it) {
- Command.ReadTextCode -> {
+ ReadTextCode -> {
enterCodeLauncher.launch(RECOVERY_CODE)
}
- Command.LoginSuccess -> {
+ is LoginSuccess -> {
setResult(RESULT_OK)
finish()
}
- Command.FinishWithError -> {
+ FinishWithError -> {
setResult(RESULT_CANCELED)
finish()
}
-
- is Command.ShowMessage -> Snackbar.make(binding.root, it.messageId, Snackbar.LENGTH_SHORT).show()
- is Command.ShowError -> showError(it)
+ is ShowMessage -> Snackbar.make(binding.root, it.messageId, Snackbar.LENGTH_SHORT).show()
+ is ShowError -> showError(it)
+ is AskToSwitchAccount -> askUserToSwitchAccount(it)
+ SwitchAccountSuccess -> {
+ val resultIntent = Intent()
+ resultIntent.putExtra(EXTRA_USER_SWITCHED_ACCOUNT, true)
+ setResult(RESULT_OK, resultIntent)
+ finish()
+ }
}
}
@@ -121,7 +132,27 @@ class SyncWithAnotherDeviceActivity : DuckDuckGoActivity() {
}
}
- private fun showError(it: Command.ShowError) {
+ private fun askUserToSwitchAccount(it: AskToSwitchAccount) {
+ viewModel.onUserAskedToSwitchAccount()
+ TextAlertDialogBuilder(this)
+ .setTitle(R.string.sync_dialog_switch_account_header)
+ .setMessage(R.string.sync_dialog_switch_account_description)
+ .setPositiveButton(R.string.sync_dialog_switch_account_primary_button)
+ .setNegativeButton(R.string.sync_dialog_switch_account_secondary_button)
+ .addEventListener(
+ object : TextAlertDialogBuilder.EventListener() {
+ override fun onPositiveButtonClicked() {
+ viewModel.onUserAcceptedJoiningNewAccount(it.encodedStringCode)
+ }
+
+ override fun onNegativeButtonClicked() {
+ viewModel.onUserCancelledJoiningNewAccount()
+ }
+ },
+ ).show()
+ }
+
+ private fun showError(it: ShowError) {
TextAlertDialogBuilder(this)
.setTitle(R.string.sync_dialog_error_title)
.setMessage(getString(it.message) + "\n" + it.reason)
@@ -136,6 +167,8 @@ class SyncWithAnotherDeviceActivity : DuckDuckGoActivity() {
}
companion object {
+ const val EXTRA_USER_SWITCHED_ACCOUNT = "userSwitchedAccount"
+
internal fun intent(context: Context): Intent {
return Intent(context, SyncWithAnotherDeviceActivity::class.java)
}
diff --git a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/setup/EnterCodeContract.kt b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/setup/EnterCodeContract.kt
index fb7a05f5ea98..649971994dc1 100644
--- a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/setup/EnterCodeContract.kt
+++ b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/setup/EnterCodeContract.kt
@@ -22,8 +22,10 @@ import android.content.Intent
import androidx.activity.result.contract.ActivityResultContract
import com.duckduckgo.sync.impl.ui.EnterCodeActivity
import com.duckduckgo.sync.impl.ui.EnterCodeActivity.Companion.Code
+import com.duckduckgo.sync.impl.ui.EnterCodeActivity.Companion.EXTRA_USER_SWITCHED_ACCOUNT
+import com.duckduckgo.sync.impl.ui.setup.EnterCodeContract.EnterCodeContractOutput
-class EnterCodeContract : ActivityResultContract() {
+class EnterCodeContract : ActivityResultContract() {
override fun createIntent(
context: Context,
codeType: Code,
@@ -34,7 +36,24 @@ class EnterCodeContract : ActivityResultContract() {
override fun parseResult(
resultCode: Int,
intent: Intent?,
- ): Boolean {
- return resultCode == Activity.RESULT_OK
+ ): EnterCodeContractOutput {
+ when {
+ resultCode == Activity.RESULT_OK -> {
+ val userSwitchedAccount = intent?.getBooleanExtra(EXTRA_USER_SWITCHED_ACCOUNT, false) ?: false
+ return if (userSwitchedAccount) {
+ EnterCodeContractOutput.SwitchAccountSuccess
+ } else {
+ EnterCodeContractOutput.LoginSuccess
+ }
+ }
+
+ else -> return EnterCodeContractOutput.Error
+ }
+ }
+
+ sealed class EnterCodeContractOutput {
+ data object LoginSuccess : EnterCodeContractOutput()
+ data object SwitchAccountSuccess : EnterCodeContractOutput()
+ data object Error : EnterCodeContractOutput()
}
}
diff --git a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/setup/SyncWithAnotherDeviceContract.kt b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/setup/SyncWithAnotherDeviceContract.kt
index e59a35a556b8..cb43d3fa4901 100644
--- a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/setup/SyncWithAnotherDeviceContract.kt
+++ b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/setup/SyncWithAnotherDeviceContract.kt
@@ -21,8 +21,10 @@ import android.content.Context
import android.content.Intent
import androidx.activity.result.contract.ActivityResultContract
import com.duckduckgo.sync.impl.ui.SyncWithAnotherDeviceActivity
+import com.duckduckgo.sync.impl.ui.SyncWithAnotherDeviceActivity.Companion.EXTRA_USER_SWITCHED_ACCOUNT
+import com.duckduckgo.sync.impl.ui.setup.SyncWithAnotherDeviceContract.SyncWithAnotherDeviceContractOutput
-internal class SyncWithAnotherDeviceContract : ActivityResultContract() {
+internal class SyncWithAnotherDeviceContract : ActivityResultContract() {
override fun createIntent(
context: Context,
input: Void?,
@@ -33,7 +35,23 @@ internal class SyncWithAnotherDeviceContract : ActivityResultContract {
+ val userSwitchedAccount = intent?.getBooleanExtra(EXTRA_USER_SWITCHED_ACCOUNT, false) ?: false
+ return if (userSwitchedAccount) {
+ SyncWithAnotherDeviceContractOutput.SwitchAccountSuccess
+ } else {
+ SyncWithAnotherDeviceContractOutput.DeviceConnected
+ }
+ }
+ else -> return SyncWithAnotherDeviceContractOutput.Error
+ }
+ }
+
+ sealed class SyncWithAnotherDeviceContractOutput {
+ data object DeviceConnected : SyncWithAnotherDeviceContractOutput()
+ data object SwitchAccountSuccess : SyncWithAnotherDeviceContractOutput()
+ data object Error : SyncWithAnotherDeviceContractOutput()
}
}
diff --git a/sync/sync-impl/src/main/res/values/donottranslate.xml b/sync/sync-impl/src/main/res/values/donottranslate.xml
index ab52625f0197..99120251878c 100644
--- a/sync/sync-impl/src/main/res/values/donottranslate.xml
+++ b/sync/sync-impl/src/main/res/values/donottranslate.xml
@@ -30,4 +30,8 @@
Device Id
Device Name
+ Switch to a different Sync?
+ Switch Sync
+ Cancel
+ 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.
\ No newline at end of file
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 762a021f8b94..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
@@ -77,6 +79,7 @@ import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.ArgumentMatchers.anyString
+import org.mockito.kotlin.doAnswer
import org.mockito.kotlin.eq
import org.mockito.kotlin.mock
import org.mockito.kotlin.times
@@ -93,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
@@ -230,6 +245,62 @@ 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()
+ doAnswer {
+ givenUnauthenticatedDevice() // simulate logout locally
+ logoutSuccess
+ }.`when`(syncApi).logout(token, deviceId)
+ prepareForLoginSuccess()
+
+ val result = syncRepo.logoutAndJoinNewAccount(jsonRecoveryKeyEncoded)
+
+ assertTrue(result is Result.Success)
+ verify(syncStore).clearAll()
+ verify(syncStore).storeCredentials(
+ userId = userId,
+ deviceId = deviceId,
+ deviceName = deviceName,
+ primaryKey = primaryKey,
+ secretKey = secretKey,
+ token = token,
+ )
+ }
+
@Test
fun whenGenerateKeysFromRecoveryCodeFailsThenReturnLoginFailedError() {
prepareToProvideDeviceIds()
@@ -522,6 +593,10 @@ class AppSyncAccountRepositoryTest {
whenever(syncStore.isSignedIn()).thenReturn(true)
}
+ private fun givenUnauthenticatedDevice() {
+ whenever(syncStore.isSignedIn()).thenReturn(false)
+ }
+
private fun prepareToProvideDeviceIds() {
whenever(syncDeviceIds.userId()).thenReturn(userId)
whenever(syncDeviceIds.deviceId()).thenReturn(deviceId)
@@ -545,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 4c1434a83d6e..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
@@ -19,6 +19,8 @@ package com.duckduckgo.sync.impl.ui
import androidx.test.ext.junit.runners.AndroidJUnit4
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.TestSyncFixtures.jsonConnectKeyEncoded
import com.duckduckgo.sync.TestSyncFixtures.jsonRecoveryKeyEncoded
import com.duckduckgo.sync.impl.AccountErrorCodes.ALREADY_SIGNED_IN
@@ -31,16 +33,21 @@ import com.duckduckgo.sync.impl.Clipboard
import com.duckduckgo.sync.impl.Result.Error
import com.duckduckgo.sync.impl.Result.Success
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.AuthState
import com.duckduckgo.sync.impl.ui.EnterCodeViewModel.AuthState.Idle
-import com.duckduckgo.sync.impl.ui.EnterCodeViewModel.Command.LoginSucess
+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 kotlinx.coroutines.test.runTest
import org.junit.Assert.assertTrue
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
@@ -52,11 +59,17 @@ internal class EnterCodeViewModelTest {
private val syncAccountRepository: SyncAccountRepository = mock()
private val clipboard: Clipboard = mock()
+ private val syncFeature = FakeFeatureToggleFactory.create(SyncFeature::class.java).apply {
+ this.seamlessAccountSwitching().setRawStoredState(State(true))
+ }
+ private val syncPixels: SyncPixels = mock()
private val testee = EnterCodeViewModel(
syncAccountRepository,
clipboard,
coroutineTestRule.testDispatcherProvider,
+ syncFeature = syncFeature,
+ syncPixels = syncPixels,
)
@Test
@@ -86,7 +99,7 @@ internal class EnterCodeViewModelTest {
testee.commands().test {
val command = awaitItem()
- assertTrue(command is LoginSucess)
+ assertTrue(command is LoginSuccess)
cancelAndIgnoreRemainingEvents()
}
}
@@ -100,7 +113,7 @@ internal class EnterCodeViewModelTest {
testee.commands().test {
val command = awaitItem()
- assertTrue(command is LoginSucess)
+ assertTrue(command is LoginSuccess)
cancelAndIgnoreRemainingEvents()
}
}
@@ -121,6 +134,7 @@ internal class EnterCodeViewModelTest {
@Test
fun whenProcessCodeButUserSignedInThenShowError() = runTest {
+ syncFeature.seamlessAccountSwitching().setRawStoredState(State(false))
whenever(clipboard.pasteFromClipboard()).thenReturn(jsonRecoveryKeyEncoded)
whenever(syncAccountRepository.processCode(jsonRecoveryKeyEncoded)).thenReturn(Error(code = ALREADY_SIGNED_IN.code))
@@ -133,6 +147,61 @@ internal class EnterCodeViewModelTest {
}
}
+ @Test
+ fun whenProcessCodeButUserSignedInThenOfferToSwitchAccount() = runTest {
+ whenever(clipboard.pasteFromClipboard()).thenReturn(jsonRecoveryKeyEncoded)
+ whenever(syncAccountRepository.processCode(jsonRecoveryKeyEncoded)).thenReturn(Error(code = ALREADY_SIGNED_IN.code))
+
+ testee.onPasteCodeClicked()
+
+ testee.commands().test {
+ val command = awaitItem()
+ assertTrue(command is AskToSwitchAccount)
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun whenUserAcceptsToSwitchAccountThenPerformAction() = runTest {
+ whenever(syncAccountRepository.logoutAndJoinNewAccount(jsonRecoveryKeyEncoded)).thenReturn(Success(true))
+
+ testee.onUserAcceptedJoiningNewAccount(jsonRecoveryKeyEncoded)
+
+ testee.commands().test {
+ val command = awaitItem()
+ assertTrue(command is SwitchAccountSuccess)
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @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 341736152591..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
@@ -19,6 +19,8 @@ package com.duckduckgo.sync.impl.ui
import androidx.test.ext.junit.runners.AndroidJUnit4
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.TestSyncFixtures
import com.duckduckgo.sync.TestSyncFixtures.jsonRecoveryKeyEncoded
import com.duckduckgo.sync.impl.AccountErrorCodes.ALREADY_SIGNED_IN
@@ -26,10 +28,13 @@ import com.duckduckgo.sync.impl.AccountErrorCodes.LOGIN_FAILED
import com.duckduckgo.sync.impl.Clipboard
import com.duckduckgo.sync.impl.QREncoder
import com.duckduckgo.sync.impl.Result
+import com.duckduckgo.sync.impl.Result.Success
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.SyncWithAnotherActivityViewModel.Command
import com.duckduckgo.sync.impl.ui.SyncWithAnotherActivityViewModel.Command.LoginSuccess
+import com.duckduckgo.sync.impl.ui.SyncWithAnotherActivityViewModel.Command.SwitchAccountSuccess
import kotlinx.coroutines.test.runTest
import org.junit.Assert
import org.junit.Assert.assertTrue
@@ -53,6 +58,9 @@ class SyncWithAnotherDeviceViewModelTest {
private val clipboard: Clipboard = mock()
private val qrEncoder: QREncoder = mock()
private val syncPixels: SyncPixels = mock()
+ private val syncFeature = FakeFeatureToggleFactory.create(SyncFeature::class.java).apply {
+ this.seamlessAccountSwitching().setRawStoredState(State(true))
+ }
private val testee = SyncWithAnotherActivityViewModel(
syncRepository,
@@ -60,6 +68,7 @@ class SyncWithAnotherDeviceViewModelTest {
clipboard,
syncPixels,
coroutineTestRule.testDispatcherProvider,
+ syncFeature,
)
@Test
@@ -123,6 +132,7 @@ class SyncWithAnotherDeviceViewModelTest {
@Test
fun whenUserScansRecoveryCodeButSignedInThenCommandIsError() = runTest {
+ syncFeature.seamlessAccountSwitching().setRawStoredState(State(false))
whenever(syncRepository.processCode(jsonRecoveryKeyEncoded)).thenReturn(Result.Error(code = ALREADY_SIGNED_IN.code))
testee.commands().test {
testee.onQRCodeScanned(jsonRecoveryKeyEncoded)
@@ -133,6 +143,59 @@ class SyncWithAnotherDeviceViewModelTest {
}
}
+ @Test
+ fun whenUserScansRecoveryCodeButSignedInThenCommandIsAskToSwitchAccount() = runTest {
+ whenever(syncRepository.processCode(jsonRecoveryKeyEncoded)).thenReturn(Result.Error(code = ALREADY_SIGNED_IN.code))
+ testee.commands().test {
+ testee.onQRCodeScanned(jsonRecoveryKeyEncoded)
+ val command = awaitItem()
+ assertTrue(command is Command.AskToSwitchAccount)
+ verifyNoInteractions(syncPixels)
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun whenUserAcceptsToSwitchAccountThenPerformAction() = runTest {
+ whenever(syncRepository.logoutAndJoinNewAccount(jsonRecoveryKeyEncoded)).thenReturn(Success(true))
+
+ testee.onUserAcceptedJoiningNewAccount(jsonRecoveryKeyEncoded)
+
+ testee.commands().test {
+ val command = awaitItem()
+ assertTrue(command is SwitchAccountSuccess)
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @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))