From 06d2c9ad0b4f22d91042a69a848de2666bad36d4 Mon Sep 17 00:00:00 2001 From: Craig Russell Date: Wed, 29 May 2024 12:01:10 +0100 Subject: [PATCH] Add developer settings for importing passwords from Google Password Manager --- .../impl/importing/CredentialImporter.kt | 121 +++++++++++++++ .../impl/importing/CsvCredentialConverter.kt | 88 +++++++++++ ...sswordParser.kt => CsvCredentialParser.kt} | 55 ++++--- .../autofill/impl/importing/CsvFileReader.kt | 23 ++- .../impl/importing/CsvPasswordImporter.kt | 85 ----------- .../impl/importing/DomainNameNormalizer.kt | 4 +- ....kt => ExistingCredentialMatchDetector.kt} | 27 ++-- .../importing/GooglePasswordBlobDecoder.kt | 17 ++- ...ator.kt => ImportedCredentialValidator.kt} | 26 +++- .../importing/CredentialImporterImplTest.kt | 141 ++++++++++++++++++ .../DefaultDomainNameNormalizerTest.kt | 31 ++-- ...ultExistingCredentialMatchDetectorTest.kt} | 12 +- .../DefaultImportedCredentialValidatorTest.kt | 99 ++++++++++++ .../DefaultImportedPasswordValidatorTest.kt | 22 --- .../GooglePasswordBlobDecoderImplTest.kt | 52 +++++++ ...sswordManagerCsvCredentialConverterTest.kt | 82 ++++++++++ ...PasswordManagerCsvCredentialParserTest.kt} | 123 ++++++++------- .../AutofillDomainNameUrlMatcherTest.kt | 5 + .../autofill/gpm_import_missing_domain.csv | 2 + .../AutofillInternalSettingsActivity.kt | 90 +++++++++++ .../activity_autofill_internal_settings.xml | 21 +++ .../src/main/res/values/donottranslate.xml | 4 + 22 files changed, 894 insertions(+), 236 deletions(-) create mode 100644 autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/CredentialImporter.kt create mode 100644 autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/CsvCredentialConverter.kt rename autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/{CsvPasswordParser.kt => CsvCredentialParser.kt} (65%) delete mode 100644 autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/CsvPasswordImporter.kt rename autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/{ExistingPasswordMatchDetector.kt => ExistingCredentialMatchDetector.kt} (59%) rename autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/{ImportedPasswordValidator.kt => ImportedCredentialValidator.kt} (59%) create mode 100644 autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/CredentialImporterImplTest.kt rename autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/{DefaultExistingPasswordMatchDetectorTest.kt => DefaultExistingCredentialMatchDetectorTest.kt} (84%) create mode 100644 autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/DefaultImportedCredentialValidatorTest.kt delete mode 100644 autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/DefaultImportedPasswordValidatorTest.kt create mode 100644 autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/GooglePasswordBlobDecoderImplTest.kt create mode 100644 autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/GooglePasswordManagerCsvCredentialConverterTest.kt rename autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/{GooglePasswordManagerCsvPasswordParserTest.kt => GooglePasswordManagerCsvCredentialParserTest.kt} (51%) create mode 100644 autofill/autofill-impl/src/test/resources/csv/autofill/gpm_import_missing_domain.csv diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/CredentialImporter.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/CredentialImporter.kt new file mode 100644 index 000000000000..661f98382630 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/CredentialImporter.kt @@ -0,0 +1,121 @@ +/* + * Copyright (c) 2024 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.autofill.impl.importing + +import android.os.Parcelable +import com.duckduckgo.app.di.AppCoroutineScope +import com.duckduckgo.autofill.api.domain.app.LoginCredentials +import com.duckduckgo.autofill.impl.importing.CredentialImporter.ImportResult +import com.duckduckgo.autofill.impl.importing.CredentialImporter.ImportResult.Finished +import com.duckduckgo.autofill.impl.importing.CredentialImporter.ImportResult.InProgress +import com.duckduckgo.autofill.impl.store.InternalAutofillStore +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import dagger.SingleInstanceIn +import java.util.UUID +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.parcelize.Parcelize + +interface CredentialImporter { + suspend fun import(importList: List, originalImportListSize: Int): String + fun getImportStatus(jobId: String): Flow + + sealed interface ImportResult : Parcelable { + val jobId: String + + @Parcelize + data class InProgress( + val savedCredentialIds: List, + val numberSkipped: Int, + val originalImportListSize: Int, + override val jobId: String, + ) : ImportResult + + @Parcelize + data class Finished( + val savedCredentialIds: List, + val numberSkipped: Int, + override val jobId: String, + ) : ImportResult + } +} + +@SingleInstanceIn(AppScope::class) +@ContributesBinding(AppScope::class) +class CredentialImporterImpl @Inject constructor( + private val existingCredentialMatchDetector: ExistingCredentialMatchDetector, + private val autofillStore: InternalAutofillStore, + private val dispatchers: DispatcherProvider, + @AppCoroutineScope private val appCoroutineScope: CoroutineScope, +) : CredentialImporter { + + private val _importStatus = MutableSharedFlow(replay = 1) + private val mutex = Mutex() + + override suspend fun import(importList: List, originalImportListSize: Int): String { + val jobId = UUID.randomUUID().toString() + + mutex.withLock { + appCoroutineScope.launch(dispatchers.io()) { + doImportCredentials(importList, originalImportListSize, jobId) + } + } + + return jobId + } + + private suspend fun doImportCredentials( + importList: List, + originalImportListSize: Int, + jobId: String, + ) { + val savedCredentialIds = mutableListOf() + var skippedCredentials = originalImportListSize - importList.size + + _importStatus.emit(InProgress(savedCredentialIds, skippedCredentials, originalImportListSize, jobId)) + + importList.forEach { + if (!existingCredentialMatchDetector.alreadyExists(it)) { + val insertedId = autofillStore.saveCredentials(it.domain!!, it)?.id + + if (insertedId != null) { + savedCredentialIds.add(insertedId) + } + } else { + skippedCredentials++ + } + + _importStatus.emit(InProgress(savedCredentialIds, skippedCredentials, originalImportListSize, jobId)) + } + + _importStatus.emit(Finished(savedCredentialIds, skippedCredentials, jobId)) + } + + override fun getImportStatus(jobId: String): Flow { + return _importStatus.filter { result -> + result.jobId == jobId + } + } +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/CsvCredentialConverter.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/CsvCredentialConverter.kt new file mode 100644 index 000000000000..6758bf302c93 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/CsvCredentialConverter.kt @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2024 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.autofill.impl.importing + +import android.net.Uri +import android.os.Parcelable +import com.duckduckgo.autofill.api.domain.app.LoginCredentials +import com.duckduckgo.autofill.impl.importing.CsvCredentialConverter.CsvCredentialImportResult +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import javax.inject.Inject +import kotlinx.coroutines.withContext +import kotlinx.parcelize.Parcelize + +interface CsvCredentialConverter { + suspend fun readCsv(encodedBlob: String): CsvCredentialImportResult + suspend fun readCsv(fileUri: Uri): CsvCredentialImportResult + + sealed interface CsvCredentialImportResult : Parcelable { + + @Parcelize + data class Success(val numberCredentialsInSource: Int, val loginCredentialsToImport: List) : CsvCredentialImportResult + + @Parcelize + data object Error : CsvCredentialImportResult + } +} + +@ContributesBinding(AppScope::class) +class GooglePasswordManagerCsvCredentialConverter @Inject constructor( + private val parser: CsvCredentialParser, + private val fileReader: CsvFileReader, + private val credentialValidator: ImportedCredentialValidator, + private val domainNameNormalizer: DomainNameNormalizer, + private val dispatchers: DispatcherProvider, + private val blobDecoder: GooglePasswordBlobDecoder, +) : CsvCredentialConverter { + + override suspend fun readCsv(encodedBlob: String): CsvCredentialImportResult { + return kotlin.runCatching { + withContext(dispatchers.io()) { + val csv = blobDecoder.decode(encodedBlob) + convertToLoginCredentials(csv) + } + }.getOrElse { CsvCredentialImportResult.Error } + } + + override suspend fun readCsv(fileUri: Uri): CsvCredentialImportResult { + return kotlin.runCatching { + withContext(dispatchers.io()) { + val csv = fileReader.readCsvFile(fileUri) + convertToLoginCredentials(csv) + } + }.getOrElse { CsvCredentialImportResult.Error } + } + + private suspend fun convertToLoginCredentials(csv: String): CsvCredentialImportResult { + return when (val parseResult = parser.parseCsv(csv)) { + is CsvCredentialParser.ParseResult.Success -> { + val toImport = deduplicateAndCleanup(parseResult.credentials) + CsvCredentialImportResult.Success(parseResult.credentials.size, toImport) + } + is CsvCredentialParser.ParseResult.Error -> CsvCredentialImportResult.Error + } + } + + private suspend fun deduplicateAndCleanup(allCredentials: List): List { + val dedupedCredentials = allCredentials.distinct() + val validCredentials = dedupedCredentials.filter { credentialValidator.isValid(it) } + val normalizedDomains = domainNameNormalizer.normalizeDomains(validCredentials) + return normalizedDomains + } +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/CsvPasswordParser.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/CsvCredentialParser.kt similarity index 65% rename from autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/CsvPasswordParser.kt rename to autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/CsvCredentialParser.kt index f3ab7664da24..a84020b06cef 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/CsvPasswordParser.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/CsvCredentialParser.kt @@ -17,6 +17,9 @@ package com.duckduckgo.autofill.impl.importing import com.duckduckgo.autofill.api.domain.app.LoginCredentials +import com.duckduckgo.autofill.impl.importing.CsvCredentialParser.ParseResult +import com.duckduckgo.autofill.impl.importing.CsvCredentialParser.ParseResult.Error +import com.duckduckgo.autofill.impl.importing.CsvCredentialParser.ParseResult.Success import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.AppScope import com.squareup.anvil.annotations.ContributesBinding @@ -26,28 +29,31 @@ import javax.inject.Inject import kotlinx.coroutines.withContext import timber.log.Timber -interface CsvPasswordParser { - suspend fun parseCsv(csv: String): List +interface CsvCredentialParser { + suspend fun parseCsv(csv: String): ParseResult + + sealed interface ParseResult { + data class Success(val credentials: List) : ParseResult + data object Error : ParseResult + } } @ContributesBinding(AppScope::class) -class GooglePasswordManagerCsvPasswordParser @Inject constructor( +class GooglePasswordManagerCsvCredentialParser @Inject constructor( private val dispatchers: DispatcherProvider, -) : CsvPasswordParser { - -// private val csvFormat by lazy { -// CSVFormat.Builder.create(CSVFormat.DEFAULT).build() -// } +) : CsvCredentialParser { - override suspend fun parseCsv(csv: String): List { + override suspend fun parseCsv(csv: String): ParseResult { return kotlin.runCatching { - convertToPasswordList(csv).also { - Timber.i("Parsed CSV. Found %d passwords", it.size) + val credentials = convertToCredentials(csv).also { + Timber.i("Parsed CSV. Found %d credentials", it.size) } + Success(credentials) }.onFailure { - Timber.e("Failed to parse CSV: %s", it.message) + Timber.e(it, "Failed to parse CSV") + Error }.getOrElse { - emptyList() + Error } } @@ -55,7 +61,7 @@ class GooglePasswordManagerCsvPasswordParser @Inject constructor( * Format of the Google Password Manager CSV is: * name | url | username | password | note */ - private suspend fun convertToPasswordList(csv: String): List { + private suspend fun convertToCredentials(csv: String): List { return withContext(dispatchers.io()) { val lines = mutableListOf() val iter = CsvReader.builder().build(csv).spliterator() @@ -65,13 +71,12 @@ class GooglePasswordManagerCsvPasswordParser @Inject constructor( lines.firstOrNull().verifyExpectedFormat() // drop the header row - val passwordsLines = lines.drop(1) + val credentialLines = lines.drop(1) - Timber.v("About to parse %d passwords", passwordsLines.size) - return@withContext passwordsLines + return@withContext credentialLines .mapNotNull { - if (it.fields.size != EXPECTED_HEADERS.size) { - Timber.w("CSV line does not match expected format. Expected ${EXPECTED_HEADERS.size} parts, found ${it.fields.size}") + if (it.fields.size != EXPECTED_HEADERS_ORDERED.size) { + Timber.w("Line is unexpected format. Expected ${EXPECTED_HEADERS_ORDERED.size} parts, found ${it.fields.size}") return@mapNotNull null } @@ -113,21 +118,23 @@ class GooglePasswordManagerCsvPasswordParser @Inject constructor( val headers = this.fields - if (headers.size != EXPECTED_HEADERS.size) { + if (headers.size != EXPECTED_HEADERS_ORDERED.size) { throw IllegalArgumentException( - "CSV header size does not match expected amount. Expected: ${EXPECTED_HEADERS.size}, found: ${headers.size}", + "CSV header size does not match expected amount. Expected: ${EXPECTED_HEADERS_ORDERED.size}, found: ${headers.size}", ) } headers.forEachIndexed { index, value -> - if (value != EXPECTED_HEADERS[index]) { - throw IllegalArgumentException("CSV header does not match expected format. Expected: ${EXPECTED_HEADERS[index]}, found: $value") + if (value != EXPECTED_HEADERS_ORDERED[index]) { + throw IllegalArgumentException( + "CSV header does not match expected format. Expected: ${EXPECTED_HEADERS_ORDERED[index]}, found: $value", + ) } } } companion object { - val EXPECTED_HEADERS = listOf( + val EXPECTED_HEADERS_ORDERED = listOf( "name", "url", "username", diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/CsvFileReader.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/CsvFileReader.kt index c996b80e392e..7102ec5844ed 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/CsvFileReader.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/CsvFileReader.kt @@ -18,26 +18,35 @@ package com.duckduckgo.autofill.impl.importing import android.content.Context import android.net.Uri +import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.AppScope import com.squareup.anvil.annotations.ContributesBinding import java.io.BufferedReader import java.io.InputStreamReader import javax.inject.Inject +import kotlinx.coroutines.withContext interface CsvFileReader { - fun readCsvFile(fileUri: Uri): String + suspend fun readCsvFile(fileUri: Uri): String } @ContributesBinding(AppScope::class) class ContentResolverFileReader @Inject constructor( private val context: Context, + private val dispatchers: DispatcherProvider, ) : CsvFileReader { - override fun readCsvFile(fileUri: Uri): String { - return context.contentResolver.openInputStream(fileUri)?.use { inputStream -> - BufferedReader(InputStreamReader(inputStream)).use { reader -> - reader.readText() - } - } ?: "" + override suspend fun readCsvFile(fileUri: Uri): String { + return withContext(dispatchers.io()) { + context.contentResolver.openInputStream(fileUri)?.use { inputStream -> + BufferedReader(InputStreamReader(inputStream, Charsets.UTF_8)).use { reader -> + buildString { + reader.forEachLine { line -> + append(line).append("\n") + } + } + } + } ?: "" + } } } diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/CsvPasswordImporter.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/CsvPasswordImporter.kt deleted file mode 100644 index 2539628dd818..000000000000 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/CsvPasswordImporter.kt +++ /dev/null @@ -1,85 +0,0 @@ -/* - * Copyright (c) 2024 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.autofill.impl.importing - -import android.net.Uri -import android.os.Parcelable -import com.duckduckgo.autofill.api.domain.app.LoginCredentials -import com.duckduckgo.autofill.impl.importing.CsvPasswordImporter.ParseResult -import com.duckduckgo.autofill.impl.importing.CsvPasswordImporter.ParseResult.Success -import com.duckduckgo.common.utils.DispatcherProvider -import com.duckduckgo.di.scopes.AppScope -import com.squareup.anvil.annotations.ContributesBinding -import javax.inject.Inject -import kotlinx.coroutines.withContext -import kotlinx.parcelize.Parcelize - -interface CsvPasswordImporter { - suspend fun readCsv(blob: String): ParseResult - suspend fun readCsv(fileUri: Uri): ParseResult - - sealed interface ParseResult : Parcelable { - @Parcelize - data class Success(val numberPasswordsInSource: Int, val loginCredentialsToImport: List) : ParseResult - - @Parcelize - data object Error : ParseResult - } -} - -@ContributesBinding(AppScope::class) -class GooglePasswordManagerCsvPasswordImporter @Inject constructor( - private val parser: CsvPasswordParser, - private val fileReader: CsvFileReader, - private val credentialValidator: ImportedPasswordValidator, - private val domainNameNormalizer: DomainNameNormalizer, - private val dispatchers: DispatcherProvider, - private val blobDecoder: GooglePasswordBlobDecoder, -) : CsvPasswordImporter { - - override suspend fun readCsv(blob: String): ParseResult { - return kotlin.runCatching { - withContext(dispatchers.io()) { - val csv = blobDecoder.decode(blob) - convertToLoginCredentials(csv) - } - }.getOrElse { ParseResult.Error } - } - - override suspend fun readCsv(fileUri: Uri): ParseResult { - return kotlin.runCatching { - withContext(dispatchers.io()) { - val csv = fileReader.readCsvFile(fileUri) - convertToLoginCredentials(csv) - } - }.getOrElse { ParseResult.Error } - } - - private suspend fun convertToLoginCredentials(csv: String): Success { - val allPasswords = parser.parseCsv(csv) - val dedupedPasswords = allPasswords.distinct() - val validPasswords = filterValidPasswords(dedupedPasswords) - val normalizedDomains = domainNameNormalizer.normalizeDomains(validPasswords) - return Success(allPasswords.size, normalizedDomains) - } - - private fun filterValidPasswords(passwords: List): List { - return passwords.filter { it.isValid() } - } - - private fun LoginCredentials.isValid(): Boolean = credentialValidator.isValid(this) -} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/DomainNameNormalizer.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/DomainNameNormalizer.kt index f90e89298299..86fc4fd60a03 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/DomainNameNormalizer.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/DomainNameNormalizer.kt @@ -32,9 +32,9 @@ class DefaultDomainNameNormalizer @Inject constructor( ) : DomainNameNormalizer { override suspend fun normalizeDomains(unnormalized: List): List { return unnormalized.map { - val currentDomain = it.domain ?: return@map null + val currentDomain = it.domain ?: return@map it val normalizedDomain = urlMatcher.cleanRawUrl(currentDomain) it.copy(domain = normalizedDomain) - }.filterNotNull() + } } } diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/ExistingPasswordMatchDetector.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/ExistingCredentialMatchDetector.kt similarity index 59% rename from autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/ExistingPasswordMatchDetector.kt rename to autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/ExistingCredentialMatchDetector.kt index e41892226212..f8196ccc5eb1 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/ExistingPasswordMatchDetector.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/ExistingCredentialMatchDetector.kt @@ -18,31 +18,34 @@ package com.duckduckgo.autofill.impl.importing import com.duckduckgo.autofill.api.domain.app.LoginCredentials import com.duckduckgo.autofill.impl.store.InternalAutofillStore -import com.duckduckgo.autofill.impl.urlmatcher.AutofillUrlMatcher +import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.AppScope import com.squareup.anvil.annotations.ContributesBinding import javax.inject.Inject import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.withContext -interface ExistingPasswordMatchDetector { +interface ExistingCredentialMatchDetector { suspend fun alreadyExists(newCredentials: LoginCredentials): Boolean } @ContributesBinding(AppScope::class) -class DefaultExistingPasswordMatchDetector @Inject constructor( - private val urlMatcher: AutofillUrlMatcher, +class DefaultExistingCredentialMatchDetector @Inject constructor( private val autofillStore: InternalAutofillStore, -) : ExistingPasswordMatchDetector { + private val dispatchers: DispatcherProvider, +) : ExistingCredentialMatchDetector { override suspend fun alreadyExists(newCredentials: LoginCredentials): Boolean { - val credentials = autofillStore.getAllCredentials().firstOrNull() ?: return false + return withContext(dispatchers.io()) { + val credentials = autofillStore.getAllCredentials().firstOrNull() ?: return@withContext false - return credentials.any { existing -> - existing.domain == newCredentials.domain && - existing.username == newCredentials.username && - existing.password == newCredentials.password && - existing.domainTitle == newCredentials.domainTitle && - existing.notes == newCredentials.notes + credentials.any { existing -> + existing.domain == newCredentials.domain && + existing.username == newCredentials.username && + existing.password == newCredentials.password && + existing.domainTitle == newCredentials.domainTitle && + existing.notes == newCredentials.notes + } } } } diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/GooglePasswordBlobDecoder.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/GooglePasswordBlobDecoder.kt index b593bb9fd710..1e45b9ae4027 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/GooglePasswordBlobDecoder.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/GooglePasswordBlobDecoder.kt @@ -35,10 +35,19 @@ class GooglePasswordBlobDecoderImpl @Inject constructor( override suspend fun decode(data: String): String { return withContext(dispatchers.io()) { - val base64Data = data.split(",")[1] - val decodedBytes = Base64.decode(base64Data, DEFAULT) - val decoded = String(decodedBytes, Charsets.UTF_8) - return@withContext decoded + kotlin.runCatching { + val base64Data = removeDataTypePrefix(data) + val decodedBytes = Base64.decode(base64Data, DEFAULT) + String(decodedBytes, Charsets.UTF_8) + }.getOrElse { rootCause -> + throw IllegalArgumentException("Unrecognized format", rootCause) + } } } + + /** + * String will start with data type. + * e.g., data:text/csv;charset=utf-8;;base64, + */ + private fun removeDataTypePrefix(data: String) = data.split(",")[1] } diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/ImportedPasswordValidator.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/ImportedCredentialValidator.kt similarity index 59% rename from autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/ImportedPasswordValidator.kt rename to autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/ImportedCredentialValidator.kt index bb6f6e56b2b5..5677cda8d040 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/ImportedPasswordValidator.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/ImportedCredentialValidator.kt @@ -21,16 +21,34 @@ import com.duckduckgo.di.scopes.AppScope import com.squareup.anvil.annotations.ContributesBinding import javax.inject.Inject -interface ImportedPasswordValidator { +interface ImportedCredentialValidator { fun isValid(loginCredentials: LoginCredentials): Boolean } @ContributesBinding(AppScope::class) -class DefaultImportedPasswordValidator @Inject constructor() : ImportedPasswordValidator { +class DefaultImportedCredentialValidator @Inject constructor() : ImportedCredentialValidator { override fun isValid(loginCredentials: LoginCredentials): Boolean { - return with(loginCredentials) { - domain.isNullOrBlank().not() && password.isNullOrBlank().not() + with(loginCredentials) { + if (domain?.startsWith(APP_PASSWORD_PREFIX) == true) return false + + if (allFieldsEmpty()) { + return false + } + + return true } } + + private fun LoginCredentials.allFieldsEmpty(): Boolean { + return domain.isNullOrBlank() && + username.isNullOrBlank() && + password.isNullOrBlank() && + domainTitle.isNullOrBlank() && + notes.isNullOrBlank() + } + + companion object { + private const val APP_PASSWORD_PREFIX = "android://" + } } diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/CredentialImporterImplTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/CredentialImporterImplTest.kt new file mode 100644 index 000000000000..f108d5ccc8e4 --- /dev/null +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/CredentialImporterImplTest.kt @@ -0,0 +1,141 @@ +package com.duckduckgo.autofill.impl.importing + +import app.cash.turbine.test +import com.duckduckgo.autofill.api.domain.app.LoginCredentials +import com.duckduckgo.autofill.impl.importing.CredentialImporter.ImportResult +import com.duckduckgo.autofill.impl.store.InternalAutofillStore +import com.duckduckgo.common.test.CoroutineTestRule +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.test.runTest +import org.junit.Assert.* +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +class CredentialImporterImplTest { + @get:Rule + val coroutineTestRule: CoroutineTestRule = CoroutineTestRule() + private val existingCredentialMatchDetector: ExistingCredentialMatchDetector = mock() + private val autofillStore: InternalAutofillStore = mock() + private val dispatchers = coroutineTestRule.testDispatcherProvider + private val appCoroutineScope: CoroutineScope = coroutineTestRule.testScope + + private val testee = CredentialImporterImpl( + existingCredentialMatchDetector = existingCredentialMatchDetector, + autofillStore = autofillStore, + dispatchers = dispatchers, + appCoroutineScope = appCoroutineScope, + ) + + private var nextId = 0L + + @Before + fun before() = runTest { + whenever(existingCredentialMatchDetector.alreadyExists(any())).thenReturn(false) + whenever(autofillStore.saveCredentials(any(), any())).then { + creds(id = nextId++) + } + } + + @Test + fun whenImportingEmptyListThenResultIsCorrect() = runTest { + val jobId = listOf().import() + jobId.assertResult(numberSkippedExpected = 0, importListSizeExpected = 0) + } + + @Test + fun whenImportingSingleItemNotADuplicateThenResultIsCorrect() = runTest { + val jobId = listOf(creds()).import() + jobId.assertResult(numberSkippedExpected = 0, importListSizeExpected = 1) + } + + @Test + fun whenImportingMultipleItemsNoDuplicatesThenResultIsCorrect() = runTest { + val jobId = listOf( + creds(username = "username1"), + creds(username = "username2"), + ).import() + jobId.assertResult(numberSkippedExpected = 0, importListSizeExpected = 2) + } + + @Test + fun whenImportingSingleItemWhichIsADuplicateThenResultIsCorrect() = runTest { + val duplicatedLogin = creds(username = "username") + whenever(existingCredentialMatchDetector.alreadyExists(duplicatedLogin)).thenReturn(true) + val jobId = listOf(duplicatedLogin).import() + jobId.assertResult(numberSkippedExpected = 1, importListSizeExpected = 0) + } + + @Test + fun whenImportingMultipleItemsAllDuplicatesThenResultIsCorrect() = runTest { + val duplicatedLogin1 = creds(username = "username1") + val duplicatedLogin2 = creds(username = "username2") + whenever(existingCredentialMatchDetector.alreadyExists(duplicatedLogin1)).thenReturn(true) + whenever(existingCredentialMatchDetector.alreadyExists(duplicatedLogin2)).thenReturn(true) + + val jobId = listOf(duplicatedLogin1, duplicatedLogin2).import() + jobId.assertResult(numberSkippedExpected = 2, importListSizeExpected = 0) + } + + @Test + fun whenImportingMultipleItemsSomeDuplicatesThenResultIsCorrect() = runTest { + val duplicatedLogin1 = creds(username = "username1") + val duplicatedLogin2 = creds(username = "username2") + val notADuplicate = creds(username = "username3") + whenever(existingCredentialMatchDetector.alreadyExists(duplicatedLogin1)).thenReturn(true) + whenever(existingCredentialMatchDetector.alreadyExists(duplicatedLogin2)).thenReturn(true) + whenever(existingCredentialMatchDetector.alreadyExists(notADuplicate)).thenReturn(false) + + val jobId = listOf(duplicatedLogin1, duplicatedLogin2, notADuplicate).import() + jobId.assertResult(numberSkippedExpected = 2, importListSizeExpected = 1) + } + + @Test + fun whenAllPasswordsSkippedAlreadyBeforeImportThenResultIsCorrect() = runTest { + val jobId = listOf().import(originalListSize = 3) + jobId.assertResult(numberSkippedExpected = 3, importListSizeExpected = 0) + } + + @Test + fun whenSomePasswordsSkippedAlreadyBeforeImportThenResultIsCorrect() = runTest { + val jobId = listOf(creds()).import(originalListSize = 3) + jobId.assertResult(numberSkippedExpected = 2, importListSizeExpected = 1) + } + + private suspend fun List.import(originalListSize: Int = this.size): String { + return testee.import(this, originalListSize) + } + + private suspend fun String.assertResult( + numberSkippedExpected: Int, + importListSizeExpected: Int, + ) { + testee.getImportStatus(this).test { + with(awaitItem() as ImportResult.Finished) { + assertEquals("Wrong number of duplicates in result", numberSkippedExpected, numberSkipped) + assertEquals("Wrong import size in result", importListSizeExpected, savedCredentialIds.size) + } + } + } + + private fun creds( + id: Long? = null, + domain: String? = "example.com", + username: String? = "username", + password: String? = "password", + notes: String? = "notes", + domainTitle: String? = "example title", + ): LoginCredentials { + return LoginCredentials( + id = id, + domainTitle = domainTitle, + domain = domain, + username = username, + password = password, + notes = notes, + ) + } +} diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/DefaultDomainNameNormalizerTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/DefaultDomainNameNormalizerTest.kt index e9d59bb9d15f..7ef23f71300f 100644 --- a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/DefaultDomainNameNormalizerTest.kt +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/DefaultDomainNameNormalizerTest.kt @@ -27,41 +27,38 @@ class DefaultDomainNameNormalizerTest { val input = listOf(creds(domain = "example.com")) val output = testee.normalizeDomains(input) assertEquals(1, output.size) - assertEquals(creds(), output.first()) + assertEquals(input.first(), output.first()) } @Test - fun whenInputDomainNotAlreadyNormalizedThenIncludedInOutput() = runTest { + fun whenInputDomainNotAlreadyNormalizedThenNormalizedAndIncludedInOutput() = runTest { val input = listOf(creds(domain = "https://example.com/foo/bar")) val output = testee.normalizeDomains(input) assertEquals(1, output.size) - assertEquals(creds(), output.first()) + assertEquals(input.first().copy(domain = "example.com"), output.first()) } @Test - fun whenInputDomainIsNullThenNotIncludedInOutput() = runTest { + fun whenInputDomainIsNullThenNormalizedToNullDomain() = runTest { val input = listOf(creds(domain = null)) val output = testee.normalizeDomains(input) - assertTrue(output.isEmpty()) + assertEquals(1, output.size) + assertEquals(null, output.first().domain) } @Test - fun whenManyInputsWithOneNullDomainThenOnlyMissingDomainIsNotIncludedInOutput() = runTest { - val input = listOf( - creds(), - creds(domain = null), - creds(), - ) + fun whenDomainCannotBeNormalizedThenIsIncludedUnmodified() = runTest { + val input = listOf(creds(domain = "unnormalizable")) val output = testee.normalizeDomains(input) - assertEquals(2, output.size) + assertEquals("unnormalizable", output.first().domain) } private fun creds( - domain: String? = "example.com", - username: String? = "username", - password: String? = "password", - notes: String? = "notes", - domainTitle: String? = "example title", + domain: String? = null, + username: String? = null, + password: String? = null, + notes: String? = null, + domainTitle: String? = null, ): LoginCredentials { return LoginCredentials( domainTitle = domainTitle, diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/DefaultExistingPasswordMatchDetectorTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/DefaultExistingCredentialMatchDetectorTest.kt similarity index 84% rename from autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/DefaultExistingPasswordMatchDetectorTest.kt rename to autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/DefaultExistingCredentialMatchDetectorTest.kt index 30512fc2296d..8c77131a6e28 100644 --- a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/DefaultExistingPasswordMatchDetectorTest.kt +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/DefaultExistingCredentialMatchDetectorTest.kt @@ -2,26 +2,28 @@ package com.duckduckgo.autofill.impl.importing import androidx.test.ext.junit.runners.AndroidJUnit4 import com.duckduckgo.autofill.api.domain.app.LoginCredentials -import com.duckduckgo.autofill.impl.encoding.UrlUnicodeNormalizerImpl import com.duckduckgo.autofill.impl.store.InternalAutofillStore -import com.duckduckgo.autofill.impl.urlmatcher.AutofillDomainNameUrlMatcher +import com.duckduckgo.common.test.CoroutineTestRule import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue +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 DefaultExistingPasswordMatchDetectorTest { +class DefaultExistingCredentialMatchDetectorTest { + @get:Rule + val coroutineTestRule: CoroutineTestRule = CoroutineTestRule() private val autofillStore: InternalAutofillStore = mock() - private val testee = DefaultExistingPasswordMatchDetector( - urlMatcher = AutofillDomainNameUrlMatcher(UrlUnicodeNormalizerImpl()), + private val testee = DefaultExistingCredentialMatchDetector( autofillStore = autofillStore, + dispatchers = coroutineTestRule.testDispatcherProvider, ) @Test diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/DefaultImportedCredentialValidatorTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/DefaultImportedCredentialValidatorTest.kt new file mode 100644 index 000000000000..801ac953ae17 --- /dev/null +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/DefaultImportedCredentialValidatorTest.kt @@ -0,0 +1,99 @@ +package com.duckduckgo.autofill.impl.importing + +import com.duckduckgo.autofill.api.domain.app.LoginCredentials +import org.junit.Assert.* +import org.junit.Test + +class DefaultImportedCredentialValidatorTest { + private val testee = DefaultImportedCredentialValidator() + + @Test + fun whenAllFieldsPopulatedThenIsValid() { + assertTrue(testee.isValid(fullyPopulatedCredentials())) + } + + @Test + fun whenNoFieldsPopulatedThenIsInvalid() { + assertFalse(testee.isValid(emptyCredentials())) + } + + @Test + fun whenUsernameMissingThenIsValid() { + val missingUsername = fullyPopulatedCredentials().copy(username = null) + assertTrue(testee.isValid(missingUsername)) + } + + @Test + fun whenPasswordMissingThenIsValid() { + val missingPassword = fullyPopulatedCredentials().copy(password = null) + assertTrue(testee.isValid(missingPassword)) + } + + @Test + fun whenDomainMissingThenIsValid() { + val missingDomain = fullyPopulatedCredentials().copy(domain = null) + assertTrue(testee.isValid(missingDomain)) + } + + @Test + fun whenTitleIsMissingThenIsValid() { + val missingTitle = fullyPopulatedCredentials().copy(domainTitle = null) + assertTrue(testee.isValid(missingTitle)) + } + + @Test + fun whenNotesIsMissingThenIsValid() { + assertTrue(testee.isValid(fullyPopulatedCredentials().copy(notes = null))) + } + + @Test + fun whenUsernameOnlyFieldPopulatedThenIsValid() { + assertTrue(testee.isValid(emptyCredentials().copy(username = "user"))) + } + + @Test + fun whenPasswordOnlyFieldPopulatedThenIsValid() { + assertTrue(testee.isValid(emptyCredentials().copy(password = "password"))) + } + + @Test + fun whenDomainOnlyFieldPopulatedThenIsValid() { + assertTrue(testee.isValid(emptyCredentials().copy(domain = "example.com"))) + } + + @Test + fun whenTitleIsOnlyFieldPopulatedThenIsValid() { + assertTrue(testee.isValid(emptyCredentials().copy(domainTitle = "title"))) + } + + @Test + fun whenNotesIsOnlyFieldPopulatedThenIsValid() { + assertTrue(testee.isValid(emptyCredentials().copy(notes = "notes"))) + } + + @Test + fun whenDomainIsAppPasswordThenIsNotValid() { + val appPassword = fullyPopulatedCredentials().copy(domain = "android://Jz-U_hg==@com.netflix.mediaclient/") + assertFalse(testee.isValid(appPassword)) + } + + private fun fullyPopulatedCredentials(): LoginCredentials { + return LoginCredentials( + username = "username", + password = "password", + domain = "example.com", + domainTitle = "example title", + notes = "notes", + ) + } + + private fun emptyCredentials(): LoginCredentials { + return LoginCredentials( + username = null, + password = null, + domain = null, + domainTitle = null, + notes = null, + ) + } +} diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/DefaultImportedPasswordValidatorTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/DefaultImportedPasswordValidatorTest.kt deleted file mode 100644 index 42b8c53c5afd..000000000000 --- a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/DefaultImportedPasswordValidatorTest.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.duckduckgo.autofill.impl.importing - -import com.duckduckgo.autofill.api.domain.app.LoginCredentials -import org.junit.Assert.* -import org.junit.Test - -class DefaultImportedPasswordValidatorTest { - private val testee = DefaultImportedPasswordValidator() - - @Test - fun whenThen() { - assertTrue(testee.isValid(validCreds())) - } - - private fun validCreds(): LoginCredentials { - return LoginCredentials( - username = "username", - password = "password", - domain = "example.com", - ) - } -} diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/GooglePasswordBlobDecoderImplTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/GooglePasswordBlobDecoderImplTest.kt new file mode 100644 index 000000000000..8085f7e3f0cd --- /dev/null +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/GooglePasswordBlobDecoderImplTest.kt @@ -0,0 +1,52 @@ +package com.duckduckgo.autofill.impl.importing + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.duckduckgo.common.test.CoroutineTestRule +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class GooglePasswordBlobDecoderImplTest { + + @get:Rule + val coroutineTestRule: CoroutineTestRule = CoroutineTestRule() + private val testee = GooglePasswordBlobDecoderImpl(dispatchers = coroutineTestRule.testDispatcherProvider) + + @Test + fun whenEmptyCsvDataThenNoCredentialsReturned() = runTest { + val input = "${DATA_TYPE_PREFIX}bmFtZSx1cmwsdXNlcm5hbWUscGFzc3dvcmQsbm90ZQ==" + val expected = "name,url,username,password,note" + assertEquals(expected, testee.decode(input)) + } + + @Test + fun whenValidBlobMultiplePasswordsThenCredentialsReturned() = runTest { + val input = DATA_TYPE_PREFIX + + "bmFtZSx1cmwsdXNlcm5hbWUscGFzc3dvcmQsbm90ZQosaHR0cHM6Ly9leGFtcGxlLmNvbSx0ZXN0LXVzZXIsdGVzdC1wYXNzd29yZCx" + + "0ZXN0LW5vdGVzCmZpbGwuZGV2LGh0dHBzOi8vZmlsbC5kZXYvZm9ybS9sb2dpbi1zaW1wbGUsdGVzdC11c2VyLHRlc3RQYXNzd29yZEZpbGxEZXYs" + .trimIndent() + val expected = """ + name,url,username,password,note + ,https://example.com,test-user,test-password,test-notes + fill.dev,https://fill.dev/form/login-simple,test-user,testPasswordFillDev, + """.trimIndent() + assertEquals(expected, testee.decode(input)) + } + + @Test(expected = IllegalArgumentException::class) + fun whenMissingDataTypeThenExceptionThrown() = runTest { + testee.decode("bmFtZSx1cmwsdXNlcm5hbW") + } + + @Test(expected = IllegalArgumentException::class) + fun whenEmptyInputThenExceptionThrow() = runTest { + testee.decode("") + } + + companion object { + private const val DATA_TYPE_PREFIX = "data:text/csv;charset=utf-8;;base64," + } +} diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/GooglePasswordManagerCsvCredentialConverterTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/GooglePasswordManagerCsvCredentialConverterTest.kt new file mode 100644 index 000000000000..587e6ec53cab --- /dev/null +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/GooglePasswordManagerCsvCredentialConverterTest.kt @@ -0,0 +1,82 @@ +package com.duckduckgo.autofill.impl.importing + +import com.duckduckgo.autofill.api.domain.app.LoginCredentials +import com.duckduckgo.autofill.impl.importing.CsvCredentialConverter.CsvCredentialImportResult +import com.duckduckgo.autofill.impl.importing.CsvCredentialParser.ParseResult +import com.duckduckgo.common.test.CoroutineTestRule +import kotlinx.coroutines.test.runTest +import org.junit.Assert.* +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +class GooglePasswordManagerCsvCredentialConverterTest { + + @get:Rule + val coroutineTestRule: CoroutineTestRule = CoroutineTestRule() + + private val parser: CsvCredentialParser = mock() + private val fileReader: CsvFileReader = mock() + private val passthroughValidator = object : ImportedCredentialValidator { + override fun isValid(loginCredentials: LoginCredentials): Boolean = true + } + private val passthroughDomainNormalizer = object : DomainNameNormalizer { + override suspend fun normalizeDomains(unnormalized: List): List { + return unnormalized + } + } + private val blobDecoder: GooglePasswordBlobDecoder = mock() + + private val testee = GooglePasswordManagerCsvCredentialConverter( + parser = parser, + fileReader = fileReader, + credentialValidator = passthroughValidator, + domainNameNormalizer = passthroughDomainNormalizer, + dispatchers = coroutineTestRule.testDispatcherProvider, + blobDecoder = blobDecoder, + ) + + @Before + fun before() = runTest { + whenever(blobDecoder.decode(any())).thenReturn("") + } + + @Test + fun whenBlobDecodedIntoEmptyListThenSuccessReturned() = runTest { + val result = configureParseResult(emptyList()) + assertEquals(0, result.numberCredentialsInSource) + assertEquals(0, result.loginCredentialsToImport.size) + } + + @Test + fun whenBlobDecodedIntoSingleItemNotADuplicateListThenSuccessReturned() = runTest { + val importSource = listOf(creds()) + val result = configureParseResult(importSource) + assertEquals(1, result.numberCredentialsInSource) + assertEquals(1, result.loginCredentialsToImport.size) + } + + private suspend fun configureParseResult(passwords: List): CsvCredentialImportResult.Success { + whenever(parser.parseCsv(any())).thenReturn(ParseResult.Success(passwords)) + return testee.readCsv("") as CsvCredentialImportResult.Success + } + + private fun creds( + domain: String? = "example.com", + username: String? = "username", + password: String? = "password", + notes: String? = "notes", + domainTitle: String? = "example title", + ): LoginCredentials { + return LoginCredentials( + domainTitle = domainTitle, + domain = domain, + username = username, + password = password, + notes = notes, + ) + } +} diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/GooglePasswordManagerCsvPasswordParserTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/GooglePasswordManagerCsvCredentialParserTest.kt similarity index 51% rename from autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/GooglePasswordManagerCsvPasswordParserTest.kt rename to autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/GooglePasswordManagerCsvCredentialParserTest.kt index a965cbab10c5..d48ccbe6e9cd 100644 --- a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/GooglePasswordManagerCsvPasswordParserTest.kt +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/GooglePasswordManagerCsvCredentialParserTest.kt @@ -1,128 +1,143 @@ package com.duckduckgo.autofill.impl.importing import com.duckduckgo.autofill.api.domain.app.LoginCredentials +import com.duckduckgo.autofill.impl.importing.CsvCredentialParser.ParseResult.Success import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.common.test.FileUtilities import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test -class GooglePasswordManagerCsvPasswordParserTest { +class GooglePasswordManagerCsvCredentialParserTest { @get:Rule val coroutineTestRule: CoroutineTestRule = CoroutineTestRule() - private val testee = GooglePasswordManagerCsvPasswordParser( + private val testee = GooglePasswordManagerCsvCredentialParser( dispatchers = coroutineTestRule.testDispatcherProvider, ) @Test fun whenEmptyStringThenNoPasswords() = runTest { - val passwords = testee.parseCsv("") - assertEquals(0, passwords.size) + val result = testee.parseCsv("") + assertTrue(result is CsvCredentialParser.ParseResult.Error) } @Test - fun whenHeaderRowOnlyThenNoPasswords() = runTest { + fun whenHeaderRowOnlyThenNoCredentials() = runTest { val csv = "gpm_import_header_row_only".readFile() - val passwords = testee.parseCsv(csv) - assertEquals(0, passwords.size) + with(testee.parseCsv(csv) as Success) { + assertEquals(0, credentials.size) + } } @Test - fun whenHeaderRowHasUnknownFieldThenNoPasswords() = runTest { + fun whenHeaderRowHasUnknownFieldThenNoCredentials() = runTest { val csv = "gpm_import_header_row_unknown_field".readFile() - val passwords = testee.parseCsv(csv) - assertEquals(0, passwords.size) + val result = testee.parseCsv(csv) + assertTrue(result is CsvCredentialParser.ParseResult.Error) } @Test - fun whenHeadersRowAndOnePasswordRowThen1Password() = runTest { + fun whenHeadersRowAndOneCredentialsRowThen1Credential() = runTest { val csv = "gpm_import_one_valid_basic_password".readFile() - val passwords = testee.parseCsv(csv) - assertEquals(1, passwords.size) - passwords.first().verifyMatchesCreds1() + with(testee.parseCsv(csv) as Success) { + assertEquals(1, credentials.size) + credentials.first().verifyMatchesCreds1() + } } @Test fun whenHeadersRowAndTwoDifferentPasswordsThen2Passwords() = runTest { val csv = "gpm_import_two_valid_basic_passwords".readFile() - val passwords = testee.parseCsv(csv) - assertEquals(2, passwords.size) - passwords[0].verifyMatchesCreds1() - passwords[1].verifyMatchesCreds2() + with(testee.parseCsv(csv) as Success) { + assertEquals(2, credentials.size) + credentials[0].verifyMatchesCreds1() + credentials[1].verifyMatchesCreds2() + } } @Test fun whenTwoIdenticalPasswordsThen2Passwords() = runTest { val csv = "gpm_import_two_valid_identical_passwords".readFile() - val passwords = testee.parseCsv(csv) - assertEquals(2, passwords.size) - passwords[0].verifyMatchesCreds1() - passwords[1].verifyMatchesCreds1() + with(testee.parseCsv(csv) as Success) { + assertEquals(2, credentials.size) + credentials[0].verifyMatchesCreds1() + credentials[1].verifyMatchesCreds1() + } } @Test fun whenPasswordContainsACommaThenIsParsedSuccessfully() = runTest { val csv = "gpm_import_password_has_a_comma".readFile() - val passwords = testee.parseCsv(csv) - - assertEquals(1, passwords.size) - val expected = LoginCredentials( - domain = "https://example.com", - domainTitle = "example.com", - username = "user", - password = "password, a comma it has", - notes = "notes", - ) - passwords.first().verifyMatches(expected) + with(testee.parseCsv(csv) as Success) { + assertEquals(1, credentials.size) + val expected = LoginCredentials( + domain = "https://example.com", + domainTitle = "example.com", + username = "user", + password = "password, a comma it has", + notes = "notes", + ) + credentials.first().verifyMatches(expected) + } } @Test fun whenPasswordContainsOtherSpecialCharactersThenIsParsedSuccessfully() = runTest { val csv = "gpm_import_password_has_special_characters".readFile() - val passwords = testee.parseCsv(csv) - - assertEquals(1, passwords.size) - val expected = creds1.copy(password = "p\$ssw0rd`\"[]'\\") - passwords.first().verifyMatches(expected) + with(testee.parseCsv(csv) as Success) { + assertEquals(1, credentials.size) + val expected = creds1.copy(password = "p\$ssw0rd`\"[]'\\") + credentials.first().verifyMatches(expected) + } } @Test fun whenNotesIsEmptyThenIsParsedSuccessfully() = runTest { val csv = "gpm_import_missing_notes".readFile() - val passwords = testee.parseCsv(csv) - - assertEquals(1, passwords.size) - passwords.first().verifyMatches(creds1.copy(notes = null)) + with(testee.parseCsv(csv) as Success) { + assertEquals(1, credentials.size) + credentials.first().verifyMatches(creds1.copy(notes = null)) + } } @Test fun whenUsernameIsEmptyThenIsParsedSuccessfully() = runTest { val csv = "gpm_import_missing_username".readFile() - val passwords = testee.parseCsv(csv) - - assertEquals(1, passwords.size) - passwords.first().verifyMatches(creds1.copy(username = null)) + with(testee.parseCsv(csv) as Success) { + assertEquals(1, credentials.size) + credentials.first().verifyMatches(creds1.copy(username = null)) + } } @Test fun whenPasswordIsEmptyThenIsParsedSuccessfully() = runTest { val csv = "gpm_import_missing_password".readFile() - val passwords = testee.parseCsv(csv) - - assertEquals(1, passwords.size) - passwords.first().verifyMatches(creds1.copy(password = null)) + with(testee.parseCsv(csv) as Success) { + assertEquals(1, credentials.size) + credentials.first().verifyMatches(creds1.copy(password = null)) + } } @Test fun whenTitleIsEmptyThenIsParsedSuccessfully() = runTest { val csv = "gpm_import_missing_title".readFile() - val passwords = testee.parseCsv(csv) + with(testee.parseCsv(csv) as Success) { + assertEquals(1, credentials.size) + credentials.first().verifyMatches(creds1.copy(domainTitle = null)) + } + } - assertEquals(1, passwords.size) - passwords.first().verifyMatches(creds1.copy(domainTitle = null)) + @Test + fun whenDomainIsEmptyThenIsParsedSuccessfully() = runTest { + val csv = "gpm_import_missing_domain".readFile() + with(testee.parseCsv(csv) as Success) { + assertEquals(1, credentials.size) + credentials.first().verifyMatches(creds1.copy(domain = null)) + } } private fun LoginCredentials.verifyMatchesCreds1() = verifyMatches(creds1) @@ -155,7 +170,7 @@ class GooglePasswordManagerCsvPasswordParserTest { private fun String.readFile(): String { val fileContents = kotlin.runCatching { FileUtilities.loadText( - GooglePasswordManagerCsvPasswordParserTest::class.java.classLoader!!, + GooglePasswordManagerCsvCredentialParserTest::class.java.classLoader!!, "csv/autofill/$this.csv", ) }.getOrNull() diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/urlmatcher/AutofillDomainNameUrlMatcherTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/urlmatcher/AutofillDomainNameUrlMatcherTest.kt index fed68f4326ae..53254554e9bc 100644 --- a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/urlmatcher/AutofillDomainNameUrlMatcherTest.kt +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/urlmatcher/AutofillDomainNameUrlMatcherTest.kt @@ -255,6 +255,11 @@ class AutofillDomainNameUrlMatcherTest { assertFalse(testee.matchingForAutofill(visitedSite, savedSite)) } + @Test + fun whenCleanRawUrlWithEmptyStringThenEmptyStringReturned() { + assertEquals("", testee.cleanRawUrl("")) + } + @Test fun whenCleanRawUrlThenReturnOnlySchemeAndDomain() { assertEquals("www.foo.com", testee.cleanRawUrl("https://www.foo.com/path/to/foo?key=value")) diff --git a/autofill/autofill-impl/src/test/resources/csv/autofill/gpm_import_missing_domain.csv b/autofill/autofill-impl/src/test/resources/csv/autofill/gpm_import_missing_domain.csv new file mode 100644 index 000000000000..12439aed6c7a --- /dev/null +++ b/autofill/autofill-impl/src/test/resources/csv/autofill/gpm_import_missing_domain.csv @@ -0,0 +1,2 @@ +name,url,username,password,note +example.com,,user,password,note \ No newline at end of file diff --git a/autofill/autofill-internal/src/main/java/com/duckduckgo/autofill/internal/AutofillInternalSettingsActivity.kt b/autofill/autofill-internal/src/main/java/com/duckduckgo/autofill/internal/AutofillInternalSettingsActivity.kt index 5ea4fc90179d..8338824825bd 100644 --- a/autofill/autofill-internal/src/main/java/com/duckduckgo/autofill/internal/AutofillInternalSettingsActivity.kt +++ b/autofill/autofill-internal/src/main/java/com/duckduckgo/autofill/internal/AutofillInternalSettingsActivity.kt @@ -16,15 +16,19 @@ package com.duckduckgo.autofill.internal +import android.annotation.SuppressLint +import android.app.Activity import android.content.Context import android.content.Intent import android.os.Bundle import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle.State.STARTED import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import com.duckduckgo.anvil.annotations.InjectWith +import com.duckduckgo.app.tabs.BrowserNav import com.duckduckgo.autofill.api.AutofillFeature import com.duckduckgo.autofill.api.AutofillScreens.AutofillSettingsScreen import com.duckduckgo.autofill.api.AutofillSettingsLaunchSource.InternalDevSettings @@ -33,6 +37,12 @@ import com.duckduckgo.autofill.api.email.EmailManager import com.duckduckgo.autofill.impl.configuration.AutofillJavascriptEnvironmentConfiguration import com.duckduckgo.autofill.impl.email.incontext.store.EmailProtectionInContextDataStore import com.duckduckgo.autofill.impl.engagement.store.AutofillEngagementRepository +import com.duckduckgo.autofill.impl.importing.CredentialImporter +import com.duckduckgo.autofill.impl.importing.CredentialImporter.ImportResult.Finished +import com.duckduckgo.autofill.impl.importing.CredentialImporter.ImportResult.InProgress +import com.duckduckgo.autofill.impl.importing.CsvCredentialConverter +import com.duckduckgo.autofill.impl.importing.CsvCredentialConverter.CsvCredentialImportResult +import com.duckduckgo.autofill.impl.importing.gpm.feature.AutofillImportPasswordConfigStore import com.duckduckgo.autofill.impl.reporting.AutofillSiteBreakageReportingDataStore import com.duckduckgo.autofill.impl.store.InternalAutofillStore import com.duckduckgo.autofill.impl.store.NeverSavedSiteRepository @@ -50,6 +60,7 @@ import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.ActivityScope import com.duckduckgo.feature.toggles.api.Toggle import com.duckduckgo.navigation.api.GlobalActivityStarter +import com.google.android.material.snackbar.Snackbar import java.text.SimpleDateFormat import javax.inject.Inject import kotlinx.coroutines.flow.first @@ -77,6 +88,12 @@ class AutofillInternalSettingsActivity : DuckDuckGoActivity() { @Inject lateinit var autofillStore: InternalAutofillStore + @Inject + lateinit var credentialImporter: CredentialImporter + + @Inject + lateinit var browserNav: BrowserNav + @Inject lateinit var autofillPrefsStore: AutofillPrefsStore @@ -103,6 +120,56 @@ class AutofillInternalSettingsActivity : DuckDuckGoActivity() { @Inject lateinit var reportBreakageDataStore: AutofillSiteBreakageReportingDataStore + @Inject + lateinit var csvCredentialConverter: CsvCredentialConverter + + @Inject + lateinit var autofillImportPasswordConfigStore: AutofillImportPasswordConfigStore + + private val importCsvLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode == Activity.RESULT_OK) { + val data: Intent? = result.data + val fileUrl = data?.data + + logcat { "cdr onActivityResult for CSV file request. resultCode=${result.resultCode}. uri=$fileUrl" } + if (fileUrl != null) { + lifecycleScope.launch { + when (val parseResult = csvCredentialConverter.readCsv(fileUrl)) { + is CsvCredentialImportResult.Success -> { + val jobId = credentialImporter.import( + parseResult.loginCredentialsToImport, + parseResult.numberCredentialsInSource, + ) + observePasswordInputUpdates(jobId) + } + is CsvCredentialImportResult.Error -> { + "Failed to import passwords due to an error".showSnackbar() + } + } + } + } + } + } + + private fun observePasswordInputUpdates(jobId: String) { + lifecycleScope.launch { + repeatOnLifecycle(STARTED) { + credentialImporter.getImportStatus(jobId).collect { + when (it) { + is InProgress -> { + logcat { "cdr import status: $it" } + } + + is Finished -> { + logcat { "cdr Imported ${it.savedCredentialIds.size} passwords" } + "Imported ${it.savedCredentialIds.size} passwords".showSnackbar() + } + } + } + } + } + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(binding.root) @@ -170,6 +237,7 @@ class AutofillInternalSettingsActivity : DuckDuckGoActivity() { configureEngagementEventHandlers() configureReportBreakagesHandlers() configureDeclineCounterHandlers() + configureImportPasswordsEventHandlers() } private fun configureReportBreakagesHandlers() { @@ -181,6 +249,24 @@ class AutofillInternalSettingsActivity : DuckDuckGoActivity() { } } + @SuppressLint("QueryPermissionsNeeded") + private fun configureImportPasswordsEventHandlers() { + binding.importPasswordsLaunchGooglePasswordWebpage.setClickListener { + lifecycleScope.launch(dispatchers.io()) { + val googlePasswordsUrl = autofillImportPasswordConfigStore.getConfig().launchUrlGooglePasswords + startActivity(browserNav.openInNewTab(this@AutofillInternalSettingsActivity, googlePasswordsUrl)) + } + } + + binding.importPasswordsImportCsv.setClickListener { + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = "*/*" + } + importCsvLauncher.launch(intent) + } + } + private fun configureEngagementEventHandlers() { binding.engagementClearEngagementHistoryButton.setOnClickListener { lifecycleScope.launch(dispatchers.io()) { @@ -444,6 +530,10 @@ class AutofillInternalSettingsActivity : DuckDuckGoActivity() { } } + private fun String.showSnackbar(duration: Int = Snackbar.LENGTH_LONG) { + Snackbar.make(binding.root, this, duration).show() + } + private fun Context.daysInstalledOverrideOptions(): List> { return listOf( Pair(getString(R.string.autofillDevSettingsOverrideMaxInstalledOptionNever), -1), diff --git a/autofill/autofill-internal/src/main/res/layout/activity_autofill_internal_settings.xml b/autofill/autofill-internal/src/main/res/layout/activity_autofill_internal_settings.xml index 9018dec4ab59..1646042b7d70 100644 --- a/autofill/autofill-internal/src/main/res/layout/activity_autofill_internal_settings.xml +++ b/autofill/autofill-internal/src/main/res/layout/activity_autofill_internal_settings.xml @@ -85,6 +85,27 @@ android:layout_height="wrap_content" app:primaryText="@string/autofillDevSettingsViewSavedLogins" /> + + + + + + + + diff --git a/autofill/autofill-internal/src/main/res/values/donottranslate.xml b/autofill/autofill-internal/src/main/res/values/donottranslate.xml index 247348c0325d..fc2841729d65 100644 --- a/autofill/autofill-internal/src/main/res/values/donottranslate.xml +++ b/autofill/autofill-internal/src/main/res/values/donottranslate.xml @@ -37,6 +37,10 @@ Number of sites: %1$d Add sample site (fill.dev) + Import Passwords + Launch Google Passwords (normal tab) + Import CSV + Maximum number of days since install OK Cancel