Skip to content

Commit 632723d

Browse files
committed
Add ability to support importing passwords from Google as CSV
1 parent 4c51a0a commit 632723d

24 files changed

+789
-0
lines changed

autofill/autofill-impl/build.gradle

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ plugins {
1818
id 'com.android.library'
1919
id 'kotlin-android'
2020
id 'com.squareup.anvil'
21+
id 'kotlin-parcelize'
2122
}
2223

2324
apply from: "$rootProject.projectDir/gradle/android-library.gradle"
@@ -59,6 +60,8 @@ dependencies {
5960

6061
implementation "androidx.datastore:datastore-preferences:_"
6162

63+
implementation "de.siegmar:fastcsv:_"
64+
6265
implementation Square.retrofit2.converter.moshi
6366
implementation "com.squareup.moshi:moshi-kotlin:_"
6467
implementation "com.squareup.moshi:moshi-adapters:_"
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/*
2+
* Copyright (c) 2024 DuckDuckGo
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.duckduckgo.autofill.impl.importing
18+
19+
import android.content.Context
20+
import android.net.Uri
21+
import com.duckduckgo.di.scopes.AppScope
22+
import com.squareup.anvil.annotations.ContributesBinding
23+
import java.io.BufferedReader
24+
import java.io.InputStreamReader
25+
import javax.inject.Inject
26+
27+
interface CsvFileReader {
28+
fun readCsvFile(fileUri: Uri): String
29+
}
30+
31+
@ContributesBinding(AppScope::class)
32+
class ContentResolverFileReader @Inject constructor(
33+
private val context: Context,
34+
) : CsvFileReader {
35+
36+
override fun readCsvFile(fileUri: Uri): String {
37+
return context.contentResolver.openInputStream(fileUri)?.use { inputStream ->
38+
BufferedReader(InputStreamReader(inputStream)).use { reader ->
39+
reader.readText()
40+
}
41+
} ?: ""
42+
}
43+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
/*
2+
* Copyright (c) 2024 DuckDuckGo
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.duckduckgo.autofill.impl.importing
18+
19+
import android.net.Uri
20+
import android.os.Parcelable
21+
import com.duckduckgo.autofill.api.domain.app.LoginCredentials
22+
import com.duckduckgo.autofill.impl.importing.CsvPasswordImporter.ParseResult
23+
import com.duckduckgo.autofill.impl.importing.CsvPasswordImporter.ParseResult.Success
24+
import com.duckduckgo.common.utils.DispatcherProvider
25+
import com.duckduckgo.di.scopes.AppScope
26+
import com.squareup.anvil.annotations.ContributesBinding
27+
import javax.inject.Inject
28+
import kotlinx.coroutines.withContext
29+
import kotlinx.parcelize.Parcelize
30+
31+
interface CsvPasswordImporter {
32+
suspend fun readCsv(blob: String): ParseResult
33+
suspend fun readCsv(fileUri: Uri): ParseResult
34+
35+
sealed interface ParseResult : Parcelable {
36+
@Parcelize
37+
data class Success(val numberPasswordsInSource: Int, val loginCredentialsToImport: List<LoginCredentials>) : ParseResult
38+
39+
@Parcelize
40+
data object Error : ParseResult
41+
}
42+
}
43+
44+
@ContributesBinding(AppScope::class)
45+
class GooglePasswordManagerCsvPasswordImporter @Inject constructor(
46+
private val parser: CsvPasswordParser,
47+
private val fileReader: CsvFileReader,
48+
private val credentialValidator: ImportedPasswordValidator,
49+
private val domainNameNormalizer: DomainNameNormalizer,
50+
private val dispatchers: DispatcherProvider,
51+
private val blobDecoder: GooglePasswordBlobDecoder,
52+
) : CsvPasswordImporter {
53+
54+
override suspend fun readCsv(blob: String): ParseResult {
55+
return kotlin.runCatching {
56+
withContext(dispatchers.io()) {
57+
val csv = blobDecoder.decode(blob)
58+
convertToLoginCredentials(csv)
59+
}
60+
}.getOrElse { ParseResult.Error }
61+
}
62+
63+
override suspend fun readCsv(fileUri: Uri): ParseResult {
64+
return kotlin.runCatching {
65+
withContext(dispatchers.io()) {
66+
val csv = fileReader.readCsvFile(fileUri)
67+
convertToLoginCredentials(csv)
68+
}
69+
}.getOrElse { ParseResult.Error }
70+
}
71+
72+
private suspend fun convertToLoginCredentials(csv: String): Success {
73+
val allPasswords = parser.parseCsv(csv)
74+
val dedupedPasswords = allPasswords.distinct()
75+
val validPasswords = filterValidPasswords(dedupedPasswords)
76+
val normalizedDomains = domainNameNormalizer.normalizeDomains(validPasswords)
77+
return Success(allPasswords.size, normalizedDomains)
78+
}
79+
80+
private fun filterValidPasswords(passwords: List<LoginCredentials>): List<LoginCredentials> {
81+
return passwords.filter { it.isValid() }
82+
}
83+
84+
private fun LoginCredentials.isValid(): Boolean = credentialValidator.isValid(this)
85+
}
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
/*
2+
* Copyright (c) 2024 DuckDuckGo
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.duckduckgo.autofill.impl.importing
18+
19+
import com.duckduckgo.autofill.api.domain.app.LoginCredentials
20+
import com.duckduckgo.common.utils.DispatcherProvider
21+
import com.duckduckgo.di.scopes.AppScope
22+
import com.squareup.anvil.annotations.ContributesBinding
23+
import de.siegmar.fastcsv.reader.CsvReader
24+
import de.siegmar.fastcsv.reader.CsvRow
25+
import javax.inject.Inject
26+
import kotlinx.coroutines.withContext
27+
import timber.log.Timber
28+
29+
interface CsvPasswordParser {
30+
suspend fun parseCsv(csv: String): List<LoginCredentials>
31+
}
32+
33+
@ContributesBinding(AppScope::class)
34+
class GooglePasswordManagerCsvPasswordParser @Inject constructor(
35+
private val dispatchers: DispatcherProvider,
36+
) : CsvPasswordParser {
37+
38+
// private val csvFormat by lazy {
39+
// CSVFormat.Builder.create(CSVFormat.DEFAULT).build()
40+
// }
41+
42+
override suspend fun parseCsv(csv: String): List<LoginCredentials> {
43+
return kotlin.runCatching {
44+
convertToPasswordList(csv).also {
45+
Timber.i("Parsed CSV. Found %d passwords", it.size)
46+
}
47+
}.onFailure {
48+
Timber.e("Failed to parse CSV: %s", it.message)
49+
}.getOrElse {
50+
emptyList()
51+
}
52+
}
53+
54+
/**
55+
* Format of the Google Password Manager CSV is:
56+
* name | url | username | password | note
57+
*/
58+
private suspend fun convertToPasswordList(csv: String): List<LoginCredentials> {
59+
return withContext(dispatchers.io()) {
60+
val lines = mutableListOf<CsvRow>()
61+
val iter = CsvReader.builder().build(csv).spliterator()
62+
iter.forEachRemaining { lines.add(it) }
63+
Timber.d("Found %d lines in the CSV", lines.size)
64+
65+
lines.firstOrNull().verifyExpectedFormat()
66+
67+
// drop the header row
68+
val passwordsLines = lines.drop(1)
69+
70+
Timber.v("About to parse %d passwords", passwordsLines.size)
71+
return@withContext passwordsLines
72+
.mapNotNull {
73+
if (it.fields.size != EXPECTED_HEADERS.size) {
74+
Timber.w("CSV line does not match expected format. Expected ${EXPECTED_HEADERS.size} parts, found ${it.fields.size}")
75+
return@mapNotNull null
76+
}
77+
78+
parseToCredential(
79+
domainTitle = it.getField(0).blanksToNull(),
80+
domain = it.getField(1).blanksToNull(),
81+
username = it.getField(2).blanksToNull(),
82+
password = it.getField(3).blanksToNull(),
83+
notes = it.getField(4).blanksToNull(),
84+
)
85+
}
86+
}
87+
}
88+
89+
private fun parseToCredential(
90+
domainTitle: String?,
91+
domain: String?,
92+
username: String?,
93+
password: String?,
94+
notes: String?,
95+
): LoginCredentials {
96+
return LoginCredentials(
97+
domainTitle = domainTitle,
98+
domain = domain,
99+
username = username,
100+
password = password,
101+
notes = notes,
102+
)
103+
}
104+
105+
private fun String?.blanksToNull(): String? {
106+
return if (isNullOrBlank()) null else this
107+
}
108+
109+
private fun CsvRow?.verifyExpectedFormat() {
110+
if (this == null) {
111+
throw IllegalArgumentException("File not recognised as a CSV")
112+
}
113+
114+
val headers = this.fields
115+
116+
if (headers.size != EXPECTED_HEADERS.size) {
117+
throw IllegalArgumentException(
118+
"CSV header size does not match expected amount. Expected: ${EXPECTED_HEADERS.size}, found: ${headers.size}",
119+
)
120+
}
121+
122+
headers.forEachIndexed { index, value ->
123+
if (value != EXPECTED_HEADERS[index]) {
124+
throw IllegalArgumentException("CSV header does not match expected format. Expected: ${EXPECTED_HEADERS[index]}, found: $value")
125+
}
126+
}
127+
}
128+
129+
companion object {
130+
val EXPECTED_HEADERS = listOf(
131+
"name",
132+
"url",
133+
"username",
134+
"password",
135+
"note",
136+
)
137+
}
138+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/*
2+
* Copyright (c) 2024 DuckDuckGo
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.duckduckgo.autofill.impl.importing
18+
19+
import com.duckduckgo.autofill.api.domain.app.LoginCredentials
20+
import com.duckduckgo.autofill.impl.urlmatcher.AutofillUrlMatcher
21+
import com.duckduckgo.di.scopes.AppScope
22+
import com.squareup.anvil.annotations.ContributesBinding
23+
import javax.inject.Inject
24+
25+
interface DomainNameNormalizer {
26+
suspend fun normalizeDomains(unnormalized: List<LoginCredentials>): List<LoginCredentials>
27+
}
28+
29+
@ContributesBinding(AppScope::class)
30+
class DefaultDomainNameNormalizer @Inject constructor(
31+
private val urlMatcher: AutofillUrlMatcher,
32+
) : DomainNameNormalizer {
33+
override suspend fun normalizeDomains(unnormalized: List<LoginCredentials>): List<LoginCredentials> {
34+
return unnormalized.map {
35+
val currentDomain = it.domain ?: return@map null
36+
val normalizedDomain = urlMatcher.cleanRawUrl(currentDomain)
37+
it.copy(domain = normalizedDomain)
38+
}.filterNotNull()
39+
}
40+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/*
2+
* Copyright (c) 2024 DuckDuckGo
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.duckduckgo.autofill.impl.importing
18+
19+
import com.duckduckgo.autofill.api.domain.app.LoginCredentials
20+
import com.duckduckgo.autofill.impl.store.InternalAutofillStore
21+
import com.duckduckgo.autofill.impl.urlmatcher.AutofillUrlMatcher
22+
import com.duckduckgo.di.scopes.AppScope
23+
import com.squareup.anvil.annotations.ContributesBinding
24+
import javax.inject.Inject
25+
import kotlinx.coroutines.flow.firstOrNull
26+
27+
interface ExistingPasswordMatchDetector {
28+
suspend fun alreadyExists(newCredentials: LoginCredentials): Boolean
29+
}
30+
31+
@ContributesBinding(AppScope::class)
32+
class DefaultExistingPasswordMatchDetector @Inject constructor(
33+
private val urlMatcher: AutofillUrlMatcher,
34+
private val autofillStore: InternalAutofillStore,
35+
) : ExistingPasswordMatchDetector {
36+
37+
override suspend fun alreadyExists(newCredentials: LoginCredentials): Boolean {
38+
val credentials = autofillStore.getAllCredentials().firstOrNull() ?: return false
39+
40+
return credentials.any { existing ->
41+
existing.domain == newCredentials.domain &&
42+
existing.username == newCredentials.username &&
43+
existing.password == newCredentials.password &&
44+
existing.domainTitle == newCredentials.domainTitle &&
45+
existing.notes == newCredentials.notes
46+
}
47+
}
48+
}

0 commit comments

Comments
 (0)