Skip to content

Commit 2ada4fb

Browse files
committed
Support URLs inside sync barcodes
1 parent 793c07b commit 2ada4fb

12 files changed

+482
-14
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

+7-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,11 @@ 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)
136+
qrEncoder.encodeAsBitmap(barcodeString, dimen.qrSizeSmall, dimen.qrSizeSmall)
133137
}
134138
viewState.emit(viewState.value.copy(qrCodeBitmap = qrBitmap))
135139
}.onFailure {

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

+5-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,9 @@ constructor(
242244
return@launch
243245
}
244246

245-
is Success -> qrCodeResult.data
247+
is Success -> {
248+
urlDecorator.decorateCode(qrCodeResult.data, SyncBarcodeDecorator.CodeType.Connect)
249+
}
246250
}
247251
updateViewState()
248252
command.send(ShowQR(qrCode))

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

+27-10
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,12 @@ import com.duckduckgo.sync.impl.QREncoder
3737
import com.duckduckgo.sync.impl.R
3838
import com.duckduckgo.sync.impl.R.dimen
3939
import com.duckduckgo.sync.impl.R.string
40+
import com.duckduckgo.sync.impl.Result
4041
import com.duckduckgo.sync.impl.Result.Error
4142
import com.duckduckgo.sync.impl.Result.Success
4243
import com.duckduckgo.sync.impl.SyncAccountRepository
4344
import com.duckduckgo.sync.impl.SyncFeature
45+
import com.duckduckgo.sync.impl.getOrNull
4446
import com.duckduckgo.sync.impl.onFailure
4547
import com.duckduckgo.sync.impl.onSuccess
4648
import com.duckduckgo.sync.impl.pixels.SyncPixels
@@ -52,6 +54,10 @@ import com.duckduckgo.sync.impl.ui.SyncWithAnotherActivityViewModel.Command.Read
5254
import com.duckduckgo.sync.impl.ui.SyncWithAnotherActivityViewModel.Command.ShowError
5355
import com.duckduckgo.sync.impl.ui.SyncWithAnotherActivityViewModel.Command.ShowMessage
5456
import com.duckduckgo.sync.impl.ui.SyncWithAnotherActivityViewModel.Command.SwitchAccountSuccess
57+
import com.duckduckgo.sync.impl.ui.qrcode.SyncBarcodeDecorator
58+
import com.duckduckgo.sync.impl.ui.qrcode.SyncBarcodeDecorator.CodeType
59+
import com.duckduckgo.sync.impl.ui.qrcode.SyncBarcodeDecorator.CodeType.Exchange
60+
import com.duckduckgo.sync.impl.ui.qrcode.SyncBarcodeDecorator.CodeType.Recovery
5561
import com.duckduckgo.sync.impl.ui.setup.EnterCodeContract.EnterCodeContractOutput
5662
import javax.inject.Inject
5763
import kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST
@@ -73,6 +79,7 @@ class SyncWithAnotherActivityViewModel @Inject constructor(
7379
private val syncPixels: SyncPixels,
7480
private val dispatchers: DispatcherProvider,
7581
private val syncFeature: SyncFeature,
82+
private val urlDecorator: SyncBarcodeDecorator,
7683
) : ViewModel() {
7784
private val command = Channel<Command>(1, DROP_OLDEST)
7885
fun commands(): Flow<Command> = command.receiveAsFlow()
@@ -107,18 +114,28 @@ class SyncWithAnotherActivityViewModel @Inject constructor(
107114
}
108115
}
109116

117+
// get the code as a Result, and pair it with the type of code we're dealing with
118+
private fun getCode(): Pair<Result<String>, CodeType> {
119+
return if (!syncFeature.exchangeKeysToSyncWithAnotherDevice().isEnabled()) {
120+
Pair(syncAccountRepository.getRecoveryCode(), Recovery)
121+
} else {
122+
Pair(syncAccountRepository.generateExchangeInvitationCode(), Exchange)
123+
}
124+
}
125+
110126
private suspend fun showQRCode() {
111-
val shouldExchangeKeysToSyncAnotherDevice = syncFeature.exchangeKeysToSyncWithAnotherDevice().isEnabled()
127+
val (result, codeType) = getCode()
128+
129+
result.onSuccess { code ->
130+
// wrap the code inside a URL if feature flag allows it
131+
val barcodeString = urlDecorator.decorateCode(code, codeType)
132+
133+
barcodeContents = barcodeString
112134

113-
if (!shouldExchangeKeysToSyncAnotherDevice) {
114-
syncAccountRepository.getRecoveryCode()
115-
} else {
116-
syncAccountRepository.generateExchangeInvitationCode()
117-
}.onSuccess { connectQR ->
118-
barcodeContents = connectQR
119135
val qrBitmap = withContext(dispatchers.io()) {
120-
qrEncoder.encodeAsBitmap(connectQR, dimen.qrSizeSmall, dimen.qrSizeSmall)
136+
qrEncoder.encodeAsBitmap(barcodeString, dimen.qrSizeSmall, dimen.qrSizeSmall)
121137
}
138+
122139
viewState.emit(viewState.value.copy(qrCodeBitmap = qrBitmap))
123140
}.onFailure {
124141
command.send(Command.FinishWithError)
@@ -133,8 +150,8 @@ class SyncWithAnotherActivityViewModel @Inject constructor(
133150

134151
fun onCopyCodeClicked() {
135152
viewModelScope.launch(dispatchers.io()) {
136-
barcodeContents?.let { code ->
137-
clipboard.copyToClipboard(code)
153+
getCode().first.getOrNull()?.let {
154+
clipboard.copyToClipboard(it)
138155
command.send(ShowMessage(string.sync_code_copied_message))
139156
} ?: command.send(FinishWithError)
140157
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
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+
}.also {
75+
Timber.v("Sync: code to include in the barcode is $it")
76+
}
77+
}
78+
79+
private fun urlFeatureSupported(): Boolean {
80+
return syncFeature.syncSetupBarcodeIsUrlBased().isEnabled()
81+
}
82+
83+
private fun String.wrapInUrl(): String {
84+
return kotlin.runCatching {
85+
val urlSafeCode = this.applyUrlSafetyFromB64()
86+
SyncBarcodeUrl(webSafeB64EncodedCode = urlSafeCode, urlEncodedDeviceName = getDeviceName()).asUrl()
87+
}.getOrElse {
88+
Timber.w("Sync-url: Failed to encode string for use inside a URL; returning original code")
89+
this
90+
}
91+
}
92+
93+
private fun getDeviceName(): String {
94+
val deviceName = syncDeviceIds.deviceName()
95+
return URLEncoder.encode(deviceName, "UTF-8")
96+
}
97+
}
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)