diff --git a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/EncodingExtension.kt b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/EncodingExtension.kt index 6565bce1e89f..1e4bf4c830c6 100644 --- a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/EncodingExtension.kt +++ b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/EncodingExtension.kt @@ -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 + } +} diff --git a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/SyncFeature.kt b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/SyncFeature.kt index be105cff5e67..dd2f351a79b2 100644 --- a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/SyncFeature.kt +++ b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/SyncFeature.kt @@ -53,4 +53,7 @@ interface SyncFeature { @Toggle.DefaultValue(true) fun automaticallyUpdateSyncSettings(): Toggle + + @Toggle.DefaultValue(false) + fun syncSetupBarcodeIsUrlBased(): Toggle } diff --git a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncConnectViewModel.kt b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncConnectViewModel.kt index 92075b6ce0a7..6fa7f4d85517 100644 --- a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncConnectViewModel.kt +++ b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncConnectViewModel.kt @@ -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 @@ -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(1, DROP_OLDEST) fun commands(): Flow = command.receiveAsFlow() @@ -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 { diff --git a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncInternalSettingsViewModel.kt b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncInternalSettingsViewModel.kt index 2a725bc38fad..0e18c0fd99df 100644 --- a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncInternalSettingsViewModel.kt +++ b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncInternalSettingsViewModel.kt @@ -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 @@ -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(1, BufferOverflow.DROP_OLDEST) @@ -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)) diff --git a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncWithAnotherActivityViewModel.kt b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncWithAnotherActivityViewModel.kt index 66ed97db5e69..58c7ba03f34f 100644 --- a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncWithAnotherActivityViewModel.kt +++ b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncWithAnotherActivityViewModel.kt @@ -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 @@ -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(1, DROP_OLDEST) fun commands(): Flow = command.receiveAsFlow() - private var barcodeContents: String? = null + private var barcodeContents: BarcodeContents? = null private val viewState = MutableStateFlow(ViewState()) fun viewState(): Flow = viewState.onStart { @@ -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) @@ -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) command.send(ShowMessage(string.sync_code_copied_message)) } ?: command.send(FinishWithError) } @@ -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, + ) } diff --git a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/qrcode/SyncBarcodeDecorator.kt b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/qrcode/SyncBarcodeDecorator.kt new file mode 100644 index 000000000000..225044da075d --- /dev/null +++ b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/qrcode/SyncBarcodeDecorator.kt @@ -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") + } +} diff --git a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/qrcode/SyncBarcodeUrl.kt b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/qrcode/SyncBarcodeUrl.kt new file mode 100644 index 000000000000..7fe5778872c4 --- /dev/null +++ b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/qrcode/SyncBarcodeUrl.kt @@ -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() + } + } +} diff --git a/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/EncodingExtensionTest.kt b/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/EncodingExtensionTest.kt new file mode 100644 index 000000000000..0565a822484c --- /dev/null +++ b/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/EncodingExtensionTest.kt @@ -0,0 +1,78 @@ +package com.duckduckgo.sync.impl + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class EncodingExtensionTest { + + /** + * For reference, here are example strings and their base-64 encoded forms (regular and URL-safe versions) + * + * String | Base64 | Base64 URL Safe + * noSPECIALchars3 | bm9TUEVDSUFMY2hhcnMz | bm9TUEVDSUFMY2hhcnMz (no modifications) + * 1paddingNeeded | MXBhZGRpbmdOZWVkZWQ= | MXBhZGRpbmdOZWVkZWQ (padding stripped) + * 2 padding needed | MiBwYWRkaW5nIG5lZWRlZA== | MiBwYWRkaW5nIG5lZWRlZA (double padding stripped) + * AA> | QUE+ | QUE- (plus replaced with minus) + * AA? | QUE/ | QUE_ (forward slash replaced with underscore) + */ + + @Test + fun whenEmptyStringInThenEmptyStringOut() { + val input = "" + val expectedOutput = "" + assertEquals(expectedOutput, input.applyUrlSafetyFromB64()) + } + + @Test + fun whenNoSpecialCharactersThenEncodedStringIsUnchanged() { + val input = "noSPECIALchars3" + val expectedOutput = "bm9TUEVDSUFMY2hhcnMz" + val normalB64Encoding = input.encodeB64() + assertEquals(expectedOutput, normalB64Encoding) + assertEquals(expectedOutput, normalB64Encoding.applyUrlSafetyFromB64()) + } + + @Test + fun whenHasSinglePaddingCharacterThenPaddingIsTrimmed() { + val input = "1paddingNeeded" + val expectedOutput = "MXBhZGRpbmdOZWVkZWQ" + val normalB64Encoding = input.encodeB64() + assertEquals("$expectedOutput=", normalB64Encoding) + assertEquals(expectedOutput, normalB64Encoding.applyUrlSafetyFromB64()) + } + + @Test + fun whenHasDoublePaddingCharactersThenPaddingIsTrimmed() { + val input = "2 padding needed" + val expectedOutput = "MiBwYWRkaW5nIG5lZWRlZA" + val normalB64Encoding = input.encodeB64() + assertEquals("$expectedOutput==", normalB64Encoding) + assertEquals(expectedOutput, normalB64Encoding.applyUrlSafetyFromB64()) + } + + @Test + fun whenInputContainsAPlusThenReplacedWithMinus() { + val input = "AA>" + val expectedOutput = "QUE-" + assertEquals(expectedOutput, input.encodeB64().applyUrlSafetyFromB64()) + } + + @Test + fun whenInputContainsAForwardSlashThenReplacedWithUnderscore() { + val input = "AA?" + assertEquals("QUE_", input.encodeB64().applyUrlSafetyFromB64()) + } + + @Test + fun whenDecodedFormContainsAnUnderscoreThenReplacedWithForwardSlash() { + assertEquals("AA?", "QUE_".removeUrlSafetyToRestoreB64().decodeB64()) + } + + @Test + fun whenDecodedFormContainsAMinusThenReplacedWithPlus() { + assertEquals("AA>", "QUE+".removeUrlSafetyToRestoreB64().decodeB64()) + } +} diff --git a/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/ui/SyncConnectViewModelTest.kt b/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/ui/SyncConnectViewModelTest.kt index 5b35ec97e487..c583590dfe32 100644 --- a/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/ui/SyncConnectViewModelTest.kt +++ b/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/ui/SyncConnectViewModelTest.kt @@ -32,6 +32,8 @@ import com.duckduckgo.sync.impl.SyncAccountRepository import com.duckduckgo.sync.impl.pixels.SyncPixels import com.duckduckgo.sync.impl.ui.SyncConnectViewModel.Command import com.duckduckgo.sync.impl.ui.SyncConnectViewModel.Command.LoginSuccess +import com.duckduckgo.sync.impl.ui.qrcode.SyncBarcodeDecorator +import com.duckduckgo.sync.impl.ui.qrcode.SyncBarcodeDecorator.CodeType import kotlinx.coroutines.test.runTest import org.junit.Assert import org.junit.Assert.assertTrue @@ -55,6 +57,14 @@ class SyncConnectViewModelTest { private val clipboard: Clipboard = mock() private val qrEncoder: QREncoder = mock() private val syncPixels: SyncPixels = mock() + private val noOpSyncCodeDecorator: SyncBarcodeDecorator = object : SyncBarcodeDecorator { + override suspend fun decorateCode( + originalCodeB64Encoded: String, + codeType: CodeType, + ): String { + return originalCodeB64Encoded + } + } private val testee = SyncConnectViewModel( syncRepository, @@ -62,6 +72,7 @@ class SyncConnectViewModelTest { clipboard, syncPixels, coroutineTestRule.testDispatcherProvider, + noOpSyncCodeDecorator, ) @Test diff --git a/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/ui/SyncWithAnotherDeviceViewModelTest.kt b/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/ui/SyncWithAnotherDeviceViewModelTest.kt index 5ecfe0dd5ddd..9f75d98cb725 100644 --- a/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/ui/SyncWithAnotherDeviceViewModelTest.kt +++ b/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/ui/SyncWithAnotherDeviceViewModelTest.kt @@ -49,9 +49,12 @@ import com.duckduckgo.sync.impl.ui.SyncWithAnotherActivityViewModel.Command import com.duckduckgo.sync.impl.ui.SyncWithAnotherActivityViewModel.Command.AskToSwitchAccount import com.duckduckgo.sync.impl.ui.SyncWithAnotherActivityViewModel.Command.LoginSuccess 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 import kotlinx.coroutines.test.runTest import org.junit.Assert import org.junit.Assert.assertTrue +import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -77,6 +80,7 @@ class SyncWithAnotherDeviceViewModelTest { this.seamlessAccountSwitching().setRawStoredState(State(true)) this.exchangeKeysToSyncWithAnotherDevice().setRawStoredState(State(false)) } + private val urlDecorator: SyncBarcodeDecorator = mock() private val testee = SyncWithAnotherActivityViewModel( syncRepository, @@ -85,8 +89,14 @@ class SyncWithAnotherDeviceViewModelTest { syncPixels, coroutineTestRule.testDispatcherProvider, syncFeature, + urlDecorator, ) + @Before + fun setup() = runTest { + whenever(urlDecorator.decorateCode(any(), any())).thenAnswer { it.arguments[0] as String } + } + @Test fun whenScreenStartedThenShowQRCode() = runTest { val bitmap = TestSyncFixtures.qrBitmap() @@ -110,6 +120,17 @@ class SyncWithAnotherDeviceViewModelTest { } } + @Test + fun whenScreenStartedWithExchangeCodeThenUrlDecoratorIsInvokedWithCorrectCodeType() = runTest { + configureExchangeKeysSupported() + + testee.viewState().test { + val viewState = awaitItem() + verify(urlDecorator).decorateCode(any(), eq(CodeType.Exchange)) + cancelAndIgnoreRemainingEvents() + } + } + @Test fun whenGenerateRecoveryQRFailsThenFinishWithError() = runTest { whenever(syncRepository.getRecoveryCode()).thenReturn(Result.Error(reason = "error")) diff --git a/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/ui/qrcode/SyncBarcodeUrlDecoratorTest.kt b/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/ui/qrcode/SyncBarcodeUrlDecoratorTest.kt new file mode 100644 index 000000000000..121fbfc2c80d --- /dev/null +++ b/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/ui/qrcode/SyncBarcodeUrlDecoratorTest.kt @@ -0,0 +1,88 @@ +package com.duckduckgo.sync.impl.ui.qrcode + +import android.annotation.SuppressLint +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory +import com.duckduckgo.feature.toggles.api.Toggle.State +import com.duckduckgo.sync.impl.SyncDeviceIds +import com.duckduckgo.sync.impl.SyncFeature +import com.duckduckgo.sync.impl.ui.qrcode.SyncBarcodeDecorator.CodeType.Connect +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.qrcode.SyncBarcodeUrl.Companion.URL_BASE +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +@SuppressLint("DenyListedApi") +@RunWith(AndroidJUnit4::class) +class SyncBarcodeUrlDecoratorTest { + + @get:Rule + val coroutineTestRule: CoroutineTestRule = CoroutineTestRule() + + private val feature = FakeFeatureToggleFactory.create(SyncFeature::class.java) + private val syncDeviceIds: SyncDeviceIds = mock() + + private val testee = SyncBarcodeUrlDecorator( + syncFeature = feature, + dispatchers = coroutineTestRule.testDispatcherProvider, + syncDeviceIds = syncDeviceIds, + ) + + @Before + fun setup() { + whenever(syncDeviceIds.deviceName()).thenReturn("iPhone") + } + + @Test + fun whenFeatureDisabledThenUrlNeverReturned() = runTest { + configureFeatureState(enabled = false) + testee.decorateCode(B64_ENCODED_PLAIN_CODE, Exchange).assertIsNotUrlAndOnlyCode(B64_ENCODED_PLAIN_CODE) + testee.decorateCode(B64_ENCODED_PLAIN_CODE, Recovery).assertIsNotUrlAndOnlyCode(B64_ENCODED_PLAIN_CODE) + testee.decorateCode(B64_ENCODED_PLAIN_CODE, Connect).assertIsNotUrlAndOnlyCode(B64_ENCODED_PLAIN_CODE) + } + + @Test + fun whenFeatureEnabledThenExchangeCodeIsUrlWrapped() = runTest { + configureFeatureState(enabled = true) + testee.decorateCode(B64_ENCODED_PLAIN_CODE, Exchange).assertIsUrlContainingCode(B64_URL_SAFE_ENCODED_PLAIN_CODE) + } + + @Test + fun whenFeatureEnabledThenConnectCodeIsUrlWrapped() = runTest { + configureFeatureState(enabled = true) + testee.decorateCode(B64_ENCODED_PLAIN_CODE, Connect).assertIsUrlContainingCode(B64_URL_SAFE_ENCODED_PLAIN_CODE) + } + + @Test + fun whenFeatureEnabledThenRecoveryCodeIsNotUrlWrapped() = runTest { + configureFeatureState(enabled = true) + testee.decorateCode(B64_ENCODED_PLAIN_CODE, Recovery).assertIsNotUrlAndOnlyCode(B64_ENCODED_PLAIN_CODE) + } + + private fun configureFeatureState(enabled: Boolean) { + feature.syncSetupBarcodeIsUrlBased().setRawStoredState(State(enable = enabled)) + } + + private companion object { + private const val B64_ENCODED_PLAIN_CODE = "QUJDLTEyMw==" + private const val B64_URL_SAFE_ENCODED_PLAIN_CODE = "QUJDLTEyMw" + } + + private fun String.assertIsUrlContainingCode(code: String) { + assertTrue("Expected $this to be a sync pairing URL", this.startsWith(URL_BASE)) + assertTrue("Expected $this to contain code $code", this.contains(code)) + } + + private fun String.assertIsNotUrlAndOnlyCode(expectedCode: String) { + assertEquals("Expected $this to be a plain code exactly matching $expectedCode", expectedCode, this) + } +} diff --git a/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/ui/qrcode/SyncBarcodeUrlTest.kt b/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/ui/qrcode/SyncBarcodeUrlTest.kt new file mode 100644 index 000000000000..13c99eea3ec2 --- /dev/null +++ b/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/ui/qrcode/SyncBarcodeUrlTest.kt @@ -0,0 +1,53 @@ +package com.duckduckgo.sync.impl.ui.qrcode + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class SyncBarcodeUrlTest { + + @Test + fun whenDeviceNamePopulatedThenIncludedInUrl() { + val url = SyncBarcodeUrl(webSafeB64EncodedCode = "ABC-123", urlEncodedDeviceName = "iPhone") + assertEquals("${URL_BASE}code=ABC-123&deviceName=iPhone", url.asUrl()) + } + + @Test + fun whenDeviceNameBlankStringThenNotIncludedInUrl() { + val url = SyncBarcodeUrl(webSafeB64EncodedCode = "ABC-123", urlEncodedDeviceName = " ") + assertEquals("${URL_BASE}code=ABC-123", url.asUrl()) + } + + @Test + fun whenDeviceNameEmptyStringThenNotIncludedInUrl() { + val url = SyncBarcodeUrl(webSafeB64EncodedCode = "ABC-123", urlEncodedDeviceName = "") + assertEquals("${URL_BASE}code=ABC-123", url.asUrl()) + } + + @Test + fun whenDeviceNameNullThenNotIncludedInUrl() { + val url = SyncBarcodeUrl(webSafeB64EncodedCode = "ABC-123", urlEncodedDeviceName = null) + assertEquals("${URL_BASE}code=ABC-123", url.asUrl()) + } + + @Test + fun whenCodeProvidedThenIsSuccessfullyExtracted() { + val url = SyncBarcodeUrl.parseUrl("${URL_BASE}code=ABC-123") + assertNotNull(url) + assertEquals("ABC-123", url!!.webSafeB64EncodedCode) + } + + @Test + fun whenCodeMissingProvidedThenIsNull() { + val url = SyncBarcodeUrl.parseUrl(URL_BASE) + assertNull(url) + } + + companion object { + private const val URL_BASE = "https://duckduckgo.com/sync/pairing/#&" + } +}