Skip to content

Commit

Permalink
Add custom webview for importing via GPM
Browse files Browse the repository at this point in the history
  • Loading branch information
CDRussell committed Nov 13, 2024
1 parent d2141a9 commit 1a7c8ea
Show file tree
Hide file tree
Showing 18 changed files with 1,132 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/*
* 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.app.browser

import androidx.webkit.WebViewFeature
import com.duckduckgo.browser.api.WebViewMessageListening
import com.duckduckgo.browser.api.WebViewVersionProvider
import com.duckduckgo.common.utils.DispatcherProvider
import com.duckduckgo.common.utils.extensions.compareSemanticVersion
import com.duckduckgo.di.scopes.AppScope
import com.squareup.anvil.annotations.ContributesBinding
import javax.inject.Inject
import kotlinx.coroutines.withContext

@ContributesBinding(AppScope::class)
class WebViewSafeMessageListening @Inject constructor(
private val dispatchers: DispatcherProvider,
private val webViewVersionProvider: WebViewVersionProvider,
) : WebViewMessageListening {

override suspend fun isWebMessageListenerSupported(): Boolean {
return withContext(dispatchers.io()) {
webViewVersionProvider.getFullVersion().compareSemanticVersion(WEB_MESSAGE_LISTENER_WEBVIEW_VERSION)?.let {
it >= 0
} ?: false
} && WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)
}

companion object {
private const val WEB_MESSAGE_LISTENER_WEBVIEW_VERSION = "126.0.6478.40"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,10 @@

package com.duckduckgo.autofill.api

import android.os.Parcelable
import com.duckduckgo.autofill.api.domain.app.LoginCredentials
import com.duckduckgo.navigation.api.GlobalActivityStarter.ActivityParams
import kotlinx.parcelize.Parcelize

sealed interface AutofillScreens {

Expand Down Expand Up @@ -48,6 +50,29 @@ sealed interface AutofillScreens {
val loginCredentials: LoginCredentials,
val source: AutofillSettingsLaunchSource,
) : ActivityParams

object ImportGooglePassword {
data object AutofillImportViaGooglePasswordManagerScreen : ActivityParams {
private fun readResolve(): Any = AutofillImportViaGooglePasswordManagerScreen
}

sealed interface Result : Parcelable {

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

@Parcelize
data class Success(val importedCount: Int) : Result

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

@Parcelize
data object Error : Result
}
}
}

enum class AutofillSettingsLaunchSource {
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,200 @@
/*
* 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.gpm.webflow.GooglePasswordBlobConsumer.Callback
import com.duckduckgo.browser.api.WebViewMessageListening
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 dispatchers: DispatcherProvider,
private val webViewMessageListening: WebViewMessageListening,
@AppCoroutineScope private val appCoroutineScope: CoroutineScope,
) : GooglePasswordBlobConsumer {

private val replyProxyMap = mutableMapOf<String, JavaScriptReplyProxy>()

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

@SuppressLint("RequiresFeature")
override suspend fun configureWebViewForBlobDownload(
webView: WebView,
callback: Callback,
) {
withContext(dispatchers.main()) {
WebViewCompat.addDocumentStartJavaScript(webView, blobDownloadScript(), setOf("*"))
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()
saveReplyProxyForBlobDownload(sourceOrigin.toString(), replyProxy, locationRef)
}
}

private suspend fun saveReplyProxyForBlobDownload(
originUrl: String,
replyProxy: JavaScriptReplyProxy,
locationHref: String? = null,
) {
withContext(dispatchers.io()) { // FF check has disk IO
if (true) {
// if (webViewBlobDownloadFeature.fixBlobDownloadWithIframes().isEnabled()) {
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
} else {
replyProxyMap[originUrl] = replyProxy
}
}
}

@SuppressLint("RequiresFeature") // it's already checked in isBlobDownloadWebViewFeatureEnabled
override suspend fun postMessageToConvertBlobToDataUri(url: String) {
withContext(dispatchers.main()) { // main because postMessage is not always safe in another thread
if (true) {
// if (withContext(dispatchers.io()) { webViewBlobDownloadFeature.fixBlobDownloadWithIframes().isEnabled() }) {
for ((key, proxies) in fixedReplyProxyMap) {
if (sameOrigin(url.removePrefix("blob:"), key)) {
for (replyProxy in proxies.values) {
replyProxy.postMessage(url)
}
return@withContext
}
}
} else {
for ((key, value) in replyProxyMap) {
if (sameOrigin(url.removePrefix("blob:"), key)) {
value.postMessage(url)
return@withContext
}
}
}
}
}

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
}

private fun blobDownloadScript(): String {
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()

return script
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/*
* Copyright (c) 2023 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.content.Intent
import android.os.Bundle
import androidx.fragment.app.commit
import com.duckduckgo.anvil.annotations.ContributeToActivityStarter
import com.duckduckgo.anvil.annotations.InjectWith
import com.duckduckgo.autofill.api.AutofillScreens.ImportGooglePassword.AutofillImportViaGooglePasswordManagerScreen
import com.duckduckgo.autofill.api.AutofillScreens.ImportGooglePassword.Result.Companion.RESULT_KEY
import com.duckduckgo.autofill.api.AutofillScreens.ImportGooglePassword.Result.Companion.RESULT_KEY_DETAILS
import com.duckduckgo.autofill.api.AutofillScreens.ImportGooglePassword.Result.UserCancelled
import com.duckduckgo.autofill.impl.R
import com.duckduckgo.autofill.impl.databinding.ActivityImportGooglePasswordsWebflowBinding
import com.duckduckgo.common.ui.DuckDuckGoActivity
import com.duckduckgo.common.ui.viewbinding.viewBinding
import com.duckduckgo.di.scopes.ActivityScope

@InjectWith(ActivityScope::class)
@ContributeToActivityStarter(AutofillImportViaGooglePasswordManagerScreen::class)
class ImportGooglePasswordsWebFlowActivity : DuckDuckGoActivity() {

val binding: ActivityImportGooglePasswordsWebflowBinding by viewBinding()

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(binding.root)
configureResultListeners()
launchImportFragment()
}

private fun launchImportFragment() {
supportFragmentManager.commit {
replace(R.id.fragment_container, ImportGooglePasswordsWebFlowFragment())
}
}

private fun configureResultListeners() {
supportFragmentManager.setFragmentResultListener(RESULT_KEY, this) { _, result ->
exitWithResult(result)
}
}

private fun exitWithResult(resultBundle: Bundle) {
setResult(RESULT_OK, Intent().putExtras(resultBundle))
finish()
}

fun exitUserCancelled(stage: String) {
val result = Bundle().apply {
putParcelable(RESULT_KEY_DETAILS, UserCancelled(stage))
}
exitWithResult(result)
}
}
Loading

0 comments on commit 1a7c8ea

Please sign in to comment.