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

Merged
merged 2 commits into from
May 13, 2025
Merged
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 @@ -37,7 +37,9 @@ import com.duckduckgo.sync.impl.CodeType.UNKNOWN
import com.duckduckgo.sync.impl.ExchangeResult.*
import com.duckduckgo.sync.impl.Result.Error
import com.duckduckgo.sync.impl.Result.Success
import com.duckduckgo.sync.impl.SyncAccountRepository.AuthCode
import com.duckduckgo.sync.impl.pixels.*
import com.duckduckgo.sync.impl.ui.qrcode.SyncBarcodeUrlWrapper
import com.duckduckgo.sync.store.*
import com.squareup.anvil.annotations.*
import com.squareup.moshi.*
Expand All @@ -59,16 +61,28 @@ interface SyncAccountRepository {
fun logout(deviceId: String): Result<Boolean>
fun deleteAccount(): Result<Boolean>
fun latestToken(): String
fun getRecoveryCode(): Result<String>
fun getRecoveryCode(): Result<AuthCode>
fun getThisConnectedDevice(): ConnectedDevice?
fun getConnectedDevices(): Result<List<ConnectedDevice>>
fun getConnectQR(): Result<String>
fun getConnectQR(): Result<AuthCode>
fun pollConnectionKeys(): Result<Boolean>
fun generateExchangeInvitationCode(): Result<String>
fun generateExchangeInvitationCode(): Result<AuthCode>
fun pollSecondDeviceExchangeAcknowledgement(): Result<Boolean>
fun pollForRecoveryCodeAndLogin(): Result<ExchangeResult>
fun renameDevice(device: ConnectedDevice): Result<Boolean>
fun logoutAndJoinNewAccount(stringCode: String): Result<Boolean>

data class AuthCode(
/**
* A code that is suitable for displaying in a QR code.
*/
val qrCode: String,

/**
* Just the code (b64-encoded)
*/
val rawCode: String,
)
}

