Skip to content
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 @@ -122,6 +122,12 @@ interface AutofillFeature {
@Toggle.DefaultValue(DefaultFeatureValue.INTERNAL)
fun canImportBookmarksFromGoogleTakeout(): Toggle

/**
* Remote Flag that enables the ability to use web message listener during bookmark import flow from Google Takeout
*/
@Toggle.DefaultValue(DefaultFeatureValue.TRUE)
fun canUseWebMessageListenerDuringBookmarkImport(): Toggle

/**
* Remote flag that enables the ability to support partial form saves. A partial form save is common with scenarios like:
* - a multi-step login form where username and password are entered on separate pages
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package com.duckduckgo.autofill.impl.importing.takeout.webflow

import androidx.core.net.toUri
import com.duckduckgo.autofill.impl.importing.takeout.processor.BookmarkImportProcessor.ImportResult
import com.duckduckgo.autofill.impl.importing.takeout.processor.BookmarkImportProcessor.ImportResult.Error
import com.duckduckgo.autofill.impl.importing.takeout.processor.BookmarkImportProcessor.ImportResult.Success
import com.duckduckgo.di.scopes.FragmentScope
import com.squareup.anvil.annotations.ContributesBinding
import logcat.logcat
import javax.inject.Inject

interface BookmarkImportWebFlowStepTracker {
fun getCurrentStep(): String
fun startFlow()
fun updateStepFromUrl(url: String?)
fun updateLatestStepSpecificStage(step: String)
fun updateStepToDownloadDetected()
fun updateStepFromImportResult(importResult: ImportResult)
}

@ContributesBinding(FragmentScope::class)
class BookmarkImportWebFlowStepTrackerImpl @Inject constructor() : BookmarkImportWebFlowStepTracker {

private var latestStepInWebFlow: String = STEP_UNINITIALIZED
private var hasVisitedLogin: Boolean = false
private var hasVisitedTakeout: Boolean = false

override fun getCurrentStep(): String = latestStepInWebFlow

override fun startFlow() {
latestStepInWebFlow = STEP_UNINITIALIZED
hasVisitedLogin = false
hasVisitedTakeout = false
logcat { "Bookmark-import: flow started, flags reset" }
}

override fun updateLatestStepSpecificStage(step: String) {
latestStepInWebFlow = step
logcat { "Bookmark-import: latest step is: $step" }
}

override fun updateStepFromUrl(url: String?) {
val host = url?.toUri()?.host ?: return

when {
host.contains(TAKEOUT_ADDRESS, ignoreCase = true) -> updateLatestStepTakeoutReached()
host.contains(ACCOUNTS_ADDRESS, ignoreCase = true) -> updateLatestStepLoginPage()
else -> updateLatestStepSpecificStage(STEP_UNKNOWN_URL)
}
}

override fun updateStepToDownloadDetected() {
updateLatestStepSpecificStage(STEP_DOWNLOAD_DETECTED)
}

override fun updateStepFromImportResult(importResult: ImportResult) {
val step = when (importResult) {
is Success -> STEP_IMPORT_SUCCESS
is Error.DownloadError -> STEP_IMPORT_ERROR_DOWNLOAD
is Error.ParseError -> STEP_IMPORT_ERROR_PARSE
is Error.ImportError -> STEP_IMPORT_ERROR_WHILE_IMPORTING
}
updateLatestStepSpecificStage(step)
}

private fun updateLatestStepTakeoutReached() {
if (latestStepInWebFlow == STEP_GOOGLE_TAKEOUT_PAGE_FIRST || latestStepInWebFlow == STEP_GOOGLE_TAKEOUT_PAGE_REPEATED) {
return
}

if (!hasVisitedTakeout) {
hasVisitedTakeout = true
updateLatestStepSpecificStage(STEP_GOOGLE_TAKEOUT_PAGE_FIRST)
} else {
updateLatestStepSpecificStage(STEP_GOOGLE_TAKEOUT_PAGE_REPEATED)
}
}

private fun updateLatestStepLoginPage() {
if (latestStepInWebFlow == STEP_GOOGLE_ACCOUNTS_PAGE_FIRST || latestStepInWebFlow == STEP_GOOGLE_ACCOUNTS_REPEATED) {
return
}

if (!hasVisitedLogin) {
hasVisitedLogin = true
updateLatestStepSpecificStage(STEP_GOOGLE_ACCOUNTS_PAGE_FIRST)
} else {
updateLatestStepSpecificStage(STEP_GOOGLE_ACCOUNTS_REPEATED)
}
}

companion object {
private const val STEP_GOOGLE_TAKEOUT_PAGE_FIRST = "takeout-first"
private const val STEP_GOOGLE_TAKEOUT_PAGE_REPEATED = "takeout-repeat"
private const val STEP_GOOGLE_ACCOUNTS_PAGE_FIRST = "login-first"
private const val STEP_GOOGLE_ACCOUNTS_REPEATED = "login-repeat"
private const val STEP_UNINITIALIZED = "uninitialized"
private const val STEP_IMPORT_SUCCESS = "completed-successful"
private const val STEP_IMPORT_ERROR_PARSE = "completed-failure-parse"
private const val STEP_IMPORT_ERROR_DOWNLOAD = "completed-failure-download"
private const val STEP_IMPORT_ERROR_WHILE_IMPORTING = "completed-failure-import"
private const val STEP_DOWNLOAD_DETECTED = "download-detected"
private const val STEP_UNKNOWN_URL = "unknown-url"
private const val TAKEOUT_ADDRESS = "takeout.google.com"
private const val ACCOUNTS_ADDRESS = "accounts.google.com"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ sealed interface UserCannotImportReason : Parcelable {
@Parcelize
data object DownloadError : UserCannotImportReason

@Parcelize
data class WebAutomationError(val step: String) : UserCannotImportReason

@Parcelize
data object Unknown : UserCannotImportReason

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.webkit.WebViewCompat
import com.duckduckgo.anvil.annotations.InjectWith
import com.duckduckgo.autofill.api.AutofillFeature
import com.duckduckgo.autofill.api.AutofillFragmentResultsPlugin
import com.duckduckgo.autofill.api.BrowserAutofill
import com.duckduckgo.autofill.api.CredentialAutofillDialogFactory
Expand Down Expand Up @@ -66,6 +67,7 @@ import com.duckduckgo.autofill.impl.importing.takeout.webflow.ImportGoogleBookma
import com.duckduckgo.autofill.impl.importing.takeout.webflow.UserCannotImportReason.DownloadError
import com.duckduckgo.autofill.impl.importing.takeout.webflow.UserCannotImportReason.ErrorParsingBookmarks
import com.duckduckgo.autofill.impl.importing.takeout.webflow.UserCannotImportReason.Unknown
import com.duckduckgo.autofill.impl.importing.takeout.webflow.UserCannotImportReason.WebAutomationError
import com.duckduckgo.autofill.impl.importing.takeout.webflow.UserCannotImportReason.WebViewError
import com.duckduckgo.autofill.impl.jsbridge.request.SupportedAutofillInputSubType
import com.duckduckgo.autofill.impl.jsbridge.request.SupportedAutofillInputSubType.PASSWORD
Expand Down Expand Up @@ -123,8 +125,12 @@ class ImportGoogleBookmarksWebFlowFragment :
@Inject
lateinit var browserAutofillConfigurator: InternalBrowserAutofillConfigurator

@Inject
lateinit var autofillFeature: AutofillFeature

private var binding: FragmentImportGoogleBookmarksWebflowBinding? = null
private var cancellationDialog: DaxAlertDialog? = null
private var webFlowIsEnding = false

private val viewModel by lazy {
ViewModelProvider(requireActivity(), viewModelFactory)[ImportGoogleBookmarksWebFlowViewModel::class.java]
Expand Down Expand Up @@ -195,16 +201,12 @@ class ImportGoogleBookmarksWebFlowFragment :
// Inject null to indicate no credentials available
browserAutofill.injectCredentials(null)
}
is PromptUserToSelectFromStoredCredentials ->
showCredentialChooserDialog(
command.originalUrl,
command.credentials,
command.triggerType,
)
is ExitFlowWithSuccess -> {
logcat { "Bookmark-import: ExitFlowWithSuccess received with count: ${command.importedCount}" }
exitFlowAsSuccess(command.importedCount)
}
is PromptUserToSelectFromStoredCredentials -> showCredentialChooserDialog(
command.originalUrl,
command.credentials,
command.triggerType,
)
is ExitFlowWithSuccess -> exitFlowAsSuccess(command.importedCount)
is ExitFlowAsFailure -> exitFlowAsError(command.reason)
is PromptUserToConfirmFlowCancellation -> askUserToConfirmCancellation()
}
Expand All @@ -223,12 +225,11 @@ class ImportGoogleBookmarksWebFlowFragment :
return@withContext
}

val credentials =
LoginCredentials(
domain = url,
username = username,
password = password,
)
val credentials = LoginCredentials(
domain = url,
username = username,
password = password,
)

logcat { "Injecting re-authentication credentials" }
browserAutofill.injectCredentials(credentials)
Expand Down Expand Up @@ -319,6 +320,24 @@ class ImportGoogleBookmarksWebFlowFragment :
} else {
logcat(WARN) { "Bookmark-import: Not able to inject bookmark import JavaScript" }
}

val canAddMessageListener = withContext(dispatchers.io()) {
autofillFeature.canUseWebMessageListenerDuringBookmarkImport().isEnabled()
}

if (canAddMessageListener) {
WebViewCompat.addWebMessageListener(webView, "ddgBookmarkImport", setOf("*")) { _, message, sourceOrigin, _, _ ->
if (webFlowIsEnding) {
logcat(WARN) { "Bookmark-import: web flow is ending, ignoring message" }
return@addWebMessageListener
}

val data = message.data ?: return@addWebMessageListener
viewModel.onWebMessageReceived(data)
}
} else {
logcat(WARN) { "Bookmark-import: Not able to add WebMessage listener for bookmark import" }
}
}

private fun initialiseToolbar() {
Expand Down Expand Up @@ -357,6 +376,11 @@ class ImportGoogleBookmarksWebFlowFragment :
private fun getToolbar() = (activity as ImportGoogleBookmarksWebFlowActivity).binding.includeToolbar.toolbar

override fun onPageStarted(url: String?) {
if (webFlowIsEnding) {
logcat(WARN) { "Bookmark-import: web flow is ending, ignoring page started" }
return
}

viewModel.onPageStarted(url)
lifecycleScope.launch(dispatchers.main()) {
binding?.let {
Expand Down Expand Up @@ -384,6 +408,8 @@ class ImportGoogleBookmarksWebFlowFragment :

private fun exitFlowAsSuccess(bookmarkCount: Int) {
logcat { "Bookmark-import: Reporting import success with bookmarkCount: $bookmarkCount" }
onWebFlowEnding()

lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
dismissCancellationDialog()
Expand All @@ -397,6 +423,7 @@ class ImportGoogleBookmarksWebFlowFragment :

private fun exitFlowAsCancellation(stage: String) {
logcat { "Bookmark-import: Flow cancelled at stage: $stage" }
onWebFlowEnding()

lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
Expand All @@ -413,6 +440,7 @@ class ImportGoogleBookmarksWebFlowFragment :

private fun exitFlowAsError(reason: UserCannotImportReason) {
logcat { "Bookmark-import: Flow error at stage: ${reason.mapToStage()}" }
onWebFlowEnding()

lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
Expand All @@ -426,6 +454,17 @@ class ImportGoogleBookmarksWebFlowFragment :
}
}

/**
* Does a best-effort to attempt to stop the web flow from any further processing.
*/
private fun onWebFlowEnding() {
webFlowIsEnding = true
binding?.webView?.run {
stopLoading()
loadUrl("about:blank")
}
}

private suspend fun showCredentialChooserDialog(
originalUrl: String,
credentials: List<LoginCredentials>,
Expand All @@ -438,13 +477,12 @@ class ImportGoogleBookmarksWebFlowFragment :
return@withContext
}

val dialog =
credentialAutofillDialogFactory.autofillSelectCredentialsDialog(
url,
credentials,
triggerType,
CUSTOM_FLOW_TAB_ID,
)
val dialog = credentialAutofillDialogFactory.autofillSelectCredentialsDialog(
url,
credentials,
triggerType,
CUSTOM_FLOW_TAB_ID,
)
dialog.show(childFragmentManager, SELECT_CREDENTIALS_FRAGMENT_TAG)
}
}
Expand Down Expand Up @@ -548,8 +586,9 @@ class ImportGoogleBookmarksWebFlowFragment :

private fun UserCannotImportReason.mapToStage(): String =
when (this) {
DownloadError -> "zip-download-error"
ErrorParsingBookmarks -> "zip-parse-error"
Unknown -> "import-error-unknown"
WebViewError -> "webview-error"
is DownloadError -> "zip-download-error"
is ErrorParsingBookmarks -> "zip-parse-error"
is Unknown -> "import-error-unknown"
is WebViewError -> "webview-error"
is WebAutomationError -> "web-automation-step-failure-${this.step}"
}
Loading
Loading