Skip to content

Commit 8393d44

Browse files
committed
Add webflow for importing bookmarks from Google
1 parent f8afe22 commit 8393d44

File tree

34 files changed

+7980
-513
lines changed

34 files changed

+7980
-513
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: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -17,29 +17,41 @@
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 java.io.BufferedReader
2425
import javax.inject.Inject
2526
import kotlinx.coroutines.withContext
2627

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

3133
@ContributesBinding(FragmentScope::class)
32-
class PasswordImporterCssScriptLoader @Inject constructor(
34+
class GoogleImporterScriptLoaderImpl @Inject constructor(
3335
private val dispatchers: DispatcherProvider,
34-
private val configStore: AutofillImportPasswordConfigStore,
35-
) : PasswordImporterScriptLoader {
36+
private val passwordConfigStore: AutofillImportPasswordConfigStore,
37+
private val bookmarkConfigStore: BookmarkImportConfigStore,
38+
) : GoogleImporterScriptLoader {
3639

3740
private lateinit var contentScopeJS: String
3841

39-
override suspend fun getScript(): String {
42+
override suspend fun getScriptForPasswordImport(): String {
4043
return withContext(dispatchers.io()) {
4144
getContentScopeJS()
42-
.replace(CONTENT_SCOPE_PLACEHOLDER, getContentScopeJson(loadSettingsJson()))
45+
.replace(CONTENT_SCOPE_PLACEHOLDER, getContentScopeScriptJson(loadSettingsJsonPassword()))
46+
.replace(USER_UNPROTECTED_DOMAINS_PLACEHOLDER, getUnprotectedDomainsJson())
47+
.replace(USER_PREFERENCES_PLACEHOLDER, getUserPreferencesJson())
48+
}
49+
}
50+
51+
override suspend fun getScriptForBookmarkImport(): String {
52+
return withContext(dispatchers.io()) {
53+
getContentScopeJS()
54+
.replace(CONTENT_SCOPE_PLACEHOLDER, getContentScopeScriptJson(loadSettingsJsonBookmark()))
4355
.replace(USER_UNPROTECTED_DOMAINS_PLACEHOLDER, getUnprotectedDomainsJson())
4456
.replace(USER_PREFERENCES_PLACEHOLDER, getUserPreferencesJson())
4557
}
@@ -49,10 +61,10 @@ class PasswordImporterCssScriptLoader @Inject constructor(
4961
* This enables the password import hints feature in C-S-S.
5062
* These settings are for enabling it; the check for whether it should be enabled or not is done elsewhere.
5163
*/
52-
private fun getContentScopeJson(settingsJson: String): String {
64+
private fun getContentScopeScriptJson(settingsJson: String): String {
5365
return """{
5466
"features":{
55-
"autofillPasswordImport" : {
67+
"autofillImport" : {
5668
"state": "enabled",
5769
"exceptions": [],
5870
"settings": $settingsJson
@@ -64,8 +76,12 @@ class PasswordImporterCssScriptLoader @Inject constructor(
6476
""".trimMargin()
6577
}
6678

67-
private suspend fun loadSettingsJson(): String {
68-
return configStore.getConfig().javascriptConfigGooglePasswords
79+
private suspend fun loadSettingsJsonPassword(): String {
80+
return passwordConfigStore.getConfig().javascriptConfigGooglePasswords
81+
}
82+
83+
private suspend fun loadSettingsJsonBookmark(): String {
84+
return bookmarkConfigStore.getConfig().javascriptConfigGoogleTakeout
6985
}
7086

7187
private fun getUserPreferencesJson(): String {
@@ -84,7 +100,7 @@ class PasswordImporterCssScriptLoader @Inject constructor(
84100

85101
private fun getContentScopeJS(): String {
86102
if (!this::contentScopeJS.isInitialized) {
87-
contentScopeJS = loadJs("autofillPasswordImport.js")
103+
contentScopeJS = loadJs("autofillImport.js")
88104
}
89105
return contentScopeJS
90106
}

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
@@ -99,9 +98,6 @@ class ImportGooglePasswordsWebFlowFragment :
9998
@Inject
10099
lateinit var viewModelFactory: FragmentViewModelFactory
101100

102-
@Inject
103-
lateinit var autofillCapabilityChecker: AutofillCapabilityChecker
104-
105101
@Inject
106102
lateinit var credentialAutofillDialogFactory: CredentialAutofillDialogFactory
107103

@@ -115,7 +111,7 @@ class ImportGooglePasswordsWebFlowFragment :
115111
lateinit var passwordBlobConsumer: GooglePasswordBlobConsumer
116112

117113
@Inject
118-
lateinit var passwordImporterScriptLoader: PasswordImporterScriptLoader
114+
lateinit var googleImporterScriptLoader: GoogleImporterScriptLoader
119115

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

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

Lines changed: 15 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,12 @@ import kotlinx.coroutines.withContext
3434
interface TakeoutBookmarkImporter {
3535

3636
/**
37-
* Imports bookmarks from HTML content to the specified destination.
38-
* @param htmlContent The HTML bookmark content to import (in Netscape format)
37+
* Imports bookmarks from a temporary HTML file to the specified destination. The file will be deleted after import.
38+
* @param tempFileUri URI of the temporary HTML file containing bookmark content (in Netscape format)
3939
* @param destination Where to import the bookmarks (Root or named Folder within bookmarks root)
4040
* @return ImportSavedSitesResult indicating success with imported items or error
4141
*/
42-
suspend fun importBookmarks(htmlContent: String, destination: ImportFolder): ImportSavedSitesResult
42+
suspend fun importBookmarks(tempFileUri: Uri, destination: ImportFolder): ImportSavedSitesResult
4343
}
4444

4545
@ContributesBinding(AppScope::class)
@@ -48,25 +48,22 @@ class RealTakeoutBookmarkImporter @Inject constructor(
4848
private val dispatchers: DispatcherProvider,
4949
) : TakeoutBookmarkImporter {
5050

51-
override suspend fun importBookmarks(htmlContent: String, destination: ImportFolder): ImportSavedSitesResult {
51+
override suspend fun importBookmarks(tempFileUri: Uri, destination: ImportFolder): ImportSavedSitesResult {
5252
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")
6153
try {
62-
tempFile.writeText(htmlContent)
63-
return savedSitesImporter.import(Uri.fromFile(tempFile), destination)
54+
savedSitesImporter.import(tempFileUri, destination)
55+
} catch (exception: Exception) {
56+
ImportSavedSitesResult.Error(exception)
6457
} finally {
65-
// delete the temp file after import
66-
tempFile.takeIf { it.exists() }?.delete()
58+
cleanupTempFile(tempFileUri)
6759
}
68-
} catch (exception: Exception) {
69-
ImportSavedSitesResult.Error(exception)
60+
}
61+
}
62+
63+
private fun cleanupTempFile(tempFileUri: Uri) {
64+
runCatching {
65+
val filePath = tempFileUri.path ?: return
66+
File(filePath).delete()
7067
}
7168
}
7269
}

0 commit comments

Comments
 (0)