Skip to content

Commit 08a816c

Browse files
committed
Add web message listener for js call from bookmark import
1 parent f942c31 commit 08a816c

19 files changed

+406
-49
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
@@ -193,16 +195,12 @@ class ImportGoogleBookmarksWebFlowFragment :
193195
// Inject null to indicate no credentials available
194196
browserAutofill.injectCredentials(null)
195197
}
196-
is PromptUserToSelectFromStoredCredentials ->
197-
showCredentialChooserDialog(
198-
command.originalUrl,
199-
command.credentials,
200-
command.triggerType,
201-
)
202-
is ExitFlowWithSuccess -> {
203-
logcat { "Bookmark-import: ExitFlowWithSuccess received with count: ${command.importedCount}" }
204-
exitFlowAsSuccess(command.importedCount)
205-
}
198+
is PromptUserToSelectFromStoredCredentials -> showCredentialChooserDialog(
199+
command.originalUrl,
200+
command.credentials,
201+
command.triggerType,
202+
)
203+
is ExitFlowWithSuccess -> exitFlowAsSuccess(command.importedCount)
206204
is ExitFlowAsFailure -> exitFlowAsError(command.reason)
207205
is PromptUserToConfirmFlowCancellation -> askUserToConfirmCancellation()
208206
}
@@ -221,12 +219,11 @@ class ImportGoogleBookmarksWebFlowFragment :
221219
return@withContext
222220
}
223221

224-
val credentials =
225-
LoginCredentials(
226-
domain = url,
227-
username = username,
228-
password = password,
229-
)
222+
val credentials = LoginCredentials(
223+
domain = url,
224+
username = username,
225+
password = password,
226+
)
230227

231228
logcat { "Injecting re-authentication credentials" }
232229
browserAutofill.injectCredentials(credentials)
@@ -317,6 +314,11 @@ class ImportGoogleBookmarksWebFlowFragment :
317314
} else {
318315
logcat(WARN) { "Bookmark-import: Not able to inject bookmark import JavaScript" }
319316
}
317+
318+
WebViewCompat.addWebMessageListener(webView, "ddgBookmarkImport", setOf("*")) { _, message, sourceOrigin, _, _ ->
319+
val data = message.data ?: return@addWebMessageListener
320+
viewModel.onWebMessageReceived(data)
321+
}
320322
}
321323

322324
private fun initialiseToolbar() {
@@ -375,35 +377,32 @@ class ImportGoogleBookmarksWebFlowFragment :
375377
requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner, onBackPressedCallback)
376378
}
377379

378-
private fun exitFlowAsSuccess(bookmarkCount: Int) {
379-
logcat { "Bookmark-import: Reporting import success with bookmarkCount: $bookmarkCount" }
380+
private fun exitFlowAsSuccess(importedCount: Int = 0) {
381+
logcat { "Bookmark-import: Reporting import success with bookmarkCount: $importedCount" }
380382
dismissCancellationDialog()
381-
val result =
382-
Bundle().apply {
383-
putParcelable(ImportGoogleBookmarkResult.RESULT_KEY_DETAILS, Success(bookmarkCount))
384-
}
383+
val result = Bundle().apply {
384+
putParcelable(ImportGoogleBookmarkResult.Companion.RESULT_KEY_DETAILS, Success(importedCount))
385+
}
385386
parentFragmentManager.setFragmentResult(ImportGoogleBookmarkResult.RESULT_KEY, result)
386387
}
387388

388389
private fun exitFlowAsCancellation(stage: String) {
389390
logcat { "Bookmark-import: Flow cancelled at stage: $stage" }
390391
dismissCancellationDialog()
391392

392-
val result =
393-
Bundle().apply {
394-
putParcelable(ImportGoogleBookmarkResult.Companion.RESULT_KEY_DETAILS, ImportGoogleBookmarkResult.UserCancelled(stage))
395-
}
393+
val result = Bundle().apply {
394+
putParcelable(ImportGoogleBookmarkResult.Companion.RESULT_KEY_DETAILS, UserCancelled(stage))
395+
}
396396
setFragmentResult(ImportGoogleBookmarkResult.Companion.RESULT_KEY, result)
397397
}
398398

399399
private fun exitFlowAsError(reason: UserCannotImportReason) {
400400
logcat { "Bookmark-import: Flow error at stage: ${reason.mapToStage()}" }
401401
dismissCancellationDialog()
402402

403-
val result =
404-
Bundle().apply {
405-
putParcelable(ImportGoogleBookmarkResult.Companion.RESULT_KEY_DETAILS, ImportGoogleBookmarkResult.Error(reason))
406-
}
403+
val result = Bundle().apply {
404+
putParcelable(ImportGoogleBookmarkResult.Companion.RESULT_KEY_DETAILS, Error(reason))
405+
}
407406
setFragmentResult(ImportGoogleBookmarkResult.Companion.RESULT_KEY, result)
408407
}
409408

@@ -419,13 +418,12 @@ class ImportGoogleBookmarksWebFlowFragment :
419418
return@withContext
420419
}
421420

422-
val dialog =
423-
credentialAutofillDialogFactory.autofillSelectCredentialsDialog(
424-
url,
425-
credentials,
426-
triggerType,
427-
CUSTOM_FLOW_TAB_ID,
428-
)
421+
val dialog = credentialAutofillDialogFactory.autofillSelectCredentialsDialog(
422+
url,
423+
credentials,
424+
triggerType,
425+
CUSTOM_FLOW_TAB_ID,
426+
)
429427
dialog.show(childFragmentManager, SELECT_CREDENTIALS_FRAGMENT_TAG)
430428
}
431429
}

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

Lines changed: 62 additions & 4 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

@@ -223,13 +233,61 @@ class ImportGoogleBookmarksWebFlowViewModel @Inject constructor(
223233
fun onPageStarted(url: String?) {
224234
val host = url?.toUri()?.host ?: return
225235
_viewState.value = if (host.contains(TAKEOUT_ADDRESS, ignoreCase = true)) {
236+
updateLatestStepSpecificStage(GOOGLE_TAKEOUT_PAGE_REACHED)
226237
HideWebPage
238+
} else if (host.contains("accounts.google.com", ignoreCase = true)) {
239+
updateLatestStepLoginPage()
240+
ShowWebPage
227241
} else {
228242
ShowWebPage
229243
}
230244
}
231245

232-
private companion object {
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+
}
282+
}
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"
233291
private const val TAKEOUT_ADDRESS = "takeout.google.com"
234292
}
235293

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: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -27,14 +27,14 @@ class ImportGoogleBookmarksWebFlowViewModelTest {
2727

2828
private val mockBookmarkImportProcessor: BookmarkImportProcessor = mock()
2929

30-
private val testee =
31-
ImportGoogleBookmarksWebFlowViewModel(
32-
dispatchers = coroutineTestRule.testDispatcherProvider,
33-
reauthenticationHandler = mock(),
34-
autofillFeature = mock(),
35-
bookmarkImportProcessor = mockBookmarkImportProcessor,
36-
bookmarkImportConfigStore = mock(),
37-
)
30+
private val testee = ImportGoogleBookmarksWebFlowViewModel(
31+
dispatchers = coroutineTestRule.testDispatcherProvider,
32+
reauthenticationHandler = mock(),
33+
autofillFeature = mock(),
34+
bookmarkImportProcessor = mockBookmarkImportProcessor,
35+
bookmarkImportConfigStore = mock(),
36+
takeoutWebMessageParser = mock(),
37+
)
3838

3939
@Test
4040
fun whenOnPageStartedWithTakeoutUrlThenHideWebPage() =

0 commit comments

Comments
 (0)