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/SyncAccountRepository.kt b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/SyncAccountRepository.kt index 0f5df5b3a9d9..328fd06842c5 100644 --- a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/SyncAccountRepository.kt +++ b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/SyncAccountRepository.kt @@ -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.* @@ -59,16 +61,28 @@ interface SyncAccountRepository { fun logout(deviceId: String): Result fun deleteAccount(): Result fun latestToken(): String - fun getRecoveryCode(): Result + fun getRecoveryCode(): Result fun getThisConnectedDevice(): ConnectedDevice? fun getConnectedDevices(): Result> - fun getConnectQR(): Result + fun getConnectQR(): Result fun pollConnectionKeys(): Result - fun generateExchangeInvitationCode(): Result + fun generateExchangeInvitationCode(): Result fun pollSecondDeviceExchangeAcknowledgement(): Result fun pollForRecoveryCodeAndLogin(): Result fun renameDevice(device: ConnectedDevice): Result fun logoutAndJoinNewAccount(stringCode: String): Result + + 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) @@ -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 { /** @@ -300,13 +315,16 @@ class AppSyncAccountRepository @Inject constructor( ) } - override fun getRecoveryCode(): Result { + override fun getRecoveryCode(): Result { 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 { + override fun generateExchangeInvitationCode(): Result { // Sync: InviteFlow - A (https://app.asana.com/0/72649045549333/1209571867429615) Timber.d("Sync-exchange: InviteFlow - A. Generating invitation code") @@ -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 { + override fun getConnectQR(): Result { val prepareForConnect = kotlin.runCatching { nativeLib.prepareForConnect().also { it.checkResult("Creating ConnectQR code failed") @@ -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 { @@ -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 { 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 37ebe28e1f9e..9946c5617101 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 @@ -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, @@ -54,4 +53,7 @@ interface SyncFeature { @Toggle.DefaultValue(DefaultFeatureValue.TRUE) fun automaticallyUpdateSyncSettings(): Toggle + + @Toggle.DefaultValue(DefaultFeatureValue.FALSE) + fun syncSetupBarcodeIsUrlBased(): Toggle } diff --git a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncActivityViewModel.kt b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncActivityViewModel.kt index bb36f982ad20..93af406a6a62 100644 --- a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncActivityViewModel.kt +++ b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/SyncActivityViewModel.kt @@ -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 { 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..38d82709532d 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,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 @@ -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 { @@ -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) } 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..7339f0e0e261 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 @@ -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)) } } @@ -242,7 +242,9 @@ constructor( return@launch } - is Success -> qrCodeResult.data + is Success -> { + qrCodeResult.data.qrCode + } } 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..bf3699a8a1af 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 @@ -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 @@ -77,7 +78,7 @@ class SyncWithAnotherActivityViewModel @Inject constructor( private val command = Channel(1, DROP_OLDEST) fun commands(): Flow = command.receiveAsFlow() - private var barcodeContents: String? = null + private var barcodeContents: AuthCode? = null private val viewState = MutableStateFlow(ViewState()) fun viewState(): Flow = viewState.onStart { @@ -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) @@ -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) } 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/main/java/com/duckduckgo/sync/impl/ui/qrcode/SyncBarcodeUrlWrapper.kt b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/qrcode/SyncBarcodeUrlWrapper.kt new file mode 100644 index 000000000000..cdf72e884cd3 --- /dev/null +++ b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/qrcode/SyncBarcodeUrlWrapper.kt @@ -0,0 +1,62 @@ +/* + * 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.di.scopes.AppScope +import com.duckduckgo.sync.impl.SyncDeviceIds +import com.duckduckgo.sync.impl.applyUrlSafetyFromB64 +import com.squareup.anvil.annotations.ContributesBinding +import java.net.URLEncoder +import javax.inject.Inject +import timber.log.Timber + +interface SyncBarcodeUrlWrapper { + + /** + * Will accept a sync code and format it so it's wrapped in a URL. + * + * @param originalCodeB64Encoded the original base64-encoded code to be modified. + */ + fun wrapCodeInUrl(originalCodeB64Encoded: String): String +} + +@ContributesBinding(AppScope::class) +class SyncBarcodeUrlUrlWrapper @Inject constructor( + private val syncDeviceIds: SyncDeviceIds, +) : SyncBarcodeUrlWrapper { + + override fun wrapCodeInUrl(originalCodeB64Encoded: String): String { + return originalCodeB64Encoded.wrapInUrl().also { + Timber.v("Sync: code to include in the barcode is $it") + } + } + + 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/setup/SaveRecoveryCodeViewModel.kt b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/setup/SaveRecoveryCodeViewModel.kt index 020703266c62..e02fcc544990 100644 --- a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/setup/SaveRecoveryCodeViewModel.kt +++ b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ui/setup/SaveRecoveryCodeViewModel.kt @@ -66,7 +66,7 @@ class SaveRecoveryCodeViewModel @Inject constructor( if (syncAccountRepository.isSignedIn()) { syncAccountRepository.getRecoveryCode().getOrNull()?.let { recoveryCode -> val newState = SignedIn( - b64RecoveryCode = recoveryCode, + b64RecoveryCode = recoveryCode.rawCode, ) viewState.emit(ViewState(newState)) } ?: command.send(Command.FinishWithError) @@ -77,7 +77,7 @@ class SaveRecoveryCodeViewModel @Inject constructor( }.onSuccess { syncAccountRepository.getRecoveryCode().getOrNull()?.let { recoveryCode -> val newState = SignedIn( - b64RecoveryCode = recoveryCode, + b64RecoveryCode = recoveryCode.rawCode, ) viewState.emit(ViewState(newState)) } ?: command.send(Command.FinishWithError) @@ -115,8 +115,8 @@ class SaveRecoveryCodeViewModel @Inject constructor( fun onCopyCodeClicked() { viewModelScope.launch(dispatchers.io()) { - val recoveryCodeB64 = syncAccountRepository.getRecoveryCode().getOrNull() ?: return@launch - clipboard.copyToClipboard(recoveryCodeB64) + val authCode = syncAccountRepository.getRecoveryCode().getOrNull() ?: return@launch + clipboard.copyToClipboard(authCode.rawCode) command.send(ShowMessage(R.string.sync_code_copied_message)) } } @@ -130,9 +130,9 @@ class SaveRecoveryCodeViewModel @Inject constructor( fun generateRecoveryCode(viewContext: Context) { viewModelScope.launch(dispatchers.io()) { syncAccountRepository.getRecoveryCode() - .onSuccess { recoveryCodeB64 -> + .onSuccess { authCode -> kotlin.runCatching { - recoveryCodePDF.generateAndStoreRecoveryCodePDF(viewContext, recoveryCodeB64) + recoveryCodePDF.generateAndStoreRecoveryCodePDF(viewContext, authCode.rawCode) }.onSuccess { generateRecoveryCodePDF -> command.send(RecoveryCodePDFSuccess(generateRecoveryCodePDF)) }.onFailure { diff --git a/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/AppSyncAccountRepositoryTest.kt b/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/AppSyncAccountRepositoryTest.kt index e010aee5e27f..2eb3c0debfcf 100644 --- a/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/AppSyncAccountRepositoryTest.kt +++ b/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/AppSyncAccountRepositoryTest.kt @@ -73,7 +73,9 @@ import com.duckduckgo.sync.impl.AccountErrorCodes.INVALID_CODE import com.duckduckgo.sync.impl.AccountErrorCodes.LOGIN_FAILED 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.SyncPixels +import com.duckduckgo.sync.impl.ui.qrcode.SyncBarcodeUrlWrapper import com.duckduckgo.sync.store.SyncStore import com.squareup.moshi.Moshi import kotlinx.coroutines.test.TestScope @@ -115,6 +117,8 @@ class AppSyncAccountRepositoryTest { private lateinit var syncRepo: SyncAccountRepository + private val syncCodeUrlWrapper: SyncBarcodeUrlWrapper = mock() + @Before fun before() { syncRepo = AppSyncAccountRepository( @@ -128,7 +132,11 @@ class AppSyncAccountRepositoryTest { DefaultDispatcherProvider(), syncFeature, deviceKeyGenerator, + syncCodeUrlWrapper = syncCodeUrlWrapper, ) + + // passthrough by default (no modifications) + whenever(syncCodeUrlWrapper.wrapCodeInUrl(any())).thenAnswer { it.arguments[0] as String } } @Test @@ -601,7 +609,7 @@ class AppSyncAccountRepositoryTest { val result = syncRepo.getRecoveryCode() as Success - assertEquals(jsonRecoveryKeyEncoded, result.data) + assertEquals(jsonRecoveryKeyEncoded, result.data.rawCode) } @Test @@ -618,7 +626,7 @@ class AppSyncAccountRepositoryTest { val result = syncRepo.getConnectQR() as Success - assertEquals(jsonConnectKeyEncoded, result.data) + assertEquals(jsonConnectKeyEncoded, result.data.rawCode) } @Test @@ -782,6 +790,74 @@ class AppSyncAccountRepositoryTest { assertFalse(result) } + @Test + fun whenConnectCodeRetrievedItRespectsUrlBasedFeatureFlag() { + whenever(nativeLib.prepareForConnect()).thenReturn(connectKeys) + prepareToProvideDeviceIds() + val decoratedCode = "decorated" + whenever(syncCodeUrlWrapper.wrapCodeInUrl(any())).thenReturn(decoratedCode) + + configureUrlWrappedCodeFeatureFlagState(enabled = true).also { + val result = syncRepo.getConnectQR() as Success + assertEquals(jsonConnectKeyEncoded, result.data.rawCode) + assertEquals(decoratedCode, result.data.qrCode) + } + + configureUrlWrappedCodeFeatureFlagState(enabled = false).also { + val result = syncRepo.getConnectQR() as Success + assertEquals(jsonConnectKeyEncoded, result.data.rawCode) + assertEquals(jsonConnectKeyEncoded, result.data.qrCode) + } + } + + @Test + fun whenExchangeCodeRetrievedItRespectsUrlBasedFeatureFlag() { + prepareForExchangeSuccess() + whenever(deviceKeyGenerator.generate()).thenReturn(primaryDeviceKeyId) + + val encodedJsonExchange = jsonExchangeKey(primaryDeviceKeyId, validLoginKeys.primaryKey).encodeB64() + val decoratedCode = "decorated" + whenever(syncCodeUrlWrapper.wrapCodeInUrl(any())).thenReturn(decoratedCode) + + configureUrlWrappedCodeFeatureFlagState(enabled = true).also { + val result = syncRepo.generateExchangeInvitationCode() as Success + assertEquals(encodedJsonExchange, result.data.rawCode) + assertEquals(decoratedCode, result.data.qrCode) + } + + configureUrlWrappedCodeFeatureFlagState(enabled = false).also { + val result = syncRepo.generateExchangeInvitationCode() as Success + assertEquals(encodedJsonExchange, result.data.rawCode) + assertEquals(encodedJsonExchange, result.data.qrCode) + } + } + + @Test + fun whenRecoveryCodeRetrievedItRespectsUrlBasedFeatureFlag() { + whenever(syncStore.primaryKey).thenReturn(primaryKey) + whenever(syncStore.userId).thenReturn(userId) + + val decoratedCode = "decorated" + whenever(syncCodeUrlWrapper.wrapCodeInUrl(any())).thenReturn(decoratedCode) + + // even feature is enabled, recovery codes don't have their QR codes decorated + configureUrlWrappedCodeFeatureFlagState(enabled = true).also { + val result = syncRepo.getRecoveryCode() as Success + assertEquals(jsonRecoveryKeyEncoded, result.data.rawCode) + assertEquals(jsonRecoveryKeyEncoded, result.data.qrCode) + } + + configureUrlWrappedCodeFeatureFlagState(enabled = false).also { + val result = syncRepo.getRecoveryCode() as Success + assertEquals(jsonRecoveryKeyEncoded, result.data.rawCode) + assertEquals(jsonRecoveryKeyEncoded, result.data.qrCode) + } + } + + private fun configureUrlWrappedCodeFeatureFlagState(enabled: Boolean) { + syncFeature.syncSetupBarcodeIsUrlBased().setRawStoredState(State(enable = enabled)) + } + private fun prepareForLoginSuccess() { prepareForEncryption() whenever(syncDeviceIds.deviceId()).thenReturn(deviceId) @@ -814,9 +890,9 @@ class AppSyncAccountRepositoryTest { whenever(syncApi.sendEncryptedMessage(eq(otherDeviceKeyId), eq(encryptedExchangeCode))).thenReturn(Success(true)) } - private fun parseInvitationCodeJson(resultJson: Result): InvitationCodeWrapper { + private fun parseInvitationCodeJson(resultJson: Result): InvitationCodeWrapper { assertTrue(resultJson is Success) - return invitationCodeWrapperAdapter.fromJson(resultJson.getOrNull()?.decodeB64()!!)!! + return invitationCodeWrapperAdapter.fromJson(resultJson.getOrNull()?.rawCode!!.decodeB64())!! } private fun givenAuthenticatedDevice() { 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/SyncActivityViewModelTest.kt b/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/ui/SyncActivityViewModelTest.kt index c4b2a1f749a7..5392e9c02f9c 100644 --- a/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/ui/SyncActivityViewModelTest.kt +++ b/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/ui/SyncActivityViewModelTest.kt @@ -34,6 +34,7 @@ import com.duckduckgo.sync.impl.RecoveryCodePDF import com.duckduckgo.sync.impl.Result 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.SyncFeatureToggle import com.duckduckgo.sync.impl.auth.DeviceAuthenticator import com.duckduckgo.sync.impl.pixels.SyncPixels @@ -451,7 +452,8 @@ class SyncActivityViewModelTest { @Test fun whenUserClicksOnSaveRecoveryCodeThenEmitCheckIfUserHasPermissionCommand() = runTest { givenUserHasDeviceAuthentication(true) - whenever(syncAccountRepository.getRecoveryCode()).thenReturn(Result.Success(jsonRecoveryKeyEncoded)) + val authCodeToUse = AuthCode(qrCode = jsonRecoveryKeyEncoded, rawCode = "something else") + whenever(syncAccountRepository.getRecoveryCode()).thenReturn(Result.Success(authCodeToUse)) testee.commands().test { testee.onSaveRecoveryCodeClicked() val command = awaitItem() @@ -463,7 +465,8 @@ class SyncActivityViewModelTest { @Test fun whenUserClicksOnSaveRecoveryCodeWithoutDeviceAuthenticationThenEmitCommandRequestSetupAuthentication() = runTest { givenUserHasDeviceAuthentication(false) - whenever(syncAccountRepository.getRecoveryCode()).thenReturn(Result.Success(jsonRecoveryKeyEncoded)) + val authCodeToUse = AuthCode(qrCode = jsonRecoveryKeyEncoded, rawCode = "something else") + whenever(syncAccountRepository.getRecoveryCode()).thenReturn(Result.Success(authCodeToUse)) testee.commands().test { testee.onSaveRecoveryCodeClicked() val command = awaitItem() @@ -474,8 +477,9 @@ class SyncActivityViewModelTest { @Test fun whenGenerateRecoveryCodeThenGenerateFileAndEmitSuccessCommand() = runTest { - whenever(syncAccountRepository.getRecoveryCode()).thenReturn(Result.Success(jsonRecoveryKeyEncoded)) - whenever(recoveryPDF.generateAndStoreRecoveryCodePDF(any(), eq(jsonRecoveryKeyEncoded))).thenReturn(TestSyncFixtures.pdfFile()) + val authCodeToUse = AuthCode(qrCode = jsonRecoveryKeyEncoded, rawCode = "something else") + whenever(syncAccountRepository.getRecoveryCode()).thenReturn(Result.Success(authCodeToUse)) + whenever(recoveryPDF.generateAndStoreRecoveryCodePDF(any(), eq(authCodeToUse.rawCode))).thenReturn(TestSyncFixtures.pdfFile()) testee.commands().test { testee.generateRecoveryCode(mock()) @@ -636,7 +640,8 @@ class SyncActivityViewModelTest { private fun givenAuthenticatedUser() { whenever(syncAccountRepository.isSignedIn()).thenReturn(true) whenever(syncStateMonitor.syncState()).thenReturn(stateFlow.asStateFlow()) - whenever(syncAccountRepository.getRecoveryCode()).thenReturn(Result.Success(jsonRecoveryKeyEncoded)) + val authCodeToUse = AuthCode(qrCode = jsonRecoveryKeyEncoded, rawCode = "something else") + whenever(syncAccountRepository.getRecoveryCode()).thenReturn(Result.Success(authCodeToUse)) whenever(syncAccountRepository.getThisConnectedDevice()).thenReturn(connectedDevice) whenever(syncAccountRepository.getConnectedDevices()).thenReturn(Success(listOf(connectedDevice))) } 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..e3768d200005 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 @@ -29,6 +29,7 @@ import com.duckduckgo.sync.impl.Clipboard import com.duckduckgo.sync.impl.QREncoder import com.duckduckgo.sync.impl.Result import com.duckduckgo.sync.impl.SyncAccountRepository +import com.duckduckgo.sync.impl.SyncAccountRepository.AuthCode import com.duckduckgo.sync.impl.pixels.SyncPixels import com.duckduckgo.sync.impl.ui.SyncConnectViewModel.Command import com.duckduckgo.sync.impl.ui.SyncConnectViewModel.Command.LoginSuccess @@ -67,7 +68,8 @@ class SyncConnectViewModelTest { @Test fun whenScreenStartedThenShowQRCode() = runTest { val bitmap = TestSyncFixtures.qrBitmap() - whenever(syncRepository.getConnectQR()).thenReturn(Result.Success(jsonConnectKeyEncoded)) + val authCodeToUse = AuthCode(qrCode = jsonConnectKeyEncoded, rawCode = "something else") + whenever(syncRepository.getConnectQR()).thenReturn(Result.Success(authCodeToUse)) whenever(qrEncoder.encodeAsBitmap(eq(jsonConnectKeyEncoded), any(), any())).thenReturn(bitmap) whenever(syncRepository.pollConnectionKeys()).thenReturn(Result.Success(true)) testee.viewState(source = null).test { @@ -95,7 +97,8 @@ class SyncConnectViewModelTest { @Test fun whenConnectionKeysSuccessThenLoginSuccess() = runTest { - whenever(syncRepository.getConnectQR()).thenReturn(Result.Success(jsonConnectKeyEncoded)) + val authCodeToUse = AuthCode(qrCode = jsonConnectKeyEncoded, rawCode = "something else") + whenever(syncRepository.getConnectQR()).thenReturn(Result.Success(authCodeToUse)) whenever(syncRepository.pollConnectionKeys()).thenReturn(Result.Success(true)) testee.viewState(source = null).test { awaitItem() @@ -112,7 +115,8 @@ class SyncConnectViewModelTest { @Test fun whenConnectionKeysSuccessWithSourceThenPixelContainsSource() = runTest { - whenever(syncRepository.getConnectQR()).thenReturn(Result.Success(jsonConnectKeyEncoded)) + val authCodeToUse = AuthCode(qrCode = jsonConnectKeyEncoded, rawCode = "something else") + whenever(syncRepository.getConnectQR()).thenReturn(Result.Success(authCodeToUse)) whenever(syncRepository.pollConnectionKeys()).thenReturn(Result.Success(true)) testee.viewState(source = "foo").test { awaitItem() @@ -128,7 +132,8 @@ class SyncConnectViewModelTest { @Test fun whenOnCopyCodeClickedThenShowMessage() = runTest { - whenever(syncRepository.getConnectQR()).thenReturn(Result.Success(jsonConnectKeyEncoded)) + val authCodeToUse = AuthCode(qrCode = jsonConnectKeyEncoded, rawCode = "something else") + whenever(syncRepository.getConnectQR()).thenReturn(Result.Success(authCodeToUse)) testee.onCopyCodeClicked() @@ -141,11 +146,12 @@ class SyncConnectViewModelTest { @Test fun whenOnCopyCodeClickedThenCopyCodeToClipboard() = runTest { - whenever(syncRepository.getConnectQR()).thenReturn(Result.Success(jsonConnectKeyEncoded)) + val authCodeToUse = AuthCode(qrCode = jsonConnectKeyEncoded, rawCode = "something else") + whenever(syncRepository.getConnectQR()).thenReturn(Result.Success(authCodeToUse)) testee.onCopyCodeClicked() - verify(clipboard).copyToClipboard(jsonConnectKeyEncoded) + verify(clipboard).copyToClipboard(authCodeToUse.rawCode) } @Test @@ -237,7 +243,8 @@ class SyncConnectViewModelTest { @Test fun whenPollingIfGenericErrorThenDoNothing() = runTest { - whenever(syncRepository.getConnectQR()).thenReturn(Result.Success(jsonConnectKeyEncoded)) + val authCodeToUse = AuthCode(qrCode = jsonConnectKeyEncoded, rawCode = "something else") + whenever(syncRepository.getConnectQR()).thenReturn(Result.Success(authCodeToUse)) whenever(syncRepository.pollConnectionKeys()) .thenReturn(Result.Error()) .thenReturn(Result.Success(true)) 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..8176f2fa1043 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 @@ -42,6 +42,7 @@ import com.duckduckgo.sync.impl.QREncoder import com.duckduckgo.sync.impl.Result 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.encodeB64 import com.duckduckgo.sync.impl.pixels.SyncPixels @@ -91,7 +92,8 @@ class SyncWithAnotherDeviceViewModelTest { fun whenScreenStartedThenShowQRCode() = runTest { val bitmap = TestSyncFixtures.qrBitmap() whenever(qrEncoder.encodeAsBitmap(eq(jsonRecoveryKeyEncoded), any(), any())).thenReturn(bitmap) - whenever(syncRepository.getRecoveryCode()).thenReturn(Result.Success(jsonRecoveryKeyEncoded)) + val authCodeToUse = AuthCode(qrCode = jsonRecoveryKeyEncoded, rawCode = "something else") + whenever(syncRepository.getRecoveryCode()).thenReturn(Result.Success(authCodeToUse)) testee.viewState().test { val viewState = awaitItem() Assert.assertEquals(bitmap, viewState.qrCodeBitmap) @@ -143,7 +145,8 @@ class SyncWithAnotherDeviceViewModelTest { @Test fun whenOnCopyCodeClickedThenShowMessage() = runTest { - whenever(syncRepository.getRecoveryCode()).thenReturn(Result.Success(jsonRecoveryKeyEncoded)) + val authCodeToUse = AuthCode(qrCode = jsonRecoveryKeyEncoded, rawCode = "something else") + whenever(syncRepository.getRecoveryCode()).thenReturn(Result.Success(authCodeToUse)) // need to ensure view state is started testee.viewState().test { cancelAndConsumeRemainingEvents() } @@ -175,19 +178,20 @@ class SyncWithAnotherDeviceViewModelTest { @Test fun whenOnCopyCodeClickedThenCopyCodeToClipboard() = runTest { - whenever(syncRepository.getRecoveryCode()).thenReturn(Result.Success(jsonRecoveryKeyEncoded)) + val authCodeToUse = AuthCode(qrCode = jsonRecoveryKeyEncoded, rawCode = "something else") + whenever(syncRepository.getRecoveryCode()).thenReturn(Result.Success(authCodeToUse)) // need to ensure view state is started testee.viewState().test { cancelAndConsumeRemainingEvents() } testee.onCopyCodeClicked() - verify(clipboard).copyToClipboard(jsonRecoveryKeyEncoded) + verify(clipboard).copyToClipboard(authCodeToUse.rawCode) } @Test fun whenOnCopyCodeClickedAndExchangingKeysEnabledThenCopyCodeToClipboard() = runTest { - val expectedJson = configureExchangeKeysSupported().second + val expectedJson = configureExchangeKeysSupported().second.rawCode // need to ensure view state is started testee.viewState().test { cancelAndConsumeRemainingEvents() } @@ -265,7 +269,7 @@ class SyncWithAnotherDeviceViewModelTest { @Test fun whenUserScansExchangeCodeAndExchangingKeysEnabledThenCommandIsLoginSuccess() = runTest { - val exchangeJson = configureExchangeKeysSupported().second + val exchangeJson = configureExchangeKeysSupported().second.qrCode // configure success response: logged in whenever(syncRepository.pollForRecoveryCodeAndLogin()).thenReturn(Success(LoggedIn)) @@ -280,7 +284,7 @@ class SyncWithAnotherDeviceViewModelTest { @Test fun whenUserScansExchangeCodeAndExchangingKeysEnabledButAccountSwitchingRequiredThenCommandIsAskToSwitchAccount() = runTest { - val exchangeJson = configureExchangeKeysSupported().second + val exchangeJson = configureExchangeKeysSupported().second.qrCode // configure success response: account switching required whenever(syncRepository.pollForRecoveryCodeAndLogin()).thenReturn(Success(AccountSwitchingRequired(encryptedRecoveryCode))) @@ -386,17 +390,17 @@ class SyncWithAnotherDeviceViewModelTest { } } - private fun configureExchangeKeysSupported(): Pair { + private fun configureExchangeKeysSupported(): Pair { syncFeature.exchangeKeysToSyncWithAnotherDevice().setRawStoredState(State(true)) whenever(syncRepository.pollSecondDeviceExchangeAcknowledgement()).thenReturn(Success(true)) whenever(syncRepository.getCodeType(any())).thenReturn(EXCHANGE) whenever(syncRepository.getAccountInfo()).thenReturn(accountA) val bitmap = TestSyncFixtures.qrBitmap() - val jsonExchangeKey = jsonExchangeKey(primaryDeviceKeyId, validLoginKeys.primaryKey).also { - whenever(syncRepository.generateExchangeInvitationCode()).thenReturn(Success(it)) - whenever(qrEncoder.encodeAsBitmap(eq(it), any(), any())).thenReturn(bitmap) - } + val jsonExchangeKey = jsonExchangeKey(primaryDeviceKeyId, validLoginKeys.primaryKey) + val authCodeToUse = AuthCode(qrCode = jsonExchangeKey, rawCode = "something else") + whenever(syncRepository.generateExchangeInvitationCode()).thenReturn(Success(authCodeToUse)) + whenever(qrEncoder.encodeAsBitmap(eq(jsonExchangeKey), any(), any())).thenReturn(bitmap) whenever(syncRepository.processCode(any())).thenReturn(Success(true)) - return Pair(bitmap, jsonExchangeKey) + return Pair(bitmap, authCodeToUse) } } 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/#&" + } +} diff --git a/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/ui/qrcode/SyncBarcodeUrlUrlWrapperTest.kt b/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/ui/qrcode/SyncBarcodeUrlUrlWrapperTest.kt new file mode 100644 index 000000000000..c950101ab284 --- /dev/null +++ b/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/ui/qrcode/SyncBarcodeUrlUrlWrapperTest.kt @@ -0,0 +1,50 @@ +package com.duckduckgo.sync.impl.ui.qrcode + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.sync.impl.SyncDeviceIds +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 + +@RunWith(AndroidJUnit4::class) +class SyncBarcodeUrlUrlWrapperTest { + + @get:Rule + val coroutineTestRule: CoroutineTestRule = CoroutineTestRule() + + private val syncDeviceIds: SyncDeviceIds = mock() + + private val testee = SyncBarcodeUrlUrlWrapper(syncDeviceIds = syncDeviceIds) + + @Before + fun setup() { + whenever(syncDeviceIds.deviceName()).thenReturn("iPhone") + } + + @Test + fun whenUrlGivenThenUrlWrappedCodeReturned() = runTest { + testee.wrapCodeInUrl(B64_ENCODED_PLAIN_CODE).assertIsUrlContainingCode(B64_URL_SAFE_ENCODED_PLAIN_CODE) + } + + 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/setup/SaveRecoveryCodeViewModelTest.kt b/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/ui/setup/SaveRecoveryCodeViewModelTest.kt index 47a27afaef9a..b1af5792e377 100644 --- a/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/ui/setup/SaveRecoveryCodeViewModelTest.kt +++ b/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/ui/setup/SaveRecoveryCodeViewModelTest.kt @@ -26,6 +26,7 @@ import com.duckduckgo.sync.impl.Clipboard import com.duckduckgo.sync.impl.RecoveryCodePDF import com.duckduckgo.sync.impl.Result import com.duckduckgo.sync.impl.SyncAccountRepository +import com.duckduckgo.sync.impl.SyncAccountRepository.AuthCode import com.duckduckgo.sync.impl.pixels.SyncPixels import com.duckduckgo.sync.impl.ui.setup.SaveRecoveryCodeViewModel.Command import com.duckduckgo.sync.impl.ui.setup.SaveRecoveryCodeViewModel.Command.Next @@ -66,7 +67,8 @@ class SaveRecoveryCodeViewModelTest { fun whenUserIsNotSignedInThenAccountCreatedAndViewStateUpdated() = runTest { whenever(syncAccountRepository.isSignedIn()).thenReturn(false) whenever(syncAccountRepository.createAccount()).thenReturn(Result.Success(true)) - whenever(syncAccountRepository.getRecoveryCode()).thenReturn(Result.Success(jsonRecoveryKeyEncoded)) + val authCodeToUse = AuthCode(qrCode = jsonRecoveryKeyEncoded, rawCode = "something else") + whenever(syncAccountRepository.getRecoveryCode()).thenReturn(Result.Success(authCodeToUse)) testee.viewState().test { val viewState = awaitItem() @@ -78,7 +80,8 @@ class SaveRecoveryCodeViewModelTest { @Test fun whenUserSignedInThenShowViewState() = runTest { whenever(syncAccountRepository.isSignedIn()).thenReturn(true) - whenever(syncAccountRepository.getRecoveryCode()).thenReturn(Result.Success(jsonRecoveryKeyEncoded)) + val authCodeToUse = AuthCode(qrCode = jsonRecoveryKeyEncoded, rawCode = "something else") + whenever(syncAccountRepository.getRecoveryCode()).thenReturn(Result.Success(authCodeToUse)) testee.viewState().test { val viewState = awaitItem() @@ -117,7 +120,8 @@ class SaveRecoveryCodeViewModelTest { @Test fun whenUserClicksOnSaveRecoveryCodeThenEmitCheckIfUserHasPermissionCommand() = runTest { - whenever(syncAccountRepository.getRecoveryCode()).thenReturn(Result.Success(jsonRecoveryKeyEncoded)) + val authCodeToUse = AuthCode(qrCode = jsonRecoveryKeyEncoded, rawCode = "something else") + whenever(syncAccountRepository.getRecoveryCode()).thenReturn(Result.Success(authCodeToUse)) testee.commands().test { testee.onSaveRecoveryCodeClicked() val command = awaitItem() @@ -128,8 +132,9 @@ class SaveRecoveryCodeViewModelTest { @Test fun whenGenerateRecoveryCodeThenGenerateFileAndEmitSuccessCommand() = runTest { - whenever(syncAccountRepository.getRecoveryCode()).thenReturn(Result.Success(jsonRecoveryKeyEncoded)) - whenever(recoveryPDF.generateAndStoreRecoveryCodePDF(any(), eq(jsonRecoveryKeyEncoded))).thenReturn(pdfFile()) + val authCodeToUse = AuthCode(qrCode = jsonRecoveryKeyEncoded, rawCode = "something else") + whenever(syncAccountRepository.getRecoveryCode()).thenReturn(Result.Success(authCodeToUse)) + whenever(recoveryPDF.generateAndStoreRecoveryCodePDF(any(), eq(authCodeToUse.rawCode))).thenReturn(pdfFile()) testee.commands().test { testee.generateRecoveryCode(mock()) @@ -141,11 +146,12 @@ class SaveRecoveryCodeViewModelTest { @Test fun whenUserClicksCopyThenCopyToClipboard() = runTest { - whenever(syncAccountRepository.getRecoveryCode()).thenReturn(Result.Success(jsonRecoveryKeyEncoded)) + val authCodeToUse = AuthCode(qrCode = jsonRecoveryKeyEncoded, rawCode = "something else") + whenever(syncAccountRepository.getRecoveryCode()).thenReturn(Result.Success(authCodeToUse)) testee.commands().test { testee.onCopyCodeClicked() val command = awaitItem() - verify(clipboard).copyToClipboard(eq(jsonRecoveryKeyEncoded)) + verify(clipboard).copyToClipboard(eq("something else")) assertTrue(command is Command.ShowMessage) cancelAndIgnoreRemainingEvents() }