Skip to content

Commit

Permalink
Add developer settings for importing passwords from Google Password M…
Browse files Browse the repository at this point in the history
…anager
  • Loading branch information
CDRussell committed Oct 29, 2024
1 parent 99efe20 commit 8da096d
Show file tree
Hide file tree
Showing 15 changed files with 632 additions and 90 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -19,62 +19,70 @@ 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
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 ParseResult : Parcelable {
sealed interface CsvPasswordImportResult : Parcelable {
@Parcelize
data class Success(val numberPasswordsInSource: Int, val loginCredentialsToImport: List<LoginCredentials>) : ParseResult
data class Success(val numberPasswordsInSource: Int, val loginCredentialsToImport: List<LoginCredentials>) : 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): Success {
val allPasswords = parser.parseCsv(csv)
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 deduplicateAndCleanup(allPasswords: List<LoginCredentials>): List<LoginCredentials> {
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<LoginCredentials>): List<LoginCredentials> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -27,27 +30,30 @@ import kotlinx.coroutines.withContext
import timber.log.Timber

interface CsvPasswordParser {
suspend fun parseCsv(csv: String): List<LoginCredentials>
suspend fun parseCsv(csv: String): ParseResult

sealed interface ParseResult {
data class Success(val passwords: List<LoginCredentials>) : ParseResult
data object Error : ParseResult
}
}

@ContributesBinding(AppScope::class)
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<LoginCredentials> {
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
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
suspend fun alreadyExists(newCredentials: LoginCredentials): Boolean
}

@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
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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.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<LoginCredentials>): String
fun getImportStatus(jobId: String): Flow<ImportResult>

sealed interface ImportResult : Parcelable {
val jobId: String

@Parcelize
data class InProgress(
val savedCredentialIds: List<Long>,
val duplicatedPasswords: List<LoginCredentials>,
val importListSize: Int,
override val jobId: String,
) : ImportResult

@Parcelize
data class Finished(
val savedCredentialIds: List<Long>,
val duplicatedPasswords: List<LoginCredentials>,
val importListSize: 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<ImportResult>(replay = 1)
private val mutex = Mutex()

override suspend fun importPasswords(importList: List<LoginCredentials>): String {
val jobId = UUID.randomUUID().toString()

mutex.withLock {
appCoroutineScope.launch(dispatchers.io()) {
doImportPasswords(importList, jobId)
}
}

return jobId
}

private suspend fun doImportPasswords(
importList: List<LoginCredentials>,
jobId: String,
) {
val savedCredentialIds = mutableListOf<Long>()
val duplicatedPasswords = mutableListOf<LoginCredentials>()

_importStatus.emit(InProgress(savedCredentialIds, duplicatedPasswords, importList.size, jobId))

importList.forEach {
if (!existingPasswordMatchDetector.alreadyExists(it)) {
val insertedId = autofillStore.saveCredentials(it.domain!!, it)?.id

if (insertedId != null) {
savedCredentialIds.add(insertedId)
}
} else {
duplicatedPasswords.add(it)
}

_importStatus.emit(InProgress(savedCredentialIds, duplicatedPasswords, importList.size, jobId))
}

_importStatus.emit(Finished(savedCredentialIds, duplicatedPasswords, importList.size, jobId))
}

override fun getImportStatus(jobId: String): Flow<ImportResult> {
return _importStatus.filter { result ->
result.jobId == jobId
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,22 @@ 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))
}

private fun validCreds(): LoginCredentials {
return LoginCredentials(
username = "username",
Expand Down
Loading

0 comments on commit 8da096d

Please sign in to comment.