Skip to content

Commit 3a7b85f

Browse files
authored
Support scanning URL-based sync setup codes (#5957)
Task/Issue URL: https://app.asana.com/1/137249556945/project/72649045549333/task/1209921186465304?focus=true ### Description #5944 allows the barcodes to be rendered as URL-based; this PR is the other side of the coin in adding support for scanning URL-based sync setup barcodes. #### A note on feature flagging For feature flagging around URL-based barcodes, I’ve separated out the ability to render them vs the ability to scan them. I think this will help the logistics around rolling it out, but sense-check that for me. | Feature flag | Description | Default state | |--------|--------|--------| |`syncSetupBarcodeIsUrlBased` | Whether to render URL-based or plain barcodes | Off by default | | `canScanUrlBasedSyncSetupBarcodes` | Whether we can scan URL-based codes | On by default (it’s a kill-switch | ### Steps to test this PR You’ll need two devices, at least one of them being a real device so you can scan the barcode using its camera. #### Scanning a `connect` code - [x] Log out of sync on both devices - [x] Choose `Sync with another device` on both - [x] Scan the barcode **using the system camera** first, to verify you are dealing with a URL-based barcode - [x] Scan the barcode from inside sync settings; verify sync sets up correctly #### Scanning an `exchange` code - [x] Log out of sync on a physical device; stay logged in on the other - [x] Choose `Sync with another device` on both - [x] Using physical device, scan the barcode **using the system camera** first, to verify you are dealing with a URL-based barcode - [x] Using physical device, scan the barcode from inside sync settings; verify sync sets up correctly #### Scanning a plaintext `recovery` code - [x] Log out of sync on a physical device; stay logged in on the other - [x] On the logged-in device, disable `exchangeKeysToSyncWithAnotherDevice` - [x] Choose `Sync with another device` on both devices - [x] Using physical device, scan the barcode **using the system camera** first, and verify you are **not** dealing with a URL-based barcode; this should be just the b64-encoded recovery code (recovery codes aren’t allowed in URLs, so disabling `exchangeKeysToSyncWithAnotherDevice` means URL-based codes won’t show either) - [x] Using physical device, scan the barcode from inside sync settings; verify sync sets up correctly #### Scanning a URL-based `recovery` code This isn’t acceptable so we won’t generate URLs containing recovery codes, but let’s test what happens if one is scanned. [Barcode for testing what happens if you scan a recovery code inside a URL](https://app.asana.com/1/137249556945/task/1210110837732281?focus=true) - [x] Log out of sync on a physical device (don’t need the other device for this test) - [x] Choose `Sync with another device` on the physical device - [x] Using physical device, scan the barcode linked above from inside sync settings; verify sync is **not** set up and you see an error message. In the logs, you’ll see `Recovery code found inside a URL which is not acceptable`
1 parent e4435de commit 3a7b85f

12 files changed

+217
-117
lines changed

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

Lines changed: 68 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,16 @@ import com.duckduckgo.sync.impl.AccountErrorCodes.EXCHANGE_FAILED
3333
import com.duckduckgo.sync.impl.AccountErrorCodes.GENERIC_ERROR
3434
import com.duckduckgo.sync.impl.AccountErrorCodes.INVALID_CODE
3535
import com.duckduckgo.sync.impl.AccountErrorCodes.LOGIN_FAILED
36-
import com.duckduckgo.sync.impl.CodeType.UNKNOWN
3736
import com.duckduckgo.sync.impl.ExchangeResult.*
3837
import com.duckduckgo.sync.impl.Result.Error
3938
import com.duckduckgo.sync.impl.Result.Success
4039
import com.duckduckgo.sync.impl.SyncAccountRepository.AuthCode
40+
import com.duckduckgo.sync.impl.SyncAuthCode.Connect
41+
import com.duckduckgo.sync.impl.SyncAuthCode.Exchange
42+
import com.duckduckgo.sync.impl.SyncAuthCode.Recovery
43+
import com.duckduckgo.sync.impl.SyncAuthCode.Unknown
4144
import com.duckduckgo.sync.impl.pixels.*
45+
import com.duckduckgo.sync.impl.ui.qrcode.SyncBarcodeUrl
4246
import com.duckduckgo.sync.impl.ui.qrcode.SyncBarcodeUrlWrapper
4347
import com.duckduckgo.sync.store.*
4448
import com.squareup.anvil.annotations.*
@@ -52,11 +56,11 @@ import timber.log.Timber
5256

5357
interface SyncAccountRepository {
5458

55-
fun getCodeType(stringCode: String): CodeType
59+
fun parseSyncAuthCode(stringCode: String): SyncAuthCode
5660
fun isSyncSupported(): Boolean
5761
fun createAccount(): Result<Boolean>
5862
fun isSignedIn(): Boolean
59-
fun processCode(stringCode: String): Result<Boolean>
63+
fun processCode(code: SyncAuthCode): Result<Boolean>
6064
fun getAccountInfo(): AccountInfo
6165
fun logout(deviceId: String): Result<Boolean>
6266
fun deleteAccount(): Result<Boolean>
@@ -129,58 +133,72 @@ class AppSyncAccountRepository @Inject constructor(
129133
}
130134
}
131135

132-
override fun processCode(stringCode: String): Result<Boolean> {
133-
val decodedCode: String? = kotlin.runCatching {
134-
return@runCatching stringCode.decodeB64()
135-
}.getOrNull()
136-
if (decodedCode == null) {
137-
Timber.w("Failed while b64 decoding barcode; barcode is unusable")
138-
return Error(code = INVALID_CODE.code, reason = "Failed to decode code")
139-
}
140-
141-
kotlin.runCatching {
142-
Adapters.recoveryCodeAdapter.fromJson(decodedCode)?.recovery
143-
}.getOrNull()?.let {
144-
Timber.d("Sync: code is a recovery code")
145-
return login(it)
146-
}
136+
override fun processCode(code: SyncAuthCode): Result<Boolean> {
137+
when (code) {
138+
is Recovery -> {
139+
Timber.d("Sync: code is a recovery code")
140+
return login(code.b64Code)
141+
}
147142

148-
kotlin.runCatching {
149-
Adapters.recoveryCodeAdapter.fromJson(decodedCode)?.connect
150-
}.getOrNull()?.let {
151-
Timber.d("Sync: code is a connect code")
152-
return connectDevice(it)
153-
}
143+
is Connect -> {
144+
Timber.d("Sync: code is a connect code")
145+
return connectDevice(code.b64Code)
146+
}
154147

155-
kotlin.runCatching {
156-
Adapters.invitationCodeAdapter.fromJson(decodedCode)?.exchangeKey
157-
}.getOrNull()?.let {
158-
if (!syncFeature.exchangeKeysToSyncWithAnotherDevice().isEnabled()) {
159-
Timber.w("Sync: Scanned exchange code type but exchanging keys to sync with another device is disabled")
160-
return@let null
148+
is Exchange -> {
149+
if (!syncFeature.exchangeKeysToSyncWithAnotherDevice().isEnabled()) {
150+
Timber.w("Sync: Scanned exchange code type but exchanging keys to sync with another device is disabled")
151+
} else {
152+
return onInvitationCodeReceived(code.b64Code)
153+
}
161154
}
162155

163-
return onInvitationCodeReceived(it)
156+
else -> {
157+
Timber.d("Sync: code type unknown")
158+
}
164159
}
165-
166-
Timber.e("Sync: code is not supported")
160+
Timber.e("Sync: code type (${code.javaClass.simpleName}) is not supported")
167161
return Error(code = INVALID_CODE.code, reason = "Failed to decode code")
168162
}
169163

170-
override fun getCodeType(stringCode: String): CodeType {
164+
override fun parseSyncAuthCode(stringCode: String): SyncAuthCode {
165+
// check first if it's a URL which contains the code
166+
val (code, wasInUrl) = kotlin.runCatching {
167+
SyncBarcodeUrl.parseUrl(stringCode)?.webSafeB64EncodedCode?.removeUrlSafetyToRestoreB64()
168+
?.let { Pair(it, true) }
169+
?: Pair(stringCode, false)
170+
}.getOrDefault(Pair(stringCode, false))
171+
172+
if (wasInUrl && syncFeature.canScanUrlBasedSyncSetupBarcodes().isEnabled().not()) {
173+
Timber.e("Feature to allow scanning URL-based sync setup codes is disabled")
174+
return Unknown(code)
175+
}
176+
171177
return kotlin.runCatching {
172-
val decodedCode = stringCode.decodeB64()
173-
when {
174-
Adapters.recoveryCodeAdapter.fromJson(decodedCode)?.recovery != null -> CodeType.RECOVERY
175-
Adapters.recoveryCodeAdapter.fromJson(decodedCode)?.connect != null -> CodeType.CONNECT
176-
Adapters.invitationCodeAdapter.fromJson(decodedCode)?.exchangeKey != null -> CodeType.EXCHANGE
177-
else -> UNKNOWN
178-
}
179-
}.onFailure {
178+
val decodedCode = code.decodeB64()
179+
180+
canParseAsRecoveryCode(decodedCode)?.let {
181+
if (wasInUrl) {
182+
throw IllegalArgumentException("Sync: Recovery code found inside a URL which is not acceptable")
183+
} else {
184+
Recovery(it)
185+
}
186+
}
187+
?: canParseAsExchangeCode(decodedCode)?.let { Exchange(it) }
188+
?: canParseAsConnectCode(decodedCode)?.let { Connect(it) }
189+
?: Unknown(code)
190+
}.onSuccess {
191+
Timber.i("Sync: code type is ${it.javaClass.simpleName}. was inside url: $wasInUrl")
192+
}.getOrElse {
180193
Timber.e(it, "Failed to decode code")
181-
}.getOrDefault(UNKNOWN)
194+
Unknown(code)
195+
}
182196
}
183197

198+
private fun canParseAsRecoveryCode(decodedCode: String) = Adapters.recoveryCodeAdapter.fromJson(decodedCode)?.recovery
199+
private fun canParseAsExchangeCode(decodedCode: String) = Adapters.invitationCodeAdapter.fromJson(decodedCode)?.exchangeKey
200+
private fun canParseAsConnectCode(decodedCode: String) = Adapters.recoveryCodeAdapter.fromJson(decodedCode)?.connect
201+
184202
private fun onInvitationCodeReceived(invitationCode: InvitationCode): Result<Boolean> {
185203
// Sync: InviteFlow - B (https://app.asana.com/0/72649045549333/1209571867429615)
186204
Timber.d("Sync-exchange: InviteFlow - B. code is an exchange code $invitationCode")
@@ -625,7 +643,8 @@ class AppSyncAccountRepository @Inject constructor(
625643
}
626644

627645
is Success -> {
628-
val loginResult = processCode(stringCode)
646+
val codeType = parseSyncAuthCode(stringCode)
647+
val loginResult = processCode(codeType)
629648
if (loginResult is Error) {
630649
syncPixels.fireUserSwitchedLoginError()
631650
}
@@ -911,11 +930,11 @@ enum class AccountErrorCodes(val code: Int) {
911930
EXCHANGE_FAILED(56),
912931
}
913932

914-
enum class CodeType {
915-
RECOVERY,
916-
CONNECT,
917-
EXCHANGE,
918-
UNKNOWN,
933+
sealed interface SyncAuthCode {
934+
data class Recovery(val b64Code: RecoveryCode) : SyncAuthCode
935+
data class Connect(val b64Code: ConnectCode) : SyncAuthCode
936+
data class Exchange(val b64Code: InvitationCode) : SyncAuthCode
937+
data class Unknown(val code: String) : SyncAuthCode
919938
}
920939

921940
sealed class Result<out R> {

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,4 +56,7 @@ interface SyncFeature {
5656

5757
@Toggle.DefaultValue(DefaultFeatureValue.FALSE)
5858
fun syncSetupBarcodeIsUrlBased(): Toggle
59+
60+
@Toggle.DefaultValue(DefaultFeatureValue.TRUE)
61+
fun canScanUrlBasedSyncSetupBarcodes(): Toggle
5962
}

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,14 +28,14 @@ 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
3231
import com.duckduckgo.sync.impl.ExchangeResult.AccountSwitchingRequired
3332
import com.duckduckgo.sync.impl.ExchangeResult.LoggedIn
3433
import com.duckduckgo.sync.impl.ExchangeResult.Pending
3534
import com.duckduckgo.sync.impl.R
3635
import com.duckduckgo.sync.impl.Result
3736
import com.duckduckgo.sync.impl.Result.Error
3837
import com.duckduckgo.sync.impl.SyncAccountRepository
38+
import com.duckduckgo.sync.impl.SyncAuthCode
3939
import com.duckduckgo.sync.impl.SyncFeature
4040
import com.duckduckgo.sync.impl.onFailure
4141
import com.duckduckgo.sync.impl.onSuccess
@@ -100,10 +100,10 @@ class EnterCodeViewModel @Inject constructor(
100100
pastedCode: String,
101101
) {
102102
val previousPrimaryKey = syncAccountRepository.getAccountInfo().primaryKey
103-
val codeType = syncAccountRepository.getCodeType(pastedCode)
104-
when (val result = syncAccountRepository.processCode(pastedCode)) {
103+
val codeType = syncAccountRepository.parseSyncAuthCode(pastedCode)
104+
when (val result = syncAccountRepository.processCode(codeType)) {
105105
is Result.Success -> {
106-
if (codeType == EXCHANGE) {
106+
if (codeType is SyncAuthCode.Exchange) {
107107
pollForRecoveryKey(previousPrimaryKey = previousPrimaryKey, code = pastedCode)
108108
} else {
109109
onLoginSuccess(previousPrimaryKey)

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@ 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
3332
import com.duckduckgo.sync.impl.ExchangeResult.AccountSwitchingRequired
3433
import com.duckduckgo.sync.impl.ExchangeResult.LoggedIn
3534
import com.duckduckgo.sync.impl.ExchangeResult.Pending
@@ -39,6 +38,7 @@ import com.duckduckgo.sync.impl.R.dimen
3938
import com.duckduckgo.sync.impl.Result.Error
4039
import com.duckduckgo.sync.impl.Result.Success
4140
import com.duckduckgo.sync.impl.SyncAccountRepository
41+
import com.duckduckgo.sync.impl.SyncAuthCode.Exchange
4242
import com.duckduckgo.sync.impl.getOrNull
4343
import com.duckduckgo.sync.impl.onFailure
4444
import com.duckduckgo.sync.impl.onSuccess
@@ -173,14 +173,14 @@ class SyncConnectViewModel @Inject constructor(
173173

174174
fun onQRCodeScanned(qrCode: String) {
175175
viewModelScope.launch(dispatchers.io()) {
176-
val codeType = syncAccountRepository.getCodeType(qrCode)
177-
when (val result = syncAccountRepository.processCode(qrCode)) {
176+
val codeType = syncAccountRepository.parseSyncAuthCode(qrCode)
177+
when (val result = syncAccountRepository.processCode(codeType)) {
178178
is Error -> {
179179
processError(result)
180180
}
181181

182182
is Success -> {
183-
if (codeType == EXCHANGE) {
183+
if (codeType is Exchange) {
184184
pollForRecoveryKey()
185185
} else {
186186
syncPixels.fireLoginPixel()

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

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,8 @@ constructor(
210210

211211
fun onQRScanned(contents: String) {
212212
viewModelScope.launch(dispatchers.io()) {
213-
val result = syncAccountRepository.processCode(contents)
213+
val codeType = syncAccountRepository.parseSyncAuthCode(contents)
214+
val result = syncAccountRepository.processCode(codeType)
214215
if (result is Error) {
215216
command.send(Command.ShowMessage("$result"))
216217
}
@@ -220,7 +221,8 @@ constructor(
220221

221222
fun onConnectQRScanned(contents: String) {
222223
viewModelScope.launch(dispatchers.io()) {
223-
val result = syncAccountRepository.processCode(contents)
224+
val codeType = syncAccountRepository.parseSyncAuthCode(contents)
225+
val result = syncAccountRepository.processCode(codeType)
224226
when (result) {
225227
is Error -> {
226228
command.send(Command.ShowMessage("$result"))
@@ -289,7 +291,8 @@ constructor(
289291
private suspend fun authFlow(
290292
pastedCode: String,
291293
) {
292-
val result = syncAccountRepository.processCode(pastedCode)
294+
val codeType = syncAccountRepository.parseSyncAuthCode(pastedCode)
295+
val result = syncAccountRepository.processCode(codeType)
293296
when (result) {
294297
is Result.Success -> command.send(Command.LoginSuccess)
295298
is Result.Error -> {

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,14 +27,14 @@ import com.duckduckgo.sync.impl.AccountErrorCodes.CONNECT_FAILED
2727
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
30-
import com.duckduckgo.sync.impl.CodeType.EXCHANGE
3130
import com.duckduckgo.sync.impl.ExchangeResult.AccountSwitchingRequired
3231
import com.duckduckgo.sync.impl.ExchangeResult.LoggedIn
3332
import com.duckduckgo.sync.impl.ExchangeResult.Pending
3433
import com.duckduckgo.sync.impl.R
3534
import com.duckduckgo.sync.impl.Result.Error
3635
import com.duckduckgo.sync.impl.Result.Success
3736
import com.duckduckgo.sync.impl.SyncAccountRepository
37+
import com.duckduckgo.sync.impl.SyncAuthCode
3838
import com.duckduckgo.sync.impl.onFailure
3939
import com.duckduckgo.sync.impl.onSuccess
4040
import com.duckduckgo.sync.impl.pixels.SyncPixels
@@ -87,14 +87,14 @@ class SyncLoginViewModel @Inject constructor(
8787

8888
fun onQRCodeScanned(qrCode: String) {
8989
viewModelScope.launch(dispatchers.io()) {
90-
val codeType = syncAccountRepository.getCodeType(qrCode)
91-
when (val result = syncAccountRepository.processCode(qrCode)) {
90+
val codeType = syncAccountRepository.parseSyncAuthCode(qrCode)
91+
when (val result = syncAccountRepository.processCode(codeType)) {
9292
is Error -> {
9393
processError(result)
9494
}
9595

9696
is Success -> {
97-
if (codeType == EXCHANGE) {
97+
if (codeType is SyncAuthCode.Exchange) {
9898
pollForRecoveryKey()
9999
} else {
100100
syncPixels.fireLoginPixel()

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@ 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
3332
import com.duckduckgo.sync.impl.ExchangeResult.AccountSwitchingRequired
3433
import com.duckduckgo.sync.impl.ExchangeResult.LoggedIn
3534
import com.duckduckgo.sync.impl.ExchangeResult.Pending
@@ -41,6 +40,7 @@ import com.duckduckgo.sync.impl.Result.Error
4140
import com.duckduckgo.sync.impl.Result.Success
4241
import com.duckduckgo.sync.impl.SyncAccountRepository
4342
import com.duckduckgo.sync.impl.SyncAccountRepository.AuthCode
43+
import com.duckduckgo.sync.impl.SyncAuthCode
4444
import com.duckduckgo.sync.impl.SyncFeature
4545
import com.duckduckgo.sync.impl.onFailure
4646
import com.duckduckgo.sync.impl.onSuccess
@@ -171,15 +171,15 @@ class SyncWithAnotherActivityViewModel @Inject constructor(
171171
fun onQRCodeScanned(qrCode: String) {
172172
viewModelScope.launch(dispatchers.io()) {
173173
val previousPrimaryKey = syncAccountRepository.getAccountInfo().primaryKey
174-
val codeType = syncAccountRepository.getCodeType(qrCode)
175-
when (val result = syncAccountRepository.processCode(qrCode)) {
174+
val codeType = syncAccountRepository.parseSyncAuthCode(qrCode)
175+
when (val result = syncAccountRepository.processCode(codeType)) {
176176
is Error -> {
177177
Timber.w("Sync: error processing code ${result.reason}")
178178
emitError(result, qrCode)
179179
}
180180

181181
is Success -> {
182-
if (codeType == EXCHANGE) {
182+
if (codeType is SyncAuthCode.Exchange) {
183183
pollForRecoveryKey(previousPrimaryKey = previousPrimaryKey, qrCode = qrCode)
184184
} else {
185185
onLoginSuccess(previousPrimaryKey)

0 commit comments

Comments
 (0)