Skip to content

Support rendering URLs inside sync barcodes #5944

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,28 @@ internal fun String.encodeB64(): String {
internal fun String.decodeB64(): String {
return String(Base64.decode(this, Base64.DEFAULT))
}

/**
* This assumes the string is already base64-encoded
*/
internal fun String.applyUrlSafetyFromB64(): String {
return this
.replace('+', '-')
.replace('/', '_')
.trimEnd('=')
}

internal fun String.removeUrlSafetyToRestoreB64(): String {
return this
.replace('-', '+')
.replace('_', '/')
.restoreBase64Padding()
}

private fun String.restoreBase64Padding(): String {
return when (length % 4) {
2 -> "$this=="
3 -> "$this="
else -> this
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -53,4 +53,7 @@ interface SyncFeature {

@Toggle.DefaultValue(true)
fun automaticallyUpdateSyncSettings(): Toggle

@Toggle.DefaultValue(false)
fun syncSetupBarcodeIsUrlBased(): Toggle
}
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@ import com.duckduckgo.sync.impl.ui.SyncConnectViewModel.Command.LoginSuccess
import com.duckduckgo.sync.impl.ui.SyncConnectViewModel.Command.ReadTextCode
import com.duckduckgo.sync.impl.ui.SyncConnectViewModel.Command.ShowError
import com.duckduckgo.sync.impl.ui.SyncConnectViewModel.Command.ShowMessage
import javax.inject.*
import com.duckduckgo.sync.impl.ui.qrcode.SyncBarcodeDecorator
import javax.inject.Inject
import kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
Expand All @@ -67,6 +68,7 @@ class SyncConnectViewModel @Inject constructor(
private val clipboard: Clipboard,
private val syncPixels: SyncPixels,
private val dispatchers: DispatcherProvider,
private val urlDecorator: SyncBarcodeDecorator,
) : ViewModel() {
private val command = Channel<Command>(1, DROP_OLDEST)
fun commands(): Flow<Command> = command.receiveAsFlow()
Expand Down Expand Up @@ -127,9 +129,11 @@ class SyncConnectViewModel @Inject constructor(

private suspend fun showQRCode() {
syncAccountRepository.getConnectQR()
.onSuccess { connectQR ->
.onSuccess { originalCode ->
val qrBitmap = withContext(dispatchers.io()) {
qrEncoder.encodeAsBitmap(connectQR, dimen.qrSizeSmall, dimen.qrSizeSmall)
// wrap the code inside a URL if feature flag allows it
val barcodeString = urlDecorator.decorateCode(originalCode, SyncBarcodeDecorator.CodeType.Connect)
qrEncoder.encodeAsBitmap(barcodeString, dimen.qrSizeSmall, dimen.qrSizeSmall)
}
viewState.emit(viewState.value.copy(qrCodeBitmap = qrBitmap))
}.onFailure {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import com.duckduckgo.sync.impl.ui.SyncInternalSettingsViewModel.Command.ReadCon
import com.duckduckgo.sync.impl.ui.SyncInternalSettingsViewModel.Command.ReadQR
import com.duckduckgo.sync.impl.ui.SyncInternalSettingsViewModel.Command.ShowMessage
import com.duckduckgo.sync.impl.ui.SyncInternalSettingsViewModel.Command.ShowQR
import com.duckduckgo.sync.impl.ui.qrcode.SyncBarcodeDecorator
import com.duckduckgo.sync.store.*
import javax.inject.Inject
import kotlinx.coroutines.channels.BufferOverflow
Expand All @@ -54,6 +55,7 @@ constructor(
private val syncEnvDataStore: SyncInternalEnvDataStore,
private val syncFaviconFetchingStore: FaviconsFetchingStore,
private val dispatchers: DispatcherProvider,
private val urlDecorator: SyncBarcodeDecorator,
) : ViewModel() {

private val command = Channel<Command>(1, BufferOverflow.DROP_OLDEST)
Expand Down Expand Up @@ -242,7 +244,9 @@ constructor(
return@launch
}

is Success -> qrCodeResult.data
is Success -> {
urlDecorator.decorateCode(qrCodeResult.data, SyncBarcodeDecorator.CodeType.Connect)
}
}
updateViewState()
command.send(ShowQR(qrCode))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ import com.duckduckgo.sync.impl.ui.SyncWithAnotherActivityViewModel.Command.Read
import com.duckduckgo.sync.impl.ui.SyncWithAnotherActivityViewModel.Command.ShowError
import com.duckduckgo.sync.impl.ui.SyncWithAnotherActivityViewModel.Command.ShowMessage
import com.duckduckgo.sync.impl.ui.SyncWithAnotherActivityViewModel.Command.SwitchAccountSuccess
import com.duckduckgo.sync.impl.ui.qrcode.SyncBarcodeDecorator
import com.duckduckgo.sync.impl.ui.qrcode.SyncBarcodeDecorator.CodeType.Exchange
import com.duckduckgo.sync.impl.ui.qrcode.SyncBarcodeDecorator.CodeType.Recovery
import com.duckduckgo.sync.impl.ui.setup.EnterCodeContract.EnterCodeContractOutput
import javax.inject.Inject
import kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST
Expand All @@ -73,11 +76,12 @@ class SyncWithAnotherActivityViewModel @Inject constructor(
private val syncPixels: SyncPixels,
private val dispatchers: DispatcherProvider,
private val syncFeature: SyncFeature,
private val urlDecorator: SyncBarcodeDecorator,
) : ViewModel() {
private val command = Channel<Command>(1, DROP_OLDEST)
fun commands(): Flow<Command> = command.receiveAsFlow()

private var barcodeContents: String? = null
private var barcodeContents: BarcodeContents? = null

private val viewState = MutableStateFlow(ViewState())
fun viewState(): Flow<ViewState> = viewState.onStart {
Expand Down Expand Up @@ -108,17 +112,23 @@ class SyncWithAnotherActivityViewModel @Inject constructor(
}

private suspend fun showQRCode() {
val shouldExchangeKeysToSyncAnotherDevice = syncFeature.exchangeKeysToSyncWithAnotherDevice().isEnabled()

if (!shouldExchangeKeysToSyncAnotherDevice) {
syncAccountRepository.getRecoveryCode()
// get the code as a Result, and pair it with the type of code we're dealing with
val (result, codeType) = if (!syncFeature.exchangeKeysToSyncWithAnotherDevice().isEnabled()) {
Pair(syncAccountRepository.getRecoveryCode(), Recovery)
} else {
syncAccountRepository.generateExchangeInvitationCode()
}.onSuccess { connectQR ->
barcodeContents = connectQR
Pair(syncAccountRepository.generateExchangeInvitationCode(), Exchange)
}

result.onSuccess { code ->
// wrap the code inside a URL if feature flag allows it
val barcodeString = urlDecorator.decorateCode(code, codeType)

barcodeContents = BarcodeContents(underlyingCode = code, barcodeString = barcodeString)

val qrBitmap = withContext(dispatchers.io()) {
qrEncoder.encodeAsBitmap(connectQR, dimen.qrSizeSmall, dimen.qrSizeSmall)
qrEncoder.encodeAsBitmap(barcodeString, dimen.qrSizeSmall, dimen.qrSizeSmall)
}

viewState.emit(viewState.value.copy(qrCodeBitmap = qrBitmap))
}.onFailure {
command.send(Command.FinishWithError)
Expand All @@ -133,8 +143,8 @@ class SyncWithAnotherActivityViewModel @Inject constructor(

fun onCopyCodeClicked() {
viewModelScope.launch(dispatchers.io()) {
barcodeContents?.let { code ->
clipboard.copyToClipboard(code)
barcodeContents?.let { contents ->
clipboard.copyToClipboard(contents.underlyingCode)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

want to make sure we use the underlying code and not the URL

command.send(ShowMessage(string.sync_code_copied_message))
} ?: command.send(FinishWithError)
}
Expand Down Expand Up @@ -299,4 +309,17 @@ class SyncWithAnotherActivityViewModel @Inject constructor(
fun onUserAskedToSwitchAccount() {
syncPixels.fireAskUserToSwitchAccount()
}

private data class BarcodeContents(
/**
* The underlying code that was encoded in the barcode.
* It's possible this is different from the barcode string which might contain extra data
*/
val underlyingCode: String,

/**
* The string that was encoded in the barcode.
*/
val barcodeString: String,
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/*
* Copyright (c) 2025 DuckDuckGo
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.duckduckgo.sync.impl.ui.qrcode

import com.duckduckgo.common.utils.DispatcherProvider
import com.duckduckgo.di.scopes.AppScope
import com.duckduckgo.sync.impl.SyncDeviceIds
import com.duckduckgo.sync.impl.SyncFeature
import com.duckduckgo.sync.impl.applyUrlSafetyFromB64
import com.duckduckgo.sync.impl.ui.qrcode.SyncBarcodeDecorator.CodeType
import com.duckduckgo.sync.impl.ui.qrcode.SyncBarcodeDecorator.CodeType.Connect
import com.duckduckgo.sync.impl.ui.qrcode.SyncBarcodeDecorator.CodeType.Exchange
import com.squareup.anvil.annotations.ContributesBinding
import java.net.URLEncoder
import javax.inject.Inject
import kotlinx.coroutines.withContext
import timber.log.Timber

interface SyncBarcodeDecorator {

/**
* Will accept a sync code and potentially modify it depending on feature flagged capabilities.
* Not all code types can be modified so the type of code must be provided.
*
* @param originalCodeB64Encoded the original base64-encoded code to be potentially modified.
* @param codeType the type of code to be decorated
*/
suspend fun decorateCode(
originalCodeB64Encoded: String,
codeType: CodeType,
): String

sealed interface CodeType {
data object Connect : CodeType
data object Exchange : CodeType
data object Recovery : CodeType
}
}

@ContributesBinding(AppScope::class)
class SyncBarcodeUrlDecorator @Inject constructor(
private val syncDeviceIds: SyncDeviceIds,
private val syncFeature: SyncFeature,
private val dispatchers: DispatcherProvider,
) : SyncBarcodeDecorator {

override suspend fun decorateCode(originalCodeB64Encoded: String, codeType: CodeType): String {
return withContext(dispatchers.io()) {
// can only wrap codes in a URL if the feature is enabled
if (!urlFeatureSupported()) {
return@withContext originalCodeB64Encoded
}

// only `Connect` and `Exchange` codes can be wrapped in a URL
when (codeType) {
is Connect -> originalCodeB64Encoded.wrapInUrl()
is Exchange -> originalCodeB64Encoded.wrapInUrl()
else -> originalCodeB64Encoded
}
}.also {
Timber.v("Sync: code to include in the barcode is $it")
}
}

private fun urlFeatureSupported(): Boolean {
return syncFeature.syncSetupBarcodeIsUrlBased().isEnabled()
}

private fun String.wrapInUrl(): String {
return kotlin.runCatching {
val urlSafeCode = this.applyUrlSafetyFromB64()
SyncBarcodeUrl(webSafeB64EncodedCode = urlSafeCode, urlEncodedDeviceName = getDeviceName()).asUrl()
}.getOrElse {
Timber.w("Sync-url: Failed to encode string for use inside a URL; returning original code")
this
}
}

private fun getDeviceName(): String {
val deviceName = syncDeviceIds.deviceName()
return URLEncoder.encode(deviceName, "UTF-8")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/*
* Copyright (c) 2025 DuckDuckGo
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.duckduckgo.sync.impl.ui.qrcode

import androidx.core.net.toUri

data class SyncBarcodeUrl(
val webSafeB64EncodedCode: String,
val urlEncodedDeviceName: String? = null,
) {

fun asUrl(): String {
val sb = StringBuilder(URL_BASE)
.append("&")
.append(CODE_PARAM).append("=").append(webSafeB64EncodedCode)

if (urlEncodedDeviceName?.isNotBlank() == true) {
sb.append("&")
sb.append(DEVICE_NAME_PARAM).append("=").append(urlEncodedDeviceName)
}

return sb.toString()
}

companion object {
const val URL_BASE = "https://duckduckgo.com/sync/pairing/#"
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this might need to change in a later PR because we’ll need to be able to access this from app module most likely. good for now though, just FYI it’ll probably change.

private const val CODE_PARAM = "code"
private const val DEVICE_NAME_PARAM = "deviceName"

fun parseUrl(fullSyncUrl: String): SyncBarcodeUrl? {
return kotlin.runCatching {
if (!fullSyncUrl.startsWith(URL_BASE)) {
return null
}

val uri = fullSyncUrl.toUri()
val fragment = uri.fragment ?: return null
val fragmentParts = fragment.split("&")

val code = fragmentParts
.find { it.startsWith("code=") }
?.substringAfter("code=")
?: return null

val deviceName = fragmentParts
.find { it.startsWith("deviceName=") }
?.substringAfter("deviceName=")

SyncBarcodeUrl(webSafeB64EncodedCode = code, urlEncodedDeviceName = deviceName)
}.getOrNull()
}
}
}
Loading
Loading