Skip to content

Commit 2906afe

Browse files
committed
Add web message listener for js call from bookmark import
1 parent 9f7fd93 commit 2906afe

19 files changed

+412
-53
lines changed

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

Lines changed: 35 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,9 @@ import com.duckduckgo.autofill.impl.importing.gpm.webflow.autofill.NoOpAutofillE
4747
import com.duckduckgo.autofill.impl.importing.gpm.webflow.autofill.NoOpEmailProtectionInContextSignupFlowListener
4848
import com.duckduckgo.autofill.impl.importing.gpm.webflow.autofill.NoOpEmailProtectionUserPromptListener
4949
import com.duckduckgo.autofill.impl.importing.takeout.store.BookmarkImportConfigStore
50+
import com.duckduckgo.autofill.impl.importing.takeout.webflow.ImportGoogleBookmarkResult.Error
5051
import com.duckduckgo.autofill.impl.importing.takeout.webflow.ImportGoogleBookmarkResult.Success
52+
import com.duckduckgo.autofill.impl.importing.takeout.webflow.ImportGoogleBookmarkResult.UserCancelled
5153
import com.duckduckgo.autofill.impl.importing.takeout.webflow.ImportGoogleBookmarksWebFlowViewModel.Command.ExitFlowAsFailure
5254
import com.duckduckgo.autofill.impl.importing.takeout.webflow.ImportGoogleBookmarksWebFlowViewModel.Command.ExitFlowWithSuccess
5355
import com.duckduckgo.autofill.impl.importing.takeout.webflow.ImportGoogleBookmarksWebFlowViewModel.Command.InjectCredentialsFromReauth
@@ -197,16 +199,12 @@ class ImportGoogleBookmarksWebFlowFragment :
197199
// Inject null to indicate no credentials available
198200
browserAutofill.injectCredentials(null)
199201
}
200-
is PromptUserToSelectFromStoredCredentials ->
201-
showCredentialChooserDialog(
202-
command.originalUrl,
203-
command.credentials,
204-
command.triggerType,
205-
)
206-
is ExitFlowWithSuccess -> {
207-
logcat { "Bookmark-import: ExitFlowWithSuccess received with count: ${command.importedCount}" }
208-
exitFlowAsSuccess(command.importedCount)
209-
}
202+
is PromptUserToSelectFromStoredCredentials -> showCredentialChooserDialog(
203+
command.originalUrl,
204+
command.credentials,
205+
command.triggerType,
206+
)
207+
is ExitFlowWithSuccess -> exitFlowAsSuccess(command.importedCount)
210208
is ExitFlowAsFailure -> exitFlowAsError(command.reason)
211209
is PromptUserToConfirmFlowCancellation -> askUserToConfirmCancellation()
212210
}
@@ -225,12 +223,11 @@ class ImportGoogleBookmarksWebFlowFragment :
225223
return@withContext
226224
}
227225

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

235232
logcat { "Injecting re-authentication credentials" }
236233
browserAutofill.injectCredentials(credentials)
@@ -321,6 +318,11 @@ class ImportGoogleBookmarksWebFlowFragment :
321318
} else {
322319
logcat(WARN) { "Bookmark-import: Not able to inject bookmark import JavaScript" }
323320
}
321+
322+
WebViewCompat.addWebMessageListener(webView, "ddgBookmarkImport", setOf("*")) { _, message, sourceOrigin, _, _ ->
323+
val data = message.data ?: return@addWebMessageListener
324+
viewModel.onWebMessageReceived(data)
325+
}
324326
}
325327

326328
private fun initialiseToolbar() {
@@ -379,35 +381,32 @@ class ImportGoogleBookmarksWebFlowFragment :
379381
requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner, onBackPressedCallback)
380382
}
381383

