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 Nov 8, 2024
1 parent d564643 commit 06d2c9a
Show file tree
Hide file tree
Showing 22 changed files with 894 additions and 236 deletions.
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.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<LoginCredentials>, originalImportListSize: Int): String
fun getImportStatus(jobId: String): Flow<ImportResult>

sealed interface ImportResult : Parcelable {
val jobId: String

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

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

override suspend fun import(importList: List<LoginCredentials>, 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<LoginCredentials>,
originalImportListSize: Int,
jobId: String,
) {
val savedCredentialIds = mutableListOf<Long>()
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<ImportResult> {
return _importStatus.filter { result ->
result.jobId == jobId
}
}
}
Original file line number Diff line number Diff line change
@@ -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<LoginCredentials>) : 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<LoginCredentials>): List<LoginCredentials> {
val dedupedCredentials = allCredentials.distinct()
val validCredentials = dedupedCredentials.filter { credentialValidator.isValid(it) }
val normalizedDomains = domainNameNormalizer.normalizeDomains(validCredentials)
return normalizedDomains
}
}
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.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
Expand All @@ -26,36 +29,39 @@ import javax.inject.Inject
import kotlinx.coroutines.withContext
import timber.log.Timber

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

sealed interface ParseResult {
data class Success(val credentials: List<LoginCredentials>) : 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<LoginCredentials> {
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
}
}

/**
* Format of the Google Password Manager CSV is:
* name | url | username | password | note
*/
private suspend fun convertToPasswordList(csv: String): List<LoginCredentials> {
private suspend fun convertToCredentials(csv: String): List<LoginCredentials> {
return withContext(dispatchers.io()) {
val lines = mutableListOf<CsvRow>()
val iter = CsvReader.builder().build(csv).spliterator()
Expand All @@ -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
}

Expand Down Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
}
} ?: ""
}
}
}
Loading

0 comments on commit 06d2c9a

Please sign in to comment.