Skip to content

Commit 29e253d

Browse files
committed
Add ability to parse a Takeout zip file and extra bookmarks
1 parent eda2570 commit 29e253d

File tree

18 files changed

+666
-4
lines changed

18 files changed

+666
-4
lines changed

autofill/autofill-impl/build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ dependencies {
4747
testImplementation project(':feature-toggles-test')
4848
implementation project(path: ':settings-api') // temporary until we release new settings
4949
implementation project(':library-loader-api')
50+
implementation project(':saved-sites-api')
5051

5152
anvil project(path: ':anvil-compiler')
5253
implementation project(path: ':anvil-annotations')
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
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.common.utils.DispatcherProvider
21+
import com.duckduckgo.di.scopes.AppScope
22+
import com.duckduckgo.savedsites.api.service.ImportSavedSitesResult
23+
import com.duckduckgo.savedsites.api.service.SavedSitesImporter
24+
import com.duckduckgo.savedsites.api.service.SavedSitesImporter.ImportFolder
25+
import com.squareup.anvil.annotations.ContributesBinding
26+
import java.io.File
27+
import javax.inject.Inject
28+
import kotlinx.coroutines.withContext
29+
30+
/**
31+
* Interface for importing bookmarks with flexible destination handling.
32+
* Supports both root-level imports and folder-based imports while preserving structure.
33+
*/
34+
interface TakeoutBookmarkImporter {
35+
36+
/**
37+
* Imports bookmarks from HTML content to the specified destination.
38+
* @param htmlContent The HTML bookmark content to import (in Netscape format)
39+
* @param destination Where to import the bookmarks (Root or named Folder within bookmarks root)
40+
* @return ImportSavedSitesResult indicating success with imported items or error
41+
*/
42+
suspend fun importBookmarks(htmlContent: String, destination: ImportFolder): ImportSavedSitesResult
43+
}
44+
45+
@ContributesBinding(AppScope::class)
46+
class RealTakeoutBookmarkImporter @Inject constructor(
47+
private val savedSitesImporter: SavedSitesImporter,
48+
private val dispatchers: DispatcherProvider,
49+
) : 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")
61+
try {
62+
tempFile.writeText(htmlContent)
63+
return savedSitesImporter.import(Uri.fromFile(tempFile), destination)
64+
} finally {
65+
// delete the temp file after import
66+
tempFile.takeIf { it.exists() }?.delete()
67+
}
68+
} catch (exception: Exception) {
69+
ImportSavedSitesResult.Error(exception)
70+
}
71+
}
72+
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
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.zip
18+
19+
import android.content.Context
20+
import android.net.Uri
21+
import com.duckduckgo.autofill.impl.importing.takeout.zip.TakeoutBookmarkExtractor.ExtractionResult
22+
import com.duckduckgo.autofill.impl.importing.takeout.zip.ZipEntryContentReader.ReadResult
23+
import com.duckduckgo.common.utils.DispatcherProvider
24+
import com.duckduckgo.di.scopes.AppScope
25+
import com.squareup.anvil.annotations.ContributesBinding
26+
import java.util.zip.ZipEntry
27+
import java.util.zip.ZipInputStream
28+
import javax.inject.Inject
29+
import kotlinx.coroutines.withContext
30+
import logcat.LogPriority.WARN
31+
import logcat.logcat
32+
33+
interface TakeoutBookmarkExtractor {
34+
35+
sealed class ExtractionResult {
36+
data class Success(val bookmarkHtmlContent: String) : ExtractionResult() {
37+
override fun toString(): String {
38+
return "ExtractionResult=success"
39+
}
40+
}
41+
data class Error(val exception: Exception) : ExtractionResult()
42+
}
43+
44+
/**
45+
* Extracts the bookmark HTML content from the provided Google Takeout ZIP file URI.
46+
* @param fileUri The URI of the Google Takeout ZIP file containing the bookmarks.
47+
* @return ExtractionResult containing either the bookmark HTML content or an error.
48+
*/
49+
suspend fun extractBookmarksHtml(fileUri: Uri): ExtractionResult
50+
}
51+
52+
@ContributesBinding(AppScope::class)
53+
class TakeoutZipBookmarkExtractor @Inject constructor(
54+
private val context: Context,
55+
private val dispatchers: DispatcherProvider,
56+
private val zipEntryContentReader: ZipEntryContentReader,
57+
) : TakeoutBookmarkExtractor {
58+
59+
override suspend fun extractBookmarksHtml(fileUri: Uri): ExtractionResult {
60+
return withContext(dispatchers.io()) {
61+
runCatching {
62+
context.contentResolver.openInputStream(fileUri)?.use { inputStream ->
63+
ZipInputStream(inputStream).use { zipInputStream ->
64+
processZipEntries(zipInputStream)
65+
}
66+
} ?: ExtractionResult.Error(Exception("Unable to open file: $fileUri"))
67+
}.getOrElse { ExtractionResult.Error(Exception(it)) }
68+
}
69+
}
70+
71+
private fun processZipEntries(zipInputStream: ZipInputStream): ExtractionResult {
72+
var entry = zipInputStream.nextEntry
73+
74+
if (entry == null) {
75+
logcat(WARN) { "No entries found in ZIP stream" }
76+
return ExtractionResult.Error(Exception("Invalid or empty ZIP file"))
77+
}
78+
79+
while (entry != null) {
80+
val entryName = entry.name
81+
logcat { "Processing zip entry '$entryName'" }
82+
83+
if (isBookmarkEntry(entry)) {
84+
return when (val readResult = zipEntryContentReader.readAndValidateContent(zipInputStream, entryName)) {
85+
is ReadResult.Success -> ExtractionResult.Success(readResult.content)
86+
is ReadResult.Error -> ExtractionResult.Error(readResult.exception)
87+
}
88+
}
89+
90+
entry = zipInputStream.nextEntry
91+
}
92+
93+
return ExtractionResult.Error(Exception("Chrome/Bookmarks.html not found in file"))
94+
}
95+
96+
private fun isBookmarkEntry(entry: ZipEntry): Boolean {
97+
return !entry.isDirectory && entry.name.endsWith(EXPECTED_BOOKMARKS_FILENAME, ignoreCase = true)
98+
}
99+
100+
companion object {
101+
private const val EXPECTED_BOOKMARKS_FILENAME = "Chrome/Bookmarks.html"
102+
}
103+
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
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.zip
18+
19+
import com.duckduckgo.di.scopes.AppScope
20+
import com.squareup.anvil.annotations.ContributesBinding
21+
import java.util.zip.ZipInputStream
22+
import javax.inject.Inject
23+
import logcat.logcat
24+
25+
interface ZipEntryContentReader {
26+
27+
sealed class ReadResult {
28+
data class Success(val content: String) : ReadResult()
29+
data class Error(val exception: Exception) : ReadResult()
30+
}
31+
32+
fun readAndValidateContent(
33+
zipInputStream: ZipInputStream,
34+
entryName: String,
35+
): ReadResult
36+
}
37+
38+
@ContributesBinding(AppScope::class)
39+
class BookmarkZipEntryContentReader @Inject constructor() : ZipEntryContentReader {
40+
41+
override fun readAndValidateContent(
42+
zipInputStream: ZipInputStream,
43+
entryName: String,
44+
): ZipEntryContentReader.ReadResult {
45+
logcat { "Reading content from ZIP entry: '$entryName'" }
46+
47+
return try {
48+
val content = readContent(zipInputStream, entryName)
49+
50+
if (isValidBookmarkContent(content)) {
51+
logcat { "Content validation passed for: '$entryName'" }
52+
ZipEntryContentReader.ReadResult.Success(content)
53+
} else {
54+
logcat { "Content validation failed for: '$entryName'" }
55+
ZipEntryContentReader.ReadResult.Error(
56+
Exception("File content is not a valid bookmark file"),
57+
)
58+
}
59+
} catch (e: Exception) {
60+
logcat { "Error reading ZIP entry content: ${e.message}" }
61+
ZipEntryContentReader.ReadResult.Error(e)
62+
}
63+
}
64+
65+
private fun readContent(zipInputStream: ZipInputStream, entryName: String): String {
66+
val content = zipInputStream.bufferedReader(Charsets.UTF_8).use { it.readText() }
67+
logcat { "Read content from '$entryName', length: ${content.length}" }
68+
return content
69+
}
70+
71+
private fun isValidBookmarkContent(content: String): Boolean {
72+
val hasNetscapeHeader = content.contains(NETSCAPE_HEADER, ignoreCase = true)
73+
val hasBookmarkTitle = content.contains(BOOKMARK_TITLE, ignoreCase = true)
74+
75+
logcat { "Content validation: hasNetscapeHeader=$hasNetscapeHeader, hasBookmarkTitle=$hasBookmarkTitle" }
76+
77+
return hasNetscapeHeader || hasBookmarkTitle
78+
}
79+
80+
companion object {
81+
private const val NETSCAPE_HEADER = "<!DOCTYPE NETSCAPE-Bookmark-file"
82+
private const val BOOKMARK_TITLE = "<title>Bookmarks</title>"
83+
}
84+
}

autofill/autofill-impl/src/main/res/values/donottranslate.xml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,8 @@
1616

1717
<resources>
1818

19+
<!-- Import Bookmarks Strings -->
20+
<string name="autofillImportBookmarksChromeFolderName">Imported from Chrome</string>
21+
22+
1923
</resources>
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
package com.duckduckgo.autofill.impl.importing.takeout.processor
2+
3+
import androidx.test.ext.junit.runners.AndroidJUnit4
4+
import com.duckduckgo.common.test.CoroutineTestRule
5+
import com.duckduckgo.common.test.FileUtilities
6+
import com.duckduckgo.savedsites.api.models.SavedSite
7+
import com.duckduckgo.savedsites.api.service.ImportSavedSitesResult
8+
import com.duckduckgo.savedsites.api.service.SavedSitesImporter
9+
import com.duckduckgo.savedsites.api.service.SavedSitesImporter.ImportFolder
10+
import kotlinx.coroutines.test.runTest
11+
import org.junit.Assert.assertEquals
12+
import org.junit.Assert.assertTrue
13+
import org.junit.Rule
14+
import org.junit.Test
15+
import org.junit.runner.RunWith
16+
import org.mockito.kotlin.any
17+
import org.mockito.kotlin.eq
18+
import org.mockito.kotlin.mock
19+
import org.mockito.kotlin.verify
20+
import org.mockito.kotlin.whenever
21+
22+
@RunWith(AndroidJUnit4::class)
23+
class TakeoutBookmarkImporterTest {
24+
25+
@get:Rule
26+
val coroutineTestRule: CoroutineTestRule = CoroutineTestRule()
27+
28+
private val mockSavedSitesImporter = mock<SavedSitesImporter>()
29+
30+
private val successfulResultNoneImported = ImportSavedSitesResult.Success(emptyList())
31+
private val successfulResultWithImports = ImportSavedSitesResult.Success(
32+
listOf(
33+
SavedSite.Bookmark("1", "Title 1", "http://example1.com", lastModified = "2023-01-01"),
34+
SavedSite.Bookmark("2", "Title 2", "http://example2.com", lastModified = "2023-01-01"),
35+
),
36+
)
37+
private val failedResult = ImportSavedSitesResult.Error(Exception())
38+
39+
private val testFolder = ImportFolder.Folder("Test Folder")
40+
41+
private val testee = RealTakeoutBookmarkImporter(
42+
savedSitesImporter = mockSavedSitesImporter,
43+
dispatchers = coroutineTestRule.testDispatcherProvider,
44+
)
45+
46+
@Test
47+
fun whenImportingToRootThenCallsSavedSitesImporterWithRoot() = runTest {
48+
val expectedResult = successfulResultNoneImported
49+
configureResult(expectedResult)
50+
51+
val result = testee.importBookmarks(loadHtmlFile("valid_chrome_bookmarks_netscape"), ImportFolder.Root)
52+
53+
verify(mockSavedSitesImporter).import(any(), eq(ImportFolder.Root))
54+
assertEquals(expectedResult, result)
55+
}
56+
57+
@Test
58+
fun whenImportingToFolderThenCallsSavedSitesImporterWithFolder() = runTest {
59+
val expectedResult = successfulResultNoneImported
60+
configureResult(expectedResult)
61+
62+
val actualResult = testee.importBookmarks(loadHtmlFile("valid_chrome_bookmarks_netscape"), testFolder)
63+
verify(mockSavedSitesImporter).import(any(), eq(testFolder))
64+
assertEquals(expectedResult, actualResult)
65+
}
66+
67+
@Test
68+
fun whenImportSucceedsWithMultipleImportsThenReturnsSuccessResult() = runTest {
69+
configureResult(successfulResultWithImports)
70+
71+
val result = testee.importBookmarks(loadHtmlFile("valid_chrome_bookmarks_netscape"), ImportFolder.Root)
72+
73+
assertTrue(result is ImportSavedSitesResult.Success)
74+
assertEquals(2, (result as ImportSavedSitesResult.Success).savedSites.size)
75+
}
76+
77+
@Test
78+
fun whenImportFailsThenReturnsErrorResult() = runTest {
79+
configureResult(failedResult)
80+
81+
val result = testee.importBookmarks(loadHtmlFile("valid_chrome_bookmarks_netscape"), ImportFolder.Root)
82+
83+
assertTrue(result is ImportSavedSitesResult.Error)
84+
}
85+
86+
@Test
87+
fun whenSavedSitesImporterThrowsExceptionThenReturnsErrorResult() = runTest {
88+
whenever(mockSavedSitesImporter.import(any(), any())).thenThrow(RuntimeException("Unexpected error"))
89+
val result = testee.importBookmarks(loadHtmlFile("valid_chrome_bookmarks_netscape"), ImportFolder.Root)
90+
assertTrue(result is ImportSavedSitesResult.Error)
91+
}
92+
93+
private suspend fun configureResult(result: ImportSavedSitesResult) {
94+
whenever(mockSavedSitesImporter.import(any(), any())).thenReturn(result)
95+
}
96+
97+
private fun loadHtmlFile(filename: String): String {
98+
return FileUtilities.loadText(
99+
TakeoutBookmarkImporterTest::class.java.classLoader!!,
100+
"html/$filename.html",
101+
)
102+
}
103+
}

0 commit comments

Comments
 (0)