382-
private fun exitFlowAsSuccess(bookmarkCount: Int) {
383-
logcat { "Bookmark-import: Reporting import success with bookmarkCount: $bookmarkCount" }
384+
private fun exitFlowAsSuccess(importedCount: Int = 0) {
385+
logcat { "Bookmark-import: Reporting import success with bookmarkCount: $importedCount" }
384386
dismissCancellationDialog()
385-
val result =
386-
Bundle().apply {
387-
putParcelable(ImportGoogleBookmarkResult.RESULT_KEY_DETAILS, Success(bookmarkCount))
388-
}
387+
val result = Bundle().apply {
388+
putParcelable(ImportGoogleBookmarkResult.Companion.RESULT_KEY_DETAILS, Success(importedCount))
389+
}
389390
parentFragmentManager.setFragmentResult(ImportGoogleBookmarkResult.RESULT_KEY, result)
390391
}
391392

392393
private fun exitFlowAsCancellation(stage: String) {
393394
logcat { "Bookmark-import: Flow cancelled at stage: $stage" }
394395
dismissCancellationDialog()
395396

396-
val result =
397-
Bundle().apply {
398-
putParcelable(ImportGoogleBookmarkResult.Companion.RESULT_KEY_DETAILS, ImportGoogleBookmarkResult.UserCancelled(stage))
399-
}
397+
val result = Bundle().apply {
398+
putParcelable(ImportGoogleBookmarkResult.Companion.RESULT_KEY_DETAILS, UserCancelled(stage))
399+
}
400400
setFragmentResult(ImportGoogleBookmarkResult.Companion.RESULT_KEY, result)
401401
}
402402

403403
private fun exitFlowAsError(reason: UserCannotImportReason) {
404404
logcat { "Bookmark-import: Flow error at stage: ${reason.mapToStage()}" }
405405
dismissCancellationDialog()
406406

407-
val result =
408-
Bundle().apply {
409-
putParcelable(ImportGoogleBookmarkResult.Companion.RESULT_KEY_DETAILS, ImportGoogleBookmarkResult.Error(reason))
410-
}
407+
val result = Bundle().apply {
408+
putParcelable(ImportGoogleBookmarkResult.Companion.RESULT_KEY_DETAILS, Error(reason))
409+
}
411410
setFragmentResult(ImportGoogleBookmarkResult.Companion.RESULT_KEY, result)
412411
}
413412

@@ -423,13 +422,12 @@ class ImportGoogleBookmarksWebFlowFragment :
423422
return@withContext
424423
}
425424

426-
val dialog =
427-
credentialAutofillDialogFactory.autofillSelectCredentialsDialog(
428-
url,
429-
credentials,
430-
triggerType,
431-
CUSTOM_FLOW_TAB_ID,
432-
)
425+
val dialog = credentialAutofillDialogFactory.autofillSelectCredentialsDialog(
426+
url,
427+
credentials,
428+
triggerType,
429+
CUSTOM_FLOW_TAB_ID,
430+
)
433431
dialog.show(childFragmentManager, SELECT_CREDENTIALS_FRAGMENT_TAG)
434432
}
435433
}

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

Lines changed: 68 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,12 @@ 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
34+
import com.duckduckgo.autofill.impl.importing.takeout.zip.TakeoutBookmarkExtractor
35+
import com.duckduckgo.autofill.impl.importing.takeout.zip.TakeoutBookmarkExtractor.ExtractionResult
36+
import com.duckduckgo.autofill.impl.importing.takeout.zip.TakeoutZipDownloader
3137
import com.duckduckgo.autofill.impl.store.ReAuthenticationDetails
3238
import com.duckduckgo.autofill.impl.store.ReauthenticationHandler
3339
import com.duckduckgo.common.utils.DispatcherProvider
@@ -39,6 +45,7 @@ import kotlinx.coroutines.flow.StateFlow
3945
import kotlinx.coroutines.launch
4046
import kotlinx.coroutines.withContext
4147
import logcat.logcat
48+
import logcat.LogPriority.WARN
4249
import javax.inject.Inject
4350

