Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Dbajpeyi/autofill gpm javascript #5045

Draft
wants to merge 5 commits into
base: develop
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ import com.duckduckgo.app.di.AppCoroutineScope
import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteEntity
import com.duckduckgo.app.fire.fireproofwebsite.data.website
import com.duckduckgo.app.global.model.PrivacyShield.UNKNOWN
import com.duckduckgo.app.global.model.Site
import com.duckduckgo.app.global.model.orderedTrackerBlockedEntities
import com.duckduckgo.app.global.view.NonDismissibleBehavior
import com.duckduckgo.app.global.view.TextChangedWatcher
Expand Down Expand Up @@ -274,6 +275,12 @@ import com.duckduckgo.common.utils.extensions.websiteFromGeoLocationsApiOrigin
import com.duckduckgo.common.utils.extractDomain
import com.duckduckgo.common.utils.playstore.PlayStoreUtils
import com.duckduckgo.common.utils.plugins.PluginPoint
import com.duckduckgo.contentscopescripts.impl.RealContentScopeScripts.Companion.contentScope
import com.duckduckgo.contentscopescripts.impl.RealContentScopeScripts.Companion.emptyJson
import com.duckduckgo.contentscopescripts.impl.RealContentScopeScripts.Companion.emptyJsonList
import com.duckduckgo.contentscopescripts.impl.RealContentScopeScripts.Companion.messagingParameters
import com.duckduckgo.contentscopescripts.impl.RealContentScopeScripts.Companion.userPreferences
import com.duckduckgo.contentscopescripts.impl.RealContentScopeScripts.Companion.userUnprotectedDomains
import com.duckduckgo.di.scopes.FragmentScope
import com.duckduckgo.downloads.api.DOWNLOAD_SNACKBAR_DELAY
import com.duckduckgo.downloads.api.DOWNLOAD_SNACKBAR_LENGTH
Expand All @@ -286,6 +293,7 @@ import com.duckduckgo.downloads.api.FileDownloader.PendingFileDownload
import com.duckduckgo.duckplayer.api.DuckPlayer
import com.duckduckgo.duckplayer.api.DuckPlayerSettingsNoParams
import com.duckduckgo.experiments.api.loadingbarexperiment.LoadingBarExperimentManager
import com.duckduckgo.feature.toggles.api.FeatureExceptions.FeatureException
import com.duckduckgo.js.messaging.api.JsCallbackData
import com.duckduckgo.js.messaging.api.JsMessageCallback
import com.duckduckgo.js.messaging.api.JsMessaging
Expand Down Expand Up @@ -319,6 +327,9 @@ import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.BaseTransientBottomBar
import com.google.android.material.snackbar.Snackbar
import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.Moshi.Builder
import com.squareup.moshi.Types
import java.io.File
import javax.inject.Inject
import javax.inject.Named
Expand All @@ -336,6 +347,8 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONObject
import timber.log.Timber
import java.io.BufferedReader
import java.util.concurrent.CopyOnWriteArrayList

@InjectWith(FragmentScope::class)
class BrowserTabFragment :
Expand Down Expand Up @@ -2599,6 +2612,24 @@ class BrowserTabFragment :
WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)
}

fun loadJs(resourceName: String): String = readResource(resourceName).use { it?.readText() }.orEmpty()

private fun readResource(resourceName: String): BufferedReader? {
return javaClass.classLoader?.getResource(resourceName)?.openStream()?.bufferedReader()
}

private fun getUnprotectedTemporaryJson(unprotectedTemporaryExceptions: List<FeatureException>): String {
val type = Types.newParameterizedType(MutableList::class.java, FeatureException::class.java)
val moshi = Builder().build()
val jsonAdapter: JsonAdapter<List<FeatureException>> = moshi.adapter(type)
return jsonAdapter.toJson(unprotectedTemporaryExceptions)
}

private fun getContentScopeJson(config: String, unprotectedTemporaryExceptions: List<FeatureException>): String = (
"{\"features\":{$config},\"unprotectedTemporary\":${getUnprotectedTemporaryJson(unprotectedTemporaryExceptions)}}"
)

@SuppressLint("RequiresFeature")
private fun configureWebViewForAutofill(it: DuckDuckGoWebView) {
browserAutofill.addJsInterface(it, autofillCallback, this, null, tabId)

Expand All @@ -2615,6 +2646,19 @@ class BrowserTabFragment :
}
}
}



val cachedContentScopeJson: String = getContentScopeJson("", emptyList())

val cachedUserUnprotectedDomainsJson: String = emptyJsonList

val script = loadJs("contentScopeForFrames.js")
.replace(contentScope, cachedContentScopeJson)
.replace(userUnprotectedDomains, cachedUserUnprotectedDomainsJson)
.replace(userPreferences, "")
.replace(messagingParameters, "{}")
WebViewCompat.addDocumentStartJavaScript(it, script, setOf("https://passwords.google.com"))
}

