Skip to content

Commit 11c24a9

Browse files
committed
Additional flow covered for when device showing barcode is authenticated
1 parent 7a3d637 commit 11c24a9

File tree

8 files changed

+497
-125
lines changed

8 files changed

+497
-125
lines changed

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

Lines changed: 232 additions & 64 deletions
Large diffs are not rendered by default.

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

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

51+
@InternalAlwaysEnabled
5152
@Toggle.DefaultValue(false)
52-
fun canShowRecoveryCode(): Toggle
53+
fun exchangeKeysToSyncWithAnotherDevice(): Toggle
5354
}

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

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

72-
@GET("$SYNC_PROD_ENVIRONMENT_URL/sync/connect/{device_id}") // TODO: change url
73-
fun invitationACK(
74-
@Path("device_id") deviceId: String,
75-
): Call<InvitationACK>
72+
@GET("$SYNC_PROD_ENVIRONMENT_URL/sync/exchange/{key_id}")
73+
fun getInvitationAcceptance(
74+
@Path("key_id") keyId: String,
75+
): Call<EncryptedMessage>
7676

77-
@GET("$SYNC_PROD_ENVIRONMENT_URL/sync/connect/{device_id}") // TODO: change url and validate is GET or POST
77+
@POST("$SYNC_PROD_ENVIRONMENT_URL/sync/exchange")
7878
fun sendSecret(
79-
@Path("device_id") deviceId: String,
80-
@Path("secret") secret: String,
79+
@Body request: EncryptedMessage,
8180
): Call<Void>
8281

8382
@PATCH("$SYNC_PROD_ENVIRONMENT_URL/sync/data")
@@ -141,8 +140,9 @@ data class ConnectKey(
141140
@field:Json(name = "encrypted_recovery_key") val encryptedRecoveryKey: String,
142141
)
143142

144-
data class InvitationACK(
145-
@field:Json(name = "encrypted_recovery_key") val encryptedACK: String, // TODO: revisit once defined
143+
data class EncryptedMessage(
144+
@field:Json(name = "key_id") val keyId: String,
145+
@field:Json(name = "encrypted_message") val encryptedMessage: String,
146146
)
147147

148148
data class Connect(

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

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -60,12 +60,11 @@ interface SyncApi {
6060
deviceId: String,
6161
): Result<String>
6262

63-
fun invitationACK(
64-
deviceId: String,
65-
): Result<String>
63+
// TODO rename this
64+
fun getExchange(keyId: String): Result<String>
6665

6766
fun sendSecrets(
68-
deviceId: String,
67+
keyId: String,
6968
encryptedSecrets: String,
7069
): Result<Boolean>
7170

@@ -92,6 +91,11 @@ interface SyncApi {
9291
token: String,
9392
since: String,
9493
): Result<JSONObject>
94+
95+
fun acceptInvitation(
96+
keyId: String,
97+
encryptedMessage: String,
98+
): Result<Boolean>
9599
}
96100

97101
@ContributesBinding(AppScope::class)
@@ -201,27 +205,33 @@ class SyncServiceRemote @Inject constructor(
201205
}
202206
}
203207

