Skip to content

Commit a1b61b8

Browse files
committed
Support URLs inside sync barcodes
1 parent 2788232 commit a1b61b8

16 files changed

+667
-38
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/SyncAccountRepository.kt

+10-11
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@ 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
@@ -156,14 +155,14 @@ class AppSyncAccountRepository @Inject constructor(
156155
return kotlin.runCatching {
157156
val decodedCode = stringCode.decodeB64()
158157
when {
159-
Adapters.recoveryCodeAdapter.fromJson(decodedCode)?.recovery != null -> CodeType.RECOVERY
160-
Adapters.recoveryCodeAdapter.fromJson(decodedCode)?.connect != null -> CodeType.CONNECT
161-
Adapters.invitationCodeAdapter.fromJson(decodedCode)?.exchangeKey != null -> CodeType.EXCHANGE
162-
else -> UNKNOWN
158+
Adapters.recoveryCodeAdapter.fromJson(decodedCode)?.recovery != null -> CodeType.Recovery(decodedCode)
159+
Adapters.recoveryCodeAdapter.fromJson(decodedCode)?.connect != null -> CodeType.Connect(decodedCode)
160+
Adapters.invitationCodeAdapter.fromJson(decodedCode)?.exchangeKey != null -> CodeType.Exchange(decodedCode)
161+
else -> CodeType.Unknown(decodedCode)
163162
}
164163
}.onFailure {
165164
Timber.e(it, "Failed to decode code")
166-
}.getOrDefault(UNKNOWN)
165+
}.getOrDefault(CodeType.Unknown(stringCode))
167166
}
168167

169168
private fun onInvitationCodeReceived(invitationCode: InvitationCode): Result<Boolean> {
@@ -882,11 +881,11 @@ enum class AccountErrorCodes(val code: Int) {
882881
EXCHANGE_FAILED(56),
883882
}
884883

885-
enum class CodeType {
886-
RECOVERY,
887-
CONNECT,
888-
EXCHANGE,
889-
UNKNOWN,
884+
sealed class CodeType(open val code: String) {
885+
data class Recovery(override val code: String) : CodeType(code)
886+
data class Connect(override val code: String) : CodeType(code)
887+
data class Exchange(override val code: String) : CodeType(code)
888+
data class Unknown(override val code: String) : CodeType(code)
890889
}
891890

892891
sealed class Result<out R> {

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/EnterCodeViewModel.kt

+2-2
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ 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
31+
import com.duckduckgo.sync.impl.CodeType
3232
import com.duckduckgo.sync.impl.ExchangeResult.AccountSwitchingRequired
3333
import com.duckduckgo.sync.impl.ExchangeResult.LoggedIn
3434
import com.duckduckgo.sync.impl.ExchangeResult.Pending
@@ -103,7 +103,7 @@ class EnterCodeViewModel @Inject constructor(
103103
val codeType = syncAccountRepository.getCodeType(pastedCode)
104104
when (val result = syncAccountRepository.processCode(pastedCode)) {
105105
is Result.Success -> {
106-
if (codeType == EXCHANGE) {
106+
if (codeType is CodeType.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

+12-6
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ 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
32+
import com.duckduckgo.sync.impl.CodeType.Exchange
3333
import com.duckduckgo.sync.impl.ExchangeResult.AccountSwitchingRequired
3434
import com.duckduckgo.sync.impl.ExchangeResult.LoggedIn
3535
import com.duckduckgo.sync.impl.ExchangeResult.Pending
@@ -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 {
@@ -174,13 +180,13 @@ class SyncConnectViewModel @Inject constructor(
174180
fun onQRCodeScanned(qrCode: String) {
175181
viewModelScope.launch(dispatchers.io()) {
176182
val codeType = syncAccountRepository.getCodeType(qrCode)
177-
when (val result = syncAccountRepository.processCode(qrCode)) {
183+
when (val result = syncAccountRepository.processCode(codeType.code)) {
178184
is Error -> {
179185
processError(result)
180186
}
181187

182188
is Success -> {
183-
if (codeType == EXCHANGE) {
189+
if (codeType is Exchange) {
184190
pollForRecoveryKey()
185191
} else {
186192
syncPixels.fireLoginPixel()

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/SyncLoginViewModel.kt

+2-2
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ 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
30+
import com.duckduckgo.sync.impl.CodeType.Exchange
3131
import com.duckduckgo.sync.impl.ExchangeResult.AccountSwitchingRequired
3232
import com.duckduckgo.sync.impl.ExchangeResult.LoggedIn
3333
import com.duckduckgo.sync.impl.ExchangeResult.Pending
@@ -94,7 +94,7 @@ class SyncLoginViewModel @Inject constructor(
9494
}
9595

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

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

+20-10
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ 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
32+
import com.duckduckgo.sync.impl.CodeType
3333
import com.duckduckgo.sync.impl.ExchangeResult.AccountSwitchingRequired
3434
import com.duckduckgo.sync.impl.ExchangeResult.LoggedIn
3535
import com.duckduckgo.sync.impl.ExchangeResult.Pending
@@ -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)
@@ -175,7 +185,7 @@ class SyncWithAnotherActivityViewModel @Inject constructor(
175185
}
176186

177187
is Success -> {
178-
if (codeType == EXCHANGE) {
188+
if (codeType is CodeType.Exchange) {
179189
pollForRecoveryKey(previousPrimaryKey = previousPrimaryKey, qrCode = qrCode)
180190
} else {
181191
onLoginSuccess(previousPrimaryKey)
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+
}

0 commit comments

Comments
 (0)