private fun injectAutofillCredentials(
Expand Down
2 changes: 2 additions & 0 deletions autofill/autofill-impl/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ dependencies {

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

implementation "de.siegmar:fastcsv:_"

implementation Square.retrofit2.converter.moshi
implementation "com.squareup.moshi:moshi-kotlin:_"
implementation "com.squareup.moshi:moshi-adapters:_"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/*
* 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.content.Context
import android.net.Uri
import com.duckduckgo.di.scopes.AppScope
import com.squareup.anvil.annotations.ContributesBinding
import java.io.BufferedReader
import java.io.InputStreamReader
import javax.inject.Inject

interface CsvFileReader {
fun readCsvFile(fileUri: Uri): String
}

@ContributesBinding(AppScope::class)
class ContentResolverFileReader @Inject constructor(
private val context: Context,
) : CsvFileReader {

override fun readCsvFile(fileUri: Uri): String {
return context.contentResolver.openInputStream(fileUri)?.use { inputStream ->
BufferedReader(InputStreamReader(inputStream)).use { reader ->
reader.readText()
}
} ?: ""
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/*
* 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 com.duckduckgo.autofill.api.domain.app.LoginCredentials
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 javax.inject.Inject
import kotlinx.coroutines.withContext

interface CsvPasswordImporter {
suspend fun importCsv(fileUri: Uri): List<Long>
}

@ContributesBinding(AppScope::class)
class GooglePasswordManagerCsvPasswordImporter @Inject constructor(
private val parser: CsvPasswordParser,
private val fileReader: CsvFileReader,
private val credentialValidator: ImportedPasswordValidator,
private val existingPasswordMatchDetector: ExistingPasswordMatchDetector,
private val domainNameNormalizer: DomainNameNormalizer,
private val dispatchers: DispatcherProvider,
private val autofillStore: InternalAutofillStore,
) : CsvPasswordImporter {

override suspend fun importCsv(fileUri: Uri): List<Long> {
return kotlin.runCatching {
withContext(dispatchers.io()) {
val csv = fileReader.readCsvFile(fileUri)
val allPasswords = parser.parseCsv(csv)
val dedupedPasswords = allPasswords.distinct()
val validPasswords = filterValidPasswords(dedupedPasswords)
val normalizedDomains = domainNameNormalizer.normalizeDomains(validPasswords)
savePasswords(normalizedDomains)
}
}.getOrElse { emptyList() }
}

private suspend fun savePasswords(passwords: List<LoginCredentials>): List<Long> {
val savedCredentialIds = mutableListOf<Long>()
passwords.forEach {
if (!existingPasswordMatchDetector.alreadyExists(it)) {
val insertedId = autofillStore.saveCredentials(it.domain!!, it)?.id

if (insertedId != null) {
savedCredentialIds.add(insertedId)
}
}
}
return savedCredentialIds
}

private fun filterValidPasswords(passwords: List<LoginCredentials>): List<LoginCredentials> {
return passwords.filter { it.isValid() }
}

private fun LoginCredentials.isValid(): Boolean = credentialValidator.isValid(this)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
/*
* 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 com.duckduckgo.autofill.api.domain.app.LoginCredentials
import com.duckduckgo.common.utils.DispatcherProvider
import com.duckduckgo.di.scopes.AppScope
import com.squareup.anvil.annotations.ContributesBinding
import de.siegmar.fastcsv.reader.CsvReader
import de.siegmar.fastcsv.reader.CsvRow
import javax.inject.Inject
import kotlinx.coroutines.withContext
import timber.log.Timber

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

@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> {
return kotlin.runCatching {
convertToPasswordList(csv).also {
Timber.i("Parsed CSV. Found %d passwords", it.size)
}
}.onFailure {
Timber.e("Failed to parse CSV: %s", it.message)
}.getOrElse {
emptyList()
}
}

/**
* Format of the Google Password Manager CSV is:
* name | url | username | password | note
*/
private suspend fun convertToPasswordList(csv: String): List<LoginCredentials> {
return withContext(dispatchers.io()) {
val lines = mutableListOf<CsvRow>()
val iter = CsvReader.builder().build(csv).spliterator()
iter.forEachRemaining { lines.add(it) }
Timber.d("Found %d lines in the CSV", lines.size)

lines.firstOrNull().verifyExpectedFormat()

// drop the header row
val passwordsLines = lines.drop(1)

Timber.v("About to parse %d passwords", passwordsLines.size)
return@withContext passwordsLines
.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}")
return@mapNotNull null
}

parseToCredential(
domainTitle = it.getField(0).blanksToNull(),
domain = it.getField(1).blanksToNull(),
username = it.getField(2).blanksToNull(),
password = it.getField(3).blanksToNull(),
notes = it.getField(4).blanksToNull(),
)
}
}
}

private fun parseToCredential(
domainTitle: String?,
domain: String?,
username: String?,
password: String?,
notes: String?,
): LoginCredentials {
return LoginCredentials(
domainTitle = domainTitle,
domain = domain,
username = username,
password = password,
notes = notes,
)
}

private fun String?.blanksToNull(): String? {
return if (isNullOrBlank()) null else this
}

private fun CsvRow?.verifyExpectedFormat() {
if (this == null) {
throw IllegalArgumentException("File not recognised as a CSV")
}

val headers = this.fields

if (headers.size != EXPECTED_HEADERS.size) {
throw IllegalArgumentException(
"CSV header size does not match expected amount. Expected: ${EXPECTED_HEADERS.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")
}
}
}

companion object {
val EXPECTED_HEADERS = listOf(
"name",
"url",
"username",
"password",
"note",
)
}
}
Loading