Skip to content

Commit a891cc9

Browse files
authored
Support rendering URLs inside sync barcodes (#5944)
Task/Issue URL: https://app.asana.com/1/137249556945/project/608920331025315/task/1209921184459923?focus=true ### Description Allows sync setup codes which appear in the QR barcode to be URL-based. - Note, this is disabled by default. - Note, this PR doesn’t handle scanning a URL-based barcode which will be in a higher branch on this stack. ### Steps to test this PR Get two devices (or one device + one emulator) as you’ll need to scan barcodes. #### Feature disabled, everything works as normal - [x] The feature is disabled by default, so just fresh install the app from this branch on both devices - For each of the following scenarios, make sure you can set up sync AND ensure that the “code to include” in logcat is NOT a URL: - [x] Scanning barcode when both devices signed out - [x] Scanning barcode when both devices signed in - [x] Scanning barcode when scanning device is the only one signed out - [x] Scanning barcode when scanning device is the only one signed in - [x] Manually entering codes for the same scenarios… #### Feature enabled - [x] Enable `syncSetupBarcodeIsUrlBased` feature flag on a device. Note, we expect the URL to be in the barcode but the other device won’t be able to scan it successfully (implemented in a higher branch on this stack) so don’t worry about that part. - Repeat the scenarios above, - [ ] ensuring the barcode is a URL when showing the barcode (e.g., scan it from system camera app and inspect it). URL should match the spec from the [tech design](https://app.asana.com/1/137249556945/project/608920331025315/task/1209922290919800?focus=true). - [ ] ensuring when manually sharing the code it does NOT use a URL
1 parent 3d4b2c3 commit a891cc9

18 files changed

+542
-72
lines changed

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,28 @@ internal fun String.encodeB64(): String {
2525
internal fun String.decodeB64(): String {
2626
return String(Base64.decode(this, Base64.DEFAULT))
2727
}
28+
29+
/**
30+
* This assumes the string is already base64-encoded
31+
*/
32+
internal fun String.applyUrlSafetyFromB64(): String {
33+
return this
34+
.replace('+', '-')
35+
.replace('/', '_')
36+
.trimEnd('=')
37+
}
38+
39+
internal fun String.removeUrlSafetyToRestoreB64(): String {
40+
return this
41+
.replace('-', '+')
42+
.replace('_', '/')
43+
.restoreBase64Padding()
44+
}
45+
46+
private fun String.restoreBase64Padding(): String {
47+
return when (length % 4) {
48+
2 -> "$this=="
49+
3 -> "$this="
50+
else -> this
51+
}
52+
}

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

Lines changed: 40 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,9 @@ import com.duckduckgo.sync.impl.CodeType.UNKNOWN
3737
import com.duckduckgo.sync.impl.ExchangeResult.*
3838
import com.duckduckgo.sync.impl.Result.Error
3939
import com.duckduckgo.sync.impl.Result.Success
40+
import com.duckduckgo.sync.impl.SyncAccountRepository.AuthCode
4041
import com.duckduckgo.sync.impl.pixels.*
42+
import com.duckduckgo.sync.impl.ui.qrcode.SyncBarcodeUrlWrapper
4143
import com.duckduckgo.sync.store.*
4244
import com.squareup.anvil.annotations.*
4345
import com.squareup.moshi.*
@@ -59,16 +61,28 @@ interface SyncAccountRepository {
5961
fun logout(deviceId: String): Result<Boolean>
6062
fun deleteAccount(): Result<Boolean>
6163
fun latestToken(): String
62-
fun getRecoveryCode(): Result<String>
64+
fun getRecoveryCode(): Result<AuthCode>
6365
fun getThisConnectedDevice(): ConnectedDevice?
6466
fun getConnectedDevices(): Result<List<ConnectedDevice>>
65-
fun getConnectQR(): Result<String>
67+
fun getConnectQR(): Result<AuthCode>
6668
fun pollConnectionKeys(): Result<Boolean>
67-
fun generateExchangeInvitationCode(): Result<String>
69+
fun generateExchangeInvitationCode(): Result<AuthCode>
6870
fun pollSecondDeviceExchangeAcknowledgement(): Result<Boolean>
6971
fun pollForRecoveryCodeAndLogin(): Result<ExchangeResult>
7072
fun renameDevice(device: ConnectedDevice): Result<Boolean>
7173
fun logoutAndJoinNewAccount(stringCode: String): Result<Boolean>
74+
75+
data class AuthCode(
76+
/**
77+
* A code that is suitable for displaying in a QR code.
78+
*/
79+
val qrCode: String,
80+
81+
/**
82+
* Just the code (b64-encoded)
83+
*/
84+
val rawCode: String,
85+
)
7286
}
7387

7488
@ContributesBinding(AppScope::class)
@@ -85,6 +99,7 @@ class AppSyncAccountRepository @Inject constructor(
8599
private val dispatcherProvider: DispatcherProvider,
86100
private val syncFeature: SyncFeature,
87101
private val deviceKeyGenerator: DeviceKeyGenerator,
102+
private val syncCodeUrlWrapper: SyncBarcodeUrlWrapper,
88103
) : SyncAccountRepository {
89104

90105
/**
@@ -300,13 +315,16 @@ class AppSyncAccountRepository @Inject constructor(
300315
)
301316
}
302317

303-
override fun getRecoveryCode(): Result<String> {
318+
override fun getRecoveryCode(): Result<AuthCode> {
304319
val primaryKey = syncStore.primaryKey ?: return Error(reason = "Get Recovery Code: Not existing primary Key").alsoFireAccountErrorPixel()
305320
val userID = syncStore.userId ?: return Error(reason = "Get Recovery Code: Not existing userId").alsoFireAccountErrorPixel()
306-
return Success(Adapters.recoveryCodeAdapter.toJson(LinkCode(RecoveryCode(primaryKey, userID))).encodeB64())
321+
val b64Encoded = Adapters.recoveryCodeAdapter.toJson(LinkCode(RecoveryCode(primaryKey, userID))).encodeB64()
322+
323+
// no additional formatting on the QR code for recovery codes, so qrCode always identical to rawCode
324+
return Success(AuthCode(qrCode = b64Encoded, rawCode = b64Encoded))
307325
}
308326

309-
override fun generateExchangeInvitationCode(): Result<String> {
327+
override fun generateExchangeInvitationCode(): Result<AuthCode> {
310328
// Sync: InviteFlow - A (https://app.asana.com/0/72649045549333/1209571867429615)
311329
Timber.d("Sync-exchange: InviteFlow - A. Generating invitation code")
312330

@@ -321,14 +339,19 @@ class AppSyncAccountRepository @Inject constructor(
321339
val invitationWrapper = InvitationCodeWrapper(invitationCode)
322340

323341
return kotlin.runCatching {
324-
val code = Adapters.invitationCodeAdapter.toJson(invitationWrapper).encodeB64()
325-
Success(code)
342+
val b64Encoded = Adapters.invitationCodeAdapter.toJson(invitationWrapper).encodeB64()
343+
val qrCode = if (syncFeature.syncSetupBarcodeIsUrlBased().isEnabled()) {
344+
syncCodeUrlWrapper.wrapCodeInUrl(b64Encoded)
345+
} else {
346+
b64Encoded
347+
}
348+
Success(AuthCode(qrCode = qrCode, rawCode = b64Encoded))
326349
}.getOrElse {
327350
Error(code = EXCHANGE_FAILED.code, reason = "Error generating invitation code").alsoFireAccountErrorPixel()
328351
}
329352
}
330353

331-
override fun getConnectQR(): Result<String> {
354+
override fun getConnectQR(): Result<AuthCode> {
332355
val prepareForConnect = kotlin.runCatching {
333356
nativeLib.prepareForConnect().also {
334357
it.checkResult("Creating ConnectQR code failed")
@@ -344,7 +367,13 @@ class AppSyncAccountRepository @Inject constructor(
344367
LinkCode(connect = ConnectCode(deviceId = deviceId, secretKey = prepareForConnect.publicKey)),
345368
) ?: return Error(reason = "Error generating Linking Code").alsoFireAccountErrorPixel()
346369

347-
return Success(linkingQRCode.encodeB64())
370+
val b64Encoded = linkingQRCode.encodeB64()
371+
val qrCode = if (syncFeature.syncSetupBarcodeIsUrlBased().isEnabled()) {
372+
syncCodeUrlWrapper.wrapCodeInUrl(b64Encoded)
373+
} else {
374+
b64Encoded
375+
}
376+
return Success(AuthCode(qrCode = qrCode, rawCode = b64Encoded))
348377
}
349378

350379
private fun connectDevice(connectKeys: ConnectCode): Result<Boolean> {
@@ -465,7 +494,7 @@ class AppSyncAccountRepository @Inject constructor(
465494

466495
// recovery code comes b64 encoded, so we need to decode it, then encrypt, which automatically b64 encodes the encrypted form
467496
return kotlin.runCatching {
468-
val json = recoveryCode.data.decodeB64()
497+
val json = recoveryCode.data.rawCode.decodeB64()
469498
val encryptedJson = nativeLib.seal(json, publicKey)
470499
syncApi.sendEncryptedMessage(keyId, encryptedJson)
471500
}.getOrElse {

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ import com.duckduckgo.anvil.annotations.ContributesRemoteFeature
2020
import com.duckduckgo.di.scopes.AppScope
2121
import com.duckduckgo.feature.toggles.api.Toggle
2222
import com.duckduckgo.feature.toggles.api.Toggle.DefaultFeatureValue
23-
import com.duckduckgo.feature.toggles.api.Toggle.InternalAlwaysEnabled
2423

2524
@ContributesRemoteFeature(
2625
scope = AppScope::class,
@@ -54,4 +53,7 @@ interface SyncFeature {
5453

5554
@Toggle.DefaultValue(DefaultFeatureValue.TRUE)
5655
fun automaticallyUpdateSyncSettings(): Toggle
56+
57+
@Toggle.DefaultValue(DefaultFeatureValue.FALSE)
58+
fun syncSetupBarcodeIsUrlBased(): Toggle
5759
}

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -310,9 +310,9 @@ class SyncActivityViewModel @Inject constructor(
310310

311311
fun generateRecoveryCode(viewContext: Context) {
312312
viewModelScope.launch(dispatchers.io()) {
313-
syncAccountRepository.getRecoveryCode().onSuccess { recoveryCodeB64 ->
313+
syncAccountRepository.getRecoveryCode().onSuccess { authCode ->
314314
kotlin.runCatching {
315-
recoveryCodePDF.generateAndStoreRecoveryCodePDF(viewContext, recoveryCodeB64)
315+
recoveryCodePDF.generateAndStoreRecoveryCodePDF(viewContext, authCode.rawCode)
316316
}.onSuccess { generateRecoveryCodePDF ->
317317
command.send(RecoveryCodePDFSuccess(generateRecoveryCodePDF))
318318
}.onFailure {

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

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ import com.duckduckgo.sync.impl.ui.SyncConnectViewModel.Command.LoginSuccess
4848
import com.duckduckgo.sync.impl.ui.SyncConnectViewModel.Command.ReadTextCode
4949
import com.duckduckgo.sync.impl.ui.SyncConnectViewModel.Command.ShowError
5050
import com.duckduckgo.sync.impl.ui.SyncConnectViewModel.Command.ShowMessage
51-
import javax.inject.*
51+
import javax.inject.Inject
5252
import kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST
5353
import kotlinx.coroutines.channels.Channel
5454
import kotlinx.coroutines.delay
@@ -127,9 +127,9 @@ class SyncConnectViewModel @Inject constructor(
127127

128128
private suspend fun showQRCode() {
129129
syncAccountRepository.getConnectQR()
130-
.onSuccess { connectQR ->
130+
.onSuccess { code ->
131131
val qrBitmap = withContext(dispatchers.io()) {
132-
qrEncoder.encodeAsBitmap(connectQR, dimen.qrSizeSmall, dimen.qrSizeSmall)
132+
qrEncoder.encodeAsBitmap(code.qrCode, dimen.qrSizeSmall, dimen.qrSizeSmall)
133133
}
134134
viewState.emit(viewState.value.copy(qrCodeBitmap = qrBitmap))
135135
}.onFailure {
@@ -146,8 +146,8 @@ class SyncConnectViewModel @Inject constructor(
146146
fun onCopyCodeClicked() {
147147
viewModelScope.launch(dispatchers.io()) {
148148
syncAccountRepository.getConnectQR().getOrNull()?.let { code ->
149-
Timber.d("Sync: recovery available for sharing manually: $code")
150-
clipboard.copyToClipboard(code)
149+
Timber.d("Sync: code available for sharing manually: $code")
150+
clipboard.copyToClipboard(code.rawCode)
151151
command.send(ShowMessage(R.string.sync_code_copied_message))
152152
} ?: command.send(FinishWithError)
153153
}

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,7 @@ constructor(
204204
fun onShowQRClicked() {
205205
viewModelScope.launch(dispatchers.io()) {
206206
val recoveryCode = syncAccountRepository.getRecoveryCode().getOrNull() ?: return@launch
207-
command.send(ShowQR(recoveryCode))
207+
command.send(ShowQR(recoveryCode.qrCode))
208208
}
209209
}
210210

@@ -242,7 +242,9 @@ constructor(
242242
return@launch
243243
}
244244

245-
is Success -> qrCodeResult.data
245+
is Success -> {
246+
qrCodeResult.data.qrCode
247+
}
246248
}
247249
updateViewState()
248250
command.send(ShowQR(qrCode))

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

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import com.duckduckgo.sync.impl.R.string
4040
import com.duckduckgo.sync.impl.Result.Error
4141
import com.duckduckgo.sync.impl.Result.Success
4242
import com.duckduckgo.sync.impl.SyncAccountRepository
43+
import com.duckduckgo.sync.impl.SyncAccountRepository.AuthCode
4344
import com.duckduckgo.sync.impl.SyncFeature
4445
import com.duckduckgo.sync.impl.onFailure
4546
import com.duckduckgo.sync.impl.onSuccess
@@ -77,7 +78,7 @@ class SyncWithAnotherActivityViewModel @Inject constructor(
7778
private val command = Channel<Command>(1, DROP_OLDEST)
7879
fun commands(): Flow<Command> = command.receiveAsFlow()
7980

80-
private var barcodeContents: String? = null
81+
private var barcodeContents: AuthCode? = null
8182

8283
private val viewState = MutableStateFlow(ViewState())
8384
fun viewState(): Flow<ViewState> = viewState.onStart {
@@ -108,17 +109,20 @@ class SyncWithAnotherActivityViewModel @Inject constructor(
108109
}
109110

110111
private suspend fun showQRCode() {
111-
val shouldExchangeKeysToSyncAnotherDevice = syncFeature.exchangeKeysToSyncWithAnotherDevice().isEnabled()
112-
113-
if (!shouldExchangeKeysToSyncAnotherDevice) {
112+
// get the code as a Result, and pair it with the type of code we're dealing with
113+
val result = if (!syncFeature.exchangeKeysToSyncWithAnotherDevice().isEnabled()) {
114114
syncAccountRepository.getRecoveryCode()
115115
} else {
116116
syncAccountRepository.generateExchangeInvitationCode()
117-
}.onSuccess { connectQR ->
118-
barcodeContents = connectQR
117+
}
118+
119+
result.onSuccess { authCode ->
120+
barcodeContents = authCode
121+
119122
val qrBitmap = withContext(dispatchers.io()) {
120-
qrEncoder.encodeAsBitmap(connectQR, dimen.qrSizeSmall, dimen.qrSizeSmall)
123+
qrEncoder.encodeAsBitmap(authCode.qrCode, dimen.qrSizeSmall, dimen.qrSizeSmall)
121124
}
125+
122126
viewState.emit(viewState.value.copy(qrCodeBitmap = qrBitmap))
123127
}.onFailure {
124128
command.send(Command.FinishWithError)
@@ -133,8 +137,8 @@ class SyncWithAnotherActivityViewModel @Inject constructor(
133137

134138
fun onCopyCodeClicked() {
135139
viewModelScope.launch(dispatchers.io()) {
136-
barcodeContents?.let { code ->
137-
clipboard.copyToClipboard(code)
140+
barcodeContents?.let { contents ->
141+
clipboard.copyToClipboard(contents.rawCode)
138142
command.send(ShowMessage(string.sync_code_copied_message))
139143
} ?: command.send(FinishWithError)
140144
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/*
2+
* Copyright (c) 2025 DuckDuckGo
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.duckduckgo.sync.impl.ui.qrcode
18+
19+
import androidx.core.net.toUri
20+
21+
data class SyncBarcodeUrl(
22+
val webSafeB64EncodedCode: String,
23+
val urlEncodedDeviceName: String? = null,
24+
) {
25+
26+
fun asUrl(): String {
27+
val sb = StringBuilder(URL_BASE)
28+
.append("&")
29+
.append(CODE_PARAM).append("=").append(webSafeB64EncodedCode)
30+
31+
if (urlEncodedDeviceName?.isNotBlank() == true) {
32+
sb.append("&")
33+
sb.append(DEVICE_NAME_PARAM).append("=").append(urlEncodedDeviceName)
34+
}
35+
36+
return sb.toString()
37+
}
38+
39+
companion object {
40+
const val URL_BASE = "https://duckduckgo.com/sync/pairing/#"
41+
private const val CODE_PARAM = "code"
42+
private const val DEVICE_NAME_PARAM = "deviceName"
43+
44+
fun parseUrl(fullSyncUrl: String): SyncBarcodeUrl? {
45+
return kotlin.runCatching {
46+
if (!fullSyncUrl.startsWith(URL_BASE)) {
47+
return null
48+
}
49+
50+
val uri = fullSyncUrl.toUri()
51+
val fragment = uri.fragment ?: return null
52+
val fragmentParts = fragment.split("&")
53+
54+
val code = fragmentParts
55+
.find { it.startsWith("code=") }
56+
?.substringAfter("code=")
57+
?: return null
58+
59+
val deviceName = fragmentParts
60+
.find { it.startsWith("deviceName=") }
61+
?.substringAfter("deviceName=")
62+
63+
SyncBarcodeUrl(webSafeB64EncodedCode = code, urlEncodedDeviceName = deviceName)
64+
}.getOrNull()
65+
}
66+
}
67+
}

0 commit comments

Comments
 (0)