Skip to content

Commit 3a473d1

Browse files
committed
Add web message listener for js call from bookmark import
1 parent 889c892 commit 3a473d1

22 files changed

+673
-36
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: 36 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import androidx.lifecycle.lifecycleScope
3333
import androidx.lifecycle.repeatOnLifecycle
3434
import androidx.webkit.WebViewCompat
3535
import com.duckduckgo.anvil.annotations.InjectWith
36+
import com.duckduckgo.autofill.api.AutofillFeature
3637
import com.duckduckgo.autofill.api.AutofillFragmentResultsPlugin
3738
import com.duckduckgo.autofill.api.BrowserAutofill
3839
import com.duckduckgo.autofill.api.CredentialAutofillDialogFactory
@@ -48,7 +49,9 @@ import com.duckduckgo.autofill.impl.importing.gpm.webflow.autofill.NoOpAutofillE
4849
import com.duckduckgo.autofill.impl.importing.gpm.webflow.autofill.NoOpEmailProtectionInContextSignupFlowListener
4950
import com.duckduckgo.autofill.impl.importing.gpm.webflow.autofill.NoOpEmailProtectionUserPromptListener
5051
import com.duckduckgo.autofill.impl.importing.takeout.store.BookmarkImportConfigStore
52+
import com.duckduckgo.autofill.impl.importing.takeout.webflow.ImportGoogleBookmarkResult.Error
5153
import com.duckduckgo.autofill.impl.importing.takeout.webflow.ImportGoogleBookmarkResult.Success
54+
import com.duckduckgo.autofill.impl.importing.takeout.webflow.ImportGoogleBookmarkResult.UserCancelled
5255
import com.duckduckgo.autofill.impl.importing.takeout.webflow.ImportGoogleBookmarksWebFlowViewModel.Command.ExitFlowAsFailure
5356
import com.duckduckgo.autofill.impl.importing.takeout.webflow.ImportGoogleBookmarksWebFlowViewModel.Command.ExitFlowWithSuccess
5457
import com.duckduckgo.autofill.impl.importing.takeout.webflow.ImportGoogleBookmarksWebFlowViewModel.Command.InjectCredentialsFromReauth
@@ -123,6 +126,9 @@ class ImportGoogleBookmarksWebFlowFragment :
123126
@Inject
124127
lateinit var browserAutofillConfigurator: InternalBrowserAutofillConfigurator
125128

129+
@Inject
130+
lateinit var autofillFeature: AutofillFeature
131+
126132
private var binding: FragmentImportGoogleBookmarksWebflowBinding? = null
127133
private var cancellationDialog: DaxAlertDialog? = null
128134

@@ -195,16 +201,12 @@ class ImportGoogleBookmarksWebFlowFragment :
195201
// Inject null to indicate no credentials available
196202
browserAutofill.injectCredentials(null)
197203
}
198-
is PromptUserToSelectFromStoredCredentials ->
199-
showCredentialChooserDialog(
200-
command.originalUrl,
201-
command.credentials,
202-
command.triggerType,
203-
)
204-
is ExitFlowWithSuccess -> {
205-
logcat { "Bookmark-import: ExitFlowWithSuccess received with count: ${command.importedCount}" }
206-
exitFlowAsSuccess(command.importedCount)
207-
}
204+
is PromptUserToSelectFromStoredCredentials -> showCredentialChooserDialog(
205+
command.originalUrl,
206+
command.credentials,
207+
command.triggerType,
208+
)
209+
is ExitFlowWithSuccess -> exitFlowAsSuccess(command.importedCount)
208210
is ExitFlowAsFailure -> exitFlowAsError(command.reason)
209211
is PromptUserToConfirmFlowCancellation -> askUserToConfirmCancellation()
210212
}
@@ -223,12 +225,11 @@ class ImportGoogleBookmarksWebFlowFragment :
223225
return@withContext
224226
}
225227

226-
val credentials =
227-
LoginCredentials(
228-
domain = url,
229-
username = username,
230-
password = password,
231-
)
228+
val credentials = LoginCredentials(
229+
domain = url,
230+
username = username,
231+
password = password,
232+
)
232233

233234
logcat { "Injecting re-authentication credentials" }
234235
browserAutofill.injectCredentials(credentials)
@@ -319,6 +320,19 @@ class ImportGoogleBookmarksWebFlowFragment :
319320
} else {
320321
logcat(WARN) { "Bookmark-import: Not able to inject bookmark import JavaScript" }
321322
}
323+
324+
val canAddMessageListener = withContext(dispatchers.io()) {
325+
autofillFeature.canUseWebMessageListenerDuringBookmarkImport().isEnabled()
326+
}
327+
328+
if (canAddMessageListener) {
329+
WebViewCompat.addWebMessageListener(webView, "ddgBookmarkImport", setOf("*")) { _, message, sourceOrigin, _, _ ->
330+
val data = message.data ?: return@addWebMessageListener
331+
viewModel.onWebMessageReceived(data)
332+
}
333+
} else {
334+
logcat(WARN) { "Bookmark-import: Not able to add WebMessage listener for bookmark import" }
335+
}
322336
}
323337

324338
private fun initialiseToolbar() {
@@ -438,13 +452,12 @@ class ImportGoogleBookmarksWebFlowFragment :
438452
return@withContext
439453
}
440454

441-
val dialog =
442-
credentialAutofillDialogFactory.autofillSelectCredentialsDialog(
443-
url,
444-
credentials,
445-
triggerType,
446-
CUSTOM_FLOW_TAB_ID,
447-
)
455+
val dialog = credentialAutofillDialogFactory.autofillSelectCredentialsDialog(
456+
url,
457+
credentials,
458+
triggerType,
459+
CUSTOM_FLOW_TAB_ID,
460+
)
448461
dialog.show(childFragmentManager, SELECT_CREDENTIALS_FRAGMENT_TAG)
449462
}
450463
}

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)