@ContributesBinding(AppScope::class)
Expand All @@ -85,6 +99,7 @@ class AppSyncAccountRepository @Inject constructor(
private val dispatcherProvider: DispatcherProvider,
private val syncFeature: SyncFeature,
private val deviceKeyGenerator: DeviceKeyGenerator,
private val syncCodeUrlWrapper: SyncBarcodeUrlWrapper,
) : SyncAccountRepository {

/**
Expand Down Expand Up @@ -300,13 +315,16 @@ class AppSyncAccountRepository @Inject constructor(
)
}

override fun getRecoveryCode(): Result<String> {
override fun getRecoveryCode(): Result<AuthCode> {
val primaryKey = syncStore.primaryKey ?: return Error(reason = "Get Recovery Code: Not existing primary Key").alsoFireAccountErrorPixel()
val userID = syncStore.userId ?: return Error(reason = "Get Recovery Code: Not existing userId").alsoFireAccountErrorPixel()
return Success(Adapters.recoveryCodeAdapter.toJson(LinkCode(RecoveryCode(primaryKey, userID))).encodeB64())
val b64Encoded = Adapters.recoveryCodeAdapter.toJson(LinkCode(RecoveryCode(primaryKey, userID))).encodeB64()

// no additional formatting on the QR code for recovery codes, so qrCode always identical to rawCode
return Success(AuthCode(qrCode = b64Encoded, rawCode = b64Encoded))
}

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

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

return kotlin.runCatching {
val code = Adapters.invitationCodeAdapter.toJson(invitationWrapper).encodeB64()
Success(code)
val b64Encoded = Adapters.invitationCodeAdapter.toJson(invitationWrapper).encodeB64()
val qrCode = if (syncFeature.syncSetupBarcodeIsUrlBased().isEnabled()) {
syncCodeUrlWrapper.wrapCodeInUrl(b64Encoded)
} else {
b64Encoded
}
Success(AuthCode(qrCode = qrCode, rawCode = b64Encoded))
}.getOrElse {
Error(code = EXCHANGE_FAILED.code, reason = "Error generating invitation code").alsoFireAccountErrorPixel()
}
}

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

return Success(linkingQRCode.encodeB64())
val b64Encoded = linkingQRCode.encodeB64()
val qrCode = if (syncFeature.syncSetupBarcodeIsUrlBased().isEnabled()) {
syncCodeUrlWrapper.wrapCodeInUrl(b64Encoded)
} else {
b64Encoded
}
return Success(AuthCode(qrCode = qrCode, rawCode = b64Encoded))
}

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

// recovery code comes b64 encoded, so we need to decode it, then encrypt, which automatically b64 encodes the encrypted form
return kotlin.runCatching {
val json = recoveryCode.data.decodeB64()
val json = recoveryCode.data.rawCode.decodeB64()
val encryptedJson = nativeLib.seal(json, publicKey)
syncApi.sendEncryptedMessage(keyId, encryptedJson)
}.getOrElse {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ import com.duckduckgo.anvil.annotations.ContributesRemoteFeature
import com.duckduckgo.di.scopes.AppScope
import com.duckduckgo.feature.toggles.api.Toggle
import com.duckduckgo.feature.toggles.api.Toggle.DefaultFeatureValue
import com.duckduckgo.feature.toggles.api.Toggle.InternalAlwaysEnabled

@ContributesRemoteFeature(
scope = AppScope::class,
Expand Down Expand Up @@ -54,4 +53,7 @@ interface SyncFeature {

@Toggle.DefaultValue(DefaultFeatureValue.TRUE)
fun automaticallyUpdateSyncSettings(): Toggle

@Toggle.DefaultValue(DefaultFeatureValue.FALSE)
fun syncSetupBarcodeIsUrlBased(): Toggle
}
Original file line number Diff line number Diff line change
Expand Up @@ -310,9 +310,9 @@ class SyncActivityViewModel @Inject constructor(

fun generateRecoveryCode(viewContext: Context) {
viewModelScope.launch(dispatchers.io()) {
syncAccountRepository.getRecoveryCode().onSuccess { recoveryCodeB64 ->
syncAccountRepository.getRecoveryCode().onSuccess { authCode ->
kotlin.runCatching {
recoveryCodePDF.generateAndStoreRecoveryCodePDF(viewContext, recoveryCodeB64)
recoveryCodePDF.generateAndStoreRecoveryCodePDF(viewContext, authCode.rawCode)
}.onSuccess { generateRecoveryCodePDF ->
command.send(RecoveryCodePDFSuccess(generateRecoveryCodePDF))
}.onFailure {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ 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 javax.inject.Inject
import kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
Expand Down Expand Up @@ -127,9 +127,9 @@ class SyncConnectViewModel @Inject constructor(

private suspend fun showQRCode() {
syncAccountRepository.getConnectQR()
.onSuccess { connectQR ->
.onSuccess { code ->
val qrBitmap = withContext(dispatchers.io()) {
qrEncoder.encodeAsBitmap(connectQR, dimen.qrSizeSmall, dimen.qrSizeSmall)
qrEncoder.encodeAsBitmap(code.qrCode, dimen.qrSizeSmall, dimen.qrSizeSmall)
}
viewState.emit(viewState.value.copy(qrCodeBitmap = qrBitmap))
}.onFailure {
Expand All @@ -146,8 +146,8 @@ class SyncConnectViewModel @Inject constructor(
fun onCopyCodeClicked() {
viewModelScope.launch(dispatchers.io()) {
syncAccountRepository.getConnectQR().getOrNull()?.let { code ->
Timber.d("Sync: recovery available for sharing manually: $code")
clipboard.copyToClipboard(code)
Timber.d("Sync: code available for sharing manually: $code")
clipboard.copyToClipboard(code.rawCode)
command.send(ShowMessage(R.string.sync_code_copied_message))
} ?: command.send(FinishWithError)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ constructor(
fun onShowQRClicked() {
viewModelScope.launch(dispatchers.io()) {
val recoveryCode = syncAccountRepository.getRecoveryCode().getOrNull() ?: return@launch
command.send(ShowQR(recoveryCode))
command.send(ShowQR(recoveryCode.qrCode))
}
}

Expand Down Expand Up @@ -242,7 +242,9 @@ constructor(
return@launch
}

is Success -> qrCodeResult.data
is Success -> {
qrCodeResult.data.qrCode
}
}
updateViewState()
command.send(ShowQR(qrCode))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import com.duckduckgo.sync.impl.R.string
import com.duckduckgo.sync.impl.Result.Error
import com.duckduckgo.sync.impl.Result.Success
import com.duckduckgo.sync.impl.SyncAccountRepository
import com.duckduckgo.sync.impl.SyncAccountRepository.AuthCode
import com.duckduckgo.sync.impl.SyncFeature
import com.duckduckgo.sync.impl.onFailure
import com.duckduckgo.sync.impl.onSuccess
Expand Down Expand Up @@ -77,7 +78,7 @@ class SyncWithAnotherActivityViewModel @Inject constructor(
private val command = Channel<Command>(1, DROP_OLDEST)
fun commands(): Flow<Command> = command.receiveAsFlow()

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

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

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

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

result.onSuccess { authCode ->
barcodeContents = authCode

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

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

fun onCopyCodeClicked() {
viewModelScope.launch(dispatchers.io()) {
barcodeContents?.let { code ->
clipboard.copyToClipboard(code)
barcodeContents?.let { contents ->
clipboard.copyToClipboard(contents.rawCode)
command.send(ShowMessage(string.sync_code_copied_message))
} ?: command.send(FinishWithError)
}
Expand Down
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/#"
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