Repository: duckduckgo/Android
Pull request #5762: Aligning recovery code with connect flow
Author: login: CDRussell
Created: 2025-03-11T11:24:29Z | Merged: 2025-03-21T09:52:59Z

Task/Issue URL: https://app.asana.com/0/1209272522976873/1209569901379925 

### Description
Aligns recovery code flow with connect code flow. 
- The feature is **disabled** by default. It will be enabled for all later via a FF update.
- You’ll need 2 or more devices to complete the tests.
- Suggested logcat filter: `package:mine & (message~:"Sync-exchange|InvitationFlow”)`

### Steps to test this PR

There is a matrix of test cases detailed here: [test scenarios](https://app.asana.com/0/1209272522976873/1209569901379925/f)

#### Feature enabled
- [x] Fresh install `internal` builds on two devices. 
- [x] Enable `exchangeKeysToSyncWithAnotherDevice` FF on all devices.
- [x] Run through the test scenarios in the linked task, verifying it behaves as expected in each case

#### Feature disabled on all devices
In these tests, we want to ensure existing behaviour happens and there’s no regressions introduced even when the FF is disabled. 

- [x] Ensure `exchangeKeysToSyncWithAnotherDevice` is disabled on all devices.
- [x] Run through the various sync scenarios ensuring everything behaves identically to production, devices can be joined etc..

#### Feature enabled on some, disabled on others
- [x] Enable the feature on one device, disable it on another
- [x] Verify that if the device where it is **disabled** is signed in and has its barcode scanned, this flow will work
- [x] Verify that if the device where it is **enabled** is signed in and has its barcode scanned, this won’t work

#### Smoke test cross-platform
- [ ] Help test compatibility against as many of macOS/iOS/Windows builds as you can 🙏
- [ ] Latest builds from other platforms available here:
  - [iOS](https://app.asana.com/0/0/1209666996614195/f)
  - [macOS](https://app.asana.com/0/1209272522976873/1209666996614200/f)
  - [windows](https://app.asana.com/0/1209272522976873/1209677677642394/f)

#### Verify errors states
- [x] Use your sync knowledge to pay particular to the places where errors might happen that aren’t already caught (e.g., what else is missing a `runCatching`, which other error flows / codes aren’t handled well enough etc…)

#### Verify pixels
- [x] Use your sync knowledge to consider if any pixels are erroneously being affected.


Diff:
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..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,15 +29,20 @@ 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
+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 +50,7 @@ import timber.log.Timber
 
 interface SyncAccountRepository {
 
+    fun getCodeType(stringCode: String): CodeType
     fun isSyncSupported(): Boolean
     fun createAccount(): Result<Boolean>
     fun isSignedIn(): Boolean
@@ -58,6 +64,9 @@ interface SyncAccountRepository {
     fun getConnectedDevices(): Result<List<ConnectedDevice>>
     fun getConnectQR(): Result<String>
     fun pollConnectionKeys(): Result<Boolean>
+    fun generateExchangeInvitationCode(): Result<String>
+    fun pollSecondDeviceExchangeAcknowledgement(): Result<Boolean>
+    fun pollForRecoveryCodeAndLogin(): Result<ExchangeResult>
     fun renameDevice(device: ConnectedDevice): Result<Boolean>
     fun logoutAndJoinNewAccount(stringCode: String): Result<Boolean>
 }
@@ -75,8 +84,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 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 exchangeDeviceDetailsAsInviter: DeviceDetailsForKeyExchange? = null
+    private var exchangeDeviceDetailsAsReceiver: DeviceDetailsForKeyExchange? = null
+
     private val connectedDevicesCached: MutableList<ConnectedDevice> = mutableListOf()
 
     override fun isSyncSupported(): Boolean {
@@ -95,19 +115,136 @@ class AppSyncAccountRepository @Inject constructor(
     }
 
     override fun processCode(stringCode: String): Result<Boolean> {
+        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 onInvitationCodeReceived(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 onInvitationCodeReceived(invitationCode: InvitationCode): Result<Boolean> {
+        // 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 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 = deviceDetailsAsReceiver.keyId,
+            publicKey = deviceDetailsAsReceiver.publicKey,
+            deviceName = syncDeviceIds.deviceName(),
+        )
+
+        val encryptedPayload = kotlin.runCatching {
+            val payload = Adapters.invitedDeviceAdapter.toJson(invitedDeviceDetails)
+            nativeLib.seal(payload, invitationCode.publicKey)
+        }.getOrElse { throwable ->
+            throwable.asErrorResult().alsoFireAccountErrorPixel()
+            return Error(code = EXCHANGE_FAILED.code, reason = "Exchange: Error encrypting payload")
+        }
+        return syncApi.sendEncryptedMessage(invitationCode.keyId, encryptedPayload)
+    }
+
+    override fun pollForRecoveryCodeAndLogin(): Result<ExchangeResult> {
+        // Sync: InviteFlow - E (https://app.asana.com/0/72649045549333/1209571867429615)
+        Timber.d("Sync-exchange: InviteFlow - E")
+
+        val pendingInvite = exchangeDeviceDetailsAsReceiver
+            ?: return Error(code = EXCHANGE_FAILED.code, reason = "Exchange: No pending invite initialized").also {
+                Timber.w("Sync-exchange: no pending invite initialized")
+            }
+
+        return when (val result = syncApi.getEncryptedMessage(pendingInvite.keyId)) {
+            is Error -> {
+                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 = EXCHANGE_FAILED.code, reason = "Exchange: keys expired").alsoFireAccountErrorPixel()
+                    }
+                    else -> {
+                        Timber.e("Sync-exchange: error getting encrypted recovery code: ${result.reason}")
+                        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 = EXCHANGE_FAILED.code, reason = "Connect: Error opening seal").alsoFireAccountErrorPixel()
+
+                val recoveryData = kotlin.runCatching {
+                    Adapters.recoveryCodeAdapter.fromJson(decryptedJson)?.recovery
+                }.getOrNull()
+                    ?: return Error(code = EXCHANGE_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<Boolean> {
@@ -166,7 +303,29 @@ class AppSyncAccountRepository @Inject constructor(
     override fun getRecoveryCode(): Result<String> {
         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<String> {
+        // 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 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 = deviceDetailsAsInviter.keyId, publicKey = deviceDetailsAsInviter.publicKey)
+        val invitationWrapper = InvitationCodeWrapper(invitationCode)
+
+        return kotlin.runCatching {
+            val code = Adapters.invitationCodeAdapter.toJson(invitationWrapper).encodeB64()
+            Success(code)
+        }.getOrElse {
+            Error(code = EXCHANGE_FAILED.code, reason = "Error generating invitation code").alsoFireAccountErrorPixel()
+        }
     }
 
     override fun getConnectQR(): Result<String> {
@@ -185,7 +344,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<Boolean> {
@@ -205,18 +364,17 @@ class AppSyncAccountRepository @Inject constructor(
     override fun pollConnectionKeys(): Result<Boolean> {
         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 +392,85 @@ class AppSyncAccountRepository @Inject constructor(
         }
     }
 
+    override fun pollSecondDeviceExchangeAcknowledgement(): Result<Boolean> {
+        // Sync: InviteFlow - C (https://app.asana.com/0/72649045549333/1209571867429615)
+        Timber.d("Sync-exchange: InviteFlow - C")
+
+        val keyId = exchangeDeviceDetailsAsInviter?.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 = EXCHANGE_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 = 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 = 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 = EXCHANGE_FAILED.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 = EXCHANGE_FAILED.code)
+                }
+            }
+        }
+    }
+
+    private fun sendSecrets(keyId: String, publicKey: String): Result<Boolean> {
+        // 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<Boolean> {
         val token = syncStore.token.takeUnless { it.isNullOrEmpty() }
             ?: return Error(reason = "Logout: Token Empty").alsoFireLogoutErrorPixel()
@@ -257,11 +494,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 +513,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 +546,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 +590,49 @@ 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(): DeviceDetailsForKeyExchange {
+        Timber.i("Sync-exchange: Generating inviter device details")
+        val keyId = deviceKeyGenerator.generate()
+        val prepareForConnect = nativeLib.prepareForConnect()
+
+        return DeviceDetailsForKeyExchange(
+            keyId = keyId,
+            privateKey = prepareForConnect.secretKey,
+            publicKey = prepareForConnect.publicKey,
+        ).also {
+            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<Boolean> {
         val userId = syncDeviceIds.userId()
         val account: AccountKeys = kotlin.runCatching {
@@ -407,11 +677,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 +720,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 +734,7 @@ class AppSyncAccountRepository @Inject constructor(
                     syncEngine.triggerSync(ACCOUNT_LOGIN)
                 }
 
-                Result.Success(true)
+                Success(true)
             }
         }
     }
@@ -527,6 +797,9 @@ class AppSyncAccountRepository @Inject constructor(
         companion object {
             private val moshi = Moshi.Builder().build()
             val recoveryCodeAdapter: JsonAdapter<LinkCode> = moshi.adapter(LinkCode::class.java)
+
+            val invitationCodeAdapter: JsonAdapter<InvitationCodeWrapper> = moshi.adapter(InvitationCodeWrapper::class.java)
+            val invitedDeviceAdapter: JsonAdapter<InvitedDeviceDetails> = moshi.adapter(InvitedDeviceDetails::class.java)
         }
     }
 }
@@ -568,6 +841,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,
@@ -587,6 +875,14 @@ enum class AccountErrorCodes(val code: Int) {
     CREATE_ACCOUNT_FAILED(53),
     CONNECT_FAILED(54),
     INVALID_CODE(55),
+    EXCHANGE_FAILED(56),
+}
+
+enum class CodeType {
+    RECOVERY,
+    CONNECT,
+    EXCHANGE,
+    UNKNOWN,
 }
 
 sealed class Result<out R> {
@@ -607,23 +903,60 @@ sealed class Result<out R> {
 
 fun <T> Result<T>.getOrNull(): T? {
     return when (this) {
-        is Result.Success -> data
-        is Result.Error -> null
+        is Success -> data
+        is Error -> null
     }
 }
 
 inline fun <T> Result<T>.onSuccess(action: (value: T) -> Unit): Result<T> {
-    if (this is Result.Success) {
+    if (this is Success) {
         action(data)
     }
 
     return this
 }
 
-inline fun <T> Result<T>.onFailure(action: (error: Result.Error) -> Unit): Result<T> {
-    if (this is Result.Error) {
+inline fun <T> Result<T>.onFailure(action: (error: Error) -> Unit): Result<T> {
+    if (this is Error) {
         action(this)
     }
 
     return this
 }
+
+private data class DeviceDetailsForKeyExchange(
+    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<ConnectKey>
 
+    @GET("$SYNC_PROD_ENVIRONMENT_URL/sync/exchange/{key_id}")
+    fun getEncryptedMessage(
+        @Path("key_id") keyId: String,
+    ): Call<EncryptedMessage>
+
+    @POST("$SYNC_PROD_ENVIRONMENT_URL/sync/exchange")
+    fun sendEncryptedMessage(
+        @Body request: EncryptedMessage,
+    ): Call<Void>
+
     @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<String>
 
+    fun getEncryptedMessage(keyId: String): Result<String>
+
+    fun sendEncryptedMessage(
+        keyId: String,
+        encryptedSecrets: String,
+    ): Result<Boolean>
+
     fun deleteAccount(token: String): Result<Boolean>
 
     fun getDevices(token: String): Result<List<Device>>
@@ -192,6 +199,42 @@ class SyncServiceRemote @Inject constructor(
         }
     }
 
+    override fun getEncryptedMessage(keyId: String): Result<String> {
+        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<Boolean> {
+        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<Boolean> {
         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..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
@@ -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,51 @@ 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 ->
+                        when (success) {
+                            is Pending -> return@onSuccess // continue polling
+                            is AccountSwitchingRequired -> {
+                                polling = false
+                                command.send(AskToSwitchAccount(success.recoveryCode))
+                            }
+                            LoggedIn -> {
+                                polling = false
+                                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..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
@@ -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,31 @@ class SyncConnectViewModel @Inject constructor(
         }
     }
 
+    private suspend fun pollForRecoveryKey() {
+        var polling = true
+        while (polling) {
+            delay(POLLING_INTERVAL_EXCHANGE_FLOW)
+            syncAccountRepository.pollForRecoveryCodeAndLogin()
+                .onSuccess { success ->
+                    when (success) {
+                        is Pending -> return@onSuccess // continue polling
+                        is AccountSwitchingRequired -> {
+                            polling = false
+                            processError(Error(ALREADY_SIGNED_IN.code, success.recoveryCode))
+                        }
+                        is LoggedIn -> {
+                            polling = false
+                            syncPixels.fireLoginPixel()
+                            command.send(LoginSuccess)
+                        }
+                    }
+                }.onFailure {
+                    polling = false
+                    processError(it)
+                }
+        }
+    }
+
     private suspend fun showQRCode() {
         syncAccountRepository.getConnectQR()
             .onSuccess { connectQR ->
@@ -116,6 +146,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 +173,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 +212,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..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
@@ -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,59 @@ 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 ->
+                    when (success) {
+                        is Pending -> return@onSuccess // continue polling
+                        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 ccb2badda2c1..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
@@ -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
@@ -37,10 +41,10 @@ import com.duckduckgo.sync.impl.Result.Error
 import com.duckduckgo.sync.impl.Result.Success
 import com.duckduckgo.sync.impl.SyncAccountRepository
 import com.duckduckgo.sync.impl.SyncFeature
-import com.duckduckgo.sync.impl.getOrNull
 import com.duckduckgo.sync.impl.onFailure
 import com.duckduckgo.sync.impl.onSuccess
 import com.duckduckgo.sync.impl.pixels.SyncPixels
+import com.duckduckgo.sync.impl.ui.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 +56,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 +77,52 @@ class SyncWithAnotherActivityViewModel @Inject constructor(
     private val command = Channel<Command>(1, DROP_OLDEST)
     fun commands(): Flow<Command> = command.receiveAsFlow()
 
+    private var barcodeContents: String? = null
+
     private val viewState = MutableStateFlow(ViewState())
     fun viewState(): Flow<ViewState> = 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()
+        }.onSuccess { connectQR ->
+            barcodeContents = 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 +133,7 @@ class SyncWithAnotherActivityViewModel @Inject constructor(
 
     fun onCopyCodeClicked() {
         viewModelScope.launch(dispatchers.io()) {
-            syncAccountRepository.getRecoveryCode().getOrNull()?.let { code ->
+            barcodeContents?.let { code ->
                 clipboard.copyToClipboard(code)
                 command.send(ShowMessage(string.sync_code_copied_message))
             } ?: command.send(FinishWithError)
@@ -136,27 +167,66 @@ 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 ->
+                        when (success) {
+                            is Pending -> return@onSuccess // continue polling
+                            is AccountSwitchingRequired -> {
+                                polling = false
+                                command.send(AskToSwitchAccount(success.recoveryCode))
+                            }
+                            is LoggedIn -> {
+                                polling = false
+                                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<Device>()
+        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<String>): 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..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
@@ -16,6 +16,8 @@
 
 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
@@ -25,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
@@ -52,6 +63,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 +75,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(
@@ -77,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)
@@ -86,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"))
@@ -101,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 {
@@ -118,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 {
@@ -147,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)
@@ -160,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)
@@ -177,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)
@@ -234,4 +385,18 @@ class SyncWithAnotherDeviceViewModelTest {
             cancelAndIgnoreRemainingEvents()
         }
     }
+
+    private fun configureExchangeKeysSupported(): Pair<Bitmap, String> {
+        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)
+    }
 }


Reviews:
user: login: CDRussell
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-18T12:07:25Z
user: login: cmonfortep
body: looking good.

I need to test, and I want to take another detailed pass around pixels.
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-20T12:04:37Z
user: login: cmonfortep
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-20T13:22:55Z
user: login: CDRussell
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-20T14:46:17Z
user: login: CDRussell
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-20T15:19:42Z
user: login: CDRussell
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-20T16:32:49Z
user: login: cmonfortep
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-21T01:02:24Z
user: login: cmonfortep
body: All test cases worked. 

Found one issue with the pixel, added a comment. It's from reader device (unauthenticated) reading QR code from another device (authenticated). We are not signaling that pixel.
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-21T01:03:25Z

Review comments:
diff_hunk: @@ -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)
path: sync/sync-impl/src/test/java/com/duckduckgo/sync/TestSyncFixtures.kt
user: login: CDRussell
body: not strictly needed but slightly more efficient for testing
created_at: 2025-03-18T12:07:25Z
author_association: MEMBER
diff_hunk: @@ -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))
path: sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/ui/SyncWithAnotherDeviceViewModelTest.kt
user: login: cmonfortep
body: Reminder to add some tests for the `State(true)` scenario.

You can check the switchAccount tests, there are some where we test with state false.
created_at: 2025-03-20T10:19:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -71,27 +78,53 @@ class SyncWithAnotherActivityViewModel @Inject constructor(
     private val command = Channel<Command>(1, DROP_OLDEST)
     fun commands(): Flow<Command> = command.receiveAsFlow()
 
+    private var exchangeInvitationCode: String? = null
+
     private val viewState = MutableStateFlow(ViewState())
     fun viewState(): Flow<ViewState> = 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()
path: sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncWithAnotherActivityViewModel.kt
user: login: cmonfortep
body: We can simplify this. I believe your intention here is just to store the QR code in memory so we can share it when user clicks "share with Text". We don't really care if it's the recovery or the exchange invitation, we just want to share whatever is represented in the QR code.

I'd make it generic (even part of the ViewState tbh):

```
val qrCodeValue = if (!shouldExchangeKeysToSyncAnotherDevice) {
            syncAccountRepository.getRecoveryCode()
        } else {
            syncAccountRepository.generateExchangeInvitationCode()
}
```


created_at: 2025-03-20T11:08:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -102,7 +135,18 @@ class SyncWithAnotherActivityViewModel @Inject constructor(
 
     fun onCopyCodeClicked() {
         viewModelScope.launch(dispatchers.io()) {
-            syncAccountRepository.getRecoveryCode().getOrNull()?.let { code ->
+            val shouldExchangeKeysToSyncAnotherDevice = syncFeature.exchangeKeysToSyncWithAnotherDevice().isEnabled()
path: sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncWithAnotherActivityViewModel.kt
user: login: cmonfortep
body: Similar, if we just store both codes in memory, we can simplify this to be agnostic of the code we are showing.
created_at: 2025-03-20T11:16:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -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
path: sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncConnectViewModel.kt
user: login: cmonfortep
body: Now I see the issue you mentioned ;)
created_at: 2025-03-20T11:30:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -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
path: sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncWithAnotherActivityViewModel.kt
user: login: cmonfortep
body: Leaving traces to ensure we remove it (I know you have spotted this).
created_at: 2025-03-20T11:31:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -71,27 +78,53 @@ class SyncWithAnotherActivityViewModel @Inject constructor(
     private val command = Channel<Command>(1, DROP_OLDEST)
     fun commands(): Flow<Command> = command.receiveAsFlow()
 
+    private var exchangeInvitationCode: String? = null
+
     private val viewState = MutableStateFlow(ViewState())
     fun viewState(): Flow<ViewState> = 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
path: sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncWithAnotherActivityViewModel.kt
user: login: cmonfortep
body: Another trace here to validate.
created_at: 2025-03-20T11:34:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -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
path: sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncLoginViewModel.kt
user: login: cmonfortep
body: Trace
created_at: 2025-03-20T11:35:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -92,24 +100,62 @@ 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)
             }
         }
     }
 
+    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
path: sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/EnterCodeViewModel.kt
user: login: cmonfortep
body: trace
created_at: 2025-03-20T11:35:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -95,19 +114,139 @@ class AppSyncAccountRepository @Inject constructor(
     }
 
     override fun processCode(stringCode: String): Result<Boolean> {
+        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 = "Invalid barcode")
+        }
+
         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<Boolean> {
path: sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/SyncAccountRepository.kt
user: login: cmonfortep
body: Thoughts on calling this `onInvitationCodeReceived`? Think makes it clear we've just been invited, and we are going to process.
created_at: 2025-03-20T11:49:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -95,19 +114,139 @@ class AppSyncAccountRepository @Inject constructor(
     }
 
     override fun processCode(stringCode: String): Result<Boolean> {
+        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 = "Invalid barcode")
+        }
+
         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<Boolean> {
+        // 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(
path: sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/SyncAccountRepository.kt
user: login: cmonfortep
body: Is this an invitation or just the keys we need to use for later decrypt and validate in the exchange? I feel invitation is just what other will read first, later are just keys?
created_at: 2025-03-20T11:55:36Z
author_association: CONTRIBUTOR
diff_hunk: @@ -95,19 +114,139 @@ class AppSyncAccountRepository @Inject constructor(
     }
 
     override fun processCode(stringCode: String): Result<Boolean> {
+        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 = "Invalid barcode")
+        }
+
         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<Boolean> {
+        // 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")
path: sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/SyncAccountRepository.kt
user: login: cmonfortep
body: `syncApi.sendEncryptedMessage(invitationCode.keyId, encrypted)` is inside the runCatching. Can we take it out so an error there is not just a GENERIC_ERROR?
created_at: 2025-03-20T11:57:32Z
author_association: CONTRIBUTOR
diff_hunk: @@ -95,19 +114,139 @@ class AppSyncAccountRepository @Inject constructor(
     }
 
     override fun processCode(stringCode: String): Result<Boolean> {
+        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 = "Invalid barcode")
+        }
+
         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<Boolean> {
+        // 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<ExchangeResult> {
+        // 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()
path: sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/SyncAccountRepository.kt
user: login: cmonfortep
body: Let's create a new code failed to differentiate from the actual connect. This is android internal, so feel free to introduce a new value just for the exchange.
created_at: 2025-03-20T13:18:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -95,19 +114,139 @@ class AppSyncAccountRepository @Inject constructor(
     }
 
     override fun processCode(stringCode: String): Result<Boolean> {
+        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 = "Invalid barcode")
+        }
+
         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<Boolean> {
+        // 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<ExchangeResult> {
+        // 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()
path: sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/SyncAccountRepository.kt
user: login: cmonfortep
body: same, we should use a different one to the connect to differentiate them
created_at: 2025-03-20T13:18:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -95,19 +114,139 @@ class AppSyncAccountRepository @Inject constructor(
     }
 
     override fun processCode(stringCode: String): Result<Boolean> {
+        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 = "Invalid barcode")
+        }
+
         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<Boolean> {
+        // 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<ExchangeResult> {
+        // 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()
path: sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/SyncAccountRepository.kt
user: login: cmonfortep
body: ditto
created_at: 2025-03-20T13:18:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -234,6 +393,85 @@ class AppSyncAccountRepository @Inject constructor(
         }
     }
 
+    override fun pollSecondDeviceExchangeAcknowledgement(): Result<Boolean> {
+        // 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,
path: sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/SyncAccountRepository.kt
user: login: cmonfortep
body: ditto, use different to connect
created_at: 2025-03-20T13:19:32Z
author_association: CONTRIBUTOR
diff_hunk: @@ -234,6 +393,85 @@ class AppSyncAccountRepository @Inject constructor(
         }
     }
 
+    override fun pollSecondDeviceExchangeAcknowledgement(): Result<Boolean> {
+        // 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")
path: sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/SyncAccountRepository.kt
user: login: cmonfortep
body: ditto
created_at: 2025-03-20T13:19:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -234,6 +393,85 @@ class AppSyncAccountRepository @Inject constructor(
         }
     }
 
+    override fun pollSecondDeviceExchangeAcknowledgement(): Result<Boolean> {
+        // 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)
path: sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/SyncAccountRepository.kt
user: login: cmonfortep
body: This should be exchange failed right? sending secrets we are not performing login
created_at: 2025-03-20T13:21:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -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() {
path: sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/SyncAccountRepository.kt
user: login: cmonfortep
body: add try-catch to this method
created_at: 2025-03-20T13:22:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -95,19 +114,139 @@ class AppSyncAccountRepository @Inject constructor(
     }
 
     override fun processCode(stringCode: String): Result<Boolean> {
+        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 = "Invalid barcode")
+        }
+
         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<Boolean> {
+        // 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<ExchangeResult> {
+        // 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()
path: sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/SyncAccountRepository.kt
user: login: CDRussell
body: Good idea; added `EXCHANGE_FAILED`
created_at: 2025-03-20T14:46:16Z
author_association: MEMBER
diff_hunk: @@ -95,19 +114,139 @@ class AppSyncAccountRepository @Inject constructor(
     }
 
     override fun processCode(stringCode: String): Result<Boolean> {
+        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 = "Invalid barcode")
+        }
+
         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<Boolean> {
+        // 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(
path: sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/SyncAccountRepository.kt
user: login: CDRussell
body: Good point, the naming is off here. I think we can drop `invitation` altogether and speak about device details for making the exchange, both as a receiver and an inviter. e.g., 

```
 /**
  * If there is a key-exchange flow in progress, we need to keep a reference to them
  */

private var exchangeDeviceDetailsAsInviter: DeviceDetailsForKeyExchange? = null
private var exchangeDeviceDetailsAsReceiver: DeviceDetailsForKeyExchange? = null
```
created_at: 2025-03-20T15:19:42Z
author_association: MEMBER
diff_hunk: @@ -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))
path: sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/ui/SyncWithAnotherDeviceViewModelTest.kt
user: login: CDRussell
body: Done!
created_at: 2025-03-20T16:32:48Z
author_association: MEMBER
diff_hunk: @@ -95,6 +100,30 @@ class SyncConnectViewModel @Inject constructor(
         }
     }
 
+    private suspend fun pollForRecoveryKey() {
+        var polling = true
+        while (polling) {
+            delay(POLLING_INTERVAL_EXCHANGE_FLOW)
+            syncAccountRepository.pollForRecoveryCodeAndLogin()
+                .onSuccess { success ->
+                    when (success) {
+                        is Pending -> return@onSuccess // continue polling
+                        is AccountSwitchingRequired -> {
+                            polling = false
+                            processError(Error(ALREADY_SIGNED_IN.code, success.recoveryCode))
+                        }
+                        is LoggedIn -> {
+                            polling = false
path: sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncConnectViewModel.kt
user: login: cmonfortep
body: We should send a pixel from here `syncPixels.fireLoginPixel()`
created_at: 2025-03-21T01:02:24Z
author_association: CONTRIBUTOR

PR comments:
user: login: CDRussell
created_at: 2025-03-11T11:24:52Z
body: * **#5798** <a href="https://app.graphite.dev/github/pr/duckduckgo/Android/5798?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>
* **#5762** <a href="https://app.graphite.dev/github/pr/duckduckgo/Android/5762?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a> 👈 <a href="https://app.graphite.dev/github/pr/duckduckgo/Android/5762?utm_source=stack-comment-view-in-graphite" target="_blank">(View in Graphite)</a>
* **#5773** <a href="https://app.graphite.dev/github/pr/duckduckgo/Android/5773?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>
* `develop`


<h2></h2>

This stack of pull requests is managed by <a href="https://graphite.dev?utm-source=stack-comment"><b>Graphite</b></a>. Learn more about <a href="https://stacking.dev/?utm_source=stack-comment">stacking</a>.
<!-- Current dependencies on/for this PR: -->
author_association: MEMBER
user: login: CDRussell
created_at: 2025-03-20T16:34:42Z
body: Appreciate the first pass review @cmonfortep; all feedback addressed. back to you for a 2nd round.
author_association: MEMBER
user: login: CDRussell
created_at: 2025-03-21T09:53:01Z
body: ### Merge activity

* **Mar 21, 5:53 AM EDT**: A user merged this pull request with [Graphite](https://app.graphite.dev/github/pr/duckduckgo/Android/5762).

author_association: MEMBER