diff --git a/app/src/test/java/com/duckduckgo/app/bookmarks/model/SavedSitesParserTest.kt b/app/src/test/java/com/duckduckgo/app/bookmarks/model/SavedSitesParserTest.kt index fa553f4509cd..fc7b0f8b6523 100644 --- a/app/src/test/java/com/duckduckgo/app/bookmarks/model/SavedSitesParserTest.kt +++ b/app/src/test/java/com/duckduckgo/app/bookmarks/model/SavedSitesParserTest.kt @@ -32,6 +32,7 @@ import com.duckduckgo.savedsites.api.models.SavedSite.Bookmark import com.duckduckgo.savedsites.api.models.SavedSite.Favorite import com.duckduckgo.savedsites.api.models.SavedSitesNames import com.duckduckgo.savedsites.api.models.TreeNode +import com.duckduckgo.savedsites.api.service.SavedSitesImporter.ImportFolder import com.duckduckgo.savedsites.impl.MissingEntitiesRelationReconciler import com.duckduckgo.savedsites.impl.RealFavoritesDelegate import com.duckduckgo.savedsites.impl.RealSavedSitesRepository @@ -156,7 +157,7 @@ class SavedSitesParserTest { val inputStream = FileUtilities.loadResource(javaClass.classLoader!!, "bookmarks/bookmarks_invalid.html") val document = Jsoup.parse(inputStream, Charsets.UTF_8.name(), "duckduckgo.com") - val bookmarks = parser.parseHtml(document, repository) + val bookmarks = parser.parseHtml(document, repository, ImportFolder.Root) assertTrue(bookmarks.isEmpty()) } @@ -166,7 +167,7 @@ class SavedSitesParserTest { val inputStream = FileUtilities.loadResource(javaClass.classLoader!!, "bookmarks/bookmarks_firefox.html") val document = Jsoup.parse(inputStream, Charsets.UTF_8.name(), "duckduckgo.com") - val bookmarks = parser.parseHtml(document, repository).filterIsInstance() + val bookmarks = parser.parseHtml(document, repository, ImportFolder.Root).filterIsInstance() assertEquals(17, bookmarks.size) @@ -185,7 +186,7 @@ class SavedSitesParserTest { val inputStream = FileUtilities.loadResource(javaClass.classLoader!!, "bookmarks/bookmarks_brave.html") val document = Jsoup.parse(inputStream, Charsets.UTF_8.name(), "duckduckgo.com") - val bookmarks = parser.parseHtml(document, repository).filterIsInstance() + val bookmarks = parser.parseHtml(document, repository, ImportFolder.Root).filterIsInstance() assertEquals(12, bookmarks.size) @@ -206,7 +207,7 @@ class SavedSitesParserTest { val inputStream = FileUtilities.loadResource(javaClass.classLoader!!, "bookmarks/bookmarks_chrome.html") val document = Jsoup.parse(inputStream, Charsets.UTF_8.name(), "duckduckgo.com") - val bookmarks = parser.parseHtml(document, repository).filterIsInstance() + val bookmarks = parser.parseHtml(document, repository, ImportFolder.Root).filterIsInstance() assertEquals(12, bookmarks.size) @@ -228,7 +229,7 @@ class SavedSitesParserTest { val inputStream = FileUtilities.loadResource(javaClass.classLoader!!, "bookmarks/bookmarks_ddg_android.html") val document = Jsoup.parse(inputStream, Charsets.UTF_8.name(), "duckduckgo.com") - val bookmarks = parser.parseHtml(document, repository).filterNot { it is BookmarkFolder } + val bookmarks = parser.parseHtml(document, repository, ImportFolder.Root).filterNot { it is BookmarkFolder } assertEquals(13, bookmarks.size) @@ -250,7 +251,7 @@ class SavedSitesParserTest { val inputStream = FileUtilities.loadResource(javaClass.classLoader!!, "bookmarks/bookmarks_ddg_macos.html") val document = Jsoup.parse(inputStream, Charsets.UTF_8.name(), "duckduckgo.com") - val bookmarks = parser.parseHtml(document, repository).filterIsInstance() + val bookmarks = parser.parseHtml(document, repository, ImportFolder.Root).filterIsInstance() assertEquals(13, bookmarks.size) @@ -273,7 +274,7 @@ class SavedSitesParserTest { val inputStream = FileUtilities.loadResource(javaClass.classLoader!!, "bookmarks/bookmarks_safari.html") val document = Jsoup.parse(inputStream, Charsets.UTF_8.name(), "duckduckgo.com") - val bookmarks = parser.parseHtml(document, repository).filterIsInstance() + val bookmarks = parser.parseHtml(document, repository, ImportFolder.Root).filterIsInstance() assertEquals(14, bookmarks.size) @@ -295,7 +296,7 @@ class SavedSitesParserTest { val inputStream = FileUtilities.loadResource(javaClass.classLoader!!, "bookmarks/bookmarks_favorites_ddg.html") val document = Jsoup.parse(inputStream, Charsets.UTF_8.name(), "duckduckgo.com") - val savedSites = parser.parseHtml(document, repository) + val savedSites = parser.parseHtml(document, repository, ImportFolder.Root) val favorites = savedSites.filterIsInstance() val bookmarks = savedSites.filterIsInstance() @@ -367,4 +368,29 @@ class SavedSitesParserTest { assertEquals(3, favoritesLists.size) assertEquals(9, bookmarks.size) } + + @Test + fun canImportBookmarksWithDestinationFolder() = runTest { + val inputStream = FileUtilities.loadResource(javaClass.classLoader!!, "bookmarks/bookmarks_chrome.html") + val document = Jsoup.parse(inputStream, Charsets.UTF_8.name(), "duckduckgo.com") + + val folderName = "Imported Bookmarks" + val savedSites = parser.parseHtml(document, repository, ImportFolder.Folder(folderName)) + val bookmarks = savedSites.filterIsInstance() + + // Should import bookmarks successfully + assertTrue("Should import bookmarks with folder destination", bookmarks.isNotEmpty()) + + // Verify that a destination folder was created in the repository + val createdFolders = repository.getFolderTreeItems(SavedSitesNames.BOOKMARKS_ROOT) + .filter { it.name == folderName && it.url == null } + + assertTrue("Should create destination folder in repository", createdFolders.isNotEmpty()) + + val destinationFolderId = createdFolders.first().id + + // Verify that bookmarks reference the destination folder as their parent + val bookmarksInDestinationFolder = bookmarks.filter { it.parentId == destinationFolderId } + assertTrue("Bookmarks should be placed in destination folder", bookmarksInDestinationFolder.isNotEmpty()) + } } diff --git a/autofill/autofill-impl/build.gradle b/autofill/autofill-impl/build.gradle index e7b6d646ecf9..2815b368f35d 100644 --- a/autofill/autofill-impl/build.gradle +++ b/autofill/autofill-impl/build.gradle @@ -47,6 +47,7 @@ dependencies { testImplementation project(':feature-toggles-test') implementation project(path: ':settings-api') // temporary until we release new settings implementation project(':library-loader-api') + implementation project(':saved-sites-api') anvil project(path: ':anvil-compiler') implementation project(path: ':anvil-annotations') diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/takeout/processor/TakeoutBookmarkImporter.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/takeout/processor/TakeoutBookmarkImporter.kt new file mode 100644 index 000000000000..83f553d22264 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/takeout/processor/TakeoutBookmarkImporter.kt @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2025 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.autofill.impl.importing.takeout.processor + +import android.net.Uri +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.savedsites.api.service.ImportSavedSitesResult +import com.duckduckgo.savedsites.api.service.SavedSitesImporter +import com.duckduckgo.savedsites.api.service.SavedSitesImporter.ImportFolder +import com.squareup.anvil.annotations.ContributesBinding +import java.io.File +import javax.inject.Inject +import kotlinx.coroutines.withContext + +/** + * Interface for importing bookmarks with flexible destination handling. + * Supports both root-level imports and folder-based imports while preserving structure. + */ +interface TakeoutBookmarkImporter { + + /** + * Imports bookmarks from HTML content to the specified destination. + * @param htmlContent The HTML bookmark content to import (in Netscape format) + * @param destination Where to import the bookmarks (Root or named Folder within bookmarks root) + * @return ImportSavedSitesResult indicating success with imported items or error + */ + suspend fun importBookmarks(htmlContent: String, destination: ImportFolder): ImportSavedSitesResult +} + +@ContributesBinding(AppScope::class) +class RealTakeoutBookmarkImporter @Inject constructor( + private val savedSitesImporter: SavedSitesImporter, + private val dispatchers: DispatcherProvider, +) : TakeoutBookmarkImporter { + + override suspend fun importBookmarks(htmlContent: String, destination: ImportFolder): ImportSavedSitesResult { + return withContext(dispatchers.io()) { + import(htmlContent = htmlContent, destination = destination) + } + } + + private suspend fun import(htmlContent: String, destination: ImportFolder = ImportFolder.Root): ImportSavedSitesResult { + return try { + // saved sites importer needs a file uri, so we create a temp file here + val tempFile = File.createTempFile("bookmark_import_", ".html") + try { + tempFile.writeText(htmlContent) + return savedSitesImporter.import(Uri.fromFile(tempFile), destination) + } finally { + // delete the temp file after import + tempFile.takeIf { it.exists() }?.delete() + } + } catch (exception: Exception) { + ImportSavedSitesResult.Error(exception) + } + } +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/takeout/zip/TakeoutBookmarkExtractor.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/takeout/zip/TakeoutBookmarkExtractor.kt new file mode 100644 index 000000000000..5e44e2ddfb02 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/takeout/zip/TakeoutBookmarkExtractor.kt @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2025 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.autofill.impl.importing.takeout.zip + +import android.content.Context +import android.net.Uri +import com.duckduckgo.autofill.impl.importing.takeout.zip.TakeoutBookmarkExtractor.ExtractionResult +import com.duckduckgo.autofill.impl.importing.takeout.zip.ZipEntryContentReader.ReadResult +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import java.util.zip.ZipEntry +import java.util.zip.ZipInputStream +import javax.inject.Inject +import kotlinx.coroutines.withContext +import logcat.LogPriority.WARN +import logcat.logcat + +interface TakeoutBookmarkExtractor { + + sealed class ExtractionResult { + data class Success(val bookmarkHtmlContent: String) : ExtractionResult() { + override fun toString(): String { + return "ExtractionResult=success" + } + } + data class Error(val exception: Exception) : ExtractionResult() + } + + /** + * Extracts the bookmark HTML content from the provided Google Takeout ZIP file URI. + * @param fileUri The URI of the Google Takeout ZIP file containing the bookmarks. + * @return ExtractionResult containing either the bookmark HTML content or an error. + */ + suspend fun extractBookmarksHtml(fileUri: Uri): ExtractionResult +} + +@ContributesBinding(AppScope::class) +class TakeoutZipBookmarkExtractor @Inject constructor( + private val context: Context, + private val dispatchers: DispatcherProvider, + private val zipEntryContentReader: ZipEntryContentReader, +) : TakeoutBookmarkExtractor { + + override suspend fun extractBookmarksHtml(fileUri: Uri): ExtractionResult { + return withContext(dispatchers.io()) { + runCatching { + context.contentResolver.openInputStream(fileUri)?.use { inputStream -> + ZipInputStream(inputStream).use { zipInputStream -> + processZipEntries(zipInputStream) + } + } ?: ExtractionResult.Error(Exception("Unable to open file: $fileUri")) + }.getOrElse { ExtractionResult.Error(Exception(it)) } + } + } + + private fun processZipEntries(zipInputStream: ZipInputStream): ExtractionResult { + var entry = zipInputStream.nextEntry + + if (entry == null) { + logcat(WARN) { "No entries found in ZIP stream" } + return ExtractionResult.Error(Exception("Invalid or empty ZIP file")) + } + + while (entry != null) { + val entryName = entry.name + logcat { "Processing zip entry '$entryName'" } + + if (isBookmarkEntry(entry)) { + return when (val readResult = zipEntryContentReader.readAndValidateContent(zipInputStream, entryName)) { + is ReadResult.Success -> ExtractionResult.Success(readResult.content) + is ReadResult.Error -> ExtractionResult.Error(readResult.exception) + } + } + + entry = zipInputStream.nextEntry + } + + return ExtractionResult.Error(Exception("Chrome/Bookmarks.html not found in file")) + } + + private fun isBookmarkEntry(entry: ZipEntry): Boolean { + return !entry.isDirectory && entry.name.endsWith(EXPECTED_BOOKMARKS_FILENAME, ignoreCase = true) + } + + companion object { + private const val EXPECTED_BOOKMARKS_FILENAME = "Chrome/Bookmarks.html" + } +} diff --git a/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/takeout/zip/ZipEntryContentReader.kt b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/takeout/zip/ZipEntryContentReader.kt new file mode 100644 index 000000000000..489295999ab4 --- /dev/null +++ b/autofill/autofill-impl/src/main/java/com/duckduckgo/autofill/impl/importing/takeout/zip/ZipEntryContentReader.kt @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2025 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.autofill.impl.importing.takeout.zip + +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import java.util.zip.ZipInputStream +import javax.inject.Inject +import logcat.logcat + +interface ZipEntryContentReader { + + sealed class ReadResult { + data class Success(val content: String) : ReadResult() + data class Error(val exception: Exception) : ReadResult() + } + + fun readAndValidateContent( + zipInputStream: ZipInputStream, + entryName: String, + ): ReadResult +} + +@ContributesBinding(AppScope::class) +class BookmarkZipEntryContentReader @Inject constructor() : ZipEntryContentReader { + + override fun readAndValidateContent( + zipInputStream: ZipInputStream, + entryName: String, + ): ZipEntryContentReader.ReadResult { + logcat { "Reading content from ZIP entry: '$entryName'" } + + return try { + val content = readContent(zipInputStream, entryName) + + if (isValidBookmarkContent(content)) { + logcat { "Content validation passed for: '$entryName'" } + ZipEntryContentReader.ReadResult.Success(content) + } else { + logcat { "Content validation failed for: '$entryName'" } + ZipEntryContentReader.ReadResult.Error( + Exception("File content is not a valid bookmark file"), + ) + } + } catch (e: Exception) { + logcat { "Error reading ZIP entry content: ${e.message}" } + ZipEntryContentReader.ReadResult.Error(e) + } + } + + private fun readContent(zipInputStream: ZipInputStream, entryName: String): String { + val content = zipInputStream.bufferedReader(Charsets.UTF_8).use { it.readText() } + logcat { "Read content from '$entryName', length: ${content.length}" } + return content + } + + private fun isValidBookmarkContent(content: String): Boolean { + val hasNetscapeHeader = content.contains(NETSCAPE_HEADER, ignoreCase = true) + val hasBookmarkTitle = content.contains(BOOKMARK_TITLE, ignoreCase = true) + + logcat { "Content validation: hasNetscapeHeader=$hasNetscapeHeader, hasBookmarkTitle=$hasBookmarkTitle" } + + return hasNetscapeHeader || hasBookmarkTitle + } + + companion object { + private const val NETSCAPE_HEADER = "Bookmarks" + } +} diff --git a/autofill/autofill-impl/src/main/res/values/donottranslate.xml b/autofill/autofill-impl/src/main/res/values/donottranslate.xml index def6aa352279..9f5e1db5733f 100644 --- a/autofill/autofill-impl/src/main/res/values/donottranslate.xml +++ b/autofill/autofill-impl/src/main/res/values/donottranslate.xml @@ -16,4 +16,8 @@ + + Imported from Chrome + + \ No newline at end of file diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/takeout/processor/TakeoutBookmarkImporterTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/takeout/processor/TakeoutBookmarkImporterTest.kt new file mode 100644 index 000000000000..1e8c70bf8864 --- /dev/null +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/importing/takeout/processor/TakeoutBookmarkImporterTest.kt @@ -0,0 +1,110 @@ +package com.duckduckgo.autofill.impl.importing.takeout.processor + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.common.test.FileUtilities +import com.duckduckgo.savedsites.api.models.SavedSite +import com.duckduckgo.savedsites.api.service.ImportSavedSitesResult +import com.duckduckgo.savedsites.api.service.SavedSitesImporter +import com.duckduckgo.savedsites.api.service.SavedSitesImporter.ImportFolder +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +@RunWith(AndroidJUnit4::class) +class TakeoutBookmarkImporterTest { + + @get:Rule + val coroutineTestRule: CoroutineTestRule = CoroutineTestRule() + + private val mockSavedSitesImporter = mock() + + private val successfulResultNoneImported = ImportSavedSitesResult.Success(emptyList()) + private val successfulResultWithImports = ImportSavedSitesResult.Success( + listOf( + SavedSite.Bookmark("1", "Title 1", "http://example1.com", lastModified = "2023-01-01"), + SavedSite.Bookmark("2", "Title 2", "http://example2.com", lastModified = "2023-01-01"), + ), + ) + private val failedResult = ImportSavedSitesResult.Error(Exception()) + + private val testFolder = ImportFolder.Folder("Test Folder") + + private val testee = RealTakeoutBookmarkImporter( + savedSitesImporter = mockSavedSitesImporter, + dispatchers = coroutineTestRule.testDispatcherProvider, + ) + + @Test + fun whenImportingToRootThenCallsSavedSitesImporterWithRoot() = runTest { + configureSuccessfulResult() + testee.importBookmarks(loadHtmlFile("valid_chrome_bookmarks_netscape"), ImportFolder.Root) + verify(mockSavedSitesImporter).import(any(), eq(ImportFolder.Root)) + } + + @Test + fun whenImportingToFolderThenCallsSavedSitesImporterWithFolder() = runTest { + configureSuccessfulResult() + testee.importBookmarks(loadHtmlFile("valid_chrome_bookmarks_netscape"), testFolder) + verify(mockSavedSitesImporter).import(any(), eq(testFolder)) + } + + @Test + fun whenImportSucceedsWithMultipleImportsThenReturnsSuccessResult() = runTest { + configureResult(successfulResultWithImports) + + val result = testee.importBookmarks(loadHtmlFile("valid_chrome_bookmarks_netscape"), ImportFolder.Root) + + assertTrue(result is ImportSavedSitesResult.Success) + assertEquals(2, (result as ImportSavedSitesResult.Success).savedSites.size) + } + + @Test + fun whenImportSucceedsWithNoImportsThenReturnsSuccessResult() = runTest { + configureResult(successfulResultNoneImported) + + val result = testee.importBookmarks(loadHtmlFile("valid_chrome_bookmarks_netscape"), ImportFolder.Root) + + assertTrue(result is ImportSavedSitesResult.Success) + assertEquals(0, (result as ImportSavedSitesResult.Success).savedSites.size) + } + + @Test + fun whenImportFailsThenReturnsErrorResult() = runTest { + configureResult(failedResult) + + val result = testee.importBookmarks(loadHtmlFile("valid_chrome_bookmarks_netscape"), ImportFolder.Root) + + assertTrue(result is ImportSavedSitesResult.Error) + } + + @Test + fun whenSavedSitesImporterThrowsExceptionThenReturnsErrorResult() = runTest { + whenever(mockSavedSitesImporter.import(any(), any())).thenThrow(RuntimeException("Unexpected error")) + val result = testee.importBookmarks(loadHtmlFile("valid_chrome_bookmarks_netscape"), ImportFolder.Root) + assertTrue(result is ImportSavedSitesResult.Error) + } + + private suspend fun configureResult(result: ImportSavedSitesResult) { + whenever(mockSavedSitesImporter.import(any(), any())).thenReturn(result) + } + + private suspend fun configureSuccessfulResult() { + whenever(mockSavedSitesImporter.import(any(), any())).thenReturn(successfulResultNoneImported) + } + + private fun loadHtmlFile(filename: String): String { + return FileUtilities.loadText( + TakeoutBookmarkImporterTest::class.java.classLoader!!, + "html/$filename.html", + ) + } +} diff --git a/autofill/autofill-impl/src/test/resources/html/complex_chrome_bookmarks.html b/autofill/autofill-impl/src/test/resources/html/complex_chrome_bookmarks.html new file mode 100644 index 000000000000..f298af1d53d7 --- /dev/null +++ b/autofill/autofill-impl/src/test/resources/html/complex_chrome_bookmarks.html @@ -0,0 +1,33 @@ + + + +Bookmarks +

Bookmarks

+ +

+

Bookmarks Bar

+

+

DuckDuckGo +
GitHub +
Stack Overflow +

Development

+

+

MDN Web Docs +
Kotlin +
Android Developers +

+

News

+

+

Hacker News +
TechCrunch +

+

+

Other Bookmarks

+

+

Wikipedia +
YouTube +
Reddit +

+

\ No newline at end of file diff --git a/autofill/autofill-impl/src/test/resources/html/invalid_bookmark_content.html b/autofill/autofill-impl/src/test/resources/html/invalid_bookmark_content.html new file mode 100644 index 000000000000..458df8a24440 --- /dev/null +++ b/autofill/autofill-impl/src/test/resources/html/invalid_bookmark_content.html @@ -0,0 +1,10 @@ + + + Not Bookmarks + + +

This is not a bookmark file

+

This file does not contain bookmark data and should fail validation.

+
No HREF links here, no bookmark title, no Netscape header.
+ + \ No newline at end of file diff --git a/autofill/autofill-impl/src/test/resources/html/mixed_valid_invalid_bookmarks.html b/autofill/autofill-impl/src/test/resources/html/mixed_valid_invalid_bookmarks.html new file mode 100644 index 000000000000..99a306555ba3 --- /dev/null +++ b/autofill/autofill-impl/src/test/resources/html/mixed_valid_invalid_bookmarks.html @@ -0,0 +1,53 @@ + + + +Bookmarks + + +

Bookmarks

+

+ +

DuckDuckGo + + +
Invalid - No URL + + +
GitHub + + +
Invalid - Empty URL + + +
Invalid - Malformed URL + + +
Example Site + + +
Broken HTML + + +
Stack Overflow + + +
Incomplete + + +

Mixed Folder

+

+ +

Valid in Folder + + +
Invalid - JavaScript URL + + +
Another Valid +

+ + +

Final Valid +

+ + \ No newline at end of file diff --git a/autofill/autofill-impl/src/test/resources/html/valid_chrome_bookmarks_netscape.html b/autofill/autofill-impl/src/test/resources/html/valid_chrome_bookmarks_netscape.html new file mode 100644 index 000000000000..be6b0a2167f9 --- /dev/null +++ b/autofill/autofill-impl/src/test/resources/html/valid_chrome_bookmarks_netscape.html @@ -0,0 +1,17 @@ + + + +Bookmarks + + +

Bookmarks

+

+

DuckDuckGo +
Example Site +

Folder

+

+

Nested Link +

+

+ + \ No newline at end of file diff --git a/autofill/autofill-impl/src/test/resources/html/valid_chrome_bookmarks_title_only.html b/autofill/autofill-impl/src/test/resources/html/valid_chrome_bookmarks_title_only.html new file mode 100644 index 000000000000..ab566bf323fc --- /dev/null +++ b/autofill/autofill-impl/src/test/resources/html/valid_chrome_bookmarks_title_only.html @@ -0,0 +1,12 @@ + + +Bookmarks + + +

My Bookmarks

+

+

DuckDuckGo +
Example Site +

+ + \ No newline at end of file diff --git a/autofill/autofill-internal/build.gradle b/autofill/autofill-internal/build.gradle index 7d3ec61ad2f4..5273dae6d065 100644 --- a/autofill/autofill-internal/build.gradle +++ b/autofill/autofill-internal/build.gradle @@ -39,6 +39,7 @@ dependencies { implementation project(':internal-features-api') implementation project(path: ':browser-api') implementation project(path: ':navigation-api') + implementation project(':saved-sites-api') anvil project(path: ':anvil-compiler') implementation project(path: ':anvil-annotations') diff --git a/autofill/autofill-internal/src/main/java/com/duckduckgo/autofill/internal/AutofillInternalSettingsActivity.kt b/autofill/autofill-internal/src/main/java/com/duckduckgo/autofill/internal/AutofillInternalSettingsActivity.kt index e3712e6dd51e..594c17f325a5 100644 --- a/autofill/autofill-internal/src/main/java/com/duckduckgo/autofill/internal/AutofillInternalSettingsActivity.kt +++ b/autofill/autofill-internal/src/main/java/com/duckduckgo/autofill/internal/AutofillInternalSettingsActivity.kt @@ -36,6 +36,7 @@ import com.duckduckgo.autofill.api.AutofillScreenLaunchSource.InternalDevSetting import com.duckduckgo.autofill.api.AutofillScreens.AutofillPasswordsManagementScreen import com.duckduckgo.autofill.api.domain.app.LoginCredentials import com.duckduckgo.autofill.api.email.EmailManager +import com.duckduckgo.autofill.impl.R as autofillR import com.duckduckgo.autofill.impl.configuration.AutofillJavascriptEnvironmentConfiguration import com.duckduckgo.autofill.impl.email.incontext.store.EmailProtectionInContextDataStore import com.duckduckgo.autofill.impl.engagement.store.AutofillEngagementRepository @@ -53,6 +54,9 @@ import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordRe import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordResult.Error import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordResult.Success import com.duckduckgo.autofill.impl.importing.gpm.webflow.ImportGooglePasswordResult.UserCancelled +import com.duckduckgo.autofill.impl.importing.takeout.processor.TakeoutBookmarkImporter +import com.duckduckgo.autofill.impl.importing.takeout.zip.TakeoutBookmarkExtractor +import com.duckduckgo.autofill.impl.importing.takeout.zip.TakeoutBookmarkExtractor.ExtractionResult import com.duckduckgo.autofill.impl.reporting.AutofillSiteBreakageReportingDataStore import com.duckduckgo.autofill.impl.store.InternalAutofillStore import com.duckduckgo.autofill.impl.store.NeverSavedSiteRepository @@ -60,6 +64,7 @@ import com.duckduckgo.autofill.impl.ui.credential.management.survey.AutofillSurv import com.duckduckgo.autofill.internal.databinding.ActivityAutofillInternalSettingsBinding import com.duckduckgo.autofill.store.AutofillPrefsStore import com.duckduckgo.browser.api.UserBrowserProperties +import com.duckduckgo.browser.api.ui.BrowserScreens import com.duckduckgo.common.ui.DuckDuckGoActivity import com.duckduckgo.common.ui.view.button.ButtonType.DESTRUCTIVE import com.duckduckgo.common.ui.view.button.ButtonType.GHOST_ALT @@ -72,12 +77,15 @@ import com.duckduckgo.common.utils.extensions.launchAutofillProviderSystemSettin import com.duckduckgo.di.scopes.ActivityScope import com.duckduckgo.feature.toggles.api.Toggle import com.duckduckgo.navigation.api.GlobalActivityStarter +import com.duckduckgo.savedsites.api.service.ImportSavedSitesResult +import com.duckduckgo.savedsites.api.service.SavedSitesImporter.ImportFolder import com.google.android.material.snackbar.Snackbar import java.text.SimpleDateFormat import javax.inject.Inject import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import logcat.LogPriority import logcat.logcat @InjectWith(ActivityScope::class) @@ -144,6 +152,12 @@ class AutofillInternalSettingsActivity : DuckDuckGoActivity() { @Inject lateinit var inBrowserImportPromoPreviousPromptsStore: InternalInBrowserPromoStore + @Inject + lateinit var takeoutBookmarkImporter: TakeoutBookmarkImporter + + @Inject + lateinit var takeoutZipTakeoutBookmarkExtractor: TakeoutBookmarkExtractor + private var passwordImportWatcher = ConflatedJob() // used to output duration of import @@ -177,6 +191,49 @@ class AutofillInternalSettingsActivity : DuckDuckGoActivity() { } } + private val importBookmarksTakeoutZipLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode == RESULT_OK) { + val data: Intent? = result.data + val fileUrl = data?.data + + logcat { "onActivityResult for bookmarks zip request. uri=$fileUrl" } + fileUrl?.let { file -> + lifecycleScope.launch(dispatchers.io()) { + // Extract HTML content from zip + val extractionResult = takeoutZipTakeoutBookmarkExtractor.extractBookmarksHtml(file) + logcat { "Bookmark extraction result: $extractionResult" } + + when (extractionResult) { + is ExtractionResult.Success -> onBookmarksExtracted(extractionResult) + is ExtractionResult.Error -> { + "Error extracting bookmarks".showSnackbar() + logcat(LogPriority.WARN) { "Error extracting bookmarks: ${extractionResult.exception.message}" } + } + } + } + } + } + } + + private suspend fun onBookmarksExtracted(extractionResult: ExtractionResult.Success) { + when ( + val importResult = takeoutBookmarkImporter.importBookmarks( + htmlContent = extractionResult.bookmarkHtmlContent, + destination = ImportFolder.Folder(getString(autofillR.string.autofillImportBookmarksChromeFolderName)), + ) + ) { + is ImportSavedSitesResult.Success -> { + logcat { "Successfully imported ${importResult.savedSites.size} bookmarks" } + "Imported ${importResult.savedSites.size} bookmarks".showSnackbar() + } + + is ImportSavedSitesResult.Error -> { + logcat(LogPriority.WARN) { "Error importing bookmarks: ${importResult.exception.message}" } + "Could not import bookmarks".showSnackbar() + } + } + } + private val importGooglePasswordsFlowLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> logcat { "onActivityResult for Google Password Manager import flow. resultCode=${result.resultCode}" } @@ -279,6 +336,7 @@ class AutofillInternalSettingsActivity : DuckDuckGoActivity() { configureReportBreakagesHandlers() configureDeclineCounterHandlers() configureImportPasswordsEventHandlers() + configureImportBookmarksEventHandlers() } private fun configureReportBreakagesHandlers() { @@ -671,6 +729,27 @@ class AutofillInternalSettingsActivity : DuckDuckGoActivity() { Toast.makeText(this, R.string.autofillDevSettingsGoogleLogoutSuccess, Toast.LENGTH_SHORT).show() } + private fun configureImportBookmarksEventHandlers() { + binding.importBookmarksLaunchGoogleTakeoutWebpage.setClickListener { + lifecycleScope.launch(dispatchers.io()) { + val url = "https://takeout.google.com" + startActivity(browserNav.openInNewTab(this@AutofillInternalSettingsActivity, url)) + } + } + + binding.importBookmarksImportTakeoutZip.setClickListener { + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = "*/*" + } + importBookmarksTakeoutZipLauncher.launch(intent) + } + + binding.viewBookmarks.setClickListener { + globalActivityStarter.start(this, BrowserScreens.BookmarksScreenNoParams) + } + } + companion object { fun intent(context: Context): Intent { return Intent(context, AutofillInternalSettingsActivity::class.java) diff --git a/autofill/autofill-internal/src/main/res/layout/activity_autofill_internal_settings.xml b/autofill/autofill-internal/src/main/res/layout/activity_autofill_internal_settings.xml index 0227d624b062..7ca474d25c62 100644 --- a/autofill/autofill-internal/src/main/res/layout/activity_autofill_internal_settings.xml +++ b/autofill/autofill-internal/src/main/res/layout/activity_autofill_internal_settings.xml @@ -146,6 +146,35 @@ app:secondaryText="@string/autofillDevSettingsSimulatePasswordsImportedInstruction" /> + + + + + + + + + + diff --git a/autofill/autofill-internal/src/main/res/values/donottranslate.xml b/autofill/autofill-internal/src/main/res/values/donottranslate.xml index 2ef596a8dd29..91b68832ed18 100644 --- a/autofill/autofill-internal/src/main/res/values/donottranslate.xml +++ b/autofill/autofill-internal/src/main/res/values/donottranslate.xml @@ -47,6 +47,11 @@ Tap to clear all Google cookies Google cookies cleared %1$d passwords imported from Google + + Import Bookmarks + Launch Google Takeout (normal tab) + Choose zip file (downloaded from Takeout) + View Bookmarks Clear Previous Google Imports interactions Tap to forget whether we\'ve previously imported, what we chose when previously prompted etc… Eligible to see Google Import promos again diff --git a/saved-sites/saved-sites-api/src/main/java/com/duckduckgo/savedsites/api/service/SavedSitesImporter.kt b/saved-sites/saved-sites-api/src/main/java/com/duckduckgo/savedsites/api/service/SavedSitesImporter.kt index e54466dff485..6b26270162f4 100644 --- a/saved-sites/saved-sites-api/src/main/java/com/duckduckgo/savedsites/api/service/SavedSitesImporter.kt +++ b/saved-sites/saved-sites-api/src/main/java/com/duckduckgo/savedsites/api/service/SavedSitesImporter.kt @@ -27,9 +27,26 @@ interface SavedSitesImporter { * Reads a HTML based file with all [SavedSites] that the user has * in Netscape format. * @param uri of the [File] we'll read the data from + * @param destination where to import the bookmarks (defaults to Root) * @return [ImportSavedSitesResult] result of the operation */ - suspend fun import(uri: Uri): ImportSavedSitesResult + suspend fun import(uri: Uri, importFolder: ImportFolder = ImportFolder.Root): ImportSavedSitesResult + + /** + * Where to import bookmarks, either directly to the bookmark root or into a named folder. + */ + sealed class ImportFolder { + /** + * Import bookmarks directly into the root bookmark folder structure. + */ + object Root : ImportFolder() + + /** + * Import bookmarks into a named folder. + * @param folderName The name of the folder + */ + data class Folder(val folderName: String) : ImportFolder() + } } sealed class ImportSavedSitesResult { diff --git a/saved-sites/saved-sites-impl/src/main/java/com/duckduckgo/savedsites/impl/service/SavedSitesImporter.kt b/saved-sites/saved-sites-impl/src/main/java/com/duckduckgo/savedsites/impl/service/SavedSitesImporter.kt index 8afb9ef092f4..3d4185f0b139 100644 --- a/saved-sites/saved-sites-impl/src/main/java/com/duckduckgo/savedsites/impl/service/SavedSitesImporter.kt +++ b/saved-sites/saved-sites-impl/src/main/java/com/duckduckgo/savedsites/impl/service/SavedSitesImporter.kt @@ -25,6 +25,7 @@ import com.duckduckgo.savedsites.api.models.SavedSite import com.duckduckgo.savedsites.api.models.SavedSitesNames import com.duckduckgo.savedsites.api.service.ImportSavedSitesResult import com.duckduckgo.savedsites.api.service.SavedSitesImporter +import com.duckduckgo.savedsites.api.service.SavedSitesImporter.ImportFolder import com.duckduckgo.savedsites.store.Entity import com.duckduckgo.savedsites.store.EntityType.BOOKMARK import com.duckduckgo.savedsites.store.EntityType.FOLDER @@ -62,11 +63,11 @@ class RealSavedSitesImporter( private const val IMPORT_BATCH_SIZE = 200 } - override suspend fun import(uri: Uri): ImportSavedSitesResult { + override suspend fun import(uri: Uri, destination: ImportFolder): ImportSavedSitesResult { return try { val savedSites = contentResolver.openInputStream(uri).use { stream -> val document = Jsoup.parse(stream, Charsets.UTF_8.name(), BASE_URI) - savedSitesParser.parseHtml(document, savedSitesRepository) + savedSitesParser.parseHtml(document, savedSitesRepository, destination) } val bookmarks = savedSites.filterIsInstance() diff --git a/saved-sites/saved-sites-impl/src/main/java/com/duckduckgo/savedsites/impl/service/SavedSitesParser.kt b/saved-sites/saved-sites-impl/src/main/java/com/duckduckgo/savedsites/impl/service/SavedSitesParser.kt index 6a5781fb1aaf..823e0849ff7a 100644 --- a/saved-sites/saved-sites-impl/src/main/java/com/duckduckgo/savedsites/impl/service/SavedSitesParser.kt +++ b/saved-sites/saved-sites-impl/src/main/java/com/duckduckgo/savedsites/impl/service/SavedSitesParser.kt @@ -21,6 +21,7 @@ import com.duckduckgo.savedsites.api.SavedSitesRepository import com.duckduckgo.savedsites.api.models.BookmarkFolder import com.duckduckgo.savedsites.api.models.SavedSite import com.duckduckgo.savedsites.api.models.SavedSitesNames +import com.duckduckgo.savedsites.api.service.SavedSitesImporter.ImportFolder import java.util.UUID import org.jsoup.nodes.Document import org.jsoup.nodes.Element @@ -34,6 +35,7 @@ interface SavedSitesParser { suspend fun parseHtml( document: Document, savedSitesRepository: SavedSitesRepository, + destination: ImportFolder, ): List } @@ -125,6 +127,7 @@ class RealSavedSitesParser : SavedSitesParser { override suspend fun parseHtml( document: Document, savedSitesRepository: SavedSitesRepository, + destination: ImportFolder, ): List { val body = document.select("body").first() ?: return emptyList() val children = body.childNodes() @@ -136,7 +139,30 @@ class RealSavedSitesParser : SavedSitesParser { if (children.size > 1) { rootElement = Element("DL").appendChildren(children) } - return parseElement(rootElement, SavedSitesNames.BOOKMARKS_ROOT, savedSitesRepository, mutableListOf(), false) + + val destinationFolderId = when (destination) { + is ImportFolder.Root -> SavedSitesNames.BOOKMARKS_ROOT + is ImportFolder.Folder -> { + // Check if folder with this name already exists, otherwise create it + val existingFolder = savedSitesRepository.getFolderTreeItems(SavedSitesNames.BOOKMARKS_ROOT) + .find { it.url == null && it.name == destination.folderName && it.parentId == SavedSitesNames.BOOKMARKS_ROOT } + + existingFolder?.id ?: run { + // Create new folder if it doesn't exist + val newFolder = savedSitesRepository.insert( + BookmarkFolder( + id = UUID.randomUUID().toString(), + name = destination.folderName, + parentId = SavedSitesNames.BOOKMARKS_ROOT, + lastModified = DatabaseDateFormatter.iso8601(), + ), + ) + newFolder.id + } + } + } + + return parseElement(rootElement, destinationFolderId, savedSitesRepository, mutableListOf(), false) } private fun parseElement(