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/CsvPasswordConverter.kt similarity index 63% rename from autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/CsvPasswordImporter.kt rename to autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/CsvPasswordConverter.kt index 2539628dd818..5c5954c2de26 100644 --- 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/CsvPasswordConverter.kt @@ -19,8 +19,7 @@ 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.autofill.impl.importing.CsvPasswordConverter.CsvPasswordImportResult import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.AppScope import com.squareup.anvil.annotations.ContributesBinding @@ -28,53 +27,63 @@ 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 +interface CsvPasswordConverter { + suspend fun readCsv(blob: String): CsvPasswordImportResult + suspend fun readCsv(fileUri: Uri): CsvPasswordImportResult + + sealed interface CsvPasswordImportResult : Parcelable { - sealed interface ParseResult : Parcelable { @Parcelize - data class Success(val numberPasswordsInSource: Int, val loginCredentialsToImport: List) : ParseResult + data class Success(val numberPasswordsInSource: Int, val loginCredentialsToImport: List) : CsvPasswordImportResult @Parcelize - data object Error : ParseResult + data object Error : CsvPasswordImportResult } } @ContributesBinding(AppScope::class) -class GooglePasswordManagerCsvPasswordImporter @Inject constructor( +class GooglePasswordManagerCsvPasswordConverter @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 { +) : CsvPasswordConverter { - override suspend fun readCsv(blob: String): ParseResult { + override suspend fun readCsv(blob: String): CsvPasswordImportResult { return kotlin.runCatching { withContext(dispatchers.io()) { val csv = blobDecoder.decode(blob) convertToLoginCredentials(csv) } - }.getOrElse { ParseResult.Error } + }.getOrElse { CsvPasswordImportResult.Error } } - override suspend fun readCsv(fileUri: Uri): ParseResult { + override suspend fun readCsv(fileUri: Uri): CsvPasswordImportResult { return kotlin.runCatching { withContext(dispatchers.io()) { val csv = fileReader.readCsvFile(fileUri) convertToLoginCredentials(csv) } - }.getOrElse { ParseResult.Error } + }.getOrElse { CsvPasswordImportResult.Error } + } + + private suspend fun convertToLoginCredentials(csv: String): CsvPasswordImportResult { + return when (val parseResult = parser.parseCsv(csv)) { + is CsvPasswordParser.ParseResult.Success -> { + val toImport = deduplicateAndCleanup(parseResult.passwords) + CsvPasswordImportResult.Success(parseResult.passwords.size, toImport) + } + is CsvPasswordParser.ParseResult.Error -> CsvPasswordImportResult.Error + } } - private suspend fun convertToLoginCredentials(csv: String): Success { - val allPasswords = parser.parseCsv(csv) + private suspend fun deduplicateAndCleanup(allPasswords: List): List { val dedupedPasswords = allPasswords.distinct() val validPasswords = filterValidPasswords(dedupedPasswords) val normalizedDomains = domainNameNormalizer.normalizeDomains(validPasswords) - return Success(allPasswords.size, normalizedDomains) + return normalizedDomains } private fun filterValidPasswords(passwords: List): List { 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/CsvPasswordParser.kt index f3ab7664da24..03e986597829 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/CsvPasswordParser.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.CsvPasswordParser.ParseResult +import com.duckduckgo.autofill.impl.importing.CsvPasswordParser.ParseResult.Error +import com.duckduckgo.autofill.impl.importing.CsvPasswordParser.ParseResult.Success import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.AppScope import com.squareup.anvil.annotations.ContributesBinding @@ -27,7 +30,12 @@ import kotlinx.coroutines.withContext import timber.log.Timber interface CsvPasswordParser { - suspend fun parseCsv(csv: String): List + suspend fun parseCsv(csv: String): ParseResult + + sealed interface ParseResult { + data class Success(val passwords: List) : ParseResult + data object Error : ParseResult + } } @ContributesBinding(AppScope::class) @@ -35,19 +43,17 @@ class GooglePasswordManagerCsvPasswordParser @Inject constructor( private val dispatchers: DispatcherProvider, ) : CsvPasswordParser { -// private val csvFormat by lazy { -// CSVFormat.Builder.create(CSVFormat.DEFAULT).build() -// } - - override suspend fun parseCsv(csv: String): List { + override suspend fun parseCsv(csv: String): ParseResult { return kotlin.runCatching { - convertToPasswordList(csv).also { + val passwords = convertToPasswordList(csv).also { Timber.i("Parsed CSV. Found %d passwords", it.size) } + Success(passwords) }.onFailure { Timber.e("Failed to parse CSV: %s", it.message) + Error }.getOrElse { - emptyList() + Error } } 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/ExistingPasswordMatchDetector.kt index e41892226212..7ccacc44bea8 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/ExistingPasswordMatchDetector.kt @@ -18,11 +18,12 @@ 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 { suspend fun alreadyExists(newCredentials: LoginCredentials): Boolean @@ -30,19 +31,21 @@ interface ExistingPasswordMatchDetector { @ContributesBinding(AppScope::class) class DefaultExistingPasswordMatchDetector @Inject constructor( - private val urlMatcher: AutofillUrlMatcher, private val autofillStore: InternalAutofillStore, + private val dispatchers: DispatcherProvider, ) : ExistingPasswordMatchDetector { 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..5ff008298ff8 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,13 @@ 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 = data.split(",")[1] + val decodedBytes = Base64.decode(base64Data, DEFAULT) + String(decodedBytes, Charsets.UTF_8) + }.getOrElse { rootCause -> + throw IllegalArgumentException("Unrecognized format", rootCause) + } } } } 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/ImportedPasswordValidator.kt index bb6f6e56b2b5..9c7c052ea74b 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/ImportedPasswordValidator.kt @@ -29,8 +29,15 @@ interface ImportedPasswordValidator { class DefaultImportedPasswordValidator @Inject constructor() : ImportedPasswordValidator { override fun isValid(loginCredentials: LoginCredentials): Boolean { - return with(loginCredentials) { - domain.isNullOrBlank().not() && password.isNullOrBlank().not() + with(loginCredentials) { + if (domain.isNullOrBlank()) return false + if (domain?.startsWith(APP_PASSWORD_PREFIX) == true) return false + if (password.isNullOrBlank()) return false + return true } } + + companion object { + private const val APP_PASSWORD_PREFIX = "android://" + } } diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/PasswordImporter.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/PasswordImporter.kt new file mode 100644 index 000000000000..1305504a32ed --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/PasswordImporter.kt @@ -0,0 +1,122 @@ +/* + * 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.PasswordImporter.ImportResult +import com.duckduckgo.autofill.impl.importing.PasswordImporter.ImportResult.Finished +import com.duckduckgo.autofill.impl.importing.PasswordImporter.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 PasswordImporter { + suspend fun importPasswords(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 importListOriginalSize: Int, + override val jobId: String, + ) : ImportResult + + @Parcelize + data class Finished( + val savedCredentialIds: List, + val numberSkipped: Int, + val importListOriginalSize: Int, + override val jobId: String, + ) : ImportResult + } +} + +@SingleInstanceIn(AppScope::class) +@ContributesBinding(AppScope::class) +class PasswordImporterImpl @Inject constructor( + private val existingPasswordMatchDetector: ExistingPasswordMatchDetector, + private val autofillStore: InternalAutofillStore, + private val dispatchers: DispatcherProvider, + @AppCoroutineScope private val appCoroutineScope: CoroutineScope, +) : PasswordImporter { + + private val _importStatus = MutableSharedFlow(replay = 1) + private val mutex = Mutex() + + override suspend fun importPasswords(importList: List, originalImportListSize: Int): String { + val jobId = UUID.randomUUID().toString() + + mutex.withLock { + appCoroutineScope.launch(dispatchers.io()) { + doImportPasswords(importList, originalImportListSize, jobId) + } + } + + return jobId + } + + private suspend fun doImportPasswords( + importList: List, + originalImportListSize: Int, + jobId: String, + ) { + val savedCredentialIds = mutableListOf() + var skippedPasswords = originalImportListSize - importList.size + + _importStatus.emit(InProgress(savedCredentialIds, skippedPasswords, originalImportListSize, jobId)) + + importList.forEach { + if (!existingPasswordMatchDetector.alreadyExists(it)) { + val insertedId = autofillStore.saveCredentials(it.domain!!, it)?.id + + if (insertedId != null) { + savedCredentialIds.add(insertedId) + } + } else { + skippedPasswords++ + } + + _importStatus.emit(InProgress(savedCredentialIds, skippedPasswords, originalImportListSize, jobId)) + } + + _importStatus.emit(Finished(savedCredentialIds, skippedPasswords, originalImportListSize, jobId)) + } + + override fun getImportStatus(jobId: String): Flow { + return _importStatus.filter { result -> + result.jobId == jobId + } + } +} 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/DefaultExistingPasswordMatchDetectorTest.kt index 30512fc2296d..00d9e7f77866 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/DefaultExistingPasswordMatchDetectorTest.kt @@ -2,13 +2,13 @@ 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 @@ -17,11 +17,13 @@ import org.mockito.kotlin.whenever @RunWith(AndroidJUnit4::class) class DefaultExistingPasswordMatchDetectorTest { + @get:Rule + val coroutineTestRule: CoroutineTestRule = CoroutineTestRule() private val autofillStore: InternalAutofillStore = mock() private val testee = DefaultExistingPasswordMatchDetector( - urlMatcher = AutofillDomainNameUrlMatcher(UrlUnicodeNormalizerImpl()), autofillStore = autofillStore, + dispatchers = coroutineTestRule.testDispatcherProvider, ) @Test 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 index 42b8c53c5afd..e331b11a2909 100644 --- 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 @@ -8,10 +8,28 @@ class DefaultImportedPasswordValidatorTest { private val testee = DefaultImportedPasswordValidator() @Test - fun whenThen() { + fun whenDomainAndPasswordPopulatedThenIsValid() { assertTrue(testee.isValid(validCreds())) } + @Test + fun whenDomainMissingThenIsInvalid() { + val missingDomain = validCreds().copy(domain = null) + assertFalse(testee.isValid(missingDomain)) + } + + @Test + fun whenPasswordMissingThenIsInvalid() { + val missingPassword = validCreds().copy(password = null) + assertFalse(testee.isValid(missingPassword)) + } + + @Test + fun whenDomainIsAppPasswordThenIsNotValid() { + val appPassword = validCreds().copy(domain = "android://Jz-U_hg==@com.netflix.mediaclient/") + assertFalse(testee.isValid(appPassword)) + } + private fun validCreds(): LoginCredentials { return LoginCredentials( username = "username", 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..cfd4e4bdd185 --- /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/GooglePasswordManagerCsvPasswordConverterTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/GooglePasswordManagerCsvPasswordConverterTest.kt new file mode 100644 index 000000000000..d15f3093fb0b --- /dev/null +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/GooglePasswordManagerCsvPasswordConverterTest.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.CsvPasswordConverter.CsvPasswordImportResult +import com.duckduckgo.autofill.impl.importing.CsvPasswordParser.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 GooglePasswordManagerCsvPasswordConverterTest { + + @get:Rule + val coroutineTestRule: CoroutineTestRule = CoroutineTestRule() + + private val parser: CsvPasswordParser = mock() + private val fileReader: CsvFileReader = mock() + private val passthroughValidator = object : ImportedPasswordValidator { + 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 = GooglePasswordManagerCsvPasswordConverter( + 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.numberPasswordsInSource) + assertEquals(0, result.loginCredentialsToImport.size) + } + + @Test + fun whenBlobDecodedIntoSingleItemNotADuplicateListThenSuccessReturned() = runTest { + val importSource = listOf(creds()) + val result = configureParseResult(importSource) + assertEquals(1, result.numberPasswordsInSource) + assertEquals(1, result.loginCredentialsToImport.size) + } + + private suspend fun configureParseResult(passwords: List): CsvPasswordImportResult.Success { + whenever(parser.parseCsv(any())).thenReturn(ParseResult.Success(passwords)) + return testee.readCsv("") as CsvPasswordImportResult.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/GooglePasswordManagerCsvPasswordParserTest.kt index a965cbab10c5..c52bd6786be8 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/GooglePasswordManagerCsvPasswordParserTest.kt @@ -1,10 +1,12 @@ package com.duckduckgo.autofill.impl.importing import com.duckduckgo.autofill.api.domain.app.LoginCredentials +import com.duckduckgo.autofill.impl.importing.CsvPasswordParser.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 @@ -19,110 +21,123 @@ class GooglePasswordManagerCsvPasswordParserTest { @Test fun whenEmptyStringThenNoPasswords() = runTest { - val passwords = testee.parseCsv("") - assertEquals(0, passwords.size) + val result = testee.parseCsv("") + assertTrue(result is CsvPasswordParser.ParseResult.Error) } @Test fun whenHeaderRowOnlyThenNoPasswords() = 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, passwords.size) + } } @Test fun whenHeaderRowHasUnknownFieldThenNoPasswords() = 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 CsvPasswordParser.ParseResult.Error) } @Test fun whenHeadersRowAndOnePasswordRowThen1Password() = 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, passwords.size) + passwords.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, passwords.size) + passwords[0].verifyMatchesCreds1() + passwords[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, passwords.size) + passwords[0].verifyMatchesCreds1() + passwords[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, 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) + } } @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, passwords.size) + val expected = creds1.copy(password = "p\$ssw0rd`\"[]'\\") + passwords.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, passwords.size) + passwords.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, passwords.size) + passwords.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, passwords.size) + passwords.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, passwords.size) + passwords.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, passwords.size) + passwords.first().verifyMatches(creds1.copy(domain = null)) + } } private fun LoginCredentials.verifyMatchesCreds1() = verifyMatches(creds1) diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/PasswordImporterImplTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/PasswordImporterImplTest.kt new file mode 100644 index 000000000000..5a3d21277c13 --- /dev/null +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/PasswordImporterImplTest.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.PasswordImporter.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 PasswordImporterImplTest { + @get:Rule + val coroutineTestRule: CoroutineTestRule = CoroutineTestRule() + private val existingPasswordMatchDetector: ExistingPasswordMatchDetector = mock() + private val autofillStore: InternalAutofillStore = mock() + private val dispatchers = coroutineTestRule.testDispatcherProvider + private val appCoroutineScope: CoroutineScope = coroutineTestRule.testScope + + private val testee = PasswordImporterImpl( + existingPasswordMatchDetector = existingPasswordMatchDetector, + autofillStore = autofillStore, + dispatchers = dispatchers, + appCoroutineScope = appCoroutineScope, + ) + + private var nextId = 0L + + @Before + fun before() = runTest { + whenever(existingPasswordMatchDetector.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(existingPasswordMatchDetector.alreadyExists(duplicatedLogin)).thenReturn(true) + val jobId = listOf(duplicatedLogin).import() + jobId.assertResult(numberSkippedExpected = 1, importListSizeExpected = 1) + } + + @Test + fun whenImportingMultipleItemsAllDuplicatesThenResultIsCorrect() = runTest { + val duplicatedLogin1 = creds(username = "username1") + val duplicatedLogin2 = creds(username = "username2") + whenever(existingPasswordMatchDetector.alreadyExists(duplicatedLogin1)).thenReturn(true) + whenever(existingPasswordMatchDetector.alreadyExists(duplicatedLogin2)).thenReturn(true) + + val jobId = listOf(duplicatedLogin1, duplicatedLogin2).import() + jobId.assertResult(numberSkippedExpected = 2, importListSizeExpected = 2) + } + + @Test + fun whenImportingMultipleItemsSomeDuplicatesThenResultIsCorrect() = runTest { + val duplicatedLogin1 = creds(username = "username1") + val duplicatedLogin2 = creds(username = "username2") + val notADuplicate = creds(username = "username3") + whenever(existingPasswordMatchDetector.alreadyExists(duplicatedLogin1)).thenReturn(true) + whenever(existingPasswordMatchDetector.alreadyExists(duplicatedLogin2)).thenReturn(true) + whenever(existingPasswordMatchDetector.alreadyExists(notADuplicate)).thenReturn(false) + + val jobId = listOf(duplicatedLogin1, duplicatedLogin2, notADuplicate).import() + jobId.assertResult(numberSkippedExpected = 2, importListSizeExpected = 3) + } + + @Test + fun whenAllPasswordsSkippedAlreadyBeforeImportThenResultIsCorrect() = runTest { + val jobId = listOf().import(originalListSize = 3) + jobId.assertResult(numberSkippedExpected = 3, importListSizeExpected = 3) + } + + @Test + fun whenSomePasswordsSkippedAlreadyBeforeImportThenResultIsCorrect() = runTest { + val jobId = listOf(creds()).import(originalListSize = 3) + jobId.assertResult(numberSkippedExpected = 2, importListSizeExpected = 3) + } + + private suspend fun List.import(originalListSize: Int = this.size): String { + return testee.importPasswords(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, importListOriginalSize) + } + } + } + + 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/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..19d2ee6a1d4a 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,11 @@ 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.CsvPasswordConverter +import com.duckduckgo.autofill.impl.importing.CsvPasswordConverter.CsvPasswordImportResult +import com.duckduckgo.autofill.impl.importing.PasswordImporter +import com.duckduckgo.autofill.impl.importing.PasswordImporter.ImportResult.Finished +import com.duckduckgo.autofill.impl.importing.PasswordImporter.ImportResult.InProgress import com.duckduckgo.autofill.impl.reporting.AutofillSiteBreakageReportingDataStore import com.duckduckgo.autofill.impl.store.InternalAutofillStore import com.duckduckgo.autofill.impl.store.NeverSavedSiteRepository @@ -50,6 +59,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 +87,12 @@ class AutofillInternalSettingsActivity : DuckDuckGoActivity() { @Inject lateinit var autofillStore: InternalAutofillStore + @Inject + lateinit var passwordImporter: PasswordImporter + + @Inject + lateinit var browserNav: BrowserNav + @Inject lateinit var autofillPrefsStore: AutofillPrefsStore @@ -103,6 +119,50 @@ class AutofillInternalSettingsActivity : DuckDuckGoActivity() { @Inject lateinit var reportBreakageDataStore: AutofillSiteBreakageReportingDataStore + @Inject + lateinit var csvPasswordConverter: CsvPasswordConverter + + 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 = csvPasswordConverter.readCsv(fileUrl)) { + is CsvPasswordImportResult.Success -> { + val jobId = passwordImporter.importPasswords(parseResult.loginCredentialsToImport, parseResult.numberPasswordsInSource) + observePasswordInputUpdates(jobId) + } + is CsvPasswordImportResult.Error -> { + "Failed to import passwords due to an error".showSnackbar() + } + } + } + } + } + } + + private fun observePasswordInputUpdates(jobId: String) { + lifecycleScope.launch { + repeatOnLifecycle(STARTED) { + passwordImporter.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 +230,7 @@ class AutofillInternalSettingsActivity : DuckDuckGoActivity() { configureEngagementEventHandlers() configureReportBreakagesHandlers() configureDeclineCounterHandlers() + configureImportPasswordsEventHandlers() } private fun configureReportBreakagesHandlers() { @@ -181,6 +242,22 @@ class AutofillInternalSettingsActivity : DuckDuckGoActivity() { } } + @SuppressLint("QueryPermissionsNeeded") + private fun configureImportPasswordsEventHandlers() { + binding.importPasswordsLaunchGooglePasswordWebpage.setClickListener { + val googlePasswordsUrl = "https://passwords.google.com/options?ep=1" + startActivity(browserNav.openInNewTab(this, 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 +521,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