Skip to content

Commit 15f7cb8

Browse files
cmonfortepCDRussell
authored andcommitted
aligning recovery code with connect flow
1 parent ceb1f46 commit 15f7cb8

File tree

4 files changed

+189
-11
lines changed

4 files changed

+189
-11
lines changed

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

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import com.duckduckgo.sync.impl.AccountErrorCodes.GENERIC_ERROR
3333
import com.duckduckgo.sync.impl.AccountErrorCodes.INVALID_CODE
3434
import com.duckduckgo.sync.impl.AccountErrorCodes.LOGIN_FAILED
3535
import com.duckduckgo.sync.impl.Result.Error
36+
import com.duckduckgo.sync.impl.Result.Success
3637
import com.duckduckgo.sync.impl.pixels.*
3738
import com.duckduckgo.sync.store.*
3839
import com.squareup.anvil.annotations.*
@@ -54,10 +55,12 @@ interface SyncAccountRepository {
5455
fun deleteAccount(): Result<Boolean>
5556
fun latestToken(): String
5657
fun getRecoveryCode(): Result<String>
58+
fun getInvitationCode(): Result<String>
5759
fun getThisConnectedDevice(): ConnectedDevice?
5860
fun getConnectedDevices(): Result<List<ConnectedDevice>>
5961
fun getConnectQR(): Result<String>
6062
fun pollConnectionKeys(): Result<Boolean>
63+
fun pollSecondDeviceAck(): Result<Boolean>
6164
fun renameDevice(device: ConnectedDevice): Result<Boolean>
6265
fun logoutAndJoinNewAccount(stringCode: String): Result<Boolean>
6366
}
@@ -77,6 +80,9 @@ class AppSyncAccountRepository @Inject constructor(
7780
private val syncFeature: SyncFeature,
7881
) : SyncAccountRepository {
7982

83+
private var tempPrivateKey: String? = null
84+
private var tempPublickKey: String? = null
85+
8086
private val connectedDevicesCached: MutableList<ConnectedDevice> = mutableListOf()
8187

8288
override fun isSyncSupported(): Boolean {
@@ -169,6 +175,17 @@ class AppSyncAccountRepository @Inject constructor(
169175
return Result.Success(Adapters.recoveryCodeAdapter.toJson(LinkCode(RecoveryCode(primaryKey, userID))).encodeB64())
170176
}
171177

178+
override fun getInvitationCode(): Result<String> {
179+
Timber.d("InvitationFlow: Generating invitation code")
180+
181+
val deviceID = syncStore.deviceId ?: return Error(reason = "Get Invitation Code: Not existing device ID").alsoFireAccountErrorPixel()
182+
val prepareForConnect = nativeLib.prepareForConnect() // TODO: check if values returned are different or always the same
183+
tempPrivateKey = prepareForConnect.secretKey
184+
tempPublickKey = prepareForConnect.publicKey
185+
val invitationCode = InvitationCode(deviceId = deviceID, publicKey = prepareForConnect.publicKey)
186+
return Result.Success(Adapters.invitationCodeAdapter.toJson(invitationCode).encodeB64())
187+
}
188+
172189
override fun getConnectQR(): Result<String> {
173190
val prepareForConnect = kotlin.runCatching {
174191
nativeLib.prepareForConnect().also {
@@ -234,6 +251,53 @@ class AppSyncAccountRepository @Inject constructor(
234251
}
235252
}
236253

254+
// TODO: review error codes
255+
override fun pollSecondDeviceAck(): Result<Boolean> {
256+
val deviceId = syncDeviceIds.deviceId()
257+
258+
val result = syncApi.invitationACK(deviceId)
259+
return when (result) {
260+
is Error -> {
261+
if (result.code == NOT_FOUND.code) { // TODO: check with JP which errors we can receive here.
262+
return Result.Success(false)
263+
} else if (result.code == GONE.code) {
264+
return Error(code = CONNECT_FAILED.code, reason = "Connect: keys expired").alsoFireAccountErrorPixel() // TODO: change according to JP errors
265+
}
266+
result.alsoFireAccountErrorPixel()
267+
}
268+
269+
is Result.Success -> {
270+
val sealOpen = kotlin.runCatching {
271+
nativeLib.sealOpen(result.data, tempPrivateKey!!, tempPublickKey!!) // TODO: handle those double bangs
272+
}.getOrElse { throwable ->
273+
throwable.asErrorResult().alsoFireAccountErrorPixel()
274+
return Error(code = CONNECT_FAILED.code, reason = "Connect: Error opening seal")
275+
}
276+
val publicKeyNewDevice = Adapters.ackCodeAdapter.fromJson(sealOpen)?.publicKey
277+
?: return Error(code = CONNECT_FAILED.code, reason = "Connect: Error reading received recovery code").alsoFireAccountErrorPixel()
278+
val deviceId2 = Adapters.ackCodeAdapter.fromJson(sealOpen)?.deviceId
279+
?: return Error(code = CONNECT_FAILED.code, reason = "Connect: Error reading received recovery code").alsoFireAccountErrorPixel()
280+
281+
// we encrypt our secrets with publickeynewdevice
282+
// we send them to the BE endpoint
283+
return sendSecrets(deviceId2, publicKeyNewDevice).onFailure {
284+
return it.copy(code = LOGIN_FAILED.code)
285+
}
286+
}
287+
}
288+
}
289+
290+
private fun sendSecrets(deviceId: String, publicKey: String): Result<Boolean> {
291+
val recoveryCode = getRecoveryCode()
292+
when (recoveryCode) {
293+
is Error -> TODO()
294+
is Success -> {
295+
val encryptedSecrets = nativeLib.seal(recoveryCode.data, publicKey)
296+
return syncApi.sendSecrets(deviceId, encryptedSecrets)
297+
}
298+
}
299+
}
300+
237301
override fun logout(deviceId: String): Result<Boolean> {
238302
val token = syncStore.token.takeUnless { it.isNullOrEmpty() }
239303
?: return Error(reason = "Logout: Token Empty").alsoFireLogoutErrorPixel()
@@ -527,6 +591,9 @@ class AppSyncAccountRepository @Inject constructor(
527591
companion object {
528592
private val moshi = Moshi.Builder().build()
529593
val recoveryCodeAdapter: JsonAdapter<LinkCode> = moshi.adapter(LinkCode::class.java)
594+
595+
val invitationCodeAdapter: JsonAdapter<InvitationCode> = moshi.adapter(InvitationCode::class.java)
596+
val ackCodeAdapter: JsonAdapter<ACKCode> = moshi.adapter(ACKCode::class.java)
530597
}
531598
}
532599
}
@@ -563,6 +630,16 @@ data class LinkCode(
563630
val connect: ConnectCode? = null,
564631
)
565632

633+
data class InvitationCode(
634+
val deviceId: String,
635+
val publicKey: String,
636+
)
637+
638+
data class ACKCode( // TODO: if this is the same as Invitation, we can remove
639+
val deviceId: String,
640+
val publicKey: String,
641+
)
642+
566643
data class RecoveryCode(
567644
@field:Json(name = "primary_key") val primaryKey: String,
568645
@field:Json(name = "user_id") val userId: String,

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,17 @@ 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>
76+
77+
@GET("$SYNC_PROD_ENVIRONMENT_URL/sync/connect/{device_id}") // TODO: change url and validate is GET or POST
78+
fun sendSecret(
79+
@Path("device_id") deviceId: String,
80+
@Path("secret") secret: String,
81+
): Call<Void>
82+
7283
@PATCH("$SYNC_PROD_ENVIRONMENT_URL/sync/data")
7384
fun patch(
7485
@Header("Authorization") token: String,
@@ -130,6 +141,10 @@ data class ConnectKey(
130141
@field:Json(name = "encrypted_recovery_key") val encryptedRecoveryKey: String,
131142
)
132143

144+
data class InvitationACK(
145+
@field:Json(name = "encrypted_recovery_key") val encryptedACK: String, // TODO: revisit once defined
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: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,15 @@ interface SyncApi {
6060
deviceId: String,
6161
): Result<String>
6262

63+
fun invitationACK(
64+
deviceId: String,
65+
): Result<String>
66+
67+
fun sendSecrets(
68+
deviceId: String,
69+
encryptedSecrets: String,
70+
): Result<Boolean>
71+
6372
fun deleteAccount(token: String): Result<Boolean>
6473

6574
fun getDevices(token: String): Result<List<Device>>
@@ -192,6 +201,37 @@ class SyncServiceRemote @Inject constructor(
192201
}
193202
}
194203

204+
override fun invitationACK(deviceId: String): Result<String> {
205+
val response = runCatching {
206+
val logoutCall = syncService.invitationACK(deviceId)
207+
logoutCall.execute()
208+
}.getOrElse { throwable ->
209+
return Result.Error(reason = throwable.message.toString())
210+
}
211+
212+
return onSuccess(response) {
213+
val sealed = response.body()?.encryptedACK.takeUnless { it.isNullOrEmpty() }
214+
?: return@onSuccess Result.Error(reason = "InvitationFlow: empty body")
215+
Result.Success(sealed)
216+
}
217+
}
218+
219+
override fun sendSecrets(
220+
deviceId: String,
221+
encryptedSecrets: String,
222+
): Result<Boolean> {
223+
val response = runCatching {
224+
val sendSecretCall = syncService.sendSecret(deviceId, encryptedSecrets)
225+
sendSecretCall.execute()
226+
}.getOrElse { throwable ->
227+
return Result.Error(reason = throwable.message.toString())
228+
}
229+
230+
return onSuccess(response) {
231+
Result.Success(true)
232+
}
233+
}
234+
195235
override fun deleteAccount(token: String): Result<Boolean> {
196236
val response = runCatching {
197237
val deleteAccountCall = syncService.deleteAccount("Bearer $token")

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

Lines changed: 57 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,12 @@ import com.duckduckgo.sync.impl.Result.Error
3737
import com.duckduckgo.sync.impl.Result.Success
3838
import com.duckduckgo.sync.impl.SyncAccountRepository
3939
import com.duckduckgo.sync.impl.SyncFeature
40+
import com.duckduckgo.sync.impl.decodeB64
4041
import com.duckduckgo.sync.impl.getOrNull
4142
import com.duckduckgo.sync.impl.onFailure
4243
import com.duckduckgo.sync.impl.onSuccess
4344
import com.duckduckgo.sync.impl.pixels.SyncPixels
45+
import com.duckduckgo.sync.impl.ui.SyncConnectViewModel.Companion.POLLING_INTERVAL
4446
import com.duckduckgo.sync.impl.ui.SyncWithAnotherActivityViewModel.Command.AskToSwitchAccount
4547
import com.duckduckgo.sync.impl.ui.SyncWithAnotherActivityViewModel.Command.FinishWithError
4648
import com.duckduckgo.sync.impl.ui.SyncWithAnotherActivityViewModel.Command.LoginSuccess
@@ -52,12 +54,14 @@ import com.duckduckgo.sync.impl.ui.setup.EnterCodeContract.EnterCodeContractOutp
5254
import javax.inject.Inject
5355
import kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST
5456
import kotlinx.coroutines.channels.Channel
57+
import kotlinx.coroutines.delay
5558
import kotlinx.coroutines.flow.Flow
5659
import kotlinx.coroutines.flow.MutableStateFlow
5760
import kotlinx.coroutines.flow.onStart
5861
import kotlinx.coroutines.flow.receiveAsFlow
5962
import kotlinx.coroutines.launch
6063
import kotlinx.coroutines.withContext
64+
import timber.log.Timber
6165

6266
@ContributesViewModel(ActivityScope::class)
6367
class SyncWithAnotherActivityViewModel @Inject constructor(
@@ -73,7 +77,31 @@ class SyncWithAnotherActivityViewModel @Inject constructor(
7377

7478
private val viewState = MutableStateFlow(ViewState())
7579
fun viewState(): Flow<ViewState> = viewState.onStart {
76-
generateQRCode()
80+
startInvitationProcess()
81+
}
82+
83+
private fun startInvitationProcess() {
84+
viewModelScope.launch(dispatchers.io()) {
85+
generateQRCode()
86+
var polling = true
87+
while (polling) {
88+
delay(POLLING_INTERVAL)
89+
syncAccountRepository.pollSecondDeviceAck()
90+
.onSuccess { success ->
91+
if (!success) return@onSuccess // continue polling
92+
// syncPixels.fireSignupConnectPixel(source) //TODO: pixel?
93+
command.send(Command.LoginSuccess)
94+
polling = false
95+
}.onFailure {
96+
when (it.code) {
97+
CONNECT_FAILED.code, LOGIN_FAILED.code -> {
98+
command.send(Command.ShowError(string.sync_connect_login_error, it.reason)) // TODO: review error message
99+
polling = false
100+
}
101+
}
102+
}
103+
}
104+
}
77105
}
78106

79107
private fun generateQRCode() {
@@ -83,15 +111,29 @@ class SyncWithAnotherActivityViewModel @Inject constructor(
83111
}
84112

85113
private suspend fun showQRCode() {
86-
syncAccountRepository.getRecoveryCode()
87-
.onSuccess { connectQR ->
88-
val qrBitmap = withContext(dispatchers.io()) {
89-
qrEncoder.encodeAsBitmap(connectQR, dimen.qrSizeSmall, dimen.qrSizeSmall)
114+
if (syncFeature.canShowRecoveryCode().isEnabled()) {
115+
syncAccountRepository.getRecoveryCode()
116+
.onSuccess { connectQR ->
117+
val qrBitmap = withContext(dispatchers.io()) {
118+
Timber.i("cdr QR code generated. ${connectQR.decodeB64()} $connectQR ")
119+
qrEncoder.encodeAsBitmap(connectQR, dimen.qrSizeSmall, dimen.qrSizeSmall)
120+
}
121+
viewState.emit(viewState.value.copy(qrCodeBitmap = qrBitmap))
122+
}.onFailure {
123+
command.send(Command.FinishWithError)
90124
}
91-
viewState.emit(viewState.value.copy(qrCodeBitmap = qrBitmap))
92-
}.onFailure {
93-
command.send(Command.FinishWithError)
94-
}
125+
} else {
126+
syncAccountRepository.getInvitationCode()
127+
.onSuccess { connectQR ->
128+
val qrBitmap = withContext(dispatchers.io()) {
129+
Timber.i("cdr QR code generated. ${connectQR.decodeB64()} $connectQR ")
130+
qrEncoder.encodeAsBitmap(connectQR, dimen.qrSizeSmall, dimen.qrSizeSmall)
131+
}
132+
viewState.emit(viewState.value.copy(qrCodeBitmap = qrBitmap))
133+
}.onFailure {
134+
command.send(Command.FinishWithError)
135+
}
136+
}
95137
}
96138

97139
fun onErrorDialogDismissed() {
@@ -110,7 +152,7 @@ class SyncWithAnotherActivityViewModel @Inject constructor(
110152
}
111153

112154
data class ViewState(
113-
val qrCodeBitmap: Bitmap? = null,
155+
val qrCodeBitmap: Bitmap? = null, // TODO: this can be 2 different codes
114156
)
115157

116158
sealed class Command {
@@ -157,7 +199,10 @@ class SyncWithAnotherActivityViewModel @Inject constructor(
157199
}
158200
}
159201

160-
private suspend fun emitError(result: Error, qrCode: String) {
202+
private suspend fun emitError(
203+
result: Error,
204+
qrCode: String,
205+
) {
161206
if (result.code == ALREADY_SIGNED_IN.code && syncFeature.seamlessAccountSwitching().isEnabled()) {
162207
command.send(AskToSwitchAccount(qrCode))
163208
} else {
@@ -214,6 +259,7 @@ class SyncWithAnotherActivityViewModel @Inject constructor(
214259
syncPixels.fireLoginPixel()
215260
command.send(LoginSuccess)
216261
}
262+
217263
EnterCodeContractOutput.SwitchAccountSuccess -> {
218264
syncPixels.fireLoginPixel()
219265
command.send(SwitchAccountSuccess)

0 commit comments

Comments
 (0)