Skip to content

Commit 8a51bdd

Browse files
committed
Add web message listener for js call from bookmark import
1 parent cb2b5cb commit 8a51bdd

19 files changed

+394
-19
lines changed

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

Lines changed: 18 additions & 6 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,13 +338,18 @@ 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() {
340349
with(getToolbar()) {
341350
title = getString(R.string.autofillManagementImportBookmarks)
342351
setNavigationIconAsCross()
343-
setNavigationOnClickListener { viewModel.onCloseButtonPressed(binding?.webView?.url) }
352+
setNavigationOnClickListener { viewModel.onCloseButtonPressed() }
344353
}
345354
}
346355

@@ -368,29 +377,32 @@ class ImportGoogleBookmarksWebFlowFragment :
368377
val onBackPressedCallback = object : OnBackPressedCallback(true) {
369378
override fun handleOnBackPressed() {
370379
val canGoBack = binding?.webView?.canGoBack() ?: false
371-
viewModel.onBackButtonPressed(binding?.webView?.url, canGoBack)
380+
viewModel.onBackButtonPressed(canGoBack)
372381
}
373382
}
374383
requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner, onBackPressedCallback)
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: 72 additions & 11 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
@@ -157,26 +170,23 @@ class ImportGoogleBookmarksWebFlowViewModel @Inject constructor(
157170
}
158171
}
159172

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

164-
fun onBackButtonPressed(
165-
url: String? = null,
166-
canGoBack: Boolean = false,
167-
) {
177+
fun onBackButtonPressed(canGoBack: Boolean = false) {
168178
// 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.
169179
if (!canGoBack) {
170-
terminateFlowAsCancellation(url ?: "unknown")
180+
terminateFlowAsCancellation(latestStepInWebFlow)
171181
return
172182
}
173183

174184
_viewState.value = ViewState.NavigatingBack
175185
}
176186

177-
private fun terminateFlowAsCancellation(url: String) {
187+
private fun terminateFlowAsCancellation(stage: String) {
178188
viewModelScope.launch {
179-
_viewState.value = ViewState.UserCancelledImportFlow(stage = "unknown")
189+
_viewState.value = ViewState.UserCancelledImportFlow(stage)
180190
}
181191
}
182192

@@ -251,12 +261,63 @@ class ImportGoogleBookmarksWebFlowViewModel @Inject constructor(
251261
fun onPageStarted(url: String?) {
252262
val host = url?.toUri()?.host ?: return
253263
_viewState.value = if (host.contains("takeout.google.com", ignoreCase = true)) {
264+
updateLatestStepSpecificStage(GOOGLE_TAKEOUT_PAGE_REACHED)
254265
HideWebPage
266+
} else if (host.contains("accounts.google.com", ignoreCase = true)) {
267+
updateLatestStepLoginPage()
268+
ShowWebPage
255269
} else {
256270
ShowWebPage
257271
}
258272
}
259273

274+
private fun updateLatestStepLoginPage() {
275+
// if we already have the login page as the current step, do nothing
276+
if (latestStepInWebFlow == GOOGLE_ACCOUNTS_PAGE_FIRST || latestStepInWebFlow == GOOGLE_ACCOUNTS_REPEATED) {
277+
return
278+
}
279+
280+
// if uninitialized, this is the first time seeing the login page
281+
if (latestStepInWebFlow == STEP_UNINITIALIZED) {
282+
updateLatestStepSpecificStage(GOOGLE_ACCOUNTS_PAGE_FIRST)
283+
return
284+
}
285+
286+
// this must be a repeated visit to the login page
287+
updateLatestStepSpecificStage(GOOGLE_ACCOUNTS_REPEATED)
288+
}
289+
290+
private fun updateLatestStepSpecificStage(step: String) {
291+
latestStepInWebFlow = step
292+
logcat { "cdr latest step is: $step" }
293+
}
294+
295+
fun onWebMessageReceived(data: String) {
296+
viewModelScope.launch {
297+
when (val result = takeoutWebMessageParser.parseMessage(data)) {
298+
is TakeoutActionSuccess -> {
299+
logcat { "cdr successfully parsed message: $result" }
300+
updateLatestStepSpecificStage(result.actionID)
301+
}
302+
303+
is TakeoutActionError -> {
304+
logcat { "cdr experienced an error in the step: $result" }
305+
}
306+
307+
UnknownMessageFormat -> {
308+
logcat(WARN) { "cdr failed to parse message, unknown format: $data" }
309+
}
310+
}
311+
}
312+
}
313+
314+
companion object {
315+
private const val GOOGLE_TAKEOUT_PAGE_REACHED = "takeout"
316+
private const val GOOGLE_ACCOUNTS_PAGE_FIRST = "login-first"
317+
private const val GOOGLE_ACCOUNTS_REPEATED = "login-repeat"
318+
private const val STEP_UNINITIALIZED = "uninitialized"
319+
}
320+
260321
sealed interface Command {
261322
data class InjectCredentialsFromReauth(val url: String? = null, val username: String = "", val password: String?) : Command
262323
data class PromptUserToSelectFromStoredCredentials(
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+
)

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

Lines changed: 3 additions & 2 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
@@ -91,13 +92,13 @@ class ImportGoogleBookmarksWebFlowViewModelTest {
9192

9293
@Test
9394
fun whenBackButtonPressedAndCannotGoBackThenUserCancelledState() = runTest {
94-
testee.onBackButtonPressed(url = "https://accounts.google.com", canGoBack = false)
95+
testee.onBackButtonPressed(canGoBack = false)
9596
assertTrue(testee.viewState.value is UserCancelledImportFlow)
9697
}
9798

9899
@Test
99100
fun whenBackButtonPressedAndCanGoBackThenNavigatingBackState() = runTest {
100-
testee.onBackButtonPressed(url = "https://accounts.google.com", canGoBack = true)
101+
testee.onBackButtonPressed(canGoBack = true)
101102
assertEquals(NavigatingBack, testee.viewState.value)
102103
}
103104

0 commit comments

Comments
 (0)