Skip to content

Commit 51594c0

Browse files
committed
Add web message listener for js call from bookmark import
1 parent a14e381 commit 51594c0

19 files changed

+389
-10
lines changed

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

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,12 +50,16 @@ import com.duckduckgo.autofill.impl.importing.gpm.webflow.autofill.NoOpAutofillE
5050
import com.duckduckgo.autofill.impl.importing.gpm.webflow.autofill.NoOpEmailProtectionInContextSignupFlowListener
5151
import com.duckduckgo.autofill.impl.importing.gpm.webflow.autofill.NoOpEmailProtectionUserPromptListener
5252
import com.duckduckgo.autofill.impl.importing.takeout.store.BookmarkImportConfigStore
53+
import com.duckduckgo.autofill.impl.importing.takeout.webflow.ImportGoogleBookmarkResult.Error
54+
import com.duckduckgo.autofill.impl.importing.takeout.webflow.ImportGoogleBookmarkResult.Success
55+
import com.duckduckgo.autofill.impl.importing.takeout.webflow.ImportGoogleBookmarkResult.UserCancelled
5356
import com.duckduckgo.autofill.impl.importing.takeout.webflow.ImportGoogleBookmarksWebFlowViewModel.Command.ExitFlowAsFailure
5457
import com.duckduckgo.autofill.impl.importing.takeout.webflow.ImportGoogleBookmarksWebFlowViewModel.Command.ExitFlowWithSuccess
5558
import com.duckduckgo.autofill.impl.importing.takeout.webflow.ImportGoogleBookmarksWebFlowViewModel.Command.InjectCredentialsFromReauth
5659
import com.duckduckgo.autofill.impl.importing.takeout.webflow.ImportGoogleBookmarksWebFlowViewModel.Command.NoCredentialsAvailable
5760
import com.duckduckgo.autofill.impl.importing.takeout.webflow.ImportGoogleBookmarksWebFlowViewModel.Command.ProcessDownloadedZipData
5861
import com.duckduckgo.autofill.impl.importing.takeout.webflow.ImportGoogleBookmarksWebFlowViewModel.Command.PromptUserToSelectFromStoredCredentials
62+
import com.duckduckgo.autofill.impl.importing.takeout.webflow.ImportGoogleBookmarksWebFlowViewModel.UserCannotImportReason
5963
import com.duckduckgo.autofill.impl.importing.takeout.webflow.ImportGoogleBookmarksWebFlowViewModel.ViewState.HideWebPage
6064
import com.duckduckgo.autofill.impl.importing.takeout.webflow.ImportGoogleBookmarksWebFlowViewModel.ViewState.Initializing
6165
import com.duckduckgo.autofill.impl.importing.takeout.webflow.ImportGoogleBookmarksWebFlowViewModel.ViewState.LoadingWebPage
@@ -334,6 +338,11 @@ class ImportGoogleBookmarksWebFlowFragment :
334338
} else {
335339
logcat(WARN) { "Bookmark-import: Not able to inject bookmark import JavaScript" }
336340
}
341+
342+
WebViewCompat.addWebMessageListener(webView, "ddgBookmarkImport", setOf("*")) { _, message, sourceOrigin, _, _ ->
343+
val data = message.data ?: return@addWebMessageListener
344+
viewModel.onWebMessageReceived(data)
345+
}
337346
}
338347

339348
private fun initialiseToolbar() {
@@ -375,22 +384,25 @@ class ImportGoogleBookmarksWebFlowFragment :
375384
}
376385

377386
private fun exitFlowAsSuccess(importedCount: Int = 0) {
387+
logcat { "cdr exiting flow as success: $importedCount bookmarks imported" }
378388
val result = Bundle().apply {
379-
putParcelable(ImportGoogleBookmarkResult.Companion.RESULT_KEY_DETAILS, ImportGoogleBookmarkResult.Success(importedCount))
389+
putParcelable(ImportGoogleBookmarkResult.Companion.RESULT_KEY_DETAILS, Success(importedCount))
380390
}
381391
setFragmentResult(ImportGoogleBookmarkResult.Companion.RESULT_KEY, result)
382392
}
383393

