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

Add custom webview for importing via GPM #5097

Draft
wants to merge 2 commits into
base: feature/craig/autofill_csv_import
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
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
@@ -0,0 +1,163 @@
/*
* 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.WebViewCompat
import com.duckduckgo.app.browser.api.WebViewCapabilityChecker
import com.duckduckgo.app.browser.api.WebViewCapabilityChecker.WebViewCapability
import com.duckduckgo.di.scopes.AppScope
import com.squareup.anvil.annotations.ContributesBinding
import javax.inject.Inject

/**
* This interface provides the ability to add modern blob download support to a WebView.
*/
interface WebViewBlobDownloader {

/**
* Configures a web view to support blob downloads, including in iframes.
*/
suspend fun addBlobDownloadSupport(webView: WebView)

/**
* Requests the WebView to convert a blob URL to a data URI.
*/
suspend fun convertBlobToDataUri(blobUrl: String)

/**
* Stores a reply proxy for a given location.
*/
suspend fun storeReplyProxy(
originUrl: String,
replyProxy: JavaScriptReplyProxy,
locationHref: String?,
)

/**
* Clears any stored JavaScript reply proxies.
*/
fun clearReplyProxies()
}

@ContributesBinding(AppScope::class)
class WebViewBlobDownloaderModernImpl @Inject constructor(
private val webViewCapabilityChecker: WebViewCapabilityChecker,
) : WebViewBlobDownloader {

// Map<String, Map<String, JavaScriptReplyProxy>>() = Map<Origin, Map<location.href, JavaScriptReplyProxy>>()
private val fixedReplyProxyMap = mutableMapOf<String, Map<String, JavaScriptReplyProxy>>()

@SuppressLint("RequiresFeature")
override suspend fun addBlobDownloadSupport(webView: WebView) {
if (isBlobDownloadWebViewFeatureEnabled()) {
WebViewCompat.addDocumentStartJavaScript(webView, script, setOf("*"))
}
}

@SuppressLint("RequiresFeature")
override suspend fun convertBlobToDataUri(blobUrl: String) {
for ((key, proxies) in fixedReplyProxyMap) {
if (sameOrigin(blobUrl.removePrefix("blob:"), key)) {
for (replyProxy in proxies.values) {
replyProxy.postMessage(blobUrl)
}
return
}
}
}

override suspend fun storeReplyProxy(
originUrl: String,
replyProxy: JavaScriptReplyProxy,
locationHref: String?,
) {
val frameProxies = fixedReplyProxyMap[originUrl]?.toMutableMap() ?: mutableMapOf()
// if location.href is not passed, we fall back to origin
val safeLocationHref = locationHref ?: originUrl
frameProxies[safeLocationHref] = replyProxy
fixedReplyProxyMap[originUrl] = frameProxies
}

private fun sameOrigin(
firstUrl: String,
secondUrl: String,
): Boolean {
return kotlin.runCatching {
val firstUri = Uri.parse(firstUrl)
val secondUri = Uri.parse(secondUrl)

firstUri.host == secondUri.host && firstUri.scheme == secondUri.scheme && firstUri.port == secondUri.port
}.getOrNull() ?: return false
}

override fun clearReplyProxies() {
fixedReplyProxyMap.clear()
}

private suspend fun isBlobDownloadWebViewFeatureEnabled(): Boolean {
return webViewCapabilityChecker.isSupported(WebViewCapability.WebMessageListener) &&
webViewCapabilityChecker.isSupported(WebViewCapability.DocumentStartJavaScript)
}

companion object {
private val script = """
window.__url_to_blob_collection = {};

const original_createObjectURL = URL.createObjectURL;

URL.createObjectURL = function () {
const blob = arguments[0];
const url = original_createObjectURL.call(this, ...arguments);
if (blob instanceof Blob) {
__url_to_blob_collection[url] = blob;
}
return url;
}

function blobToBase64DataUrl(blob) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = function() {
resolve(reader.result);
}
reader.onerror = function() {
reject(new Error('Failed to read Blob object'));
}
reader.readAsDataURL(blob);
});
}

const pingMessage = 'Ping:' + window.location.href
ddgBlobDownloadObj.postMessage(pingMessage)

ddgBlobDownloadObj.onmessage = function(event) {
if (event.data.startsWith('blob:')) {
const blob = window.__url_to_blob_collection[event.data];
if (blob) {
blobToBase64DataUrl(blob).then((dataUrl) => {
ddgBlobDownloadObj.postMessage(dataUrl);
});
}
}
}
""".trimIndent()
}
}
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.gpm.webflow

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.WebViewBlobDownloader
import com.duckduckgo.autofill.impl.importing.gpm.webflow.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 {

@SuppressLint("RequiresFeature")
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)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/*
* 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.gpm.webflow

import android.os.Parcelable
import kotlinx.parcelize.Parcelize

sealed interface ImportGooglePasswordResult : Parcelable {

@Parcelize
data class Success(val importedCount: Int, val foundInImport: Int, val importJobId: String) : ImportGooglePasswordResult

@Parcelize
data class UserCancelled(val stage: String) : ImportGooglePasswordResult

@Parcelize
data object Error : ImportGooglePasswordResult

companion object {
const val RESULT_KEY = "importResult"
const val RESULT_KEY_DETAILS = "importResultDetails"
}
}
Loading
Loading