diff --git a/autofill/autofill-api/src/main/java/com/duckduckgo/autofill/api/AutofillFeature.kt b/autofill/autofill-api/src/main/java/com/duckduckgo/autofill/api/AutofillFeature.kt index 60922f728d0e..6c4f21cb243f 100644 --- a/autofill/autofill-api/src/main/java/com/duckduckgo/autofill/api/AutofillFeature.kt +++ b/autofill/autofill-api/src/main/java/com/duckduckgo/autofill/api/AutofillFeature.kt @@ -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 diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/takeout/webflow/BookmarkImportWebFlowStepTracker.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/takeout/webflow/BookmarkImportWebFlowStepTracker.kt new file mode 100644 index 000000000000..a64657982b2f --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/takeout/webflow/BookmarkImportWebFlowStepTracker.kt @@ -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" + } +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/takeout/webflow/ImportGoogleBookmarkResult.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/takeout/webflow/ImportGoogleBookmarkResult.kt index 85023337b85c..487631772bc1 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/takeout/webflow/ImportGoogleBookmarkResult.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/takeout/webflow/ImportGoogleBookmarkResult.kt @@ -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 diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/takeout/webflow/ImportGoogleBookmarksWebFlowFragment.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/takeout/webflow/ImportGoogleBookmarksWebFlowFragment.kt index 146049b0ffd6..fff0395730e0 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/takeout/webflow/ImportGoogleBookmarksWebFlowFragment.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/takeout/webflow/ImportGoogleBookmarksWebFlowFragment.kt @@ -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 @@ -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 @@ -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] @@ -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() } @@ -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) @@ -311,7 +312,7 @@ class ImportGoogleBookmarksWebFlowFragment : } } - @SuppressLint("RequiresFeature", "AddDocumentStartJavaScriptUsage") + @SuppressLint("RequiresFeature", "AddDocumentStartJavaScriptUsage", "AddWebMessageListenerUsage") private suspend fun configureBookmarkImportJavascript(webView: WebView) { if (importBookmarkConfig.getConfig().canInjectJavascript) { val script = googleImporterScriptLoader.getScriptForBookmarkImport() @@ -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() { @@ -357,6 +376,10 @@ class ImportGoogleBookmarksWebFlowFragment : private fun getToolbar() = (activity as ImportGoogleBookmarksWebFlowActivity).binding.includeToolbar.toolbar override fun onPageStarted(url: String?) { + if (webFlowIsEnding) { + return + } + viewModel.onPageStarted(url) lifecycleScope.launch(dispatchers.main()) { binding?.let { @@ -384,6 +407,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() @@ -397,6 +422,7 @@ class ImportGoogleBookmarksWebFlowFragment : private fun exitFlowAsCancellation(stage: String) { logcat { "Bookmark-import: Flow cancelled at stage: $stage" } + onWebFlowEnding() lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { @@ -413,6 +439,7 @@ class ImportGoogleBookmarksWebFlowFragment : private fun exitFlowAsError(reason: UserCannotImportReason) { logcat { "Bookmark-import: Flow error at stage: ${reason.mapToStage()}" } + onWebFlowEnding() lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { @@ -426,6 +453,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, @@ -438,13 +476,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) } } @@ -548,8 +585,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}" } diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/takeout/webflow/ImportGoogleBookmarksWebFlowViewModel.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/takeout/webflow/ImportGoogleBookmarksWebFlowViewModel.kt index be7606796faf..7a2a0be8f72b 100644 --- a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/takeout/webflow/ImportGoogleBookmarksWebFlowViewModel.kt +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/takeout/webflow/ImportGoogleBookmarksWebFlowViewModel.kt @@ -25,9 +25,14 @@ import com.duckduckgo.autofill.api.domain.app.LoginCredentials import com.duckduckgo.autofill.api.domain.app.LoginTriggerType import com.duckduckgo.autofill.impl.importing.takeout.processor.BookmarkImportProcessor import com.duckduckgo.autofill.impl.importing.takeout.store.BookmarkImportConfigStore +import com.duckduckgo.autofill.impl.importing.takeout.webflow.ImportGoogleBookmarksWebFlowViewModel.Command.ExitFlowAsFailure import com.duckduckgo.autofill.impl.importing.takeout.webflow.ImportGoogleBookmarksWebFlowViewModel.Command.PromptUserToConfirmFlowCancellation import com.duckduckgo.autofill.impl.importing.takeout.webflow.ImportGoogleBookmarksWebFlowViewModel.ViewState.HideWebPage import com.duckduckgo.autofill.impl.importing.takeout.webflow.ImportGoogleBookmarksWebFlowViewModel.ViewState.ShowWebPage +import com.duckduckgo.autofill.impl.importing.takeout.webflow.TakeoutMessageResult.TakeoutActionError +import com.duckduckgo.autofill.impl.importing.takeout.webflow.TakeoutMessageResult.TakeoutActionSuccess +import com.duckduckgo.autofill.impl.importing.takeout.webflow.TakeoutMessageResult.UnknownMessageFormat +import com.duckduckgo.autofill.impl.importing.takeout.webflow.UserCannotImportReason.WebAutomationError import com.duckduckgo.autofill.impl.store.ReAuthenticationDetails import com.duckduckgo.autofill.impl.store.ReauthenticationHandler import com.duckduckgo.common.utils.DispatcherProvider @@ -38,6 +43,7 @@ import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import logcat.LogPriority.WARN import logcat.logcat import javax.inject.Inject @@ -48,6 +54,8 @@ class ImportGoogleBookmarksWebFlowViewModel @Inject constructor( private val autofillFeature: AutofillFeature, private val bookmarkImportProcessor: BookmarkImportProcessor, private val bookmarkImportConfigStore: BookmarkImportConfigStore, + private val takeoutWebMessageParser: TakeoutWebMessageParser, + private val webFlowStepTracker: BookmarkImportWebFlowStepTracker, ) : ViewModel() { private val _viewState = MutableStateFlow(ViewState.Initializing) val viewState: StateFlow = _viewState @@ -71,6 +79,7 @@ class ImportGoogleBookmarksWebFlowViewModel @Inject constructor( ) { viewModelScope.launch(dispatchers.io()) { logcat { "Download detected: $url, mimeType: $mimeType, contentDisposition: $contentDisposition" } + webFlowStepTracker.updateStepToDownloadDetected() // Check if this looks like a valid Google Takeout bookmark export val isValidImport = isTakeoutZipDownloadLink(mimeType, url, contentDisposition) @@ -80,7 +89,7 @@ class ImportGoogleBookmarksWebFlowViewModel @Inject constructor( processBookmarkImport(url, userAgent, folderName) } else { logcat { "Invalid import type detected, exiting as failure. URL: $url" } - _commands.emit(Command.ExitFlowAsFailure(UserCannotImportReason.DownloadError)) + _commands.emit(ExitFlowAsFailure(UserCannotImportReason.DownloadError)) } } } @@ -109,6 +118,8 @@ class ImportGoogleBookmarksWebFlowViewModel @Inject constructor( importResult: BookmarkImportProcessor.ImportResult, folderName: String, ) { + webFlowStepTracker.updateStepFromImportResult(importResult) + when (importResult) { is BookmarkImportProcessor.ImportResult.Success -> { logcat { "Successfully imported ${importResult.importedCount} bookmarks into '$folderName' folder" } @@ -116,21 +127,21 @@ class ImportGoogleBookmarksWebFlowViewModel @Inject constructor( } is BookmarkImportProcessor.ImportResult.Error.DownloadError -> { - _commands.emit(Command.ExitFlowAsFailure(UserCannotImportReason.DownloadError)) + _commands.emit(ExitFlowAsFailure(UserCannotImportReason.DownloadError)) } is BookmarkImportProcessor.ImportResult.Error.ParseError -> { - _commands.emit(Command.ExitFlowAsFailure(UserCannotImportReason.ErrorParsingBookmarks)) + _commands.emit(ExitFlowAsFailure(UserCannotImportReason.ErrorParsingBookmarks)) } is BookmarkImportProcessor.ImportResult.Error.ImportError -> { - _commands.emit(Command.ExitFlowAsFailure(UserCannotImportReason.ErrorParsingBookmarks)) + _commands.emit(ExitFlowAsFailure(UserCannotImportReason.ErrorParsingBookmarks)) } } } fun onCloseButtonPressed() { - terminateFlowAsCancellation() + terminateFlowAsCancellation(webFlowStepTracker.getCurrentStep()) } fun onBackButtonPressed(canGoBack: Boolean = false) { @@ -142,9 +153,9 @@ class ImportGoogleBookmarksWebFlowViewModel @Inject constructor( } } - private fun terminateFlowAsCancellation() { + private fun terminateFlowAsCancellation(stage: String) { viewModelScope.launch { - _viewState.value = ViewState.UserCancelledImportFlow(stage = "unknown") + _viewState.value = ViewState.UserCancelledImportFlow(stage) } } @@ -221,16 +232,34 @@ class ImportGoogleBookmarksWebFlowViewModel @Inject constructor( } fun onPageStarted(url: String?) { + webFlowStepTracker.updateStepFromUrl(url) val host = url?.toUri()?.host ?: return _viewState.value = if (host.contains(TAKEOUT_ADDRESS, ignoreCase = true)) { HideWebPage + } else if (host.contains(ACCOUNTS_ADDRESS, ignoreCase = true)) { + ShowWebPage } else { ShowWebPage } } - private companion object { + fun onWebMessageReceived(data: String) { + viewModelScope.launch { + when (val result = takeoutWebMessageParser.parseMessage(data)) { + is TakeoutActionSuccess -> webFlowStepTracker.updateLatestStepSpecificStage(result.actionID) + is TakeoutActionError -> { + logcat(WARN) { "Bookmark-import: experienced an error in the step: $result, raw:$data" } + val step = result.actionID ?: "last-success-${webFlowStepTracker.getCurrentStep()}" + _commands.emit(ExitFlowAsFailure(WebAutomationError(step))) + } + is UnknownMessageFormat -> logcat(WARN) { "Bookmark-import: failed to parse message, unknown format: $data" } + } + } + } + + companion object { private const val TAKEOUT_ADDRESS = "takeout.google.com" + private const val ACCOUNTS_ADDRESS = "accounts.google.com" } sealed interface Command { diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/takeout/webflow/TakeoutWebMessageParser.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/takeout/webflow/TakeoutWebMessageParser.kt new file mode 100644 index 000000000000..cc6656518c4f --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/takeout/webflow/TakeoutWebMessageParser.kt @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2025 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.takeout.webflow + +import com.duckduckgo.autofill.impl.importing.takeout.webflow.TakeoutMessageResult.TakeoutActionError +import com.duckduckgo.autofill.impl.importing.takeout.webflow.TakeoutMessageResult.TakeoutActionSuccess +import com.duckduckgo.autofill.impl.importing.takeout.webflow.TakeoutMessageResult.UnknownMessageFormat +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import com.squareup.moshi.Json +import com.squareup.moshi.Moshi +import kotlinx.coroutines.withContext +import logcat.LogPriority.WARN +import logcat.asLog +import logcat.logcat +import javax.inject.Inject + +/** + * Parser for web messages from takeout.google.com during bookmark import flow + */ +interface TakeoutWebMessageParser { + + /** + * Parses a JSON web message from takeout.google.com + * @param jsonMessage The raw JSON message string + * @return TakeoutMessageResult containing the parsed action data + */ + suspend fun parseMessage(jsonMessage: String): TakeoutMessageResult +} + +@ContributesBinding(AppScope::class) +class RealTakeoutWebMessageParser @Inject constructor( + private val dispatchers: DispatcherProvider, + private val moshi: Moshi, +) : TakeoutWebMessageParser { + + private val adapter by lazy { + moshi.adapter(TakeoutWebMessage::class.java) + } + + override suspend fun parseMessage(jsonMessage: String): TakeoutMessageResult { + return runCatching { + withContext(dispatchers.io()) { + val message = adapter.fromJson(jsonMessage) ?: return@withContext UnknownMessageFormat + val resultData = message.data?.result + if (resultData.isInvalid()) return@withContext UnknownMessageFormat + + return@withContext if (resultData?.success != null && resultData.success.actionID != null) { + TakeoutActionSuccess(actionID = resultData.success.actionID) + } else { + TakeoutActionError(actionID = resultData?.error?.actionID) + } + } + }.getOrElse { + logcat(WARN) { "Error parsing takeout web message: ${it.asLog()}" } + UnknownMessageFormat + } + } + + private fun RawResultData?.isInvalid(): Boolean { + if (this?.success == null && this?.error == null) { + logcat(WARN) { "Error parsing takeout web message: unknown format: $this" } + return true + } + return false + } +} + +/** + * Public API models for takeout web message parsing results + */ +sealed interface TakeoutMessageResult { + data class TakeoutActionSuccess( + val actionID: String, + ) : TakeoutMessageResult + + data class TakeoutActionError( + val actionID: String?, + ) : TakeoutMessageResult + + data object UnknownMessageFormat : TakeoutMessageResult +} + +/** + * Internal JSON models for parsing web messages from takeout.google.com + * All fields are nullable to gracefully handle structure changes + */ +private data class TakeoutWebMessage( + @Json(name = "name") val name: String? = null, + @Json(name = "data") val data: TakeoutMessageData? = null, +) + +private data class TakeoutMessageData( + @Json(name = "result") val result: RawResultData? = null, +) + +private data class RawResultData( + @Json(name = "success") val success: JsonActionSuccess? = null, + @Json(name = "error") val error: JsonActionError? = null, +) + +private data class JsonActionSuccess( + @Json(name = "actionID") val actionID: String? = null, +) + +private data class JsonActionError( + @Json(name = "actionID") val actionID: String? = null, +) diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/takeout/webflow/BookmarkImportWebFlowStepTrackerImplTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/takeout/webflow/BookmarkImportWebFlowStepTrackerImplTest.kt new file mode 100644 index 000000000000..0418ba830f3a --- /dev/null +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/takeout/webflow/BookmarkImportWebFlowStepTrackerImplTest.kt @@ -0,0 +1,178 @@ +package com.duckduckgo.autofill.impl.importing.takeout.webflow + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.duckduckgo.autofill.impl.importing.takeout.processor.BookmarkImportProcessor +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class BookmarkImportWebFlowStepTrackerImplTest { + + private val testee = BookmarkImportWebFlowStepTrackerImpl() + + @Test + fun whenCreatedThenCurrentStepIsUninitialized() { + assertEquals("uninitialized", testee.getCurrentStep()) + } + + @Test + fun whenUpdateStepFromAccountsUrlFromUninitializedThenSetsToLoginFirst() { + testee.updateStepFromUrl("https://accounts.google.com/signin") + assertEquals("login-first", testee.getCurrentStep()) + } + + @Test + fun whenUpdateStepFromAccountsUrlFromLoginFirstThenStaysTheSame() { + testee.updateStepFromUrl("https://accounts.google.com/signin") + testee.updateStepFromUrl("https://accounts.google.com/signin") + assertEquals("login-first", testee.getCurrentStep()) + } + + @Test + fun whenUpdateStepFromAccountsUrlFromTakeoutFirstThenSetsToLoginFirst() { + testee.updateStepFromUrl("https://takeout.google.com") + testee.updateStepFromUrl("https://accounts.google.com/signin") + assertEquals("login-first", testee.getCurrentStep()) + } + + @Test + fun whenVisitingLoginMultipleTimesAfterTakeoutThenStaysAtLoginFirst() { + testee.updateStepFromUrl("https://takeout.google.com") + testee.updateStepFromUrl("https://accounts.google.com/signin") + testee.updateStepFromUrl("https://accounts.google.com/signin") + assertEquals("login-first", testee.getCurrentStep()) + } + + @Test + fun whenReturnToLoginAfterVisitingElsewhereThenSetsToLoginRepeated() { + testee.updateStepFromUrl("https://accounts.google.com/signin") // First login visit + testee.updateStepFromUrl("https://takeout.google.com") // Go to takeout + testee.updateStepFromUrl("https://accounts.google.com/signin") // Return to login + assertEquals("login-repeat", testee.getCurrentStep()) + } + + @Test + fun whenUpdateStepFromTakeoutUrlFromUninitializedThenSetsToTakeoutFirst() { + testee.updateStepFromUrl("https://takeout.google.com") + assertEquals("takeout-first", testee.getCurrentStep()) + } + + @Test + fun whenUpdateStepFromTakeoutUrlFromTakeoutFirstThenStaysTheSame() { + testee.updateStepFromUrl("https://takeout.google.com") + testee.updateStepFromUrl("https://takeout.google.com") + assertEquals("takeout-first", testee.getCurrentStep()) + } + + @Test + fun whenUpdateStepFromTakeoutUrlFromLoginFirstThenSetsToTakeoutFirst() { + testee.updateStepFromUrl("https://accounts.google.com/signin") + testee.updateStepFromUrl("https://takeout.google.com") + assertEquals("takeout-first", testee.getCurrentStep()) + } + + @Test + fun whenUpdateStepFromTakeoutUrlAfterFirstVisitThenSetsToTakeoutRepeated() { + testee.updateStepFromUrl("https://takeout.google.com") // First visit + testee.updateStepFromUrl("https://accounts.google.com/signin") // Go to login + testee.updateStepFromUrl("https://takeout.google.com") // Back to takeout + assertEquals("takeout-repeat", testee.getCurrentStep()) + } + + @Test + fun whenUpdateStepFromNullUrlThenStepRemainsUnchanged() { + val initialStep = testee.getCurrentStep() + testee.updateStepFromUrl(null) + assertEquals(initialStep, testee.getCurrentStep()) + } + + @Test + fun whenUpdateStepFromMalformedUrlThenStepRemainsUnchanged() { + val initialStep = testee.getCurrentStep() + testee.updateStepFromUrl("not-a-valid-url") + assertEquals(initialStep, testee.getCurrentStep()) + } + + @Test + fun whenUpdateStepFromOtherUrlThenSetsToUnknownUrl() { + testee.updateStepFromUrl("https://example.com") + assertEquals("unknown-url", testee.getCurrentStep()) + } + + @Test + fun whenUpdateStepFromUppercaseTakeoutUrlThenSetsToTakeoutFirst() { + testee.updateStepFromUrl("https://TAKEOUT.GOOGLE.COM") + assertEquals("takeout-first", testee.getCurrentStep()) + } + + @Test + fun whenUpdateStepFromUppercaseAccountsUrlThenSetsToLoginFirst() { + testee.updateStepFromUrl("https://ACCOUNTS.GOOGLE.COM/signin") + assertEquals("login-first", testee.getCurrentStep()) + } + + @Test + fun whenUpdateStepToDownloadDetectedThenSetsCorrectStep() { + testee.updateStepToDownloadDetected() + assertEquals("download-detected", testee.getCurrentStep()) + } + + @Test + fun whenUpdateStepFromImportResultSuccessThenSetsCorrectStep() { + testee.updateStepFromImportResult(BookmarkImportProcessor.ImportResult.Success(10)) + assertEquals("completed-successful", testee.getCurrentStep()) + } + + @Test + fun whenUpdateStepFromImportResultDownloadErrorThenSetsCorrectStep() { + testee.updateStepFromImportResult(BookmarkImportProcessor.ImportResult.Error.DownloadError) + assertEquals("completed-failure-download", testee.getCurrentStep()) + } + + @Test + fun whenUpdateStepFromImportResultParseErrorThenSetsCorrectStep() { + testee.updateStepFromImportResult(BookmarkImportProcessor.ImportResult.Error.ParseError) + assertEquals("completed-failure-parse", testee.getCurrentStep()) + } + + @Test + fun whenUpdateStepFromImportResultImportErrorThenSetsCorrectStep() { + testee.updateStepFromImportResult(BookmarkImportProcessor.ImportResult.Error.ImportError) + assertEquals("completed-failure-import", testee.getCurrentStep()) + } + + @Test + fun whenUpdateLatestStepSpecificStageThenSetsToActionID() { + testee.updateLatestStepSpecificStage("custom-action-id") + assertEquals("custom-action-id", testee.getCurrentStep()) + } + + @Test + fun whenAccountsUrlContainsTakeoutInPathThenStillDetectedAsAccounts() { + testee.updateStepFromUrl("https://accounts.google.com/signin?continue=https://takeout.google.com") + assertEquals("login-first", testee.getCurrentStep()) + } + + @Test + fun whenStartFlowThenResetsToUninitialized() { + testee.updateStepFromUrl("https://takeout.google.com") + testee.updateStepFromUrl("https://accounts.google.com/signin") + + testee.startFlow() + + assertEquals("uninitialized", testee.getCurrentStep()) + } + + @Test + fun whenStartFlowThenResetsFlags() { + testee.startFlow() + + // First visits should be "first", not "repeat" + testee.updateStepFromUrl("https://takeout.google.com") + assertEquals("takeout-first", testee.getCurrentStep()) + + testee.updateStepFromUrl("https://accounts.google.com/signin") + assertEquals("login-first", testee.getCurrentStep()) + } +} diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/takeout/webflow/ImportGoogleBookmarksWebFlowViewModelTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/takeout/webflow/ImportGoogleBookmarksWebFlowViewModelTest.kt index a50072e02b9d..713bd0e93df5 100644 --- a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/takeout/webflow/ImportGoogleBookmarksWebFlowViewModelTest.kt +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/takeout/webflow/ImportGoogleBookmarksWebFlowViewModelTest.kt @@ -26,15 +26,17 @@ class ImportGoogleBookmarksWebFlowViewModelTest { val coroutineTestRule: CoroutineTestRule = CoroutineTestRule() private val mockBookmarkImportProcessor: BookmarkImportProcessor = mock() - - private val testee = - ImportGoogleBookmarksWebFlowViewModel( - dispatchers = coroutineTestRule.testDispatcherProvider, - reauthenticationHandler = mock(), - autofillFeature = mock(), - bookmarkImportProcessor = mockBookmarkImportProcessor, - bookmarkImportConfigStore = mock(), - ) + private val mockWebFlowStepTracker: BookmarkImportWebFlowStepTracker = mock() + + private val testee = ImportGoogleBookmarksWebFlowViewModel( + dispatchers = coroutineTestRule.testDispatcherProvider, + reauthenticationHandler = mock(), + autofillFeature = mock(), + bookmarkImportProcessor = mockBookmarkImportProcessor, + bookmarkImportConfigStore = mock(), + takeoutWebMessageParser = mock(), + webFlowStepTracker = mockWebFlowStepTracker, + ) @Test fun whenOnPageStartedWithTakeoutUrlThenHideWebPage() = @@ -143,6 +145,13 @@ class ImportGoogleBookmarksWebFlowViewModelTest { } } + @Test + fun whenUpdateLatestStepLoginPageFromUninitializedThenSetsToFirstVisit() = + runTest { + testee.onPageStarted("https://accounts.google.com") + assertEquals(ShowWebPage, testee.viewState.value) + } + private fun triggerDownloadDetectedWithTakeoutZip() { testee.onDownloadDetected( url = "https://example.com/valid-file.zip", diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/takeout/webflow/RealTakeoutWebMessageParserTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/takeout/webflow/RealTakeoutWebMessageParserTest.kt new file mode 100644 index 000000000000..e61f46f53941 --- /dev/null +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/takeout/webflow/RealTakeoutWebMessageParserTest.kt @@ -0,0 +1,164 @@ +/* + * Copyright (c) 2025 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.takeout.webflow + +import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.common.test.FileUtilities +import com.squareup.moshi.Moshi +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +class RealTakeoutWebMessageParserTest { + + @get:Rule + val coroutineTestRule: CoroutineTestRule = CoroutineTestRule() + + private lateinit var parser: TakeoutWebMessageParser + + @Before + fun setUp() { + val moshi = Moshi.Builder().add(KotlinJsonAdapterFactory()).build() + parser = RealTakeoutWebMessageParser( + dispatchers = coroutineTestRule.testDispatcherProvider, + moshi = moshi, + ) + } + + @Test + fun whenParsingSuccessfulActionMessageThenReturnsSuccess() = runTest { + val result = parser.parseMessage("success_with_action_type".readFile()) + + assertTrue(result is TakeoutMessageResult.TakeoutActionSuccess) + val success = result as TakeoutMessageResult.TakeoutActionSuccess + assertEquals("manage-button-click", success.actionID) + } + + @Test + fun whenParsingErrorActionMessageThenReturnsError() = runTest { + val result = parser.parseMessage("error_with_action_id".readFile()) + + assertTrue(result is TakeoutMessageResult.TakeoutActionError) + val error = result as TakeoutMessageResult.TakeoutActionError + assertEquals("failed-action", error.actionID) + } + + @Test + fun whenParsingErrorWithoutActionIDThenReturnsErrorWithNullActionID() = runTest { + val result = parser.parseMessage("error_without_action_id".readFile()) + + assertTrue(result is TakeoutMessageResult.TakeoutActionError) + val error = result as TakeoutMessageResult.TakeoutActionError + assertEquals(null, error.actionID) + } + + @Test + fun whenParsingUnexpectedValidJsonThenReturnsUnknownFormat() = runTest { + val result = parser.parseMessage("invalid_structure".readFile()) + + assertTrue(result is TakeoutMessageResult.UnknownMessageFormat) + } + + @Test + fun whenParsingMalformedJsonThenReturnsUnknownFormat() = runTest { + val result = parser.parseMessage("malformed_json".readFile()) + + assertTrue(result is TakeoutMessageResult.UnknownMessageFormat) + } + + @Test + fun whenParsingEmptyResultThenReturnsUnknownFormat() = runTest { + val result = parser.parseMessage("empty_result".readFile()) + + assertTrue(result is TakeoutMessageResult.UnknownMessageFormat) + } + + @Test + fun whenParsingMessageWithoutDataFieldThenReturnsUnknownFormat() = runTest { + val result = parser.parseMessage("missing_data_field".readFile()) + assertTrue(result is TakeoutMessageResult.UnknownMessageFormat) + } + + @Test + fun whenParsingMessageWithoutResultFieldThenReturnsUnknownFormat() = runTest { + val result = parser.parseMessage("missing_result_field".readFile()) + assertTrue(result is TakeoutMessageResult.UnknownMessageFormat) + } + + @Test + fun whenParsingSuccessWithMissingActionIDThenReturnsError() = runTest { + val result = parser.parseMessage("success_missing_action_id".readFile()) + + assertTrue(result is TakeoutMessageResult.TakeoutActionError) + val error = result as TakeoutMessageResult.TakeoutActionError + assertEquals(null, error.actionID) + } + + @Test + fun whenParsingSuccessWithMissingActionTypeThenStillSucceeds() = runTest { + val result = parser.parseMessage("success_missing_action_type".readFile()) + + assertTrue(result is TakeoutMessageResult.TakeoutActionSuccess) + val success = result as TakeoutMessageResult.TakeoutActionSuccess + assertEquals("test-action", success.actionID) + } + + @Test + fun whenParsingCompletelyEmptySuccessObjectThenReturnsError() = runTest { + val result = parser.parseMessage("success_empty_object".readFile()) + + assertTrue(result is TakeoutMessageResult.TakeoutActionError) + val error = result as TakeoutMessageResult.TakeoutActionError + assertEquals(null, error.actionID) + } + + @Test + fun whenParsingCompletelyEmptyErrorObjectThenGracefullyHandles() = runTest { + val result = parser.parseMessage("error_empty_object".readFile()) + + assertTrue(result is TakeoutMessageResult.TakeoutActionError) + val error = result as TakeoutMessageResult.TakeoutActionError + assertEquals(null, error.actionID) + } + + @Test + fun whenParsingUnexpectedStructureThenReturnsUnknownFormat() = runTest { + val result = parser.parseMessage("unexpected_structure".readFile()) + + assertTrue(result is TakeoutMessageResult.UnknownMessageFormat) + } + + @Test + fun whenParsingWithExtraFieldsThenIgnoresThemGracefully() = runTest { + val result = parser.parseMessage("success_with_extra_fields".readFile()) + + assertTrue(result is TakeoutMessageResult.TakeoutActionSuccess) + val success = result as TakeoutMessageResult.TakeoutActionSuccess + assertEquals("test-action", success.actionID) + } + + private fun String.readFile(): String { + return FileUtilities.loadText( + RealTakeoutWebMessageParserTest::class.java.classLoader!!, + "json/takeout/$this.json", + ) + } +} diff --git a/autofill/autofill-impl/src/test/resources/json/takeout/empty_result.json b/autofill/autofill-impl/src/test/resources/json/takeout/empty_result.json new file mode 100644 index 000000000000..614e80a299a1 --- /dev/null +++ b/autofill/autofill-impl/src/test/resources/json/takeout/empty_result.json @@ -0,0 +1 @@ +{"name":"actionCompleted","data":{"result":{}}} \ No newline at end of file diff --git a/autofill/autofill-impl/src/test/resources/json/takeout/error_empty_object.json b/autofill/autofill-impl/src/test/resources/json/takeout/error_empty_object.json new file mode 100644 index 000000000000..0d7b6e326e7a --- /dev/null +++ b/autofill/autofill-impl/src/test/resources/json/takeout/error_empty_object.json @@ -0,0 +1 @@ +{"name":"actionCompleted","data":{"result":{"error":{}}}} \ No newline at end of file diff --git a/autofill/autofill-impl/src/test/resources/json/takeout/error_with_action_id.json b/autofill/autofill-impl/src/test/resources/json/takeout/error_with_action_id.json new file mode 100644 index 000000000000..7415326c7936 --- /dev/null +++ b/autofill/autofill-impl/src/test/resources/json/takeout/error_with_action_id.json @@ -0,0 +1 @@ +{"name":"actionCompleted","data":{"result":{"error":{"actionID":"failed-action","error":"Something went wrong"}}}} \ No newline at end of file diff --git a/autofill/autofill-impl/src/test/resources/json/takeout/error_without_action_id.json b/autofill/autofill-impl/src/test/resources/json/takeout/error_without_action_id.json new file mode 100644 index 000000000000..08e0c2c2ede2 --- /dev/null +++ b/autofill/autofill-impl/src/test/resources/json/takeout/error_without_action_id.json @@ -0,0 +1 @@ +{"name":"actionCompleted","data":{"result":{"error":{"error":"General error"}}}} \ No newline at end of file diff --git a/autofill/autofill-impl/src/test/resources/json/takeout/invalid_structure.json b/autofill/autofill-impl/src/test/resources/json/takeout/invalid_structure.json new file mode 100644 index 000000000000..7d9ef6eaadf1 --- /dev/null +++ b/autofill/autofill-impl/src/test/resources/json/takeout/invalid_structure.json @@ -0,0 +1 @@ +{"invalid": "json structure"} \ No newline at end of file diff --git a/autofill/autofill-impl/src/test/resources/json/takeout/malformed_json.json b/autofill/autofill-impl/src/test/resources/json/takeout/malformed_json.json new file mode 100644 index 000000000000..1b0139f43b06 --- /dev/null +++ b/autofill/autofill-impl/src/test/resources/json/takeout/malformed_json.json @@ -0,0 +1 @@ +{"name":"actionCompleted","data":{"result": \ No newline at end of file diff --git a/autofill/autofill-impl/src/test/resources/json/takeout/missing_data_field.json b/autofill/autofill-impl/src/test/resources/json/takeout/missing_data_field.json new file mode 100644 index 000000000000..ff0dea33af30 --- /dev/null +++ b/autofill/autofill-impl/src/test/resources/json/takeout/missing_data_field.json @@ -0,0 +1 @@ +{"name":"actionCompleted"} \ No newline at end of file diff --git a/autofill/autofill-impl/src/test/resources/json/takeout/missing_result_field.json b/autofill/autofill-impl/src/test/resources/json/takeout/missing_result_field.json new file mode 100644 index 000000000000..0a2044dbaa46 --- /dev/null +++ b/autofill/autofill-impl/src/test/resources/json/takeout/missing_result_field.json @@ -0,0 +1 @@ +{"name":"actionCompleted","data":{}} \ No newline at end of file diff --git a/autofill/autofill-impl/src/test/resources/json/takeout/success_empty_object.json b/autofill/autofill-impl/src/test/resources/json/takeout/success_empty_object.json new file mode 100644 index 000000000000..1abecbae8349 --- /dev/null +++ b/autofill/autofill-impl/src/test/resources/json/takeout/success_empty_object.json @@ -0,0 +1 @@ +{"name":"actionCompleted","data":{"result":{"success":{}}}} \ No newline at end of file diff --git a/autofill/autofill-impl/src/test/resources/json/takeout/success_missing_action_id.json b/autofill/autofill-impl/src/test/resources/json/takeout/success_missing_action_id.json new file mode 100644 index 000000000000..b5caac8df042 --- /dev/null +++ b/autofill/autofill-impl/src/test/resources/json/takeout/success_missing_action_id.json @@ -0,0 +1 @@ +{"name":"actionCompleted","data":{"result":{"success":{"actionType":"click"}}}} \ No newline at end of file diff --git a/autofill/autofill-impl/src/test/resources/json/takeout/success_missing_action_type.json b/autofill/autofill-impl/src/test/resources/json/takeout/success_missing_action_type.json new file mode 100644 index 000000000000..82709842241e --- /dev/null +++ b/autofill/autofill-impl/src/test/resources/json/takeout/success_missing_action_type.json @@ -0,0 +1 @@ +{"name":"actionCompleted","data":{"result":{"success":{"actionID":"test-action"}}}} \ No newline at end of file diff --git a/autofill/autofill-impl/src/test/resources/json/takeout/success_with_action_type.json b/autofill/autofill-impl/src/test/resources/json/takeout/success_with_action_type.json new file mode 100644 index 000000000000..d46bc6e1d153 --- /dev/null +++ b/autofill/autofill-impl/src/test/resources/json/takeout/success_with_action_type.json @@ -0,0 +1 @@ +{"name":"actionCompleted","data":{"result":{"success":{"actionID":"manage-button-click","actionType":"click","response":null}}}} \ No newline at end of file diff --git a/autofill/autofill-impl/src/test/resources/json/takeout/success_with_extra_fields.json b/autofill/autofill-impl/src/test/resources/json/takeout/success_with_extra_fields.json new file mode 100644 index 000000000000..50688ae068e0 --- /dev/null +++ b/autofill/autofill-impl/src/test/resources/json/takeout/success_with_extra_fields.json @@ -0,0 +1 @@ +{"name":"actionCompleted","data":{"result":{"success":{"actionID":"test-action","actionType":"click","extraField":"ignored","anotherField":123}},"extraData":"ignored"}} \ No newline at end of file diff --git a/autofill/autofill-impl/src/test/resources/json/takeout/unexpected_structure.json b/autofill/autofill-impl/src/test/resources/json/takeout/unexpected_structure.json new file mode 100644 index 000000000000..06b317f25923 --- /dev/null +++ b/autofill/autofill-impl/src/test/resources/json/takeout/unexpected_structure.json @@ -0,0 +1 @@ +{"name":"newEventType","data":{"someOtherField":"value"}} \ No newline at end of file diff --git a/autofill/autofill-internal/src/main/java/com/duckduckgo/autofill/internal/AutofillInternalSettingsActivity.kt b/autofill/autofill-internal/src/main/java/com/duckduckgo/autofill/internal/AutofillInternalSettingsActivity.kt index 9e0e2a608c19..bd094514f72d 100644 --- a/autofill/autofill-internal/src/main/java/com/duckduckgo/autofill/internal/AutofillInternalSettingsActivity.kt +++ b/autofill/autofill-internal/src/main/java/com/duckduckgo/autofill/internal/AutofillInternalSettingsActivity.kt @@ -61,6 +61,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.importing.takeout.zip.TakeoutBookmarkExtractor import com.duckduckgo.autofill.impl.importing.takeout.zip.TakeoutBookmarkExtractor.ExtractionResult @@ -290,6 +291,7 @@ class AutofillInternalSettingsActivity : DuckDuckGoActivity() { ErrorParsingBookmarks -> "Failed to parse bookmark data" Unknown -> "Failed to import bookmarks" WebViewError -> "WebView error occurred" + is WebAutomationError -> "Automation error ${(result.reason as WebAutomationError).step}" } errorMessage.showSnackbar() hidePreImportDialog()