384394
private fun exitFlowAsCancellation(stage: String) {
395+
logcat { "cdr exiting flow as cancellation at stage: $stage" }
385396
val result = Bundle().apply {
386-
putParcelable(ImportGoogleBookmarkResult.Companion.RESULT_KEY_DETAILS, ImportGoogleBookmarkResult.UserCancelled(stage))
397+
putParcelable(ImportGoogleBookmarkResult.Companion.RESULT_KEY_DETAILS, UserCancelled(stage))
387398
}
388399
setFragmentResult(ImportGoogleBookmarkResult.Companion.RESULT_KEY, result)
389400
}
390401

391-
private fun exitFlowAsError(reason: ImportGoogleBookmarksWebFlowViewModel.UserCannotImportReason) {
402+
private fun exitFlowAsError(reason: UserCannotImportReason) {
403+
logcat { "cdr exiting flow as error: $reason" }
392404
val result = Bundle().apply {
393-
putParcelable(ImportGoogleBookmarkResult.Companion.RESULT_KEY_DETAILS, ImportGoogleBookmarkResult.Error(reason))
405+
putParcelable(ImportGoogleBookmarkResult.Companion.RESULT_KEY_DETAILS, Error(reason))
394406
}
395407
setFragmentResult(ImportGoogleBookmarkResult.Companion.RESULT_KEY, result)
396408
}

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

Lines changed: 70 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ import com.duckduckgo.autofill.impl.importing.takeout.processor.TakeoutBookmarkI
2828
import com.duckduckgo.autofill.impl.importing.takeout.store.BookmarkImportConfigStore
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.importing.takeout.zip.TakeoutBookmarkExtractor
3235
import com.duckduckgo.autofill.impl.importing.takeout.zip.TakeoutBookmarkExtractor.ExtractionResult
3336
import com.duckduckgo.autofill.impl.importing.takeout.zip.TakeoutZipDownloader
@@ -57,6 +60,7 @@ class ImportGoogleBookmarksWebFlowViewModel @Inject constructor(
5760
private val takeoutBookmarkImporter: TakeoutBookmarkImporter,
5861
private val takeoutZipDownloader: TakeoutZipDownloader,
5962
private val bookmarkImportConfigStore: BookmarkImportConfigStore,
63+
private val takeoutWebMessageParser: TakeoutWebMessageParser,
6064
) : ViewModel() {
6165

6266
private val _viewState = MutableStateFlow<ViewState>(ViewState.Initializing)
@@ -65,6 +69,8 @@ class ImportGoogleBookmarksWebFlowViewModel @Inject constructor(
6569
private val _commands = MutableSharedFlow<Command>(replay = 0, extraBufferCapacity = 1)
6670
val commands: SharedFlow<Command> = _commands
6771

72+
private var latestStepInWebFlow: String = STEP_UNINITIALIZED
73+
6874
suspend fun loadInitialWebpage() {
6975
withContext(dispatchers.io()) {
7076
val initialUrl = bookmarkImportConfigStore.getConfig().launchUrlGoogleTakeout
@@ -87,6 +93,7 @@ class ImportGoogleBookmarksWebFlowViewModel @Inject constructor(
8793
url.contains(".zip") ||
8894
url.contains("takeout.google.com") ||
8995
contentDisposition?.contains("attachment") == true -> true
96+
9097
else -> false
9198
}
9299

@@ -125,7 +132,10 @@ class ImportGoogleBookmarksWebFlowViewModel @Inject constructor(
125132
}
126133
}
127134

128-
private suspend fun onBookmarksExtracted(extractionResult: ExtractionResult, folderName: String) {
135+
private suspend fun onBookmarksExtracted(
136+
extractionResult: ExtractionResult,
137+
folderName: String,
138+
) {
129139
when (extractionResult) {
130140
is ExtractionResult.Success -> {
131141
val importResult = takeoutBookmarkImporter.importBookmarks(
@@ -142,7 +152,10 @@ class ImportGoogleBookmarksWebFlowViewModel @Inject constructor(
142152
}
143153
}
144154

145-
private suspend fun onBookmarksImported(importResult: ImportSavedSitesResult, folderName: String) {
155+
private suspend fun onBookmarksImported(
156+
importResult: ImportSavedSitesResult,
157+
folderName: String,
158+
) {
146159
when (importResult) {
147160
is ImportSavedSitesResult.Success -> {
148161
val importedCount = importResult.savedSites.size
@@ -158,7 +171,7 @@ class ImportGoogleBookmarksWebFlowViewModel @Inject constructor(
158171
}
159172

160173
fun onCloseButtonPressed(url: String?) {
161-
terminateFlowAsCancellation(url ?: "unknown")
174+
terminateFlowAsCancellation(latestStepInWebFlow)
162175
}
163176

164177
fun onBackButtonPressed(
@@ -167,16 +180,16 @@ class ImportGoogleBookmarksWebFlowViewModel @Inject constructor(
167180
) {
168181
// if WebView can't go back, then we're at the first stage or something's gone wrong. Either way, time to cancel out of the screen.
169182
if (!canGoBack) {
170-
terminateFlowAsCancellation(url ?: "unknown")
183+
terminateFlowAsCancellation(latestStepInWebFlow)
171184
return
172185
}
173186

174187
_viewState.value = ViewState.NavigatingBack
175188
}
176189

177-
private fun terminateFlowAsCancellation(url: String) {
190+
private fun terminateFlowAsCancellation(stage: String) {
178191
viewModelScope.launch {
179-
_viewState.value = ViewState.UserCancelledImportFlow(stage = "unknown")
192+
_viewState.value = ViewState.UserCancelledImportFlow(stage)
180193
}
181194
}
182195

@@ -251,12 +264,63 @@ class ImportGoogleBookmarksWebFlowViewModel @Inject constructor(
251264
fun onPageStarted(url: String?) {
252265
val host = url?.toUri()?.host ?: return
253266
_viewState.value = if (host.contains("takeout.google.com", ignoreCase = true)) {
267+
updateLatestStepSpecificStage(GOOGLE_TAKEOUT_PAGE_REACHED)
254268
HideWebPage
269+
} else if (host.contains("accounts.google.com", ignoreCase = true)) {
270+
updateLatestStepLoginPage()
271+
ShowWebPage
255272
} else {
256273
ShowWebPage
257274
}
258275
}
259276

277+
private fun updateLatestStepLoginPage() {
278+
// if we already have the login page as the current step, do nothing
279+
if (latestStepInWebFlow == GOOGLE_ACCOUNTS_PAGE_FIRST || latestStepInWebFlow == GOOGLE_ACCOUNTS_REPEATED) {
280+
return
281+
}
282+
283+
// if uninitialized, this is the first time seeing the login page
284+
if (latestStepInWebFlow == STEP_UNINITIALIZED) {
285+
updateLatestStepSpecificStage(GOOGLE_ACCOUNTS_PAGE_FIRST)
286+
return
287+
}
288+
289+
// this must be a repeated visit to the login page
290+
updateLatestStepSpecificStage(GOOGLE_ACCOUNTS_REPEATED)
291+
}
292+
293+
private fun updateLatestStepSpecificStage(step: String) {
294+
latestStepInWebFlow = step
295+
logcat { "cdr latest step is: $step" }
296+
}
297+
298+
fun onWebMessageReceived(data: String) {
299+
viewModelScope.launch {
300+
when (val result = takeoutWebMessageParser.parseMessage(data)) {
301+
is TakeoutActionSuccess -> {
302+
logcat { "cdr successfully parsed message: $result" }
303+
updateLatestStepSpecificStage(result.actionID)
304+
}
305+
306+
is TakeoutActionError -> {
307+
logcat { "cdr experienced an error in the step: $result" }
308+
}
309+
310+
UnknownMessageFormat -> {
311+
logcat(WARN) { "cdr failed to parse message, unknown format: $data" }
312+
}
313+
}
314+
}
315+
}
316+
317+
companion object {
318+
private const val GOOGLE_TAKEOUT_PAGE_REACHED = "takeout"
319+
private const val GOOGLE_ACCOUNTS_PAGE_FIRST = "login-first"
320+
private const val GOOGLE_ACCOUNTS_REPEATED = "login-repeat"
321+
private const val STEP_UNINITIALIZED = "uninitialized"
322+
}
323+
260324
sealed interface Command {
261325
data class InjectCredentialsFromReauth(val url: String? = null, val username: String = "", val password: String?) : Command
262326
data class PromptUserToSelectFromStoredCredentials(
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
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+
logcat { "cdr TakeoutWebMessageParser: parsing message: $jsonMessage" }
58+
return runCatching {
59+
withContext(dispatchers.io()) {
60+
val message = adapter.fromJson(jsonMessage) ?: return@withContext UnknownMessageFormat
61+
val resultData = message.data?.result
62+
if (resultData.isInvalid()) return@withContext UnknownMessageFormat
63+
64+
return@withContext if (resultData?.success != null && resultData.success.actionID != null) {
65+
TakeoutActionSuccess(actionID = resultData.success.actionID)
66+
} else {
67+
TakeoutActionError(actionID = resultData?.error?.actionID)
68+
}
69+
}
70+
}.getOrElse {
71+
logcat(WARN) { "Error parsing takeout web message: ${it.asLog()}" }
72+
UnknownMessageFormat
73+
}
74+
}
75+
76+
private fun RawResultData?.isInvalid(): Boolean {
77+
if (this?.success == null && this?.error == null) {
78+
logcat(WARN) { "Error parsing takeout web message: unknown format: $this" }
79+
return true
80+
}
81+
return false
82+
}
83+
}
84+
85+
/**
86+
* Public API models for takeout web message parsing results
87+
*/
88+
sealed interface TakeoutMessageResult {
89+
data class TakeoutActionSuccess(
90+
val actionID: String,
91+
) : TakeoutMessageResult
92+
93+
data class TakeoutActionError(
94+
val actionID: String?,
95+
) : TakeoutMessageResult
96+
97+
data object UnknownMessageFormat : TakeoutMessageResult
98+
}
99+
100+
/**
101+
* Internal JSON models for parsing web messages from takeout.google.com
102+
* All fields are nullable to gracefully handle structure changes
103+
*/
104+
private data class TakeoutWebMessage(
105+
@Json(name = "name") val name: String? = null,
106+
@Json(name = "data") val data: TakeoutMessageData? = null,
107+
)
108+
109+
private data class TakeoutMessageData(
110+
@Json(name = "result") val result: RawResultData? = null,
111+
)
112+
113+
private data class RawResultData(
114+
@Json(name = "success") val success: JsonActionSuccess? = null,
115+
@Json(name = "error") val error: JsonActionError? = null,
116+
)
117+
118+
private data class JsonActionSuccess(
119+
@Json(name = "actionID") val actionID: String? = null,
120+
)
121+
122+
private data class JsonActionError(
123+
@Json(name = "actionID") val actionID: String? = null,
124+
)

autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/takeout/webflow/ImportGoogleBookmarksWebFlowViewModelTest.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ class ImportGoogleBookmarksWebFlowViewModelTest {
3737
takeoutBookmarkImporter = mock(),
3838
takeoutZipDownloader = mockTakeoutZipDownloader,
3939
bookmarkImportConfigStore = mock(),
40+
takeoutWebMessageParser = mock(),
4041
)
4142

4243
@Test

0 commit comments

Comments
 (0)