Skip to content

Aligning recovery code with connect flow #5762

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,6 @@ interface SyncFeature {
@Toggle.DefaultValue(true)
fun seamlessAccountSwitching(): Toggle

@InternalAlwaysEnabled
@Toggle.DefaultValue(false)
fun exchangeKeysToSyncWithAnotherDevice(): Toggle

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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>>
Expand Down Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -92,24 +100,66 @@ 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 ->
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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand All @@ -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
Expand All @@ -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 ->
Expand All @@ -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)
Expand All @@ -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()
Expand All @@ -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
}
}
Loading
Loading