From acb5531c66070571a089244ca2ebc3a06e188be4 Mon Sep 17 00:00:00 2001 From: Cristian Monforte Date: Wed, 5 Mar 2025 12:20:47 +0100 Subject: [PATCH 1/3] aligning recovery code with connect flow --- .../sync/impl/SyncAccountRepository.kt | 370 ++++++++++++++++-- .../com/duckduckgo/sync/impl/SyncFeature.kt | 1 - .../com/duckduckgo/sync/impl/SyncService.kt | 15 + .../duckduckgo/sync/impl/SyncServiceRemote.kt | 43 ++ .../sync/impl/ui/EnterCodeViewModel.kt | 60 ++- .../sync/impl/ui/SyncConnectViewModel.kt | 63 ++- .../sync/impl/ui/SyncLoginViewModel.kt | 71 +++- .../ui/SyncWithAnotherActivityViewModel.kt | 116 +++++- .../com/duckduckgo/sync/TestSyncFixtures.kt | 9 +- .../sync/impl/AppSyncAccountRepositoryTest.kt | 246 +++++++++++- .../ui/SyncWithAnotherDeviceViewModelTest.kt | 3 + 11 files changed, 902 insertions(+), 95 deletions(-) diff --git a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/SyncAccountRepository.kt b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/SyncAccountRepository.kt index c5ac8003d753..ca1f5a0e8f17 100644 --- a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/SyncAccountRepository.kt +++ b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/SyncAccountRepository.kt @@ -32,12 +32,16 @@ import com.duckduckgo.sync.impl.AccountErrorCodes.CREATE_ACCOUNT_FAILED import com.duckduckgo.sync.impl.AccountErrorCodes.GENERIC_ERROR import com.duckduckgo.sync.impl.AccountErrorCodes.INVALID_CODE import com.duckduckgo.sync.impl.AccountErrorCodes.LOGIN_FAILED +import com.duckduckgo.sync.impl.CodeType.UNKNOWN +import com.duckduckgo.sync.impl.ExchangeResult.* import com.duckduckgo.sync.impl.Result.Error +import com.duckduckgo.sync.impl.Result.Success import com.duckduckgo.sync.impl.pixels.* import com.duckduckgo.sync.store.* import com.squareup.anvil.annotations.* import com.squareup.moshi.* import dagger.* +import java.util.UUID import javax.inject.* import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -45,6 +49,7 @@ import timber.log.Timber interface SyncAccountRepository { + fun getCodeType(stringCode: String): CodeType fun isSyncSupported(): Boolean fun createAccount(): Result fun isSignedIn(): Boolean @@ -58,6 +63,9 @@ interface SyncAccountRepository { fun getConnectedDevices(): Result> fun getConnectQR(): Result fun pollConnectionKeys(): Result + fun generateExchangeInvitationCode(): Result + fun pollSecondDeviceExchangeAcknowledgement(): Result + fun pollForRecoveryCodeAndLogin(): Result fun renameDevice(device: ConnectedDevice): Result fun logoutAndJoinNewAccount(stringCode: String): Result } @@ -75,8 +83,19 @@ class AppSyncAccountRepository @Inject constructor( @AppCoroutineScope private val appCoroutineScope: CoroutineScope, private val dispatcherProvider: DispatcherProvider, private val syncFeature: SyncFeature, + private val deviceKeyGenerator: DeviceKeyGenerator, ) : SyncAccountRepository { + /** + * If there is a key-exchange invitation in progress, we need to keep a reference to them + * There are separate invitation details for the inviter and the receiver + * + * Inviter is reset every time a new exchange invitation code is created. + * Receiver is reset every time an exchange invitation is received. + */ + private var pendingInvitationAsInviter: PendingInvitation? = null + private var pendingInvitationAsReceiver: PendingInvitation? = null + private val connectedDevicesCached: MutableList = mutableListOf() override fun isSyncSupported(): Boolean { @@ -95,19 +114,139 @@ class AppSyncAccountRepository @Inject constructor( } override fun processCode(stringCode: String): Result { + val decodedCode: String? = kotlin.runCatching { + return@runCatching stringCode.decodeB64() + }.getOrNull() + if (decodedCode == null) { + Timber.w("Failed while b64 decoding barcode; barcode is unusable") + return Error(code = INVALID_CODE.code, reason = "Failed to decode code") + } + kotlin.runCatching { - Adapters.recoveryCodeAdapter.fromJson(stringCode.decodeB64())?.recovery + Adapters.recoveryCodeAdapter.fromJson(decodedCode)?.recovery }.getOrNull()?.let { + Timber.d("Sync: code is a recovery code") return login(it) } kotlin.runCatching { - Adapters.recoveryCodeAdapter.fromJson(stringCode.decodeB64())?.connect + Adapters.recoveryCodeAdapter.fromJson(decodedCode)?.connect }.getOrNull()?.let { + Timber.d("Sync: code is a connect code") return connectDevice(it) } - return Error(code = INVALID_CODE.code, reason = "Failed to decode recovery code") + kotlin.runCatching { + Adapters.invitationCodeAdapter.fromJson(decodedCode)?.exchangeKey + }.getOrNull()?.let { + if (!syncFeature.exchangeKeysToSyncWithAnotherDevice().isEnabled()) { + Timber.w("Sync: Scanned exchange code type but exchanging keys to sync with another device is disabled") + return@let null + } + + return completeExchange(it) + } + + Timber.e("Sync: code is not supported") + return Error(code = INVALID_CODE.code, reason = "Failed to decode code") + } + + override fun getCodeType(stringCode: String): CodeType { + return kotlin.runCatching { + val decodedCode = stringCode.decodeB64() + when { + Adapters.recoveryCodeAdapter.fromJson(decodedCode)?.recovery != null -> CodeType.RECOVERY + Adapters.recoveryCodeAdapter.fromJson(decodedCode)?.connect != null -> CodeType.CONNECT + Adapters.invitationCodeAdapter.fromJson(decodedCode)?.exchangeKey != null -> CodeType.EXCHANGE + else -> UNKNOWN + } + }.onFailure { + Timber.e(it, "Failed to decode code") + }.getOrDefault(UNKNOWN) + } + + private fun completeExchange(invitationCode: InvitationCode): Result { + // Sync: InviteFlow - B (https://app.asana.com/0/72649045549333/1209571867429615) + Timber.d("Sync-exchange: InviteFlow - B. code is an exchange code $invitationCode") + + // generate new ID and and public/private key-pair for receiving device + val thisDeviceKeyId = deviceKeyGenerator.generate() + val thisDeviceKeyPair = nativeLib.prepareForConnect() + + pendingInvitationAsReceiver = PendingInvitation( + keyId = thisDeviceKeyId, + privateKey = thisDeviceKeyPair.secretKey, + publicKey = thisDeviceKeyPair.publicKey, + ) + val deviceName = syncDeviceIds.deviceName() + + Timber.i( + "Sync: details for this (receiver) device:" + + "\n\tkey ID is $thisDeviceKeyId" + + "\n\tpublic key is ${thisDeviceKeyPair.publicKey}" + + "\n\tdevice name is $deviceName", + ) + + val invitedDeviceDetails = InvitedDeviceDetails( + keyId = thisDeviceKeyId, + publicKey = thisDeviceKeyPair.publicKey, + deviceName = deviceName, + ) + + kotlin.runCatching { + val payload = Adapters.invitedDeviceAdapter.toJson(invitedDeviceDetails) + val encrypted = nativeLib.seal(payload, invitationCode.publicKey) + return syncApi.sendEncryptedMessage(invitationCode.keyId, encrypted) + }.getOrElse { throwable -> + throwable.asErrorResult().alsoFireAccountErrorPixel() + return Error(code = GENERIC_ERROR.code, reason = "Exchange: Error encrypting payload") + } + } + + override fun pollForRecoveryCodeAndLogin(): Result { + // Sync: InviteFlow - E (https://app.asana.com/0/72649045549333/1209571867429615) + Timber.d("Sync-exchange: InviteFlow - E") + + val pendingInvite = pendingInvitationAsReceiver + ?: return Error(code = CONNECT_FAILED.code, reason = "Connect: No pending invite initialized").also { + Timber.w("Sync-exchange: no pending invite initialized") + } + + return when (val result = syncApi.getEncryptedMessage(pendingInvite.keyId)) { + is Error -> { + if (result.code == NOT_FOUND.code) { + return Success(Pending) + } else if (result.code == GONE.code) { + return Error(code = CONNECT_FAILED.code, reason = "Connect: keys expired").alsoFireAccountErrorPixel() + } + result.alsoFireAccountErrorPixel() + } + + is Success -> { + Timber.d("Sync-exchange: received encrypted recovery code") + + val decryptedJson = kotlin.runCatching { + nativeLib.sealOpen(result.data, pendingInvite.publicKey, pendingInvite.privateKey) + }.getOrNull() + ?: return Error(code = CONNECT_FAILED.code, reason = "Connect: Error opening seal").alsoFireAccountErrorPixel() + + val recoveryData = kotlin.runCatching { + Adapters.recoveryCodeAdapter.fromJson(decryptedJson)?.recovery + }.getOrNull() + ?: return Error(code = CONNECT_FAILED.code, reason = "Connect: Error reading recovery code").alsoFireAccountErrorPixel() + + return when (val loginResult = login(recoveryData)) { + is Success -> Success(LoggedIn) + is Error -> { + return if (loginResult.code == ALREADY_SIGNED_IN.code) { + Success(AccountSwitchingRequired(decryptedJson.encodeB64())) + } else { + loginResult + } + } + } + } + } } private fun login(recoveryCode: RecoveryCode): Result { @@ -166,7 +305,28 @@ class AppSyncAccountRepository @Inject constructor( override fun getRecoveryCode(): Result { val primaryKey = syncStore.primaryKey ?: return Error(reason = "Get Recovery Code: Not existing primary Key").alsoFireAccountErrorPixel() val userID = syncStore.userId ?: return Error(reason = "Get Recovery Code: Not existing userId").alsoFireAccountErrorPixel() - return Result.Success(Adapters.recoveryCodeAdapter.toJson(LinkCode(RecoveryCode(primaryKey, userID))).encodeB64()) + return Success(Adapters.recoveryCodeAdapter.toJson(LinkCode(RecoveryCode(primaryKey, userID))).encodeB64()) + } + + override fun generateExchangeInvitationCode(): Result { + // Sync: InviteFlow - A (https://app.asana.com/0/72649045549333/1209571867429615) + Timber.d("Sync-exchange: InviteFlow - A. Generating invitation code") + + // generate new ID and and public/private key-pair + generateInviterDeviceDetails() + + val pendingInvitation = pendingInvitationAsInviter + ?: return Error(code = GENERIC_ERROR.code, reason = "Exchange: No pending invitation initialized").alsoFireAccountErrorPixel() + + val invitationCode = InvitationCode(keyId = pendingInvitation.keyId, publicKey = pendingInvitation.publicKey) + val invitationWrapper = InvitationCodeWrapper(invitationCode) + + return kotlin.runCatching { + val code = Adapters.invitationCodeAdapter.toJson(invitationWrapper).encodeB64() + Success(code) + }.getOrElse { + Error(code = GENERIC_ERROR.code, reason = "Error generating invitation code").alsoFireAccountErrorPixel() + } } override fun getConnectQR(): Result { @@ -185,7 +345,7 @@ class AppSyncAccountRepository @Inject constructor( LinkCode(connect = ConnectCode(deviceId = deviceId, secretKey = prepareForConnect.publicKey)), ) ?: return Error(reason = "Error generating Linking Code").alsoFireAccountErrorPixel() - return Result.Success(linkingQRCode.encodeB64()) + return Success(linkingQRCode.encodeB64()) } private fun connectDevice(connectKeys: ConnectCode): Result { @@ -205,18 +365,17 @@ class AppSyncAccountRepository @Inject constructor( override fun pollConnectionKeys(): Result { val deviceId = syncDeviceIds.deviceId() - val result = syncApi.connectDevice(deviceId) - return when (result) { + return when (val result = syncApi.connectDevice(deviceId)) { is Error -> { if (result.code == NOT_FOUND.code) { - return Result.Success(false) + return Success(false) } else if (result.code == GONE.code) { return Error(code = CONNECT_FAILED.code, reason = "Connect: keys expired").alsoFireAccountErrorPixel() } result.alsoFireAccountErrorPixel() } - is Result.Success -> { + is Success -> { val sealOpen = kotlin.runCatching { nativeLib.sealOpen(result.data, syncStore.primaryKey!!, syncStore.secretKey!!) }.getOrElse { throwable -> @@ -234,6 +393,85 @@ class AppSyncAccountRepository @Inject constructor( } } + override fun pollSecondDeviceExchangeAcknowledgement(): Result { + // Sync: InviteFlow - C (https://app.asana.com/0/72649045549333/1209571867429615) + Timber.d("Sync-exchange: InviteFlow - C") + + val keyId = pendingInvitationAsInviter?.keyId ?: return Error(reason = "No pending invitation initialized") + + return when (val result = syncApi.getEncryptedMessage(keyId)) { + is Error -> { + if (result.code == NOT_FOUND.code) { + return Success(false) + } else if (result.code == GONE.code) { + return Error( + code = CONNECT_FAILED.code, + reason = "Connect: keys expired", + ).alsoFireAccountErrorPixel() + } + result.alsoFireAccountErrorPixel() + } + + is Success -> { + Timber.v("Sync-exchange: Found invitation acceptance for keyId: $keyId} ${result.data}") + + val decrypted = kotlin.runCatching { + val pending = pendingInvitationAsInviter + ?: return Error(code = CONNECT_FAILED.code, reason = "Exchange: No pending invitation initialized") + .alsoFireAccountErrorPixel() + + nativeLib.sealOpen(result.data, pending.publicKey, pending.privateKey) + }.getOrElse { throwable -> + throwable.asErrorResult().alsoFireAccountErrorPixel() + return Error(code = CONNECT_FAILED.code, reason = "Connect: Error opening seal") + } + + Timber.v("Sync-exchange: invitation acceptance received: $decrypted") + + val response = Adapters.invitedDeviceAdapter.fromJson(decrypted) + ?: return Error(code = GENERIC_ERROR.code, reason = "Connect: Error reading invitation response").alsoFireAccountErrorPixel() + + val otherDevicePublicKey = response.publicKey + val otherDeviceKeyId = response.keyId + + Timber.v( + "Sync-exchange: We have received the other device's details. " + + "name:${response.deviceName}, keyId:${response.keyId}, public key: ${response.publicKey}", + ) + + // we encrypt our secrets with otherDevicePublicKey, and send them to the backend endpoint + return sendSecrets(otherDeviceKeyId, otherDevicePublicKey).onFailure { + Timber.w("Sync-exchange: failed to send secrets. error code: ${it.code} ${it.reason}") + return it.copy(code = LOGIN_FAILED.code) + } + } + } + } + + private fun sendSecrets(keyId: String, publicKey: String): Result { + // Sync: InviteFlow - D (https://app.asana.com/0/72649045549333/1209571867429615) + Timber.d("Sync-exchange: InviteFlow - D") + + when (val recoveryCode = getRecoveryCode()) { + is Error -> { + Timber.e("Sync-exchange: failed to get recovery code. error code: ${recoveryCode.code} ${recoveryCode.reason}") + return Error(code = GENERIC_ERROR.code, reason = "Connect: Error getting recovery code").alsoFireAccountErrorPixel() + } + is Success -> { + Timber.v("Sync-exchange: Got recovery code, ready to share encrypted data for key ID: $keyId") + + // recovery code comes b64 encoded, so we need to decode it, then encrypt, which automatically b64 encodes the encrypted form + return kotlin.runCatching { + val json = recoveryCode.data.decodeB64() + val encryptedJson = nativeLib.seal(json, publicKey) + syncApi.sendEncryptedMessage(keyId, encryptedJson) + }.getOrElse { + it.asErrorResult() + } + } + } + } + override fun logout(deviceId: String): Result { val token = syncStore.token.takeUnless { it.isNullOrEmpty() } ?: return Error(reason = "Logout: Token Empty").alsoFireLogoutErrorPixel() @@ -257,11 +495,11 @@ class AppSyncAccountRepository @Inject constructor( result.copy(code = GENERIC_ERROR.code) } - is Result.Success -> { + is Success -> { if (logoutThisDevice) { syncStore.clearAll() } - Result.Success(true) + Success(true) } } } @@ -276,9 +514,9 @@ class AppSyncAccountRepository @Inject constructor( result.alsoFireDeleteAccountErrorPixel().copy(code = GENERIC_ERROR.code) } - is Result.Success -> { + is Success -> { syncStore.clearAll() - Result.Success(true) + Success(true) } } } @@ -309,8 +547,8 @@ class AppSyncAccountRepository @Inject constructor( result.alsoFireAccountErrorPixel().copy(code = GENERIC_ERROR.code) } - is Result.Success -> { - return Result.Success( + is Success -> { + return Success( result.data.mapNotNull { device -> try { val decryptedDeviceName = nativeLib.decryptData(device.deviceName, primaryKey).decryptedData @@ -353,16 +591,34 @@ class AppSyncAccountRepository @Inject constructor( syncPixels.fireUserSwitchedLogoutError() result } - is Result.Success -> { + + is Success -> { val loginResult = processCode(stringCode) if (loginResult is Error) { syncPixels.fireUserSwitchedLoginError() } - loginResult + Success(true) } } } + private fun generateInviterDeviceDetails() { + Timber.i("Sync-exchange: Generating inviter device details") + // generate new ID and and public/private key-pair + val keyId = deviceKeyGenerator.generate() + val prepareForConnect = nativeLib.prepareForConnect() + + PendingInvitation( + keyId = keyId, + privateKey = prepareForConnect.secretKey, + publicKey = prepareForConnect.publicKey, + ).also { + pendingInvitationAsInviter = it + Timber.w("Sync-exchange: this (inviter) device's key ID is $keyId") + Timber.w("Sync-exchange: this (inviter) device's public key is ${it.publicKey}") + } + } + private fun performCreateAccount(): Result { val userId = syncDeviceIds.userId() val account: AccountKeys = kotlin.runCatching { @@ -407,11 +663,11 @@ class AppSyncAccountRepository @Inject constructor( result } - is Result.Success -> { + is Success -> { syncStore.storeCredentials(account.userId, deviceId, deviceName, account.primaryKey, account.secretKey, result.data.token) syncEngine.triggerSync(ACCOUNT_CREATION) Timber.d("Sync-Account: recovery code is ${getRecoveryCode()}") - Result.Success(true) + Success(true) } } } @@ -450,7 +706,7 @@ class AppSyncAccountRepository @Inject constructor( result } - is Result.Success -> { + is Success -> { val decryptResult = kotlin.runCatching { nativeLib.decrypt(result.data.protected_encryption_key, preLogin.stretchedPrimaryKey).also { it.checkResult("Login: decrypt protection keys failed") @@ -464,7 +720,7 @@ class AppSyncAccountRepository @Inject constructor( syncEngine.triggerSync(ACCOUNT_LOGIN) } - Result.Success(true) + Success(true) } } } @@ -527,6 +783,9 @@ class AppSyncAccountRepository @Inject constructor( companion object { private val moshi = Moshi.Builder().build() val recoveryCodeAdapter: JsonAdapter = moshi.adapter(LinkCode::class.java) + + val invitationCodeAdapter: JsonAdapter = moshi.adapter(InvitationCodeWrapper::class.java) + val invitedDeviceAdapter: JsonAdapter = moshi.adapter(InvitedDeviceDetails::class.java) } } } @@ -568,6 +827,21 @@ data class RecoveryCode( @field:Json(name = "user_id") val userId: String, ) +data class InvitationCodeWrapper( + @field:Json(name = "exchange_key") val exchangeKey: InvitationCode, +) + +data class InvitationCode( + @field:Json(name = "key_id") val keyId: String, + @field:Json(name = "public_key") val publicKey: String, +) + +data class InvitedDeviceDetails( + @field:Json(name = "key_id") val keyId: String, + @field:Json(name = "public_key") val publicKey: String, + @field:Json(name = "device_name") val deviceName: String, +) + data class ConnectedDevice( val thisDevice: Boolean = false, val deviceName: String, @@ -589,6 +863,13 @@ enum class AccountErrorCodes(val code: Int) { INVALID_CODE(55), } +enum class CodeType { + RECOVERY, + CONNECT, + EXCHANGE, + UNKNOWN, +} + sealed class Result { data class Success(val data: T) : Result() @@ -607,23 +888,60 @@ sealed class Result { fun Result.getOrNull(): T? { return when (this) { - is Result.Success -> data - is Result.Error -> null + is Success -> data + is Error -> null } } inline fun Result.onSuccess(action: (value: T) -> Unit): Result { - if (this is Result.Success) { + if (this is Success) { action(data) } return this } -inline fun Result.onFailure(action: (error: Result.Error) -> Unit): Result { - if (this is Result.Error) { +inline fun Result.onFailure(action: (error: Error) -> Unit): Result { + if (this is Error) { action(this) } return this } + +private data class PendingInvitation( + val keyId: String, + val privateKey: String, + var publicKey: String, +) + +/** + * Used to indicate the result of the key exchange flow + */ +sealed interface ExchangeResult { + /** + * Exchange finished leaving the user logged in + */ + data object LoggedIn : ExchangeResult + + /** + * Exchange is currently pending, awaiting external action before it's completed + */ + data object Pending : ExchangeResult + + /** + * Exchange finished but the user is already logged in; account switching is required to complete the exchange and log the user in + */ + data class AccountSwitchingRequired(val recoveryCode: String) : ExchangeResult +} + +interface DeviceKeyGenerator { + fun generate(): String +} + +@ContributesBinding(AppScope::class) +class RealDeviceKeyGenerator @Inject constructor() : DeviceKeyGenerator { + override fun generate(): String { + return UUID.randomUUID().toString() + } +} 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 8912f4f569b5..be105cff5e67 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 @@ -48,7 +48,6 @@ interface SyncFeature { @Toggle.DefaultValue(true) fun seamlessAccountSwitching(): Toggle - @InternalAlwaysEnabled @Toggle.DefaultValue(false) fun exchangeKeysToSyncWithAnotherDevice(): Toggle diff --git a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/SyncService.kt b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/SyncService.kt index b9c8f8a8639f..9d886c1cdf6a 100644 --- a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/SyncService.kt +++ b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/SyncService.kt @@ -69,6 +69,16 @@ interface SyncService { @Path("device_id") deviceId: String, ): Call + @GET("$SYNC_PROD_ENVIRONMENT_URL/sync/exchange/{key_id}") + fun getEncryptedMessage( + @Path("key_id") keyId: String, + ): Call + + @POST("$SYNC_PROD_ENVIRONMENT_URL/sync/exchange") + fun sendEncryptedMessage( + @Body request: EncryptedMessage, + ): Call + @PATCH("$SYNC_PROD_ENVIRONMENT_URL/sync/data") fun patch( @Header("Authorization") token: String, @@ -130,6 +140,11 @@ data class ConnectKey( @field:Json(name = "encrypted_recovery_key") val encryptedRecoveryKey: String, ) +data class EncryptedMessage( + @field:Json(name = "key_id") val keyId: String, + @field:Json(name = "encrypted_message") val encryptedMessage: String, +) + data class Connect( @field:Json(name = "device_id") val deviceId: String, @field:Json(name = "encrypted_recovery_key") val encryptedRecoveryKey: String, diff --git a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/SyncServiceRemote.kt b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/SyncServiceRemote.kt index 364afe75c60c..9e6f824a383a 100644 --- a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/SyncServiceRemote.kt +++ b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/SyncServiceRemote.kt @@ -60,6 +60,13 @@ interface SyncApi { deviceId: String, ): Result + fun getEncryptedMessage(keyId: String): Result + + fun sendEncryptedMessage( + keyId: String, + encryptedSecrets: String, + ): Result + fun deleteAccount(token: String): Result fun getDevices(token: String): Result> @@ -192,6 +199,42 @@ class SyncServiceRemote @Inject constructor( } } + override fun getEncryptedMessage(keyId: String): Result { + Timber.v("Sync-exchange: Looking for exchange for keyId: $keyId") + val response = runCatching { + val request = syncService.getEncryptedMessage(keyId) + request.execute() + }.getOrElse { throwable -> + return Result.Error(reason = throwable.message.toString()) + } + + return onSuccess(response) { + val sealed = response.body()?.encryptedMessage.takeUnless { it.isNullOrEmpty() } + ?: return@onSuccess Result.Error(reason = "InvitationFlow: empty body") + Result.Success(sealed) + } + } + + override fun sendEncryptedMessage( + keyId: String, + encryptedSecrets: String, + ): Result { + val response = runCatching { + val shareRecoveryKeyRequest = EncryptedMessage( + keyId = keyId, + encryptedMessage = encryptedSecrets, + ) + val sendSecretCall = syncService.sendEncryptedMessage(shareRecoveryKeyRequest) + sendSecretCall.execute() + }.getOrElse { throwable -> + return Result.Error(reason = throwable.message.toString()) + } + + return onSuccess(response) { + Result.Success(true) + } + } + override fun deleteAccount(token: String): Result { val response = runCatching { val deleteAccountCall = syncService.deleteAccount("Bearer $token") 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 9514d5c9e37d..7fabf9e03232 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 @@ -28,19 +28,27 @@ import com.duckduckgo.sync.impl.AccountErrorCodes.CREATE_ACCOUNT_FAILED import com.duckduckgo.sync.impl.AccountErrorCodes.INVALID_CODE import com.duckduckgo.sync.impl.AccountErrorCodes.LOGIN_FAILED import com.duckduckgo.sync.impl.Clipboard +import com.duckduckgo.sync.impl.CodeType.EXCHANGE +import com.duckduckgo.sync.impl.ExchangeResult.AccountSwitchingRequired +import com.duckduckgo.sync.impl.ExchangeResult.LoggedIn +import com.duckduckgo.sync.impl.ExchangeResult.Pending 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.onFailure +import com.duckduckgo.sync.impl.onSuccess 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 com.duckduckgo.sync.impl.ui.SyncConnectViewModel.Companion.POLLING_INTERVAL_EXCHANGE_FLOW import javax.inject.* import kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.receiveAsFlow @@ -92,17 +100,14 @@ class EnterCodeViewModel @Inject constructor( pastedCode: String, ) { val previousPrimaryKey = syncAccountRepository.getAccountInfo().primaryKey + val codeType = syncAccountRepository.getCodeType(pastedCode) when (val result = syncAccountRepository.processCode(pastedCode)) { is Result.Success -> { - val postProcessCodePK = syncAccountRepository.getAccountInfo().primaryKey - val userSwitchedAccount = previousPrimaryKey.isNotBlank() && previousPrimaryKey != postProcessCodePK - val commandSuccess = if (userSwitchedAccount) { - syncPixels.fireUserSwitchedAccount() - SwitchAccountSuccess + if (codeType == EXCHANGE) { + pollForRecoveryKey(previousPrimaryKey = previousPrimaryKey, code = pastedCode) } else { - LoginSuccess + onLoginSuccess(previousPrimaryKey) } - command.send(commandSuccess) } is Result.Error -> { processError(result, pastedCode) @@ -110,6 +115,47 @@ class EnterCodeViewModel @Inject constructor( } } + private suspend fun onLoginSuccess(previousPrimaryKey: String) { + val postProcessCodePK = syncAccountRepository.getAccountInfo().primaryKey + val userSwitchedAccount = previousPrimaryKey.isNotBlank() && previousPrimaryKey != postProcessCodePK + val commandSuccess = if (userSwitchedAccount) { + syncPixels.fireUserSwitchedAccount() + SwitchAccountSuccess + } else { + LoginSuccess + } + command.send(commandSuccess) + } + + private fun pollForRecoveryKey( + previousPrimaryKey: String, + code: String, + ) { + viewModelScope.launch(dispatchers.io()) { + var polling = true + while (polling) { + delay(POLLING_INTERVAL_EXCHANGE_FLOW) + syncAccountRepository.pollForRecoveryCodeAndLogin() + .onSuccess { success -> + polling = false + + when (success) { + is Pending -> return@onSuccess // continue polling + is AccountSwitchingRequired -> command.send(AskToSwitchAccount(success.recoveryCode)) + LoggedIn -> onLoginSuccess(previousPrimaryKey) + } + }.onFailure { + when (it.code) { + CONNECT_FAILED.code, LOGIN_FAILED.code -> { + polling = false + processError(result = it, pastedCode = code) + } + } + } + } + } + } + private suspend fun processError(result: Error, pastedCode: String) { if (result.code == ALREADY_SIGNED_IN.code && syncFeature.seamlessAccountSwitching().isEnabled()) { command.send(AskToSwitchAccount(pastedCode)) diff --git a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncConnectViewModel.kt b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncConnectViewModel.kt index b4e51afbcc7e..b2791d0dbfc8 100644 --- a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncConnectViewModel.kt +++ b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncConnectViewModel.kt @@ -29,6 +29,10 @@ import com.duckduckgo.sync.impl.AccountErrorCodes.CREATE_ACCOUNT_FAILED import com.duckduckgo.sync.impl.AccountErrorCodes.INVALID_CODE import com.duckduckgo.sync.impl.AccountErrorCodes.LOGIN_FAILED import com.duckduckgo.sync.impl.Clipboard +import com.duckduckgo.sync.impl.CodeType.EXCHANGE +import com.duckduckgo.sync.impl.ExchangeResult.AccountSwitchingRequired +import com.duckduckgo.sync.impl.ExchangeResult.LoggedIn +import com.duckduckgo.sync.impl.ExchangeResult.Pending import com.duckduckgo.sync.impl.QREncoder import com.duckduckgo.sync.impl.R import com.duckduckgo.sync.impl.R.dimen @@ -54,6 +58,7 @@ import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import timber.log.Timber @ContributesViewModel(ActivityScope::class) class SyncConnectViewModel @Inject constructor( @@ -76,7 +81,7 @@ class SyncConnectViewModel @Inject constructor( showQRCode() var polling = true while (polling) { - delay(POLLING_INTERVAL) + delay(POLLING_INTERVAL_CONNECT_FLOW) syncAccountRepository.pollConnectionKeys() .onSuccess { success -> if (!success) return@onSuccess // continue polling @@ -95,6 +100,25 @@ class SyncConnectViewModel @Inject constructor( } } + private suspend fun pollForRecoveryKey() { + var polling = true + while (polling) { + delay(POLLING_INTERVAL_EXCHANGE_FLOW) + syncAccountRepository.pollForRecoveryCodeAndLogin() + .onSuccess { success -> + polling = false + + when (success) { + is Pending -> return@onSuccess // continue polling + is AccountSwitchingRequired -> processError(Error(ALREADY_SIGNED_IN.code, success.recoveryCode)) + is LoggedIn -> command.send(LoginSuccess) + } + }.onFailure { + processError(it) + } + } + } + private suspend fun showQRCode() { syncAccountRepository.getConnectQR() .onSuccess { connectQR -> @@ -116,6 +140,7 @@ class SyncConnectViewModel @Inject constructor( fun onCopyCodeClicked() { viewModelScope.launch(dispatchers.io()) { syncAccountRepository.getConnectQR().getOrNull()?.let { code -> + Timber.d("Sync: recovery available for sharing manually: $code") clipboard.copyToClipboard(code) command.send(ShowMessage(R.string.sync_code_copied_message)) } ?: command.send(FinishWithError) @@ -142,28 +167,37 @@ class SyncConnectViewModel @Inject constructor( fun onQRCodeScanned(qrCode: String) { viewModelScope.launch(dispatchers.io()) { + val codeType = syncAccountRepository.getCodeType(qrCode) 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)) - } + processError(result) } is Success -> { - syncPixels.fireLoginPixel() - command.send(LoginSuccess) + if (codeType == EXCHANGE) { + pollForRecoveryKey() + } else { + syncPixels.fireLoginPixel() + command.send(LoginSuccess) + } } } } } + private suspend fun processError(result: 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)) + } + } + fun onLoginSuccess() { viewModelScope.launch { syncPixels.fireLoginPixel() @@ -172,6 +206,7 @@ class SyncConnectViewModel @Inject constructor( } companion object { - const val POLLING_INTERVAL = 5000L + const val POLLING_INTERVAL_CONNECT_FLOW = 5_000L + const val POLLING_INTERVAL_EXCHANGE_FLOW = 2_000L } } diff --git a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncLoginViewModel.kt b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncLoginViewModel.kt index f0fc1b3b1876..b69aa09dd7c3 100644 --- a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncLoginViewModel.kt +++ b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncLoginViewModel.kt @@ -27,17 +27,25 @@ import com.duckduckgo.sync.impl.AccountErrorCodes.CONNECT_FAILED import com.duckduckgo.sync.impl.AccountErrorCodes.CREATE_ACCOUNT_FAILED import com.duckduckgo.sync.impl.AccountErrorCodes.INVALID_CODE import com.duckduckgo.sync.impl.AccountErrorCodes.LOGIN_FAILED +import com.duckduckgo.sync.impl.CodeType.EXCHANGE +import com.duckduckgo.sync.impl.ExchangeResult.AccountSwitchingRequired +import com.duckduckgo.sync.impl.ExchangeResult.LoggedIn +import com.duckduckgo.sync.impl.ExchangeResult.Pending import com.duckduckgo.sync.impl.R 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.onFailure +import com.duckduckgo.sync.impl.onSuccess import com.duckduckgo.sync.impl.pixels.SyncPixels -import com.duckduckgo.sync.impl.ui.SyncConnectViewModel.Command +import com.duckduckgo.sync.impl.ui.SyncConnectViewModel.Companion.POLLING_INTERVAL_EXCHANGE_FLOW 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 javax.inject.* import kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch @@ -79,22 +87,55 @@ class SyncLoginViewModel @Inject constructor( fun onQRCodeScanned(qrCode: String) { viewModelScope.launch(dispatchers.io()) { - val result = syncAccountRepository.processCode(qrCode) - 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)) + val codeType = syncAccountRepository.getCodeType(qrCode) + when (val result = syncAccountRepository.processCode(qrCode)) { + is Error -> { + processError(result) + } + + is Success -> { + if (codeType == EXCHANGE) { + pollForRecoveryKey() + } else { + syncPixels.fireLoginPixel() + command.send(LoginSucess) + } } - } else { - syncPixels.fireLoginPixel() - command.send(LoginSucess) } } } + + private suspend fun processError(result: 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)) + } + } + + private suspend fun pollForRecoveryKey() { + var polling = true + while (polling) { + delay(POLLING_INTERVAL_EXCHANGE_FLOW) + syncAccountRepository.pollForRecoveryCodeAndLogin() + .onSuccess { success -> + polling = false + when (success) { + is Pending -> return@onSuccess // continue polling + is AccountSwitchingRequired -> processError(Error(ALREADY_SIGNED_IN.code, "user already signed in")) + LoggedIn -> { + syncPixels.fireLoginPixel() + command.send(LoginSucess) + } + } + }.onFailure { + processError(it) + } + } + } } 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 ccb2badda2c1..1ec34808f700 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 @@ -29,6 +29,10 @@ import com.duckduckgo.sync.impl.AccountErrorCodes.CREATE_ACCOUNT_FAILED import com.duckduckgo.sync.impl.AccountErrorCodes.INVALID_CODE import com.duckduckgo.sync.impl.AccountErrorCodes.LOGIN_FAILED import com.duckduckgo.sync.impl.Clipboard +import com.duckduckgo.sync.impl.CodeType.EXCHANGE +import com.duckduckgo.sync.impl.ExchangeResult.AccountSwitchingRequired +import com.duckduckgo.sync.impl.ExchangeResult.LoggedIn +import com.duckduckgo.sync.impl.ExchangeResult.Pending import com.duckduckgo.sync.impl.QREncoder import com.duckduckgo.sync.impl.R import com.duckduckgo.sync.impl.R.dimen @@ -41,6 +45,7 @@ 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.SyncConnectViewModel.Companion.POLLING_INTERVAL_EXCHANGE_FLOW 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 @@ -52,12 +57,14 @@ import com.duckduckgo.sync.impl.ui.setup.EnterCodeContract.EnterCodeContractOutp import javax.inject.Inject import kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import timber.log.Timber @ContributesViewModel(ActivityScope::class) class SyncWithAnotherActivityViewModel @Inject constructor( @@ -71,27 +78,53 @@ class SyncWithAnotherActivityViewModel @Inject constructor( private val command = Channel(1, DROP_OLDEST) fun commands(): Flow = command.receiveAsFlow() + private var exchangeInvitationCode: String? = null + private val viewState = MutableStateFlow(ViewState()) fun viewState(): Flow = viewState.onStart { - generateQRCode() + startExchangeProcess() } - private fun generateQRCode() { + private fun startExchangeProcess() { viewModelScope.launch(dispatchers.io()) { showQRCode() + var polling = syncFeature.exchangeKeysToSyncWithAnotherDevice().isEnabled() + while (polling) { + delay(POLLING_INTERVAL_EXCHANGE_FLOW) + syncAccountRepository.pollSecondDeviceExchangeAcknowledgement() + .onSuccess { success -> + if (!success) return@onSuccess // continue polling + command.send(Command.LoginSuccess) + polling = false + }.onFailure { + when (it.code) { + CONNECT_FAILED.code, LOGIN_FAILED.code -> { + command.send(Command.ShowError(string.sync_connect_login_error, it.reason)) + polling = false + } + } + } + } } } private suspend fun showQRCode() { - syncAccountRepository.getRecoveryCode() - .onSuccess { connectQR -> - val qrBitmap = withContext(dispatchers.io()) { - qrEncoder.encodeAsBitmap(connectQR, dimen.qrSizeSmall, dimen.qrSizeSmall) - } - viewState.emit(viewState.value.copy(qrCodeBitmap = qrBitmap)) - }.onFailure { - command.send(Command.FinishWithError) + val shouldExchangeKeysToSyncAnotherDevice = syncFeature.exchangeKeysToSyncWithAnotherDevice().isEnabled() + + if (!shouldExchangeKeysToSyncAnotherDevice) { + syncAccountRepository.getRecoveryCode() + } else { + syncAccountRepository.generateExchangeInvitationCode().also { + exchangeInvitationCode = it.getOrNull() } + }.onSuccess { connectQR -> + val qrBitmap = withContext(dispatchers.io()) { + qrEncoder.encodeAsBitmap(connectQR, dimen.qrSizeSmall, dimen.qrSizeSmall) + } + viewState.emit(viewState.value.copy(qrCodeBitmap = qrBitmap)) + }.onFailure { + command.send(Command.FinishWithError) + } } fun onErrorDialogDismissed() { @@ -102,7 +135,18 @@ class SyncWithAnotherActivityViewModel @Inject constructor( fun onCopyCodeClicked() { viewModelScope.launch(dispatchers.io()) { - syncAccountRepository.getRecoveryCode().getOrNull()?.let { code -> + val shouldExchangeKeysToSyncAnotherDevice = syncFeature.exchangeKeysToSyncWithAnotherDevice().isEnabled() + if (!shouldExchangeKeysToSyncAnotherDevice) { + syncAccountRepository.getRecoveryCode() + } else { + if (exchangeInvitationCode != null) { + Success(exchangeInvitationCode) + } else { + Error(reason = "Exchange code is null").also { + Timber.e("Sync-exchange: ${it.reason}") + } + } + }.getOrNull()?.let { code -> clipboard.copyToClipboard(code) command.send(ShowMessage(string.sync_code_copied_message)) } ?: command.send(FinishWithError) @@ -136,27 +180,61 @@ class SyncWithAnotherActivityViewModel @Inject constructor( fun onQRCodeScanned(qrCode: String) { viewModelScope.launch(dispatchers.io()) { val previousPrimaryKey = syncAccountRepository.getAccountInfo().primaryKey + val codeType = syncAccountRepository.getCodeType(qrCode) when (val result = syncAccountRepository.processCode(qrCode)) { is Error -> { + Timber.w("Sync: error processing code ${result.reason}") emitError(result, qrCode) } is Success -> { - val postProcessCodePK = syncAccountRepository.getAccountInfo().primaryKey - syncPixels.fireLoginPixel() - val userSwitchedAccount = previousPrimaryKey.isNotBlank() && previousPrimaryKey != postProcessCodePK - val commandSuccess = if (userSwitchedAccount) { - syncPixels.fireUserSwitchedAccount() - SwitchAccountSuccess + if (codeType == EXCHANGE) { + pollForRecoveryKey(previousPrimaryKey = previousPrimaryKey, qrCode = qrCode) } else { - LoginSuccess + onLoginSuccess(previousPrimaryKey) } - command.send(commandSuccess) } } } } + private suspend fun onLoginSuccess(previousPrimaryKey: String) { + val postProcessCodePK = syncAccountRepository.getAccountInfo().primaryKey + syncPixels.fireLoginPixel() + val userSwitchedAccount = previousPrimaryKey.isNotBlank() && previousPrimaryKey != postProcessCodePK + val commandSuccess = if (userSwitchedAccount) { + syncPixels.fireUserSwitchedAccount() + SwitchAccountSuccess + } else { + LoginSuccess + } + command.send(commandSuccess) + } + + private fun pollForRecoveryKey( + previousPrimaryKey: String, + qrCode: String, + ) { + viewModelScope.launch(dispatchers.io()) { + var polling = true + while (polling) { + delay(POLLING_INTERVAL_EXCHANGE_FLOW) + syncAccountRepository.pollForRecoveryCodeAndLogin() + .onSuccess { success -> + polling = false + when (success) { + is Pending -> return@onSuccess // continue polling + is AccountSwitchingRequired -> command.send(AskToSwitchAccount(success.recoveryCode)) + is LoggedIn -> onLoginSuccess(previousPrimaryKey) + } + }.onFailure { + polling = false + emitError(it, qrCode) + } + } + } + } + private suspend fun emitError(result: Error, qrCode: String) { if (result.code == ALREADY_SIGNED_IN.code && syncFeature.seamlessAccountSwitching().isEnabled()) { command.send(AskToSwitchAccount(qrCode)) 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 120d523b784c..cb90e518a874 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 @@ -53,6 +53,9 @@ object TestSyncFixtures { const val hashedPassword = "hashedPassword" const val protectedEncryptionKey = "protectedEncryptionKey" const val encryptedRecoveryCode = "encrypted_recovery_code" + const val encryptedExchangeCode = "encrypted_exchange_code" + const val primaryDeviceKeyId = "primary_device_key_id" + const val otherDeviceKeyId = "other_device_key_id" val accountKeys = AccountKeys( result = 0, userId = userId, @@ -192,8 +195,12 @@ object TestSyncFixtures { "\"bookmark4\"]},\"id\":\"bookmarks_root\",\"title\":\"Bookmarks\"}]}}" fun qrBitmap(): Bitmap { - return Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888) + return Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888) } fun pdfFile(): File = File("Sync Data Recovery - DuckDuckGo.pdf") + + fun jsonExchangeKey(keyId: String, publicKey: String) = """ + {"exchange_key":{"key_id":"$keyId","public_key":"$publicKey"}} + """.trimIndent() } 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 09c5d17cfc96..0d07e1cfd8e2 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 @@ -16,6 +16,7 @@ package com.duckduckgo.sync.impl +import android.annotation.SuppressLint import androidx.test.ext.junit.runners.AndroidJUnit4 import com.duckduckgo.common.utils.DefaultDispatcherProvider import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory @@ -36,6 +37,7 @@ import com.duckduckgo.sync.TestSyncFixtures.deviceFactor import com.duckduckgo.sync.TestSyncFixtures.deviceId import com.duckduckgo.sync.TestSyncFixtures.deviceName import com.duckduckgo.sync.TestSyncFixtures.deviceType +import com.duckduckgo.sync.TestSyncFixtures.encryptedExchangeCode import com.duckduckgo.sync.TestSyncFixtures.encryptedRecoveryCode import com.duckduckgo.sync.TestSyncFixtures.failedLoginKeys import com.duckduckgo.sync.TestSyncFixtures.getDevicesError @@ -43,12 +45,15 @@ import com.duckduckgo.sync.TestSyncFixtures.getDevicesSuccess import com.duckduckgo.sync.TestSyncFixtures.hashedPassword import com.duckduckgo.sync.TestSyncFixtures.invalidDecryptedSecretKey import com.duckduckgo.sync.TestSyncFixtures.jsonConnectKeyEncoded +import com.duckduckgo.sync.TestSyncFixtures.jsonExchangeKey import com.duckduckgo.sync.TestSyncFixtures.jsonRecoveryKey import com.duckduckgo.sync.TestSyncFixtures.jsonRecoveryKeyEncoded import com.duckduckgo.sync.TestSyncFixtures.listOfConnectedDevices import com.duckduckgo.sync.TestSyncFixtures.loginFailed import com.duckduckgo.sync.TestSyncFixtures.loginSuccess import com.duckduckgo.sync.TestSyncFixtures.logoutSuccess +import com.duckduckgo.sync.TestSyncFixtures.otherDeviceKeyId +import com.duckduckgo.sync.TestSyncFixtures.primaryDeviceKeyId import com.duckduckgo.sync.TestSyncFixtures.primaryKey import com.duckduckgo.sync.TestSyncFixtures.protectedEncryptionKey import com.duckduckgo.sync.TestSyncFixtures.secretKey @@ -70,6 +75,7 @@ import com.duckduckgo.sync.impl.Result.Error import com.duckduckgo.sync.impl.Result.Success import com.duckduckgo.sync.impl.pixels.SyncPixels import com.duckduckgo.sync.store.SyncStore +import com.squareup.moshi.Moshi import kotlinx.coroutines.test.TestScope import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -79,6 +85,7 @@ import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.mockito.ArgumentMatchers.anyString +import org.mockito.kotlin.any import org.mockito.kotlin.doAnswer import org.mockito.kotlin.eq import org.mockito.kotlin.mock @@ -87,6 +94,7 @@ import org.mockito.kotlin.verify import org.mockito.kotlin.verifyNoInteractions import org.mockito.kotlin.whenever +@SuppressLint("DenyListedApi") @RunWith(AndroidJUnit4::class) class AppSyncAccountRepositoryTest { @@ -96,6 +104,11 @@ class AppSyncAccountRepositoryTest { private var syncStore: SyncStore = mock() private var syncEngine: SyncEngine = mock() private var syncPixels: SyncPixels = mock() + private val deviceKeyGenerator: DeviceKeyGenerator = mock() + private val moshi = Moshi.Builder().build() + private val invitationCodeWrapperAdapter = moshi.adapter(InvitationCodeWrapper::class.java) + private val invitedDeviceDetailsAdapter = moshi.adapter(InvitedDeviceDetails::class.java) + private val recoveryCodeAdapter = moshi.adapter(LinkCode::class.java) private val syncFeature = FakeFeatureToggleFactory.create(SyncFeature::class.java).apply { this.seamlessAccountSwitching().setRawStoredState(State(true)) } @@ -114,6 +127,7 @@ class AppSyncAccountRepositoryTest { TestScope(), DefaultDispatcherProvider(), syncFeature, + deviceKeyGenerator, ) } @@ -124,7 +138,7 @@ class AppSyncAccountRepositoryTest { val result = syncRepo.createAccount() - assertEquals(Result.Success(true), result) + assertEquals(Success(true), result) verify(syncStore).storeCredentials( userId = userId, deviceId = deviceId, @@ -201,7 +215,7 @@ class AppSyncAccountRepositoryTest { val result = syncRepo.logout(deviceId) - assertTrue(result is Result.Success) + assertTrue(result is Success) verify(syncStore).clearAll() } @@ -224,7 +238,7 @@ class AppSyncAccountRepositoryTest { val result = syncRepo.deleteAccount() - assertTrue(result is Result.Success) + assertTrue(result is Success) verify(syncStore).clearAll() } @@ -234,7 +248,7 @@ class AppSyncAccountRepositoryTest { val result = syncRepo.processCode(jsonRecoveryKeyEncoded) - assertEquals(Result.Success(true), result) + assertEquals(Success(true), result) verify(syncStore).storeCredentials( userId = userId, deviceId = deviceId, @@ -245,6 +259,186 @@ class AppSyncAccountRepositoryTest { ) } + @Test + fun whenExchangeCodeProcessedButFeatureFlagIsDisabledThenIsError() { + prepareForExchangeSuccess() + syncFeature.exchangeKeysToSyncWithAnotherDevice().setRawStoredState(State(false)) + + val exchangeCode = jsonExchangeKey(primaryDeviceKeyId, validLoginKeys.primaryKey) + + val result = syncRepo.processCode(exchangeCode.encodeB64()) + assertTrue(result is Error) + } + + @Test + fun whenExchangeCodeProcessedThenInvitationAccepted() { + prepareForExchangeSuccess() + + val exchangeCode = jsonExchangeKey(primaryDeviceKeyId, validLoginKeys.primaryKey) + whenever(deviceKeyGenerator.generate()).thenReturn(otherDeviceKeyId) + + val result = syncRepo.processCode(exchangeCode.encodeB64()) + + assertTrue(result is Success) + verify(syncApi).sendEncryptedMessage(eq(primaryDeviceKeyId), eq(encryptedExchangeCode)) + } + + @Test + fun whenExchangeCodeProcessedAndInvitationAcceptedRequestFailedThenIsError() { + syncFeature.exchangeKeysToSyncWithAnotherDevice().setRawStoredState(State(true)) + prepareForExchangeSuccess() + + whenever(syncApi.sendEncryptedMessage(eq(primaryDeviceKeyId), eq(encryptedExchangeCode))).thenReturn(Result.Error(reason = "error")) + whenever(deviceKeyGenerator.generate()).thenReturn(otherDeviceKeyId) + + val exchangeCode = jsonExchangeKey(primaryDeviceKeyId, validLoginKeys.primaryKey) + val result = syncRepo.processCode(exchangeCode.encodeB64()) + + assertTrue(result is Error) + } + + @Test + fun whenGettingExchangeCodeThenFormatIsCorrect() { + prepareForExchangeSuccess() + whenever(deviceKeyGenerator.generate()).thenReturn(primaryDeviceKeyId) + val resultJson = syncRepo.generateExchangeInvitationCode() + val result = parseInvitationCodeJson(resultJson) + assertEquals(validLoginKeys.primaryKey, result.exchangeKey.publicKey) + assertEquals(primaryDeviceKeyId, result.exchangeKey.keyId) + } + + @Test + fun whenAttemptingPollForOtherDeviceExchangeBeforeInvitationCodeGeneratedThenIsError() { + prepareForExchangeSuccess() + val result = syncRepo.pollSecondDeviceExchangeAcknowledgement() + assert(result is Error) + } + + @Test + fun whenPollingForOtherDeviceExchangeAndResponseReceivedThenRecoveryCodeSentSuccessfully() { + givenAuthenticatedDevice() + prepareForExchangeSuccess() + initiateInvitationAsPrimaryDevice() + + val result = syncRepo.pollSecondDeviceExchangeAcknowledgement() + assertTrue(result is Success) + } + + @Test + fun whenAttemptingToPollForRecoveryCodeBeforePendingInviteReceivedThenIsError() { + val result = syncRepo.pollForRecoveryCodeAndLogin() + assertTrue(result is Error) + } + + @Test + fun whenPollingForRecoveryCodeReturnsUnexpectedResponseThenIsError() { + prepareForExchangeSuccess() + + val exchangeCode = jsonExchangeKey(primaryDeviceKeyId, validLoginKeys.primaryKey) + whenever(deviceKeyGenerator.generate()).thenReturn(otherDeviceKeyId) + syncRepo.processCode(exchangeCode.encodeB64()) + + whenever(syncApi.getEncryptedMessage(otherDeviceKeyId)).thenReturn(Success("encryptedExchangeResponse")) + whenever(nativeLib.sealOpen("encryptedExchangeResponse", primaryKey, secretKey)).thenReturn("invalid response") + + assertTrue(syncRepo.pollForRecoveryCodeAndLogin() is Error) + } + + @Test + fun whenPollingForRecoveryCodeSuccessfulAndNotAlreadySignedInThenIsLoggedIn() { + prepareForExchangeSuccess() + + val exchangeCode = jsonExchangeKey(primaryDeviceKeyId, validLoginKeys.primaryKey) + whenever(deviceKeyGenerator.generate()).thenReturn(otherDeviceKeyId) + syncRepo.processCode(exchangeCode.encodeB64()) + + configureExchangeResultRecoveryReceived() + + prepareForLoginSuccess() + val result = syncRepo.pollForRecoveryCodeAndLogin() + assertTrue(result.getOrNull() is ExchangeResult.LoggedIn) + } + + @Test + fun whenPollingForRecoveryCodeSuccessfulAndAlreadySignedInSingleDeviceThenIsLoggedIn() { + prepareForExchangeSuccess() + + val exchangeCode = jsonExchangeKey(primaryDeviceKeyId, validLoginKeys.primaryKey) + whenever(deviceKeyGenerator.generate()).thenReturn(otherDeviceKeyId) + syncRepo.processCode(exchangeCode.encodeB64()) + + configureExchangeResultRecoveryReceived() + prepareForLoginSuccess() + configureAsSignedWithConnectedDevices(numberOfDevices = 1) + + val result = syncRepo.pollForRecoveryCodeAndLogin() + assertTrue(result.getOrNull() is ExchangeResult.LoggedIn) + } + + @Test + fun whenPollingForRecoveryCodeSuccessfulAndAlreadySignedInMultipleDevicesThenAccountSwitchingRequired() { + prepareForExchangeSuccess() + + val exchangeCode = jsonExchangeKey(primaryDeviceKeyId, validLoginKeys.primaryKey) + whenever(deviceKeyGenerator.generate()).thenReturn(otherDeviceKeyId) + syncRepo.processCode(exchangeCode.encodeB64()) + + configureExchangeResultRecoveryReceived() + prepareForLoginSuccess() + configureAsSignedWithConnectedDevices(numberOfDevices = 2) + + val result = syncRepo.pollForRecoveryCodeAndLogin() + assertTrue(result.getOrNull() is ExchangeResult.AccountSwitchingRequired) + } + + private fun configureAsSignedWithConnectedDevices(numberOfDevices: Int) { + whenever(syncStore.isSignedIn()).thenReturn(true) + whenever(syncStore.token).thenReturn(token) + whenever(syncStore.deviceId).thenReturn(deviceId) + whenever(syncStore.primaryKey).thenReturn(primaryKey) + val devices = mutableListOf() + for (i in 1..numberOfDevices) { + devices.add(Device(deviceId = "$deviceId-$i", deviceName = "$deviceName-$i", jwIat = "", deviceType = deviceFactor)) + } + whenever(syncApi.getDevices(token)).thenReturn(Success(devices)) + whenever(syncApi.logout(token, deviceId)).thenReturn(Success(Logout(deviceId))) + syncRepo.getConnectedDevices() + } + + private fun configureExchangeResultRecoveryReceived() { + val recoveryCodeJson = recoveryCodeAdapter.toJson((LinkCode(recovery = RecoveryCode(primaryKey, "userId")))) + whenever(syncApi.getEncryptedMessage(otherDeviceKeyId)).thenReturn(Success("encryptedExchangeResponse")) + whenever(nativeLib.sealOpen("encryptedExchangeResponse", primaryKey, secretKey)).thenReturn(recoveryCodeJson) + } + + @Test + fun whenCodeIsEmptyThenCodeTypeIsUnknown() { + val type = syncRepo.getCodeType("") + assertTrue(type == CodeType.UNKNOWN) + } + + @Test + fun whenCodeIsRecoveryThenCodeTypeIsIdentified() { + val code = recoveryCodeAdapter.toJson((LinkCode(recovery = RecoveryCode(primaryKey, "userId")))) + val type = syncRepo.getCodeType(code.encodeB64()) + assertTrue(type == CodeType.RECOVERY) + } + + @Test + fun whenCodeIsConnectThenCodeTypeIsIdentified() { + val code = recoveryCodeAdapter.toJson((LinkCode(connect = ConnectCode(deviceId, secretKey)))) + val type = syncRepo.getCodeType(code.encodeB64()) + assertTrue(type == CodeType.CONNECT) + } + + @Test + fun whenCodeIsExchangeThenCodeTypeIsIdentified() { + val invitationCode = InvitationCode(keyId = primaryDeviceKeyId, publicKey = validLoginKeys.primaryKey) + val code = invitationCodeWrapperAdapter.toJson(InvitationCodeWrapper(exchangeKey = invitationCode)) + val type = syncRepo.getCodeType(code.encodeB64()) + assertTrue(type == CodeType.EXCHANGE) + } + @Test fun whenSignedInAndProcessRecoveryCodeIfAllowSwitchAccountTrueThenSwitchAccountIfOnly1DeviceConnected() { givenAuthenticatedDevice() @@ -289,7 +483,7 @@ class AppSyncAccountRepositoryTest { val result = syncRepo.logoutAndJoinNewAccount(jsonRecoveryKeyEncoded) - assertTrue(result is Result.Success) + assertTrue(result is Success) verify(syncStore).clearAll() verify(syncStore).storeCredentials( userId = userId, @@ -363,7 +557,7 @@ class AppSyncAccountRepositoryTest { val thisDevice = Device(deviceId = deviceId, deviceName = deviceName, jwIat = "", deviceType = deviceFactor) val anotherDevice = Device(deviceId = "anotherDeviceId", deviceName = deviceName, jwIat = "", deviceType = deviceFactor) val anotherRemoteDevice = Device(deviceId = "anotherRemoteDeviceId", deviceName = deviceName, jwIat = "", deviceType = deviceFactor) - whenever(syncApi.getDevices(anyString())).thenReturn(Result.Success(listOf(anotherDevice, anotherRemoteDevice, thisDevice))) + whenever(syncApi.getDevices(anyString())).thenReturn(Success(listOf(anotherDevice, anotherRemoteDevice, thisDevice))) val result = syncRepo.getConnectedDevices() as Success @@ -392,8 +586,8 @@ class AppSyncAccountRepositoryTest { whenever(syncStore.deviceId).thenReturn(deviceId) whenever(syncStore.primaryKey).thenReturn(primaryKey) whenever(nativeLib.decryptData(anyString(), anyString())).thenThrow(NegativeArraySizeException()) - whenever(syncApi.getDevices(anyString())).thenReturn(Result.Success(listOf(thisDevice, otherDevice))) - whenever(syncApi.logout("token", "otherDeviceId")).thenReturn(Result.Success(Logout("otherDeviceId"))) + whenever(syncApi.getDevices(anyString())).thenReturn(Success(listOf(thisDevice, otherDevice))) + whenever(syncApi.logout("token", "otherDeviceId")).thenReturn(Success(Logout("otherDeviceId"))) val result = syncRepo.getConnectedDevices() as Success verify(syncApi).logout("token", "otherDeviceId") @@ -431,7 +625,7 @@ class AppSyncAccountRepositoryTest { fun whenProcessConnectCodeFromAuthenticatedDeviceThenConnectsDevice() { givenAuthenticatedDevice() whenever(nativeLib.seal(jsonRecoveryKey, primaryKey)).thenReturn(encryptedRecoveryCode) - whenever(syncApi.connect(token, deviceId, encryptedRecoveryCode)).thenReturn(Result.Success(true)) + whenever(syncApi.connect(token, deviceId, encryptedRecoveryCode)).thenReturn(Success(true)) val result = syncRepo.processCode(jsonConnectKeyEncoded) @@ -449,7 +643,7 @@ class AppSyncAccountRepositoryTest { prepareToProvideDeviceIds() prepareForCreateAccountSuccess() whenever(nativeLib.seal(jsonRecoveryKey, primaryKey)).thenReturn(encryptedRecoveryCode) - whenever(syncApi.connect(token, deviceId, encryptedRecoveryCode)).thenReturn(Result.Success(true)) + whenever(syncApi.connect(token, deviceId, encryptedRecoveryCode)).thenReturn(Success(true)) val result = syncRepo.processCode(jsonConnectKeyEncoded) @@ -562,7 +756,7 @@ class AppSyncAccountRepositoryTest { val result = syncRepo.renameDevice(connectedDevice) verify(syncApi).login(anyString(), anyString(), eq(connectedDevice.deviceId), anyString(), anyString()) - assertTrue(result is Result.Success) + assertTrue(result is Success) } @Test @@ -583,6 +777,34 @@ class AppSyncAccountRepositoryTest { whenever(syncApi.login(userId, hashedPassword, deviceId, deviceName, deviceFactor)).thenReturn(loginSuccess) } + private fun prepareForExchangeSuccess() { + prepareForEncryption() + syncFeature.exchangeKeysToSyncWithAnotherDevice().setRawStoredState(State(true)) + whenever(syncDeviceIds.deviceId()).thenReturn(deviceId) + whenever(syncDeviceIds.deviceName()).thenReturn(deviceName) + whenever(syncDeviceIds.deviceType()).thenReturn(deviceType) + whenever(syncApi.sendEncryptedMessage(eq(primaryDeviceKeyId), eq(encryptedExchangeCode))).thenReturn(Success(true)) + whenever(nativeLib.prepareForLogin(primaryKey = primaryKey)).thenReturn(validLoginKeys) + whenever(nativeLib.prepareForConnect()).thenReturn(connectKeys) + whenever(nativeLib.seal(any(), eq(primaryKey))).thenReturn(encryptedExchangeCode) + } + + private fun initiateInvitationAsPrimaryDevice() { + whenever(deviceKeyGenerator.generate()).thenReturn(primaryDeviceKeyId) + syncRepo.generateExchangeInvitationCode() + val otherDeviceDetails = InvitedDeviceDetails(keyId = otherDeviceKeyId, publicKey = "otherDevicePublicKey", deviceName = "otherDeviceName") + val json = invitedDeviceDetailsAdapter.toJson(otherDeviceDetails) + whenever(nativeLib.sealOpen(any(), eq(primaryKey), eq(secretKey))).thenReturn(json) + whenever(nativeLib.seal(any(), eq("otherDevicePublicKey"))).thenReturn(encryptedExchangeCode) + whenever(syncApi.getEncryptedMessage(primaryDeviceKeyId)).thenReturn(Success(json)) + whenever(syncApi.sendEncryptedMessage(eq(otherDeviceKeyId), eq(encryptedExchangeCode))).thenReturn(Success(true)) + } + + private fun parseInvitationCodeJson(resultJson: Result): InvitationCodeWrapper { + assertTrue(resultJson is Success) + return invitationCodeWrapperAdapter.fromJson(resultJson.getOrNull()?.decodeB64()!!)!! + } + private fun givenAuthenticatedDevice() { whenever(syncStore.userId).thenReturn(userId) whenever(syncStore.deviceId).thenReturn(deviceId) @@ -608,7 +830,7 @@ class AppSyncAccountRepositoryTest { prepareForEncryption() whenever(nativeLib.generateAccountKeys(userId = anyString(), password = anyString())).thenReturn(accountKeys) whenever(syncApi.createAccount(anyString(), anyString(), anyString(), anyString(), anyString(), anyString())) - .thenReturn(Result.Success(AccountCreatedResponse(userId, token))) + .thenReturn(Success(AccountCreatedResponse(userId, token))) } private fun prepareForEncryption() { 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 f799c354dcfa..58a4ae33a167 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 @@ -16,6 +16,7 @@ package com.duckduckgo.sync.impl.ui +import android.annotation.SuppressLint import androidx.test.ext.junit.runners.AndroidJUnit4 import app.cash.turbine.test import com.duckduckgo.common.test.CoroutineTestRule @@ -52,6 +53,7 @@ import org.mockito.kotlin.verify import org.mockito.kotlin.verifyNoInteractions import org.mockito.kotlin.whenever +@SuppressLint("DenyListedApi") @RunWith(AndroidJUnit4::class) class SyncWithAnotherDeviceViewModelTest { @get:Rule @@ -63,6 +65,7 @@ class SyncWithAnotherDeviceViewModelTest { private val syncPixels: SyncPixels = mock() private val syncFeature = FakeFeatureToggleFactory.create(SyncFeature::class.java).apply { this.seamlessAccountSwitching().setRawStoredState(State(true)) + this.exchangeKeysToSyncWithAnotherDevice().setRawStoredState(State(false)) } private val testee = SyncWithAnotherActivityViewModel( From b50b97481935ded16af68927c59054b7956b7d3e Mon Sep 17 00:00:00 2001 From: Craig Russell <1336281+CDRussell@users.noreply.github.com> Date: Thu, 20 Mar 2025 13:44:43 +0000 Subject: [PATCH 2/3] Fix polling when waiting for recovery code --- .../sync/impl/SyncAccountRepository.kt | 16 ++++++++++++---- .../sync/impl/ui/EnterCodeViewModel.kt | 12 ++++++++---- .../sync/impl/ui/SyncConnectViewModel.kt | 13 +++++++++---- .../sync/impl/ui/SyncLoginViewModel.kt | 8 ++++++-- .../impl/ui/SyncWithAnotherActivityViewModel.kt | 11 ++++++++--- 5 files changed, 43 insertions(+), 17 deletions(-) diff --git a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/SyncAccountRepository.kt b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/SyncAccountRepository.kt index ca1f5a0e8f17..90ab16eaedac 100644 --- a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/SyncAccountRepository.kt +++ b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/SyncAccountRepository.kt @@ -214,10 +214,18 @@ class AppSyncAccountRepository @Inject constructor( return when (val result = syncApi.getEncryptedMessage(pendingInvite.keyId)) { is Error -> { - if (result.code == NOT_FOUND.code) { - return Success(Pending) - } else if (result.code == GONE.code) { - return Error(code = CONNECT_FAILED.code, reason = "Connect: keys expired").alsoFireAccountErrorPixel() + when (result.code) { + NOT_FOUND.code -> { + Timber.v("Sync-exchange: no encrypted recovery code found yet") + return Success(Pending) + } + GONE.code -> { + Timber.w("Sync-exchange: keys expired: ${result.reason}") + return Error(code = CONNECT_FAILED.code, reason = "Connect: keys expired").alsoFireAccountErrorPixel() + } + else -> { + Timber.e("Sync-exchange: error getting encrypted recovery code: ${result.reason}") + } } result.alsoFireAccountErrorPixel() } 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 7fabf9e03232..3b67fa4fd98f 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 @@ -137,12 +137,16 @@ class EnterCodeViewModel @Inject constructor( delay(POLLING_INTERVAL_EXCHANGE_FLOW) syncAccountRepository.pollForRecoveryCodeAndLogin() .onSuccess { success -> - polling = false - when (success) { is Pending -> return@onSuccess // continue polling - is AccountSwitchingRequired -> command.send(AskToSwitchAccount(success.recoveryCode)) - LoggedIn -> onLoginSuccess(previousPrimaryKey) + is AccountSwitchingRequired -> { + polling = false + command.send(AskToSwitchAccount(success.recoveryCode)) + } + LoggedIn -> { + polling = false + onLoginSuccess(previousPrimaryKey) + } } }.onFailure { when (it.code) { diff --git a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncConnectViewModel.kt b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncConnectViewModel.kt index b2791d0dbfc8..f04c695f7e11 100644 --- a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncConnectViewModel.kt +++ b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncConnectViewModel.kt @@ -106,14 +106,19 @@ class SyncConnectViewModel @Inject constructor( delay(POLLING_INTERVAL_EXCHANGE_FLOW) syncAccountRepository.pollForRecoveryCodeAndLogin() .onSuccess { success -> - polling = false - when (success) { is Pending -> return@onSuccess // continue polling - is AccountSwitchingRequired -> processError(Error(ALREADY_SIGNED_IN.code, success.recoveryCode)) - is LoggedIn -> command.send(LoginSuccess) + is AccountSwitchingRequired -> { + polling = false + processError(Error(ALREADY_SIGNED_IN.code, success.recoveryCode)) + } + is LoggedIn -> { + polling = false + command.send(LoginSuccess) + } } }.onFailure { + polling = false processError(it) } } diff --git a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncLoginViewModel.kt b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncLoginViewModel.kt index b69aa09dd7c3..4b418022570c 100644 --- a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncLoginViewModel.kt +++ b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncLoginViewModel.kt @@ -124,16 +124,20 @@ class SyncLoginViewModel @Inject constructor( delay(POLLING_INTERVAL_EXCHANGE_FLOW) syncAccountRepository.pollForRecoveryCodeAndLogin() .onSuccess { success -> - polling = false when (success) { is Pending -> return@onSuccess // continue polling - is AccountSwitchingRequired -> processError(Error(ALREADY_SIGNED_IN.code, "user already signed in")) + is AccountSwitchingRequired -> { + polling = false + processError(Error(ALREADY_SIGNED_IN.code, "user already signed in")) + } LoggedIn -> { + polling = false syncPixels.fireLoginPixel() command.send(LoginSucess) } } }.onFailure { + polling = false processError(it) } } 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 1ec34808f700..2c55ef2a70e0 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 @@ -221,11 +221,16 @@ class SyncWithAnotherActivityViewModel @Inject constructor( delay(POLLING_INTERVAL_EXCHANGE_FLOW) syncAccountRepository.pollForRecoveryCodeAndLogin() .onSuccess { success -> - polling = false when (success) { is Pending -> return@onSuccess // continue polling - is AccountSwitchingRequired -> command.send(AskToSwitchAccount(success.recoveryCode)) - is LoggedIn -> onLoginSuccess(previousPrimaryKey) + is AccountSwitchingRequired -> { + polling = false + command.send(AskToSwitchAccount(success.recoveryCode)) + } + is LoggedIn -> { + polling = false + onLoginSuccess(previousPrimaryKey) + } } }.onFailure { polling = false From ee2865a8074ceeb56b953956251e2122606b0080 Mon Sep 17 00:00:00 2001 From: Craig Russell <1336281+CDRussell@users.noreply.github.com> Date: Thu, 20 Mar 2025 14:31:01 +0000 Subject: [PATCH 3/3] Code tidy from review --- .../sync/impl/SyncAccountRepository.kt | 117 +++++++------ .../sync/impl/ui/SyncConnectViewModel.kt | 1 + .../ui/SyncWithAnotherActivityViewModel.kt | 21 +-- .../ui/SyncWithAnotherDeviceViewModelTest.kt | 164 +++++++++++++++++- 4 files changed, 230 insertions(+), 73 deletions(-) diff --git a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/SyncAccountRepository.kt b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/SyncAccountRepository.kt index 90ab16eaedac..b213f97ab0f2 100644 --- a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/SyncAccountRepository.kt +++ b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/SyncAccountRepository.kt @@ -29,6 +29,7 @@ import com.duckduckgo.sync.impl.API_CODE.NOT_FOUND import com.duckduckgo.sync.impl.AccountErrorCodes.ALREADY_SIGNED_IN import com.duckduckgo.sync.impl.AccountErrorCodes.CONNECT_FAILED import com.duckduckgo.sync.impl.AccountErrorCodes.CREATE_ACCOUNT_FAILED +import com.duckduckgo.sync.impl.AccountErrorCodes.EXCHANGE_FAILED import com.duckduckgo.sync.impl.AccountErrorCodes.GENERIC_ERROR import com.duckduckgo.sync.impl.AccountErrorCodes.INVALID_CODE import com.duckduckgo.sync.impl.AccountErrorCodes.LOGIN_FAILED @@ -87,14 +88,14 @@ class AppSyncAccountRepository @Inject constructor( ) : SyncAccountRepository { /** - * If there is a key-exchange invitation in progress, we need to keep a reference to them - * There are separate invitation details for the inviter and the receiver + * If there is a key-exchange flow in progress, we need to keep a reference to them + * There are separate device details for the inviter and the receiver * * Inviter is reset every time a new exchange invitation code is created. * Receiver is reset every time an exchange invitation is received. */ - private var pendingInvitationAsInviter: PendingInvitation? = null - private var pendingInvitationAsReceiver: PendingInvitation? = null + private var exchangeDeviceDetailsAsInviter: DeviceDetailsForKeyExchange? = null + private var exchangeDeviceDetailsAsReceiver: DeviceDetailsForKeyExchange? = null private val connectedDevicesCached: MutableList = mutableListOf() @@ -144,7 +145,7 @@ class AppSyncAccountRepository @Inject constructor( return@let null } - return completeExchange(it) + return onInvitationCodeReceived(it) } Timber.e("Sync: code is not supported") @@ -165,50 +166,39 @@ class AppSyncAccountRepository @Inject constructor( }.getOrDefault(UNKNOWN) } - private fun completeExchange(invitationCode: InvitationCode): Result { + private fun onInvitationCodeReceived(invitationCode: InvitationCode): Result { // Sync: InviteFlow - B (https://app.asana.com/0/72649045549333/1209571867429615) Timber.d("Sync-exchange: InviteFlow - B. code is an exchange code $invitationCode") - // generate new ID and and public/private key-pair for receiving device - val thisDeviceKeyId = deviceKeyGenerator.generate() - val thisDeviceKeyPair = nativeLib.prepareForConnect() - - pendingInvitationAsReceiver = PendingInvitation( - keyId = thisDeviceKeyId, - privateKey = thisDeviceKeyPair.secretKey, - publicKey = thisDeviceKeyPair.publicKey, - ) - val deviceName = syncDeviceIds.deviceName() - - Timber.i( - "Sync: details for this (receiver) device:" + - "\n\tkey ID is $thisDeviceKeyId" + - "\n\tpublic key is ${thisDeviceKeyPair.publicKey}" + - "\n\tdevice name is $deviceName", - ) + // generate new ID and public/private key-pair for receiving device + val deviceDetailsAsReceiver = kotlin.runCatching { + generateReceiverDeviceDetails() + }.getOrElse { + return Error(code = EXCHANGE_FAILED.code, reason = "Error generating receiver key-pair").alsoFireAccountErrorPixel() + } val invitedDeviceDetails = InvitedDeviceDetails( - keyId = thisDeviceKeyId, - publicKey = thisDeviceKeyPair.publicKey, - deviceName = deviceName, + keyId = deviceDetailsAsReceiver.keyId, + publicKey = deviceDetailsAsReceiver.publicKey, + deviceName = syncDeviceIds.deviceName(), ) - kotlin.runCatching { + val encryptedPayload = kotlin.runCatching { val payload = Adapters.invitedDeviceAdapter.toJson(invitedDeviceDetails) - val encrypted = nativeLib.seal(payload, invitationCode.publicKey) - return syncApi.sendEncryptedMessage(invitationCode.keyId, encrypted) + nativeLib.seal(payload, invitationCode.publicKey) }.getOrElse { throwable -> throwable.asErrorResult().alsoFireAccountErrorPixel() - return Error(code = GENERIC_ERROR.code, reason = "Exchange: Error encrypting payload") + return Error(code = EXCHANGE_FAILED.code, reason = "Exchange: Error encrypting payload") } + return syncApi.sendEncryptedMessage(invitationCode.keyId, encryptedPayload) } override fun pollForRecoveryCodeAndLogin(): Result { // Sync: InviteFlow - E (https://app.asana.com/0/72649045549333/1209571867429615) Timber.d("Sync-exchange: InviteFlow - E") - val pendingInvite = pendingInvitationAsReceiver - ?: return Error(code = CONNECT_FAILED.code, reason = "Connect: No pending invite initialized").also { + val pendingInvite = exchangeDeviceDetailsAsReceiver + ?: return Error(code = EXCHANGE_FAILED.code, reason = "Exchange: No pending invite initialized").also { Timber.w("Sync-exchange: no pending invite initialized") } @@ -221,13 +211,13 @@ class AppSyncAccountRepository @Inject constructor( } GONE.code -> { Timber.w("Sync-exchange: keys expired: ${result.reason}") - return Error(code = CONNECT_FAILED.code, reason = "Connect: keys expired").alsoFireAccountErrorPixel() + return Error(code = EXCHANGE_FAILED.code, reason = "Exchange: keys expired").alsoFireAccountErrorPixel() } else -> { Timber.e("Sync-exchange: error getting encrypted recovery code: ${result.reason}") + result.alsoFireAccountErrorPixel() } } - result.alsoFireAccountErrorPixel() } is Success -> { @@ -236,12 +226,12 @@ class AppSyncAccountRepository @Inject constructor( val decryptedJson = kotlin.runCatching { nativeLib.sealOpen(result.data, pendingInvite.publicKey, pendingInvite.privateKey) }.getOrNull() - ?: return Error(code = CONNECT_FAILED.code, reason = "Connect: Error opening seal").alsoFireAccountErrorPixel() + ?: return Error(code = EXCHANGE_FAILED.code, reason = "Connect: Error opening seal").alsoFireAccountErrorPixel() val recoveryData = kotlin.runCatching { Adapters.recoveryCodeAdapter.fromJson(decryptedJson)?.recovery }.getOrNull() - ?: return Error(code = CONNECT_FAILED.code, reason = "Connect: Error reading recovery code").alsoFireAccountErrorPixel() + ?: return Error(code = EXCHANGE_FAILED.code, reason = "Connect: Error reading recovery code").alsoFireAccountErrorPixel() return when (val loginResult = login(recoveryData)) { is Success -> Success(LoggedIn) @@ -320,20 +310,21 @@ class AppSyncAccountRepository @Inject constructor( // Sync: InviteFlow - A (https://app.asana.com/0/72649045549333/1209571867429615) Timber.d("Sync-exchange: InviteFlow - A. Generating invitation code") - // generate new ID and and public/private key-pair - generateInviterDeviceDetails() - - val pendingInvitation = pendingInvitationAsInviter - ?: return Error(code = GENERIC_ERROR.code, reason = "Exchange: No pending invitation initialized").alsoFireAccountErrorPixel() + // generate new ID and and public/private key-pair for inviter device + val deviceDetailsAsInviter = kotlin.runCatching { + generateInviterDeviceDetails() + }.getOrElse { + return Error(code = EXCHANGE_FAILED.code, reason = "Error generating inviter key-pair").alsoFireAccountErrorPixel() + } - val invitationCode = InvitationCode(keyId = pendingInvitation.keyId, publicKey = pendingInvitation.publicKey) + val invitationCode = InvitationCode(keyId = deviceDetailsAsInviter.keyId, publicKey = deviceDetailsAsInviter.publicKey) val invitationWrapper = InvitationCodeWrapper(invitationCode) return kotlin.runCatching { val code = Adapters.invitationCodeAdapter.toJson(invitationWrapper).encodeB64() Success(code) }.getOrElse { - Error(code = GENERIC_ERROR.code, reason = "Error generating invitation code").alsoFireAccountErrorPixel() + Error(code = EXCHANGE_FAILED.code, reason = "Error generating invitation code").alsoFireAccountErrorPixel() } } @@ -405,7 +396,7 @@ class AppSyncAccountRepository @Inject constructor( // Sync: InviteFlow - C (https://app.asana.com/0/72649045549333/1209571867429615) Timber.d("Sync-exchange: InviteFlow - C") - val keyId = pendingInvitationAsInviter?.keyId ?: return Error(reason = "No pending invitation initialized") + val keyId = exchangeDeviceDetailsAsInviter?.keyId ?: return Error(reason = "No pending invitation initialized") return when (val result = syncApi.getEncryptedMessage(keyId)) { is Error -> { @@ -413,7 +404,7 @@ class AppSyncAccountRepository @Inject constructor( return Success(false) } else if (result.code == GONE.code) { return Error( - code = CONNECT_FAILED.code, + code = EXCHANGE_FAILED.code, reason = "Connect: keys expired", ).alsoFireAccountErrorPixel() } @@ -424,20 +415,20 @@ class AppSyncAccountRepository @Inject constructor( Timber.v("Sync-exchange: Found invitation acceptance for keyId: $keyId} ${result.data}") val decrypted = kotlin.runCatching { - val pending = pendingInvitationAsInviter - ?: return Error(code = CONNECT_FAILED.code, reason = "Exchange: No pending invitation initialized") + val pending = exchangeDeviceDetailsAsInviter + ?: return Error(code = EXCHANGE_FAILED.code, reason = "Exchange: No pending invitation initialized") .alsoFireAccountErrorPixel() nativeLib.sealOpen(result.data, pending.publicKey, pending.privateKey) }.getOrElse { throwable -> throwable.asErrorResult().alsoFireAccountErrorPixel() - return Error(code = CONNECT_FAILED.code, reason = "Connect: Error opening seal") + return Error(code = EXCHANGE_FAILED.code, reason = "Connect: Error opening seal") } Timber.v("Sync-exchange: invitation acceptance received: $decrypted") val response = Adapters.invitedDeviceAdapter.fromJson(decrypted) - ?: return Error(code = GENERIC_ERROR.code, reason = "Connect: Error reading invitation response").alsoFireAccountErrorPixel() + ?: return Error(code = EXCHANGE_FAILED.code, reason = "Connect: Error reading invitation response").alsoFireAccountErrorPixel() val otherDevicePublicKey = response.publicKey val otherDeviceKeyId = response.keyId @@ -450,7 +441,7 @@ class AppSyncAccountRepository @Inject constructor( // we encrypt our secrets with otherDevicePublicKey, and send them to the backend endpoint return sendSecrets(otherDeviceKeyId, otherDevicePublicKey).onFailure { Timber.w("Sync-exchange: failed to send secrets. error code: ${it.code} ${it.reason}") - return it.copy(code = LOGIN_FAILED.code) + return it.copy(code = EXCHANGE_FAILED.code) } } } @@ -610,23 +601,38 @@ class AppSyncAccountRepository @Inject constructor( } } - private fun generateInviterDeviceDetails() { + private fun generateInviterDeviceDetails(): DeviceDetailsForKeyExchange { Timber.i("Sync-exchange: Generating inviter device details") - // generate new ID and and public/private key-pair val keyId = deviceKeyGenerator.generate() val prepareForConnect = nativeLib.prepareForConnect() - PendingInvitation( + return DeviceDetailsForKeyExchange( keyId = keyId, privateKey = prepareForConnect.secretKey, publicKey = prepareForConnect.publicKey, ).also { - pendingInvitationAsInviter = it + exchangeDeviceDetailsAsInviter = it Timber.w("Sync-exchange: this (inviter) device's key ID is $keyId") Timber.w("Sync-exchange: this (inviter) device's public key is ${it.publicKey}") } } + private fun generateReceiverDeviceDetails(): DeviceDetailsForKeyExchange { + Timber.i("Sync-exchange: Generating receiver device details") + val thisDeviceKeyId = deviceKeyGenerator.generate() + val thisDeviceKeyPair = nativeLib.prepareForConnect() + + return DeviceDetailsForKeyExchange( + keyId = thisDeviceKeyId, + privateKey = thisDeviceKeyPair.secretKey, + publicKey = thisDeviceKeyPair.publicKey, + ).also { + exchangeDeviceDetailsAsReceiver = it + Timber.w("Sync-exchange: this (receiver) device's key ID is ${it.keyId}") + Timber.w("Sync-exchange: this (receiver) device's public key is ${it.publicKey}") + } + } + private fun performCreateAccount(): Result { val userId = syncDeviceIds.userId() val account: AccountKeys = kotlin.runCatching { @@ -869,6 +875,7 @@ enum class AccountErrorCodes(val code: Int) { CREATE_ACCOUNT_FAILED(53), CONNECT_FAILED(54), INVALID_CODE(55), + EXCHANGE_FAILED(56), } enum class CodeType { @@ -917,7 +924,7 @@ inline fun Result.onFailure(action: (error: Error) -> Unit): Result { return this } -private data class PendingInvitation( +private data class DeviceDetailsForKeyExchange( val keyId: String, val privateKey: String, var publicKey: String, diff --git a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncConnectViewModel.kt b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncConnectViewModel.kt index f04c695f7e11..92075b6ce0a7 100644 --- a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncConnectViewModel.kt +++ b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncConnectViewModel.kt @@ -114,6 +114,7 @@ class SyncConnectViewModel @Inject constructor( } is LoggedIn -> { polling = false + syncPixels.fireLoginPixel() command.send(LoginSuccess) } } 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 2c55ef2a70e0..66ed97db5e69 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 @@ -41,7 +41,6 @@ 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 @@ -78,7 +77,7 @@ class SyncWithAnotherActivityViewModel @Inject constructor( private val command = Channel(1, DROP_OLDEST) fun commands(): Flow = command.receiveAsFlow() - private var exchangeInvitationCode: String? = null + private var barcodeContents: String? = null private val viewState = MutableStateFlow(ViewState()) fun viewState(): Flow = viewState.onStart { @@ -114,10 +113,9 @@ class SyncWithAnotherActivityViewModel @Inject constructor( if (!shouldExchangeKeysToSyncAnotherDevice) { syncAccountRepository.getRecoveryCode() } else { - syncAccountRepository.generateExchangeInvitationCode().also { - exchangeInvitationCode = it.getOrNull() - } + syncAccountRepository.generateExchangeInvitationCode() }.onSuccess { connectQR -> + barcodeContents = connectQR val qrBitmap = withContext(dispatchers.io()) { qrEncoder.encodeAsBitmap(connectQR, dimen.qrSizeSmall, dimen.qrSizeSmall) } @@ -135,18 +133,7 @@ class SyncWithAnotherActivityViewModel @Inject constructor( fun onCopyCodeClicked() { viewModelScope.launch(dispatchers.io()) { - val shouldExchangeKeysToSyncAnotherDevice = syncFeature.exchangeKeysToSyncWithAnotherDevice().isEnabled() - if (!shouldExchangeKeysToSyncAnotherDevice) { - syncAccountRepository.getRecoveryCode() - } else { - if (exchangeInvitationCode != null) { - Success(exchangeInvitationCode) - } else { - Error(reason = "Exchange code is null").also { - Timber.e("Sync-exchange: ${it.reason}") - } - } - }.getOrNull()?.let { code -> + barcodeContents?.let { code -> clipboard.copyToClipboard(code) command.send(ShowMessage(string.sync_code_copied_message)) } ?: command.send(FinishWithError) 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 58a4ae33a167..5ecfe0dd5ddd 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 @@ -17,6 +17,7 @@ package com.duckduckgo.sync.impl.ui import android.annotation.SuppressLint +import android.graphics.Bitmap import androidx.test.ext.junit.runners.AndroidJUnit4 import app.cash.turbine.test import com.duckduckgo.common.test.CoroutineTestRule @@ -26,17 +27,26 @@ import com.duckduckgo.sync.SyncAccountFixtures.accountA import com.duckduckgo.sync.SyncAccountFixtures.accountB import com.duckduckgo.sync.SyncAccountFixtures.noAccount import com.duckduckgo.sync.TestSyncFixtures +import com.duckduckgo.sync.TestSyncFixtures.encryptedRecoveryCode +import com.duckduckgo.sync.TestSyncFixtures.jsonExchangeKey import com.duckduckgo.sync.TestSyncFixtures.jsonRecoveryKeyEncoded +import com.duckduckgo.sync.TestSyncFixtures.primaryDeviceKeyId +import com.duckduckgo.sync.TestSyncFixtures.validLoginKeys import com.duckduckgo.sync.impl.AccountErrorCodes.ALREADY_SIGNED_IN import com.duckduckgo.sync.impl.AccountErrorCodes.LOGIN_FAILED import com.duckduckgo.sync.impl.Clipboard +import com.duckduckgo.sync.impl.CodeType.EXCHANGE +import com.duckduckgo.sync.impl.ExchangeResult.AccountSwitchingRequired +import com.duckduckgo.sync.impl.ExchangeResult.LoggedIn 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.encodeB64 import com.duckduckgo.sync.impl.pixels.SyncPixels 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.LoginSuccess import com.duckduckgo.sync.impl.ui.SyncWithAnotherActivityViewModel.Command.SwitchAccountSuccess import kotlinx.coroutines.test.runTest @@ -80,8 +90,8 @@ class SyncWithAnotherDeviceViewModelTest { @Test fun whenScreenStartedThenShowQRCode() = runTest { val bitmap = TestSyncFixtures.qrBitmap() - whenever(syncRepository.getRecoveryCode()).thenReturn(Result.Success(jsonRecoveryKeyEncoded)) whenever(qrEncoder.encodeAsBitmap(eq(jsonRecoveryKeyEncoded), any(), any())).thenReturn(bitmap) + whenever(syncRepository.getRecoveryCode()).thenReturn(Result.Success(jsonRecoveryKeyEncoded)) testee.viewState().test { val viewState = awaitItem() Assert.assertEquals(bitmap, viewState.qrCodeBitmap) @@ -89,6 +99,17 @@ class SyncWithAnotherDeviceViewModelTest { } } + @Test + fun whenScreenStartedAndExchangingKeysEnabledThenExchangeKeysUsedInQrCode() = runTest { + val expectedBitmap = configureExchangeKeysSupported().first + + testee.viewState().test { + val viewState = awaitItem() + Assert.assertEquals(expectedBitmap, viewState.qrCodeBitmap) + cancelAndIgnoreRemainingEvents() + } + } + @Test fun whenGenerateRecoveryQRFailsThenFinishWithError() = runTest { whenever(syncRepository.getRecoveryCode()).thenReturn(Result.Error(reason = "error")) @@ -104,10 +125,45 @@ class SyncWithAnotherDeviceViewModelTest { } } + @Test + fun whenGenerateRecoveryQRFailsAndExchangingKeysEnabledThenFinishWithError() = runTest { + configureExchangeKeysSupported() + whenever(syncRepository.generateExchangeInvitationCode()).thenReturn(Result.Error(reason = "error")) + testee.viewState().test { + awaitItem() + cancelAndIgnoreRemainingEvents() + } + + testee.commands().test { + val command = awaitItem() + assertTrue(command is Command.FinishWithError) + cancelAndIgnoreRemainingEvents() + } + } + @Test fun whenOnCopyCodeClickedThenShowMessage() = runTest { whenever(syncRepository.getRecoveryCode()).thenReturn(Result.Success(jsonRecoveryKeyEncoded)) + // need to ensure view state is started + testee.viewState().test { cancelAndConsumeRemainingEvents() } + + testee.onCopyCodeClicked() + + testee.commands().test { + val command = awaitItem() + assertTrue(command is Command.ShowMessage) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun whenOnCopyCodeClickedAndExchangingKeysEnabledThenShowMessage() = runTest { + configureExchangeKeysSupported() + + // need to ensure view state is started + testee.viewState().test { cancelAndConsumeRemainingEvents() } + testee.onCopyCodeClicked() testee.commands().test { @@ -121,11 +177,26 @@ class SyncWithAnotherDeviceViewModelTest { fun whenOnCopyCodeClickedThenCopyCodeToClipboard() = runTest { whenever(syncRepository.getRecoveryCode()).thenReturn(Result.Success(jsonRecoveryKeyEncoded)) + // need to ensure view state is started + testee.viewState().test { cancelAndConsumeRemainingEvents() } + testee.onCopyCodeClicked() verify(clipboard).copyToClipboard(jsonRecoveryKeyEncoded) } + @Test + fun whenOnCopyCodeClickedAndExchangingKeysEnabledThenCopyCodeToClipboard() = runTest { + val expectedJson = configureExchangeKeysSupported().second + + // need to ensure view state is started + testee.viewState().test { cancelAndConsumeRemainingEvents() } + + testee.onCopyCodeClicked() + + verify(clipboard).copyToClipboard(expectedJson) + } + @Test fun whenUserClicksOnReadTextCodeThenCommandIsReadTextCode() = runTest { testee.commands().test { @@ -150,6 +221,21 @@ class SyncWithAnotherDeviceViewModelTest { } } + @Test + fun whenUserScansRecoveryCodeAndExchangingKeysEnabledButSignedInThenCommandIsError() = runTest { + configureExchangeKeysSupported() + syncFeature.seamlessAccountSwitching().setRawStoredState(State(false)) + whenever(syncRepository.getAccountInfo()).thenReturn(accountA) + 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.ShowError) + verifyNoInteractions(syncPixels) + cancelAndIgnoreRemainingEvents() + } + } + @Test fun whenUserScansRecoveryCodeButSignedInThenCommandIsAskToSwitchAccount() = runTest { whenever(syncRepository.getAccountInfo()).thenReturn(accountA) @@ -163,6 +249,50 @@ class SyncWithAnotherDeviceViewModelTest { } } + @Test + fun whenUserScansRecoveryCodeAndExchangingKeysEnabledButSignedInThenCommandIsAskToSwitchAccount() = runTest { + configureExchangeKeysSupported() + whenever(syncRepository.getAccountInfo()).thenReturn(accountA) + 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 whenUserScansExchangeCodeAndExchangingKeysEnabledThenCommandIsLoginSuccess() = runTest { + val exchangeJson = configureExchangeKeysSupported().second + + // configure success response: logged in + whenever(syncRepository.pollForRecoveryCodeAndLogin()).thenReturn(Success(LoggedIn)) + + testee.commands().test { + testee.onQRCodeScanned(exchangeJson.encodeB64()) + val command = awaitItem() + assertTrue(command is LoginSuccess) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun whenUserScansExchangeCodeAndExchangingKeysEnabledButAccountSwitchingRequiredThenCommandIsAskToSwitchAccount() = runTest { + val exchangeJson = configureExchangeKeysSupported().second + + // configure success response: account switching required + whenever(syncRepository.pollForRecoveryCodeAndLogin()).thenReturn(Success(AccountSwitchingRequired(encryptedRecoveryCode))) + + testee.commands().test { + testee.onQRCodeScanned(exchangeJson.encodeB64()) + val command = awaitItem() + assertTrue(command is AskToSwitchAccount) + cancelAndIgnoreRemainingEvents() + } + } + @Test fun whenUserAcceptsToSwitchAccountThenPerformAction() = runTest { whenever(syncRepository.getAccountInfo()).thenReturn(accountA) @@ -180,6 +310,24 @@ class SyncWithAnotherDeviceViewModelTest { } } + @Test + fun whenUserAcceptsToSwitchAccountAndExchangingKeysEnabledThenPerformAction() = runTest { + configureExchangeKeysSupported() + whenever(syncRepository.getAccountInfo()).thenReturn(accountA) + whenever(syncRepository.logoutAndJoinNewAccount(jsonRecoveryKeyEncoded)).thenAnswer { + whenever(syncRepository.getAccountInfo()).thenReturn(accountB) + Success(true) + } + + testee.onUserAcceptedJoiningNewAccount(jsonRecoveryKeyEncoded) + + testee.commands().test { + val command = awaitItem() + assertTrue(command is SwitchAccountSuccess) + cancelAndIgnoreRemainingEvents() + } + } + @Test fun whenSignedInUserScansRecoveryCodeAndLoginSucceedsThenReturnSwitchAccount() = runTest { whenever(syncRepository.getAccountInfo()).thenReturn(accountA) @@ -237,4 +385,18 @@ class SyncWithAnotherDeviceViewModelTest { cancelAndIgnoreRemainingEvents() } } + + private fun configureExchangeKeysSupported(): Pair { + syncFeature.exchangeKeysToSyncWithAnotherDevice().setRawStoredState(State(true)) + whenever(syncRepository.pollSecondDeviceExchangeAcknowledgement()).thenReturn(Success(true)) + whenever(syncRepository.getCodeType(any())).thenReturn(EXCHANGE) + whenever(syncRepository.getAccountInfo()).thenReturn(accountA) + val bitmap = TestSyncFixtures.qrBitmap() + val jsonExchangeKey = jsonExchangeKey(primaryDeviceKeyId, validLoginKeys.primaryKey).also { + whenever(syncRepository.generateExchangeInvitationCode()).thenReturn(Success(it)) + whenever(qrEncoder.encodeAsBitmap(eq(it), any(), any())).thenReturn(bitmap) + } + whenever(syncRepository.processCode(any())).thenReturn(Success(true)) + return Pair(bitmap, jsonExchangeKey) + } }