Skip to content

Commit 14be2ac

Browse files
authored
Add webflow for importing bookmarks from Google (#6707)
Task/Issue URL: https://app.asana.com/1/137249556945/project/608920331025315/task/1211197010246724?focus=true ### Description Adds the core webflow to support a guided bookmark import based on Google Takeout. - This PR is only about the flow itself - does not contain the final UI that will come before, during and after the import flow (future PR to add this) - does not contain timeouts / error handling if automation doesn't complete - does not make it available yet to production code, accessible through `Autofill Dev Settings` screen for `internal` builds to test - ℹ️ don't interact with the WebView while the automation is running; in a later PR it won't be possible to interact with it during this flow <img width="50%" alt="Screenshot 2025-09-24 at 16 02 57" src="https://github.com/user-attachments/assets/72f333e0-5b02-4297-9315-fd92890bd330" /> ### Steps to test this PR #### First time import - [x] Fresh install `internal` build type from this branch - [x] Open `Settings->Autofill Dev Settings` - [x] Tap on `Launch Bookmarks import flow` - [x] You won't be signed in yet, so sign in to your Google account when prompted - [x] After signing in, verify that automation takes care of the rest and you eventually leave the web flow and see the success snackbar. - [x] Tap on `View Bookmarks` and verify they imported under a `Imported from Chrome` folder #### Subsequent import (already signed in) - [x] Tap on `Launch Bookmarks import flow` again. This time, you're already signed so verify it jumps straight into the automation (and that it succeeds) - [x] Verify in `View Bookmarks` that there is only one `Imported From Chrome` folder (bookmarks will be duplicated within that folder (it's known we're matching existing bookmark importer behaviour within that folder, including not further de-duplicating so if you import _n_ times you'll see _n_ sets of copies of bookmarks in that one folder) #### Check autofill works - [x] Manually add your Google credentials to our password manager - [x] Tap on `Google Account Logout` (from the `import passwords` section) to logout of Google - [x] Tap on `Launch Bookmarks import flow` again. - [x] Verify you are prompted to autofill; accept - [x] Verify the rest of the flow is automated and you don't have to manually enter your password again --------- Co-authored-by: Craig Russell <[email protected]>
1 parent 21107e0 commit 14be2ac

27 files changed

+2570
-370
lines changed

autofill/autofill-impl/build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ dependencies {
4848
implementation project(path: ':settings-api') // temporary until we release new settings
4949
implementation project(':library-loader-api')
5050
implementation project(':saved-sites-api')
51+
implementation project(':cookies-api')
5152

5253
anvil project(path: ':anvil-compiler')
5354
implementation project(path: ':anvil-annotations')

autofill/autofill-impl/src/main/AndroidManifest.xml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@
2121
android:name=".importing.gpm.webflow.ImportGooglePasswordsWebFlowActivity"
2222
android:configChanges="keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout|navigation|keyboard"
2323
android:exported="false" />
24+
<activity
25+
android:name=".importing.takeout.webflow.ImportGoogleBookmarksWebFlowActivity"
26+
android:configChanges="keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout|navigation|keyboard"
27+
android:exported="false" />
2428
<activity
2529
android:name=".ui.credential.management.AutofillManagementActivity"
2630
android:configChanges="orientation|screenSize"
Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,28 +17,40 @@
1717
package com.duckduckgo.autofill.impl.importing.gpm.webflow
1818

1919
import com.duckduckgo.autofill.impl.importing.gpm.feature.AutofillImportPasswordConfigStore
20+
import com.duckduckgo.autofill.impl.importing.takeout.store.BookmarkImportConfigStore
2021
import com.duckduckgo.common.utils.DispatcherProvider
2122
import com.duckduckgo.di.scopes.FragmentScope
2223
import com.squareup.anvil.annotations.ContributesBinding
2324
import kotlinx.coroutines.withContext
2425
import java.io.BufferedReader
2526
import javax.inject.Inject
2627

27-
interface PasswordImporterScriptLoader {
28-
suspend fun getScript(): String
28+
interface GoogleImporterScriptLoader {
29+
suspend fun getScriptForPasswordImport(): String
30+
31+
suspend fun getScriptForBookmarkImport(): String
2932
}
3033

3134
@ContributesBinding(FragmentScope::class)
32-
class PasswordImporterCssScriptLoader @Inject constructor(
35+
class GoogleImporterScriptLoaderImpl @Inject constructor(
3336
private val dispatchers: DispatcherProvider,
34-
private val configStore: AutofillImportPasswordConfigStore,
35-
) : PasswordImporterScriptLoader {
37+
private val passwordConfigStore: AutofillImportPasswordConfigStore,
38+
private val bookmarkConfigStore: BookmarkImportConfigStore,
39+
) : GoogleImporterScriptLoader {
3640
private lateinit var contentScopeJS: String
3741

38-
override suspend fun getScript(): String =
42+
override suspend fun getScriptForPasswordImport(): String =
43+
withContext(dispatchers.io()) {
44+
getContentScopeJS()
45+
.replace(CONTENT_SCOPE_PLACEHOLDER, getContentScopeScriptJson(loadSettingsJsonPassword()))
46+
.replace(USER_UNPROTECTED_DOMAINS_PLACEHOLDER, getUnprotectedDomainsJson())
47+
.replace(USER_PREFERENCES_PLACEHOLDER, getUserPreferencesJson())
48+
}
49+
50+
override suspend fun getScriptForBookmarkImport(): String =
3951
withContext(dispatchers.io()) {
4052
getContentScopeJS()
41-
.replace(CONTENT_SCOPE_PLACEHOLDER, getContentScopeJson(loadSettingsJson()))
53+
.replace(CONTENT_SCOPE_PLACEHOLDER, getContentScopeScriptJson(loadSettingsJsonBookmark()))
4254
.replace(USER_UNPROTECTED_DOMAINS_PLACEHOLDER, getUnprotectedDomainsJson())
4355
.replace(USER_PREFERENCES_PLACEHOLDER, getUserPreferencesJson())
4456
}
@@ -47,7 +59,7 @@ class PasswordImporterCssScriptLoader @Inject constructor(
4759
* This enables the password import hints feature in C-S-S.
4860
* These settings are for enabling it; the check for whether it should be enabled or not is done elsewhere.
4961
*/
50-
private fun getContentScopeJson(settingsJson: String): String =
62+
private fun getContentScopeScriptJson(settingsJson: String): String =
5163
"""{
5264
"features":{
5365
"autofillImport" : {
@@ -61,7 +73,9 @@ class PasswordImporterCssScriptLoader @Inject constructor(
6173
6274
""".trimMargin()
6375

64-
private suspend fun loadSettingsJson(): String = configStore.getConfig().javascriptConfigGooglePasswords
76+
private suspend fun loadSettingsJsonPassword(): String = passwordConfigStore.getConfig().javascriptConfigGooglePasswords
77+
78+
private suspend fun loadSettingsJsonBookmark(): String = bookmarkConfigStore.getConfig().javascriptConfigGoogleTakeout
6579

6680
private fun getUserPreferencesJson(): String =
6781
"""

autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/gpm/webflow/ImportGooglePasswordsWebFlowFragment.kt

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@ import androidx.lifecycle.flowWithLifecycle
3333
import androidx.lifecycle.lifecycleScope
3434
import androidx.webkit.WebViewCompat
3535
import com.duckduckgo.anvil.annotations.InjectWith
36-
import com.duckduckgo.autofill.api.AutofillCapabilityChecker
3736
import com.duckduckgo.autofill.api.AutofillFragmentResultsPlugin
3837
import com.duckduckgo.autofill.api.BrowserAutofill
3938
import com.duckduckgo.autofill.api.CredentialAutofillDialogFactory
@@ -98,9 +97,6 @@ class ImportGooglePasswordsWebFlowFragment :
9897
@Inject
9998
lateinit var viewModelFactory: FragmentViewModelFactory
10099

101-
@Inject
102-
lateinit var autofillCapabilityChecker: AutofillCapabilityChecker
103-
104100
@Inject
105101
lateinit var credentialAutofillDialogFactory: CredentialAutofillDialogFactory
106102

@@ -114,7 +110,7 @@ class ImportGooglePasswordsWebFlowFragment :
114110
lateinit var passwordBlobConsumer: GooglePasswordBlobConsumer
115111

116112
@Inject
117-
lateinit var passwordImporterScriptLoader: PasswordImporterScriptLoader
113+
lateinit var googleImporterScriptLoader: GoogleImporterScriptLoader
118114

119115
@Inject
120116
lateinit var browserAutofillConfigurator: InternalBrowserAutofillConfigurator
@@ -343,7 +339,7 @@ class ImportGooglePasswordsWebFlowFragment :
343339
@SuppressLint("RequiresFeature", "AddDocumentStartJavaScriptUsage")
344340
private suspend fun configurePasswordImportJavascript(webView: WebView) {
345341
if (importPasswordConfig.getConfig().canInjectJavascript) {
346-
val script = passwordImporterScriptLoader.getScript()
342+
val script = googleImporterScriptLoader.getScriptForPasswordImport()
347343
WebViewCompat.addDocumentStartJavaScript(webView, script, setOf("*"))
348344
}
349345
}
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
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.processor
18+
19+
import android.net.Uri
20+
import com.duckduckgo.autofill.impl.importing.takeout.processor.BookmarkImportProcessor.*
21+
import com.duckduckgo.autofill.impl.importing.takeout.processor.BookmarkImportProcessor.ImportResult.Error
22+
import com.duckduckgo.autofill.impl.importing.takeout.processor.BookmarkImportProcessor.ImportResult.Error.ParseError
23+
import com.duckduckgo.autofill.impl.importing.takeout.zip.TakeoutBookmarkExtractor
24+
import com.duckduckgo.autofill.impl.importing.takeout.zip.TakeoutBookmarkExtractor.ExtractionResult
25+
import com.duckduckgo.autofill.impl.importing.takeout.zip.TakeoutZipDownloader
26+
import com.duckduckgo.common.utils.DispatcherProvider
27+
import com.duckduckgo.di.scopes.AppScope
28+
import com.duckduckgo.savedsites.api.service.ImportSavedSitesResult
29+
import com.duckduckgo.savedsites.api.service.SavedSitesImporter.ImportFolder
30+
import com.squareup.anvil.annotations.ContributesBinding
31+
import kotlinx.coroutines.withContext
32+
import logcat.LogPriority.WARN
33+
import logcat.asLog
34+
import logcat.logcat
35+
import java.io.File
36+
import javax.inject.Inject
37+
38+
interface BookmarkImportProcessor {
39+
sealed class ImportResult {
40+
data class Success(
41+
val importedCount: Int,
42+
) : ImportResult()
43+
44+
sealed class Error : ImportResult() {
45+
data object DownloadError : Error()
46+
47+
data object ParseError : Error()
48+
49+
data object ImportError : Error()
50+
}
51+
}
52+
53+
suspend fun downloadAndImportFromTakeoutZipUrl(
54+
url: String,
55+
userAgent: String,
56+
folderName: String,
57+
): ImportResult
58+
}
59+
60+
@ContributesBinding(AppScope::class)
61+
class TakeoutBookmarkImportProcessor @Inject constructor(
62+
private val dispatchers: DispatcherProvider,
63+
private val takeoutZipDownloader: TakeoutZipDownloader,
64+
private val bookmarkExtractor: TakeoutBookmarkExtractor,
65+
private val takeoutBookmarkImporter: TakeoutBookmarkImporter,
66+
) : BookmarkImportProcessor {
67+
override suspend fun downloadAndImportFromTakeoutZipUrl(
68+
url: String,
69+
userAgent: String,
70+
folderName: String,
71+
): ImportResult =
72+
withContext(dispatchers.io()) {
73+
runCatching {
74+
val zipUri = takeoutZipDownloader.downloadZip(url, userAgent)
75+
processBookmarkZip(zipUri, folderName)
76+
}.getOrElse { e ->
77+
logcat(WARN) { "Bookmark zip download failed: ${e.asLog()}" }
78+
Error.DownloadError
79+
}
80+
}
81+
82+
private suspend fun processBookmarkZip(
83+
zipUri: Uri,
84+
folderName: String,
85+
): ImportResult =
86+
runCatching {
87+
val extractionResult = bookmarkExtractor.extractBookmarksFromFile(zipUri)
88+
handleExtractionResult(extractionResult, folderName)
89+
}.getOrElse { e ->
90+
logcat(WARN) { "Error processing bookmark zip: ${e.asLog()}" }
91+
ParseError
92+
}.also {
93+
cleanupZipFile(zipUri)
94+
}
95+
96+
private suspend fun handleExtractionResult(
97+
extractionResult: ExtractionResult,
98+
folderName: String,
99+
): ImportResult =
100+
when (extractionResult) {
101+
is ExtractionResult.Success -> {
102+
val importResult =
103+
takeoutBookmarkImporter.importBookmarks(
104+
extractionResult.tempFileUri,
105+
ImportFolder.Folder(folderName),
106+
)
107+
handleImportResult(importResult)
108+
}
109+
110+
is ExtractionResult.Error -> {
111+
logcat(WARN) { "Error extracting bookmarks from zip" }
112+
ParseError
113+
}
114+
}
115+
116+
private fun handleImportResult(importResult: ImportSavedSitesResult): ImportResult =
117+
when (importResult) {
118+
is ImportSavedSitesResult.Success -> {
119+
val importedCount = importResult.savedSites.size
120+
logcat { "Successfully imported $importedCount bookmarks" }
121+
ImportResult.Success(importedCount)
122+
}
123+
124+
is ImportSavedSitesResult.Error -> {
125+
logcat(WARN) { "Error importing bookmarks: ${importResult.exception.message}" }
126+
Error.ImportError
127+
}
128+
}
129+
130+
private fun cleanupZipFile(zipUri: Uri) {
131+
runCatching {
132+
val zipFile = File(zipUri.path ?: return)
133+
if (zipFile.exists() && zipFile.delete()) {
134+
logcat { "Cleaned up downloaded zip file: ${zipFile.absolutePath}" }
135+
}
136+
}.onFailure { logcat(WARN) { "Failed to cleanup downloaded zip file: ${it.asLog()}" } }
137+
}
138+
}

autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/takeout/processor/TakeoutBookmarkImporter.kt

Lines changed: 22 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -23,50 +23,50 @@ import com.duckduckgo.savedsites.api.service.ImportSavedSitesResult
2323
import com.duckduckgo.savedsites.api.service.SavedSitesImporter
2424
import com.duckduckgo.savedsites.api.service.SavedSitesImporter.ImportFolder
2525
import com.squareup.anvil.annotations.ContributesBinding
26+
import kotlinx.coroutines.withContext
2627
import java.io.File
2728
import javax.inject.Inject
28-
import kotlinx.coroutines.withContext
2929

3030
/**
3131
* Interface for importing bookmarks with flexible destination handling.
3232
* Supports both root-level imports and folder-based imports while preserving structure.
3333
*/
3434
interface TakeoutBookmarkImporter {
35-
3635
/**
37-
* Imports bookmarks from HTML content to the specified destination.
38-
* @param htmlContent The HTML bookmark content to import (in Netscape format)
36+
* Imports bookmarks from a temporary HTML file to the specified destination. The file will be deleted after import.
37+
* @param tempFileUri URI of the temporary HTML file containing bookmark content (in Netscape format)
3938
* @param destination Where to import the bookmarks (Root or named Folder within bookmarks root)
4039
* @return ImportSavedSitesResult indicating success with imported items or error
4140
*/
42-
suspend fun importBookmarks(htmlContent: String, destination: ImportFolder): ImportSavedSitesResult
41+
suspend fun importBookmarks(
42+
tempFileUri: Uri,
43+
destination: ImportFolder,
44+
): ImportSavedSitesResult
4345
}
4446

4547
@ContributesBinding(AppScope::class)
4648
class RealTakeoutBookmarkImporter @Inject constructor(
4749
private val savedSitesImporter: SavedSitesImporter,
4850
private val dispatchers: DispatcherProvider,
4951
) : TakeoutBookmarkImporter {
50-
51-
override suspend fun importBookmarks(htmlContent: String, destination: ImportFolder): ImportSavedSitesResult {
52-
return withContext(dispatchers.io()) {
53-
import(htmlContent = htmlContent, destination = destination)
54-
}
55-
}
56-
57-
private suspend fun import(htmlContent: String, destination: ImportFolder = ImportFolder.Root): ImportSavedSitesResult {
58-
return try {
59-
// saved sites importer needs a file uri, so we create a temp file here
60-
val tempFile = File.createTempFile("bookmark_import_", ".html")
52+
override suspend fun importBookmarks(
53+
tempFileUri: Uri,
54+
destination: ImportFolder,
55+
): ImportSavedSitesResult =
56+
withContext(dispatchers.io()) {
6157
try {
62-
tempFile.writeText(htmlContent)
63-
return savedSitesImporter.import(Uri.fromFile(tempFile), destination)
58+
savedSitesImporter.import(tempFileUri, destination)
59+
} catch (exception: Exception) {
60+
ImportSavedSitesResult.Error(exception)
6461
} finally {
65-
// delete the temp file after import
66-
tempFile.takeIf { it.exists() }?.delete()
62+
cleanupTempFile(tempFileUri)
6763
}
68-
} catch (exception: Exception) {
69-
ImportSavedSitesResult.Error(exception)
64+
}
65+
66+
private fun cleanupTempFile(tempFileUri: Uri) {
67+
runCatching {
68+
val filePath = tempFileUri.path ?: return
69+
File(filePath).delete()
7070
}
7171
}
7272
}

0 commit comments

Comments
 (0)