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