204-
override fun invitationACK(deviceId: String): Result<String> {
208+
override fun getExchange(keyId: String): Result<String> {
209+
Timber.i("cdr Looking for exchange for keyId: $keyId")
205210
val response = runCatching {
206-
val logoutCall = syncService.invitationACK(deviceId)
207-
logoutCall.execute()
211+
val request = syncService.getInvitationAcceptance(keyId)
212+
request.execute()
208213
}.getOrElse { throwable ->
209214
return Result.Error(reason = throwable.message.toString())
210215
}
211216

212217
return onSuccess(response) {
213-
val sealed = response.body()?.encryptedACK.takeUnless { it.isNullOrEmpty() }
218+
Timber.v("cdr received exchange. ${response.body()} ${response.raw()}")
219+
val sealed = response.body()?.encryptedMessage.takeUnless { it.isNullOrEmpty() }
214220
?: return@onSuccess Result.Error(reason = "InvitationFlow: empty body")
215221
Result.Success(sealed)
216222
}
217223
}
218224

219225
override fun sendSecrets(
220-
deviceId: String,
226+
keyId: String,
221227
encryptedSecrets: String,
222228
): Result<Boolean> {
223229
val response = runCatching {
224-
val sendSecretCall = syncService.sendSecret(deviceId, encryptedSecrets)
230+
val shareRecoveryKeyRequest = EncryptedMessage(
231+
keyId = keyId,
232+
encryptedMessage = encryptedSecrets,
233+
)
234+
val sendSecretCall = syncService.sendSecret(shareRecoveryKeyRequest)
225235
sendSecretCall.execute()
226236
}.getOrElse { throwable ->
227237
return Result.Error(reason = throwable.message.toString())
@@ -232,6 +242,22 @@ class SyncServiceRemote @Inject constructor(
232242
}
233243
}
234244

245+
override fun acceptInvitation(keyId: String, encryptedMessage: String): Result<Boolean> {
246+
val response = runCatching {
247+
Timber.v("cdr Accepting invitation with details $keyId")
248+
val request = syncService.sendSecret(EncryptedMessage(keyId = keyId, encryptedMessage = encryptedMessage))
249+
request.execute()
250+
}.getOrElse { throwable ->
251+
return Result.Error(reason = throwable.message.toString())
252+
}
253+
254+
Timber.v("cdr Invitation acceptance successfully sent to backend")
255+
256+
return onSuccess(response) {
257+
Result.Success(true)
258+
}
259+
}
260+
235261
override fun deleteAccount(token: String): Result<Boolean> {
236262
val response = runCatching {
237263
val deleteAccountCall = syncService.deleteAccount("Bearer $token")

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

Lines changed: 49 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,23 +28,29 @@ 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
3132
import com.duckduckgo.sync.impl.R
3233
import com.duckduckgo.sync.impl.Result
3334
import com.duckduckgo.sync.impl.Result.Error
3435
import com.duckduckgo.sync.impl.SyncAccountRepository
3536
import com.duckduckgo.sync.impl.SyncFeature
37+
import com.duckduckgo.sync.impl.onFailure
38+
import com.duckduckgo.sync.impl.onSuccess
3639
import com.duckduckgo.sync.impl.pixels.SyncPixels
3740
import com.duckduckgo.sync.impl.ui.EnterCodeViewModel.Command.AskToSwitchAccount
3841
import com.duckduckgo.sync.impl.ui.EnterCodeViewModel.Command.LoginSuccess
3942
import com.duckduckgo.sync.impl.ui.EnterCodeViewModel.Command.ShowError
4043
import com.duckduckgo.sync.impl.ui.EnterCodeViewModel.Command.SwitchAccountSuccess
44+
import com.duckduckgo.sync.impl.ui.SyncConnectViewModel.Companion.POLLING_INTERVAL_EXCHANGE_FLOW
4145
import javax.inject.*
4246
import kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST
4347
import kotlinx.coroutines.channels.Channel
48+
import kotlinx.coroutines.delay
4449
import kotlinx.coroutines.flow.Flow
4550
import kotlinx.coroutines.flow.MutableStateFlow
4651
import kotlinx.coroutines.flow.receiveAsFlow
4752
import kotlinx.coroutines.launch
53+
import timber.log.Timber
4854

4955
@ContributesViewModel(ActivityScope::class)
5056
class EnterCodeViewModel @Inject constructor(
@@ -92,24 +98,60 @@ class EnterCodeViewModel @Inject constructor(
9298
pastedCode: String,
9399
) {
94100
val previousPrimaryKey = syncAccountRepository.getAccountInfo().primaryKey
101+
val codeType = syncAccountRepository.getCodeType(pastedCode)
95102
when (val result = syncAccountRepository.processCode(pastedCode)) {
96103
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
104+
if (codeType == EXCHANGE) {
105+
Timber.d("cdr exchange in progress ${this@EnterCodeViewModel.javaClass.simpleName}")
106+
pollForRecoveryKey(previousPrimaryKey = previousPrimaryKey, code = pastedCode)
102107
} else {
103-
LoginSuccess
108+
onLoginSuccess(previousPrimaryKey)
104109
}
105-
command.send(commandSuccess)
106110
}
107111
is Result.Error -> {
108112
processError(result, pastedCode)
109113
}
110114
}
111115
}
112116

117+
private suspend fun onLoginSuccess(previousPrimaryKey: String) {
118+
val postProcessCodePK = syncAccountRepository.getAccountInfo().primaryKey
119+
val userSwitchedAccount = previousPrimaryKey.isNotBlank() && previousPrimaryKey != postProcessCodePK
120+
val commandSuccess = if (userSwitchedAccount) {
121+
syncPixels.fireUserSwitchedAccount()
122+
SwitchAccountSuccess
123+
} else {
124+
LoginSuccess
125+
}
126+
command.send(commandSuccess)
127+
}
128+
129+
private fun pollForRecoveryKey(
130+
previousPrimaryKey: String,
131+
code: String,
132+
) {
133+
Timber.d("cdr polling for recovery key ${this@EnterCodeViewModel.javaClass.simpleName}")
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+
if (!success) return@onSuccess // continue polling
141+
polling = false
142+
onLoginSuccess(previousPrimaryKey)
143+
}.onFailure {
144+
when (it.code) {
145+
CONNECT_FAILED.code, LOGIN_FAILED.code -> {
146+
polling = false
147+
processError(result = it, pastedCode = code)
148+
}
149+
}
150+
}
151+
}
152+
}
153+
}
154+
113155
private suspend fun processError(result: Error, pastedCode: String) {
114156
if (result.code == ALREADY_SIGNED_IN.code && syncFeature.seamlessAccountSwitching().isEnabled()) {
115157
command.send(AskToSwitchAccount(pastedCode))

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

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ 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
3233
import com.duckduckgo.sync.impl.QREncoder
3334
import com.duckduckgo.sync.impl.R
3435
import com.duckduckgo.sync.impl.R.dimen
@@ -54,6 +55,7 @@ import kotlinx.coroutines.flow.onStart
5455
import kotlinx.coroutines.flow.receiveAsFlow
5556
import kotlinx.coroutines.launch
5657
import kotlinx.coroutines.withContext
58+
import timber.log.Timber
5759

5860
@ContributesViewModel(ActivityScope::class)
5961
class SyncConnectViewModel @Inject constructor(
@@ -76,7 +78,7 @@ class SyncConnectViewModel @Inject constructor(
7678
showQRCode()
7779
var polling = true
7880
while (polling) {
79-
delay(POLLING_INTERVAL)
81+
delay(POLLING_INTERVAL_CONNECT_FLOW)
8082
syncAccountRepository.pollConnectionKeys()
8183
.onSuccess { success ->
8284
if (!success) return@onSuccess // continue polling
@@ -95,6 +97,28 @@ class SyncConnectViewModel @Inject constructor(
9597
}
9698
}
9799

100+
private suspend fun pollForRecoveryKey() {
101+
Timber.d("cdr polling for recovery key ${this@SyncConnectViewModel.javaClass.simpleName}")
102+
var polling = true
103+
while (polling) {
104+
delay(POLLING_INTERVAL_EXCHANGE_FLOW)
105+
syncAccountRepository.pollForRecoveryCodeAndLogin()
106+
.onSuccess { success ->
107+
if (!success) return@onSuccess // continue polling
108+
// syncPixels.fireSignupConnectPixel(source)
109+
command.send(LoginSuccess)
110+
polling = false
111+
}.onFailure {
112+
when (it.code) {
113+
CONNECT_FAILED.code, LOGIN_FAILED.code -> {
114+
command.send(ShowError(R.string.sync_connect_login_error, it.reason))
115+
polling = false
116+
}
117+
}
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("cdr recovery available for sharing manually: $code")
119144
clipboard.copyToClipboard(code)
120145
command.send(ShowMessage(R.string.sync_code_copied_message))
121146
} ?: command.send(FinishWithError)
@@ -141,7 +166,9 @@ class SyncConnectViewModel @Inject constructor(
141166
}
142167

143168
fun onQRCodeScanned(qrCode: String) {
169+
Timber.i("cdr SyncConnectView Model qr code scanned: $qrCode")
144170
viewModelScope.launch(dispatchers.io()) {
171+
val codeType = syncAccountRepository.getCodeType(qrCode)
145172
when (val result = syncAccountRepository.processCode(qrCode)) {
146173
is Error -> {
147174
when (result.code) {
@@ -157,8 +184,12 @@ class SyncConnectViewModel @Inject constructor(
157184
}
158185

159186
is Success -> {
160-
syncPixels.fireLoginPixel()
161-
command.send(LoginSuccess)
187+
if (codeType == EXCHANGE) {
188+
pollForRecoveryKey()
189+
} else {
190+
syncPixels.fireLoginPixel()
191+
command.send(LoginSuccess)
192+
}
162193
}
163194
}
164195
}
@@ -172,6 +203,7 @@ class SyncConnectViewModel @Inject constructor(
172203
}
173204

174205
companion object {
175-
const val POLLING_INTERVAL = 5000L
206+
const val POLLING_INTERVAL_CONNECT_FLOW = 5_000L
207+
const val POLLING_INTERVAL_EXCHANGE_FLOW = 2_000L
176208
}
177209
}

0 commit comments

Comments
 (0)