Skip to content

Commit 4ff8eae

Browse files
committed
Support URLs inside sync barcodes
1 parent 793c07b commit 4ff8eae

13 files changed

+637
-12
lines changed

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

+25
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/SyncFeature.kt

+3
Original file line numberDiff line numberDiff line change
@@ -53,4 +53,7 @@ interface SyncFeature {
5353

5454
@Toggle.DefaultValue(true)
5555
fun automaticallyUpdateSyncSettings(): Toggle
56+
57+
@Toggle.DefaultValue(false)
58+
fun syncSetupBarcodeIsUrlBased(): Toggle
5659
}

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

+9-3
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,8 @@ 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 com.duckduckgo.sync.impl.ui.qrcode.SyncBarcodeDecorator
52+
import javax.inject.Inject
5253
import kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST
5354
import kotlinx.coroutines.channels.Channel
5455
import kotlinx.coroutines.delay
@@ -67,6 +68,7 @@ class SyncConnectViewModel @Inject constructor(
6768
private val clipboard: Clipboard,
6869
private val syncPixels: SyncPixels,
6970
private val dispatchers: DispatcherProvider,
71+
private val urlDecorator: SyncBarcodeDecorator,
7072
) : ViewModel() {
7173
private val command = Channel<Command>(1, DROP_OLDEST)
7274
fun commands(): Flow<Command> = command.receiveAsFlow()
@@ -127,9 +129,13 @@ class SyncConnectViewModel @Inject constructor(
127129

128130
private suspend fun showQRCode() {
129131
syncAccountRepository.getConnectQR()
130-
.onSuccess { connectQR ->
132+
.onSuccess { originalCode ->
131133
val qrBitmap = withContext(dispatchers.io()) {
132-
qrEncoder.encodeAsBitmap(connectQR, dimen.qrSizeSmall, dimen.qrSizeSmall)
134+
// wrap the code inside a URL if feature flag allows it
135+
val barcodeString = urlDecorator.decorateCode(originalCode, SyncBarcodeDecorator.CodeType.Connect).also {
136+
Timber.i("cdr code to include in the barcode is $it")
137+
}
138+
qrEncoder.encodeAsBitmap(barcodeString, dimen.qrSizeSmall, dimen.qrSizeSmall)
133139
}
134140
viewState.emit(viewState.value.copy(qrCodeBitmap = qrBitmap))
135141
}.onFailure {

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

+7-1
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import com.duckduckgo.sync.impl.ui.SyncInternalSettingsViewModel.Command.ReadCon
3333
import com.duckduckgo.sync.impl.ui.SyncInternalSettingsViewModel.Command.ReadQR
3434
import com.duckduckgo.sync.impl.ui.SyncInternalSettingsViewModel.Command.ShowMessage
3535
import com.duckduckgo.sync.impl.ui.SyncInternalSettingsViewModel.Command.ShowQR
36+
import com.duckduckgo.sync.impl.ui.qrcode.SyncBarcodeDecorator
3637
import com.duckduckgo.sync.store.*
3738
import javax.inject.Inject
3839
import kotlinx.coroutines.channels.BufferOverflow
@@ -54,6 +55,7 @@ constructor(
5455
private val syncEnvDataStore: SyncInternalEnvDataStore,
5556
private val syncFaviconFetchingStore: FaviconsFetchingStore,
5657
private val dispatchers: DispatcherProvider,
58+
private val urlDecorator: SyncBarcodeDecorator,
5759
) : ViewModel() {
5860

5961
private val command = Channel<Command>(1, BufferOverflow.DROP_OLDEST)
@@ -242,7 +244,11 @@ constructor(
242244
return@launch
243245
}
244246

245-
is Success -> qrCodeResult.data
247+
is Success -> {
248+
urlDecorator.decorateCode(qrCodeResult.data, SyncBarcodeDecorator.CodeType.Connect).also {
249+
Timber.i("cdr code to include in the barcode is $it")
250+
}
251+
}
246252
}
247253
updateViewState()
248254
command.send(ShowQR(qrCode))

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

+18-8
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ import com.duckduckgo.sync.impl.ui.SyncWithAnotherActivityViewModel.Command.Read
5252
import com.duckduckgo.sync.impl.ui.SyncWithAnotherActivityViewModel.Command.ShowError
5353
import com.duckduckgo.sync.impl.ui.SyncWithAnotherActivityViewModel.Command.ShowMessage
5454
import com.duckduckgo.sync.impl.ui.SyncWithAnotherActivityViewModel.Command.SwitchAccountSuccess
55+
import com.duckduckgo.sync.impl.ui.qrcode.SyncBarcodeDecorator
5556
import com.duckduckgo.sync.impl.ui.setup.EnterCodeContract.EnterCodeContractOutput
5657
import javax.inject.Inject
5758
import kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST
@@ -73,6 +74,7 @@ class SyncWithAnotherActivityViewModel @Inject constructor(
7374
private val syncPixels: SyncPixels,
7475
private val dispatchers: DispatcherProvider,
7576
private val syncFeature: SyncFeature,
77+
private val urlDecorator: SyncBarcodeDecorator,
7678
) : ViewModel() {
7779
private val command = Channel<Command>(1, DROP_OLDEST)
7880
fun commands(): Flow<Command> = command.receiveAsFlow()
@@ -108,17 +110,25 @@ class SyncWithAnotherActivityViewModel @Inject constructor(
108110
}
109111

110112
private suspend fun showQRCode() {
111-
val shouldExchangeKeysToSyncAnotherDevice = syncFeature.exchangeKeysToSyncWithAnotherDevice().isEnabled()
112-
113-
if (!shouldExchangeKeysToSyncAnotherDevice) {
114-
syncAccountRepository.getRecoveryCode()
113+
// get the code as a Result, and pair it with the type of code we're dealing with
114+
val (result, codeType) = if (!syncFeature.exchangeKeysToSyncWithAnotherDevice().isEnabled()) {
115+
Pair(syncAccountRepository.getRecoveryCode(), SyncBarcodeDecorator.CodeType.Recovery)
115116
} else {
116-
syncAccountRepository.generateExchangeInvitationCode()
117-
}.onSuccess { connectQR ->
118-
barcodeContents = connectQR
117+
Pair(syncAccountRepository.generateExchangeInvitationCode(), SyncBarcodeDecorator.CodeType.Exchange)
118+
}
119+
120+
result.onSuccess { code ->
121+
// wrap the code inside a URL if feature flag allows it
122+
val barcodeString = urlDecorator.decorateCode(code, codeType).also {
123+
Timber.i("cdr code to include in the barcode is $it")
124+
}
125+
126+
barcodeContents = barcodeString
127+
119128
val qrBitmap = withContext(dispatchers.io()) {
120-
qrEncoder.encodeAsBitmap(connectQR, dimen.qrSizeSmall, dimen.qrSizeSmall)
129+
qrEncoder.encodeAsBitmap(barcodeString, dimen.qrSizeSmall, dimen.qrSizeSmall)
121130
}
131+
122132
viewState.emit(viewState.value.copy(qrCodeBitmap = qrBitmap))
123133
}.onFailure {
124134
command.send(Command.FinishWithError)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
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 com.duckduckgo.common.utils.DispatcherProvider
20+
import com.duckduckgo.di.scopes.AppScope
21+
import com.duckduckgo.sync.impl.SyncDeviceIds
22+
import com.duckduckgo.sync.impl.SyncFeature
23+
import com.duckduckgo.sync.impl.applyUrlSafetyFromB64
24+
import com.duckduckgo.sync.impl.ui.qrcode.SyncBarcodeDecorator.CodeType
25+
import com.duckduckgo.sync.impl.ui.qrcode.SyncBarcodeDecorator.CodeType.Connect
26+
import com.duckduckgo.sync.impl.ui.qrcode.SyncBarcodeDecorator.CodeType.Exchange
27+
import com.squareup.anvil.annotations.ContributesBinding
28+
import java.net.URLEncoder
29+
import javax.inject.Inject
30+
import kotlinx.coroutines.withContext
31+
import timber.log.Timber
32+
33+
interface SyncBarcodeDecorator {
34+
35+
/**
36+
* Will accept a sync code and potentially modify it depending on feature flagged capabilities.
37+
* Not all code types can be modified so the type of code must be provided.
38+
*
39+
* @param originalCodeB64Encoded the original base64-encoded code to be potentially modified.
40+
* @param codeType the type of code to be decorated
41+
*/
42+
suspend fun decorateCode(
43+
originalCodeB64Encoded: String,
44+
codeType: CodeType,
45+
): String
46+
47+
sealed interface CodeType {
48+
data object Connect : CodeType
49+
data object Exchange : CodeType
50+
data object Recovery : CodeType
51+
}
52+
}
53+
54+
@ContributesBinding(AppScope::class)
55+
class SyncBarcodeUrlDecorator @Inject constructor(
56+
private val syncDeviceIds: SyncDeviceIds,
57+
private val syncFeature: SyncFeature,
58+
private val dispatchers: DispatcherProvider,
59+
) : SyncBarcodeDecorator {
60+
61+
override suspend fun decorateCode(originalCodeB64Encoded: String, codeType: CodeType): String {
62+
return withContext(dispatchers.io()) {
63+
// can only wrap codes in a URL if the feature is enabled
64+
if (!urlFeatureSupported()) {
65+
return@withContext originalCodeB64Encoded
66+
}
67+
68+
// only `Connect` and `Exchange` codes can be wrapped in a URL
69+
when (codeType) {
70+
is Connect -> originalCodeB64Encoded.wrapInUrl()
71+
is Exchange -> originalCodeB64Encoded.wrapInUrl()
72+
else -> originalCodeB64Encoded
73+
}
74+
}
75+
}
76+
77+
private fun urlFeatureSupported(): Boolean {
78+
return syncFeature.syncSetupBarcodeIsUrlBased().isEnabled()
79+
}
80+
81+
private fun String.wrapInUrl(): String {
82+
return kotlin.runCatching {
83+
val urlSafeCode = this.applyUrlSafetyFromB64()
84+
SyncBarcodeUrl(webSafeB64EncodedCode = urlSafeCode, urlEncodedDeviceName = getDeviceName()).asUrl()
85+
}.getOrElse {
86+
Timber.w("Sync-url: Failed to encode string for use inside a URL; returning original code")
87+
this
88+
}
89+
}
90+
91+
private fun getDeviceName(): String {
92+
val deviceName = syncDeviceIds.deviceName()
93+
return URLEncoder.encode(deviceName, "UTF-8")
94+
}
95+
}
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)