Skip to content

Commit ee4b18d

Browse files
committed
Add web message listener for js call from bookmark import
1 parent 2e9a36c commit ee4b18d

21 files changed

+384
-9
lines changed

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

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,9 @@ import com.duckduckgo.autofill.impl.importing.gpm.webflow.autofill.NoOpAutofillE
4949
import com.duckduckgo.autofill.impl.importing.gpm.webflow.autofill.NoOpEmailProtectionInContextSignupFlowListener
5050
import com.duckduckgo.autofill.impl.importing.gpm.webflow.autofill.NoOpEmailProtectionUserPromptListener
5151
import com.duckduckgo.autofill.impl.importing.takeout.store.BookmarkImportConfigStore
52+
import com.duckduckgo.autofill.impl.importing.takeout.webflow.ImportGoogleBookmarkResult.Error
53+
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
@@ -331,6 +334,11 @@ class ImportGoogleBookmarksWebFlowFragment :
331334
} else {
332335
logcat(WARN) { "Bookmark-import: Not able to inject bookmark import JavaScript" }
333336
}
337+
338+
WebViewCompat.addWebMessageListener(webView, "ddgBookmarkImport", setOf("*")) { _, message, sourceOrigin, _, _ ->
339+
val data = message.data ?: return@addWebMessageListener
340+
viewModel.onWebMessageReceived(data)
341+
}
334342
}
335343

336344
private fun initialiseToolbar() {
@@ -372,22 +380,25 @@ class ImportGoogleBookmarksWebFlowFragment :
372380
}
373381

374382
private fun exitFlowAsSuccess(importedCount: Int = 0) {
383+
logcat { "cdr exiting flow as success: $importedCount bookmarks imported" }
375384
val result = Bundle().apply {
376-
putParcelable(ImportGoogleBookmarkResult.Companion.RESULT_KEY_DETAILS, ImportGoogleBookmarkResult.Success(importedCount))
385+
putParcelable(ImportGoogleBookmarkResult.Companion.RESULT_KEY_DETAILS, Success(importedCount))
377386
}
378387
setFragmentResult(ImportGoogleBookmarkResult.Companion.RESULT_KEY, result)
379388
}
380389

381390
private fun exitFlowAsCancellation(stage: String) {
391+
logcat { "cdr exiting flow as cancellation at stage: $stage" }
382392
val result = Bundle().apply {
383-
putParcelable(ImportGoogleBookmarkResult.Companion.RESULT_KEY_DETAILS, ImportGoogleBookmarkResult.UserCancelled(stage))
393+
putParcelable(ImportGoogleBookmarkResult.Companion.RESULT_KEY_DETAILS, UserCancelled(stage))
384394
}
385395
setFragmentResult(ImportGoogleBookmarkResult.Companion.RESULT_KEY, result)
386396
}
387397

388398
private fun exitFlowAsError(reason: UserCannotImportReason) {
399+
logcat { "cdr exiting flow as error: $reason" }
389400
val result = Bundle().apply {
390-
putParcelable(ImportGoogleBookmarkResult.Companion.RESULT_KEY_DETAILS, ImportGoogleBookmarkResult.Error(reason))
401+
putParcelable(ImportGoogleBookmarkResult.Companion.RESULT_KEY_DETAILS, Error(reason))
391402
}
392403
setFragmentResult(ImportGoogleBookmarkResult.Companion.RESULT_KEY, result)
393404
}

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

Lines changed: 61 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ import com.duckduckgo.autofill.impl.importing.takeout.processor.TakeoutBookmarkI
2727
import com.duckduckgo.autofill.impl.importing.takeout.store.BookmarkImportConfigStore
2828
import com.duckduckgo.autofill.impl.importing.takeout.webflow.ImportGoogleBookmarksWebFlowViewModel.ViewState.HideWebPage
2929
import com.duckduckgo.autofill.impl.importing.takeout.webflow.ImportGoogleBookmarksWebFlowViewModel.ViewState.ShowWebPage
30+
import com.duckduckgo.autofill.impl.importing.takeout.webflow.TakeoutMessageResult.TakeoutActionError
31+
import com.duckduckgo.autofill.impl.importing.takeout.webflow.TakeoutMessageResult.TakeoutActionSuccess
32+
import com.duckduckgo.autofill.impl.importing.takeout.webflow.TakeoutMessageResult.UnknownMessageFormat
3033
import com.duckduckgo.autofill.impl.importing.takeout.zip.TakeoutBookmarkExtractor
3134
import com.duckduckgo.autofill.impl.importing.takeout.zip.TakeoutBookmarkExtractor.ExtractionResult
3235
import com.duckduckgo.autofill.impl.importing.takeout.zip.TakeoutZipDownloader
@@ -55,6 +58,7 @@ class ImportGoogleBookmarksWebFlowViewModel @Inject constructor(
5558
private val takeoutBookmarkImporter: TakeoutBookmarkImporter,
5659
private val takeoutZipDownloader: TakeoutZipDownloader,
5760
private val bookmarkImportConfigStore: BookmarkImportConfigStore,
61+
private val takeoutWebMessageParser: TakeoutWebMessageParser,
5862
) : ViewModel() {
5963

6064
private val _viewState = MutableStateFlow<ViewState>(ViewState.Initializing)
@@ -63,6 +67,8 @@ class ImportGoogleBookmarksWebFlowViewModel @Inject constructor(
6367
private val _commands = MutableSharedFlow<Command>(replay = 0, extraBufferCapacity = 1)
6468
val commands: SharedFlow<Command> = _commands
6569

70+
private var latestStepInWebFlow: String = STEP_UNINITIALIZED
71+
6672
suspend fun loadInitialWebpage() {
6773
withContext(dispatchers.io()) {
6874
val initialUrl = bookmarkImportConfigStore.getConfig().launchUrlGoogleTakeout
@@ -162,22 +168,22 @@ class ImportGoogleBookmarksWebFlowViewModel @Inject constructor(
162168
}
163169

164170
fun onCloseButtonPressed() {
165-
terminateFlowAsCancellation()
171+
terminateFlowAsCancellation(latestStepInWebFlow)
166172
}
167173

168174
fun onBackButtonPressed(canGoBack: Boolean = false) {
169175
// 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.
170176
if (!canGoBack) {
171-
terminateFlowAsCancellation()
177+
terminateFlowAsCancellation(latestStepInWebFlow)
172178
return
173179
}
174180

175181
_viewState.value = ViewState.NavigatingBack
176182
}
177183

178-
private fun terminateFlowAsCancellation() {
184+
private fun terminateFlowAsCancellation(stage: String) {
179185
viewModelScope.launch {
180-
_viewState.value = ViewState.UserCancelledImportFlow(stage = "unknown")
186+
_viewState.value = ViewState.UserCancelledImportFlow(stage)
181187
}
182188
}
183189

@@ -252,12 +258,63 @@ class ImportGoogleBookmarksWebFlowViewModel @Inject constructor(
252258
fun onPageStarted(url: String?) {
253259
val host = url?.toUri()?.host ?: return
254260
_viewState.value = if (host.contains("takeout.google.com", ignoreCase = true)) {
261+
updateLatestStepSpecificStage(GOOGLE_TAKEOUT_PAGE_REACHED)
255262
HideWebPage
263+
} else if (host.contains("accounts.google.com", ignoreCase = true)) {
264+
updateLatestStepLoginPage()
265+
ShowWebPage
256266
} else {
257267
ShowWebPage
258268
}
259269
}
260270

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