diff --git a/app/build.gradle b/app/build.gradle index 93a2ad84fc83..016154defb1a 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -141,6 +141,7 @@ dependencies { implementation AndroidX.appCompat implementation Google.android.material implementation AndroidX.constraintLayout + implementation AndroidX.recyclerView implementation AndroidX.swipeRefreshLayout implementation AndroidX.webkit implementation Square.okHttp3.okHttp diff --git a/app/schemas/com.duckduckgo.app.global.db.AppDatabase/35.json b/app/schemas/com.duckduckgo.app.global.db.AppDatabase/35.json new file mode 100644 index 000000000000..d7bb0f0a618d --- /dev/null +++ b/app/schemas/com.duckduckgo.app.global.db.AppDatabase/35.json @@ -0,0 +1,914 @@ +{ + "formatVersion": 1, + "database": { + "version": 35, + "identityHash": "7e459183134182e23524e089ee6e638f", + "entities": [ + { + "tableName": "tds_tracker", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`domain` TEXT NOT NULL, `defaultAction` TEXT NOT NULL, `ownerName` TEXT NOT NULL, `categories` TEXT NOT NULL, `rules` TEXT NOT NULL, PRIMARY KEY(`domain`))", + "fields": [ + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaultAction", + "columnName": "defaultAction", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ownerName", + "columnName": "ownerName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "categories", + "columnName": "categories", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rules", + "columnName": "rules", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "domain" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "tds_entity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `displayName` TEXT NOT NULL, `prevalence` REAL NOT NULL, PRIMARY KEY(`name`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "prevalence", + "columnName": "prevalence", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "name" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "tds_domain_entity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`domain` TEXT NOT NULL, `entityName` TEXT NOT NULL, PRIMARY KEY(`domain`))", + "fields": [ + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "entityName", + "columnName": "entityName", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "domain" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "temporary_tracking_whitelist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`domain` TEXT NOT NULL, PRIMARY KEY(`domain`))", + "fields": [ + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "domain" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "user_whitelist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`domain` TEXT NOT NULL, PRIMARY KEY(`domain`))", + "fields": [ + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "domain" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "https_bloom_filter_spec", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `bitCount` INTEGER NOT NULL, `errorRate` REAL NOT NULL, `totalEntries` INTEGER NOT NULL, `sha256` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bitCount", + "columnName": "bitCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "errorRate", + "columnName": "errorRate", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "totalEntries", + "columnName": "totalEntries", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sha256", + "columnName": "sha256", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "https_false_positive_domain", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`domain` TEXT NOT NULL, PRIMARY KEY(`domain`))", + "fields": [ + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "domain" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "network_leaderboard", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkName` TEXT NOT NULL, `count` INTEGER NOT NULL, PRIMARY KEY(`networkName`))", + "fields": [ + { + "fieldPath": "networkName", + "columnName": "networkName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "count", + "columnName": "count", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "networkName" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "sites_visited", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `count` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "count", + "columnName": "count", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "key" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "tabs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tabId` TEXT NOT NULL, `url` TEXT, `title` TEXT, `skipHome` INTEGER NOT NULL, `viewed` INTEGER NOT NULL, `position` INTEGER NOT NULL, `tabPreviewFile` TEXT, `sourceTabId` TEXT, `deletable` INTEGER NOT NULL, PRIMARY KEY(`tabId`), FOREIGN KEY(`sourceTabId`) REFERENCES `tabs`(`tabId`) ON UPDATE SET NULL ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "tabId", + "columnName": "tabId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "skipHome", + "columnName": "skipHome", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewed", + "columnName": "viewed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tabPreviewFile", + "columnName": "tabPreviewFile", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sourceTabId", + "columnName": "sourceTabId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "deletable", + "columnName": "deletable", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "tabId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_tabs_tabId", + "unique": false, + "columnNames": [ + "tabId" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tabs_tabId` ON `${TABLE_NAME}` (`tabId`)" + } + ], + "foreignKeys": [ + { + "table": "tabs", + "onDelete": "SET NULL", + "onUpdate": "SET NULL", + "columns": [ + "sourceTabId" + ], + "referencedColumns": [ + "tabId" + ] + } + ] + }, + { + "tableName": "tab_selection", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `tabId` TEXT, PRIMARY KEY(`id`), FOREIGN KEY(`tabId`) REFERENCES `tabs`(`tabId`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tabId", + "columnName": "tabId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_tab_selection_tabId", + "unique": false, + "columnNames": [ + "tabId" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tab_selection_tabId` ON `${TABLE_NAME}` (`tabId`)" + } + ], + "foreignKeys": [ + { + "table": "tabs", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "tabId" + ], + "referencedColumns": [ + "tabId" + ] + } + ] + }, + { + "tableName": "bookmarks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT, `url` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "favorites", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `url` TEXT NOT NULL, `position` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_favorites_title_url", + "unique": true, + "columnNames": [ + "title", + "url" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_favorites_title_url` ON `${TABLE_NAME}` (`title`, `url`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "survey", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`surveyId` TEXT NOT NULL, `url` TEXT, `daysInstalled` INTEGER, `status` TEXT NOT NULL, PRIMARY KEY(`surveyId`))", + "fields": [ + { + "fieldPath": "surveyId", + "columnName": "surveyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "daysInstalled", + "columnName": "daysInstalled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "surveyId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "dismissed_cta", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`ctaId` TEXT NOT NULL, PRIMARY KEY(`ctaId`))", + "fields": [ + { + "fieldPath": "ctaId", + "columnName": "ctaId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "ctaId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "search_count", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `count` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "count", + "columnName": "count", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "key" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "app_days_used", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`date` TEXT NOT NULL, PRIMARY KEY(`date`))", + "fields": [ + { + "fieldPath": "date", + "columnName": "date", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "date" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "app_enjoyment", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`eventType` INTEGER NOT NULL, `promptCount` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `primaryKey` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", + "fields": [ + { + "fieldPath": "eventType", + "columnName": "eventType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "promptCount", + "columnName": "promptCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "primaryKey", + "columnName": "primaryKey", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "primaryKey" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "notification", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`notificationId` TEXT NOT NULL, PRIMARY KEY(`notificationId`))", + "fields": [ + { + "fieldPath": "notificationId", + "columnName": "notificationId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "notificationId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "privacy_protection_count", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `blocked_tracker_count` INTEGER NOT NULL, `upgrade_count` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "blockedTrackerCount", + "columnName": "blocked_tracker_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "upgradeCount", + "columnName": "upgrade_count", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "key" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "UncaughtExceptionEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `exceptionSource` TEXT NOT NULL, `message` TEXT NOT NULL, `version` TEXT NOT NULL, `timestamp` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "exceptionSource", + "columnName": "exceptionSource", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "tdsMetadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `eTag` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "eTag", + "columnName": "eTag", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "userStage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` INTEGER NOT NULL, `appStage` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "appStage", + "columnName": "appStage", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "key" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "fireproofWebsites", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`domain` TEXT NOT NULL, PRIMARY KEY(`domain`))", + "fields": [ + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "domain" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "user_events", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "locationPermissions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`domain` TEXT NOT NULL, `permission` INTEGER NOT NULL, PRIMARY KEY(`domain`))", + "fields": [ + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "permission", + "columnName": "permission", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "domain" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "pixel_store", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `pixelName` TEXT NOT NULL, `atb` TEXT NOT NULL, `additionalQueryParams` TEXT NOT NULL, `encodedQueryParams` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pixelName", + "columnName": "pixelName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "atb", + "columnName": "atb", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "additionalQueryParams", + "columnName": "additionalQueryParams", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "encodedQueryParams", + "columnName": "encodedQueryParams", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "auth_cookies_allowed_domains", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`domain` TEXT NOT NULL, PRIMARY KEY(`domain`))", + "fields": [ + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "domain" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '7e459183134182e23524e089ee6e638f')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/duckduckgo/app/autocomplete/api/AutoCompleteApiTest.kt b/app/src/androidTest/java/com/duckduckgo/app/autocomplete/api/AutoCompleteApiTest.kt index 1f159d0deb11..47769d330574 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/autocomplete/api/AutoCompleteApiTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/autocomplete/api/AutoCompleteApiTest.kt @@ -19,6 +19,8 @@ package com.duckduckgo.app.autocomplete.api import com.duckduckgo.app.autocomplete.api.AutoComplete.AutoCompleteResult import com.duckduckgo.app.bookmarks.db.BookmarkEntity import com.duckduckgo.app.bookmarks.db.BookmarksDao +import com.duckduckgo.app.bookmarks.model.FavoritesRepository +import com.duckduckgo.app.bookmarks.model.SavedSite.Favorite import com.nhaarman.mockitokotlin2.whenever import io.reactivex.Observable import io.reactivex.Single @@ -36,12 +38,15 @@ class AutoCompleteApiTest { @Mock private lateinit var mockBookmarksDao: BookmarksDao + @Mock + private lateinit var mockFavoritesRepository: FavoritesRepository + private lateinit var testee: AutoCompleteApi @Before fun before() { MockitoAnnotations.openMocks(this) - testee = AutoCompleteApi(mockAutoCompleteService, mockBookmarksDao) + testee = AutoCompleteApi(mockAutoCompleteService, mockBookmarksDao, mockFavoritesRepository) } @Test @@ -56,6 +61,7 @@ class AutoCompleteApiTest { fun whenReturnBookmarkSuggestionsThenPhraseIsURLBaseHost() { whenever(mockAutoCompleteService.autoComplete("title")).thenReturn(Observable.just(emptyList())) whenever(mockBookmarksDao.bookmarksObservable()).thenReturn(Single.just(listOf(BookmarkEntity(0, "title", "https://example.com")))) + whenever(mockFavoritesRepository.favoritesObservable()).thenReturn(Single.just(emptyList())) val result = testee.autoComplete("title").test() val value = result.values()[0] as AutoCompleteResult @@ -64,9 +70,10 @@ class AutoCompleteApiTest { } @Test - fun whenAutoCompleteDoesNotMatchBookmarksReturnEmptyBookmarkList() { + fun whenAutoCompleteDoesNotMatchAnySavedSiteReturnEmptySavedSiteList() { whenever(mockAutoCompleteService.autoComplete("wrong")).thenReturn(Observable.just(emptyList())) whenever(mockBookmarksDao.bookmarksObservable()).thenReturn(Single.just(listOf(BookmarkEntity(0, "title", "https://example.com")))) + whenever(mockFavoritesRepository.favoritesObservable()).thenReturn(Single.just(listOf(favorite(title = "title")))) val result = testee.autoComplete("wrong").test() val value = result.values()[0] as AutoCompleteResult @@ -87,6 +94,7 @@ class AutoCompleteApiTest { ) ) ) + whenever(mockFavoritesRepository.favoritesObservable()).thenReturn(Single.just(emptyList())) val result = testee.autoComplete("title").test() val value = result.values()[0] as AutoCompleteResult @@ -100,6 +108,42 @@ class AutoCompleteApiTest { ) } + @Test + fun whenAutoCompleteReturnsMultipleSavedSitesHitsThenLimitToMaxOfTwoFavoritesFirst() { + whenever(mockAutoCompleteService.autoComplete("title")).thenReturn(Observable.just(emptyList())) + whenever(mockBookmarksDao.bookmarksObservable()).thenReturn( + Single.just( + listOf( + BookmarkEntity(0, "title", "https://example.com"), + BookmarkEntity(0, "title", "https://foo.com"), + BookmarkEntity(0, "title", "https://bar.com"), + BookmarkEntity(0, "title", "https://baz.com") + ) + ) + ) + whenever(mockFavoritesRepository.favoritesObservable()).thenReturn( + Single.just( + listOf( + favorite(title = "title", url = "https://favexample.com"), + favorite(title = "title", url = "https://favfoo.com"), + favorite(title = "title", url = "https://favbar.com"), + favorite(title = "title", url = "https://favbaz.com") + ) + ) + ) + + val result = testee.autoComplete("title").test() + val value = result.values()[0] as AutoCompleteResult + + assertEquals( + listOf( + AutoComplete.AutoCompleteSuggestion.AutoCompleteBookmarkSuggestion(phrase = "favexample.com", "title", "https://favexample.com"), + AutoComplete.AutoCompleteSuggestion.AutoCompleteBookmarkSuggestion(phrase = "favfoo.com", "title", "https://favfoo.com"), + ), + value.suggestions + ) + } + @Test fun whenAutoCompleteReturnsDuplicatedItemsThenDedup() { whenever(mockAutoCompleteService.autoComplete("title")).thenReturn( @@ -122,6 +166,16 @@ class AutoCompleteApiTest { ) ) ) + whenever(mockFavoritesRepository.favoritesObservable()).thenReturn( + Single.just( + listOf( + favorite(title = "title example", url = "https://example.com"), + favorite(title = "title foo", url = "https://foo.com/path/to/foo"), + favorite(title = "title foo", url = "https://foo.com"), + favorite(title = "title bar", url = "https://bar.com") + ) + ) + ) val result = testee.autoComplete("title").test() val value = result.values()[0] as AutoCompleteResult @@ -137,8 +191,26 @@ class AutoCompleteApiTest { value.suggestions ) } + @Test + fun whenReturnOneBookmarkAndOneFavoriteSuggestionsThenShowBothFavoriteFirst() { + whenever(mockAutoCompleteService.autoComplete("title")).thenReturn(Observable.just(emptyList())) + whenever(mockBookmarksDao.bookmarksObservable()).thenReturn(Single.just(listOf(BookmarkEntity(0, "title", "https://example.com")))) + whenever(mockFavoritesRepository.favoritesObservable()).thenReturn(Single.just(listOf(Favorite(0, "title", "https://favexample.com", 1)))) + val result = testee.autoComplete("title").test() + val value = result.values()[0] as AutoCompleteResult + + assertEquals( + listOf( + AutoComplete.AutoCompleteSuggestion.AutoCompleteBookmarkSuggestion(phrase = "favexample.com", "title", "https://favexample.com"), + AutoComplete.AutoCompleteSuggestion.AutoCompleteBookmarkSuggestion(phrase = "example.com", "title", "https://example.com") + ), + value.suggestions + ) + } + + @Test fun whenAutoCompleteReturnsDuplicatedItemsThenDedupConsideringQueryParams() { whenever(mockAutoCompleteService.autoComplete("title")).thenReturn( Observable.just( @@ -159,6 +231,7 @@ class AutoCompleteApiTest { ) ) ) + whenever(mockFavoritesRepository.favoritesObservable()).thenReturn(Single.just(emptyList())) val result = testee.autoComplete("title").test() val value = result.values()[0] as AutoCompleteResult @@ -188,6 +261,7 @@ class AutoCompleteApiTest { ) ) ) + whenever(mockFavoritesRepository.favoritesObservable()).thenReturn(Single.just(emptyList())) val result = testee.autoComplete("title").test() val value = result.values()[0] as AutoCompleteResult @@ -214,6 +288,7 @@ class AutoCompleteApiTest { ) ) ) + whenever(mockFavoritesRepository.favoritesObservable()).thenReturn(Single.just(emptyList())) val result = testee.autoComplete("foo").test() val value = result.values()[0] as AutoCompleteResult @@ -238,6 +313,7 @@ class AutoCompleteApiTest { ) ) ) + whenever(mockFavoritesRepository.favoritesObservable()).thenReturn(Single.just(emptyList())) val result = testee.autoComplete("cnn").test() val value = result.values()[0] as AutoCompleteResult @@ -262,6 +338,7 @@ class AutoCompleteApiTest { ) ) ) + whenever(mockFavoritesRepository.favoritesObservable()).thenReturn(Single.just(emptyList())) val result = testee.autoComplete("reddit.com/").test() val value = result.values()[0] as AutoCompleteResult @@ -286,6 +363,7 @@ class AutoCompleteApiTest { ) ) ) + whenever(mockFavoritesRepository.favoritesObservable()).thenReturn(Single.just(emptyList())) val result = testee.autoComplete("reddit.com///").test() val value = result.values()[0] as AutoCompleteResult @@ -310,6 +388,7 @@ class AutoCompleteApiTest { ) ) ) + whenever(mockFavoritesRepository.favoritesObservable()).thenReturn(Single.just(emptyList())) val result = testee.autoComplete("reddit.com/r//").test() val value = result.values()[0] as AutoCompleteResult @@ -333,6 +412,7 @@ class AutoCompleteApiTest { ) ) ) + whenever(mockFavoritesRepository.favoritesObservable()).thenReturn(Single.just(emptyList())) val result = testee.autoComplete("reddit").test() val value = result.values()[0] as AutoCompleteResult @@ -360,6 +440,7 @@ class AutoCompleteApiTest { ) ) ) + whenever(mockFavoritesRepository.favoritesObservable()).thenReturn(Single.just(emptyList())) val result = testee.autoComplete(query).test() val value = result.values()[0] as AutoCompleteResult @@ -381,6 +462,7 @@ class AutoCompleteApiTest { ) ) ) + whenever(mockFavoritesRepository.favoritesObservable()).thenReturn(Single.just(emptyList())) val result = testee.autoComplete(query).test() val value = result.values()[0] as AutoCompleteResult @@ -393,4 +475,11 @@ class AutoCompleteApiTest { value.suggestions ) } + + private fun favorite( + id: Long = 0, + title: String = "title", + url: String = "https://example.com", + position: Int = 1 + ) = Favorite(id, title, url, position) } diff --git a/app/src/androidTest/java/com/duckduckgo/app/bookmarks/model/FavoritesDataRepositoryTest.kt b/app/src/androidTest/java/com/duckduckgo/app/bookmarks/model/FavoritesDataRepositoryTest.kt new file mode 100644 index 000000000000..2a693aa6241f --- /dev/null +++ b/app/src/androidTest/java/com/duckduckgo/app/bookmarks/model/FavoritesDataRepositoryTest.kt @@ -0,0 +1,163 @@ +/* + * Copyright (c) 2021 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.app.bookmarks.model + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.room.Room +import androidx.test.platform.app.InstrumentationRegistry +import com.duckduckgo.app.CoroutineTestRule +import com.duckduckgo.app.bookmarks.db.FavoriteEntity +import com.duckduckgo.app.bookmarks.db.FavoritesDao +import com.duckduckgo.app.bookmarks.model.SavedSite.Favorite +import com.duckduckgo.app.browser.favicon.FaviconManager +import com.duckduckgo.app.global.db.AppDatabase +import com.duckduckgo.app.runBlocking +import com.nhaarman.mockitokotlin2.mock +import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.junit.Assert.* +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import dagger.Lazy +import org.mockito.Mockito.verify + +@ExperimentalCoroutinesApi +class FavoritesDataRepositoryTest { + @get:Rule + @Suppress("unused") + var instantTaskExecutorRule = InstantTaskExecutorRule() + + @get:Rule + @Suppress("unused") + val coroutineRule = CoroutineTestRule() + + private val mockFaviconManager: FaviconManager = mock() + private val lazyFaviconManager = Lazy { mockFaviconManager } + private lateinit var db: AppDatabase + private lateinit var favoritesDao: FavoritesDao + private lateinit var repository: FavoritesRepository + + @Before + fun before() { + db = Room.inMemoryDatabaseBuilder(InstrumentationRegistry.getInstrumentation().targetContext, AppDatabase::class.java) + .allowMainThreadQueries() + .build() + favoritesDao = db.favoritesDao() + repository = FavoritesDataRepository(favoritesDao, lazyFaviconManager) + } + + @Test + fun whenInsertFavoriteThenReturnSavedSite() { + givenNoFavoritesStored() + + val savedSite = repository.insert("title", "http://example.com") + + assertEquals("title", savedSite.title) + assertEquals("http://example.com", savedSite.url) + assertEquals(1, savedSite.position) + } + + @Test + fun whenInsertFavoriteWithoutTitleThenSavedSiteUsesUrlAsTitle() { + givenNoFavoritesStored() + + val savedSite = repository.insert("", "http://example.com") + + assertEquals("http://example.com", savedSite.title) + assertEquals("http://example.com", savedSite.url) + assertEquals(1, savedSite.position) + } + + @Test + fun whenUserHasFavoritesAndInsertFavoriteThenSavedSiteUsesNextPosition() { + givenMoreFavoritesStored() + + val savedSite = repository.insert("Favorite", "http://favexample.com") + + assertEquals("Favorite", savedSite.title) + assertEquals("http://favexample.com", savedSite.url) + assertEquals(2, savedSite.position) + } + + @Test + fun whenDataSourceChangesThenNewListReceived() { + givenNoFavoritesStored() + + repository.insert("Favorite", "http://favexample.com") + + val testObserver = repository.favoritesObservable().test() + val lastState = testObserver.assertNoErrors().values().last() + assertEquals(1, lastState.size) + assertEquals(Favorite(1, "Favorite", "http://favexample.com", 1), lastState.first()) + } + + @Test + fun whenFavoriteUpdatedThenDatabaseChanged() { + val favorite = Favorite(1, "Favorite", "http://favexample.com", 1) + givenFavorite(favorite) + val updatedFavorite = favorite.copy(position = 3) + + repository.update(updatedFavorite) + + assertFavoriteExistsInDb(updatedFavorite) + } + + @Test + fun whenListReceivedThenUpdateItemsWithNewPositionInDatabase() { + val favorite = Favorite(1, "Favorite", "http://favexample.com", 1) + val favorite2 = Favorite(2, "Favorite2", "http://favexample2.com", 2) + givenFavorite(favorite, favorite2) + + repository.updateWithPosition(listOf(favorite2, favorite)) + + assertFavoriteExistsInDb(favorite2.copy(position = 0)) + assertFavoriteExistsInDb(favorite.copy(position = 1)) + } + + @Test + fun whenFavoriteDeletedThenDatabaseUpdated() = coroutineRule.runBlocking { + val favorite = Favorite(1, "Favorite", "http://favexample.com", 1) + givenFavorite(favorite) + + repository.delete(favorite) + + assertNull(favoritesDao.favorite(favorite.id)) + verify(mockFaviconManager).deletePersistedFavicon(favorite.url) + } + + private fun givenFavorite(vararg favorite: Favorite) { + favorite.forEach { + favoritesDao.insert(FavoriteEntity(it.id, it.title, it.url, it.position)) + } + } + + private fun givenMoreFavoritesStored() { + favoritesDao.insert(FavoriteEntity(title = "title", url = "http://example.com", position = 0)) + favoritesDao.insert(FavoriteEntity(title = "title 2", url = "http://other.com", position = 1)) + } + + private fun givenNoFavoritesStored() { + assertNull(favoritesDao.getLastPosition()) + } + + private fun assertFavoriteExistsInDb(favorite: Favorite) { + val storedFavorite = favoritesDao.favorite(favorite.id) ?: error("Favorite not found in database") + assertEquals(storedFavorite.title, favorite.title) + assertEquals(storedFavorite.url, favorite.url) + assertEquals(storedFavorite.position, favorite.position) + } +} diff --git a/app/src/androidTest/java/com/duckduckgo/app/bookmarks/ui/BookmarksViewModelTest.kt b/app/src/androidTest/java/com/duckduckgo/app/bookmarks/ui/BookmarksViewModelTest.kt index dbd184ea4522..a219656bcddd 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/bookmarks/ui/BookmarksViewModelTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/bookmarks/ui/BookmarksViewModelTest.kt @@ -23,6 +23,8 @@ import com.duckduckgo.app.CoroutineTestRule import com.duckduckgo.app.InstantSchedulersRule import com.duckduckgo.app.bookmarks.db.BookmarkEntity import com.duckduckgo.app.bookmarks.db.BookmarksDao +import com.duckduckgo.app.bookmarks.model.FavoritesRepository +import com.duckduckgo.app.bookmarks.model.SavedSite import com.duckduckgo.app.bookmarks.service.BookmarksManager import com.duckduckgo.app.browser.favicon.FaviconManager import com.duckduckgo.app.runBlocking @@ -30,9 +32,10 @@ import com.nhaarman.mockitokotlin2.mock import com.nhaarman.mockitokotlin2.verify import com.nhaarman.mockitokotlin2.whenever import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf import org.junit.After -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertTrue +import org.junit.Assert.* import org.junit.Before import org.junit.Rule import org.junit.Test @@ -53,26 +56,32 @@ class BookmarksViewModelTest { @Suppress("unused") val coroutineRule = CoroutineTestRule() + private val captor: ArgumentCaptor = ArgumentCaptor.forClass(BookmarksViewModel.Command::class.java) + private val commandObserver: Observer = mock() + private val liveData = MutableLiveData>() private val viewStateObserver: Observer = mock() - private val commandObserver: Observer = mock() private val bookmarksDao: BookmarksDao = mock() + private val favoritesRepository: FavoritesRepository = mock() private val faviconManager: FaviconManager = mock() private val bookmarksManager: BookmarksManager = mock() - private val bookmark = BookmarkEntity(title = "title", url = "www.example.com") + private val bookmark = SavedSite.Bookmark(id = 0, title = "title", url = "www.example.com") + private val favorite = SavedSite.Favorite(id = 0, title = "title", url = "www.example.com", position = 0) + private val bookmarkEntity = BookmarkEntity(id = bookmark.id, title = bookmark.title, url = bookmark.url) private val testee: BookmarksViewModel by lazy { - val model = BookmarksViewModel(bookmarksDao, faviconManager, bookmarksManager, coroutineRule.testDispatcherProvider) + val model = BookmarksViewModel(favoritesRepository, bookmarksDao, faviconManager, bookmarksManager, coroutineRule.testDispatcherProvider) model.viewState.observeForever(viewStateObserver) model.command.observeForever(commandObserver) model } @Before - fun before() { + fun before() = coroutineRule.runBlocking { liveData.value = emptyList() whenever(bookmarksDao.getBookmarks()).thenReturn(liveData) + whenever(favoritesRepository.favorites()).thenReturn(flowOf()) } @After @@ -82,27 +91,64 @@ class BookmarksViewModelTest { } @Test - fun whenBookmarkDeletedThenDaoUpdated() { - testee.delete(bookmark) - verify(bookmarksDao).delete(bookmark) + fun whenBookmarkInsertedThenDaoUpdated() { + testee.insert(bookmark) + + verify(bookmarksDao).insert(bookmarkEntity) + } + + @Test + fun whenFavoriteInsertedThenRepositoryUpdated() = coroutineRule.runBlocking { + testee.insert(favorite) + + verify(favoritesRepository).insert(favorite) + } + + @Test + fun whenBookmarkDeleteRequestedThenDaoUpdated() = coroutineRule.runBlocking { + testee.onDeleteSavedSiteRequested(bookmark) + + verify(faviconManager).deletePersistedFavicon(bookmark.url) + verify(bookmarksDao).delete(bookmarkEntity) + } + + @Test + fun whenFavoriteDeleteRequestedThenDeleteFromRepository() = coroutineRule.runBlocking { + testee.onDeleteSavedSiteRequested(favorite) + + verify(favoritesRepository).delete(favorite) } @Test - fun whenBookmarkSelectedThenOpenCommand() { + fun whenBookmarkEditedThenDaoUpdated() = coroutineRule.runBlocking { + testee.onSavedSiteEdited(bookmark) + + verify(bookmarksDao).update(bookmarkEntity) + } + + @Test + fun whenFavoriteEditedThenRepositoryUpdated() = coroutineRule.runBlocking { + testee.onSavedSiteEdited(favorite) + + verify(favoritesRepository).update(favorite) + } + + @Test + fun whenSavedSiteSelectedThenOpenCommand() { testee.onSelected(bookmark) - val captor: ArgumentCaptor = ArgumentCaptor.forClass(BookmarksViewModel.Command::class.java) + verify(commandObserver).onChanged(captor.capture()) assertNotNull(captor.value) - assertTrue(captor.value is BookmarksViewModel.Command.OpenBookmark) + assertTrue(captor.value is BookmarksViewModel.Command.OpenSavedSite) } @Test fun whenDeleteRequestedThenConfirmCommand() { - testee.onDeleteRequested(bookmark) - val captor: ArgumentCaptor = ArgumentCaptor.forClass(BookmarksViewModel.Command::class.java) + testee.onDeleteSavedSiteRequested(bookmark) + verify(commandObserver).onChanged(captor.capture()) assertNotNull(captor.value) - assertTrue(captor.value is BookmarksViewModel.Command.ConfirmDeleteBookmark) + assertTrue(captor.value is BookmarksViewModel.Command.ConfirmDeleteSavedSite) } @Test @@ -115,14 +161,17 @@ class BookmarksViewModelTest { } @Test - fun whenBookmarkDeletedThenFaviconDeleted() = coroutineRule.runBlocking { - testee.delete(bookmark) - verify(faviconManager).deletePersistedFavicon(bookmark.url) - } - - @Test - fun whenBookmarkInsertedThenDaoUpdated() { - testee.insert(bookmark) - verify(bookmarksDao).insert(bookmark) + fun whenFavoritesChangedThenObserverNotified() = coroutineRule.runBlocking { + whenever(favoritesRepository.favorites()).thenReturn( + flow { + emit(emptyList()) + emit(listOf(favorite)) + } + ) + testee + val captor: ArgumentCaptor = ArgumentCaptor.forClass(BookmarksViewModel.ViewState::class.java) + verify(viewStateObserver).onChanged(captor.capture()) + assertNotNull(captor.value) + assertEquals(1, captor.value.favorites.size) } } diff --git a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt index b6698832f34d..abff8f51dbb5 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt @@ -38,6 +38,9 @@ import com.duckduckgo.app.autocomplete.api.AutoCompleteApi import com.duckduckgo.app.autocomplete.api.AutoCompleteService import com.duckduckgo.app.bookmarks.db.BookmarkEntity import com.duckduckgo.app.bookmarks.db.BookmarksDao +import com.duckduckgo.app.bookmarks.model.FavoritesRepository +import com.duckduckgo.app.bookmarks.model.SavedSite +import com.duckduckgo.app.bookmarks.model.SavedSite.Favorite import com.duckduckgo.app.browser.BrowserTabViewModel.Command import com.duckduckgo.app.browser.BrowserTabViewModel.Command.Navigate import com.duckduckgo.app.browser.BrowserTabViewModel.FireButton @@ -46,6 +49,8 @@ import com.duckduckgo.app.browser.LongPressHandler.RequiredAction.OpenInNewTab import com.duckduckgo.app.browser.addtohome.AddToHomeCapabilityDetector import com.duckduckgo.app.browser.downloader.FileDownloader import com.duckduckgo.app.browser.favicon.FaviconManager +import com.duckduckgo.app.browser.favicon.FaviconSource +import com.duckduckgo.app.browser.favorites.FavoritesQuickAccessAdapter.QuickAccessFavorite import com.duckduckgo.app.browser.logindetection.FireproofDialogsEventHandler import com.duckduckgo.app.browser.logindetection.LoginDetected import com.duckduckgo.app.browser.logindetection.NavigationAwareLoginDetector @@ -141,8 +146,7 @@ import org.mockito.ArgumentMatchers.anyString import org.mockito.Captor import org.mockito.Mock import org.mockito.Mockito -import org.mockito.Mockito.never -import org.mockito.Mockito.verify +import org.mockito.Mockito.* import org.mockito.MockitoAnnotations import org.mockito.internal.util.DefaultMockingDetails import java.io.File @@ -256,6 +260,9 @@ class BrowserTabViewModelTest { @Mock private lateinit var mockEmailManager: EmailManager + @Mock + private lateinit var mockFavoritesRepository: FavoritesRepository + private val lazyFaviconManager = Lazy { mockFaviconManager } private lateinit var mockAutoCompleteApi: AutoCompleteApi @@ -296,12 +303,13 @@ class BrowserTabViewModelTest { fireproofWebsiteDao = db.fireproofWebsiteDao() locationPermissionsDao = db.locationPermissionsDao() - mockAutoCompleteApi = AutoCompleteApi(mockAutoCompleteService, mockBookmarksDao) + mockAutoCompleteApi = AutoCompleteApi(mockAutoCompleteService, mockBookmarksDao, mockFavoritesRepository) val fireproofWebsiteRepository = FireproofWebsiteRepository(fireproofWebsiteDao, coroutineRule.testDispatcherProvider, lazyFaviconManager) whenever(mockDismissedCtaDao.dismissedCtas()).thenReturn(dismissedCtaDaoChannel.consumeAsFlow()) whenever(mockTabRepository.flowTabs).thenReturn(flowOf(emptyList())) whenever(mockEmailManager.signedInFlow()).thenReturn(emailStateFlow.asStateFlow()) + whenever(mockFavoritesRepository.favorites()).thenReturn(flowOf()) ctaViewModel = CtaViewModel( mockAppInstallStore, @@ -364,7 +372,8 @@ class BrowserTabViewModelTest { fileDownloader = mockFileDownloader, globalPrivacyControl = GlobalPrivacyControlManager(mockSettingsStore), fireproofDialogsEventHandler = fireproofDialogsEventHandler, - emailManager = mockEmailManager + emailManager = mockEmailManager, + favoritesRepository = mockFavoritesRepository ) testee.loadData("abc", null, false) @@ -486,29 +495,39 @@ class BrowserTabViewModelTest { } @Test - fun whenBrowsingAndUrlPresentThenAddBookmarkButtonEnabled() { + fun whenBrowsingAndUrlPresentThenAddBookmarkFavoriteButtonsEnabled() { loadUrl("www.example.com", isBrowserShowing = true) assertTrue(browserViewState().canAddBookmarks) + assertTrue(browserViewState().canAddFavorite) } @Test - fun whenBrowsingAndNoUrlThenAddBookmarkButtonDisabled() { + fun whenBrowsingAndNoUrlThenAddBookmarkFavoriteButtonsDisabled() { loadUrl(null, isBrowserShowing = true) assertFalse(browserViewState().canAddBookmarks) + assertFalse(browserViewState().canAddFavorite) } @Test - fun whenNotBrowsingAndUrlPresentThenAddBookmarkButtonDisabled() { + fun whenNotBrowsingAndUrlPresentThenAddBookmarkFavoriteButtonsDisabled() { loadUrl("www.example.com", isBrowserShowing = false) assertFalse(browserViewState().canAddBookmarks) + assertFalse(browserViewState().canAddFavorite) } @Test fun whenBookmarkEditedThenDaoIsUpdated() = coroutineRule.runBlocking { - testee.editBookmark(0, "A title", "www.example.com") + testee.onSavedSiteEdited(SavedSite.Bookmark(0, "A title", "www.example.com")) verify(mockBookmarksDao).update(BookmarkEntity(title = "A title", url = "www.example.com")) } + @Test + fun whenFavoriteEditedThenRepositoryUpdated() = coroutineRule.runBlocking { + val favorite = Favorite(0, "A title", "www.example.com", 1) + testee.onSavedSiteEdited(favorite) + verify(mockFavoritesRepository).update(favorite) + } + @Test fun whenBookmarkAddedThenDaoIsUpdatedAndUserNotified() = coroutineRule.runBlocking { loadUrl("www.example.com", "A title") @@ -516,7 +535,66 @@ class BrowserTabViewModelTest { testee.onBookmarkAddRequested() verify(mockBookmarksDao).insert(BookmarkEntity(title = "A title", url = "www.example.com")) verify(mockCommandObserver, atLeastOnce()).onChanged(commandCaptor.capture()) - assertTrue(commandCaptor.lastValue is Command.ShowBookmarkAddedConfirmation) + assertTrue(commandCaptor.lastValue is Command.ShowSavedSiteAddedConfirmation) + } + + @Test + fun whenFavoriteAddedThenRepositoryUpdatedAndUserNotified() = coroutineRule.runBlocking { + val savedSite = Favorite(1, "title", "http://example.com", 0) + loadUrl("www.example.com", "A title") + whenever(mockFavoritesRepository.insert(any(), any())).thenReturn(savedSite) + + testee.onAddFavoriteMenuClicked() + + verify(mockFavoritesRepository).insert(title = "A title", url = "www.example.com") + assertCommandIssued() + } + + @Test + fun whenNoSiteAndUserSelectsToAddFavoriteThenSiteIsNotAdded() = coroutineRule.runBlocking { + + testee.onAddFavoriteMenuClicked() + + verify(mockFavoritesRepository, times(0)).insert(any(), any()) + } + + @Test + fun whenQuickAccessItemClickedThenSubmitNewQuery() { + val savedSite = Favorite(1, "title", "http://example.com", 0) + + testee.onQuickAccesItemClicked(savedSite) + + assertCommandIssued { + assertEquals("http://example.com", this.url) + } + } + + @Test + fun whenQuickAccessDeletedThenRepositoryUpdated() = coroutineRule.runBlocking { + val savedSite = Favorite(1, "title", "http://example.com", 0) + + testee.deleteQuickAccessItem(savedSite) + + verify(mockFavoritesRepository).delete(savedSite) + } + + @Test + fun whenQuickAccessInsertedThenRepositoryUpdated() { + val savedSite = Favorite(1, "title", "http://example.com", 0) + + testee.insertQuickAccessItem(savedSite) + + verify(mockFavoritesRepository).insert(savedSite) + } + + @Test + fun whenQuickAccessListChangedThenRepositoryUpdated() { + val savedSite = Favorite(1, "title", "http://example.com", 0) + val savedSites = listOf(QuickAccessFavorite(savedSite)) + + testee.onQuickAccessListChanged(savedSites) + + verify(mockFavoritesRepository).updateWithPosition(listOf(savedSite)) } @Test @@ -1001,6 +1079,15 @@ class BrowserTabViewModelTest { assertFalse(autoCompleteViewState().showSuggestions) } + @Test + fun whenOmnibarFocusedWithUrlAndUserHasFavoritesThenAutoCompleteShowsFavorites() { + testee.autoCompleteViewState.value = autoCompleteViewState().copy(favorites = listOf(QuickAccessFavorite(Favorite(1, "title", "http://example.com", 1)))) + doReturn(true).whenever(mockSettingsStore).autoCompleteSuggestionsEnabled + testee.onOmnibarInputStateChanged("https://example.com", true, hasQueryChanged = false) + assertFalse(autoCompleteViewState().showSuggestions) + assertTrue(autoCompleteViewState().showFavorites) + } + @Test fun whenEnteringQueryWithAutoCompleteDisabledThenAutoCompleteSuggestionsNotShown() { doReturn(false).whenever(mockSettingsStore).autoCompleteSuggestionsEnabled @@ -1376,20 +1463,18 @@ class BrowserTabViewModelTest { loadUrl("foo.com") testee.titleReceived("Foo Title") testee.onBookmarkAddRequested() - val command = captureCommands().value as Command.ShowBookmarkAddedConfirmation - assertEquals("foo.com", command.url) - assertEquals("Foo Title", command.title) + val command = captureCommands().value as Command.ShowSavedSiteAddedConfirmation + assertEquals("foo.com", command.savedSite.url) + assertEquals("Foo Title", command.savedSite.title) } @Test - fun whenNoSiteAndUserSelectsToAddBookmarkThenBookmarkAddedWithBlankTitleAndUrl() = coroutineRule.runBlocking { + fun whenNoSiteAndUserSelectsToAddBookmarkThenBookmarkIsNotAdded() = coroutineRule.runBlocking { whenever(mockBookmarksDao.insert(any())).thenReturn(1) + testee.onBookmarkAddRequested() - verify(mockBookmarksDao).insert(BookmarkEntity(title = "", url = "")) - val command = captureCommands().value as Command.ShowBookmarkAddedConfirmation - assertEquals(1, command.bookmarkId) - assertEquals("", command.title) - assertEquals("", command.url) + + verify(mockBookmarksDao, times(0)).insert(any()) } @Test @@ -1673,7 +1758,8 @@ class BrowserTabViewModelTest { @Test fun whenUserSelectToEditQueryThenMoveCaretToTheEnd() = coroutineRule.runBlocking { testee.onUserSelectedToEditQuery("foo") - assertTrue(omnibarViewState().shouldMoveCaretToEnd) + + assertCommandIssued() } @Test @@ -2138,10 +2224,11 @@ class BrowserTabViewModelTest { } @Test - fun whenUserBrowsingPressesBackThenCannotAddBookmark() { + fun whenUserBrowsingPressesBackThenCannotAddBookmarkOrFavorite() { setupNavigation(skipHome = false, isBrowsing = true, canGoBack = false) assertTrue(testee.onUserPressedBack()) assertFalse(browserViewState().canAddBookmarks) + assertFalse(browserViewState().canAddFavorite) } @Test @@ -2194,11 +2281,12 @@ class BrowserTabViewModelTest { } @Test - fun whenUserBrowsingPressesBackAndForwardThenCanAddBookmark() { + fun whenUserBrowsingPressesBackAndForwardThenCanAddBookmarkOrFavorite() { setupNavigation(skipHome = false, isBrowsing = true, canGoBack = false) testee.onUserPressedBack() testee.onUserPressedForward() assertTrue(browserViewState().canAddBookmarks) + assertTrue(browserViewState().canAddFavorite) } @Test @@ -2658,12 +2746,12 @@ class BrowserTabViewModelTest { } @Test - fun whenPrefetchFaviconThenPrefetchToTemp() = coroutineRule.runBlocking { + fun whenPrefetchFaviconThenFetchFaviconForCurrentTab() = coroutineRule.runBlocking { val url = "https://www.example.com/" givenCurrentSite(url) testee.prefetchFavicon(url) - verify(mockFaviconManager).prefetchToTemp("TAB_ID", url) + verify(mockFaviconManager).tryFetchFaviconForUrl("TAB_ID", url) } @Test @@ -2671,7 +2759,7 @@ class BrowserTabViewModelTest { val url = "https://www.example.com/" val file = File("test") givenCurrentSite(url) - whenever(mockFaviconManager.prefetchToTemp(any(), any())).thenReturn(file) + whenever(mockFaviconManager.tryFetchFaviconForUrl(any(), any())).thenReturn(file) testee.prefetchFavicon(url) @@ -2680,7 +2768,7 @@ class BrowserTabViewModelTest { @Test fun whenPrefetchFaviconAndFaviconDoesNotExistThenDoNotCallUpdateTabFavicon() = coroutineRule.runBlocking { - whenever(mockFaviconManager.prefetchToTemp(any(), any())).thenReturn(null) + whenever(mockFaviconManager.tryFetchFaviconForUrl(any(), any())).thenReturn(null) testee.prefetchFavicon("url") @@ -2688,13 +2776,13 @@ class BrowserTabViewModelTest { } @Test - fun whenIconReceivedThenSaveToTemp() = coroutineRule.runBlocking { + fun whenIconReceivedThenStoreFavicon() = coroutineRule.runBlocking { givenOneActiveTabSelected() val bitmap: Bitmap = Bitmap.createBitmap(1, 1, Bitmap.Config.RGB_565) testee.iconReceived("https://example.com", bitmap) - verify(mockFaviconManager).saveToTemp("TAB_ID", bitmap, "https://example.com") + verify(mockFaviconManager).storeFavicon("TAB_ID", FaviconSource.ImageFavicon(bitmap, "https://example.com")) } @Test @@ -2702,7 +2790,7 @@ class BrowserTabViewModelTest { givenOneActiveTabSelected() val bitmap: Bitmap = Bitmap.createBitmap(1, 1, Bitmap.Config.RGB_565) val file = File("test") - whenever(mockFaviconManager.saveToTemp(any(), any(), any())).thenReturn(file) + whenever(mockFaviconManager.storeFavicon(any(), any())).thenReturn(file) testee.iconReceived("https://example.com", bitmap) @@ -2713,7 +2801,7 @@ class BrowserTabViewModelTest { fun whenIconReceivedIfNotCorrectlySavedThenDoNotUpdateTabFavicon() = coroutineRule.runBlocking { givenOneActiveTabSelected() val bitmap: Bitmap = Bitmap.createBitmap(1, 1, Bitmap.Config.RGB_565) - whenever(mockFaviconManager.saveToTemp(any(), any(), any())).thenReturn(null) + whenever(mockFaviconManager.storeFavicon(any(), any())).thenReturn(null) testee.iconReceived("https://example.com", bitmap) @@ -2721,11 +2809,11 @@ class BrowserTabViewModelTest { } @Test - fun whenIconReceivedFromPreviousUrkThenDontUpdateTabFavicon() = coroutineRule.runBlocking { + fun whenIconReceivedFromPreviousUrlThenDontUpdateTabFavicon() = coroutineRule.runBlocking { givenOneActiveTabSelected() val bitmap: Bitmap = Bitmap.createBitmap(1, 1, Bitmap.Config.RGB_565) val file = File("test") - whenever(mockFaviconManager.saveToTemp(any(), any(), any())).thenReturn(file) + whenever(mockFaviconManager.storeFavicon(any(), any())).thenReturn(file) testee.iconReceived("https://notexample.com", bitmap) @@ -2733,6 +2821,49 @@ class BrowserTabViewModelTest { verify(mockTabRepository, never()).updateTabFavicon("TAB_ID", file.name) } + @Test + fun whenUrlIconReceivedThenStoreFavicon() = coroutineRule.runBlocking { + givenOneActiveTabSelected() + + testee.iconReceived("https://example.com", "https://example.com/favicon.png") + + verify(mockFaviconManager).storeFavicon("TAB_ID", FaviconSource.UrlFavicon("https://example.com/favicon.png", "https://example.com")) + } + + @Test + fun whenUrlIconReceivedIfCorrectlySavedThenUpdateTabFavicon() = coroutineRule.runBlocking { + givenOneActiveTabSelected() + val file = File("test") + whenever(mockFaviconManager.storeFavicon(any(), any())).thenReturn(file) + + testee.iconReceived("https://example.com", "https://example.com/favicon.png") + + verify(mockTabRepository).updateTabFavicon("TAB_ID", file.name) + } + + @Test + fun whenUrlIconReceivedIfNotCorrectlySavedThenDoNotUpdateTabFavicon() = coroutineRule.runBlocking { + givenOneActiveTabSelected() + whenever(mockFaviconManager.storeFavicon(any(), any())).thenReturn(null) + + testee.iconReceived("https://example.com", "https://example.com/favicon.png") + + verify(mockTabRepository, never()).updateTabFavicon(any(), any()) + } + + @Test + fun whenUrlIconReceivedFromPreviousUrlThenDontUpdateTabFavicon() = coroutineRule.runBlocking { + givenOneActiveTabSelected() + val bitmap: Bitmap = Bitmap.createBitmap(1, 1, Bitmap.Config.RGB_565) + val file = File("test") + whenever(mockFaviconManager.storeFavicon(any(), any())).thenReturn(file) + + testee.iconReceived("https://notexample.com", "https://example.com/favicon.png") + + verify(mockPixel).enqueueFire(AppPixelName.FAVICON_WRONG_URL_ERROR) + verify(mockFaviconManager, never()).storeFavicon(any(), any()) + } + @Test fun whenOnSiteLocationPermissionSelectedAndPermissionIsAllowAlwaysThenPersistFavicon() = coroutineRule.runBlocking { val url = "http://example.com" @@ -2741,7 +2872,7 @@ class BrowserTabViewModelTest { testee.onSiteLocationPermissionSelected(url, permission) - verify(mockFaviconManager).persistFavicon(any(), eq(url)) + verify(mockFaviconManager).persistCachedFavicon(any(), eq(url)) } @Test @@ -2752,7 +2883,7 @@ class BrowserTabViewModelTest { testee.onSiteLocationPermissionSelected(url, permission) - verify(mockFaviconManager).persistFavicon(any(), eq(url)) + verify(mockFaviconManager).persistCachedFavicon(any(), eq(url)) } @Test @@ -2762,7 +2893,7 @@ class BrowserTabViewModelTest { testee.onSystemLocationPermissionNeverAllowed() - verify(mockFaviconManager).persistFavicon(any(), eq(url)) + verify(mockFaviconManager).persistCachedFavicon(any(), eq(url)) } @Test @@ -2772,7 +2903,7 @@ class BrowserTabViewModelTest { testee.onBookmarkAddRequested() - verify(mockFaviconManager).persistFavicon(any(), eq(url)) + verify(mockFaviconManager).persistCachedFavicon(any(), eq(url)) } @Test @@ -2781,7 +2912,7 @@ class BrowserTabViewModelTest { testee.onBookmarkAddRequested() - verify(mockFaviconManager, never()).persistFavicon(any(), any()) + verify(mockFaviconManager, never()).persistCachedFavicon(any(), any()) } @Test @@ -2792,7 +2923,7 @@ class BrowserTabViewModelTest { testee.onFireproofWebsiteMenuClicked() assertCommandIssued { - verify(mockFaviconManager).persistFavicon(any(), eq(this.fireproofWebsiteEntity.domain)) + verify(mockFaviconManager).persistCachedFavicon(any(), eq(this.fireproofWebsiteEntity.domain)) } } @@ -2800,7 +2931,7 @@ class BrowserTabViewModelTest { fun whenOnPinPageToHomeSelectedThenAddHomeShortcutCommandIssuedWithFavicon() = coroutineRule.runBlocking { val url = "http://example.com" val bitmap: Bitmap = Bitmap.createBitmap(1, 1, Bitmap.Config.RGB_565) - whenever(mockFaviconManager.loadFromTemp(any(), any())).thenReturn(bitmap) + whenever(mockFaviconManager.loadFromDisk(any(), any())).thenReturn(bitmap) loadUrl(url, "A title") testee.onPinPageToHomeSelected() @@ -2815,7 +2946,7 @@ class BrowserTabViewModelTest { @Test fun whenOnPinPageToHomeSelectedAndFaviconDoesNotExistThenAddHomeShortcutCommandIssuedWithoutFavicon() = coroutineRule.runBlocking { val url = "http://example.com" - whenever(mockFaviconManager.loadFromTemp(any(), any())).thenReturn(null) + whenever(mockFaviconManager.loadFromDisk(any(), any())).thenReturn(null) loadUrl(url, "A title") testee.onPinPageToHomeSelected() diff --git a/app/src/androidTest/java/com/duckduckgo/app/browser/favicon/DuckDuckGoFaviconManagerTest.kt b/app/src/androidTest/java/com/duckduckgo/app/browser/favicon/DuckDuckGoFaviconManagerTest.kt index cc9ac6082ef7..2f8c1b272fe6 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/browser/favicon/DuckDuckGoFaviconManagerTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/favicon/DuckDuckGoFaviconManagerTest.kt @@ -16,12 +16,15 @@ package com.duckduckgo.app.browser.favicon +import android.content.Context import android.graphics.Bitmap import android.widget.ImageView import androidx.core.net.toUri +import androidx.test.annotation.UiThreadTest import androidx.test.platform.app.InstrumentationRegistry import com.duckduckgo.app.CoroutineTestRule import com.duckduckgo.app.bookmarks.db.BookmarksDao +import com.duckduckgo.app.bookmarks.model.FavoritesRepository import com.duckduckgo.app.browser.favicon.FileBasedFaviconPersister.Companion.FAVICON_PERSISTED_DIR import com.duckduckgo.app.browser.favicon.FileBasedFaviconPersister.Companion.FAVICON_TEMP_DIR import com.duckduckgo.app.browser.favicon.FileBasedFaviconPersister.Companion.NO_SUBFOLDER @@ -31,12 +34,7 @@ import com.duckduckgo.app.global.faviconLocation import com.duckduckgo.app.location.data.LocationPermissionsDao import com.duckduckgo.app.location.data.LocationPermissionsRepository import com.duckduckgo.app.runBlocking -import com.nhaarman.mockitokotlin2.any -import com.nhaarman.mockitokotlin2.eq -import com.nhaarman.mockitokotlin2.mock -import com.nhaarman.mockitokotlin2.never -import com.nhaarman.mockitokotlin2.verify -import com.nhaarman.mockitokotlin2.whenever +import com.nhaarman.mockitokotlin2.* import kotlinx.coroutines.ExperimentalCoroutinesApi import org.junit.Assert.assertNull import org.junit.Before @@ -52,155 +50,122 @@ class DuckDuckGoFaviconManagerTest { private val mockFaviconPersister: FaviconPersister = mock() private val mockBookmarksDao: BookmarksDao = mock() + private val mockFavoriteRepository: FavoritesRepository = mock() private val mockFireproofWebsiteDao: FireproofWebsiteDao = mock() private val mockLocationPermissionsDao: LocationPermissionsDao = mock() private val mockFaviconDownloader: FaviconDownloader = mock() private val mockFile: File = File("test") + private val context: Context = InstrumentationRegistry.getInstrumentation().targetContext private lateinit var testee: FaviconManager @Before fun setup() { + whenever(mockFavoriteRepository.favoritesCountByDomain(any())).thenReturn(0) testee = DuckDuckGoFaviconManager( mockFaviconPersister, mockBookmarksDao, FireproofWebsiteRepository(mockFireproofWebsiteDao, coroutineRule.testDispatcherProvider, mock()), LocationPermissionsRepository(mockLocationPermissionsDao, mock(), coroutineRule.testDispatcherProvider), + mockFavoriteRepository, mockFaviconDownloader, coroutineRule.testDispatcherProvider ) } @Test - fun whenLoadFromTempIfFileExistsThenGetFaviconFromDisk() = coroutineRule.runBlocking { + fun whenLoadFromDiskIfFileExistsInTempThenGetFaviconFromDisk() = coroutineRule.runBlocking { givenFaviconExistsInTemp() - testee.loadFromTemp("subfolder", "example.com") + testee.loadFromDisk("subfolder", "example.com") verify(mockFaviconDownloader).getFaviconFromDisk(any()) } @Test - fun whenLoadFromTempIfFileDoesNotExistThenDoNothing() = coroutineRule.runBlocking { - testee.loadFromTemp("subfolder", "example.com") - - verify(mockFaviconDownloader, never()).getFaviconFromDisk(any()) - } - - @Test - fun whenLoadToViewFromPersistedThenLoadView() = coroutineRule.runBlocking { + fun whenLoadFromDiskIfFileExistsInPersistedThenGetFaviconFromDisk() = coroutineRule.runBlocking { givenFaviconExistsInDirectory(FAVICON_PERSISTED_DIR) - val view: ImageView = mock() - testee.loadToViewFromPersisted("example.com", view) + testee.loadFromDisk("subfolder", "example.com") - verify(mockFaviconDownloader).loadFaviconToView(mockFile, view) + verify(mockFaviconDownloader).getFaviconFromDisk(any()) } @Test - fun whenLoadToViewFromPersistedIfCannotFindFaviconThenDownloadFromUrl() = coroutineRule.runBlocking { - val view: ImageView = mock() - val url = "https://example.com" + fun whenLoadFromDiskIfFileDoesNotExistThenDoNothing() = coroutineRule.runBlocking { + testee.loadFromDisk("subfolder", "example.com") - testee.loadToViewFromPersisted(url, view) - - verify(mockFaviconDownloader).getFaviconFromUrl(url.toUri().faviconLocation()!!) + verify(mockFaviconDownloader, never()).getFaviconFromDisk(any()) } - @Test - fun whenLoadToViewFromPersistedIfCannotFindFaviconThenLoadDefaultFaviconIntoView() = coroutineRule.runBlocking { - val view: ImageView = mock() + @Test @UiThreadTest + fun whenLoadToViewFromLocalOrFallbackIfCannotFindFaviconThenDownloadFromUrl() = coroutineRule.runBlocking { + val view = ImageView(context) val url = "https://example.com" - testee.loadToViewFromPersisted(url, view) - - verify(mockFaviconDownloader).loadDefaultFaviconToView(view) - } - - @Test - fun whenLoadToViewFromTempThenLoadView() = coroutineRule.runBlocking { - givenFaviconExistsInDirectory(FAVICON_TEMP_DIR) - val view: ImageView = mock() - - testee.loadToViewFromTemp("subFolder", "example.com", view) + testee.loadToViewFromLocalOrFallback(url = url, view = view) - verify(mockFaviconDownloader).loadFaviconToView(mockFile, view) + verify(mockFaviconDownloader).getFaviconFromUrl(url.toUri().faviconLocation()!!) } - @Test - fun whenLoadToViewFromTempIfCannotFindFaviconThenDownloadFromUrl() = coroutineRule.runBlocking { - val view: ImageView = mock() + @Test @UiThreadTest + fun whenLoadToViewFromLocalOrFallbackWithTabIdIfCannotFindFaviconThenDownloadFromUrl() = coroutineRule.runBlocking { + val view = ImageView(context) val url = "https://example.com" - testee.loadToViewFromTemp("subFolder", url, view) + testee.loadToViewFromLocalOrFallback("subFolder", "example.com", view) verify(mockFaviconDownloader).getFaviconFromUrl(url.toUri().faviconLocation()!!) } @Test - fun whenLoadToViewFromTempIfCannotFindFaviconThenLoadDefaultFaviconIntoView() = coroutineRule.runBlocking { - val view = ImageView(InstrumentationRegistry.getInstrumentation().targetContext) - - testee.loadToViewFromTemp("subFolder", "example.com", view) - - verify(mockFaviconDownloader).loadDefaultFaviconToView(view) - } - - @Test - fun whenPrefetchToTempThenGetFaviconFromUrlAndStoreFile() = coroutineRule.runBlocking { + fun whenTryFetchFaviconForUrlThenGetFaviconFromUrlAndStoreFile() = coroutineRule.runBlocking { val bitmap = asBitmap() val url = "https://example.com" whenever(mockFaviconDownloader.getFaviconFromUrl(url.toUri().faviconLocation()!!)).thenReturn(bitmap) - testee.prefetchToTemp("subFolder", url) + testee.tryFetchFaviconForUrl("subFolder", url) verify(mockFaviconPersister).store(FAVICON_TEMP_DIR, "subFolder", bitmap, "example.com") } @Test - fun whenPrefetchToTempAndCannotDownloadThenReturnNull() = coroutineRule.runBlocking { + fun whenTryFetchFaviconForUrlAndCannotDownloadThenReturnNull() = coroutineRule.runBlocking { val url = "https://example.com" whenever(mockFaviconDownloader.getFaviconFromUrl(url.toUri().faviconLocation()!!)).thenReturn(null) - val file = testee.prefetchToTemp("subFolder", url) - - assertNull(file) - } - - @Test - fun whenPrefetchToTempAndDomainDoesNotExistThenReturnNull() = coroutineRule.runBlocking { - val file = testee.prefetchToTemp("subFolder", "example.com") + val file = testee.tryFetchFaviconForUrl("subFolder", url) assertNull(file) } @Test - fun whenSaveToTempIfFaviconHasBetterQualityThenReplacePersistedFavicons() = coroutineRule.runBlocking { + fun whenStoreFaviconIfFaviconHasBetterQualityThenReplacePersistedFavicons() = coroutineRule.runBlocking { val bitmap = asBitmap() - whenever(mockFireproofWebsiteDao.fireproofWebsitesCountByDomain(any())).thenReturn(1) + givenFaviconShouldBePersisted() whenever(mockFaviconPersister.store(FAVICON_TEMP_DIR, "subFolder", bitmap, "example.com")).thenReturn(File("example")) - testee.saveToTemp("subFolder", bitmap, "example.com") + testee.storeFavicon("subFolder", FaviconSource.ImageFavicon(bitmap, "example.com")) verify(mockFaviconPersister).store(FAVICON_PERSISTED_DIR, NO_SUBFOLDER, bitmap, "example.com") } @Test - fun whenSaveToTempIfFaviconDoesNotHaveBetterQualityThenDoNotReplacePersistedFavicons() = coroutineRule.runBlocking { + fun whenStoreFaviconIfFaviconDoesNotHaveBetterQualityThenDoNotReplacePersistedFavicons() = coroutineRule.runBlocking { val bitmap = asBitmap() - whenever(mockFireproofWebsiteDao.fireproofWebsitesCountByDomain(any())).thenReturn(1) + givenFaviconShouldBePersisted() whenever(mockFaviconPersister.store(FAVICON_TEMP_DIR, "subFolder", bitmap, "example.com")).thenReturn(null) - testee.saveToTemp("subFolder", bitmap, "example.com") + testee.storeFavicon("subFolder", FaviconSource.ImageFavicon(bitmap, "example.com")) verify(mockFaviconPersister, never()).store(FAVICON_PERSISTED_DIR, NO_SUBFOLDER, bitmap, "example.com") } @Test - fun whenSaveToTempThenStoreFile() = coroutineRule.runBlocking { + fun whenStoreFaviconThenStoreFile() = coroutineRule.runBlocking { val bitmap = asBitmap() - testee.saveToTemp("subFolder", bitmap, "example.com") + testee.storeFavicon("subFolder", FaviconSource.ImageFavicon(bitmap, "example.com")) verify(mockFaviconPersister).store(FAVICON_TEMP_DIR, "subFolder", bitmap, "example.com") } @@ -209,21 +174,21 @@ class DuckDuckGoFaviconManagerTest { fun whenPersistFaviconThenCopyToPersistedDirectory() = coroutineRule.runBlocking { givenFaviconExistsInTemp() - testee.persistFavicon("subFolder", "example.com") + testee.persistCachedFavicon("subFolder", "example.com") verify(mockFaviconPersister).copyToDirectory(mockFile, FAVICON_PERSISTED_DIR, NO_SUBFOLDER, "example.com") } @Test fun whenPersistFaviconIfFaviconDoesNotExistThenDoNotCopyToPersistedDirectory() = coroutineRule.runBlocking { - testee.persistFavicon("subFolder", "example.com") + testee.persistCachedFavicon("subFolder", "example.com") verify(mockFaviconPersister, never()).copyToDirectory(any(), any(), any(), any()) } @Test fun whenDeletePersistedFaviconIfNoRemainingFaviconsInDatabaseThenDeleteFavicon() = coroutineRule.runBlocking { - whenever(mockFireproofWebsiteDao.fireproofWebsitesCountByDomain(any())).thenReturn(1) + givenFaviconShouldBePersisted() testee.deletePersistedFavicon("example.com") verify(mockFaviconPersister).deletePersistedFavicon("example.com") @@ -262,4 +227,7 @@ class DuckDuckGoFaviconManagerTest { whenever(mockFaviconPersister.faviconFile(eq(directory), any(), any())).thenReturn(mockFile) } + private fun givenFaviconShouldBePersisted() { + whenever(mockFireproofWebsiteDao.fireproofWebsitesCountByDomain(any())).thenReturn(1) + } } diff --git a/app/src/androidTest/java/com/duckduckgo/app/systemsearch/SystemSearchViewModelTest.kt b/app/src/androidTest/java/com/duckduckgo/app/systemsearch/SystemSearchViewModelTest.kt index 4e86ab9d834a..43cb136e46bf 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/systemsearch/SystemSearchViewModelTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/systemsearch/SystemSearchViewModelTest.kt @@ -24,14 +24,20 @@ import com.duckduckgo.app.InstantSchedulersRule import com.duckduckgo.app.autocomplete.api.AutoComplete import com.duckduckgo.app.autocomplete.api.AutoComplete.AutoCompleteResult import com.duckduckgo.app.autocomplete.api.AutoComplete.AutoCompleteSuggestion.AutoCompleteSearchSuggestion +import com.duckduckgo.app.bookmarks.model.FavoritesRepository +import com.duckduckgo.app.bookmarks.model.SavedSite.Favorite +import com.duckduckgo.app.browser.favicon.FaviconManager +import com.duckduckgo.app.browser.favorites.FavoritesQuickAccessAdapter.QuickAccessFavorite import com.duckduckgo.app.onboarding.store.* import com.duckduckgo.app.runBlocking import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.pixels.AppPixelName.* import com.duckduckgo.app.systemsearch.SystemSearchViewModel.Command import com.duckduckgo.app.systemsearch.SystemSearchViewModel.Command.LaunchDuckDuckGo +import com.duckduckgo.app.systemsearch.SystemSearchViewModel.Suggestions.SystemSearchResultsViewState import com.nhaarman.mockitokotlin2.* import io.reactivex.Observable +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.TestCoroutineDispatcher import kotlinx.coroutines.test.runBlockingTest import org.junit.After @@ -56,6 +62,8 @@ class SystemSearchViewModelTest { private val mockUserStageStore: UserStageStore = mock() private val mockDeviceAppLookup: DeviceAppLookup = mock() private val mockAutoComplete: AutoComplete = mock() + private val mockFavoritesRepository: FavoritesRepository = mock() + private val mockFaviconManager: FaviconManager = mock() private val mockPixel: Pixel = mock() private val commandObserver: Observer = mock() @@ -69,7 +77,8 @@ class SystemSearchViewModelTest { whenever(mockAutoComplete.autoComplete(BLANK_QUERY)).thenReturn(Observable.just(autocompleteBlankResult)) whenever(mockDeviceAppLookup.query(QUERY)).thenReturn(appQueryResult) whenever(mockDeviceAppLookup.query(BLANK_QUERY)).thenReturn(appBlankResult) - testee = SystemSearchViewModel(mockUserStageStore, mockAutoComplete, mockDeviceAppLookup, mockPixel, coroutineRule.testDispatcherProvider) + whenever(mockFavoritesRepository.favorites()).thenReturn(flowOf()) + testee = SystemSearchViewModel(mockUserStageStore, mockAutoComplete, mockDeviceAppLookup, mockPixel, mockFavoritesRepository, mockFaviconManager, coroutineRule.testDispatcherProvider) testee.command.observeForever(commandObserver) } @@ -102,7 +111,7 @@ class SystemSearchViewModelTest { fun whenDatabaseIsSlowThenIntroducingTextDoesNotCrashTheApp() = coroutineRule.runBlocking { (coroutineRule.testDispatcherProvider.io() as TestCoroutineDispatcher).pauseDispatcher() testee = - SystemSearchViewModel(givenEmptyUserStageStore(), mockAutoComplete, mockDeviceAppLookup, mockPixel, coroutineRule.testDispatcherProvider) + SystemSearchViewModel(givenEmptyUserStageStore(), mockAutoComplete, mockDeviceAppLookup, mockPixel, mockFavoritesRepository, mockFaviconManager, coroutineRule.testDispatcherProvider) testee.resetViewState() testee.userUpdatedQuery("test") @@ -152,10 +161,10 @@ class SystemSearchViewModelTest { fun whenUserUpdatesQueryThenViewStateUpdated() = coroutineRule.runBlocking { testee.userUpdatedQuery(QUERY) - val newViewState = testee.resultsViewState.value + val newViewState = testee.resultsViewState.value as SystemSearchResultsViewState assertNotNull(newViewState) - assertEquals(appQueryResult, newViewState?.appResults) - assertEquals(autocompleteQueryResult, newViewState?.autocompleteResults) + assertEquals(appQueryResult, newViewState.appResults) + assertEquals(autocompleteQueryResult, newViewState.autocompleteResults) } @Test @@ -163,10 +172,10 @@ class SystemSearchViewModelTest { testee.userUpdatedQuery(QUERY) testee.userUpdatedQuery("$QUERY ") - val newViewState = testee.resultsViewState.value + val newViewState = testee.resultsViewState.value as SystemSearchResultsViewState assertNotNull(newViewState) - assertEquals(appQueryResult, newViewState?.appResults) - assertEquals(autocompleteQueryResult, newViewState?.autocompleteResults) + assertEquals(appQueryResult, newViewState.appResults) + assertEquals(autocompleteQueryResult, newViewState.autocompleteResults) } @Test @@ -174,10 +183,7 @@ class SystemSearchViewModelTest { testee.userUpdatedQuery(QUERY) testee.userRequestedClear() - val newViewState = testee.resultsViewState.value - assertNotNull(newViewState) - assertTrue(newViewState!!.appResults.isEmpty()) - assertEquals(AutoCompleteResult("", emptyList()), newViewState.autocompleteResults) + assertTrue(testee.resultsViewState.value is SystemSearchViewModel.Suggestions.QuickAccessItems) } @Test @@ -185,10 +191,7 @@ class SystemSearchViewModelTest { testee.userUpdatedQuery(QUERY) testee.userUpdatedQuery(BLANK_QUERY) - val newViewState = testee.resultsViewState.value - assertNotNull(newViewState) - assertTrue(newViewState!!.appResults.isEmpty()) - assertEquals(AutoCompleteResult("", emptyList()), newViewState.autocompleteResults) + assertTrue(testee.resultsViewState.value is SystemSearchViewModel.Suggestions.QuickAccessItems) } @Test @@ -271,6 +274,84 @@ class SystemSearchViewModelTest { assertEquals(Command.EditQuery(query), commandCaptor.lastValue) } + @Test + fun whenQuickAccessItemClickedThenLaunchBrowser() { + val quickAccessItem = QuickAccessFavorite(Favorite(1, "title", "http://example.com", 0)) + + testee.onQuickAccesItemClicked(quickAccessItem) + + verify(commandObserver, atLeastOnce()).onChanged(commandCaptor.capture()) + assertEquals(Command.LaunchBrowser(quickAccessItem.favorite.url), commandCaptor.lastValue) + } + + @Test + fun whenQuickAccessItemEditRequestedThenLaunchEditDialog() { + val quickAccessItem = QuickAccessFavorite(Favorite(1, "title", "http://example.com", 0)) + + testee.onEditQuickAccessItemRequested(quickAccessItem) + + verify(commandObserver, atLeastOnce()).onChanged(commandCaptor.capture()) + assertEquals(Command.LaunchEditDialog(quickAccessItem.favorite), commandCaptor.lastValue) + } + + @Test + fun whenQuickAccessItemDeleteRequestedThenShowDeleteConfirmation() { + val quickAccessItem = QuickAccessFavorite(Favorite(1, "title", "http://example.com", 0)) + + testee.onDeleteQuickAccessItemRequested(quickAccessItem) + + verify(commandObserver, atLeastOnce()).onChanged(commandCaptor.capture()) + assertEquals(Command.DeleteSavedSiteConfirmation(quickAccessItem.favorite), commandCaptor.lastValue) + } + + @Test + fun whenQuickAccessEditedThenRepositoryUpdated() { + val savedSite = Favorite(1, "title", "http://example.com", 0) + + testee.onSavedSiteEdited(savedSite) + + verify(mockFavoritesRepository).update(savedSite) + } + + @Test + fun whenQuickAccessDeleteRequestedThenRepositoryUpdated() = coroutineRule.runBlocking { + val savedSite = Favorite(1, "title", "http://example.com", 0) + + testee.onDeleteQuickAccessItemRequested(QuickAccessFavorite(savedSite)) + + verify(mockFavoritesRepository).delete(savedSite) + } + + @Test + fun whenQuickAccessInsertedThenRepositoryUpdated() { + val savedSite = Favorite(1, "title", "http://example.com", 0) + + testee.insertQuickAccessItem(savedSite) + + verify(mockFavoritesRepository).insert(savedSite) + } + + @Test + fun whenQuickAccessListChangedThenRepositoryUpdated() { + val savedSite = Favorite(1, "title", "http://example.com", 0) + val savedSites = listOf(QuickAccessFavorite(savedSite)) + + testee.onQuickAccessListChanged(savedSites) + + verify(mockFavoritesRepository).updateWithPosition(listOf(savedSite)) + } + + @Test + fun whenUserHasFavoritesThenInitialStateShowsFavorites() { + val savedSite = Favorite(1, "title", "http://example.com", 0) + whenever(mockFavoritesRepository.favorites()).thenReturn(flowOf(listOf(savedSite))) + testee = SystemSearchViewModel(mockUserStageStore, mockAutoComplete, mockDeviceAppLookup, mockPixel, mockFavoritesRepository, mockFaviconManager, coroutineRule.testDispatcherProvider) + + val viewState = testee.resultsViewState.value as SystemSearchViewModel.Suggestions.QuickAccessItems + assertEquals(1, viewState.favorites.size) + assertEquals(savedSite, viewState.favorites.first().favorite) + } + private suspend fun whenOnboardingShowing() { whenever(mockUserStageStore.getUserAppStage()).thenReturn(AppStage.NEW) testee.resetViewState() diff --git a/app/src/main/java/com/duckduckgo/app/autocomplete/api/AutoComplete.kt b/app/src/main/java/com/duckduckgo/app/autocomplete/api/AutoComplete.kt index 95ab368afaaa..894afef8cfa6 100644 --- a/app/src/main/java/com/duckduckgo/app/autocomplete/api/AutoComplete.kt +++ b/app/src/main/java/com/duckduckgo/app/autocomplete/api/AutoComplete.kt @@ -22,6 +22,8 @@ import com.duckduckgo.app.autocomplete.api.AutoComplete.AutoCompleteSuggestion.A import com.duckduckgo.app.autocomplete.api.AutoComplete.AutoCompleteSuggestion.AutoCompleteSearchSuggestion import com.duckduckgo.app.bookmarks.db.BookmarkEntity import com.duckduckgo.app.bookmarks.db.BookmarksDao +import com.duckduckgo.app.bookmarks.model.FavoritesRepository +import com.duckduckgo.app.bookmarks.model.SavedSite import com.duckduckgo.app.global.UriString import com.duckduckgo.app.global.baseHost import com.duckduckgo.app.global.toStringDropScheme @@ -47,7 +49,8 @@ interface AutoComplete { class AutoCompleteApi @Inject constructor( private val autoCompleteService: AutoCompleteService, - private val bookmarksDao: BookmarksDao + private val bookmarksDao: BookmarksDao, + private val favoritesRepository: FavoritesRepository ) : AutoComplete { override fun autoComplete(query: String): Observable { @@ -56,7 +59,15 @@ class AutoCompleteApi @Inject constructor( return Observable.just(AutoCompleteResult(query = query, suggestions = emptyList())) } - return getAutoCompleteBookmarkResults(query).zipWith( + val savedSitesObservable = getAutoCompleteBookmarkResults(query) + .zipWith( + getAutoCompleteFavoritesResults(query), + { bookmarks, favorites -> + (favorites + bookmarks).take(2) + } + ) + + return savedSitesObservable.zipWith( getAutoCompleteSearchResults(query), { bookmarksResults, searchResults -> AutoCompleteResult( @@ -90,21 +101,43 @@ class AutoCompleteApi @Inject constructor( .onErrorReturn { emptyList() } .toObservable() - private fun rankBookmarks(query: String, bookmarks: List): List { + private fun getAutoCompleteFavoritesResults(query: String) = + favoritesRepository.favoritesObservable() + .map { rankFavorites(query, it) } + .flattenAsObservable { it } + .map { + AutoCompleteBookmarkSuggestion(phrase = it.url.toUri().toStringDropScheme(), title = it.title.orEmpty(), url = it.url) + } + .distinctUntilChanged() + .take(2) + .toList() + .onErrorReturn { emptyList() } + .toObservable() + + private fun rankBookmarks(query: String, bookmarks: List): List { return bookmarks.asSequence() - .map { RankedBookmark(bookmarkEntity = it) } + .map { SavedSite.Bookmark(it.id, it.title ?: "", it.url) } + .sortByRank(query) + } + + private fun rankFavorites(query: String, favorites: List): List { + return favorites.asSequence().sortByRank(query) + } + + private fun Sequence.sortByRank(query: String): List { + return this.map { RankedBookmark(savedSite = it) } .map { scoreTitle(it, query) } .map { scoreTokens(it, query) } .filter { it.score >= 0 } .sortedByDescending { it.score } - .map { it.bookmarkEntity } + .map { it.savedSite } .toList() } private fun scoreTitle(rankedBookmark: RankedBookmark, query: String): RankedBookmark { - if (rankedBookmark.bookmarkEntity.title?.startsWith(query, ignoreCase = true) == true) { + if (rankedBookmark.savedSite.title.startsWith(query, ignoreCase = true)) { rankedBookmark.score += 200 - } else if (rankedBookmark.bookmarkEntity.title?.contains(" $query", ignoreCase = true) == true) { + } else if (rankedBookmark.savedSite.title.contains(" $query", ignoreCase = true)) { rankedBookmark.score += 100 } @@ -112,13 +145,13 @@ class AutoCompleteApi @Inject constructor( } private fun scoreTokens(rankedBookmark: RankedBookmark, query: String): RankedBookmark { - val bookmark = rankedBookmark.bookmarkEntity - val domain = bookmark.url.toUri().baseHost + val savedSite = rankedBookmark.savedSite + val domain = savedSite.url.toUri().baseHost val tokens = query.split(" ") if (tokens.size > 1) { tokens.forEach { token -> - if (bookmark.title?.startsWith(token, ignoreCase = true) == false && - bookmark.title?.contains(" $token", ignoreCase = true) == false && + if (!savedSite.title.startsWith(token, ignoreCase = true) && + !savedSite.title.contains(" $token", ignoreCase = true) && domain?.startsWith(token, ignoreCase = true) == false ) { return rankedBookmark @@ -129,11 +162,11 @@ class AutoCompleteApi @Inject constructor( if (domain?.startsWith(tokens.first(), ignoreCase = true) == true) { rankedBookmark.score += 300 - } else if (bookmark.title?.startsWith(tokens.first(), ignoreCase = true) == true) { + } else if (savedSite.title.startsWith(tokens.first(), ignoreCase = true)) { rankedBookmark.score += 50 } - } else if (bookmark.url.redactSchemeAndWwwSubDomain().startsWith(tokens.first().trimEnd { it == '/' }, ignoreCase = true)) { + } else if (savedSite.url.redactSchemeAndWwwSubDomain().startsWith(tokens.first().trimEnd { it == '/' }, ignoreCase = true)) { rankedBookmark.score += 300 } @@ -145,7 +178,7 @@ class AutoCompleteApi @Inject constructor( } private data class RankedBookmark( - val bookmarkEntity: BookmarkEntity, + val savedSite: SavedSite, var score: Int = BOOKMARK_SCORE ) diff --git a/app/src/main/java/com/duckduckgo/app/bookmarks/db/FavoriteEntity.kt b/app/src/main/java/com/duckduckgo/app/bookmarks/db/FavoriteEntity.kt new file mode 100644 index 000000000000..f97822dde5d8 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/bookmarks/db/FavoriteEntity.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2021 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.app.bookmarks.db + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +@Entity( + tableName = "favorites", + indices = [Index(value = ["title", "url"], unique = true)] +) +data class FavoriteEntity( + @PrimaryKey(autoGenerate = true) var id: Long = 0, + var title: String, + var url: String, + var position: Int +) diff --git a/app/src/main/java/com/duckduckgo/app/bookmarks/db/FavoritesDao.kt b/app/src/main/java/com/duckduckgo/app/bookmarks/db/FavoritesDao.kt new file mode 100644 index 000000000000..14cca451d2d9 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/bookmarks/db/FavoritesDao.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2018 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.app.bookmarks.db + +import androidx.room.* +import com.duckduckgo.app.bookmarks.model.SavedSite +import io.reactivex.Single +import kotlinx.coroutines.flow.Flow + +@Dao +interface FavoritesDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insert(favorite: FavoriteEntity): Long + + @Query("select * from favorites order by position") + fun favorites(): Flow> + + @Query("select count(*) from favorites WHERE url LIKE :domain") + fun favoritesCountByUrl(domain: String): Int + + @Delete + fun delete(favorite: FavoriteEntity) + + @Update(onConflict = OnConflictStrategy.REPLACE) + fun update(favoriteEntity: FavoriteEntity) + + @Query("select * from favorites") + fun favoritesObservable(): Single> + + @Query("select position from favorites where position = ( select MAX(position) from favorites)") + fun getLastPosition(): Int? + + @Query("select * from favorites where id = :id") + fun favorite(id: Long): FavoriteEntity? + + @Transaction + fun persistChanges(favorites: List) { + favorites.forEachIndexed { index, favorite -> + val favoriteEntity = favorite(favorite.id) ?: return + favoriteEntity.position = index + update(favoriteEntity) + } + } +} diff --git a/app/src/main/java/com/duckduckgo/app/bookmarks/di/BookmarksModule.kt b/app/src/main/java/com/duckduckgo/app/bookmarks/di/BookmarksModule.kt index dc3e6cc4494f..33496a7924cc 100644 --- a/app/src/main/java/com/duckduckgo/app/bookmarks/di/BookmarksModule.kt +++ b/app/src/main/java/com/duckduckgo/app/bookmarks/di/BookmarksModule.kt @@ -28,8 +28,13 @@ import com.duckduckgo.app.bookmarks.service.RealBookmarksImporter import com.duckduckgo.app.bookmarks.service.RealBookmarksParser import com.duckduckgo.app.global.DispatcherProvider import com.duckduckgo.app.statistics.pixels.Pixel +import com.duckduckgo.app.bookmarks.db.FavoritesDao +import com.duckduckgo.app.bookmarks.model.FavoritesDataRepository +import com.duckduckgo.app.bookmarks.model.FavoritesRepository +import com.duckduckgo.app.browser.favicon.FaviconManager import com.duckduckgo.di.scopes.AppObjectGraph import com.squareup.anvil.annotations.ContributesTo +import dagger.Lazy import dagger.Module import dagger.Provides import javax.inject.Singleton @@ -74,4 +79,10 @@ class BookmarksModule { ): BookmarksManager { return RealBookmarksManager(bookmarksImporter, bookmarksExporter, pixel) } + + @Provides + @Singleton + fun favoriteRepository(favoritesDao: FavoritesDao, faviconManager: Lazy): FavoritesRepository { + return FavoritesDataRepository(favoritesDao, faviconManager) + } } diff --git a/app/src/main/java/com/duckduckgo/app/bookmarks/model/FavoritesRepository.kt b/app/src/main/java/com/duckduckgo/app/bookmarks/model/FavoritesRepository.kt new file mode 100644 index 000000000000..05915a1a0e53 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/bookmarks/model/FavoritesRepository.kt @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2021 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.app.bookmarks.model + +import com.duckduckgo.app.bookmarks.db.FavoriteEntity +import com.duckduckgo.app.bookmarks.db.FavoritesDao +import com.duckduckgo.app.browser.favicon.FaviconManager +import dagger.Lazy +import io.reactivex.Single +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import java.io.Serializable + +interface FavoritesRepository { + fun favoritesCountByDomain(domain: String): Int + fun favoritesObservable(): Single> + fun insert(title: String, url: String): SavedSite.Favorite + fun insert(favorite: SavedSite.Favorite) + fun update(favorite: SavedSite.Favorite) + fun updateWithPosition(favorites: List) + fun favorites(): Flow> + suspend fun delete(favorite: SavedSite.Favorite) +} + +sealed class SavedSite( + open val id: Long, + open val title: String, + open val url: String +) : Serializable { + data class Favorite( + override val id: Long, + override val title: String, + override val url: String, + val position: Int + ) : SavedSite(id, title, url) + + data class Bookmark( + override val id: Long, + override val title: String, + override val url: String + ) : SavedSite(id, title, url) +} + +class FavoritesDataRepository( + private val favoritesDao: FavoritesDao, + private val faviconManager: Lazy, +) : FavoritesRepository { + override fun favoritesCountByDomain(domain: String): Int { + return favoritesDao.favoritesCountByUrl(domain) + } + + override fun favoritesObservable() = + favoritesDao.favoritesObservable().map { favorites -> favorites.mapToSavedSites() } + + override fun insert(title: String, url: String): SavedSite.Favorite { + val titleOrFallback = title.takeIf { it.isNotEmpty() } ?: url + val lastPosition = favoritesDao.getLastPosition() ?: 0 + val favoriteEntity = FavoriteEntity(title = titleOrFallback, url = url, position = lastPosition + 1) + val id = favoritesDao.insert(favoriteEntity) + return SavedSite.Favorite(id, favoriteEntity.title, favoriteEntity.url, favoriteEntity.position) + } + + override fun insert(favorite: SavedSite.Favorite) { + if (favorite.url.isEmpty()) return + val favoriteEntity = FavoriteEntity(title = favorite.titleOrFallback(), url = favorite.url, position = favorite.position) + favoritesDao.insert(favoriteEntity) + } + + override fun update(favorite: SavedSite.Favorite) { + if (favorite.url.isEmpty()) return + favoritesDao.update(FavoriteEntity(favorite.id, favorite.titleOrFallback(), favorite.url, favorite.position)) + } + + override fun updateWithPosition(favorites: List) { + favoritesDao.persistChanges(favorites) + } + + override fun favorites(): Flow> { + return favoritesDao.favorites().distinctUntilChanged().map { favorites -> favorites.mapToSavedSites() } + } + + override suspend fun delete(favorite: SavedSite.Favorite) { + faviconManager.get().deletePersistedFavicon(favorite.url) + favoritesDao.delete(FavoriteEntity(favorite.id, favorite.title, favorite.url, favorite.position)) + } + + private fun SavedSite.Favorite.titleOrFallback(): String = this.title.takeIf { it.isNotEmpty() } ?: this.url + private fun List.mapToSavedSites(): List = this.map { SavedSite.Favorite(it.id, it.title, it.url, it.position) } +} diff --git a/app/src/main/java/com/duckduckgo/app/bookmarks/ui/BookmarksActivity.kt b/app/src/main/java/com/duckduckgo/app/bookmarks/ui/BookmarksActivity.kt index 094924d28edc..cf46485a8829 100644 --- a/app/src/main/java/com/duckduckgo/app/bookmarks/ui/BookmarksActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/bookmarks/ui/BookmarksActivity.kt @@ -18,23 +18,13 @@ package com.duckduckgo.app.bookmarks.ui import android.content.Context import android.content.Intent -import android.net.Uri import android.os.Bundle -import android.view.LayoutInflater import android.view.Menu import android.view.MenuItem -import android.view.View -import android.view.ViewGroup -import android.widget.ImageView import androidx.appcompat.app.AlertDialog import androidx.appcompat.widget.SearchView -import androidx.core.view.isVisible -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.Observer -import androidx.lifecycle.lifecycleScope -import androidx.recyclerview.widget.RecyclerView.Adapter -import androidx.recyclerview.widget.RecyclerView.ViewHolder -import com.duckduckgo.app.bookmarks.db.BookmarkEntity +import androidx.recyclerview.widget.ConcatAdapter +import com.duckduckgo.app.bookmarks.model.SavedSite import com.duckduckgo.app.bookmarks.service.ExportBookmarksResult import com.duckduckgo.app.bookmarks.service.ImportBookmarksResult import com.duckduckgo.app.browser.BrowserActivity @@ -42,23 +32,12 @@ import com.duckduckgo.app.browser.R import com.duckduckgo.app.browser.R.id.action_search import com.duckduckgo.app.browser.favicon.FaviconManager import com.duckduckgo.app.global.DuckDuckGoActivity -import com.duckduckgo.app.global.baseHost -import com.duckduckgo.app.global.view.gone +import com.duckduckgo.app.global.view.DividerAdapter import com.duckduckgo.app.global.view.html -import com.duckduckgo.app.global.view.show import com.google.android.material.snackbar.Snackbar import kotlinx.android.synthetic.main.activity_bookmarks.bookmarkRootView -import kotlinx.android.synthetic.main.content_bookmarks.emptyBookmarks import kotlinx.android.synthetic.main.content_bookmarks.recycler import kotlinx.android.synthetic.main.include_toolbar.toolbar -import kotlinx.android.synthetic.main.popup_window_bookmarks_menu.view.deleteBookmark -import kotlinx.android.synthetic.main.popup_window_bookmarks_menu.view.editBookmark -import kotlinx.android.synthetic.main.view_bookmark_entry.view.favicon -import kotlinx.android.synthetic.main.view_bookmark_entry.view.overflowMenu -import kotlinx.android.synthetic.main.view_bookmark_entry.view.title -import kotlinx.android.synthetic.main.view_bookmark_entry.view.url -import kotlinx.coroutines.launch -import timber.log.Timber import java.text.SimpleDateFormat import java.util.Date import java.util.Locale @@ -70,7 +49,8 @@ class BookmarksActivity : DuckDuckGoActivity() { @Inject lateinit var faviconManager: FaviconManager - lateinit var adapter: BookmarksAdapter + lateinit var bookmarksAdapter: BookmarksAdapter + lateinit var favoritesAdapter: FavoritesAdapter private var deleteDialog: AlertDialog? = null private val viewModel: BookmarksViewModel by bindViewModel() @@ -107,17 +87,19 @@ class BookmarksActivity : DuckDuckGoActivity() { } private fun setupBookmarksRecycler() { - adapter = BookmarksAdapter(layoutInflater, viewModel, this, faviconManager) - recycler.adapter = adapter + bookmarksAdapter = BookmarksAdapter(layoutInflater, viewModel, this, faviconManager) + favoritesAdapter = FavoritesAdapter(layoutInflater, viewModel, this, faviconManager) + recycler.adapter = ConcatAdapter(favoritesAdapter, DividerAdapter(), bookmarksAdapter) + recycler.itemAnimator = null } private fun observeViewModel() { viewModel.viewState.observe( this, - Observer { viewState -> + { viewState -> viewState?.let { - if (it.showBookmarks) showBookmarks() else hideBookmarks() - adapter.bookmarks = it.bookmarks + favoritesAdapter.favoriteItems = it.favorites.map { FavoritesAdapter.FavoriteItem(it) } + bookmarksAdapter.bookmarkItems = it.bookmarks.map { BookmarksAdapter.BookmarkItem(it) } invalidateOptionsMenu() } } @@ -125,11 +107,11 @@ class BookmarksActivity : DuckDuckGoActivity() { viewModel.command.observe( this, - Observer { + { when (it) { - is BookmarksViewModel.Command.ConfirmDeleteBookmark -> confirmDeleteBookmark(it.bookmark) - is BookmarksViewModel.Command.OpenBookmark -> openBookmark(it.bookmark) - is BookmarksViewModel.Command.ShowEditBookmark -> showEditBookmarkDialog(it.bookmark) + is BookmarksViewModel.Command.ConfirmDeleteSavedSite -> confirmDeleteSavedSite(it.savedSite) + is BookmarksViewModel.Command.OpenSavedSite -> openSavedSite(it.savedSite) + is BookmarksViewModel.Command.ShowEditSavedSite -> showEditSavedSiteDialog(it.savedSite) is BookmarksViewModel.Command.ImportedBookmarks -> showImportedBookmarks(it.importBookmarksResult) is BookmarksViewModel.Command.ExportedBookmarks -> showExportedBookmarks(it.exportBookmarksResult) } @@ -211,47 +193,32 @@ class BookmarksActivity : DuckDuckGoActivity() { val searchMenuItem = menu?.findItem(action_search) searchMenuItem?.isVisible = viewModel.viewState.value?.enableSearch == true val searchView = searchMenuItem?.actionView as SearchView - searchView.setOnQueryTextListener(BookmarksEntityQueryListener(viewModel.viewState.value?.bookmarks, adapter)) + searchView.setOnQueryTextListener(BookmarksEntityQueryListener(viewModel.viewState.value?.bookmarks, bookmarksAdapter)) return super.onPrepareOptionsMenu(menu) } - private fun showEditBookmarkDialog(bookmark: BookmarkEntity) { - val dialog = EditBookmarkDialogFragment.instance(bookmark.id, bookmark.title, bookmark.url) + private fun showEditSavedSiteDialog(savedSite: SavedSite) { + val dialog = EditSavedSiteDialogFragment.instance(savedSite) dialog.show(supportFragmentManager, EDIT_BOOKMARK_FRAGMENT_TAG) dialog.listener = viewModel } - private fun showBookmarks() { - recycler.show() - emptyBookmarks.gone() - } - - private fun hideBookmarks() { - recycler.gone() - emptyBookmarks.show() - } - - private fun openBookmark(bookmark: BookmarkEntity) { - startActivity(BrowserActivity.intent(this, bookmark.url)) + private fun openSavedSite(savedSite: SavedSite) { + startActivity(BrowserActivity.intent(this, savedSite.url)) finish() } - private fun confirmDeleteBookmark(bookmark: BookmarkEntity) { - val message = getString(R.string.bookmarkDeleteConfirmationMessage, bookmark.title).html(this) - viewModel.delete(bookmark) + private fun confirmDeleteSavedSite(savedSite: SavedSite) { + val message = getString(R.string.bookmarkDeleteConfirmationMessage, savedSite.title).html(this) Snackbar.make( bookmarkRootView, message, Snackbar.LENGTH_LONG ).setAction(R.string.fireproofWebsiteSnackbarAction) { - viewModel.insert(bookmark) + viewModel.insert(savedSite) }.show() } - private fun delete(bookmark: BookmarkEntity) { - viewModel.delete(bookmark) - } - override fun onDestroy() { deleteDialog?.dismiss() super.onDestroy() @@ -276,95 +243,4 @@ class BookmarksActivity : DuckDuckGoActivity() { timeZone = TimeZone.getTimeZone("UTC") } } - - class BookmarksAdapter( - private val layoutInflater: LayoutInflater, - private val viewModel: BookmarksViewModel, - private val lifecycleOwner: LifecycleOwner, - private val faviconManager: FaviconManager - ) : Adapter() { - - var bookmarks: List = emptyList() - set(value) { - field = value - notifyDataSetChanged() - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BookmarksViewHolder { - val inflater = LayoutInflater.from(parent.context) - val view = inflater.inflate(R.layout.view_bookmark_entry, parent, false) - return BookmarksViewHolder(layoutInflater, view, viewModel, lifecycleOwner, faviconManager) - } - - override fun onBindViewHolder(holder: BookmarksViewHolder, position: Int) { - holder.update(bookmarks[position]) - } - - override fun getItemCount(): Int { - return bookmarks.size - } - } - - class BookmarksViewHolder( - private val layoutInflater: LayoutInflater, - itemView: View, - private val viewModel: BookmarksViewModel, - private val lifecycleOwner: LifecycleOwner, - private val faviconManager: FaviconManager - ) : ViewHolder(itemView) { - - lateinit var bookmark: BookmarkEntity - - fun update(bookmark: BookmarkEntity) { - this.bookmark = bookmark - - itemView.overflowMenu.contentDescription = itemView.context.getString( - R.string.bookmarkOverflowContentDescription, - bookmark.title - ) - - itemView.title.text = bookmark.title - itemView.url.text = parseDisplayUrl(bookmark.url) - loadFavicon(bookmark.url) - - itemView.overflowMenu.setOnClickListener { - showOverFlowMenu(itemView.overflowMenu, bookmark) - } - - itemView.setOnClickListener { - viewModel.onSelected(bookmark) - } - } - - private fun loadFavicon(url: String) { - lifecycleOwner.lifecycleScope.launch { - faviconManager.loadToViewFromPersisted(url, itemView.favicon) - } - } - - private fun parseDisplayUrl(urlString: String): String { - val uri = Uri.parse(urlString) - return uri.baseHost ?: return urlString - } - - private fun showOverFlowMenu(anchor: ImageView, bookmark: BookmarkEntity) { - val popupMenu = BookmarksPopupMenu(layoutInflater) - val view = popupMenu.contentView - popupMenu.apply { - onMenuItemClicked(view.editBookmark) { editBookmark(bookmark) } - onMenuItemClicked(view.deleteBookmark) { deleteBookmark(bookmark) } - } - popupMenu.show(itemView, anchor) - } - - private fun editBookmark(bookmark: BookmarkEntity) { - Timber.i("Editing bookmark ${bookmark.title}") - viewModel.onEditBookmarkRequested(bookmark) - } - - private fun deleteBookmark(bookmark: BookmarkEntity) { - Timber.i("Deleting bookmark ${bookmark.title}") - viewModel.onDeleteRequested(bookmark) - } - } } diff --git a/app/src/main/java/com/duckduckgo/app/bookmarks/ui/BookmarksAdapter.kt b/app/src/main/java/com/duckduckgo/app/bookmarks/ui/BookmarksAdapter.kt new file mode 100644 index 000000000000..43371aa75a1c --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/bookmarks/ui/BookmarksAdapter.kt @@ -0,0 +1,181 @@ +/* + * Copyright (c) 2021 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.app.bookmarks.ui + +import android.net.Uri +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import com.duckduckgo.app.bookmarks.model.SavedSite +import com.duckduckgo.app.browser.R +import com.duckduckgo.app.browser.favicon.FaviconManager +import com.duckduckgo.app.global.baseHost +import kotlinx.android.synthetic.main.popup_window_saved_site_menu.view.* +import kotlinx.android.synthetic.main.view_saved_site_entry.view.* +import kotlinx.android.synthetic.main.view_saved_site_empty_hint.view.* +import kotlinx.android.synthetic.main.view_saved_site_section_title.view.* +import kotlinx.coroutines.launch + +class BookmarksAdapter( + private val layoutInflater: LayoutInflater, + private val viewModel: BookmarksViewModel, + private val lifecycleOwner: LifecycleOwner, + private val faviconManager: FaviconManager +) : ListAdapter(BookmarksDiffCallback()) { + + companion object { + const val BOOKMARK_SECTION_TITLE_TYPE = 0 + const val EMPTY_STATE_TYPE = 1 + const val BOOKMARK_TYPE = 2 + } + + interface BookmarksItemTypes + object Header : BookmarksItemTypes + object EmptyHint : BookmarksItemTypes + data class BookmarkItem(val bookmark: SavedSite.Bookmark) : BookmarksItemTypes + + var bookmarkItems: List = emptyList() + set(value) { + field = generateNewList(value) + submitList(field) + } + + private fun generateNewList(value: List): List { + return listOf(Header) + (if (value.isEmpty()) listOf(EmptyHint) else value) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BookmarkScreenViewHolders { + val inflater = LayoutInflater.from(parent.context) + return when (viewType) { + BOOKMARK_TYPE -> { + val view = inflater.inflate(R.layout.view_saved_site_entry, parent, false) + return BookmarkScreenViewHolders.BookmarksViewHolder(layoutInflater, view, viewModel, lifecycleOwner, faviconManager) + } + BOOKMARK_SECTION_TITLE_TYPE -> { + val view = inflater.inflate(R.layout.view_saved_site_section_title, parent, false) + return BookmarkScreenViewHolders.SectionTitle(view) + } + EMPTY_STATE_TYPE -> { + val view = inflater.inflate(R.layout.view_saved_site_empty_hint, parent, false) + BookmarkScreenViewHolders.EmptyHint(view) + } + else -> throw IllegalArgumentException("viewType not found") + } + } + + override fun getItemCount(): Int = bookmarkItems.size + + override fun onBindViewHolder(holder: BookmarkScreenViewHolders, position: Int) { + when (holder) { + is BookmarkScreenViewHolders.BookmarksViewHolder -> { + holder.update((bookmarkItems[position] as BookmarkItem).bookmark) + } + is BookmarkScreenViewHolders.SectionTitle -> { + holder.bind() + } + is BookmarkScreenViewHolders.EmptyHint -> { + holder.bind() + } + } + } + + override fun getItemViewType(position: Int): Int { + return when (bookmarkItems[position]) { + is Header -> BOOKMARK_SECTION_TITLE_TYPE + is EmptyHint -> EMPTY_STATE_TYPE + else -> BOOKMARK_TYPE + + } + } +} + +sealed class BookmarkScreenViewHolders(itemView: View) : RecyclerView.ViewHolder(itemView) { + + class SectionTitle(itemView: View) : BookmarkScreenViewHolders(itemView) { + fun bind() { + itemView.savedSiteSectionTitle.setText(R.string.bookmarksSectionTitle) + } + } + + class EmptyHint(itemView: View) : BookmarkScreenViewHolders(itemView) { + fun bind() { + itemView.savedSiteEmptyHint.setText(R.string.bookmarksEmptyHint) + } + } + + class BookmarksViewHolder( + private val layoutInflater: LayoutInflater, + itemView: View, + private val viewModel: BookmarksViewModel, + private val lifecycleOwner: LifecycleOwner, + private val faviconManager: FaviconManager + ) : BookmarkScreenViewHolders(itemView) { + + fun update(bookmark: SavedSite.Bookmark) { + itemView.overflowMenu.contentDescription = itemView.context.getString( + R.string.bookmarkOverflowContentDescription, + bookmark.title + ) + + itemView.title.text = bookmark.title + itemView.url.text = parseDisplayUrl(bookmark.url) + loadFavicon(bookmark.url) + + itemView.overflowMenu.setOnClickListener { + showOverFlowMenu(itemView.overflowMenu, bookmark) + } + + itemView.setOnClickListener { + viewModel.onSelected(bookmark) + } + } + + private fun loadFavicon(url: String) { + lifecycleOwner.lifecycleScope.launch { + faviconManager.loadToViewFromLocalOrFallback(url = url, view = itemView.favicon) + } + } + + private fun parseDisplayUrl(urlString: String): String { + val uri = Uri.parse(urlString) + return uri.baseHost ?: return urlString + } + + private fun showOverFlowMenu(anchor: ImageView, bookmark: SavedSite.Bookmark) { + val popupMenu = SavedSitePopupMenu(layoutInflater) + val view = popupMenu.contentView + popupMenu.apply { + onMenuItemClicked(view.editSavedSite) { editBookmark(bookmark) } + onMenuItemClicked(view.deleteSavedSite) { deleteBookmark(bookmark) } + } + popupMenu.show(itemView, anchor) + } + + private fun editBookmark(bookmark: SavedSite.Bookmark) { + viewModel.onEditSavedSiteRequested(bookmark) + } + + private fun deleteBookmark(bookmark: SavedSite.Bookmark) { + viewModel.onDeleteSavedSiteRequested(bookmark) + } + } +} diff --git a/app/src/main/java/com/duckduckgo/app/bookmarks/ui/BookmarksEntityQueryListener.kt b/app/src/main/java/com/duckduckgo/app/bookmarks/ui/BookmarksEntityQueryListener.kt index 578eaf44e3b8..b30f65b5db96 100644 --- a/app/src/main/java/com/duckduckgo/app/bookmarks/ui/BookmarksEntityQueryListener.kt +++ b/app/src/main/java/com/duckduckgo/app/bookmarks/ui/BookmarksEntityQueryListener.kt @@ -17,17 +17,16 @@ package com.duckduckgo.app.bookmarks.ui import androidx.appcompat.widget.SearchView -import com.duckduckgo.app.bookmarks.db.BookmarkEntity +import com.duckduckgo.app.bookmarks.model.SavedSite class BookmarksEntityQueryListener( - val bookmarks: List?, - val adapter: BookmarksActivity.BookmarksAdapter + val bookmarks: List?, + val adapter: BookmarksAdapter ) : SearchView.OnQueryTextListener { override fun onQueryTextChange(newText: String): Boolean { if (bookmarks != null) { - adapter.bookmarks = filter(newText, bookmarks) - adapter.notifyDataSetChanged() + adapter.bookmarkItems = filter(newText, bookmarks) } return true } @@ -36,11 +35,11 @@ class BookmarksEntityQueryListener( return false } - private fun filter(query: String, bookmarks: List): List { + private fun filter(query: String, bookmarks: List): List { val lowercaseQuery = query.toLowerCase() return bookmarks.filter { - val lowercaseTitle = it.title?.toLowerCase() - lowercaseTitle?.contains(lowercaseQuery) == true || it.url.contains(lowercaseQuery) - } + val lowercaseTitle = it.title.toLowerCase() + lowercaseTitle.contains(lowercaseQuery) || it.url.contains(lowercaseQuery) + }.map { BookmarksAdapter.BookmarkItem(it) } } } diff --git a/app/src/main/java/com/duckduckgo/app/bookmarks/ui/BookmarksViewModel.kt b/app/src/main/java/com/duckduckgo/app/bookmarks/ui/BookmarksViewModel.kt index 9431589cfe1b..f68a9a17eae5 100644 --- a/app/src/main/java/com/duckduckgo/app/bookmarks/ui/BookmarksViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/bookmarks/ui/BookmarksViewModel.kt @@ -20,45 +20,48 @@ import android.net.Uri import androidx.lifecycle.* import com.duckduckgo.app.bookmarks.db.BookmarkEntity import com.duckduckgo.app.bookmarks.db.BookmarksDao -import com.duckduckgo.app.bookmarks.service.BookmarksManager +import com.duckduckgo.app.bookmarks.model.SavedSite import com.duckduckgo.app.bookmarks.service.ExportBookmarksResult import com.duckduckgo.app.bookmarks.service.ImportBookmarksResult +import com.duckduckgo.app.bookmarks.service.BookmarksManager +import com.duckduckgo.app.bookmarks.model.FavoritesRepository +import com.duckduckgo.app.bookmarks.model.SavedSite.Bookmark +import com.duckduckgo.app.bookmarks.model.SavedSite.Favorite import com.duckduckgo.app.bookmarks.ui.BookmarksViewModel.Command.* -import com.duckduckgo.app.bookmarks.ui.EditBookmarkDialogFragment.EditBookmarkListener +import com.duckduckgo.app.bookmarks.ui.EditSavedSiteDialogFragment.EditSavedSiteListener import com.duckduckgo.app.browser.favicon.FaviconManager import com.duckduckgo.app.global.DispatcherProvider import com.duckduckgo.app.global.SingleLiveEvent import com.duckduckgo.app.global.plugins.view_model.ViewModelFactoryPlugin import com.duckduckgo.di.scopes.AppObjectGraph import com.squareup.anvil.annotations.ContributesMultibinding -import io.reactivex.schedulers.Schedulers import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import javax.inject.Inject import javax.inject.Provider class BookmarksViewModel( + private val favoritesRepository: FavoritesRepository, val dao: BookmarksDao, private val faviconManager: FaviconManager, private val bookmarksManager: BookmarksManager, private val dispatcherProvider: DispatcherProvider -) : EditBookmarkListener, ViewModel() { +) : EditSavedSiteListener, ViewModel() { data class ViewState( - val showBookmarks: Boolean = false, val enableSearch: Boolean = false, - val bookmarks: List = emptyList() + val bookmarks: List = emptyList(), + val favorites: List = emptyList() ) sealed class Command { - - class OpenBookmark(val bookmark: BookmarkEntity) : Command() - class ConfirmDeleteBookmark(val bookmark: BookmarkEntity) : Command() - class ShowEditBookmark(val bookmark: BookmarkEntity) : Command() + class OpenSavedSite(val savedSite: SavedSite) : Command() + class ConfirmDeleteSavedSite(val savedSite: SavedSite) : Command() + class ShowEditSavedSite(val savedSite: SavedSite) : Command() data class ImportedBookmarks(val importBookmarksResult: ImportBookmarksResult) : Command() data class ExportedBookmarks(val exportBookmarksResult: ExportBookmarksResult) : Command() - } companion object { @@ -68,12 +71,19 @@ class BookmarksViewModel( val viewState: MutableLiveData = MutableLiveData() val command: SingleLiveEvent = SingleLiveEvent() - private val bookmarks: LiveData> = dao.getBookmarks() - private val bookmarksObserver = Observer> { onBookmarksChanged(it!!) } + private val bookmarks: LiveData> = dao.getBookmarks().map { bookmarks -> + bookmarks.map { Bookmark(it.id, it.title ?: "", it.url) } + } + private val bookmarksObserver = Observer> { onBookmarksChanged(it!!) } init { viewState.value = ViewState() bookmarks.observeForever(bookmarksObserver) + viewModelScope.launch { + favoritesRepository.favorites().collect { + onFavoritesChanged(it) + } + } } override fun onCleared() { @@ -81,42 +91,62 @@ class BookmarksViewModel( bookmarks.removeObserver(bookmarksObserver) } - override fun onBookmarkEdited(id: Long, title: String, url: String) { - Schedulers.io().scheduleDirect { - dao.update(BookmarkEntity(id, title, url)) + override fun onSavedSiteEdited(savedSite: SavedSite) { + when (savedSite) { + is Bookmark -> { + viewModelScope.launch(dispatcherProvider.io()) { + editBookmark(savedSite) + } + } + is Favorite -> { + viewModelScope.launch(dispatcherProvider.io()) { + editFavorite(savedSite) + } + } } } - private fun onBookmarksChanged(bookmarks: List) { - viewState.value = viewState.value?.copy( - showBookmarks = bookmarks.isNotEmpty(), - bookmarks = bookmarks, - enableSearch = bookmarks.size > MIN_BOOKMARKS_FOR_SEARCH - ) + fun onSelected(savedSite: SavedSite) { + command.value = OpenSavedSite(savedSite) } - fun onSelected(bookmark: BookmarkEntity) { - command.value = OpenBookmark(bookmark) + fun onEditSavedSiteRequested(savedSite: SavedSite) { + command.value = ShowEditSavedSite(savedSite) } - fun onDeleteRequested(bookmark: BookmarkEntity) { - command.value = ConfirmDeleteBookmark(bookmark) + fun onDeleteSavedSiteRequested(savedSite: SavedSite) { + delete(savedSite) + command.value = ConfirmDeleteSavedSite(savedSite) } - fun onEditBookmarkRequested(bookmark: BookmarkEntity) { - command.value = ShowEditBookmark(bookmark) - } - - fun delete(bookmark: BookmarkEntity) { - viewModelScope.launch(dispatcherProvider.io() + NonCancellable) { - faviconManager.deletePersistedFavicon(bookmark.url) - dao.delete(bookmark) + private fun delete(savedSite: SavedSite) { + when (savedSite) { + is Bookmark -> { + viewModelScope.launch(dispatcherProvider.io() + NonCancellable) { + faviconManager.deletePersistedFavicon(savedSite.url) + dao.delete(BookmarkEntity(savedSite.id, savedSite.title, savedSite.url)) + } + } + is Favorite -> { + viewModelScope.launch(dispatcherProvider.io() + NonCancellable) { + favoritesRepository.delete(savedSite) + } + } } } - fun insert(bookmark: BookmarkEntity) { - viewModelScope.launch(dispatcherProvider.io()) { - dao.insert(BookmarkEntity(title = bookmark.title, url = bookmark.url)) + fun insert(savedSite: SavedSite) { + when (savedSite) { + is Bookmark -> { + viewModelScope.launch(dispatcherProvider.io()) { + dao.insert(BookmarkEntity(title = savedSite.title, url = savedSite.url)) + } + } + is Favorite -> { + viewModelScope.launch(dispatcherProvider.io()) { + favoritesRepository.insert(savedSite) + } + } } } @@ -137,10 +167,34 @@ class BookmarksViewModel( } } } + + private suspend fun editBookmark(bookmark: Bookmark) { + withContext(dispatcherProvider.io()) { + dao.update(BookmarkEntity(bookmark.id, bookmark.title, bookmark.url)) + } + } + + private suspend fun editFavorite(favorite: Favorite) { + withContext(dispatcherProvider.io()) { + favoritesRepository.update(favorite) + } + } + + private fun onBookmarksChanged(bookmarks: List) { + viewState.value = viewState.value?.copy( + bookmarks = bookmarks, + enableSearch = bookmarks.size > MIN_BOOKMARKS_FOR_SEARCH + ) + } + + private fun onFavoritesChanged(favorites: List) { + viewState.value = viewState.value?.copy(favorites = favorites) + } } @ContributesMultibinding(AppObjectGraph::class) class BookmarksViewModelFactory @Inject constructor( + private val favoritesRepository: Provider, private val dao: Provider, private val faviconManager: Provider, private val bookmarksManager: Provider, @@ -151,6 +205,7 @@ class BookmarksViewModelFactory @Inject constructor( return when { isAssignableFrom(BookmarksViewModel::class.java) -> ( BookmarksViewModel( + favoritesRepository.get(), dao.get(), faviconManager.get(), bookmarksManager.get(), diff --git a/app/src/main/java/com/duckduckgo/app/bookmarks/ui/EditBookmarkDialogFragment.kt b/app/src/main/java/com/duckduckgo/app/bookmarks/ui/EditSavedSiteDialogFragment.kt similarity index 65% rename from app/src/main/java/com/duckduckgo/app/bookmarks/ui/EditBookmarkDialogFragment.kt rename to app/src/main/java/com/duckduckgo/app/bookmarks/ui/EditSavedSiteDialogFragment.kt index 0427f4a9274f..3ae01c40f31f 100644 --- a/app/src/main/java/com/duckduckgo/app/bookmarks/ui/EditBookmarkDialogFragment.kt +++ b/app/src/main/java/com/duckduckgo/app/bookmarks/ui/EditSavedSiteDialogFragment.kt @@ -23,27 +23,28 @@ import android.view.WindowManager import android.widget.EditText import androidx.appcompat.app.AlertDialog import androidx.fragment.app.DialogFragment +import com.duckduckgo.app.bookmarks.model.SavedSite import com.duckduckgo.app.browser.R import com.duckduckgo.app.global.view.hideKeyboard import com.duckduckgo.app.global.view.showKeyboard -class EditBookmarkDialogFragment : DialogFragment() { +class EditSavedSiteDialogFragment : DialogFragment() { - interface EditBookmarkListener { - fun onBookmarkEdited(id: Long, title: String, url: String) + interface EditSavedSiteListener { + fun onSavedSiteEdited(savedSite: SavedSite) } - var listener: EditBookmarkListener? = null + var listener: EditSavedSiteListener? = null override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val rootView = View.inflate(activity, R.layout.edit_bookmark, null) + val rootView = View.inflate(activity, R.layout.edit_saved_site, null) val titleInput = rootView.findViewById(R.id.titleInput) val urlInput = rootView.findViewById(R.id.urlInput) val alertBuilder = AlertDialog.Builder(requireActivity()) .setView(rootView) - .setTitle(R.string.bookmarkTitleEdit) + .setTitle(R.string.savedSiteDialogTitleEdit) .setPositiveButton(R.string.dialogSave) { _, _ -> userAcceptedDialog(titleInput, urlInput) } @@ -58,12 +59,18 @@ class EditBookmarkDialogFragment : DialogFragment() { } private fun userAcceptedDialog(titleInput: EditText, urlInput: EditText) { - listener?.onBookmarkEdited( - getExistingId(), - titleInput.text.toString(), - urlInput.text.toString() - ) - + when (val savedSite = getSavedSite()) { + is SavedSite.Bookmark -> { + listener?.onSavedSiteEdited( + savedSite.copy(title = titleInput.text.toString(), url = urlInput.text.toString()) + ) + } + is SavedSite.Favorite -> { + listener?.onSavedSiteEdited( + savedSite.copy(title = titleInput.text.toString(), url = urlInput.text.toString()) + ) + } + } titleInput.hideKeyboard() } @@ -78,37 +85,27 @@ class EditBookmarkDialogFragment : DialogFragment() { urlInput.setText(getExistingUrl()) } - private fun getExistingId(): Long = requireArguments().getLong(KEY_BOOKMARK_ID) - private fun getExistingTitle(): String? = requireArguments().getString(KEY_PREEXISTING_TITLE) - private fun getExistingUrl(): String? = requireArguments().getString(KEY_PREEXISTING_URL) + private fun getSavedSite(): SavedSite = requireArguments().getSerializable(KEY_SAVED_SITE) as SavedSite + private fun getExistingTitle(): String = getSavedSite().title + private fun getExistingUrl(): String = getSavedSite().url private fun validateBundleArguments() { if (arguments == null) throw IllegalArgumentException("Missing arguments bundle") val args = requireArguments() - if (!args.containsKey(KEY_PREEXISTING_TITLE) || - !args.containsKey(KEY_PREEXISTING_URL) - ) { + if (!args.containsKey(KEY_SAVED_SITE)) { throw IllegalArgumentException("Bundle arguments required [KEY_PREEXISTING_TITLE, KEY_PREEXISTING_URL]") } } companion object { - private const val KEY_BOOKMARK_ID = "KEY_BOOKMARK_ID" - private const val KEY_PREEXISTING_TITLE = "KEY_PREEXISTING_TITLE" - private const val KEY_PREEXISTING_URL = "KEY_PREEXISTING_URL" + private const val KEY_SAVED_SITE = "KEY_SAVED_SITE" - fun instance(bookmarkId: Long, title: String?, url: String?): EditBookmarkDialogFragment { - - val dialog = EditBookmarkDialogFragment() + fun instance(savedSite: SavedSite): EditSavedSiteDialogFragment { + val dialog = EditSavedSiteDialogFragment() val bundle = Bundle() - - bundle.putLong(KEY_BOOKMARK_ID, bookmarkId) - bundle.putString(KEY_PREEXISTING_TITLE, title) - bundle.putString(KEY_PREEXISTING_URL, url) - + bundle.putSerializable(KEY_SAVED_SITE, savedSite) dialog.arguments = bundle return dialog } } - } diff --git a/app/src/main/java/com/duckduckgo/app/bookmarks/ui/FavoritesAdapter.kt b/app/src/main/java/com/duckduckgo/app/bookmarks/ui/FavoritesAdapter.kt new file mode 100644 index 000000000000..12e4d3458ccb --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/bookmarks/ui/FavoritesAdapter.kt @@ -0,0 +1,192 @@ +/* + * Copyright (c) 2021 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.app.bookmarks.ui + +import android.net.Uri +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import com.duckduckgo.app.bookmarks.model.SavedSite.Favorite +import com.duckduckgo.app.browser.R +import com.duckduckgo.app.browser.favicon.FaviconManager +import com.duckduckgo.app.global.baseHost +import kotlinx.android.synthetic.main.popup_window_saved_site_menu.view.* +import kotlinx.android.synthetic.main.view_saved_site_entry.view.* +import kotlinx.android.synthetic.main.view_saved_site_empty_hint.view.* +import kotlinx.android.synthetic.main.view_saved_site_section_title.view.* +import kotlinx.coroutines.launch + +class FavoritesAdapter( + private val layoutInflater: LayoutInflater, + private val viewModel: BookmarksViewModel, + private val lifecycleOwner: LifecycleOwner, + private val faviconManager: FaviconManager +) : ListAdapter(FavoritesDiffCallback()) { + + companion object { + const val FAVORITE_SECTION_TITLE_TYPE = 0 + const val EMPTY_STATE_TYPE = 1 + const val FAVORITE_TYPE = 2 + } + + interface FavoriteItemTypes + object Header : FavoriteItemTypes + object EmptyHint : FavoriteItemTypes + data class FavoriteItem(val favorite: Favorite) : FavoriteItemTypes + + var favoriteItems: List = emptyList() + set(value) { + field = generateNewList(value) + submitList(field) + } + + private fun generateNewList(value: List): List { + return listOf(Header) + (if (value.isEmpty()) listOf(EmptyHint) else value) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): FavoritesScreenViewHolders { + val inflater = LayoutInflater.from(parent.context) + return when (viewType) { + FAVORITE_TYPE -> { + val view = inflater.inflate(R.layout.view_saved_site_entry, parent, false) + return FavoritesScreenViewHolders.FavoriteViewHolder(layoutInflater, view, viewModel, lifecycleOwner, faviconManager) + } + FAVORITE_SECTION_TITLE_TYPE -> { + val view = inflater.inflate(R.layout.view_saved_site_section_title, parent, false) + return FavoritesScreenViewHolders.SectionTitle(view) + } + EMPTY_STATE_TYPE -> { + val view = inflater.inflate(R.layout.view_saved_site_empty_hint, parent, false) + FavoritesScreenViewHolders.EmptyHint(view) + } + else -> throw IllegalArgumentException("viewType not found") + } + } + + override fun getItemCount(): Int { + return favoriteItems.size + } + + override fun onBindViewHolder(holder: FavoritesScreenViewHolders, position: Int) { + when (holder) { + is FavoritesScreenViewHolders.FavoriteViewHolder -> { + holder.update((favoriteItems[position] as FavoriteItem).favorite) + } + is FavoritesScreenViewHolders.SectionTitle -> { + holder.bind() + } + is FavoritesScreenViewHolders.EmptyHint -> { + holder.bind() + } + } + } + + override fun getItemViewType(position: Int): Int { + return when (favoriteItems[position]) { + is Header -> { + FAVORITE_SECTION_TITLE_TYPE + } + is EmptyHint -> { + EMPTY_STATE_TYPE + } + else -> { + FAVORITE_TYPE + } + } + } +} + +sealed class FavoritesScreenViewHolders(itemView: View) : RecyclerView.ViewHolder(itemView) { + + class SectionTitle(itemView: View) : FavoritesScreenViewHolders(itemView) { + fun bind() { + itemView.savedSiteSectionTitle.setText(R.string.favoritesSectionTitle) + } + } + + class EmptyHint(itemView: View) : FavoritesScreenViewHolders(itemView) { + fun bind() { + itemView.savedSiteEmptyHint.setText(R.string.favoritesEmptyHint) + } + } + + class FavoriteViewHolder( + private val layoutInflater: LayoutInflater, + itemView: View, + private val viewModel: BookmarksViewModel, + private val lifecycleOwner: LifecycleOwner, + private val faviconManager: FaviconManager + ) : FavoritesScreenViewHolders(itemView) { + + lateinit var favorite: Favorite + + fun update(favorite: Favorite) { + this.favorite = favorite + + itemView.overflowMenu.contentDescription = itemView.context.getString( + R.string.bookmarkOverflowContentDescription, + favorite.title + ) + + itemView.title.text = favorite.title + itemView.url.text = parseDisplayUrl(favorite.url) + loadFavicon(favorite.url) + + itemView.overflowMenu.setOnClickListener { + showOverFlowMenu(itemView.overflowMenu, favorite) + } + + itemView.setOnClickListener { + viewModel.onSelected(favorite) + } + } + + private fun loadFavicon(url: String) { + lifecycleOwner.lifecycleScope.launch { + faviconManager.loadToViewFromLocalOrFallback(url = url, view = itemView.favicon) + } + } + + private fun parseDisplayUrl(urlString: String): String { + val uri = Uri.parse(urlString) + return uri.baseHost ?: return urlString + } + + private fun showOverFlowMenu(anchor: ImageView, favorite: Favorite) { + val popupMenu = SavedSitePopupMenu(layoutInflater) + val view = popupMenu.contentView + popupMenu.apply { + onMenuItemClicked(view.editSavedSite) { editFavorite(favorite) } + onMenuItemClicked(view.deleteSavedSite) { deleteFavorite(favorite) } + } + popupMenu.show(itemView, anchor) + } + + private fun editFavorite(favorite: Favorite) { + viewModel.onEditSavedSiteRequested(favorite) + } + + private fun deleteFavorite(favorite: Favorite) { + viewModel.onDeleteSavedSiteRequested(favorite) + } + } +} diff --git a/app/src/main/java/com/duckduckgo/app/bookmarks/ui/SavedSiteDiffCallback.kt b/app/src/main/java/com/duckduckgo/app/bookmarks/ui/SavedSiteDiffCallback.kt new file mode 100644 index 000000000000..6c55eada67dc --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/bookmarks/ui/SavedSiteDiffCallback.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2021 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.app.bookmarks.ui + +import androidx.recyclerview.widget.DiffUtil + +class BookmarksDiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: BookmarksAdapter.BookmarksItemTypes, newItem: BookmarksAdapter.BookmarksItemTypes): Boolean { + return oldItem == newItem + } + + override fun areContentsTheSame(oldItem: BookmarksAdapter.BookmarksItemTypes, newItem: BookmarksAdapter.BookmarksItemTypes): Boolean { + return oldItem == newItem + } +} + +class FavoritesDiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: FavoritesAdapter.FavoriteItemTypes, newItem: FavoritesAdapter.FavoriteItemTypes): Boolean { + return oldItem == newItem + } + + override fun areContentsTheSame(oldItem: FavoritesAdapter.FavoriteItemTypes, newItem: FavoritesAdapter.FavoriteItemTypes): Boolean { + return oldItem == newItem + } +} diff --git a/app/src/main/java/com/duckduckgo/app/bookmarks/ui/BookmarksPopupMenu.kt b/app/src/main/java/com/duckduckgo/app/bookmarks/ui/SavedSitePopupMenu.kt similarity index 94% rename from app/src/main/java/com/duckduckgo/app/bookmarks/ui/BookmarksPopupMenu.kt rename to app/src/main/java/com/duckduckgo/app/bookmarks/ui/SavedSitePopupMenu.kt index 8d10d48dd860..6a47b42d0839 100644 --- a/app/src/main/java/com/duckduckgo/app/bookmarks/ui/BookmarksPopupMenu.kt +++ b/app/src/main/java/com/duckduckgo/app/bookmarks/ui/SavedSitePopupMenu.kt @@ -26,7 +26,7 @@ import android.view.ViewGroup.LayoutParams.WRAP_CONTENT import android.widget.PopupWindow import com.duckduckgo.app.browser.R -class BookmarksPopupMenu(layoutInflater: LayoutInflater, view: View = inflate(layoutInflater, R.layout.popup_window_bookmarks_menu)) : +class SavedSitePopupMenu(layoutInflater: LayoutInflater, view: View = inflate(layoutInflater, R.layout.popup_window_saved_site_menu)) : PopupWindow(view, WRAP_CONTENT, WRAP_CONTENT, true) { init { diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserChromeClient.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserChromeClient.kt index a95c584565d3..fd62d0e518ce 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserChromeClient.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserChromeClient.kt @@ -86,10 +86,19 @@ class BrowserChromeClient @Inject constructor(private val uncaughtExceptionRepos override fun onReceivedIcon(webView: WebView, icon: Bitmap) { webView.url?.let { + Timber.i("Favicon bitmap received: ${webView.url}") webViewClientListener?.iconReceived(it, icon) } } + override fun onReceivedTouchIconUrl(view: WebView?, url: String?, precomposed: Boolean) { + Timber.i("Favicon touch received: ${view?.url}, $url") + val visitedUrl = view?.url ?: return + val iconUrl = url ?: return + webViewClientListener?.iconReceived(visitedUrl, iconUrl) + super.onReceivedTouchIconUrl(view, url, precomposed) + } + override fun onReceivedTitle(view: WebView, title: String) { try { webViewClientListener?.titleReceived(title) diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserStateModifier.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserStateModifier.kt index 8a997c782489..9c3b8e396dfb 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserStateModifier.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserStateModifier.kt @@ -30,6 +30,7 @@ class BrowserStateModifier { canReportSite = true, canSharePage = true, canAddBookmarks = true, + canAddFavorite = true, addToHomeEnabled = true ) } @@ -43,6 +44,7 @@ class BrowserStateModifier { canReportSite = false, canSharePage = false, canAddBookmarks = false, + canAddFavorite = false, addToHomeEnabled = false, canGoBack = false ) diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt index e3ef0fd81909..51b955602c2b 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt @@ -26,7 +26,10 @@ import android.content.pm.PackageManager import android.content.res.Configuration import android.media.MediaScannerConnection import android.net.Uri -import android.os.* +import android.os.Bundle +import android.os.Environment +import android.os.Handler +import android.os.Message import android.provider.Settings import android.text.Editable import android.view.* @@ -56,10 +59,11 @@ import androidx.fragment.app.Fragment import androidx.fragment.app.commitNow import androidx.fragment.app.transaction import androidx.lifecycle.* -import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.* import com.duckduckgo.app.autocomplete.api.AutoComplete.AutoCompleteSuggestion import com.duckduckgo.app.autocomplete.api.AutoComplete.AutoCompleteSuggestion.* -import com.duckduckgo.app.bookmarks.ui.EditBookmarkDialogFragment +import com.duckduckgo.app.bookmarks.model.SavedSite +import com.duckduckgo.app.bookmarks.ui.EditSavedSiteDialogFragment import com.duckduckgo.app.brokensite.BrokenSiteActivity import com.duckduckgo.app.brokensite.BrokenSiteData import com.duckduckgo.app.browser.BrowserTabViewModel.* @@ -72,6 +76,10 @@ import com.duckduckgo.app.browser.downloader.DownloadFailReason import com.duckduckgo.app.browser.downloader.FileDownloadNotificationManager import com.duckduckgo.app.browser.downloader.FileDownloader import com.duckduckgo.app.browser.downloader.FileDownloader.PendingFileDownload +import com.duckduckgo.app.browser.favicon.FaviconManager +import com.duckduckgo.app.browser.favorites.FavoritesQuickAccessAdapter +import com.duckduckgo.app.browser.favorites.FavoritesQuickAccessAdapter.Companion.QUICK_ACCESS_ITEM_MAX_SIZE_DP +import com.duckduckgo.app.browser.favorites.QuickAccessDragTouchItemListener import com.duckduckgo.app.browser.filechooser.FileChooserIntentBuilder import com.duckduckgo.app.browser.httpauth.WebViewHttpAuthStore import com.duckduckgo.app.browser.logindetection.DOMLoginDetector @@ -106,6 +114,7 @@ import com.duckduckgo.app.statistics.pixels.Pixel.PixelParameter.FIRE_BUTTON_STA import com.duckduckgo.app.survey.model.Survey import com.duckduckgo.app.survey.ui.SurveyActivity import com.duckduckgo.app.tabs.model.TabEntity +import com.duckduckgo.app.tabs.ui.GridViewColumnCalculator import com.duckduckgo.app.tabs.ui.TabSwitcherActivity import com.duckduckgo.app.widget.ui.AddWidgetInstructionsActivity import com.duckduckgo.widget.SearchWidgetLight @@ -119,6 +128,7 @@ import kotlinx.android.synthetic.main.include_find_in_page.* import kotlinx.android.synthetic.main.include_new_browser_tab.* import kotlinx.android.synthetic.main.include_omnibar_toolbar.* import kotlinx.android.synthetic.main.include_omnibar_toolbar.view.* +import kotlinx.android.synthetic.main.include_quick_access_items.* import kotlinx.android.synthetic.main.popup_window_browser_menu.view.* import kotlinx.coroutines.* import timber.log.Timber @@ -206,6 +216,12 @@ class BrowserTabFragment : @Inject lateinit var emailInjector: EmailInjector + @Inject + lateinit var faviconManager: FaviconManager + + @Inject + lateinit var gridViewColumnCalculator: GridViewColumnCalculator + var messageFromPreviousTab: Message? = null private val initialUrl get() = requireArguments().getString(URL_EXTRA_ARG) @@ -225,6 +241,12 @@ class BrowserTabFragment : private lateinit var decorator: BrowserTabFragmentDecorator + private lateinit var quickAccessAdapter: FavoritesQuickAccessAdapter + private lateinit var quickAccessItemTouchHelper: ItemTouchHelper + + private lateinit var omnibarQuickAccessAdapter: FavoritesQuickAccessAdapter + private lateinit var omnibarQuickAccessItemTouchHelper: ItemTouchHelper + private val viewModel: BrowserTabViewModel by lazy { val viewModel = ViewModelProvider(this, viewModelFactory).get(BrowserTabViewModel::class.java) viewModel.loadData(tabId, initialUrl, skipHome) @@ -310,6 +332,8 @@ class BrowserTabFragment : configureOmnibarTextInput() configureFindInPage() configureAutoComplete() + configureOmnibarQuickAccessGrid() + configureHomeTabQuickAccessGrid() decorator.decorateWithFeatures() @@ -491,18 +515,17 @@ class BrowserTabFragment : private fun showHome() { errorSnackbar.dismiss() newTabLayout.show() + browserLayout.gone() appBarLayout.setExpanded(true) webView?.onPause() webView?.hide() - swipeRefreshContainer.isEnabled = false - homeBackgroundLogo.showLogo() } private fun showBrowser() { newTabLayout.gone() + browserLayout.show() webView?.show() webView?.onResume() - homeBackgroundLogo.hideLogo() } fun submitQuery(query: String) { @@ -540,7 +563,9 @@ class BrowserTabFragment : openInNewBackgroundTab() } is Command.LaunchNewTab -> browserActivity?.launchNewTab() - is Command.ShowBookmarkAddedConfirmation -> bookmarkAdded(it.bookmarkId, it.title, it.url) + is Command.ShowSavedSiteAddedConfirmation -> savedSiteAdded(it.savedSite) + is Command.ShowEditSavedSiteDialog -> editSavedSite(it.savedSite) + is Command.DeleteSavedSiteConfirmation -> confirmDeleteSavedSite(it.savedSite) is Command.ShowFireproofWebSiteConfirmation -> fireproofWebsiteConfirmation(it.fireproofWebsiteEntity) is Command.Navigate -> { navigate(it.url, it.headers) @@ -625,9 +650,14 @@ class BrowserTabFragment : is Command.ConvertBlobToDataUri -> convertBlobToDataUri(it) is Command.RequestFileDownload -> requestFileDownload(it.url, it.contentDisposition, it.mimeType, it.requestUserConfirmation) is Command.ChildTabClosed -> processUriForThirdPartyCookies() + is Command.SubmitQuery -> submitQuery(it.url) is Command.CopyAliasToClipboard -> copyAliasToClipboard(it.alias) is Command.InjectEmailAddress -> injectEmailAddress(it.address) is Command.ShowEmailTooltip -> showEmailTooltip(it.address) + is Command.EditWithSelectedQuery -> { + omnibarTextInput.setText(it.query) + omnibarTextInput.setSelection(it.query.length) + } } } @@ -914,6 +944,61 @@ class BrowserTabFragment : autoCompleteSuggestionsList.adapter = autoCompleteSuggestionsAdapter } + private fun configureOmnibarQuickAccessGrid() { + configureQuickAccessGridLayout(quickAccessSuggestionsRecyclerView) + omnibarQuickAccessAdapter = createQuickAccessAdapter { viewHolder -> + quickAccessSuggestionsRecyclerView.enableAnimation() + omnibarQuickAccessItemTouchHelper.startDrag(viewHolder) + } + omnibarQuickAccessItemTouchHelper = createQuickAccessItemHolder(quickAccessSuggestionsRecyclerView, omnibarQuickAccessAdapter) + quickAccessSuggestionsRecyclerView.adapter = omnibarQuickAccessAdapter + quickAccessSuggestionsRecyclerView.disableAnimation() + } + + private fun configureHomeTabQuickAccessGrid() { + configureQuickAccessGridLayout(quickAccessRecyclerView) + quickAccessAdapter = createQuickAccessAdapter { viewHolder -> + quickAccessRecyclerView.enableAnimation() + quickAccessItemTouchHelper.startDrag(viewHolder) + } + quickAccessItemTouchHelper = createQuickAccessItemHolder(quickAccessRecyclerView, quickAccessAdapter) + quickAccessRecyclerView.adapter = quickAccessAdapter + quickAccessRecyclerView.disableAnimation() + } + + private fun createQuickAccessItemHolder(recyclerView: RecyclerView, apapter: FavoritesQuickAccessAdapter): ItemTouchHelper { + return ItemTouchHelper( + QuickAccessDragTouchItemListener( + apapter, + object : QuickAccessDragTouchItemListener.DragDropListener { + override fun onListChanged(listElements: List) { + viewModel.onQuickAccessListChanged(listElements) + recyclerView.disableAnimation() + } + } + ) + ).also { + it.attachToRecyclerView(recyclerView) + } + } + + private fun createQuickAccessAdapter(onMoveListener: (RecyclerView.ViewHolder) -> Unit): FavoritesQuickAccessAdapter { + return FavoritesQuickAccessAdapter( + this, faviconManager, onMoveListener, + { viewModel.onQuickAccesItemClicked(it.favorite) }, + { viewModel.onEditSavedSiteRequested(it.favorite) }, + { viewModel.onDeleteQuickAccessItemRequested(it.favorite) } + ) + } + + private fun configureQuickAccessGridLayout(recyclerView: RecyclerView) { + val numOfColumns = gridViewColumnCalculator.calculateNumberOfColumns(QUICK_ACCESS_ITEM_MAX_SIZE_DP, QUICK_ACCESS_GRID_MAX_COLUMNS) + val layoutManager = GridLayoutManager(requireContext(), numOfColumns) + recyclerView.layoutManager = layoutManager + val sidePadding = gridViewColumnCalculator.calculateSidePadding(QUICK_ACCESS_ITEM_MAX_SIZE_DP, numOfColumns) + recyclerView.setPadding(sidePadding, recyclerView.paddingTop, sidePadding, recyclerView.paddingBottom) + } + private fun configurePrivacyGrade() { toolbar.privacyGradeButton.setOnClickListener { browserActivity?.launchPrivacyDashboard() @@ -1118,16 +1203,38 @@ class BrowserTabFragment : return super.onContextItemSelected(item) } - private fun bookmarkAdded(bookmarkId: Long, title: String?, url: String?) { - Snackbar.make(browserLayout, R.string.bookmarkEdited, Snackbar.LENGTH_LONG) + private fun savedSiteAdded(savedSite: SavedSite) { + val snackbarMessage = when (savedSite) { + is SavedSite.Bookmark -> R.string.bookmarkAddedMessage + is SavedSite.Favorite -> R.string.favoriteAddedMessage + } + Snackbar.make(browserLayout, snackbarMessage, Snackbar.LENGTH_LONG) .setAction(R.string.edit) { - val addBookmarkDialog = EditBookmarkDialogFragment.instance(bookmarkId, title, url) - addBookmarkDialog.show(childFragmentManager, ADD_BOOKMARK_FRAGMENT_TAG) + val addBookmarkDialog = EditSavedSiteDialogFragment.instance(savedSite) + addBookmarkDialog.show(childFragmentManager, ADD_SAVED_SITE_FRAGMENT_TAG) addBookmarkDialog.listener = viewModel } .show() } + private fun editSavedSite(savedSite: SavedSite) { + val addBookmarkDialog = EditSavedSiteDialogFragment.instance(savedSite) + addBookmarkDialog.show(childFragmentManager, ADD_SAVED_SITE_FRAGMENT_TAG) + addBookmarkDialog.listener = viewModel + } + + private fun confirmDeleteSavedSite(savedSite: SavedSite) { + val message = getString(R.string.bookmarkDeleteConfirmationMessage, savedSite.title).html(requireContext()) + viewModel.deleteQuickAccessItem(savedSite) + Snackbar.make( + rootView, + message, + Snackbar.LENGTH_LONG + ).setAction(R.string.fireproofWebsiteSnackbarAction) { + viewModel.insertQuickAccessItem(savedSite) + }.show() + } + private fun fireproofWebsiteConfirmation(entity: FireproofWebsiteEntity) { Snackbar.make( rootView, @@ -1236,6 +1343,8 @@ class BrowserTabFragment : if (ctaContainer.isNotEmpty()) { renderer.renderHomeCta() } + configureQuickAccessGridLayout(quickAccessRecyclerView) + configureQuickAccessGridLayout(quickAccessSuggestionsRecyclerView) } fun onBackPressed(): Boolean { @@ -1450,7 +1559,7 @@ class BrowserTabFragment : private const val URL_EXTRA_ARG = "URL_EXTRA_ARG" private const val SKIP_HOME_ARG = "SKIP_HOME_ARG" - private const val ADD_BOOKMARK_FRAGMENT_TAG = "ADD_BOOKMARK" + private const val ADD_SAVED_SITE_FRAGMENT_TAG = "ADD_SAVED_SITE" private const val KEYBOARD_DELAY = 200L private const val LAYOUT_TRANSITION_MS = 200L @@ -1470,6 +1579,8 @@ class BrowserTabFragment : private const val DEFAULT_CIRCLE_TARGET_TIMES_1_5 = 96 + private const val QUICK_ACCESS_GRID_MAX_COLUMNS = 6 + fun newInstance(tabId: String, query: String? = null, skipHome: Boolean): BrowserTabFragment { val fragment = BrowserTabFragment() val args = Bundle() @@ -1559,6 +1670,11 @@ class BrowserTabFragment : viewModel.onBookmarkAddRequested() } } + onMenuItemClicked(view.addFavoritePopupMenuItem) { + launch { + viewModel.onAddFavoriteMenuClicked() + } + } onMenuItemClicked(view.findInPageMenuItem) { pixel.fire(AppPixelName.MENU_ACTION_FIND_IN_PAGE_PRESSED) viewModel.onFindInPageSelected() @@ -1675,11 +1791,19 @@ class BrowserTabFragment : renderIfChanged(viewState, lastSeenAutoCompleteViewState) { lastSeenAutoCompleteViewState = viewState - if (viewState.showSuggestions) { - autoCompleteSuggestionsList.show() - autoCompleteSuggestionsAdapter.updateData(viewState.searchResults.query, viewState.searchResults.suggestions) + if (viewState.showSuggestions || viewState.showFavorites) { + if (viewState.favorites.isNotEmpty() && viewState.showFavorites) { + autoCompleteSuggestionsList.gone() + quickAccessSuggestionsRecyclerView.show() + omnibarQuickAccessAdapter.submitList(viewState.favorites) + } else { + autoCompleteSuggestionsList.show() + quickAccessSuggestionsRecyclerView.gone() + autoCompleteSuggestionsAdapter.updateData(viewState.searchResults.query, viewState.searchResults.suggestions) + } } else { autoCompleteSuggestionsList.gone() + quickAccessSuggestionsRecyclerView.gone() } } } @@ -1812,6 +1936,7 @@ class BrowserTabFragment : refreshPopupMenuItem.isEnabled = browserShowing newTabPopupMenuItem.isEnabled = browserShowing addBookmarksPopupMenuItem?.isEnabled = viewState.canAddBookmarks + addFavoritePopupMenuItem?.isEnabled = viewState.canAddFavorite fireproofWebsitePopupMenuItem?.isEnabled = viewState.canFireproofSite fireproofWebsitePopupMenuItem?.isChecked = viewState.canFireproofSite && viewState.isFireproofWebsite sharePageMenuItem?.isEnabled = viewState.canSharePage @@ -1874,17 +1999,18 @@ class BrowserTabFragment : lastSeenCtaViewState = viewState removeNewTabLayoutClickListener() if (viewState.cta != null) { - showCta(viewState.cta) + showCta(viewState.cta, viewState.favorites) } else { hideHomeCta() hideDaxCta() + showHomeBackground(viewState.favorites) } } } - private fun showCta(configuration: Cta) { + private fun showCta(configuration: Cta, favorites: List) { when (configuration) { - is HomePanelCta -> showHomeCta(configuration) + is HomePanelCta -> showHomeCta(configuration, favorites) is DaxBubbleCta -> showDaxCta(configuration) is DialogCta -> showDaxDialogCta(configuration) } @@ -1908,7 +2034,7 @@ class BrowserTabFragment : } private fun showDaxCta(configuration: DaxBubbleCta) { - homeBackgroundLogo.hideLogo() + hideHomeBackground() hideHomeCta() configuration.showCta(daxCtaContainer) newTabLayout.setOnClickListener { daxCtaContainer.dialogTextCta.finishAnimation() } @@ -1919,17 +2045,33 @@ class BrowserTabFragment : newTabLayout.setOnClickListener(null) } - private fun showHomeCta(configuration: HomePanelCta) { + private fun showHomeCta(configuration: HomePanelCta, favorites: List) { hideDaxCta() if (ctaContainer.isEmpty()) { renderHomeCta() } else { configuration.showCta(ctaContainer) } - homeBackgroundLogo.showLogo() + showHomeBackground(favorites) viewModel.onCtaShown() } + private fun showHomeBackground(favorites: List) { + if (favorites.isEmpty()) { + homeBackgroundLogo.showLogo() + quickAccessRecyclerView.gone() + } else { + homeBackgroundLogo.hideLogo() + quickAccessAdapter.submitList(favorites) + quickAccessRecyclerView.show() + } + } + + private fun hideHomeBackground() { + homeBackgroundLogo.hideLogo() + quickAccessRecyclerView.gone() + } + private fun hideDaxCta() { dialogTextCta.cancelAnimation() daxCtaContainer.hide() diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt index c59d7efc8b0c..ea4b94b6e18f 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt @@ -41,7 +41,9 @@ import com.duckduckgo.app.autocomplete.api.AutoComplete.AutoCompleteSuggestion.A import com.duckduckgo.app.autocomplete.api.AutoCompleteApi import com.duckduckgo.app.bookmarks.db.BookmarkEntity import com.duckduckgo.app.bookmarks.db.BookmarksDao -import com.duckduckgo.app.bookmarks.ui.EditBookmarkDialogFragment.EditBookmarkListener +import com.duckduckgo.app.bookmarks.model.FavoritesRepository +import com.duckduckgo.app.bookmarks.model.SavedSite +import com.duckduckgo.app.bookmarks.ui.EditSavedSiteDialogFragment.EditSavedSiteListener import com.duckduckgo.app.brokensite.BrokenSiteData import com.duckduckgo.app.browser.BrowserTabViewModel.Command.* import com.duckduckgo.app.browser.BrowserTabViewModel.GlobalLayoutViewState.Browser @@ -53,6 +55,9 @@ import com.duckduckgo.app.browser.addtohome.AddToHomeCapabilityDetector import com.duckduckgo.app.browser.downloader.DownloadFailReason import com.duckduckgo.app.browser.downloader.FileDownloader import com.duckduckgo.app.browser.favicon.FaviconManager +import com.duckduckgo.app.browser.favicon.FaviconSource.ImageFavicon +import com.duckduckgo.app.browser.favicon.FaviconSource.UrlFavicon +import com.duckduckgo.app.browser.favorites.FavoritesQuickAccessAdapter import com.duckduckgo.app.browser.logindetection.FireproofDialogsEventHandler import com.duckduckgo.app.browser.logindetection.FireproofDialogsEventHandler.Event import com.duckduckgo.app.browser.logindetection.LoginDetected @@ -110,7 +115,6 @@ import io.reactivex.schedulers.Schedulers import kotlinx.coroutines.* import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.withContext import timber.log.Timber import java.io.File import java.util.* @@ -127,6 +131,7 @@ class BrowserTabViewModel( private val userWhitelistDao: UserWhitelistDao, private val networkLeaderboardDao: NetworkLeaderboardDao, private val bookmarksDao: BookmarksDao, + private val favoritesRepository: FavoritesRepository, private val fireproofWebsiteRepository: FireproofWebsiteRepository, private val locationPermissionsRepository: LocationPermissionsRepository, private val geoLocationPermissions: GeoLocationPermissions, @@ -149,7 +154,7 @@ class BrowserTabViewModel( private val globalPrivacyControl: GlobalPrivacyControl, private val fireproofDialogsEventHandler: FireproofDialogsEventHandler, private val emailManager: EmailManager -) : WebViewClientListener, EditBookmarkListener, HttpAuthenticationListener, SiteLocationPermissionDialog.SiteLocationPermissionDialogListener, +) : WebViewClientListener, EditSavedSiteListener, HttpAuthenticationListener, SiteLocationPermissionDialog.SiteLocationPermissionDialogListener, SystemLocationPermissionDialog.SystemLocationPermissionDialogListener, ViewModel() { private var buildingSiteFactoryJob: Job? = null @@ -160,7 +165,8 @@ class BrowserTabViewModel( } data class CtaViewState( - val cta: Cta? = null + val cta: Cta? = null, + val favorites: List = emptyList() ) data class BrowserViewState( @@ -176,6 +182,7 @@ class BrowserTabViewModel( val showMenuButton: Boolean = true, val canSharePage: Boolean = false, val canAddBookmarks: Boolean = false, + val canAddFavorite: Boolean = false, val canFireproofSite: Boolean = false, val isFireproofWebsite: Boolean = false, val canGoBack: Boolean = false, @@ -232,7 +239,9 @@ class BrowserTabViewModel( data class AutoCompleteViewState( val showSuggestions: Boolean = false, - val searchResults: AutoCompleteResult = AutoCompleteResult("", emptyList()) + val showFavorites: Boolean = false, + val searchResults: AutoCompleteResult = AutoCompleteResult("", emptyList()), + val favorites: List = emptyList() ) data class LocationPermission(val origin: String, val callback: GeolocationPermissions.Callback) @@ -254,12 +263,15 @@ class BrowserTabViewModel( object HideKeyboard : Command() class ShowFullScreen(val view: View) : Command() class DownloadImage(val url: String, val requestUserConfirmation: Boolean) : Command() - class ShowBookmarkAddedConfirmation(val bookmarkId: Long, val title: String?, val url: String?) : Command() + class ShowSavedSiteAddedConfirmation(val savedSite: SavedSite) : Command() + class ShowEditSavedSiteDialog(val savedSite: SavedSite) : Command() + class DeleteSavedSiteConfirmation(val savedSite: SavedSite) : Command() class ShowFireproofWebSiteConfirmation(val fireproofWebsiteEntity: FireproofWebsiteEntity) : Command() object AskToDisableLoginDetection : Command() class AskToFireproofWebsite(val fireproofWebsite: FireproofWebsiteEntity) : Command() class ShareLink(val url: String) : Command() class CopyLink(val url: String) : Command() + class SubmitQuery(val url: String) : Command() class FindInPageCommand(val searchTerm: String) : Command() class BrokenSiteFeedback(val data: BrokenSiteData) : Command() object DismissFindInPage : Command() @@ -298,6 +310,7 @@ class BrowserTabViewModel( class ShowDownloadFinishedNotification(val file: File, val mimeType: String?) : DownloadCommand() object ShowDownloadInProgressNotification : DownloadCommand() } + class EditWithSelectedQuery(val query: String) : Command() } val autoCompleteViewState: MutableLiveData = MutableLiveData() @@ -407,6 +420,12 @@ class BrowserTabViewModel( emailManager.signedInFlow().onEach { isSignedIn -> browserViewState.value = currentBrowserViewState().copy(isEmailSignedIn = isSignedIn) }.launchIn(viewModelScope) + + favoritesRepository.favorites().onEach { favorite -> + val favorites = favorite.map { FavoritesQuickAccessAdapter.QuickAccessFavorite(it) } + ctaViewState.value = currentCtaViewState().copy(favorites = favorites) + autoCompleteViewState.value = currentAutoCompleteViewState().copy(favorites = favorites) + }.launchIn(viewModelScope) } fun loadData(tabId: String, initialUrl: String?, skipHome: Boolean) { @@ -562,7 +581,7 @@ class BrowserTabViewModel( findInPageViewState.value = FindInPageViewState(visible = false, canFindInPage = true) omnibarViewState.value = currentOmnibarViewState().copy(omnibarText = trimmedInput, shouldMoveCaretToEnd = false) browserViewState.value = currentBrowserViewState().copy(browserShowing = true, showClearButton = false) - autoCompleteViewState.value = AutoCompleteViewState(false) + autoCompleteViewState.value = currentAutoCompleteViewState().copy(showSuggestions = false, showFavorites = false, searchResults = AutoCompleteResult("", emptyList())) } private fun getUrlHeaders(): Map = globalPrivacyControl.getHeaders() @@ -613,7 +632,7 @@ class BrowserTabViewModel( override fun prefetchFavicon(url: String) { faviconPrefetchJob?.cancel() faviconPrefetchJob = viewModelScope.launch { - val faviconFile = faviconManager.prefetchToTemp(tabId, url) + val faviconFile = faviconManager.tryFetchFaviconForUrl(tabId = tabId, url = url) if (faviconFile != null) { tabRepository.updateTabFavicon(tabId, faviconFile.name) } @@ -628,9 +647,24 @@ class BrowserTabViewModel( Timber.d("Favicon received for a url $url, different than the current one $currentUrl") return } + viewModelScope.launch(dispatchers.io()) { + val faviconFile = faviconManager.storeFavicon(currentTab.tabId, ImageFavicon(icon, url)) + faviconFile?.let { + tabRepository.updateTabFavicon(tabId, faviconFile.name) + } + } + } + override fun iconReceived(visitedUrl: String, iconUrl: String) { + val currentTab = tabRepository.liveSelectedTab.value ?: return + val currentUrl = currentTab.url ?: return + if (currentUrl.toUri().host != visitedUrl.toUri().host) { + pixel.enqueueFire(AppPixelName.FAVICON_WRONG_URL_ERROR) + Timber.d("Favicon received for a url $visitedUrl, different than the current one $currentUrl") + return + } viewModelScope.launch { - val faviconFile = faviconManager.saveToTemp(currentTab.tabId, icon, url) + val faviconFile = faviconManager.storeFavicon(currentTab.tabId, UrlFavicon(iconUrl, visitedUrl)) faviconFile?.let { tabRepository.updateTabFavicon(tabId, faviconFile.name) } @@ -807,6 +841,7 @@ class BrowserTabViewModel( browserViewState.value = currentBrowserViewState.copy( browserShowing = true, canAddBookmarks = true, + canAddFavorite = true, addToHomeEnabled = true, addToHomeVisible = addToHomeCapabilityDetector.isAddToHomeSupported(), canSharePage = true, @@ -913,6 +948,7 @@ class BrowserTabViewModel( val currentBrowserViewState = currentBrowserViewState() browserViewState.value = currentBrowserViewState.copy( canAddBookmarks = false, + canAddFavorite = false, addToHomeEnabled = false, addToHomeVisible = addToHomeCapabilityDetector.isAddToHomeSupported(), canSharePage = false, @@ -1006,7 +1042,7 @@ class BrowserTabViewModel( pixel.fire(AppPixelName.PRECISE_LOCATION_SITE_DIALOG_ALLOW_ALWAYS) viewModelScope.launch { locationPermissionsRepository.savePermission(domain, permission) - faviconManager.persistFavicon(tabId, domain) + faviconManager.persistCachedFavicon(tabId, domain) } } LocationPermissionType.ALLOW_ONCE -> { @@ -1019,7 +1055,7 @@ class BrowserTabViewModel( onSiteLocationPermissionAlwaysDenied() viewModelScope.launch { locationPermissionsRepository.savePermission(domain, permission) - faviconManager.persistFavicon(tabId, domain) + faviconManager.persistCachedFavicon(tabId, domain) } } LocationPermissionType.DENY_ONCE -> { @@ -1224,15 +1260,22 @@ class BrowserTabViewModel( fun onOmnibarInputStateChanged(query: String, hasFocus: Boolean, hasQueryChanged: Boolean) { // determine if empty list to be shown, or existing search results - val autoCompleteSearchResults = if (query.isBlank()) { + val autoCompleteSearchResults = if (query.isBlank() || !hasFocus) { AutoCompleteResult(query, emptyList()) } else { currentAutoCompleteViewState().searchResults } - val currentOmnibarViewState = currentOmnibarViewState() val autoCompleteSuggestionsEnabled = appSettingsPreferencesStore.autoCompleteSuggestionsEnabled val showAutoCompleteSuggestions = hasFocus && query.isNotBlank() && hasQueryChanged && autoCompleteSuggestionsEnabled + val showFavoritesAsSuggestions = if (!showAutoCompleteSuggestions) { + val urlFocused = hasFocus && query.isNotBlank() && !hasQueryChanged && UriString.isWebUrl(query) + val emptyQueryBrowsing = query.isBlank() && currentBrowserViewState().browserShowing + val favoritesAvailable = currentAutoCompleteViewState().favorites.isNotEmpty() + hasFocus && (urlFocused || emptyQueryBrowsing) && favoritesAvailable + } else { + false + } val showClearButton = hasFocus && query.isNotBlank() val showControls = !hasFocus || query.isBlank() val showPrivacyGrade = !hasFocus @@ -1243,7 +1286,7 @@ class BrowserTabViewModel( privacyGradeViewState.value = currentPrivacyGradeState().copy(showEmptyGrade = false) } - omnibarViewState.value = currentOmnibarViewState.copy(isEditing = hasFocus) + omnibarViewState.value = currentOmnibarViewState().copy(isEditing = hasFocus) val currentBrowserViewState = currentBrowserViewState() browserViewState.value = currentBrowserViewState.copy( @@ -1262,7 +1305,8 @@ class BrowserTabViewModel( Timber.d("showPrivacyGrade=$showPrivacyGrade, showSearchIcon=$showSearchIcon, showClearButton=$showClearButton") - autoCompleteViewState.value = AutoCompleteViewState(showAutoCompleteSuggestions, autoCompleteSearchResults) + autoCompleteViewState.value = currentAutoCompleteViewState() + .copy(showSuggestions = showAutoCompleteSuggestions, showFavorites = showFavoritesAsSuggestions, searchResults = autoCompleteSearchResults) if (hasQueryChanged && hasFocus && autoCompleteSuggestionsEnabled) { autoCompletePublishSubject.accept(query.trim()) @@ -1270,16 +1314,34 @@ class BrowserTabViewModel( } suspend fun onBookmarkAddRequested() { - val url = url ?: "" + val url = url ?: return val title = title ?: "" - val id = withContext(dispatchers.io()) { + val savedBookmark = withContext(dispatchers.io()) { if (url.isNotBlank()) { - faviconManager.persistFavicon(tabId, url) + faviconManager.persistCachedFavicon(tabId, url) } - bookmarksDao.insert(BookmarkEntity(title = title, url = url)) + val bookmarkEntity = BookmarkEntity(title = title, url = url) + val id = bookmarksDao.insert(bookmarkEntity) + SavedSite.Bookmark(id, title, url) } withContext(dispatchers.main()) { - command.value = ShowBookmarkAddedConfirmation(id, title, url) + command.value = ShowSavedSiteAddedConfirmation(savedBookmark) + } + } + + suspend fun onAddFavoriteMenuClicked() { + val url = url ?: return + val title = title ?: "" + + withContext(dispatchers.io()) { + if (url.isNotBlank()) { + faviconManager.persistCachedFavicon(tabId, url) + favoritesRepository.insert(title = title, url = url) + } else null + }?.let { + withContext(dispatchers.main()) { + command.value = ShowSavedSiteAddedConfirmation(it) + } } } @@ -1293,7 +1355,7 @@ class BrowserTabViewModel( fireproofWebsiteRepository.fireproofWebsite(domain)?.let { pixel.fire(AppPixelName.FIREPROOF_WEBSITE_ADDED) command.value = ShowFireproofWebSiteConfirmation(fireproofWebsiteEntity = it) - faviconManager.persistFavicon(tabId, url = domain) + faviconManager.persistCachedFavicon(tabId, url = domain) } } } @@ -1342,15 +1404,38 @@ class BrowserTabViewModel( } } - override fun onBookmarkEdited(id: Long, title: String, url: String) { - viewModelScope.launch(dispatchers.io()) { - editBookmark(id, title, url) + override fun onSavedSiteEdited(savedSite: SavedSite) { + when (savedSite) { + is SavedSite.Bookmark -> { + viewModelScope.launch(dispatchers.io()) { + editBookmark(savedSite) + } + } + is SavedSite.Favorite -> { + viewModelScope.launch(dispatchers.io()) { + editFavorite(savedSite) + } + } } } - suspend fun editBookmark(id: Long, title: String, url: String) { + fun onEditSavedSiteRequested(savedSite: SavedSite) { + command.value = ShowEditSavedSiteDialog(savedSite) + } + + fun onDeleteQuickAccessItemRequested(savedSite: SavedSite) { + command.value = DeleteSavedSiteConfirmation(savedSite) + } + + private suspend fun editBookmark(bookmark: SavedSite.Bookmark) { + withContext(dispatchers.io()) { + bookmarksDao.update(BookmarkEntity(bookmark.id, bookmark.title, bookmark.url)) + } + } + + private suspend fun editFavorite(favorite: SavedSite.Favorite) { withContext(dispatchers.io()) { - bookmarksDao.update(BookmarkEntity(id, title, url)) + favoritesRepository.update(favorite) } } @@ -1391,8 +1476,7 @@ class BrowserTabViewModel( } fun onUserSelectedToEditQuery(query: String) { - omnibarViewState.value = currentOmnibarViewState().copy(isEditing = false, omnibarText = query, shouldMoveCaretToEnd = true) - autoCompleteViewState.value = AutoCompleteViewState(showSuggestions = false) + command.value = EditWithSelectedQuery(query) } fun userLongPressedInWebView(target: LongPressTarget, menu: ContextMenu) { @@ -1566,7 +1650,7 @@ class BrowserTabViewModel( } viewModelScope.launch { - val favicon: Bitmap? = faviconManager.loadFromTemp(tabId, currentPage) + val favicon: Bitmap? = faviconManager.loadFromDisk(tabId = tabId, url = currentPage) command.value = AddHomeShortcut(title, currentPage, favicon) } } @@ -1832,6 +1916,30 @@ class BrowserTabViewModel( } } + fun onQuickAccesItemClicked(it: SavedSite) { + command.value = SubmitQuery(it.url) + } + + fun deleteQuickAccessItem(savedSite: SavedSite) { + val favorite = savedSite as? SavedSite.Favorite ?: return + viewModelScope.launch(dispatchers.io() + NonCancellable) { + favoritesRepository.delete(favorite) + } + } + + fun insertQuickAccessItem(savedSite: SavedSite) { + val favorite = savedSite as? SavedSite.Favorite ?: return + viewModelScope.launch(dispatchers.io()) { + favoritesRepository.insert(favorite) + } + } + + fun onQuickAccessListChanged(newList: List) { + viewModelScope.launch(dispatchers.io()) { + favoritesRepository.updateWithPosition(newList.map { it.favorite }) + } + } + companion object { private const val FIXED_PROGRESS = 50 @@ -1853,6 +1961,7 @@ class BrowserTabViewModelFactory @Inject constructor( private val userWhitelistDao: Provider, private val networkLeaderboardDao: Provider, private val bookmarksDao: Provider, + private val favoritesRepository: Provider, private val fireproofWebsiteRepository: Provider, private val locationPermissionsRepository: Provider, private val geoLocationPermissions: Provider, @@ -1879,7 +1988,7 @@ class BrowserTabViewModelFactory @Inject constructor( override fun create(modelClass: Class): T? { with(modelClass) { return when { - isAssignableFrom(BrowserTabViewModel::class.java) -> BrowserTabViewModel(statisticsUpdater.get(), queryUrlConverter.get(), duckDuckGoUrlDetector.get(), siteFactory.get(), tabRepository.get(), userWhitelistDao.get(), networkLeaderboardDao.get(), bookmarksDao.get(), fireproofWebsiteRepository.get(), locationPermissionsRepository.get(), geoLocationPermissions.get(), navigationAwareLoginDetector.get(), autoComplete.get(), appSettingsPreferencesStore.get(), longPressHandler.get(), webViewSessionStorage.get(), specialUrlDetector.get(), faviconManager.get(), addToHomeCapabilityDetector.get(), ctaViewModel.get(), searchCountDao.get(), pixel.get(), dispatchers, userEventsStore.get(), notificationDao.get(), variantManager.get(), fileDownloader.get(), globalPrivacyControl.get(), fireproofDialogsEventHandler.get(), emailManager.get()) as T + isAssignableFrom(BrowserTabViewModel::class.java) -> BrowserTabViewModel(statisticsUpdater.get(), queryUrlConverter.get(), duckDuckGoUrlDetector.get(), siteFactory.get(), tabRepository.get(), userWhitelistDao.get(), networkLeaderboardDao.get(), bookmarksDao.get(), favoritesRepository.get(), fireproofWebsiteRepository.get(), locationPermissionsRepository.get(), geoLocationPermissions.get(), navigationAwareLoginDetector.get(), autoComplete.get(), appSettingsPreferencesStore.get(), longPressHandler.get(), webViewSessionStorage.get(), specialUrlDetector.get(), faviconManager.get(), addToHomeCapabilityDetector.get(), ctaViewModel.get(), searchCountDao.get(), pixel.get(), dispatchers, userEventsStore.get(), notificationDao.get(), variantManager.get(), fileDownloader.get(), globalPrivacyControl.get(), fireproofDialogsEventHandler.get(), emailManager.get()) as T else -> null } } diff --git a/app/src/main/java/com/duckduckgo/app/browser/WebViewClientListener.kt b/app/src/main/java/com/duckduckgo/app/browser/WebViewClientListener.kt index 0da3f3cbc32c..5b6596c3e238 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/WebViewClientListener.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/WebViewClientListener.kt @@ -60,5 +60,6 @@ interface WebViewClientListener { fun loginDetected() fun dosAttackDetected() fun iconReceived(url: String, icon: Bitmap) + fun iconReceived(visitedUrl: String, iconUrl: String) fun prefetchFavicon(url: String) } diff --git a/app/src/main/java/com/duckduckgo/app/browser/favicon/FaviconDownloader.kt b/app/src/main/java/com/duckduckgo/app/browser/favicon/FaviconDownloader.kt index 21158c8a5282..c0a07d5e15ab 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/favicon/FaviconDownloader.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/favicon/FaviconDownloader.kt @@ -19,11 +19,8 @@ package com.duckduckgo.app.browser.favicon import android.content.Context import android.graphics.Bitmap import android.net.Uri -import android.widget.ImageView -import androidx.core.content.ContextCompat import com.bumptech.glide.Glide import com.bumptech.glide.load.engine.DiskCacheStrategy -import com.duckduckgo.app.browser.R import com.duckduckgo.app.global.DispatcherProvider import kotlinx.coroutines.withContext import java.io.File @@ -32,8 +29,6 @@ import javax.inject.Inject interface FaviconDownloader { suspend fun getFaviconFromDisk(file: File): Bitmap? suspend fun getFaviconFromUrl(uri: Uri): Bitmap? - suspend fun loadFaviconToView(file: File, view: ImageView) - suspend fun loadDefaultFaviconToView(view: ImageView) } class GlideFaviconDownloader @Inject constructor( @@ -66,23 +61,4 @@ class GlideFaviconDownloader @Inject constructor( }.getOrNull() } } - - override suspend fun loadFaviconToView(file: File, view: ImageView) { - withContext(dispatcherProvider.main()) { - Glide.with(context) - .load(file) - .diskCacheStrategy(DiskCacheStrategy.NONE) - .skipMemoryCache(true) - .placeholder(R.drawable.ic_globe_gray_16dp) - .error(R.drawable.ic_globe_gray_16dp) - .into(view) - } - } - - override suspend fun loadDefaultFaviconToView(view: ImageView) { - withContext(dispatcherProvider.main()) { - view.setImageDrawable(ContextCompat.getDrawable(view.context, R.drawable.ic_globe_gray_16dp)) - } - } - } diff --git a/app/src/main/java/com/duckduckgo/app/browser/favicon/FaviconManager.kt b/app/src/main/java/com/duckduckgo/app/browser/favicon/FaviconManager.kt index 831d6af36b1b..82a0dc9adf39 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/favicon/FaviconManager.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/favicon/FaviconManager.kt @@ -21,6 +21,7 @@ import android.net.Uri import android.widget.ImageView import androidx.core.net.toUri import com.duckduckgo.app.bookmarks.db.BookmarksDao +import com.duckduckgo.app.bookmarks.model.FavoritesRepository import com.duckduckgo.app.browser.favicon.FileBasedFaviconPersister.Companion.FAVICON_PERSISTED_DIR import com.duckduckgo.app.browser.favicon.FileBasedFaviconPersister.Companion.FAVICON_TEMP_DIR import com.duckduckgo.app.browser.favicon.FileBasedFaviconPersister.Companion.NO_SUBFOLDER @@ -28,156 +29,204 @@ import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteRepository import com.duckduckgo.app.global.DispatcherProvider import com.duckduckgo.app.global.domain import com.duckduckgo.app.global.faviconLocation +import com.duckduckgo.app.global.touchFaviconLocation +import com.duckduckgo.app.global.view.loadFavicon import com.duckduckgo.app.location.data.LocationPermissionsRepository import kotlinx.coroutines.withContext import java.io.File interface FaviconManager { - suspend fun saveToTemp(subFolder: String, icon: Bitmap, url: String): File? - suspend fun prefetchToTemp(subFolder: String, url: String): File? - suspend fun persistFavicon(subFolder: String, url: String) - suspend fun loadFromTemp(subFolder: String, url: String): Bitmap? - suspend fun loadToViewFromTemp(subFolder: String, url: String, view: ImageView) - suspend fun loadToViewFromPersisted(url: String, view: ImageView) + suspend fun storeFavicon(tabId: String, faviconSource: FaviconSource): File? + suspend fun tryFetchFaviconForUrl(tabId: String, url: String): File? + suspend fun persistCachedFavicon(tabId: String, url: String) + suspend fun loadToViewFromLocalOrFallback(tabId: String? = null, url: String, view: ImageView) + suspend fun loadFromDisk(tabId: String?, url: String): Bitmap? suspend fun deletePersistedFavicon(url: String) - suspend fun deleteOldTempFavicon(subFolder: String, path: String?) + suspend fun deleteOldTempFavicon(tabId: String, path: String?) suspend fun deleteAllTemp() } +sealed class FaviconSource { + data class ImageFavicon(val icon: Bitmap, val url: String) : FaviconSource() + data class UrlFavicon(val faviconUrl: String, val url: String) : FaviconSource() +} + class DuckDuckGoFaviconManager constructor( private val faviconPersister: FaviconPersister, private val bookmarksDao: BookmarksDao, private val fireproofWebsiteRepository: FireproofWebsiteRepository, private val locationPermissionsRepository: LocationPermissionsRepository, + private val favoritesRepository: FavoritesRepository, private val faviconDownloader: FaviconDownloader, private val dispatcherProvider: DispatcherProvider ) : FaviconManager { - override suspend fun loadFromTemp(subFolder: String, url: String): Bitmap? { - val domain = extractDomain(url) - val cachedFavicon = faviconPersister.faviconFile(FAVICON_TEMP_DIR, subFolder, domain) - return if (cachedFavicon != null) { - faviconDownloader.getFaviconFromDisk(cachedFavicon) - } else { - null + private val tempFaviconCache: HashMap>> = hashMapOf() + + override suspend fun storeFavicon(tabId: String, faviconSource: FaviconSource): File? { + val (domain, favicon) = when (faviconSource) { + is FaviconSource.ImageFavicon -> { + val domain = faviconSource.url.extractDomain() ?: return null + invalidateCacheIfNewDomain(tabId, domain) + Pair(domain, faviconSource.icon) + } + is FaviconSource.UrlFavicon -> { + val domain = faviconSource.url.extractDomain() ?: return null + invalidateCacheIfNewDomain(tabId, domain) + if (shouldSkipNetworkRequest(tabId, faviconSource)) return null + val bitmap = faviconDownloader.getFaviconFromUrl(faviconSource.faviconUrl.toUri()) ?: return null + addFaviconUrlToCache(tabId, faviconSource) + Pair(domain, bitmap) + } } - } - override suspend fun loadToViewFromPersisted(url: String, view: ImageView) { - // avoid displaying the previous favicon when the holder is recycled - faviconDownloader.loadDefaultFaviconToView(view) - loadToViewFromDirectory(url, FAVICON_PERSISTED_DIR, NO_SUBFOLDER, view) + return saveFavicon(tabId, favicon, domain) } - override suspend fun loadToViewFromTemp(subFolder: String, url: String, view: ImageView) { - // avoid displaying the previous favicon when the holder is recycled - faviconDownloader.loadDefaultFaviconToView(view) - loadToViewFromDirectory(url, FAVICON_TEMP_DIR, subFolder, view) - } + override suspend fun tryFetchFaviconForUrl(tabId: String, url: String): File? { + val domain = url.extractDomain() ?: return null + + val favicon = downloadFaviconFor(domain) - override suspend fun prefetchToTemp(subFolder: String, url: String): File? { - val domain = url.toUri().domain() ?: return null - val favicon = downloadFromUrl(url) return if (favicon != null) { - saveToFile(FAVICON_TEMP_DIR, subFolder, favicon, domain) + saveFavicon(tabId, favicon, domain) } else { null } } - override suspend fun saveToTemp(subFolder: String, icon: Bitmap, url: String): File? { - val domain = extractDomain(url) - val file = faviconPersister.store(FAVICON_TEMP_DIR, subFolder, icon, domain) - if (file != null) { // Only replace persisted favicons if we stored a new file - replacePersistedFavicons(icon, domain) + override suspend fun loadFromDisk(tabId: String?, url: String): Bitmap? { + val domain = url.extractDomain() ?: return null + + var cachedFavicon: File? = null + if (tabId != null) { + cachedFavicon = faviconPersister.faviconFile(FAVICON_TEMP_DIR, tabId, domain) + } + if (cachedFavicon == null) { + cachedFavicon = faviconPersister.faviconFile(FAVICON_PERSISTED_DIR, NO_SUBFOLDER, domain) + } + + return if (cachedFavicon != null) { + faviconDownloader.getFaviconFromDisk(cachedFavicon) + } else null + } + + override suspend fun loadToViewFromLocalOrFallback(tabId: String?, url: String, view: ImageView) { + val bitmap = loadFromDisk(tabId, url) + + if (bitmap == null) { + view.loadFavicon(bitmap, url) + val domain = url.extractDomain() ?: return + tryRemoteFallbackFavicon(subFolder = tabId, domain)?.let { + view.loadFavicon(it, url) + } + } else { + view.loadFavicon(bitmap, url) } - return file + } - override suspend fun persistFavicon(subFolder: String, url: String) { - val domain = extractDomain(url) - val cachedFavicon = faviconPersister.faviconFile(FAVICON_TEMP_DIR, subFolder, domain) + override suspend fun persistCachedFavicon(tabId: String, url: String) { + val domain = url.extractDomain() ?: return + val cachedFavicon = faviconPersister.faviconFile(FAVICON_TEMP_DIR, tabId, domain) if (cachedFavicon != null) { faviconPersister.copyToDirectory(cachedFavicon, FAVICON_PERSISTED_DIR, NO_SUBFOLDER, domain) } } override suspend fun deletePersistedFavicon(url: String) { - val domain = extractDomain(url) + val domain = url.extractDomain() ?: return val remainingFavicons = persistedFaviconsForDomain(domain) if (remainingFavicons == 1) { faviconPersister.deletePersistedFavicon(domain) } } - override suspend fun deleteOldTempFavicon(subFolder: String, path: String?) { - faviconPersister.deleteFaviconsForSubfolder(FAVICON_TEMP_DIR, subFolder, path) + override suspend fun deleteOldTempFavicon(tabId: String, path: String?) { + removeCacheForTab(path, tabId) + faviconPersister.deleteFaviconsForSubfolder(FAVICON_TEMP_DIR, tabId, path) } override suspend fun deleteAllTemp() { faviconPersister.deleteAll(FAVICON_TEMP_DIR) } - private suspend fun loadToViewFromDirectory(url: String, directory: String, subFolder: String, view: ImageView) { - val domain = extractDomain(url) - val cachedFavicon = getFaviconForDomainOrFallback(directory, subFolder, domain) - cachedFavicon?.let { - loadFaviconToView(cachedFavicon, view) + private suspend fun saveFavicon(subFolder: String?, favicon: Bitmap, domain: String): File? { + return if (subFolder != null) { + faviconPersister.store(FAVICON_TEMP_DIR, subFolder, favicon, domain) + ?.also { replacePersistedFavicons(favicon, domain) } + } else { + replacePersistedFavicons(favicon, domain) } } - private suspend fun downloadFromUrl(url: String): Bitmap? { - val faviconUrl = getFaviconUrl(url) ?: return null - return faviconDownloader.getFaviconFromUrl(faviconUrl) - } - - private fun getFaviconUrl(url: String): Uri? { - return if (url.toUri().host.isNullOrBlank()) { - "https://$url".toUri().faviconLocation() - } else { - url.toUri().faviconLocation() + private suspend fun downloadFaviconFor(domain: String): Bitmap? { + val faviconUrl = getFaviconUrl(domain) ?: return null + val touchFaviconUrl = getTouchFaviconUrl(domain) ?: return null + faviconDownloader.getFaviconFromUrl(touchFaviconUrl)?.let { + return it + } ?: faviconDownloader.getFaviconFromUrl(faviconUrl).let { + return it } } - private suspend fun loadFaviconToView(file: File, view: ImageView) { - faviconDownloader.loadFaviconToView(file, view) + private fun getFaviconUrl(domain: String): Uri? { + return "https://$domain".toUri().faviconLocation() } - private suspend fun replacePersistedFavicons(icon: Bitmap, domain: String) { - if (persistedFaviconsForDomain(domain) > 0) { - saveToFile(FAVICON_PERSISTED_DIR, NO_SUBFOLDER, icon, domain) - } + private fun getTouchFaviconUrl(domain: String): Uri? { + return "https://$domain".toUri().touchFaviconLocation() } - private suspend fun getFaviconForDomainOrFallback(directory: String, subFolder: String, domain: String): File? { - val cachedFavicon: File? = faviconPersister.faviconFile(directory, subFolder, domain) + private suspend fun replacePersistedFavicons(icon: Bitmap, domain: String): File? { + return if (persistedFaviconsForDomain(domain) > 0) { + faviconPersister.store(FAVICON_PERSISTED_DIR, NO_SUBFOLDER, icon, domain) + } else null + } - return if (cachedFavicon == null) { - val favicon = downloadFromUrl(domain) - if (favicon != null) { - saveToFile(directory, subFolder, favicon, domain) - } else { - null - } + private suspend fun tryRemoteFallbackFavicon(subFolder: String?, domain: String): File? { + val favicon = downloadFaviconFor(domain) + return if (favicon != null) { + saveFavicon(subFolder, favicon, domain) } else { - cachedFavicon + null } } - private suspend fun saveToFile(directory: String, subFolder: String, icon: Bitmap, domain: String): File? { - return faviconPersister.store(directory, subFolder, icon, domain) - } - private suspend fun persistedFaviconsForDomain(domain: String): Int { val query = "%$domain%" return withContext(dispatcherProvider.io()) { bookmarksDao.bookmarksCountByUrl(query) + locationPermissionsRepository.permissionEntitiesCountByDomain(query) + - fireproofWebsiteRepository.fireproofWebsitesCountByDomain(domain) + fireproofWebsiteRepository.fireproofWebsitesCountByDomain(domain) + + favoritesRepository.favoritesCountByDomain(query) } } - private fun extractDomain(url: String): String { - return url.toUri().domain() ?: url + private fun String.extractDomain(): String? { + return if (this.startsWith("http")) { + this.toUri().domain() + } else { + "https://$this".extractDomain() + } + } + + private fun invalidateCacheIfNewDomain(tabId: String, domain: String) { + if (tempFaviconCache[tabId]?.first != domain) { + tempFaviconCache[tabId] = Pair(domain, mutableListOf()) + } + } + + private fun removeCacheForTab(path: String?, tabId: String) { + if (path == null) { + tempFaviconCache.remove(tabId) + } + } + + private fun shouldSkipNetworkRequest(tabId: String, faviconSource: FaviconSource.UrlFavicon) = + tempFaviconCache[tabId]?.second?.contains(faviconSource.faviconUrl) == true + + private fun addFaviconUrlToCache(tabId: String, faviconSource: FaviconSource.UrlFavicon) { + tempFaviconCache[tabId]?.second?.add(faviconSource.faviconUrl) } } diff --git a/app/src/main/java/com/duckduckgo/app/browser/favicon/FaviconModule.kt b/app/src/main/java/com/duckduckgo/app/browser/favicon/FaviconModule.kt index 3f14e993accd..fa5684716a4d 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/favicon/FaviconModule.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/favicon/FaviconModule.kt @@ -18,6 +18,7 @@ package com.duckduckgo.app.browser.favicon import android.content.Context import com.duckduckgo.app.bookmarks.db.BookmarksDao +import com.duckduckgo.app.bookmarks.model.FavoritesRepository import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteRepository import com.duckduckgo.app.global.DispatcherProvider import com.duckduckgo.app.location.data.LocationPermissionsRepository @@ -35,6 +36,7 @@ class FaviconModule { bookmarksDao: BookmarksDao, fireproofWebsiteRepository: FireproofWebsiteRepository, locationPermissionsRepository: LocationPermissionsRepository, + favoritesRepository: FavoritesRepository, faviconDownloader: FaviconDownloader, dispatcherProvider: DispatcherProvider ): FaviconManager { @@ -43,6 +45,7 @@ class FaviconModule { bookmarksDao, fireproofWebsiteRepository, locationPermissionsRepository, + favoritesRepository, faviconDownloader, dispatcherProvider ) diff --git a/app/src/main/java/com/duckduckgo/app/browser/favicon/FaviconPersister.kt b/app/src/main/java/com/duckduckgo/app/browser/favicon/FaviconPersister.kt index 7a40d8cff161..a08b4193d3b9 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/favicon/FaviconPersister.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/favicon/FaviconPersister.kt @@ -24,6 +24,7 @@ import com.duckduckgo.app.global.file.FileDeleter import com.duckduckgo.app.global.sha256 import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.withContext +import timber.log.Timber import java.io.File import java.io.FileOutputStream @@ -105,7 +106,7 @@ class FileBasedFaviconPersister( val existingFile = fileForFavicon(directory, subFolder, domain) if (existingFile.exists()) { - + Timber.i("Favicon favicon exists for $domain in $subFolder") val existingFavicon = BitmapFactory.decodeFile(existingFile.absolutePath) existingFavicon?.let { diff --git a/app/src/main/java/com/duckduckgo/app/browser/favorites/FavoritesQuickAccessAdapter.kt b/app/src/main/java/com/duckduckgo/app/browser/favorites/FavoritesQuickAccessAdapter.kt new file mode 100644 index 000000000000..c60abc00a82c --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/browser/favorites/FavoritesQuickAccessAdapter.kt @@ -0,0 +1,256 @@ +/* + * Copyright (c) 2021 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.app.browser.favorites + +import android.animation.ObjectAnimator +import android.animation.PropertyValuesHolder +import android.annotation.SuppressLint +import android.os.Bundle +import android.view.* +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import com.duckduckgo.app.bookmarks.model.SavedSite +import com.duckduckgo.app.bookmarks.ui.FavoritesAdapter +import com.duckduckgo.app.browser.R +import com.duckduckgo.app.browser.favicon.FaviconManager +import com.duckduckgo.app.browser.favorites.FavoritesQuickAccessAdapter.QuickAccessFavorite +import com.duckduckgo.app.browser.favorites.FavoritesQuickAccessAdapter.QuickAccessViewHolder +import com.duckduckgo.app.browser.favorites.QuickAccessAdapterDiffCallback.Companion.DIFF_KEY_TITLE +import com.duckduckgo.app.browser.favorites.QuickAccessAdapterDiffCallback.Companion.DIFF_KEY_URL +import kotlinx.android.synthetic.main.popup_window_quick_access_menu.view.* +import kotlinx.android.synthetic.main.view_quick_access_item.view.* +import kotlinx.coroutines.launch +import timber.log.Timber +import kotlin.math.absoluteValue + +class FavoritesQuickAccessAdapter( + private val lifecycleOwner: LifecycleOwner, + private val faviconManager: FaviconManager, + private val onMoveListener: (RecyclerView.ViewHolder) -> Unit, + private val onItemSelected: (QuickAccessFavorite) -> Unit, + private val onEditClicked: (QuickAccessFavorite) -> Unit, + private val onDeleteClicked: (QuickAccessFavorite) -> Unit +) : ListAdapter(QuickAccessAdapterDiffCallback()) { + + companion object { + const val QUICK_ACCESS_ITEM_MAX_SIZE_DP = 90 + } + + data class QuickAccessFavorite(val favorite: SavedSite.Favorite) : FavoritesAdapter.FavoriteItemTypes + + class QuickAccessViewHolder( + private val inflater: LayoutInflater, + itemView: View, + private val lifecycleOwner: LifecycleOwner, + private val faviconManager: FaviconManager, + private val onMoveListener: (RecyclerView.ViewHolder) -> Unit, + private val onItemSelected: (QuickAccessFavorite) -> Unit, + private val onEditClicked: (QuickAccessFavorite) -> Unit, + private val onDeleteClicked: (QuickAccessFavorite) -> Unit + ) : RecyclerView.ViewHolder(itemView), DragDropViewHolderListener { + + private var itemState: ItemState = ItemState.Stale + private var popupMenu: QuickAccessPopupMenu? = null + + sealed class ItemState { + object Stale : ItemState() + object LongPress : ItemState() + object Drag : ItemState() + } + + private val scaleDown = ObjectAnimator.ofPropertyValuesHolder( + itemView, + PropertyValuesHolder.ofFloat("scaleX", 1.2f, 1f), + PropertyValuesHolder.ofFloat("scaleY", 1.2f, 1f) + ).apply { + duration = 150L + } + private val scaleUp = ObjectAnimator.ofPropertyValuesHolder( + itemView, + PropertyValuesHolder.ofFloat("scaleX", 1f, 1.2f), + PropertyValuesHolder.ofFloat("scaleY", 1f, 1.2f) + ).apply { + duration = 150L + } + + fun bind(item: QuickAccessFavorite) { + with(item.favorite) { + itemView.quickAccessTitle.text = title + loadFavicon(url) + configureClickListeners(item) + configureTouchListener() + } + } + + @SuppressLint("ClickableViewAccessibility") + private fun configureTouchListener() { + itemView.quickAccessFaviconCard.setOnTouchListener { v, event -> + when (event.actionMasked) { + MotionEvent.ACTION_MOVE -> { + if (itemState != ItemState.LongPress) return@setOnTouchListener false + + onMoveListener(this@QuickAccessViewHolder) + } + MotionEvent.ACTION_UP -> { + onItemReleased() + } + } + false + } + } + + fun bindFromPayload(item: QuickAccessFavorite, payloads: MutableList) { + for (payload in payloads) { + val bundle = payload as Bundle + + for (key: String in bundle.keySet()) { + Timber.v("$key changed - Need an update for $item") + } + + bundle[DIFF_KEY_TITLE]?.let { + itemView.quickAccessTitle.text = it as String + } + + bundle[DIFF_KEY_URL]?.let { + loadFavicon(it as String) + } + + configureClickListeners(item) + } + } + + private fun configureClickListeners(item: QuickAccessFavorite) { + itemView.quickAccessFaviconCard.setOnLongClickListener { + itemState = ItemState.LongPress + scaleUpFavicon() + showOverFlowMenu(inflater, itemView.quickAccessFaviconCard, item) + false + } + itemView.quickAccessFaviconCard.setOnClickListener { onItemSelected(item) } + } + + private fun showOverFlowMenu(layoutInflater: LayoutInflater, anchor: View, item: QuickAccessFavorite) { + popupMenu = QuickAccessPopupMenu(layoutInflater).apply { + val view = this.contentView + onMenuItemClicked(view.editSavedSite) { onEditClicked(item) } + onMenuItemClicked(view.deleteSavedSite) { onDeleteClicked(item) } + this.show(itemView, anchor) + } + } + + override fun onDragStarted() { + scaleUpFavicon() + itemView.quickAccessTitle.alpha = 0f + itemState = ItemState.Drag + } + + override fun onItemMoved(dX: Float, dY: Float) { + if (itemState != ItemState.Drag) return + + if (dX.absoluteValue > 10 || dY.absoluteValue > 10) { + popupMenu?.dismiss() + } + } + + override fun onItemReleased() { + scaleDownFavicon() + itemView.quickAccessTitle.alpha = 1f + itemState = ItemState.Stale + } + + private fun loadFavicon(url: String) { + lifecycleOwner.lifecycleScope.launch { + faviconManager.loadToViewFromLocalOrFallback(url = url, view = itemView.quickAccessFavicon) + } + } + + private fun scaleUpFavicon() { + if (itemView.scaleX == 1f) { + scaleUp.start() + } + } + + private fun scaleDownFavicon() { + if (itemView.scaleX != 1.0f) { + scaleDown.start() + } + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): QuickAccessViewHolder { + val inflater = LayoutInflater.from(parent.context) + val view = inflater.inflate(R.layout.view_quick_access_item, parent, false) + return QuickAccessViewHolder(inflater, view, lifecycleOwner, faviconManager, onMoveListener, onItemSelected, onEditClicked, onDeleteClicked) + } + + override fun onBindViewHolder(holder: QuickAccessViewHolder, position: Int) { + holder.bind(getItem(position)) + } + + override fun onBindViewHolder(holder: QuickAccessViewHolder, position: Int, payloads: MutableList) { + if (payloads.isEmpty()) { + onBindViewHolder(holder, position) + return + } + holder.bindFromPayload(getItem(position), payloads) + } +} + +class QuickAccessAdapterDiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: QuickAccessFavorite, newItem: QuickAccessFavorite): Boolean { + return oldItem.favorite.id == newItem.favorite.id + } + + override fun areContentsTheSame(oldItem: QuickAccessFavorite, newItem: QuickAccessFavorite): Boolean { + return oldItem.favorite.title == newItem.favorite.title && + oldItem.favorite.url == newItem.favorite.url && + oldItem.favorite.position == newItem.favorite.position + } + + override fun getChangePayload(oldItem: QuickAccessFavorite, newItem: QuickAccessFavorite): Any? { + val diffBundle = Bundle() + + if (oldItem.favorite.title != newItem.favorite.title) { + diffBundle.putString(DIFF_KEY_TITLE, newItem.favorite.title) + } + + if (oldItem.favorite.url != newItem.favorite.url) { + diffBundle.putString(DIFF_KEY_URL, newItem.favorite.url) + } + + if (oldItem.favorite.position != newItem.favorite.position) { + diffBundle.putInt(DIFF_KEY_POSITION, newItem.favorite.position) + } + + return diffBundle + } + + companion object { + const val DIFF_KEY_TITLE = "title" + const val DIFF_KEY_URL = "url" + const val DIFF_KEY_POSITION = "position" + } +} + +interface DragDropViewHolderListener { + fun onDragStarted() + fun onItemMoved(dX: Float, dY: Float) + fun onItemReleased() +} diff --git a/app/src/main/java/com/duckduckgo/app/browser/favorites/QuickAccessDragTouchItemListener.kt b/app/src/main/java/com/duckduckgo/app/browser/favorites/QuickAccessDragTouchItemListener.kt new file mode 100644 index 000000000000..558931ffcf76 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/browser/favorites/QuickAccessDragTouchItemListener.kt @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2021 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.app.browser.favorites + +import android.graphics.Canvas +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.RecyclerView +import com.duckduckgo.app.browser.favorites.FavoritesQuickAccessAdapter.QuickAccessFavorite + +class QuickAccessDragTouchItemListener( + private val favoritesQuickAccessAdapter: FavoritesQuickAccessAdapter, + private val dragDropListener: DragDropListener +) : ItemTouchHelper.SimpleCallback( + ItemTouchHelper.UP or ItemTouchHelper.DOWN or ItemTouchHelper.START or ItemTouchHelper.END, + 0 +) { + interface DragDropListener { + fun onListChanged(listElements: List) + } + + override fun isLongPressDragEnabled(): Boolean { + return false + } + + override fun onMove(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder): Boolean { + val items = favoritesQuickAccessAdapter.currentList.toMutableList() + val quickAccessFavorite = items[viewHolder.bindingAdapterPosition] + items.removeAt(viewHolder.bindingAdapterPosition) + items.add(target.bindingAdapterPosition, quickAccessFavorite) + favoritesQuickAccessAdapter.submitList(items) + return true + } + + override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { + // noop + } + + override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) { + super.clearView(recyclerView, viewHolder) + dragDropListener.onListChanged(favoritesQuickAccessAdapter.currentList) + (viewHolder as? DragDropViewHolderListener)?.onItemReleased() + } + + override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) { + super.onSelectedChanged(viewHolder, actionState) + val listener = viewHolder as? DragDropViewHolderListener ?: return + when (actionState) { + ItemTouchHelper.ACTION_STATE_DRAG -> listener.onDragStarted() + } + } + + override fun onChildDraw( + c: Canvas, + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, + dX: Float, + dY: Float, + actionState: Int, + isCurrentlyActive: Boolean + ) { + super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive) + val listener = viewHolder as? DragDropViewHolderListener ?: return + listener.onItemMoved(dX, dY) + } +} diff --git a/app/src/main/java/com/duckduckgo/app/browser/favorites/QuickAccessPopupMenu.kt b/app/src/main/java/com/duckduckgo/app/browser/favorites/QuickAccessPopupMenu.kt new file mode 100644 index 000000000000..868de7da51ab --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/browser/favorites/QuickAccessPopupMenu.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2020 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.app.browser.favorites + +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import android.os.Build.VERSION.SDK_INT +import android.view.Gravity +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup.LayoutParams.WRAP_CONTENT +import android.widget.PopupWindow +import com.duckduckgo.app.browser.R + +class QuickAccessPopupMenu(layoutInflater: LayoutInflater, view: View = inflate(layoutInflater, R.layout.popup_window_quick_access_menu)) : + PopupWindow(view, WRAP_CONTENT, WRAP_CONTENT, true) { + + init { + if (SDK_INT <= 22) { + // popupwindow gets stuck on the screen on API 22 (tested on 23) without a background + // color. Adding it however garbles the elevation so we cannot have elevation here. + setBackgroundDrawable(ColorDrawable(Color.WHITE)) + } else { + elevation = ELEVATION + } + animationStyle = android.R.style.Animation_Dialog + } + + fun onMenuItemClicked(menuView: View, onClick: () -> Unit) { + menuView.setOnClickListener { + onClick() + dismiss() + } + } + + fun show(rootView: View, anchorView: View) { + val anchorLocation = IntArray(2) + anchorView.getLocationOnScreen(anchorLocation) + val x = anchorLocation[0] + MARGIN + val y = anchorLocation[1] + MARGIN + showAtLocation(rootView, Gravity.NO_GRAVITY, x, y) + } + + companion object { + + private const val MARGIN = 30 + private const val ELEVATION = 6f + + fun inflate(layoutInflater: LayoutInflater, resourceId: Int): View { + return layoutInflater.inflate(resourceId, null) + } + } +} diff --git a/app/src/main/java/com/duckduckgo/app/di/DaoModule.kt b/app/src/main/java/com/duckduckgo/app/di/DaoModule.kt index 58b231843e78..87d34a5e27c9 100644 --- a/app/src/main/java/com/duckduckgo/app/di/DaoModule.kt +++ b/app/src/main/java/com/duckduckgo/app/di/DaoModule.kt @@ -50,6 +50,9 @@ class DaoModule { @Provides fun providesBookmarksDao(database: AppDatabase) = database.bookmarksDao() + @Provides + fun providesFavoritesDao(database: AppDatabase) = database.favoritesDao() + @Provides fun providesTabsDao(database: AppDatabase) = database.tabsDao() diff --git a/app/src/main/java/com/duckduckgo/app/fire/fireproofwebsite/ui/FireproofWebsiteAdapter.kt b/app/src/main/java/com/duckduckgo/app/fire/fireproofwebsite/ui/FireproofWebsiteAdapter.kt index 0dd0857d90ee..4a5ba9ef9eb4 100644 --- a/app/src/main/java/com/duckduckgo/app/fire/fireproofwebsite/ui/FireproofWebsiteAdapter.kt +++ b/app/src/main/java/com/duckduckgo/app/fire/fireproofwebsite/ui/FireproofWebsiteAdapter.kt @@ -178,7 +178,7 @@ sealed class FireproofWebSiteViewHolder(itemView: View) : RecyclerView.ViewHolde private fun loadFavicon(url: String) { lifecycleOwner.lifecycleScope.launch { - faviconManager.loadToViewFromPersisted(url, itemView.fireproofWebsiteEntryFavicon) + faviconManager.loadToViewFromLocalOrFallback(url = url, view = itemView.fireproofWebsiteEntryFavicon) } } diff --git a/app/src/main/java/com/duckduckgo/app/global/UriExtension.kt b/app/src/main/java/com/duckduckgo/app/global/UriExtension.kt index 233b00d681f1..b104b5bb414d 100644 --- a/app/src/main/java/com/duckduckgo/app/global/UriExtension.kt +++ b/app/src/main/java/com/duckduckgo/app/global/UriExtension.kt @@ -88,6 +88,7 @@ fun Uri.domain(): String? = this.host // to obtain a favicon for a website, we go directly to the site and look for /favicon.ico private const val faviconBaseUrlFormat = "%s://%s/favicon.ico" +private const val touchFaviconBaseUrlFormat = "%s://%s/apple-touch-icon.png" fun Uri?.faviconLocation(): Uri? { if (this == null) return null @@ -97,6 +98,14 @@ fun Uri?.faviconLocation(): Uri? { return parse(String.format(faviconBaseUrlFormat, if (isHttps) "https" else "http", host)) } +fun Uri?.touchFaviconLocation(): Uri? { + if (this == null) return null + val host = this.host + if (host.isNullOrBlank()) return null + val isHttps = this.isHttps + return parse(String.format(touchFaviconBaseUrlFormat, if (isHttps) "https" else "http", host)) +} + fun Uri.getValidUrl(): ValidUrl? { val validHost = host ?: return null val validBaseHost = baseHost ?: return null diff --git a/app/src/main/java/com/duckduckgo/app/global/db/AppDatabase.kt b/app/src/main/java/com/duckduckgo/app/global/db/AppDatabase.kt index af5351a7ff6b..e06db6037cce 100644 --- a/app/src/main/java/com/duckduckgo/app/global/db/AppDatabase.kt +++ b/app/src/main/java/com/duckduckgo/app/global/db/AppDatabase.kt @@ -24,6 +24,8 @@ import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase import com.duckduckgo.app.bookmarks.db.BookmarkEntity import com.duckduckgo.app.bookmarks.db.BookmarksDao +import com.duckduckgo.app.bookmarks.db.FavoriteEntity +import com.duckduckgo.app.bookmarks.db.FavoritesDao import com.duckduckgo.app.browser.cookies.db.AuthCookiesAllowedDomainsDao import com.duckduckgo.app.browser.cookies.db.AuthCookieAllowedDomainEntity import com.duckduckgo.app.browser.rating.db.* @@ -65,7 +67,7 @@ import com.duckduckgo.app.usage.search.SearchCountDao import com.duckduckgo.app.usage.search.SearchCountEntity @Database( - exportSchema = true, version = 34, + exportSchema = true, version = 35, entities = [ TdsTracker::class, TdsEntity::class, @@ -79,6 +81,7 @@ import com.duckduckgo.app.usage.search.SearchCountEntity TabEntity::class, TabSelectionEntity::class, BookmarkEntity::class, + FavoriteEntity::class, Survey::class, DismissedCta::class, SearchCountEntity::class, @@ -123,6 +126,7 @@ abstract class AppDatabase : RoomDatabase() { abstract fun networkLeaderboardDao(): NetworkLeaderboardDao abstract fun tabsDao(): TabsDao abstract fun bookmarksDao(): BookmarksDao + abstract fun favoritesDao(): FavoritesDao abstract fun surveyDao(): SurveyDao abstract fun dismissedCtaDao(): DismissedCtaDao abstract fun searchCountDao(): SearchCountDao @@ -416,6 +420,13 @@ class MigrationsProvider(val context: Context) { } } + val MIGRATION_34_TO_35: Migration = object : Migration(34, 35) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("CREATE TABLE IF NOT EXISTS `favorites` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `url` TEXT NOT NULL, `position` INTEGER NOT NULL)") + database.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS `index_favorites_title_url` ON `favorites` (`title`, `url`)") + } + } + val BOOKMARKS_DB_ON_CREATE = object : RoomDatabase.Callback() { override fun onCreate(database: SupportSQLiteDatabase) { MIGRATION_29_TO_30.migrate(database) @@ -462,7 +473,8 @@ class MigrationsProvider(val context: Context) { MIGRATION_30_TO_31, MIGRATION_31_TO_32, MIGRATION_32_TO_33, - MIGRATION_33_TO_34 + MIGRATION_33_TO_34, + MIGRATION_34_TO_35 ) @Deprecated( diff --git a/app/src/main/java/com/duckduckgo/app/global/view/DividerAdapter.kt b/app/src/main/java/com/duckduckgo/app/global/view/DividerAdapter.kt new file mode 100644 index 000000000000..a10aee801ad6 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/global/view/DividerAdapter.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2021 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.app.global.view + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.duckduckgo.app.browser.R + +class DividerAdapter : RecyclerView.Adapter() { + + class DividerViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DividerViewHolder { + return DividerViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.view_item_divider, parent, false)) + } + + override fun onBindViewHolder(holder: DividerViewHolder, position: Int) { + // noop + } + + override fun getItemCount(): Int = 1 +} diff --git a/app/src/main/java/com/duckduckgo/app/global/view/FaviconImageView.kt b/app/src/main/java/com/duckduckgo/app/global/view/FaviconImageView.kt new file mode 100644 index 000000000000..abdf03474225 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/global/view/FaviconImageView.kt @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2021 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.app.global.view + +import android.content.Context +import android.graphics.* +import android.graphics.drawable.Drawable +import android.widget.ImageView +import androidx.core.content.ContextCompat.getColor +import androidx.core.graphics.toColorInt +import androidx.core.net.toUri +import com.bumptech.glide.Glide +import com.bumptech.glide.load.engine.DiskCacheStrategy +import com.bumptech.glide.load.resource.bitmap.RoundedCorners +import com.duckduckgo.app.browser.R +import com.duckduckgo.app.global.baseHost +import okio.ByteString.Companion.encodeUtf8 +import java.io.File +import java.util.* +import kotlin.math.absoluteValue + +fun ImageView.loadFavicon(file: File, domain: String) { + val defaultDrawable = generateDefaultDrawable(this.context, domain) + Glide.with(context) + .load(file) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .skipMemoryCache(true) + .transform(RoundedCorners(10)) + .placeholder(defaultDrawable) + .error(defaultDrawable) + .into(this) +} + +fun ImageView.loadFavicon(bitmap: Bitmap?, domain: String) { + val defaultDrawable = generateDefaultDrawable(this.context, domain) + Glide.with(context) + .load(bitmap) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .skipMemoryCache(true) + .transform(RoundedCorners(10)) + .placeholder(defaultDrawable) + .error(defaultDrawable) + .into(this) +} + +fun ImageView.loadDefaultFavicon(domain: String) { + this.setImageDrawable(generateDefaultDrawable(this.context, domain)) +} + +private fun generateDefaultDrawable(context: Context, domain: String): Drawable { + return object : Drawable() { + private val baseHost: String = domain.toUri().baseHost ?: "" + + private val letter + get() = baseHost.firstOrNull()?.toString()?.toUpperCase(Locale.getDefault()) ?: "" + + private val palette = listOf( + "#94B3AF", + "#727998", + "#645468", + "#4D5F7F", + "#855DB6", + "#5E5ADB", + "#678FFF", + "#6BB4EF", + "#4A9BAE", + "#66C4C6", + "#55D388", + "#99DB7A", + "#ECCC7B", + "#E7A538", + "#DD6B4C", + "#D65D62" + ) + + private val backgroundPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = domainToColor(baseHost) + } + + private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = getColor(context, R.color.white) + typeface = Typeface.SANS_SERIF + } + + override fun draw(canvas: Canvas) { + val centerX = bounds.width() * 0.5f + val centerY = bounds.height() * 0.5f + textPaint.textSize = (bounds.width() / 2).toFloat() + val textWidth: Float = textPaint.measureText(letter) * 0.5f + val textBaseLineHeight = textPaint.fontMetrics.ascent * -0.4f + canvas.drawRoundRect(0f, 0f, bounds.width().toFloat(), bounds.height().toFloat(), 10f, 10f, backgroundPaint) + canvas.drawText(letter, centerX - textWidth, centerY + textBaseLineHeight, textPaint) + } + + override fun setAlpha(alpha: Int) { + } + + override fun setColorFilter(colorFilter: ColorFilter?) { + } + + override fun getOpacity(): Int { + return PixelFormat.UNKNOWN + } + + private fun domainToColor(domain: String): Int { + return domain.encodeUtf8().toByteArray().fold(5381L) { acc, byte -> + (acc shl 5) + acc + byte.toLong() + }.absoluteValue.let { + val index = (it % palette.size).toInt() + palette[index] + }.toColorInt() + } + } +} diff --git a/app/src/main/java/com/duckduckgo/app/global/view/RecyclerViewExtension.kt b/app/src/main/java/com/duckduckgo/app/global/view/RecyclerViewExtension.kt new file mode 100644 index 000000000000..43581f033d0c --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/global/view/RecyclerViewExtension.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2021 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.app.global.view + +import androidx.recyclerview.widget.DefaultItemAnimator +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.ItemAnimator + +fun RecyclerView.disableAnimation() { + this.itemAnimator = null +} + +fun RecyclerView.enableAnimation(animator: ItemAnimator? = DefaultItemAnimator()) { + this.itemAnimator = DefaultItemAnimator() +} diff --git a/app/src/main/java/com/duckduckgo/app/location/ui/LocationPermissionsAdapter.kt b/app/src/main/java/com/duckduckgo/app/location/ui/LocationPermissionsAdapter.kt index 205242ec2993..32c7294c867c 100644 --- a/app/src/main/java/com/duckduckgo/app/location/ui/LocationPermissionsAdapter.kt +++ b/app/src/main/java/com/duckduckgo/app/location/ui/LocationPermissionsAdapter.kt @@ -257,7 +257,7 @@ sealed class LocationPermissionsViewHolder(itemView: View) : RecyclerView.ViewHo private fun loadFavicon(url: String) { lifecycleOwner.lifecycleScope.launch { - faviconManager.loadToViewFromPersisted(url, itemView.locationPermissionEntryFavicon) + faviconManager.loadToViewFromLocalOrFallback(url = url, view = itemView.locationPermissionEntryFavicon) } } diff --git a/app/src/main/java/com/duckduckgo/app/location/ui/SiteLocationPermissionDialog.kt b/app/src/main/java/com/duckduckgo/app/location/ui/SiteLocationPermissionDialog.kt index 75e4b24f5355..2ad916a80ad2 100644 --- a/app/src/main/java/com/duckduckgo/app/location/ui/SiteLocationPermissionDialog.kt +++ b/app/src/main/java/com/duckduckgo/app/location/ui/SiteLocationPermissionDialog.kt @@ -128,11 +128,7 @@ class SiteLocationPermissionDialog : DialogFragment() { faviconJob?.cancel() faviconJob = this.lifecycleScope.launch { - if (tabId.isNotBlank()) { - faviconManager.loadToViewFromTemp(tabId, originUrl, imageView) - } else { - faviconManager.loadToViewFromPersisted(originUrl, imageView) - } + faviconManager.loadToViewFromLocalOrFallback(tabId, originUrl, imageView) } } diff --git a/app/src/main/java/com/duckduckgo/app/systemsearch/SystemSearchActivity.kt b/app/src/main/java/com/duckduckgo/app/systemsearch/SystemSearchActivity.kt index ff6e5af3b389..97b40bd60c4f 100644 --- a/app/src/main/java/com/duckduckgo/app/systemsearch/SystemSearchActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/systemsearch/SystemSearchActivity.kt @@ -28,20 +28,27 @@ import android.widget.TextView import android.widget.Toast import android.widget.Toast.LENGTH_SHORT import androidx.core.view.isVisible -import androidx.lifecycle.Observer +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.LinearLayoutManager +import com.duckduckgo.app.bookmarks.model.SavedSite +import com.duckduckgo.app.bookmarks.ui.EditSavedSiteDialogFragment import com.duckduckgo.app.browser.BrowserActivity import com.duckduckgo.app.browser.R import com.duckduckgo.app.browser.autocomplete.BrowserAutoCompleteSuggestionsAdapter +import com.duckduckgo.app.browser.favicon.FaviconManager +import com.duckduckgo.app.browser.favorites.FavoritesQuickAccessAdapter +import com.duckduckgo.app.browser.favorites.FavoritesQuickAccessAdapter.Companion.QUICK_ACCESS_ITEM_MAX_SIZE_DP +import com.duckduckgo.app.browser.favorites.QuickAccessDragTouchItemListener import com.duckduckgo.app.browser.omnibar.OmnibarScrolling import com.duckduckgo.app.fire.DataClearerForegroundAppRestartPixel import com.duckduckgo.app.global.DuckDuckGoActivity -import com.duckduckgo.app.global.view.TextChangedWatcher -import com.duckduckgo.app.global.view.hideKeyboard +import com.duckduckgo.app.global.view.* import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.pixels.AppPixelName import com.duckduckgo.app.systemsearch.SystemSearchViewModel.Command.* -import com.duckduckgo.app.systemsearch.SystemSearchViewModel.SystemSearchResultsViewState +import com.duckduckgo.app.tabs.ui.GridViewColumnCalculator +import com.google.android.material.snackbar.Snackbar import kotlinx.android.synthetic.main.activity_system_search.* import kotlinx.android.synthetic.main.activity_system_search.appBarLayout import kotlinx.android.synthetic.main.activity_system_search.autocompleteSuggestions @@ -52,6 +59,7 @@ import kotlinx.android.synthetic.main.activity_system_search.logo import kotlinx.android.synthetic.main.activity_system_search.omnibarTextInput import kotlinx.android.synthetic.main.activity_system_search.results import kotlinx.android.synthetic.main.activity_system_search.resultsContent +import kotlinx.android.synthetic.main.include_quick_access_items.* import kotlinx.android.synthetic.main.include_system_search_onboarding.* import javax.inject.Inject @@ -66,9 +74,17 @@ class SystemSearchActivity : DuckDuckGoActivity() { @Inject lateinit var dataClearerForegroundAppRestartPixel: DataClearerForegroundAppRestartPixel + @Inject + lateinit var faviconManager: FaviconManager + + @Inject + lateinit var gridViewColumnCalculator: GridViewColumnCalculator + private val viewModel: SystemSearchViewModel by bindViewModel() private lateinit var autocompleteSuggestionsAdapter: BrowserAutoCompleteSuggestionsAdapter private lateinit var deviceAppSuggestionsAdapter: DeviceAppSuggestionsAdapter + private lateinit var quickAccessAdapter: FavoritesQuickAccessAdapter + private lateinit var itemTouchHelper: ItemTouchHelper private val textChangeWatcher = object : TextChangedWatcher() { override fun afterTextChanged(editable: Editable) { @@ -88,6 +104,7 @@ class SystemSearchActivity : DuckDuckGoActivity() { configureDaxButton() configureOmnibar() configureTextInput() + configureQuickAccessGrid() if (savedInstanceState == null) { intent?.let { sendLaunchPixels(it) } @@ -113,19 +130,26 @@ class SystemSearchActivity : DuckDuckGoActivity() { private fun configureObservers() { viewModel.onboardingViewState.observe( this, - Observer { + { it?.let { renderOnboardingViewState(it) } } ) viewModel.resultsViewState.observe( this, - Observer { - it?.let { renderResultsViewState(it) } + { + when (it) { + is SystemSearchViewModel.Suggestions.SystemSearchResultsViewState -> { + renderResultsViewState(it) + } + is SystemSearchViewModel.Suggestions.QuickAccessItems -> { + renderQuickAccessItems(it) + } + } } ) viewModel.command.observe( this, - Observer { + { processCommand(it) } ) @@ -161,6 +185,34 @@ class SystemSearchActivity : DuckDuckGoActivity() { deviceAppSuggestions.adapter = deviceAppSuggestionsAdapter } + private fun configureQuickAccessGrid() { + val numOfColumns = gridViewColumnCalculator.calculateNumberOfColumns(QUICK_ACCESS_ITEM_MAX_SIZE_DP, QUICK_ACCESS_GRID_MAX_COLUMNS) + val layoutManager = GridLayoutManager(this, numOfColumns) + quickAccessRecyclerView.layoutManager = layoutManager + quickAccessAdapter = FavoritesQuickAccessAdapter( + this, faviconManager, + { viewHolder -> itemTouchHelper.startDrag(viewHolder) }, + { viewModel.onQuickAccesItemClicked(it) }, + { viewModel.onEditQuickAccessItemRequested(it) }, + { viewModel.onDeleteQuickAccessItemRequested(it) } + ) + itemTouchHelper = ItemTouchHelper( + QuickAccessDragTouchItemListener( + quickAccessAdapter, + object : QuickAccessDragTouchItemListener.DragDropListener { + override fun onListChanged(listElements: List) { + viewModel.onQuickAccessListChanged(listElements) + } + } + ) + ) + + itemTouchHelper.attachToRecyclerView(quickAccessRecyclerView) + quickAccessRecyclerView.adapter = quickAccessAdapter + val sidePadding = gridViewColumnCalculator.calculateSidePadding(QUICK_ACCESS_ITEM_MAX_SIZE_DP, numOfColumns) + quickAccessRecyclerView.setPadding(sidePadding, 8.toPx(), sidePadding, 8.toPx()) + } + private fun configureDaxButton() { logo.setOnClickListener { viewModel.userTappedDax() @@ -171,6 +223,12 @@ class SystemSearchActivity : DuckDuckGoActivity() { resultsContent.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> updateScroll() } } + private fun showEditSavedSiteDialog(savedSite: SavedSite) { + val dialog = EditSavedSiteDialogFragment.instance(savedSite) + dialog.show(supportFragmentManager, "EDIT_BOOKMARK") + dialog.listener = viewModel + } + private fun updateScroll() { val scrollable = resultsContent.height > (results.height - results.paddingTop - results.paddingBottom) if (scrollable) { @@ -214,12 +272,17 @@ class SystemSearchActivity : DuckDuckGoActivity() { toggleButton.text = getString(toggleText) } - private fun renderResultsViewState(viewState: SystemSearchResultsViewState) { + private fun renderResultsViewState(viewState: SystemSearchViewModel.Suggestions.SystemSearchResultsViewState) { deviceLabel.isVisible = viewState.appResults.isNotEmpty() autocompleteSuggestionsAdapter.updateData(viewState.autocompleteResults.query, viewState.autocompleteResults.suggestions) deviceAppSuggestionsAdapter.updateData(viewState.appResults) } + private fun renderQuickAccessItems(it: SystemSearchViewModel.Suggestions.QuickAccessItems) { + quickAccessAdapter.submitList(it.favorites) + quickAccessRecyclerView.visibility = View.VISIBLE + } + private fun processCommand(command: SystemSearchViewModel.Command) { when (command) { is ClearInputText -> { @@ -245,6 +308,12 @@ class SystemSearchActivity : DuckDuckGoActivity() { is EditQuery -> { editQuery(command.query) } + is LaunchEditDialog -> { + showEditSavedSiteDialog(command.savedSite) + } + is DeleteSavedSiteConfirmation -> { + confirmDeleteSavedSite(command.savedSite) + } } } @@ -253,6 +322,17 @@ class SystemSearchActivity : DuckDuckGoActivity() { omnibarTextInput.setSelection(query.length) } + private fun confirmDeleteSavedSite(savedSite: SavedSite) { + val message = getString(R.string.bookmarkDeleteConfirmationMessage, savedSite.title).html(this) + Snackbar.make( + rootView, + message, + Snackbar.LENGTH_LONG + ).setAction(R.string.fireproofWebsiteSnackbarAction) { + viewModel.insertQuickAccessItem(savedSite) + }.show() + } + private fun launchDuckDuckGo() { startActivity(BrowserActivity.intent(this)) finish() @@ -294,10 +374,10 @@ class SystemSearchActivity : DuckDuckGoActivity() { } companion object { - const val NOTIFICATION_SEARCH_EXTRA = "NOTIFICATION_SEARCH_EXTRA" const val WIDGET_SEARCH_EXTRA = "WIDGET_SEARCH_EXTRA" const val NEW_SEARCH_ACTION = "com.duckduckgo.mobile.android.NEW_SEARCH" + private const val QUICK_ACCESS_GRID_MAX_COLUMNS = 6 fun fromWidget(context: Context): Intent { val intent = Intent(context, SystemSearchActivity::class.java) diff --git a/app/src/main/java/com/duckduckgo/app/systemsearch/SystemSearchViewModel.kt b/app/src/main/java/com/duckduckgo/app/systemsearch/SystemSearchViewModel.kt index ff587c7871b0..50fb251ff02e 100644 --- a/app/src/main/java/com/duckduckgo/app/systemsearch/SystemSearchViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/systemsearch/SystemSearchViewModel.kt @@ -22,6 +22,11 @@ import androidx.lifecycle.viewModelScope import com.duckduckgo.app.autocomplete.api.AutoComplete import com.duckduckgo.app.autocomplete.api.AutoComplete.AutoCompleteResult import com.duckduckgo.app.autocomplete.api.AutoCompleteApi +import com.duckduckgo.app.bookmarks.model.FavoritesRepository +import com.duckduckgo.app.bookmarks.model.SavedSite +import com.duckduckgo.app.bookmarks.ui.EditSavedSiteDialogFragment +import com.duckduckgo.app.browser.favicon.FaviconManager +import com.duckduckgo.app.browser.favorites.FavoritesQuickAccessAdapter import com.duckduckgo.app.global.DefaultDispatcherProvider import com.duckduckgo.app.global.DispatcherProvider import com.duckduckgo.app.global.SingleLiveEvent @@ -40,6 +45,8 @@ import io.reactivex.disposables.Disposable import io.reactivex.functions.BiFunction import io.reactivex.schedulers.Schedulers import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch import timber.log.Timber import java.util.concurrent.TimeUnit @@ -53,23 +60,31 @@ class SystemSearchViewModel( private val autoComplete: AutoComplete, private val deviceAppLookup: DeviceAppLookup, private val pixel: Pixel, + private val favoritesRepository: FavoritesRepository, + private val faviconManager: FaviconManager, private val dispatchers: DispatcherProvider = DefaultDispatcherProvider() -) : ViewModel() { +) : ViewModel(), EditSavedSiteDialogFragment.EditSavedSiteListener { data class OnboardingViewState( val visible: Boolean, val expanded: Boolean = false ) - data class SystemSearchResultsViewState( - val autocompleteResults: AutoCompleteResult = AutoCompleteResult("", emptyList()), - val appResults: List = emptyList() - ) + sealed class Suggestions { + data class SystemSearchResultsViewState( + val autocompleteResults: AutoCompleteResult = AutoCompleteResult("", emptyList()), + val appResults: List = emptyList() + ) : Suggestions() + + data class QuickAccessItems(val favorites: List) : Suggestions() + } sealed class Command { object ClearInputText : Command() object LaunchDuckDuckGo : Command() data class LaunchBrowser(val query: String) : Command() + data class LaunchEditDialog(val savedSite: SavedSite) : Command() + data class DeleteSavedSiteConfirmation(val savedSite: SavedSite) : Command() data class LaunchDeviceApplication(val deviceApp: DeviceApp) : Command() data class ShowAppNotFoundMessage(val appName: String) : Command() object DismissKeyboard : Command() @@ -77,12 +92,13 @@ class SystemSearchViewModel( } val onboardingViewState: MutableLiveData = MutableLiveData() - val resultsViewState: MutableLiveData = MutableLiveData() + val resultsViewState: MutableLiveData = MutableLiveData() val command: SingleLiveEvent = SingleLiveEvent() private val resultsPublishSubject = PublishRelay.create() private var results = SystemSearchResult(AutoCompleteResult("", emptyList()), emptyList()) private var resultsDisposable: Disposable? = null + private var latestQuickAccessItems: Suggestions.QuickAccessItems = Suggestions.QuickAccessItems(emptyList()) private var appsJob: Job? = null @@ -90,10 +106,16 @@ class SystemSearchViewModel( resetViewState() configureResults() refreshAppList() + viewModelScope.launch { + favoritesRepository.favorites().collect { favorite -> + latestQuickAccessItems = Suggestions.QuickAccessItems(favorite.map { FavoritesQuickAccessAdapter.QuickAccessFavorite(it) }) + resultsViewState.postValue(latestQuickAccessItems) + } + } } private fun currentOnboardingState(): OnboardingViewState = onboardingViewState.value!! - private fun currentResultsState(): SystemSearchResultsViewState = resultsViewState.value!! + private fun currentResultsState(): Suggestions = resultsViewState.value!! fun resetViewState() { command.value = Command.ClearInputText @@ -114,7 +136,7 @@ class SystemSearchViewModel( private fun resetResultsState() { results = SystemSearchResult(AutoCompleteResult("", emptyList()), emptyList()) appsJob?.cancel() - resultsViewState.value = SystemSearchResultsViewState() + resultsViewState.value = latestQuickAccessItems } private fun configureResults() { @@ -186,10 +208,18 @@ class SystemSearchViewModel( val updatedApps = if (hasMultiResults) appResults.take(RESULTS_MAX_RESULTS_PER_GROUP) else appResults resultsViewState.postValue( - currentResultsState().copy( - autocompleteResults = AutoCompleteResult(results.autocomplete.query, updatedSuggestions), - appResults = updatedApps - ) + when (val currentResultsState = currentResultsState()) { + is Suggestions.SystemSearchResultsViewState -> { + currentResultsState.copy( + autocompleteResults = AutoCompleteResult(results.autocomplete.query, updatedSuggestions), + appResults = updatedApps + ) + } + is Suggestions.QuickAccessItems -> Suggestions.SystemSearchResultsViewState( + autocompleteResults = AutoCompleteResult(results.autocomplete.query, updatedSuggestions), + appResults = updatedApps + ) + } ) } @@ -251,10 +281,62 @@ class SystemSearchViewModel( super.onCleared() } + fun onQuickAccessListChanged(newList: List) { + viewModelScope.launch(dispatchers.io()) { + favoritesRepository.updateWithPosition(newList.map { it.favorite }) + } + } + + fun onQuickAccesItemClicked(it: FavoritesQuickAccessAdapter.QuickAccessFavorite) { + command.value = Command.LaunchBrowser(it.favorite.url) + } + + fun onEditQuickAccessItemRequested(it: FavoritesQuickAccessAdapter.QuickAccessFavorite) { + command.value = Command.LaunchEditDialog(it.favorite) + } + + fun onDeleteQuickAccessItemRequested(it: FavoritesQuickAccessAdapter.QuickAccessFavorite) { + deleteQuickAccessItem(it.favorite) + command.value = Command.DeleteSavedSiteConfirmation(it.favorite) + } + companion object { private const val DEBOUNCE_TIME_MS = 200L private const val RESULTS_MAX_RESULTS_PER_GROUP = 4 } + + override fun onSavedSiteEdited(savedSite: SavedSite) { + when (savedSite) { + is SavedSite.Favorite -> { + viewModelScope.launch(dispatchers.io()) { + favoritesRepository.update(savedSite) + } + } + else -> throw IllegalArgumentException("Illegal SavedSite to edit received") + } + } + + private fun deleteQuickAccessItem(savedSite: SavedSite) { + when (savedSite) { + is SavedSite.Favorite -> { + viewModelScope.launch(dispatchers.io() + NonCancellable) { + favoritesRepository.delete(savedSite) + } + } + else -> throw IllegalArgumentException("Illegal SavedSite to delete received") + } + } + + fun insertQuickAccessItem(savedSite: SavedSite) { + when (savedSite) { + is SavedSite.Favorite -> { + viewModelScope.launch(dispatchers.io()) { + favoritesRepository.insert(savedSite) + } + } + else -> throw IllegalArgumentException("Illegal SavedSite to delete received") + } + } } @ContributesMultibinding(AppObjectGraph::class) @@ -262,12 +344,23 @@ class SystemSearchViewModelFactory @Inject constructor( private val userStageStore: Provider, private val autoComplete: Provider, private val deviceAppLookup: Provider, + private val favoritesRepository: Provider, + private val faviconManager: Provider, private val pixel: Provider ) : ViewModelFactoryPlugin { override fun create(modelClass: Class): T? { with(modelClass) { return when { - isAssignableFrom(SystemSearchViewModel::class.java) -> (SystemSearchViewModel(userStageStore.get(), autoComplete.get(), deviceAppLookup.get(), pixel.get()) as T) + isAssignableFrom(SystemSearchViewModel::class.java) -> ( + SystemSearchViewModel( + userStageStore.get(), + autoComplete.get(), + deviceAppLookup.get(), + pixel.get(), + favoritesRepository.get(), + faviconManager.get() + ) as T + ) else -> null } } diff --git a/app/src/main/java/com/duckduckgo/app/tabs/ui/GridViewColumnCalculator.kt b/app/src/main/java/com/duckduckgo/app/tabs/ui/GridViewColumnCalculator.kt index 1d37a74825e5..949de775db26 100644 --- a/app/src/main/java/com/duckduckgo/app/tabs/ui/GridViewColumnCalculator.kt +++ b/app/src/main/java/com/duckduckgo/app/tabs/ui/GridViewColumnCalculator.kt @@ -18,6 +18,7 @@ package com.duckduckgo.app.tabs.ui import android.content.Context import com.duckduckgo.app.global.view.toDp +import com.duckduckgo.app.global.view.toPx import kotlin.math.min class GridViewColumnCalculator(val context: Context) { @@ -29,4 +30,17 @@ class GridViewColumnCalculator(val context: Context) { return min(maxColumns, numberOfColumns) } + /** + * Given a numOfColumns and their width, calculate sides padding to center all items on the GridView. + * RecyclerView should have a match_parent width to avoid clipping items if drag-drop enabled. + * + * @return start/end padding in pixels + */ + fun calculateSidePadding(columnWidthDp: Int, numOfColumns: Int): Int { + val displayMetrics = context.resources.displayMetrics + val screenWidthDp = displayMetrics.widthPixels.toDp() + val columnsWidth = columnWidthDp * numOfColumns + val remainingSpace = screenWidthDp - columnsWidth + return if (remainingSpace <= 0) 0 else (remainingSpace / 2).toPx() + } } diff --git a/app/src/main/java/com/duckduckgo/app/tabs/ui/TabSwitcherAdapter.kt b/app/src/main/java/com/duckduckgo/app/tabs/ui/TabSwitcherAdapter.kt index afedecec7520..270b65baee2d 100644 --- a/app/src/main/java/com/duckduckgo/app/tabs/ui/TabSwitcherAdapter.kt +++ b/app/src/main/java/com/duckduckgo/app/tabs/ui/TabSwitcherAdapter.kt @@ -125,7 +125,7 @@ class TabSwitcherAdapter( private fun loadFavicon(tab: TabEntity, view: ImageView) { val url = tab.url ?: return lifecycleOwner.lifecycleScope.launch { - faviconManager.loadToViewFromTemp(tab.tabId, url, view) + faviconManager.loadToViewFromLocalOrFallback(tab.tabId, url, view) } } diff --git a/app/src/main/res/layout/activity_system_search.xml b/app/src/main/res/layout/activity_system_search.xml index 21f3e5202d93..f688d720294c 100644 --- a/app/src/main/res/layout/activity_system_search.xml +++ b/app/src/main/res/layout/activity_system_search.xml @@ -110,14 +110,14 @@ - - + + - - diff --git a/app/src/main/res/layout/edit_bookmark.xml b/app/src/main/res/layout/edit_saved_site.xml similarity index 88% rename from app/src/main/res/layout/edit_bookmark.xml rename to app/src/main/res/layout/edit_saved_site.xml index 27e886f98627..63e169144ec7 100644 --- a/app/src/main/res/layout/edit_bookmark.xml +++ b/app/src/main/res/layout/edit_saved_site.xml @@ -21,7 +21,7 @@ android:paddingTop="20dp"> + app:layout_constraintTop_toBottomOf="@id/savedSiteTitleInputContainer"> diff --git a/app/src/main/res/layout/fragment_browser_tab.xml b/app/src/main/res/layout/fragment_browser_tab.xml index 2ab4b117b23e..8ed0883f089e 100644 --- a/app/src/main/res/layout/fragment_browser_tab.xml +++ b/app/src/main/res/layout/fragment_browser_tab.xml @@ -48,6 +48,19 @@ tools:listitem="@layout/item_autocomplete_search_suggestion" tools:visibility="visible" /> + + diff --git a/app/src/main/res/layout/include_new_browser_tab.xml b/app/src/main/res/layout/include_new_browser_tab.xml index 0e12d8fe0bee..c5fc7c997825 100644 --- a/app/src/main/res/layout/include_new_browser_tab.xml +++ b/app/src/main/res/layout/include_new_browser_tab.xml @@ -30,6 +30,12 @@ android:layout_marginTop="?attr/actionBarSize" android:foreground="@android:color/transparent"> + + + + + + + + + + diff --git a/app/src/main/res/layout/popup_window_browser_menu.xml b/app/src/main/res/layout/popup_window_browser_menu.xml index 3106584e876e..170727240684 100644 --- a/app/src/main/res/layout/popup_window_browser_menu.xml +++ b/app/src/main/res/layout/popup_window_browser_menu.xml @@ -82,6 +82,11 @@ style="@style/BrowserTextMenuItem" android:text="@string/addBookmarkMenuTitle" /> + + + + + + + + + + diff --git a/app/src/main/res/layout/popup_window_bookmarks_menu.xml b/app/src/main/res/layout/popup_window_saved_site_menu.xml similarity index 93% rename from app/src/main/res/layout/popup_window_bookmarks_menu.xml rename to app/src/main/res/layout/popup_window_saved_site_menu.xml index 337d368d472a..c9c52ef6149e 100644 --- a/app/src/main/res/layout/popup_window_bookmarks_menu.xml +++ b/app/src/main/res/layout/popup_window_saved_site_menu.xml @@ -23,12 +23,12 @@ android:background="@drawable/popup_menu_bg"> diff --git a/app/src/main/res/layout/view_fireproof_website_entry.xml b/app/src/main/res/layout/view_fireproof_website_entry.xml index 9275e862f5a3..7ef33a0adece 100644 --- a/app/src/main/res/layout/view_fireproof_website_entry.xml +++ b/app/src/main/res/layout/view_fireproof_website_entry.xml @@ -38,8 +38,7 @@ android:layout_width="22dp" android:layout_height="22dp" android:layout_gravity="center" - android:importantForAccessibility="no" - android:src="@drawable/ic_globe_gray_16dp" /> + android:importantForAccessibility="no" /> + + \ No newline at end of file diff --git a/app/src/main/res/layout/view_location_permissions_entry.xml b/app/src/main/res/layout/view_location_permissions_entry.xml index 447b2a46efce..5af0d377ca06 100644 --- a/app/src/main/res/layout/view_location_permissions_entry.xml +++ b/app/src/main/res/layout/view_location_permissions_entry.xml @@ -38,8 +38,7 @@ android:layout_width="22dp" android:layout_height="22dp" android:layout_gravity="center" - android:importantForAccessibility="no" - android:src="@drawable/ic_globe_gray_16dp" /> + android:importantForAccessibility="no" /> + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/view_saved_site_empty_hint.xml b/app/src/main/res/layout/view_saved_site_empty_hint.xml new file mode 100644 index 000000000000..f71aad83fffc --- /dev/null +++ b/app/src/main/res/layout/view_saved_site_empty_hint.xml @@ -0,0 +1,32 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/layout/view_bookmark_entry.xml b/app/src/main/res/layout/view_saved_site_entry.xml similarity index 94% rename from app/src/main/res/layout/view_bookmark_entry.xml rename to app/src/main/res/layout/view_saved_site_entry.xml index 434e4e73a330..9721d5dc4aae 100644 --- a/app/src/main/res/layout/view_bookmark_entry.xml +++ b/app/src/main/res/layout/view_saved_site_entry.xml @@ -22,7 +22,6 @@ android:paddingTop="14dp" android:paddingBottom="14dp" android:paddingStart="16dp" - android:paddingEnd="10dp" android:background="?android:attr/selectableItemBackground" android:clickable="true" android:focusable="true"> @@ -41,8 +40,7 @@ android:layout_width="22dp" android:layout_height="22dp" android:layout_gravity="center" - android:importantForAccessibility="no" - android:src="@drawable/ic_globe_gray_16dp" /> + android:importantForAccessibility="no"/> + + \ No newline at end of file diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml index 79f18f6b0334..f440716af84a 100644 --- a/app/src/main/res/values/attrs.xml +++ b/app/src/main/res/values/attrs.xml @@ -42,7 +42,8 @@ - + + diff --git a/app/src/main/res/values/string-untranslated.xml b/app/src/main/res/values/string-untranslated.xml index fbc6cdbad16b..9cdb49495e01 100644 --- a/app/src/main/res/values/string-untranslated.xml +++ b/app/src/main/res/values/string-untranslated.xml @@ -31,6 +31,19 @@ We only use your anonymous location to deliver better results, closer to you. You can always change your mind later. + + Add Favorite + Favorite added + Bookmark added + Title + URL + Edit + Bookmarks + Favorites + No bookmarks added yet + No favorites added yet + + Success! %s has been added to your home screen. diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 92433f9717f3..3a38dbbb9ac6 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -343,6 +343,14 @@ start + + + + \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 5294b42db226..85b75e5b295f 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -65,8 +65,9 @@ @color/white @color/almostBlack @color/white + @color/white @color/white - @color/grayishTwo + @color/grayishTwo @color/white @color/warGreyTwo @color/grayishTwo @@ -140,10 +141,11 @@ @color/white @color/almostBlackDark @color/almostBlackDark + @color/almostBlackDark @color/almostBlack @color/whiteSix @color/almostBlack - @color/warmerGray + @color/warmerGray @color/almostBlackDark @color/pinkish_grey_two @color/warmerGray diff --git a/submodules/autofill b/submodules/autofill index 5a61a9ee2d9e..350eb8b29ecb 160000 --- a/submodules/autofill +++ b/submodules/autofill @@ -1 +1 @@ -Subproject commit 5a61a9ee2d9e6ae1e81946e5dd7d813ecdd251ad +Subproject commit 350eb8b29ecb03cd2424339ffd3d74510f2605b9 diff --git a/versions.properties b/versions.properties index 2cb5b0e3a641..933b7a47c505 100644 --- a/versions.properties +++ b/versions.properties @@ -8,6 +8,7 @@ version.androidx.appcompat=1.2.0 version.androidx.arch.core=2.1.0 version.androidx.constraintlayout=2.0.4 +version.androidx.recyclerview=1.2.0 version.androidx.core=1.3.2 version.androidx.fragment=1.2.5 version.androidx.legacy=1.0.0