Skip to content

Commit

Permalink
Add custom webview for importing via GPM (#5097)
Browse files Browse the repository at this point in the history
Task/Issue URL:
https://app.asana.com/0/608920331025315/1207414915035673/f

### Description
Adds the webflow which supports importing passwords directly from Google
Password Manager (GPM).

- This PR only adds the webflow and a way to invoke from autofill dev
settings
- The user-facing UI to start the flow and show the results of the
import is in the next PR up in the stack

The webflow can be augmented by visual nudges to help the user navigate
the flow, which are governed by remote config. By default, there will be
no nudges shown (you can test that flow too), so to show the nudges
[apply the
patch](https://app.asana.com/0/1208756808903423/1208756808903423/f) to
use a fake remote config (needed until the remote config goes live).

### Steps to test this PR

- [ ] [apply the remote config
patch](https://app.asana.com/0/1208756808903423/1208756808903423/f)
- [ ] Fresh install `internal` build, and go to autofill dev settings

**Not signed into Google already / no password for Google saved in our
passwords already**
- [x] Tap on `Launch Google Passwords (import flow)`
- [x] Verify the `Sign in` _pulses_ as way of a UI nudge on the first
screen. Tap it.
- [x] Sign into your Google account
- [x] Verify the ⚙️ settings button _pulses_. Tap it.
- [x] Verify the `Export passwords` button _pulses_. Tap it. Tap
`Export` on the dialog.
- [x] Re-enter your Google password when prompted
- [x] Verify the number of passwords imported matches what you'd expect 

**Already signed into Google**
Now that you're already signed into a Google account from previous test,
let's use that for the next test.

- [x] Tap on `Launch Google Passwords (import flow)`
- [x] Verify you're already at the screen with the export button on it
- [x] Verify the `Export passwords` button _pulses_. Tap it. Tap
`Export` on the dialog.
- [x] Re-enter your Google password when prompted, and verify the
webflow ends as expected.

**Signed out from Google / has Google password saved for autofilling**
For this test, you need to add a login for google to our password
manager and sign out from Google in the webview.
- [x] Tap on `View saved logins`, and manually add your Google
credentials (using `url = google.com`)
- [x] Return to autofill dev settings
- [x] Tap on `Launch Google Passwords (import flow)`
- [x] Tap on profile icon up top-right and choose to `Sign out`
- [x] Close the webflow

You are now signed out, but have a credential ready to autofill.
- [x] Tap on `Launch Google Passwords (import flow)`
- [x] Tap `Sign in` button
- [x] If it remembers your account, tap on your username. If not, verify
we prompt to, and can, autofill your username
- [x] Verify we prompt to, and can, autofill your password. _No more to
do here._
  • Loading branch information
CDRussell authored Nov 15, 2024
1 parent 9f2134c commit 5453432
Show file tree
Hide file tree
Showing 27 changed files with 1,500 additions and 114 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,12 @@ import com.duckduckgo.app.browser.api.WebViewCapabilityChecker.WebViewCapability
import com.duckduckgo.browser.api.WebViewVersionProvider
import com.duckduckgo.common.utils.DispatcherProvider
import com.duckduckgo.common.utils.extensions.compareSemanticVersion
import com.duckduckgo.di.scopes.FragmentScope
import com.duckduckgo.di.scopes.AppScope
import com.squareup.anvil.annotations.ContributesBinding
import javax.inject.Inject
import kotlinx.coroutines.withContext

@ContributesBinding(FragmentScope::class)
@ContributesBinding(AppScope::class)
class RealWebViewCapabilityChecker @Inject constructor(
private val dispatchers: DispatcherProvider,
private val webViewVersionProvider: WebViewVersionProvider,
Expand Down
4 changes: 4 additions & 0 deletions autofill/autofill-impl/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@
android:name=".email.incontext.EmailProtectionInContextSignupActivity"
android:configChanges="keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout|navigation|keyboard"
android:exported="false" />
<activity
android:name=".importing.gpm.webflow.ImportGooglePasswordsWebFlowActivity"
android:configChanges="keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout|navigation|keyboard"
android:exported="false" />
<activity
android:name=".ui.credential.management.AutofillManagementActivity"
android:configChanges="orientation|screenSize"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,10 @@ interface CsvCredentialConverter {
sealed interface CsvCredentialImportResult : Parcelable {

@Parcelize
data class Success(val numberCredentialsInSource: Int, val loginCredentialsToImport: List<LoginCredentials>) : CsvCredentialImportResult
data class Success(
val numberCredentialsInSource: Int,
val loginCredentialsToImport: List<LoginCredentials>,
) : CsvCredentialImportResult

@Parcelize
data object Error : CsvCredentialImportResult
Expand Down Expand Up @@ -80,15 +83,27 @@ class GooglePasswordManagerCsvCredentialConverter @Inject constructor(
}
}

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)
val entriesNotAlreadySaved = filterNewCredentials(normalizedDomains)
return entriesNotAlreadySaved
private suspend fun deduplicateAndCleanup(allCredentials: List<GoogleCsvLoginCredential>): List<LoginCredentials> {
return allCredentials
.distinct()
.filter { credentialValidator.isValid(it) }
.toLoginCredentials()
.filterNewCredentials()
}

private suspend fun filterNewCredentials(credentials: List<LoginCredentials>): List<LoginCredentials> {
return existingCredentialMatchDetector.filterExistingCredentials(credentials)
private suspend fun List<GoogleCsvLoginCredential>.toLoginCredentials(): List<LoginCredentials> {
return this.map {
LoginCredentials(
domainTitle = it.title,
username = it.username,
password = it.password,
domain = domainNameNormalizer.normalize(it.url),
notes = it.notes,
)
}
}

private suspend fun List<LoginCredentials>.filterNewCredentials(): List<LoginCredentials> {
return existingCredentialMatchDetector.filterExistingCredentials(this)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@

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
Expand All @@ -33,7 +32,7 @@ interface CsvCredentialParser {
suspend fun parseCsv(csv: String): ParseResult

sealed interface ParseResult {
data class Success(val credentials: List<LoginCredentials>) : ParseResult
data class Success(val credentials: List<GoogleCsvLoginCredential>) : ParseResult
data object Error : ParseResult
}
}
Expand Down Expand Up @@ -61,7 +60,7 @@ class GooglePasswordManagerCsvCredentialParser @Inject constructor(
* Format of the Google Password Manager CSV is:
* name | url | username | password | note
*/
private suspend fun convertToCredentials(csv: String): List<LoginCredentials> {
private suspend fun convertToCredentials(csv: String): List<GoogleCsvLoginCredential> {
return withContext(dispatchers.io()) {
val lines = mutableListOf<CsvRow>()
val iter = CsvReader.builder().build(csv).spliterator()
Expand All @@ -81,8 +80,8 @@ class GooglePasswordManagerCsvCredentialParser @Inject constructor(
}

parseToCredential(
domainTitle = it.getField(0).blanksToNull(),
domain = it.getField(1).blanksToNull(),
title = it.getField(0).blanksToNull(),
url = it.getField(1).blanksToNull(),
username = it.getField(2).blanksToNull(),
password = it.getField(3).blanksToNull(),
notes = it.getField(4).blanksToNull(),
Expand All @@ -92,15 +91,15 @@ class GooglePasswordManagerCsvCredentialParser @Inject constructor(
}

private fun parseToCredential(
domainTitle: String?,
domain: String?,
title: String?,
url: String?,
username: String?,
password: String?,
notes: String?,
): LoginCredentials {
return LoginCredentials(
domainTitle = domainTitle,
domain = domain,
): GoogleCsvLoginCredential {
return GoogleCsvLoginCredential(
title = title,
url = url,
username = username,
password = password,
notes = notes,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,25 +16,24 @@

package com.duckduckgo.autofill.impl.importing

import com.duckduckgo.autofill.api.domain.app.LoginCredentials
import com.duckduckgo.autofill.impl.urlmatcher.AutofillUrlMatcher
import com.duckduckgo.di.scopes.AppScope
import com.squareup.anvil.annotations.ContributesBinding
import javax.inject.Inject

interface DomainNameNormalizer {
suspend fun normalizeDomains(unnormalized: List<LoginCredentials>): List<LoginCredentials>
suspend fun normalize(unnormalizedUrl: String?): String?
}

@ContributesBinding(AppScope::class)
class DefaultDomainNameNormalizer @Inject constructor(
private val urlMatcher: AutofillUrlMatcher,
) : DomainNameNormalizer {
override suspend fun normalizeDomains(unnormalized: List<LoginCredentials>): List<LoginCredentials> {
return unnormalized.map {
val currentDomain = it.domain ?: return@map it
val normalizedDomain = urlMatcher.cleanRawUrl(currentDomain)
it.copy(domain = normalizedDomain)
override suspend fun normalize(unnormalizedUrl: String?): String? {
return if (unnormalizedUrl == null) {
null
} else {
urlMatcher.cleanRawUrl(unnormalizedUrl)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/*
* 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

/**
* Data class representing the login credentials imported from a Google CSV file.
*/
data class GoogleCsvLoginCredential(
val url: String? = null,
val username: String? = null,
val password: String? = null,
val title: String? = null,
val notes: String? = null,
)
Original file line number Diff line number Diff line change
Expand Up @@ -16,21 +16,22 @@

package com.duckduckgo.autofill.impl.importing

import com.duckduckgo.autofill.api.domain.app.LoginCredentials
import com.duckduckgo.di.scopes.AppScope
import com.squareup.anvil.annotations.ContributesBinding
import javax.inject.Inject

interface ImportedCredentialValidator {
fun isValid(loginCredentials: LoginCredentials): Boolean
fun isValid(loginCredentials: GoogleCsvLoginCredential): Boolean
}

@ContributesBinding(AppScope::class)
class DefaultImportedCredentialValidator @Inject constructor() : ImportedCredentialValidator {

override fun isValid(loginCredentials: LoginCredentials): Boolean {
override fun isValid(loginCredentials: GoogleCsvLoginCredential): Boolean {
with(loginCredentials) {
if (domain?.startsWith(APP_PASSWORD_PREFIX) == true) return false
if (url?.startsWith(APP_PASSWORD_PREFIX) == true) {
return false
}

if (allFieldsEmpty()) {
return false
Expand All @@ -40,11 +41,11 @@ class DefaultImportedCredentialValidator @Inject constructor() : ImportedCredent
}
}

private fun LoginCredentials.allFieldsEmpty(): Boolean {
return domain.isNullOrBlank() &&
private fun GoogleCsvLoginCredential.allFieldsEmpty(): Boolean {
return url.isNullOrBlank() &&
username.isNullOrBlank() &&
password.isNullOrBlank() &&
domainTitle.isNullOrBlank() &&
title.isNullOrBlank() &&
notes.isNullOrBlank()
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/*
* 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.blob

import android.annotation.SuppressLint
import android.net.Uri
import android.webkit.WebView
import androidx.webkit.JavaScriptReplyProxy
import androidx.webkit.WebMessageCompat
import androidx.webkit.WebViewCompat
import com.duckduckgo.app.di.AppCoroutineScope
import com.duckduckgo.autofill.impl.importing.blob.GooglePasswordBlobConsumer.Callback
import com.duckduckgo.common.utils.DispatcherProvider
import com.duckduckgo.di.scopes.FragmentScope
import com.squareup.anvil.annotations.ContributesBinding
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okio.ByteString.Companion.encode

interface GooglePasswordBlobConsumer {
suspend fun configureWebViewForBlobDownload(
webView: WebView,
callback: Callback,
)

suspend fun postMessageToConvertBlobToDataUri(url: String)

interface Callback {
suspend fun onCsvAvailable(csv: String)
suspend fun onCsvError()
}
}

@ContributesBinding(FragmentScope::class)
class ImportGooglePasswordBlobConsumer @Inject constructor(
private val webViewBlobDownloader: WebViewBlobDownloader,
private val dispatchers: DispatcherProvider,
@AppCoroutineScope private val appCoroutineScope: CoroutineScope,
) : GooglePasswordBlobConsumer {

// access to the flow which uses this be guarded against where these features aren't available
@SuppressLint("RequiresFeature", "AddWebMessageListenerUsage")
override suspend fun configureWebViewForBlobDownload(
webView: WebView,
callback: Callback,
) {
withContext(dispatchers.main()) {
webViewBlobDownloader.addBlobDownloadSupport(webView)

WebViewCompat.addWebMessageListener(
webView,
"ddgBlobDownloadObj",
setOf("*"),
) { _, message, sourceOrigin, _, replyProxy ->
val data = message.data ?: return@addWebMessageListener
appCoroutineScope.launch(dispatchers.io()) {
processReceivedWebMessage(data, message, sourceOrigin, replyProxy, callback)
}
}
}
}

private suspend fun processReceivedWebMessage(
data: String,
message: WebMessageCompat,
sourceOrigin: Uri,
replyProxy: JavaScriptReplyProxy,
callback: Callback,
) {
if (data.startsWith("data:")) {
kotlin.runCatching {
callback.onCsvAvailable(data)
}.onFailure { callback.onCsvError() }
} else if (message.data?.startsWith("Ping:") == true) {
val locationRef = message.data.toString().encode().md5().toString()
webViewBlobDownloader.storeReplyProxy(sourceOrigin.toString(), replyProxy, locationRef)
}
}

override suspend fun postMessageToConvertBlobToDataUri(url: String) {
webViewBlobDownloader.convertBlobToDataUri(url)
}
}
Loading

0 comments on commit 5453432

Please sign in to comment.