Skip to content

Commit 72cb853

Browse files
committed
Add web message listener for js call from bookmark import
1 parent 94ccf12 commit 72cb853

23 files changed

+687
-52
lines changed

autofill/autofill-api/src/main/java/com/duckduckgo/autofill/api/AutofillFeature.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,12 @@ interface AutofillFeature {
122122
@Toggle.DefaultValue(DefaultFeatureValue.INTERNAL)
123123
fun canImportBookmarksFromGoogleTakeout(): Toggle
124124

125+
/**
126+
* Remote Flag that enables the ability to use web message listener during bookmark import flow from Google Takeout
127+
*/
128+
@Toggle.DefaultValue(DefaultFeatureValue.TRUE)
129+
fun canUseWebMessageListenerDuringBookmarkImport(): Toggle
130+
125131
/**
126132
* Remote flag that enables the ability to support partial form saves. A partial form save is common with scenarios like:
127133
* - a multi-step login form where username and password are entered on separate pages
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
package com.duckduckgo.autofill.impl.importing.takeout.webflow
2+
3+
import androidx.core.net.toUri
4+
import com.duckduckgo.autofill.impl.importing.takeout.processor.BookmarkImportProcessor.ImportResult
5+
import com.duckduckgo.autofill.impl.importing.takeout.processor.BookmarkImportProcessor.ImportResult.Error
6+
import com.duckduckgo.autofill.impl.importing.takeout.processor.BookmarkImportProcessor.ImportResult.Success
7+
import com.duckduckgo.di.scopes.FragmentScope
8+
import com.squareup.anvil.annotations.ContributesBinding
9+
import logcat.logcat
10+
import javax.inject.Inject
11+
12+
interface BookmarkImportWebFlowStepTracker {
13+
fun getCurrentStep(): String
14+
fun startFlow()
15+
fun updateStepFromUrl(url: String?)
16+
fun updateLatestStepSpecificStage(step: String)
17+
fun updateStepToDownloadDetected()
18+
fun updateStepFromImportResult(importResult: ImportResult)
19+
}
20+
21+
@ContributesBinding(FragmentScope::class)
22+
class BookmarkImportWebFlowStepTrackerImpl @Inject constructor() : BookmarkImportWebFlowStepTracker {
23+
24+
private var latestStepInWebFlow: String = STEP_UNINITIALIZED
25+
private var hasVisitedLogin: Boolean = false
26+
private var hasVisitedTakeout: Boolean = false
27+
28+
override fun getCurrentStep(): String = latestStepInWebFlow
29+
30+
override fun startFlow() {
31+
latestStepInWebFlow = STEP_UNINITIALIZED
32+
hasVisitedLogin = false
33+
hasVisitedTakeout = false
34+
logcat { "Bookmark-import: flow started, flags reset" }
35+
}
36+
37+
override fun updateLatestStepSpecificStage(step: String) {
38+
latestStepInWebFlow = step
39+
logcat { "Bookmark-import: latest step is: $step" }
40+
}
41+
42+
override fun updateStepFromUrl(url: String?) {
43+
val host = url?.toUri()?.host ?: return
44+
45+
when {
46+
host.contains(TAKEOUT_ADDRESS, ignoreCase = true) -> updateLatestStepTakeoutReached()
47+
host.contains(ACCOUNTS_ADDRESS, ignoreCase = true) -> updateLatestStepLoginPage()
48+
else -> updateLatestStepSpecificStage(STEP_UNKNOWN_URL)
49+
}
50+
}
51+
52+
override fun updateStepToDownloadDetected() {
53+
updateLatestStepSpecificStage(STEP_DOWNLOAD_DETECTED)
54+
}
55+
56+
override fun updateStepFromImportResult(importResult: ImportResult) {
57+
val step = when (importResult) {
58+
is Success -> STEP_IMPORT_SUCCESS
59+
is Error.DownloadError -> STEP_IMPORT_ERROR_DOWNLOAD
60+
is Error.ParseError -> STEP_IMPORT_ERROR_PARSE
61+
is Error.ImportError -> STEP_IMPORT_ERROR_WHILE_IMPORTING
62+
}
63+
updateLatestStepSpecificStage(step)
64+
}
65+
66+
private fun updateLatestStepTakeoutReached() {
67+
if (latestStepInWebFlow == STEP_GOOGLE_TAKEOUT_PAGE_FIRST || latestStepInWebFlow == STEP_GOOGLE_TAKEOUT_PAGE_REPEATED) {
68+
return
69+
}
70+
71+
if (!hasVisitedTakeout) {
72+
hasVisitedTakeout = true
73+
updateLatestStepSpecificStage(STEP_GOOGLE_TAKEOUT_PAGE_FIRST)
74+
} else {
75+
updateLatestStepSpecificStage(STEP_GOOGLE_TAKEOUT_PAGE_REPEATED)
76+
}
77+
}
78+
79+
private fun updateLatestStepLoginPage() {
80+
if (latestStepInWebFlow == STEP_GOOGLE_ACCOUNTS_PAGE_FIRST || latestStepInWebFlow == STEP_GOOGLE_ACCOUNTS_REPEATED) {
81+
return
82+
}
83+
84+
if (!hasVisitedLogin) {
85+
hasVisitedLogin = true
86+
updateLatestStepSpecificStage(STEP_GOOGLE_ACCOUNTS_PAGE_FIRST)
87+
} else {
88+
updateLatestStepSpecificStage(STEP_GOOGLE_ACCOUNTS_REPEATED)
89+
}
90+
}
91+
92+
companion object {
93+
private const val STEP_GOOGLE_TAKEOUT_PAGE_FIRST = "takeout-first"
94+
private const val STEP_GOOGLE_TAKEOUT_PAGE_REPEATED = "takeout-repeat"
95+
private const val STEP_GOOGLE_ACCOUNTS_PAGE_FIRST = "login-first"
96+
private const val STEP_GOOGLE_ACCOUNTS_REPEATED = "login-repeat"
97+
private const val STEP_UNINITIALIZED = "uninitialized"
98+
private const val STEP_IMPORT_SUCCESS = "completed-successful"
99+
private const val STEP_IMPORT_ERROR_PARSE = "completed-failure-parse"
100+
private const val STEP_IMPORT_ERROR_DOWNLOAD = "completed-failure-download"
101+
private const val STEP_IMPORT_ERROR_WHILE_IMPORTING = "completed-failure-import"
102+
private const val STEP_DOWNLOAD_DETECTED = "download-detected"
103+
private const val STEP_UNKNOWN_URL = "unknown-url"
104+
private const val TAKEOUT_ADDRESS = "takeout.google.com"
105+
private const val ACCOUNTS_ADDRESS = "accounts.google.com"
106+
}
107+
}

autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/takeout/webflow/ImportGoogleBookmarksWebFlowFragment.kt

Lines changed: 47 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import androidx.lifecycle.flowWithLifecycle
3232
import androidx.lifecycle.lifecycleScope
3333
import androidx.webkit.WebViewCompat
3434
import com.duckduckgo.anvil.annotations.InjectWith
35+
import com.duckduckgo.autofill.api.AutofillFeature
3536
import com.duckduckgo.autofill.api.AutofillFragmentResultsPlugin
3637
import com.duckduckgo.autofill.api.BrowserAutofill
3738
import com.duckduckgo.autofill.api.CredentialAutofillDialogFactory
@@ -47,7 +48,9 @@ import com.duckduckgo.autofill.impl.importing.gpm.webflow.autofill.NoOpAutofillE
4748
import com.duckduckgo.autofill.impl.importing.gpm.webflow.autofill.NoOpEmailProtectionInContextSignupFlowListener
4849
import com.duckduckgo.autofill.impl.importing.gpm.webflow.autofill.NoOpEmailProtectionUserPromptListener
4950
import com.duckduckgo.autofill.impl.importing.takeout.store.BookmarkImportConfigStore
51+
import com.duckduckgo.autofill.impl.importing.takeout.webflow.ImportGoogleBookmarkResult.Error
5052
import com.duckduckgo.autofill.impl.importing.takeout.webflow.ImportGoogleBookmarkResult.Success
53+
import com.duckduckgo.autofill.impl.importing.takeout.webflow.ImportGoogleBookmarkResult.UserCancelled
5154
import com.duckduckgo.autofill.impl.importing.takeout.webflow.ImportGoogleBookmarksWebFlowViewModel.Command.ExitFlowAsFailure
5255
import com.duckduckgo.autofill.impl.importing.takeout.webflow.ImportGoogleBookmarksWebFlowViewModel.Command.ExitFlowWithSuccess
5356
import com.duckduckgo.autofill.impl.importing.takeout.webflow.ImportGoogleBookmarksWebFlowViewModel.Command.InjectCredentialsFromReauth
@@ -121,6 +124,9 @@ class ImportGoogleBookmarksWebFlowFragment :
121124
@Inject
122125
lateinit var browserAutofillConfigurator: InternalBrowserAutofillConfigurator
123126

127+
@Inject
128+
lateinit var autofillFeature: AutofillFeature
129+
124130
private var binding: FragmentImportGoogleBookmarksWebflowBinding? = null
125131
private var cancellationDialog: DaxAlertDialog? = null
126132

@@ -193,16 +199,12 @@ class ImportGoogleBookmarksWebFlowFragment :
193199
// Inject null to indicate no credentials available
194200
browserAutofill.injectCredentials(null)
195201
}
196-
is PromptUserToSelectFromStoredCredentials ->
197-
showCredentialChooserDialog(
198-
command.originalUrl,
199-
command.credentials,
200-
command.triggerType,
201-
)
202-
is ExitFlowWithSuccess -> {
203-
logcat { "Bookmark-import: ExitFlowWithSuccess received with count: ${command.importedCount}" }
204-
exitFlowAsSuccess(command.importedCount)
205-
}
202+
is PromptUserToSelectFromStoredCredentials -> showCredentialChooserDialog(
203+
command.originalUrl,
204+
command.credentials,
205+
command.triggerType,
206+
)
207+
is ExitFlowWithSuccess -> exitFlowAsSuccess(command.importedCount)
206208
is ExitFlowAsFailure -> exitFlowAsError(command.reason)
207209
is PromptUserToConfirmFlowCancellation -> askUserToConfirmCancellation()
208210
}
@@ -221,12 +223,11 @@ class ImportGoogleBookmarksWebFlowFragment :
221223
return@withContext
222224
}
223225

224-
val credentials =
225-
LoginCredentials(
226-
domain = url,
227-
username = username,
228-
password = password,
229-
)
226+
val credentials = LoginCredentials(
227+
domain = url,
228+
username = username,
229+
password = password,
230+
)
230231

231232
logcat { "Injecting re-authentication credentials" }
232233
browserAutofill.injectCredentials(credentials)
@@ -317,6 +318,19 @@ class ImportGoogleBookmarksWebFlowFragment :
317318
} else {
318319
logcat(WARN) { "Bookmark-import: Not able to inject bookmark import JavaScript" }
319320
}
321+
322+
val canAddMessageListener = withContext(dispatchers.io()) {
323+
autofillFeature.canUseWebMessageListenerDuringBookmarkImport().isEnabled()
324+
}
325+
326+
if (canAddMessageListener) {
327+
WebViewCompat.addWebMessageListener(webView, "ddgBookmarkImport", setOf("*")) { _, message, sourceOrigin, _, _ ->
328+
val data = message.data ?: return@addWebMessageListener
329+
viewModel.onWebMessageReceived(data)
330+
}
331+
} else {
332+
logcat(WARN) { "Bookmark-import: Not able to add WebMessage listener for bookmark import" }
333+
}
320334
}
321335

322336
private fun initialiseToolbar() {
@@ -375,35 +389,32 @@ class ImportGoogleBookmarksWebFlowFragment :
375389
requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner, onBackPressedCallback)
376390
}
377391

378-
private fun exitFlowAsSuccess(bookmarkCount: Int) {
379-
logcat { "Bookmark-import: Reporting import success with bookmarkCount: $bookmarkCount" }
392+
private fun exitFlowAsSuccess(importedCount: Int = 0) {
393+
logcat { "Bookmark-import: Reporting import success with bookmarkCount: $importedCount" }
380394
dismissCancellationDialog()
381-
val result =
382-
Bundle().apply {
383-
putParcelable(ImportGoogleBookmarkResult.RESULT_KEY_DETAILS, Success(bookmarkCount))
384-
}
395+
val result = Bundle().apply {
396+
putParcelable(ImportGoogleBookmarkResult.Companion.RESULT_KEY_DETAILS, Success(importedCount))
397+
}
385398
parentFragmentManager.setFragmentResult(ImportGoogleBookmarkResult.RESULT_KEY, result)
386399
}
387400

388401
private fun exitFlowAsCancellation(stage: String) {
389402
logcat { "Bookmark-import: Flow cancelled at stage: $stage" }
390403
dismissCancellationDialog()
391404

392-
val result =
393-
Bundle().apply {
394-
putParcelable(ImportGoogleBookmarkResult.Companion.RESULT_KEY_DETAILS, ImportGoogleBookmarkResult.UserCancelled(stage))
395-
}
405+
val result = Bundle().apply {
406+
putParcelable(ImportGoogleBookmarkResult.Companion.RESULT_KEY_DETAILS, UserCancelled(stage))
407+
}
396408
setFragmentResult(ImportGoogleBookmarkResult.Companion.RESULT_KEY, result)
397409
}
398410

399411
private fun exitFlowAsError(reason: UserCannotImportReason) {
400412
logcat { "Bookmark-import: Flow error at stage: ${reason.mapToStage()}" }
401413
dismissCancellationDialog()
402414

403-
val result =
404-
Bundle().apply {
405-
putParcelable(ImportGoogleBookmarkResult.Companion.RESULT_KEY_DETAILS, ImportGoogleBookmarkResult.Error(reason))
406-
}
415+
val result = Bundle().apply {
416+
putParcelable(ImportGoogleBookmarkResult.Companion.RESULT_KEY_DETAILS, Error(reason))
417+
}
407418
setFragmentResult(ImportGoogleBookmarkResult.Companion.RESULT_KEY, result)
408419
}
409420

@@ -419,13 +430,12 @@ class ImportGoogleBookmarksWebFlowFragment :
419430
return@withContext
420431
}
421432

422-
val dialog =
423-
credentialAutofillDialogFactory.autofillSelectCredentialsDialog(
424-
url,
425-
credentials,
426-
triggerType,
427-
CUSTOM_FLOW_TAB_ID,
428-
)
433+
val dialog = credentialAutofillDialogFactory.autofillSelectCredentialsDialog(
434+
url,
435+
credentials,
436+
triggerType,
437+
CUSTOM_FLOW_TAB_ID,
438+
)
429439
dialog.show(childFragmentManager, SELECT_CREDENTIALS_FRAGMENT_TAG)
430440
}
431441
}

autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/takeout/webflow/ImportGoogleBookmarksWebFlowViewModel.kt

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ import com.duckduckgo.autofill.impl.importing.takeout.store.BookmarkImportConfig
2828
import com.duckduckgo.autofill.impl.importing.takeout.webflow.ImportGoogleBookmarksWebFlowViewModel.Command.PromptUserToConfirmFlowCancellation
2929
import com.duckduckgo.autofill.impl.importing.takeout.webflow.ImportGoogleBookmarksWebFlowViewModel.ViewState.HideWebPage
3030
import com.duckduckgo.autofill.impl.importing.takeout.webflow.ImportGoogleBookmarksWebFlowViewModel.ViewState.ShowWebPage
31+
import com.duckduckgo.autofill.impl.importing.takeout.webflow.TakeoutMessageResult.TakeoutActionError
32+
import com.duckduckgo.autofill.impl.importing.takeout.webflow.TakeoutMessageResult.TakeoutActionSuccess
33+
import com.duckduckgo.autofill.impl.importing.takeout.webflow.TakeoutMessageResult.UnknownMessageFormat
3134
import com.duckduckgo.autofill.impl.store.ReAuthenticationDetails
3235
import com.duckduckgo.autofill.impl.store.ReauthenticationHandler
3336
import com.duckduckgo.common.utils.DispatcherProvider
@@ -38,6 +41,7 @@ import kotlinx.coroutines.flow.SharedFlow
3841
import kotlinx.coroutines.flow.StateFlow
3942
import kotlinx.coroutines.launch
4043
import kotlinx.coroutines.withContext
44+
import logcat.LogPriority.WARN
4145
import logcat.logcat
4246
import javax.inject.Inject
4347

@@ -48,6 +52,8 @@ class ImportGoogleBookmarksWebFlowViewModel @Inject constructor(
4852
private val autofillFeature: AutofillFeature,
4953
private val bookmarkImportProcessor: BookmarkImportProcessor,
5054
private val bookmarkImportConfigStore: BookmarkImportConfigStore,
55+
private val takeoutWebMessageParser: TakeoutWebMessageParser,
56+
private val webFlowStepTracker: BookmarkImportWebFlowStepTracker,
5157
) : ViewModel() {
5258
private val _viewState = MutableStateFlow<ViewState>(ViewState.Initializing)
5359
val viewState: StateFlow<ViewState> = _viewState
@@ -71,6 +77,7 @@ class ImportGoogleBookmarksWebFlowViewModel @Inject constructor(
7177
) {
7278
viewModelScope.launch(dispatchers.io()) {
7379
logcat { "Download detected: $url, mimeType: $mimeType, contentDisposition: $contentDisposition" }
80+
webFlowStepTracker.updateStepToDownloadDetected()
7481

7582
// Check if this looks like a valid Google Takeout bookmark export
7683
val isValidImport = isTakeoutZipDownloadLink(mimeType, url, contentDisposition)
@@ -109,6 +116,8 @@ class ImportGoogleBookmarksWebFlowViewModel @Inject constructor(
109116
importResult: BookmarkImportProcessor.ImportResult,
110117
folderName: String,
111118
) {
119+
webFlowStepTracker.updateStepFromImportResult(importResult)
120+
112121
when (importResult) {
113122
is BookmarkImportProcessor.ImportResult.Success -> {
114123
logcat { "Successfully imported ${importResult.importedCount} bookmarks into '$folderName' folder" }
@@ -130,7 +139,7 @@ class ImportGoogleBookmarksWebFlowViewModel @Inject constructor(
130139
}
131140

132141
fun onCloseButtonPressed() {
133-
terminateFlowAsCancellation()
142+
terminateFlowAsCancellation(webFlowStepTracker.getCurrentStep())
134143
}
135144

136145
fun onBackButtonPressed(canGoBack: Boolean = false) {
@@ -142,9 +151,9 @@ class ImportGoogleBookmarksWebFlowViewModel @Inject constructor(
142151
}
143152
}
144153

145-
private fun terminateFlowAsCancellation() {
154+
private fun terminateFlowAsCancellation(stage: String) {
146155
viewModelScope.launch {
147-
_viewState.value = ViewState.UserCancelledImportFlow(stage = "unknown")
156+
_viewState.value = ViewState.UserCancelledImportFlow(stage)
148157
}
149158
}
150159

@@ -221,16 +230,30 @@ class ImportGoogleBookmarksWebFlowViewModel @Inject constructor(
221230
}
222231

223232
fun onPageStarted(url: String?) {
233+
webFlowStepTracker.updateStepFromUrl(url)
224234
val host = url?.toUri()?.host ?: return
225235
_viewState.value = if (host.contains(TAKEOUT_ADDRESS, ignoreCase = true)) {
226236
HideWebPage
237+
} else if (host.contains(ACCOUNTS_ADDRESS, ignoreCase = true)) {
238+
ShowWebPage
227239
} else {
228240
ShowWebPage
229241
}
230242
}
231243

232-
private companion object {
244+
fun onWebMessageReceived(data: String) {
245+
viewModelScope.launch {
246+
when (val result = takeoutWebMessageParser.parseMessage(data)) {
247+
is TakeoutActionSuccess -> webFlowStepTracker.updateLatestStepSpecificStage(result.actionID)
248+
is TakeoutActionError -> logcat(WARN) { "Bookmark-import: experienced an error in the step: $result, raw:$data" }
249+
is UnknownMessageFormat -> logcat(WARN) { "Bookmark-import: failed to parse message, unknown format: $data" }
250+
}
251+
}
252+
}
253+
254+
companion object {
233255
private const val TAKEOUT_ADDRESS = "takeout.google.com"
256+
private const val ACCOUNTS_ADDRESS = "accounts.google.com"
234257
}
235258

236259
sealed interface Command {

0 commit comments

Comments
 (0)