4451
@ContributesViewModel(FragmentScope::class)
@@ -48,13 +55,16 @@ class ImportGoogleBookmarksWebFlowViewModel @Inject constructor(
4855
private val autofillFeature: AutofillFeature,
4956
private val bookmarkImportProcessor: BookmarkImportProcessor,
5057
private val bookmarkImportConfigStore: BookmarkImportConfigStore,
58+
private val takeoutWebMessageParser: TakeoutWebMessageParser,
5159
) : ViewModel() {
5260
private val _viewState = MutableStateFlow<ViewState>(ViewState.Initializing)
5361
val viewState: StateFlow<ViewState> = _viewState
5462

5563
private val _commands = MutableSharedFlow<Command>(replay = 0, extraBufferCapacity = 1)
5664
val commands: SharedFlow<Command> = _commands
5765

66+
private var latestStepInWebFlow: String = STEP_UNINITIALIZED
67+
5868
suspend fun loadInitialWebpage() {
5969
withContext(dispatchers.io()) {
6070
val initialUrl = bookmarkImportConfigStore.getConfig().launchUrlGoogleTakeout
@@ -130,7 +140,7 @@ class ImportGoogleBookmarksWebFlowViewModel @Inject constructor(
130140
}
131141

132142
fun onCloseButtonPressed() {
133-
terminateFlowAsCancellation()
143+
terminateFlowAsCancellation(latestStepInWebFlow)
134144
}
135145

136146
fun onBackButtonPressed(canGoBack: Boolean = false) {
@@ -142,9 +152,9 @@ class ImportGoogleBookmarksWebFlowViewModel @Inject constructor(
142152
}
143153
}
144154

145-
private fun terminateFlowAsCancellation() {
155+
private fun terminateFlowAsCancellation(stage: String) {
146156
viewModelScope.launch {
147-
_viewState.value = ViewState.UserCancelledImportFlow(stage = "unknown")
157+
_viewState.value = ViewState.UserCancelledImportFlow(stage)
148158
}
149159
}
150160

@@ -222,12 +232,62 @@ class ImportGoogleBookmarksWebFlowViewModel @Inject constructor(
222232

223233
fun onPageStarted(url: String?) {
224234
val host = url?.toUri()?.host ?: return
225-
_viewState.value =
226-
if (host.contains("takeout.google.com", ignoreCase = true)) {
227-
HideWebPage
228-
} else {
229-
ShowWebPage
235+
_viewState.value = if (host.contains("takeout.google.com", ignoreCase = true)) {
236+
updateLatestStepSpecificStage(GOOGLE_TAKEOUT_PAGE_REACHED)
237+
HideWebPage
238+
} else if (host.contains("accounts.google.com", ignoreCase = true)) {
239+
updateLatestStepLoginPage()
240+
ShowWebPage
241+
} else {
242+
ShowWebPage
243+
}
244+
}
245+
246+
private fun updateLatestStepLoginPage() {
247+
// if we already have the login page as the current step, do nothing
248+
if (latestStepInWebFlow == GOOGLE_ACCOUNTS_PAGE_FIRST || latestStepInWebFlow == GOOGLE_ACCOUNTS_REPEATED) {
249+
return
250+
}
251+
252+
// if uninitialized, this is the first time seeing the login page
253+
if (latestStepInWebFlow == STEP_UNINITIALIZED) {
254+
updateLatestStepSpecificStage(GOOGLE_ACCOUNTS_PAGE_FIRST)
255+
return
256+
}
257+
258+
// this must be a repeated visit to the login page
259+
updateLatestStepSpecificStage(GOOGLE_ACCOUNTS_REPEATED)
260+
}
261+
262+
private fun updateLatestStepSpecificStage(step: String) {
263+
latestStepInWebFlow = step
264+
logcat { "cdr latest step is: $step" }
265+
}
266+
267+
fun onWebMessageReceived(data: String) {
268+
viewModelScope.launch {
269+
when (val result = takeoutWebMessageParser.parseMessage(data)) {
270+
is TakeoutActionSuccess -> {
271+
logcat { "cdr successfully parsed message: $result" }
272+
updateLatestStepSpecificStage(result.actionID)
273+
}
274+
275+
is TakeoutActionError -> {
276+
logcat { "cdr experienced an error in the step: $result, raw:$data" }
277+
}
278+
279+
UnknownMessageFormat -> {
280+
logcat(WARN) { "cdr failed to parse message, unknown format: $data" }
281+
}
230282
}
283+
}
284+
}
285+
286+
companion object {
287+
private const val GOOGLE_TAKEOUT_PAGE_REACHED = "takeout"
288+
private const val GOOGLE_ACCOUNTS_PAGE_FIRST = "login-first"
289+
private const val GOOGLE_ACCOUNTS_REPEATED = "login-repeat"
290+
private const val STEP_UNINITIALIZED = "uninitialized"
231291
}
232292

233293
sealed interface Command {
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
/*
2+
* Copyright (c) 2025 DuckDuckGo
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.duckduckgo.autofill.impl.importing.takeout.webflow
18+
19+
import com.duckduckgo.autofill.impl.importing.takeout.webflow.TakeoutMessageResult.TakeoutActionError
20+
import com.duckduckgo.autofill.impl.importing.takeout.webflow.TakeoutMessageResult.TakeoutActionSuccess
21+
import com.duckduckgo.autofill.impl.importing.takeout.webflow.TakeoutMessageResult.UnknownMessageFormat
22+
import com.duckduckgo.common.utils.DispatcherProvider
23+
import com.duckduckgo.di.scopes.AppScope
24+
import com.squareup.anvil.annotations.ContributesBinding
25+
import com.squareup.moshi.Json
26+
import com.squareup.moshi.Moshi
27+
import javax.inject.Inject
28+
import kotlinx.coroutines.withContext
29+
import logcat.LogPriority.WARN
30+
import logcat.asLog
31+
import logcat.logcat
32+
33+
/**
34+
* Parser for web messages from takeout.google.com during bookmark import flow
35+
*/
36+
interface TakeoutWebMessageParser {
37+
38+
/**
39+
* Parses a JSON web message from takeout.google.com
40+
* @param jsonMessage The raw JSON message string
41+
* @return TakeoutMessageResult containing the parsed action data
42+
*/
43+
suspend fun parseMessage(jsonMessage: String): TakeoutMessageResult
44+
}
45+
46+
@ContributesBinding(AppScope::class)
47+
class RealTakeoutWebMessageParser @Inject constructor(
48+
private val dispatchers: DispatcherProvider,
49+
private val moshi: Moshi,
50+
) : TakeoutWebMessageParser {
51+
52+
private val adapter by lazy {
53+
moshi.adapter(TakeoutWebMessage::class.java)
54+
}
55+
56+
override suspend fun parseMessage(jsonMessage: String): TakeoutMessageResult {
57+
return runCatching {
58+
withContext(dispatchers.io()) {
59+
val message = adapter.fromJson(jsonMessage) ?: return@withContext UnknownMessageFormat
60+
val resultData = message.data?.result
61+
if (resultData.isInvalid()) return@withContext UnknownMessageFormat
62+
63+
return@withContext if (resultData?.success != null && resultData.success.actionID != null) {
64+
TakeoutActionSuccess(actionID = resultData.success.actionID)
65+
} else {
66+
TakeoutActionError(actionID = resultData?.error?.actionID)
67+
}
68+
}
69+
}.getOrElse {
70+
logcat(WARN) { "Error parsing takeout web message: ${it.asLog()}" }
71+
UnknownMessageFormat
72+
}
73+
}
74+
75+
private fun RawResultData?.isInvalid(): Boolean {
76+
if (this?.success == null && this?.error == null) {
77+
logcat(WARN) { "Error parsing takeout web message: unknown format: $this" }
78+
return true
79+
}
80+
return false
81+
}
82+
}
83+
84+
/**
85+
* Public API models for takeout web message parsing results
86+
*/
87+
sealed interface TakeoutMessageResult {
88+
data class TakeoutActionSuccess(
89+
val actionID: String,
90+
) : TakeoutMessageResult
91+
92+
data class TakeoutActionError(
93+
val actionID: String?,
94+
) : TakeoutMessageResult
95+
96+
data object UnknownMessageFormat : TakeoutMessageResult
97+
}
98+
99+
/**
100+
* Internal JSON models for parsing web messages from takeout.google.com
101+
* All fields are nullable to gracefully handle structure changes
102+
*/
103+
private data class TakeoutWebMessage(
104+
@Json(name = "name") val name: String? = null,
105+
@Json(name = "data") val data: TakeoutMessageData? = null,
106+
)
107+
108+
private data class TakeoutMessageData(
109+
@Json(name = "result") val result: RawResultData? = null,
110+
)
111+
112+
private data class RawResultData(
113+
@Json(name = "success") val success: JsonActionSuccess? = null,
114+
@Json(name = "error") val error: JsonActionError? = null,
115+
)
116+
117+
private data class JsonActionSuccess(
118+
@Json(name = "actionID") val actionID: String? = null,
119+
)
120+
121+
private data class JsonActionError(
122+
@Json(name = "actionID") val actionID: String? = null,
123+
)

0 commit comments

Comments
 (0)