Skip to content

Commit 53cc621

Browse files
cmonfortepCDRussell
authored andcommitted
aligning recovery code with connect flow
1 parent e145310 commit 53cc621

File tree

11 files changed

+891
-95
lines changed

11 files changed

+891
-95
lines changed

sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/SyncAccountRepository.kt

Lines changed: 340 additions & 26 deletions
Large diffs are not rendered by default.

sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/SyncFeature.kt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,6 @@ interface SyncFeature {
4848
@Toggle.DefaultValue(true)
4949
fun seamlessAccountSwitching(): Toggle
5050

51-
@InternalAlwaysEnabled
5251
@Toggle.DefaultValue(false)
5352
fun exchangeKeysToSyncWithAnotherDevice(): Toggle
5453

sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/SyncService.kt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,16 @@ interface SyncService {
6969
@Path("device_id") deviceId: String,
7070
): Call<ConnectKey>
7171

72+
@GET("$SYNC_PROD_ENVIRONMENT_URL/sync/exchange/{key_id}")
73+
fun getEncryptedMessage(
74+
@Path("key_id") keyId: String,
75+
): Call<EncryptedMessage>
76+
77+
@POST("$SYNC_PROD_ENVIRONMENT_URL/sync/exchange")
78+
fun sendEncryptedMessage(
79+
@Body request: EncryptedMessage,
80+
): Call<Void>
81+
7282
@PATCH("$SYNC_PROD_ENVIRONMENT_URL/sync/data")
7383
fun patch(
7484
@Header("Authorization") token: String,
@@ -130,6 +140,11 @@ data class ConnectKey(
130140
@field:Json(name = "encrypted_recovery_key") val encryptedRecoveryKey: String,
131141
)
132142

143+
data class EncryptedMessage(
144+
@field:Json(name = "key_id") val keyId: String,
145+
@field:Json(name = "encrypted_message") val encryptedMessage: String,
146+
)
147+
133148
data class Connect(
134149
@field:Json(name = "device_id") val deviceId: String,
135150
@field:Json(name = "encrypted_recovery_key") val encryptedRecoveryKey: String,

sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/SyncServiceRemote.kt

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,13 @@ interface SyncApi {
6060
deviceId: String,
6161
): Result<String>
6262

63+
fun getEncryptedMessage(keyId: String): Result<String>
64+
65+
fun sendEncryptedMessage(
66+
keyId: String,
67+
encryptedSecrets: String,
68+
): Result<Boolean>
69+
6370
fun deleteAccount(token: String): Result<Boolean>
6471

6572
fun getDevices(token: String): Result<List<Device>>
@@ -192,6 +199,42 @@ class SyncServiceRemote @Inject constructor(
192199
}
193200
}
194201

202+
override fun getEncryptedMessage(keyId: String): Result<String> {
203+
Timber.v("Sync-exchange: Looking for exchange for keyId: $keyId")
204+
val response = runCatching {
205+
val request = syncService.getEncryptedMessage(keyId)
206+
request.execute()
207+
}.getOrElse { throwable ->
208+
return Result.Error(reason = throwable.message.toString())
209+
}
210+
211+
return onSuccess(response) {
212+
val sealed = response.body()?.encryptedMessage.takeUnless { it.isNullOrEmpty() }
213+
?: return@onSuccess Result.Error(reason = "InvitationFlow: empty body")
214+
Result.Success(sealed)
215+
}
216+
}
217+
218+
override fun sendEncryptedMessage(
219+
keyId: String,
220+
encryptedSecrets: String,
221+
): Result<Boolean> {
222+
val response = runCatching {
223+
val shareRecoveryKeyRequest = EncryptedMessage(
224+
keyId = keyId,
225+
encryptedMessage = encryptedSecrets,
226+
)
227+
val sendSecretCall = syncService.sendEncryptedMessage(shareRecoveryKeyRequest)
228+
sendSecretCall.execute()
229+
}.getOrElse { throwable ->
230+
return Result.Error(reason = throwable.message.toString())
231+
}
232+
233+
return onSuccess(response) {
234+
Result.Success(true)
235+
}
236+
}
237+
195238
override fun deleteAccount(token: String): Result<Boolean> {
196239
val response = runCatching {
197240
val deleteAccountCall = syncService.deleteAccount("Bearer $token")

sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/EnterCodeViewModel.kt

Lines changed: 53 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,19 +28,27 @@ import com.duckduckgo.sync.impl.AccountErrorCodes.CREATE_ACCOUNT_FAILED
2828
import com.duckduckgo.sync.impl.AccountErrorCodes.INVALID_CODE
2929
import com.duckduckgo.sync.impl.AccountErrorCodes.LOGIN_FAILED
3030
import com.duckduckgo.sync.impl.Clipboard
31+
import com.duckduckgo.sync.impl.CodeType.EXCHANGE
32+
import com.duckduckgo.sync.impl.ExchangeResult.AccountSwitchingRequired
33+
import com.duckduckgo.sync.impl.ExchangeResult.LoggedIn
34+
import com.duckduckgo.sync.impl.ExchangeResult.Pending
3135
import com.duckduckgo.sync.impl.R
3236
import com.duckduckgo.sync.impl.Result
3337
import com.duckduckgo.sync.impl.Result.Error
3438
import com.duckduckgo.sync.impl.SyncAccountRepository
3539
import com.duckduckgo.sync.impl.SyncFeature
40+
import com.duckduckgo.sync.impl.onFailure
41+
import com.duckduckgo.sync.impl.onSuccess
3642
import com.duckduckgo.sync.impl.pixels.SyncPixels
3743
import com.duckduckgo.sync.impl.ui.EnterCodeViewModel.Command.AskToSwitchAccount
3844
import com.duckduckgo.sync.impl.ui.EnterCodeViewModel.Command.LoginSuccess
3945
import com.duckduckgo.sync.impl.ui.EnterCodeViewModel.Command.ShowError
4046
import com.duckduckgo.sync.impl.ui.EnterCodeViewModel.Command.SwitchAccountSuccess
47+
import com.duckduckgo.sync.impl.ui.SyncConnectViewModel.Companion.POLLING_INTERVAL_EXCHANGE_FLOW
4148
import javax.inject.*
4249
import kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST
4350
import kotlinx.coroutines.channels.Channel
51+
import kotlinx.coroutines.delay
4452
import kotlinx.coroutines.flow.Flow
4553
import kotlinx.coroutines.flow.MutableStateFlow
4654
import kotlinx.coroutines.flow.receiveAsFlow
@@ -92,24 +100,62 @@ class EnterCodeViewModel @Inject constructor(
92100
pastedCode: String,
93101
) {
94102
val previousPrimaryKey = syncAccountRepository.getAccountInfo().primaryKey
103+
val codeType = syncAccountRepository.getCodeType(pastedCode)
95104
when (val result = syncAccountRepository.processCode(pastedCode)) {
96105
is Result.Success -> {
97-
val postProcessCodePK = syncAccountRepository.getAccountInfo().primaryKey
98-
val userSwitchedAccount = previousPrimaryKey.isNotBlank() && previousPrimaryKey != postProcessCodePK
99-
val commandSuccess = if (userSwitchedAccount) {
100-
syncPixels.fireUserSwitchedAccount()
101-
SwitchAccountSuccess
106+
if (codeType == EXCHANGE) {
107+
pollForRecoveryKey(previousPrimaryKey = previousPrimaryKey, code = pastedCode)
102108
} else {
103-
LoginSuccess
109+
onLoginSuccess(previousPrimaryKey)
104110
}
105-
command.send(commandSuccess)
106111
}
107112
is Result.Error -> {
108113
processError(result, pastedCode)
109114
}
110115
}
111116
}
112117

118+
private suspend fun onLoginSuccess(previousPrimaryKey: String) {
119+
val postProcessCodePK = syncAccountRepository.getAccountInfo().primaryKey
120+
val userSwitchedAccount = previousPrimaryKey.isNotBlank() && previousPrimaryKey != postProcessCodePK
121+
val commandSuccess = if (userSwitchedAccount) {
122+
syncPixels.fireUserSwitchedAccount()
123+
SwitchAccountSuccess
124+
} else {
125+
LoginSuccess
126+
}
127+
command.send(commandSuccess)
128+
}
129+
130+
private fun pollForRecoveryKey(
131+
previousPrimaryKey: String,
132+
code: String,
133+
) {
134+
viewModelScope.launch(dispatchers.io()) {
135+
var polling = true
136+
while (polling) {
137+
delay(POLLING_INTERVAL_EXCHANGE_FLOW)
138+
syncAccountRepository.pollForRecoveryCodeAndLogin()
139+
.onSuccess { success ->
140+
polling = false
141+
142+
when (success) {
143+
is Pending -> return@onSuccess // continue polling
144+
is AccountSwitchingRequired -> command.send(AskToSwitchAccount(success.recoveryCode))
145+
LoggedIn -> onLoginSuccess(previousPrimaryKey)
146+
}
147+
}.onFailure {
148+
when (it.code) {
149+
CONNECT_FAILED.code, LOGIN_FAILED.code -> {
150+
polling = false
151+
processError(result = it, pastedCode = code)
152+
}
153+
}
154+
}
155+
}
156+
}
157+
}
158+
113159
private suspend fun processError(result: Error, pastedCode: String) {
114160
if (result.code == ALREADY_SIGNED_IN.code && syncFeature.seamlessAccountSwitching().isEnabled()) {
115161
command.send(AskToSwitchAccount(pastedCode))

sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncConnectViewModel.kt

Lines changed: 49 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ import com.duckduckgo.sync.impl.AccountErrorCodes.CREATE_ACCOUNT_FAILED
2929
import com.duckduckgo.sync.impl.AccountErrorCodes.INVALID_CODE
3030
import com.duckduckgo.sync.impl.AccountErrorCodes.LOGIN_FAILED
3131
import com.duckduckgo.sync.impl.Clipboard
32+
import com.duckduckgo.sync.impl.CodeType.EXCHANGE
33+
import com.duckduckgo.sync.impl.ExchangeResult.AccountSwitchingRequired
34+
import com.duckduckgo.sync.impl.ExchangeResult.LoggedIn
35+
import com.duckduckgo.sync.impl.ExchangeResult.Pending
3236
import com.duckduckgo.sync.impl.QREncoder
3337
import com.duckduckgo.sync.impl.R
3438
import com.duckduckgo.sync.impl.R.dimen
@@ -54,6 +58,7 @@ import kotlinx.coroutines.flow.onStart
5458
import kotlinx.coroutines.flow.receiveAsFlow
5559
import kotlinx.coroutines.launch
5660
import kotlinx.coroutines.withContext
61+
import timber.log.Timber
5762

5863
@ContributesViewModel(ActivityScope::class)
5964
class SyncConnectViewModel @Inject constructor(
@@ -76,7 +81,7 @@ class SyncConnectViewModel @Inject constructor(
7681
showQRCode()
7782
var polling = true
7883
while (polling) {
79-
delay(POLLING_INTERVAL)
84+
delay(POLLING_INTERVAL_CONNECT_FLOW)
8085
syncAccountRepository.pollConnectionKeys()
8186
.onSuccess { success ->
8287
if (!success) return@onSuccess // continue polling
@@ -95,6 +100,25 @@ class SyncConnectViewModel @Inject constructor(
95100
}
96101
}
97102

103+
private suspend fun pollForRecoveryKey() {
104+
var polling = true
105+
while (polling) {
106+
delay(POLLING_INTERVAL_EXCHANGE_FLOW)
107+
syncAccountRepository.pollForRecoveryCodeAndLogin()
108+
.onSuccess { success ->
109+
polling = false
110+
111+
when (success) {
112+
is Pending -> return@onSuccess // continue polling
113+
is AccountSwitchingRequired -> processError(Error(ALREADY_SIGNED_IN.code, success.recoveryCode))
114+
is LoggedIn -> command.send(LoginSuccess)
115+
}
116+
}.onFailure {
117+
processError(it)
118+
}
119+
}
120+
}
121+
98122
private suspend fun showQRCode() {
99123
syncAccountRepository.getConnectQR()
100124
.onSuccess { connectQR ->
@@ -116,6 +140,7 @@ class SyncConnectViewModel @Inject constructor(
116140
fun onCopyCodeClicked() {
117141
viewModelScope.launch(dispatchers.io()) {
118142
syncAccountRepository.getConnectQR().getOrNull()?.let { code ->
143+
Timber.d("Sync: recovery available for sharing manually: $code")
119144
clipboard.copyToClipboard(code)
120145
command.send(ShowMessage(R.string.sync_code_copied_message))
121146
} ?: command.send(FinishWithError)
@@ -142,28 +167,37 @@ class SyncConnectViewModel @Inject constructor(
142167

143168
fun onQRCodeScanned(qrCode: String) {
144169
viewModelScope.launch(dispatchers.io()) {
170+
val codeType = syncAccountRepository.getCodeType(qrCode)
145171
when (val result = syncAccountRepository.processCode(qrCode)) {
146172
is Error -> {
147-
when (result.code) {
148-
ALREADY_SIGNED_IN.code -> R.string.sync_login_authenticated_device_error
149-
LOGIN_FAILED.code -> R.string.sync_connect_login_error
150-
CONNECT_FAILED.code -> R.string.sync_connect_generic_error
151-
CREATE_ACCOUNT_FAILED.code -> R.string.sync_create_account_generic_error
152-
INVALID_CODE.code -> R.string.sync_invalid_code_error
153-
else -> null
154-
}?.let { message ->
155-
command.send(ShowError(message = message, reason = result.reason))
156-
}
173+
processError(result)
157174
}
158175

159176
is Success -> {
160-
syncPixels.fireLoginPixel()
161-
command.send(LoginSuccess)
177+
if (codeType == EXCHANGE) {
178+
pollForRecoveryKey()
179+
} else {
180+
syncPixels.fireLoginPixel()
181+
command.send(LoginSuccess)
182+
}
162183
}
163184
}
164185
}
165186
}
166187

188+
private suspend fun processError(result: Error) {
189+
when (result.code) {
190+
ALREADY_SIGNED_IN.code -> R.string.sync_login_authenticated_device_error
191+
LOGIN_FAILED.code -> R.string.sync_connect_login_error
192+
CONNECT_FAILED.code -> R.string.sync_connect_generic_error
193+
CREATE_ACCOUNT_FAILED.code -> R.string.sync_create_account_generic_error
194+
INVALID_CODE.code -> R.string.sync_invalid_code_error
195+
else -> null
196+
}?.let { message ->
197+
command.send(ShowError(message = message, reason = result.reason))
198+
}
199+
}
200+
167201
fun onLoginSuccess() {
168202
viewModelScope.launch {
169203
syncPixels.fireLoginPixel()
@@ -172,6 +206,7 @@ class SyncConnectViewModel @Inject constructor(
172206
}
173207

174208
companion object {
175-
const val POLLING_INTERVAL = 5000L
209+
const val POLLING_INTERVAL_CONNECT_FLOW = 5_000L
210+
const val POLLING_INTERVAL_EXCHANGE_FLOW = 2_000L
176211
}
177212
}

0 commit comments

Comments
 (0)