From 2b3412ec1bccb835ddbee900c50d5685ee007ce0 Mon Sep 17 00:00:00 2001 From: Cristian Monforte Date: Fri, 1 May 2020 16:30:54 +0200 Subject: [PATCH 1/3] Fireproof websites UI (#796) * Create new database table to persist sites where cookies should be preserved * Removed divider from bookmarks list * bookmarks title show in single line * background favicon compatible with dark theme * introduce fireproof site option menu * Fireproof option menu reacts to database state. * Fireproof website screen created --- .../20.json | 700 ++++++++++++++++++ .../app/browser/BrowserTabViewModelTest.kt | 93 ++- .../data/FireproofWebsiteEntityKtTest.kt | 44 ++ .../ui/FireproofWebsitesViewModelTest.kt | 124 ++++ .../app/global/db/AppDatabaseTest.kt | 10 + app/src/main/AndroidManifest.xml | 4 + .../app/bookmarks/ui/BookmarksActivity.kt | 18 +- .../app/browser/BrowserTabFragment.kt | 22 +- .../app/browser/BrowserTabViewModel.kt | 54 +- .../duckduckgo/app/di/AndroidBindingModule.kt | 5 + .../java/com/duckduckgo/app/di/DaoModule.kt | 3 + .../data/FireproofWebsiteDao.kt | 33 + .../data/FireproofWebsiteEntity.kt | 32 + .../ui/FireproofWebsiteAdapter.kt | 145 ++++ .../ui/FireproofWebsitesActivity.kt | 89 +++ .../ui/FireproofWebsitesViewModel.kt | 71 ++ .../duckduckgo/app/global/ViewModelFactory.kt | 11 + .../duckduckgo/app/global/db/AppDatabase.kt | 17 +- .../app/settings/SettingsActivity.kt | 11 +- .../app/settings/SettingsViewModel.kt | 5 + ...ookmarks_24dp.xml => ic_overflow_24dp.xml} | 0 .../drawable/subtle_favicon_background.xml | 23 + .../layout/activity_fireproof_websites.xml | 30 + app/src/main/res/layout/content_bookmarks.xml | 4 +- .../res/layout/content_fireproof_websites.xml | 32 + .../res/layout/content_settings_privacy.xml | 15 +- .../layout_browser_bottom_navigation_bar.xml | 2 +- .../layout_tabs_bottom_navigation_bar.xml | 2 +- .../popup_window_browser_bottom_tab_menu.xml | 5 + .../res/layout/popup_window_browser_menu.xml | 5 + .../main/res/layout/view_bookmark_entry.xml | 48 +- .../view_fireproof_website_description.xml | 33 + .../layout/view_fireproof_website_entry.xml | 78 ++ ...proof_website_individual_overflow_menu.xml | 23 + app/src/main/res/values/attrs.xml | 2 + .../main/res/values/string-untranslated.xml | 10 + app/src/main/res/values/themes.xml | 4 + 37 files changed, 1757 insertions(+), 50 deletions(-) create mode 100644 app/schemas/com.duckduckgo.app.global.db.AppDatabase/20.json create mode 100644 app/src/androidTest/java/com/duckduckgo/app/fire/fireproofwebsite/data/FireproofWebsiteEntityKtTest.kt create mode 100644 app/src/androidTest/java/com/duckduckgo/app/fire/fireproofwebsite/ui/FireproofWebsitesViewModelTest.kt create mode 100644 app/src/main/java/com/duckduckgo/app/fire/fireproofwebsite/data/FireproofWebsiteDao.kt create mode 100644 app/src/main/java/com/duckduckgo/app/fire/fireproofwebsite/data/FireproofWebsiteEntity.kt create mode 100644 app/src/main/java/com/duckduckgo/app/fire/fireproofwebsite/ui/FireproofWebsiteAdapter.kt create mode 100644 app/src/main/java/com/duckduckgo/app/fire/fireproofwebsite/ui/FireproofWebsitesActivity.kt create mode 100644 app/src/main/java/com/duckduckgo/app/fire/fireproofwebsite/ui/FireproofWebsitesViewModel.kt rename app/src/main/res/drawable/{ic_overflow_bookmarks_24dp.xml => ic_overflow_24dp.xml} (100%) create mode 100644 app/src/main/res/drawable/subtle_favicon_background.xml create mode 100644 app/src/main/res/layout/activity_fireproof_websites.xml create mode 100644 app/src/main/res/layout/content_fireproof_websites.xml create mode 100644 app/src/main/res/layout/view_fireproof_website_description.xml create mode 100644 app/src/main/res/layout/view_fireproof_website_entry.xml create mode 100644 app/src/main/res/menu/fireproof_website_individual_overflow_menu.xml diff --git a/app/schemas/com.duckduckgo.app.global.db.AppDatabase/20.json b/app/schemas/com.duckduckgo.app.global.db.AppDatabase/20.json new file mode 100644 index 000000000000..4a91cd1a52a5 --- /dev/null +++ b/app/schemas/com.duckduckgo.app.global.db.AppDatabase/20.json @@ -0,0 +1,700 @@ +{ + "formatVersion": 1, + "database": { + "version": 20, + "identityHash": "69ae59777659fa9916c95bfda0d9cf4d", + "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": "https_bloom_filter_spec", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` 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": "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_whitelisted_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, PRIMARY KEY(`tabId`))", + "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 + } + ], + "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": [] + }, + { + "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": "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, `timestamp` INTEGER NOT NULL, `version` TEXT 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": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "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": [] + } + ], + "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, '69ae59777659fa9916c95bfda0d9cf4d')" + ] + } +} \ No newline at end of file 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 e35704dc2e56..f9d73c482c77 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt @@ -50,8 +50,9 @@ import com.duckduckgo.app.cta.db.DismissedCtaDao import com.duckduckgo.app.cta.model.CtaId import com.duckduckgo.app.cta.model.DismissedCta import com.duckduckgo.app.cta.ui.* +import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteDao +import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteEntity import com.duckduckgo.app.global.db.AppDatabase -import com.duckduckgo.app.cta.ui.HomeTopPanelCta import com.duckduckgo.app.global.install.AppInstallStore import com.duckduckgo.app.global.model.SiteFactory import com.duckduckgo.app.onboarding.store.OnboardingStore @@ -188,6 +189,8 @@ class BrowserTabViewModelTest { private lateinit var testee: BrowserTabViewModel + private lateinit var fireproofWebsiteDao: FireproofWebsiteDao + private val selectedTabLiveData = MutableLiveData() @Before @@ -197,6 +200,7 @@ class BrowserTabViewModelTest { db = Room.inMemoryDatabaseBuilder(getInstrumentation().targetContext, AppDatabase::class.java) .allowMainThreadQueries() .build() + fireproofWebsiteDao = db.fireproofWebsiteDao() mockAutoCompleteApi = AutoCompleteApi(mockAutoCompleteService, mockBookmarksDao) @@ -242,7 +246,8 @@ class BrowserTabViewModelTest { ctaViewModel = ctaViewModel, searchCountDao = mockSearchCountDao, pixel = mockPixel, - dispatchers = coroutineRule.testDispatcherProvider + dispatchers = coroutineRule.testDispatcherProvider, + fireproofWebsiteDao = fireproofWebsiteDao ) testee.loadData("abc", null, false) @@ -1225,6 +1230,7 @@ class BrowserTabViewModelTest { assertFalse(browserViewState().canGoForward) assertFalse(browserViewState().canReportSite) assertFalse(browserViewState().canChangeBrowsingMode) + assertFalse(browserViewState().canFireproofSite) assertFalse(findInPageViewState().canFindInPage) } @@ -1681,6 +1687,83 @@ class BrowserTabViewModelTest { assertEquals("surrogate.com", brokenSiteFeedback.surrogates) } + @Test + fun whenHomeShowingByPressingBackThenFireproofWebsiteOptionMenuDisabled() { + setupNavigation(isBrowsing = true) + testee.onUserPressedBack() + assertFalse(browserViewState().canFireproofSite) + } + + @Test + fun whenUserLoadsNotFireproofWebsiteThenFireproofWebsiteOptionMenuEnabled() { + loadUrl("http://www.example.com/path", isBrowserShowing = true) + assertTrue(browserViewState().canFireproofSite) + } + + @Test + fun whenUserLoadsFireproofWebsiteThenFireproofWebsiteOptionMenuDisabled() { + givenFireproofWebsiteDomain("www.example.com") + loadUrl("http://www.example.com/path", isBrowserShowing = true) + assertFalse(browserViewState().canFireproofSite) + } + + @Test + fun whenUserLoadsFireproofWebsiteSubDomainThenFireproofWebsiteOptionMenuEnabled() { + givenFireproofWebsiteDomain("example.com") + loadUrl("http://mobile.example.com/path", isBrowserShowing = true) + assertTrue(browserViewState().canFireproofSite) + } + + @Test + fun whenUrlClearedThenFireproofWebsiteOptionMenuDisabled() { + loadUrl("http://www.example.com/path") + assertTrue(browserViewState().canFireproofSite) + loadUrl(null) + assertFalse(browserViewState().canFireproofSite) + } + + @Test + fun whenUrlIsUpdatedWithNonFireproofWebsiteThenFireproofWebsiteOptionMenuEnabled() { + givenFireproofWebsiteDomain("www.example.com") + loadUrl("http://www.example.com/", isBrowserShowing = true) + updateUrl("http://www.example.com/", "http://twitter.com/explore", true) + assertTrue(browserViewState().canFireproofSite) + } + + @Test + fun whenUrlIsUpdatedWithFireproofWebsiteThenFireproofWebsiteOptionMenuDisabled() { + givenFireproofWebsiteDomain("twitter.com") + loadUrl("http://example.com/", isBrowserShowing = true) + updateUrl("http://example.com/", "http://twitter.com/explore", true) + assertFalse(browserViewState().canFireproofSite) + } + + @Test + fun whenUserClicksFireproofWebsiteOptionMenuThenShowConfirmationIsIssued() { + loadUrl("http://mobile.example.com/", isBrowserShowing = true) + testee.onFireproofWebsiteClicked() + assertCommandIssued { + assertEquals("mobile.example.com", this.fireproofWebsiteEntity.domain) + } + } + + @Test + fun whenUserClicksFireproofWebsiteOptionMenuThenFireproofWebsiteOptionMenuDisabled() { + loadUrl("http://example.com/", isBrowserShowing = true) + testee.onFireproofWebsiteClicked() + assertFalse(browserViewState().canFireproofSite) + } + + @Test + fun whenUserClicksOnFireproofWebsiteSnackbarUndoActionThenFireproofWebsiteIsRemoved() { + loadUrl("http://example.com/", isBrowserShowing = true) + testee.onFireproofWebsiteClicked() + assertCommandIssued { + testee.onFireproofWebsiteSnackbarUndoClicked(this.fireproofWebsiteEntity) + } + assertTrue(browserViewState().canFireproofSite) + } + private inline fun assertCommandIssued(instanceAssertions: T.() -> Unit = {}) { verify(mockCommandObserver, atLeastOnce()).onChanged(commandCaptor.capture()) val issuedCommand = commandCaptor.allValues.find { it is T } @@ -1715,6 +1798,12 @@ class BrowserTabViewModelTest { testee.loadData("TAB_ID", "https://example.com", false) } + private fun givenFireproofWebsiteDomain(vararg fireproofWebsitesDomain: String) { + fireproofWebsitesDomain.forEach { + fireproofWebsiteDao.insert(FireproofWebsiteEntity(domain = it)) + } + } + private fun setBrowserShowing(isBrowsing: Boolean) { testee.browserViewState.value = browserViewState().copy(browserShowing = isBrowsing) } diff --git a/app/src/androidTest/java/com/duckduckgo/app/fire/fireproofwebsite/data/FireproofWebsiteEntityKtTest.kt b/app/src/androidTest/java/com/duckduckgo/app/fire/fireproofwebsite/data/FireproofWebsiteEntityKtTest.kt new file mode 100644 index 000000000000..2da7ae40e543 --- /dev/null +++ b/app/src/androidTest/java/com/duckduckgo/app/fire/fireproofwebsite/data/FireproofWebsiteEntityKtTest.kt @@ -0,0 +1,44 @@ +/* + * 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.fire.fireproofwebsite.data + +import org.junit.Assert.* +import org.junit.Test + +class FireproofWebsiteEntityKtTest { + + @Test + fun whenDomainStartsWithWWWThenDropPrefix() { + val fireproofWebsiteEntity = FireproofWebsiteEntity("www.example.com") + val website = fireproofWebsiteEntity.website() + assertEquals("example.com", website) + } + + @Test + fun whenDomainStartsWithWWWUppercaseThenDropPrefix() { + val fireproofWebsiteEntity = FireproofWebsiteEntity("WWW.example.com") + val website = fireproofWebsiteEntity.website() + assertEquals("example.com", website) + } + + @Test + fun whenDomainDoesNotStartWithWWWThenDomainUnchanged() { + val fireproofWebsiteEntity = FireproofWebsiteEntity("mobile.example.com") + val website = fireproofWebsiteEntity.website() + assertEquals("mobile.example.com", website) + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/duckduckgo/app/fire/fireproofwebsite/ui/FireproofWebsitesViewModelTest.kt b/app/src/androidTest/java/com/duckduckgo/app/fire/fireproofwebsite/ui/FireproofWebsitesViewModelTest.kt new file mode 100644 index 000000000000..5b97b2c2c2bb --- /dev/null +++ b/app/src/androidTest/java/com/duckduckgo/app/fire/fireproofwebsite/ui/FireproofWebsitesViewModelTest.kt @@ -0,0 +1,124 @@ +/* + * 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.fire.fireproofwebsite.ui + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.lifecycle.Observer +import androidx.room.Room +import androidx.test.platform.app.InstrumentationRegistry +import com.duckduckgo.app.CoroutineTestRule +import com.duckduckgo.app.InstantSchedulersRule +import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteDao +import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteEntity +import com.duckduckgo.app.fire.fireproofwebsite.ui.FireproofWebsitesViewModel.Command.ConfirmDeleteFireproofWebsite +import com.duckduckgo.app.global.db.AppDatabase +import com.nhaarman.mockitokotlin2.atLeastOnce +import com.nhaarman.mockitokotlin2.mock +import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.mockito.ArgumentCaptor +import org.mockito.Mockito.verify + +class FireproofWebsitesViewModelTest { + + @get:Rule + var instantTaskExecutorRule = InstantTaskExecutorRule() + + @get:Rule + val schedulers = InstantSchedulersRule() + + @ExperimentalCoroutinesApi + @get:Rule + var coroutineRule = CoroutineTestRule() + + private lateinit var fireproofWebsiteDao: FireproofWebsiteDao + + private lateinit var viewModel: FireproofWebsitesViewModel + + private lateinit var db: AppDatabase + + private val commandCaptor = ArgumentCaptor.forClass(FireproofWebsitesViewModel.Command::class.java) + + private val viewStateCaptor = ArgumentCaptor.forClass(FireproofWebsitesViewModel.ViewState::class.java) + + private val mockCommandObserver: Observer = mock() + + private val mockViewStateObserver: Observer = mock() + + @Before + fun before() { + db = Room.inMemoryDatabaseBuilder(InstrumentationRegistry.getInstrumentation().targetContext, AppDatabase::class.java) + .allowMainThreadQueries() + .build() + fireproofWebsiteDao = db.fireproofWebsiteDao() + viewModel = FireproofWebsitesViewModel(fireproofWebsiteDao, coroutineRule.testDispatcherProvider) + viewModel.command.observeForever(mockCommandObserver) + viewModel.viewState.observeForever(mockViewStateObserver) + } + + @After + fun after() { + db.close() + viewModel.command.removeObserver(mockCommandObserver) + viewModel.viewState.removeObserver(mockViewStateObserver) + } + + @Test + fun whenUserDeletesFireProofWebsiteThenConfirmDeleteCommandIssued() { + val fireproofWebsiteEntity = FireproofWebsiteEntity("domain.com") + viewModel.onDeleteRequested(fireproofWebsiteEntity) + + assertCommandIssued { + assertEquals(fireproofWebsiteEntity, this.entity) + } + } + + @Test + fun whenUserConfirmsToDeleteThenEntityRemovedAndViewStateUpdated() { + givenFireproofWebsiteDomain("domain.com") + + viewModel.delete(FireproofWebsiteEntity("domain.com")) + + verify(mockViewStateObserver, atLeastOnce()).onChanged(viewStateCaptor.capture()) + assertTrue(viewStateCaptor.value.fireproofWebsitesEntities.isEmpty()) + } + + @Test + fun whenViewModelInitialisedThenViewStateShowsCurrentFireproofWebsites() { + givenFireproofWebsiteDomain("domain.com") + + verify(mockViewStateObserver, atLeastOnce()).onChanged(viewStateCaptor.capture()) + assertTrue(viewStateCaptor.value.fireproofWebsitesEntities.size == 1) + } + + private inline fun assertCommandIssued(instanceAssertions: T.() -> Unit = {}) { + verify(mockCommandObserver, atLeastOnce()).onChanged(commandCaptor.capture()) + val issuedCommand = commandCaptor.allValues.find { it is T } + assertNotNull(issuedCommand) + (issuedCommand as T).apply { instanceAssertions() } + } + + private fun givenFireproofWebsiteDomain(vararg fireproofWebsitesDomain: String) { + fireproofWebsitesDomain.forEach { + fireproofWebsiteDao.insert(FireproofWebsiteEntity(domain = it)) + } + } +} diff --git a/app/src/androidTest/java/com/duckduckgo/app/global/db/AppDatabaseTest.kt b/app/src/androidTest/java/com/duckduckgo/app/global/db/AppDatabaseTest.kt index 503bf10f2999..c282207dc420 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/global/db/AppDatabaseTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/global/db/AppDatabaseTest.kt @@ -184,6 +184,16 @@ class AppDatabaseTest { createDatabaseAndMigrate(17, 18, migrationsProvider.MIGRATION_17_TO_18) } + @Test + fun whenMigratingFromVersion18To19ThenValidationSucceeds() { + createDatabaseAndMigrate(18, 19, migrationsProvider.MIGRATION_18_TO_19) + } + + @Test + fun whenMigratingFromVersion19To20ThenValidationSucceeds() { + createDatabaseAndMigrate(19, 20, migrationsProvider.MIGRATION_19_TO_20) + } + @Test fun whenMigratingFromVersion17To18IfUserDidNotSawOnboardingThenMigrateToNew() = coroutineRule.runBlocking { givenUserNeverSawOnboarding() diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index fa0432c82b70..eb099e23a467 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -284,6 +284,10 @@ android:name="com.duckduckgo.app.bookmarks.ui.BookmarksActivity" android:label="@string/bookmarksActivityTitle" android:parentActivityName=".BrowserActivity" /> + 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 353cb5eb156c..49328b865ef8 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 @@ -30,8 +30,8 @@ import android.widget.ImageView import android.widget.PopupMenu import androidx.appcompat.widget.SearchView import androidx.lifecycle.Observer -import androidx.recyclerview.widget.DividerItemDecoration -import androidx.recyclerview.widget.RecyclerView.* +import androidx.recyclerview.widget.RecyclerView.Adapter +import androidx.recyclerview.widget.RecyclerView.ViewHolder import com.duckduckgo.app.bookmarks.db.BookmarkEntity import com.duckduckgo.app.browser.BrowserActivity import com.duckduckgo.app.browser.R @@ -46,7 +46,6 @@ import com.duckduckgo.app.global.view.show import kotlinx.android.synthetic.main.content_bookmarks.* import kotlinx.android.synthetic.main.include_toolbar.* import kotlinx.android.synthetic.main.view_bookmark_entry.view.* -import org.jetbrains.anko.alert import timber.log.Timber class BookmarksActivity : DuckDuckGoActivity() { @@ -67,9 +66,6 @@ class BookmarksActivity : DuckDuckGoActivity() { private fun setupBookmarksRecycler() { adapter = BookmarksAdapter(applicationContext, viewModel) recycler.adapter = adapter - - val separator = DividerItemDecoration(this, VERTICAL) - recycler.addItemDecoration(separator) } private fun observeViewModel() { @@ -129,10 +125,12 @@ class BookmarksActivity : DuckDuckGoActivity() { val message = Html.fromHtml(getString(R.string.bookmarkDeleteConfirmMessage, bookmark.title)) val title = getString(R.string.bookmarkDeleteConfirmTitle) - deleteDialog = alert(message, title) { - positiveButton(android.R.string.yes) { delete(bookmark) } - negativeButton(android.R.string.no) { } - }.build() + deleteDialog = AlertDialog.Builder(this) + .setTitle(title) + .setMessage(message) + .setPositiveButton(android.R.string.yes) { _, _ -> delete(bookmark) } + .setNegativeButton(android.R.string.no) { _, _ -> } + .create() deleteDialog?.show() } 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 8d8c01ed1f53..8d7a22475d2e 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt @@ -68,6 +68,8 @@ import androidx.constraintlayout.widget.ConstraintSet import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.content.ContextCompat import androidx.core.content.pm.ShortcutManagerCompat +import androidx.core.text.HtmlCompat +import androidx.core.text.HtmlCompat.FROM_HTML_MODE_LEGACY import androidx.core.view.isEmpty import androidx.core.view.isInvisible import androidx.core.view.isNotEmpty @@ -115,6 +117,8 @@ import com.duckduckgo.app.cta.ui.DaxBubbleCta import com.duckduckgo.app.cta.ui.DaxDialogCta import com.duckduckgo.app.cta.ui.HomePanelCta import com.duckduckgo.app.cta.ui.HomeTopPanelCta +import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteEntity +import com.duckduckgo.app.fire.fireproofwebsite.data.website import com.duckduckgo.app.global.ViewModelFactory import com.duckduckgo.app.global.device.DeviceInfo import com.duckduckgo.app.global.model.orderedTrackingEntities @@ -546,6 +550,7 @@ class BrowserTabFragment : Fragment(), FindListener, CoroutineScope, DaxDialogLi } is Command.LaunchNewTab -> browserActivity?.launchNewTab() is Command.ShowBookmarkAddedConfirmation -> bookmarkAdded(it.bookmarkId, it.title, it.url) + is Command.ShowFireproofWebSiteConfirmation -> fireproofWebsiteConfirmation(it.fireproofWebsiteEntity) is Command.Navigate -> { navigate(it.url) } @@ -980,6 +985,18 @@ class BrowserTabFragment : Fragment(), FindListener, CoroutineScope, DaxDialogLi .show() } + private fun fireproofWebsiteConfirmation(entity: FireproofWebsiteEntity) { + Snackbar.make( + rootView, + HtmlCompat.fromHtml(getString(R.string.fireproofWebsiteSnackbarConfirmation, entity.website()), FROM_HTML_MODE_LEGACY), + Snackbar.LENGTH_LONG + ) + .setAction(R.string.fireproofWebsiteSnackbarAction) { + viewModel.onFireproofWebsiteSnackbarUndoClicked(entity) + } + .show() + } + private fun launchSharePageChooser(url: String) { activity?.share(url, "") } @@ -1349,6 +1366,7 @@ class BrowserTabFragment : Fragment(), FindListener, CoroutineScope, DaxDialogLi browserActivity?.launchBookmarks() pixel.fire(String.format(Locale.US, Pixel.PixelName.MENU_ACTION_BOOKMARKS_PRESSED.pixelName, variantManager.getVariant().key)) } + onMenuItemClicked(view.fireproofWebsitePopupMenuItem) { launch { viewModel.onFireproofWebsiteClicked() } } onMenuItemClicked(view.addBookmarksPopupMenuItem) { launch { viewModel.onBookmarkAddRequested() } } onMenuItemClicked(view.findInPageMenuItem) { viewModel.onFindInPageSelected() } onMenuItemClicked(view.brokenSitePopupMenuItem) { viewModel.onBrokenSiteSelected() } @@ -1384,6 +1402,7 @@ class BrowserTabFragment : Fragment(), FindListener, CoroutineScope, DaxDialogLi pixel.fire(String.format(Locale.US, Pixel.PixelName.MENU_ACTION_NEW_TAB_PRESSED.pixelName, variantManager.getVariant().key)) } onMenuItemClicked(view.addBookmarksPopupMenuItem) { launch { viewModel.onBookmarkAddRequested() } } + onMenuItemClicked(view.fireproofWebsitePopupMenuItem) { launch { viewModel.onFireproofWebsiteClicked() } } onMenuItemClicked(view.findInPageMenuItem) { viewModel.onFindInPageSelected() } onMenuItemClicked(view.brokenSitePopupMenuItem) { viewModel.onBrokenSiteSelected() } onMenuItemClicked(view.settingsPopupMenuItem) { browserActivity?.launchSettings() } @@ -1496,7 +1515,7 @@ class BrowserTabFragment : Fragment(), FindListener, CoroutineScope, DaxDialogLi } fun incrementTabs() { - if (isBottomNavigationFeatureEnabled()){ + if (isBottomNavigationFeatureEnabled()) { bottomBarTabsItem.increment { addTabsObserver() } @@ -1681,6 +1700,7 @@ class BrowserTabFragment : Fragment(), FindListener, CoroutineScope, DaxDialogLi refreshPopupMenuItem.isEnabled = browserShowing newTabPopupMenuItem.isEnabled = browserShowing addBookmarksPopupMenuItem?.isEnabled = viewState.canAddBookmarks + fireproofWebsitePopupMenuItem?.isEnabled = viewState.canFireproofSite sharePageMenuItem?.isEnabled = viewState.canSharePage brokenSitePopupMenuItem?.isEnabled = viewState.canReportSite requestDesktopSiteCheckMenuItem?.isEnabled = viewState.canChangeBrowsingMode 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 e4e0735dd60c..975daa475d3b 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt @@ -31,6 +31,7 @@ import androidx.annotation.VisibleForTesting import androidx.core.net.toUri import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.Observer import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.duckduckgo.app.autocomplete.api.AutoComplete @@ -56,6 +57,8 @@ import com.duckduckgo.app.browser.omnibar.OmnibarEntryConverter import com.duckduckgo.app.browser.session.WebViewSessionStorage import com.duckduckgo.app.browser.ui.HttpAuthenticationDialogFragment.HttpAuthenticationListener import com.duckduckgo.app.cta.ui.* +import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteDao +import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteEntity import com.duckduckgo.app.global.* import com.duckduckgo.app.global.model.Site import com.duckduckgo.app.global.model.SiteFactory @@ -77,7 +80,6 @@ import com.jakewharton.rxrelay2.PublishRelay import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.disposables.Disposable import io.reactivex.schedulers.Schedulers -import kotlinx.android.synthetic.main.include_omnibar_toolbar.* import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -92,6 +94,7 @@ class BrowserTabViewModel( private val tabRepository: TabRepository, private val networkLeaderboardDao: NetworkLeaderboardDao, private val bookmarksDao: BookmarksDao, + private val fireproofWebsiteDao: FireproofWebsiteDao, private val autoComplete: AutoComplete, private val appSettingsPreferencesStore: SettingsDataStore, private val longPressHandler: LongPressHandler, @@ -129,6 +132,7 @@ class BrowserTabViewModel( val showMenuButton: Boolean = true, val canSharePage: Boolean = false, val canAddBookmarks: Boolean = false, + val canFireproofSite: Boolean = false, val canGoBack: Boolean = false, val canGoForward: Boolean = false, val canReportSite: Boolean = false, @@ -179,6 +183,7 @@ class BrowserTabViewModel( 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 ShowFireproofWebSiteConfirmation(val fireproofWebsiteEntity: FireproofWebsiteEntity) : Command() class ShareLink(val url: String) : Command() class CopyLink(val url: String) : Command() class FindInPageCommand(val searchTerm: String) : Command() @@ -223,15 +228,20 @@ class BrowserTabViewModel( get() = site?.title private val autoCompletePublishSubject = PublishRelay.create() + private val fireproofWebsiteState: LiveData> = fireproofWebsiteDao.fireproofWebsitesEntities() private var autoCompleteDisposable: Disposable? = null private var site: Site? = null private lateinit var tabId: String private var webNavigationState: WebNavigationState? = null private var httpsUpgraded = false + private val fireproofWebsitesObserver = Observer> { + browserViewState.value = currentBrowserViewState().copy(canFireproofSite = canFireproofWebsite()) + } init { initializeViewStates() configureAutoComplete() + fireproofWebsiteState.observeForever(fireproofWebsitesObserver) } fun loadData(tabId: String, initialUrl: String?, skipHome: Boolean) { @@ -295,6 +305,7 @@ class BrowserTabViewModel( buildingSiteFactoryJob?.cancel() autoCompleteDisposable?.dispose() autoCompleteDisposable = null + fireproofWebsiteState.removeObserver(fireproofWebsitesObserver) super.onCleared() } @@ -439,6 +450,7 @@ class BrowserTabViewModel( browserViewState.value = currentBrowserViewState().copy( browserShowing = false, canGoBack = false, + canFireproofSite = false, canGoForward = currentGlobalLayoutState() !is Invalidated ) omnibarViewState.value = currentOmnibarViewState().copy(omnibarText = "", shouldMoveCaretToEnd = false) @@ -460,7 +472,6 @@ class BrowserTabViewModel( } override fun navigationStateChanged(newWebNavigationState: WebNavigationState) { - val stateChange = newWebNavigationState.compare(webNavigationState) webNavigationState = newWebNavigationState @@ -481,7 +492,6 @@ class BrowserTabViewModel( } private fun pageChanged(url: String, title: String?) { - Timber.v("Page changed: $url") buildSiteFactory(url, title) @@ -498,7 +508,8 @@ class BrowserTabViewModel( showPrivacyGrade = true, canReportSite = true, showSearchIcon = false, - showClearButton = false + showClearButton = false, + canFireproofSite = canFireproofWebsite() ) Timber.d("showPrivacyGrade=true, showSearchIcon=false, showClearButton=false") @@ -516,6 +527,7 @@ class BrowserTabViewModel( onSiteChanged() val currentOmnibarViewState = currentOmnibarViewState() omnibarViewState.postValue(currentOmnibarViewState.copy(omnibarText = omnibarTextForUrl(url), shouldMoveCaretToEnd = false)) + browserViewState.postValue(currentBrowserViewState().copy(canFireproofSite = canFireproofWebsite())) } private fun omnibarTextForUrl(url: String?): String { @@ -540,7 +552,8 @@ class BrowserTabViewModel( showPrivacyGrade = false, canReportSite = false, showSearchIcon = true, - showClearButton = true + showClearButton = true, + canFireproofSite = false ) Timber.d("showPrivacyGrade=false, showSearchIcon=true, showClearButton=true") } @@ -700,6 +713,28 @@ class BrowserTabViewModel( } } + fun onFireproofWebsiteClicked() { + viewModelScope.launch { + val url = url ?: return@launch + val urlDomain = Uri.parse(url).host ?: return@launch + val fireproofWebsiteEntity = FireproofWebsiteEntity(domain = urlDomain) + val id = withContext(dispatchers.io()) { + fireproofWebsiteDao.insert(fireproofWebsiteEntity) + } + if (id >= 0) { + command.value = ShowFireproofWebSiteConfirmation(fireproofWebsiteEntity = fireproofWebsiteEntity) + } + } + } + + fun onFireproofWebsiteSnackbarUndoClicked(fireproofWebsiteEntity: FireproofWebsiteEntity) { + viewModelScope.launch { + withContext(dispatchers.io()) { + fireproofWebsiteDao.delete(fireproofWebsiteEntity) + } + } + } + override fun onBookmarkEdited(id: Long, title: String, url: String) { viewModelScope.launch(dispatchers.io()) { editBookmark(id, title, url) @@ -1031,6 +1066,12 @@ class BrowserTabViewModel( command.value = LaunchTabSwitcher } + private fun canFireproofWebsite(): Boolean { + val domain = site?.uri?.host ?: return false + val fireproofWebsites = fireproofWebsiteState.value + return fireproofWebsites?.all { it.domain != domain } ?: true + } + private fun invalidateBrowsingActions() { globalLayoutState.value = Invalidated loadingViewState.value = LoadingViewState() @@ -1042,7 +1083,8 @@ class BrowserTabViewModel( canGoBack = false, canGoForward = false, canReportSite = false, - canChangeBrowsingMode = false + canChangeBrowsingMode = false, + canFireproofSite = false ) } diff --git a/app/src/main/java/com/duckduckgo/app/di/AndroidBindingModule.kt b/app/src/main/java/com/duckduckgo/app/di/AndroidBindingModule.kt index 70a801269412..740528cc55f5 100644 --- a/app/src/main/java/com/duckduckgo/app/di/AndroidBindingModule.kt +++ b/app/src/main/java/com/duckduckgo/app/di/AndroidBindingModule.kt @@ -33,6 +33,7 @@ import com.duckduckgo.app.feedback.ui.negative.openended.ShareOpenEndedFeedbackF import com.duckduckgo.app.feedback.ui.negative.subreason.SubReasonNegativeFeedbackFragment import com.duckduckgo.app.feedback.ui.positive.initial.PositiveFeedbackLandingFragment import com.duckduckgo.app.fire.FireActivity +import com.duckduckgo.app.fire.fireproofwebsite.ui.FireproofWebsitesActivity import com.duckduckgo.app.icon.ui.ChangeIconActivity import com.duckduckgo.app.job.AppConfigurationJobService import com.duckduckgo.app.launch.LaunchBridgeActivity @@ -122,6 +123,10 @@ abstract class AndroidBindingModule { @ContributesAndroidInjector abstract fun bookmarksActivity(): BookmarksActivity + @ActivityScoped + @ContributesAndroidInjector + abstract fun fireproofWebsitesActivity(): FireproofWebsitesActivity + @ActivityScoped @ContributesAndroidInjector abstract fun fireActivity(): FireActivity 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 0ff94f2ef0bf..e203b621bca5 100644 --- a/app/src/main/java/com/duckduckgo/app/di/DaoModule.kt +++ b/app/src/main/java/com/duckduckgo/app/di/DaoModule.kt @@ -76,4 +76,7 @@ class DaoModule { @Provides fun userStageDao(database: AppDatabase) = database.userStageDao() + + @Provides + fun fireproofWebsiteDao(database: AppDatabase) = database.fireproofWebsiteDao() } \ No newline at end of file diff --git a/app/src/main/java/com/duckduckgo/app/fire/fireproofwebsite/data/FireproofWebsiteDao.kt b/app/src/main/java/com/duckduckgo/app/fire/fireproofwebsite/data/FireproofWebsiteDao.kt new file mode 100644 index 000000000000..d9c5eb26d2ba --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/fire/fireproofwebsite/data/FireproofWebsiteDao.kt @@ -0,0 +1,33 @@ +/* + * 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.fire.fireproofwebsite.data + +import androidx.lifecycle.LiveData +import androidx.room.* + +@Dao +interface FireproofWebsiteDao { + + @Query("select * from fireproofWebsites") + fun fireproofWebsitesEntities(): LiveData> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insert(fireproofWebsiteEntity: FireproofWebsiteEntity): Long + + @Delete + fun delete(fireproofWebsiteEntity: FireproofWebsiteEntity): Int +} \ No newline at end of file diff --git a/app/src/main/java/com/duckduckgo/app/fire/fireproofwebsite/data/FireproofWebsiteEntity.kt b/app/src/main/java/com/duckduckgo/app/fire/fireproofwebsite/data/FireproofWebsiteEntity.kt new file mode 100644 index 000000000000..157736b89a14 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/fire/fireproofwebsite/data/FireproofWebsiteEntity.kt @@ -0,0 +1,32 @@ +/* + * 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.fire.fireproofwebsite.data + +import androidx.room.Entity +import androidx.room.PrimaryKey + +private const val WWW_PREFIX = "www." + +@Entity(tableName = "fireproofWebsites") +data class FireproofWebsiteEntity( + @PrimaryKey val domain: String +) + +fun FireproofWebsiteEntity.website(): String { + return domain.takeIf { it.startsWith(WWW_PREFIX, ignoreCase = true) } + ?.drop(WWW_PREFIX.length) ?: domain +} \ No newline at end of file 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 new file mode 100644 index 000000000000..97f5b5886a7b --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/fire/fireproofwebsite/ui/FireproofWebsiteAdapter.kt @@ -0,0 +1,145 @@ +/* + * 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.fire.fireproofwebsite.ui + +import android.net.Uri +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.PopupMenu +import androidx.annotation.StringRes +import androidx.recyclerview.widget.RecyclerView +import com.duckduckgo.app.browser.R +import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteEntity +import com.duckduckgo.app.fire.fireproofwebsite.data.website +import com.duckduckgo.app.global.faviconLocation +import com.duckduckgo.app.global.image.GlideApp +import kotlinx.android.synthetic.main.view_fireproof_website_description.view.* +import kotlinx.android.synthetic.main.view_fireproof_website_entry.view.* +import timber.log.Timber +import java.lang.IllegalArgumentException + +class FireproofWebsiteAdapter( + private val viewModel: FireproofWebsitesViewModel, + @StringRes private val listDescriptionStringRes: Int +) : RecyclerView.Adapter() { + + companion object Type { + const val FIREPROOF_WEBSITE_TYPE = 0 + const val DESCRIPTION_TYPE = 1 + } + + var fireproofWebsites: List = emptyList() + set(value) { + field = value + notifyDataSetChanged() + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): FireproofWebSiteViewHolder { + val inflater = LayoutInflater.from(parent.context) + return when (viewType) { + FIREPROOF_WEBSITE_TYPE -> { + val view = inflater.inflate(R.layout.view_fireproof_website_entry, parent, false) + FireproofWebSiteViewHolder.FireproofWebsiteItemViewHolder(view, viewModel) + } + DESCRIPTION_TYPE -> { + val view = inflater.inflate(R.layout.view_fireproof_website_description, parent, false) + FireproofWebSiteViewHolder.FireproofWebsiteDescriptionViewHolder(view) + } + else -> throw IllegalArgumentException("viewType not found") + } + } + + override fun getItemViewType(position: Int): Int { + return if ((fireproofWebsites.size - 1) < position) { + DESCRIPTION_TYPE + } else { + FIREPROOF_WEBSITE_TYPE + } + } + + override fun onBindViewHolder(holder: FireproofWebSiteViewHolder, position: Int) { + when (holder) { + is FireproofWebSiteViewHolder.FireproofWebsiteDescriptionViewHolder -> holder.bind(listDescriptionStringRes) + is FireproofWebSiteViewHolder.FireproofWebsiteItemViewHolder -> holder.bind(fireproofWebsites[position]) + } + } + + override fun getItemCount(): Int { + return fireproofWebsites.size + 1 + } +} + +sealed class FireproofWebSiteViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + + class FireproofWebsiteDescriptionViewHolder(itemView: View) : FireproofWebSiteViewHolder(itemView) { + fun bind(@StringRes text: Int) = with(itemView) { + fireproofWebsiteDescription.setText(text) + } + } + + class FireproofWebsiteItemViewHolder(itemView: View, private val viewModel: FireproofWebsitesViewModel) : FireproofWebSiteViewHolder(itemView) { + + lateinit var entity: FireproofWebsiteEntity + + fun bind(entity: FireproofWebsiteEntity) { + this.entity = entity + + itemView.overflowMenu.contentDescription = itemView.context.getString( + R.string.fireproofWebsiteOverflowContentDescription, + entity.website() + ) + + itemView.fireproofWebsiteEntryDomain.text = entity.website() + loadFavicon(entity.domain) + + itemView.overflowMenu.setOnClickListener { + showOverFlowMenu(itemView.overflowMenu, entity) + } + } + + private fun loadFavicon(domain: String) { + val faviconUrl = Uri.parse("http://$domain").faviconLocation() + + GlideApp.with(itemView) + .load(faviconUrl) + .placeholder(R.drawable.ic_globe_gray_16dp) + .error(R.drawable.ic_globe_gray_16dp) + .into(itemView.fireproofWebsiteEntryFavicon) + } + + private fun showOverFlowMenu(overflowMenu: ImageView, entity: FireproofWebsiteEntity) { + val popup = PopupMenu(overflowMenu.context, overflowMenu) + popup.inflate(R.menu.fireproof_website_individual_overflow_menu) + popup.setOnMenuItemClickListener { + when (it.itemId) { + R.id.delete -> { + deleteEntity(entity); true + } + else -> false + } + } + popup.show() + } + + private fun deleteEntity(entity: FireproofWebsiteEntity) { + Timber.i("Deleting website with domain: ${entity.domain}") + viewModel.onDeleteRequested(entity) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/duckduckgo/app/fire/fireproofwebsite/ui/FireproofWebsitesActivity.kt b/app/src/main/java/com/duckduckgo/app/fire/fireproofwebsite/ui/FireproofWebsitesActivity.kt new file mode 100644 index 000000000000..5f9b4df37f4a --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/fire/fireproofwebsite/ui/FireproofWebsitesActivity.kt @@ -0,0 +1,89 @@ +/* + * 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.fire.fireproofwebsite.ui + +import android.app.AlertDialog +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.core.text.HtmlCompat +import androidx.core.text.HtmlCompat.FROM_HTML_MODE_LEGACY +import androidx.lifecycle.Observer +import com.duckduckgo.app.browser.R +import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteEntity +import com.duckduckgo.app.global.DuckDuckGoActivity +import kotlinx.android.synthetic.main.content_fireproof_websites.* +import kotlinx.android.synthetic.main.include_toolbar.* + +class FireproofWebsitesActivity : DuckDuckGoActivity() { + + lateinit var adapter: FireproofWebsiteAdapter + private var deleteDialog: AlertDialog? = null + + private val viewModel: FireproofWebsitesViewModel by bindViewModel() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_fireproof_websites) + setupToolbar(toolbar) + setupFireproofWebsiteRecycler() + observeViewModel() + } + + private fun setupFireproofWebsiteRecycler() { + adapter = FireproofWebsiteAdapter(viewModel, R.string.fireproofWebsiteFeatureDescription) + recycler.adapter = adapter + } + + private fun observeViewModel() { + viewModel.viewState.observe(this, Observer { viewState -> + viewState?.let { + adapter.fireproofWebsites = it.fireproofWebsitesEntities + } + }) + + viewModel.command.observe(this, Observer { + when (it) { + is FireproofWebsitesViewModel.Command.ConfirmDeleteFireproofWebsite -> confirmDeleteWebsite(it.entity) + } + }) + } + + @Suppress("deprecation") + private fun confirmDeleteWebsite(entity: FireproofWebsiteEntity) { + val message = HtmlCompat.fromHtml(getString(R.string.fireproofWebsiteDeleteConfirmMessage, entity.domain), FROM_HTML_MODE_LEGACY) + val title = getString(R.string.fireproofWebsiteDeleteConfirmTitle) + deleteDialog = AlertDialog.Builder(this) + .setTitle(title) + .setMessage(message) + .setPositiveButton(android.R.string.yes) { _, _ -> viewModel.delete(entity) } + .setNegativeButton(android.R.string.no) { _, _ -> } + .create() + deleteDialog?.show() + } + + override fun onDestroy() { + deleteDialog?.dismiss() + super.onDestroy() + } + + companion object { + fun intent(context: Context): Intent { + return Intent(context, FireproofWebsitesActivity::class.java) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/duckduckgo/app/fire/fireproofwebsite/ui/FireproofWebsitesViewModel.kt b/app/src/main/java/com/duckduckgo/app/fire/fireproofwebsite/ui/FireproofWebsitesViewModel.kt new file mode 100644 index 000000000000..661f98033be3 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/fire/fireproofwebsite/ui/FireproofWebsitesViewModel.kt @@ -0,0 +1,71 @@ +/* + * 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.fire.fireproofwebsite.ui + +import androidx.lifecycle.* +import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteDao +import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteEntity +import com.duckduckgo.app.fire.fireproofwebsite.ui.FireproofWebsitesViewModel.Command.ConfirmDeleteFireproofWebsite +import com.duckduckgo.app.global.DispatcherProvider +import com.duckduckgo.app.global.SingleLiveEvent +import kotlinx.coroutines.launch + +class FireproofWebsitesViewModel( + private val dao: FireproofWebsiteDao, + private val dispatcherProvider: DispatcherProvider +) : ViewModel() { + + data class ViewState( + val fireproofWebsitesEntities: List = emptyList() + ) + + sealed class Command { + class ConfirmDeleteFireproofWebsite(val entity: FireproofWebsiteEntity) : Command() + } + + val viewState: MutableLiveData = MutableLiveData() + val command: SingleLiveEvent = SingleLiveEvent() + + private val fireproofWebsites: LiveData> = dao.fireproofWebsitesEntities() + private val fireproofWebsitesObserver = Observer> { onPreservedCookiesEntitiesChanged(it!!) } + + init { + viewState.value = ViewState() + fireproofWebsites.observeForever(fireproofWebsitesObserver) + } + + override fun onCleared() { + super.onCleared() + fireproofWebsites.removeObserver(fireproofWebsitesObserver) + } + + private fun onPreservedCookiesEntitiesChanged(entities: List) { + viewState.value = viewState.value?.copy( + fireproofWebsitesEntities = entities + ) + } + + fun onDeleteRequested(entity: FireproofWebsiteEntity) { + command.value = ConfirmDeleteFireproofWebsite(entity) + } + + fun delete(entity: FireproofWebsiteEntity) { + viewModelScope.launch(dispatcherProvider.io()) { + dao.delete(entity) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/duckduckgo/app/global/ViewModelFactory.kt b/app/src/main/java/com/duckduckgo/app/global/ViewModelFactory.kt index 2a91633b3017..a2c578f742d9 100644 --- a/app/src/main/java/com/duckduckgo/app/global/ViewModelFactory.kt +++ b/app/src/main/java/com/duckduckgo/app/global/ViewModelFactory.kt @@ -37,6 +37,8 @@ import com.duckduckgo.app.feedback.ui.negative.brokensite.BrokenSiteNegativeFeed import com.duckduckgo.app.feedback.ui.negative.openended.ShareOpenEndedNegativeFeedbackViewModel import com.duckduckgo.app.feedback.ui.positive.initial.PositiveFeedbackLandingViewModel import com.duckduckgo.app.fire.DataClearer +import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteDao +import com.duckduckgo.app.fire.fireproofwebsite.ui.FireproofWebsitesViewModel import com.duckduckgo.app.global.install.AppInstallStore import com.duckduckgo.app.global.model.SiteFactory import com.duckduckgo.app.global.rating.AppEnjoymentPromptEmitter @@ -90,6 +92,7 @@ class ViewModelFactory @Inject constructor( private val siteFactory: SiteFactory, private val networkLeaderboardDao: NetworkLeaderboardDao, private val bookmarksDao: BookmarksDao, + private val fireproofWebsiteDao: FireproofWebsiteDao, private val surveyDao: SurveyDao, private val autoCompleteApi: AutoCompleteApi, private val deviceAppLookup: DeviceAppLookup, @@ -143,6 +146,7 @@ class ViewModelFactory @Inject constructor( isAssignableFrom(TrackerBlockingSelectionViewModel::class.java) -> TrackerBlockingSelectionViewModel(privacySettingsStore) isAssignableFrom(DefaultBrowserPageViewModel::class.java) -> defaultBrowserPage() isAssignableFrom(ChangeIconViewModel::class.java) -> changeAppIconViewModel() + isAssignableFrom(FireproofWebsitesViewModel::class.java) -> fireproofWebsiteViewModel() else -> throw IllegalArgumentException("Unknown ViewModel class: ${modelClass.name}") } @@ -187,6 +191,7 @@ class ViewModelFactory @Inject constructor( tabRepository = tabRepository, networkLeaderboardDao = networkLeaderboardDao, bookmarksDao = bookmarksDao, + fireproofWebsiteDao = fireproofWebsiteDao, autoComplete = autoCompleteApi, appSettingsPreferencesStore = appSettingsPreferencesStore, longPressHandler = webViewLongPressHandler, @@ -201,4 +206,10 @@ class ViewModelFactory @Inject constructor( private fun changeAppIconViewModel() = ChangeIconViewModel(settingsDataStore = appSettingsPreferencesStore, appIconModifier = appIconModifier, pixel = pixel) + + private fun fireproofWebsiteViewModel() = + FireproofWebsitesViewModel( + dao = fireproofWebsiteDao, + dispatcherProvider = dispatcherProvider + ) } 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 47682646fb1c..ea7dd4db0a1d 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 @@ -30,6 +30,8 @@ import com.duckduckgo.app.browser.rating.db.AppEnjoymentTypeConverter import com.duckduckgo.app.browser.rating.db.PromptCountConverter import com.duckduckgo.app.cta.db.DismissedCtaDao import com.duckduckgo.app.cta.model.DismissedCta +import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteDao +import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteEntity import com.duckduckgo.app.global.exception.UncaughtExceptionDao import com.duckduckgo.app.global.exception.UncaughtExceptionEntity import com.duckduckgo.app.global.exception.UncaughtExceptionSourceConverter @@ -58,7 +60,7 @@ import com.duckduckgo.app.usage.search.SearchCountDao import com.duckduckgo.app.usage.search.SearchCountEntity @Database( - exportSchema = true, version = 19, entities = [ + exportSchema = true, version = 20, entities = [ TdsTracker::class, TdsEntity::class, TdsDomainEntity::class, @@ -79,7 +81,8 @@ import com.duckduckgo.app.usage.search.SearchCountEntity PrivacyProtectionCountsEntity::class, UncaughtExceptionEntity::class, TdsMetadata::class, - UserStage::class + UserStage::class, + FireproofWebsiteEntity::class ] ) @@ -115,6 +118,7 @@ abstract class AppDatabase : RoomDatabase() { abstract fun uncaughtExceptionDao(): UncaughtExceptionDao abstract fun tdsDao(): TdsMetadataDao abstract fun userStageDao(): UserStageDao + abstract fun fireproofWebsiteDao(): FireproofWebsiteDao } @Suppress("PropertyName") @@ -276,6 +280,12 @@ class MigrationsProvider(val context: Context) { } } + val MIGRATION_19_TO_20: Migration = object : Migration(19, 20) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("CREATE TABLE IF NOT EXISTS `fireproofWebsites` (`domain` TEXT NOT NULL, PRIMARY KEY(`domain`))") + } + } + val ALL_MIGRATIONS: List get() = listOf( MIGRATION_1_TO_2, @@ -295,7 +305,8 @@ class MigrationsProvider(val context: Context) { MIGRATION_15_TO_16, MIGRATION_16_TO_17, MIGRATION_17_TO_18, - MIGRATION_18_TO_19 + MIGRATION_18_TO_19, + MIGRATION_19_TO_20 ) @Deprecated( diff --git a/app/src/main/java/com/duckduckgo/app/settings/SettingsActivity.kt b/app/src/main/java/com/duckduckgo/app/settings/SettingsActivity.kt index 4e6026ed7ddb..0b045a491262 100644 --- a/app/src/main/java/com/duckduckgo/app/settings/SettingsActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/settings/SettingsActivity.kt @@ -31,6 +31,7 @@ import androidx.lifecycle.Observer import com.duckduckgo.app.about.AboutDuckDuckGoActivity import com.duckduckgo.app.browser.R import com.duckduckgo.app.feedback.ui.common.FeedbackActivity +import com.duckduckgo.app.fire.fireproofwebsite.ui.FireproofWebsitesActivity import com.duckduckgo.app.global.DuckDuckGoActivity import com.duckduckgo.app.global.sendThemeChangedBroadcast import com.duckduckgo.app.global.view.launchDefaultAppActivity @@ -49,8 +50,7 @@ import kotlinx.android.synthetic.main.content_settings_general.setAsDefaultBrows import kotlinx.android.synthetic.main.content_settings_other.about import kotlinx.android.synthetic.main.content_settings_other.provideFeedback import kotlinx.android.synthetic.main.content_settings_other.version -import kotlinx.android.synthetic.main.content_settings_privacy.automaticallyClearWhatSetting -import kotlinx.android.synthetic.main.content_settings_privacy.automaticallyClearWhenSetting +import kotlinx.android.synthetic.main.content_settings_privacy.* import kotlinx.android.synthetic.main.include_toolbar.toolbar import javax.inject.Inject @@ -89,6 +89,7 @@ class SettingsActivity : DuckDuckGoActivity(), SettingsAutomaticallyClearWhatFra changeAppIconLabel.setOnClickListener { viewModel.userRequestedToChangeIcon() } about.setOnClickListener { startActivity(AboutDuckDuckGoActivity.intent(this)) } provideFeedback.setOnClickListener { viewModel.userRequestedToSendFeedback() } + fireproofWebsites.setOnClickListener { viewModel.onFireproofWebsitesClicked() } lightThemeToggle.setOnCheckedChangeListener(lightThemeToggleListener) autocompleteToggle.setOnCheckedChangeListener(autocompleteToggleListener) @@ -140,6 +141,7 @@ class SettingsActivity : DuckDuckGoActivity(), SettingsAutomaticallyClearWhatFra private fun processCommand(it: Command?) { when (it) { is Command.LaunchFeedback -> launchFeedback() + is Command.LaunchFireproofWebsites -> launchFireproofWebsites() is Command.LaunchAppIcon -> launchAppIconChange() is Command.UpdateTheme -> sendThemeChangedBroadcast() } @@ -167,6 +169,11 @@ class SettingsActivity : DuckDuckGoActivity(), SettingsAutomaticallyClearWhatFra startActivityForResult(Intent(FeedbackActivity.intent(this)), FEEDBACK_REQUEST_CODE, options) } + private fun launchFireproofWebsites() { + val options = ActivityOptions.makeSceneTransitionAnimation(this).toBundle() + startActivity(FireproofWebsitesActivity.intent(this), options) + } + private fun launchAppIconChange() { val options = ActivityOptions.makeSceneTransitionAnimation(this).toBundle() startActivityForResult(Intent(ChangeIconActivity.intent(this)), CHANGE_APP_ICON_REQUEST_CODE, options) diff --git a/app/src/main/java/com/duckduckgo/app/settings/SettingsViewModel.kt b/app/src/main/java/com/duckduckgo/app/settings/SettingsViewModel.kt index 5f46f8c51c77..5f4604a49704 100644 --- a/app/src/main/java/com/duckduckgo/app/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/settings/SettingsViewModel.kt @@ -59,6 +59,7 @@ class SettingsViewModel @Inject constructor( sealed class Command { object LaunchFeedback : Command() + object LaunchFireproofWebsites : Command() object LaunchAppIcon : Command() object UpdateTheme : Command() } @@ -101,6 +102,10 @@ class SettingsViewModel @Inject constructor( command.value = Command.LaunchAppIcon } + fun onFireproofWebsitesClicked() { + command.value = Command.LaunchFireproofWebsites + } + fun onLightThemeToggled(enabled: Boolean) { Timber.i("User toggled light theme, is now enabled: $enabled") settingsDataStore.theme = if (enabled) DuckDuckGoTheme.LIGHT else DuckDuckGoTheme.DARK diff --git a/app/src/main/res/drawable/ic_overflow_bookmarks_24dp.xml b/app/src/main/res/drawable/ic_overflow_24dp.xml similarity index 100% rename from app/src/main/res/drawable/ic_overflow_bookmarks_24dp.xml rename to app/src/main/res/drawable/ic_overflow_24dp.xml diff --git a/app/src/main/res/drawable/subtle_favicon_background.xml b/app/src/main/res/drawable/subtle_favicon_background.xml new file mode 100644 index 000000000000..c28b3a517f11 --- /dev/null +++ b/app/src/main/res/drawable/subtle_favicon_background.xml @@ -0,0 +1,23 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_fireproof_websites.xml b/app/src/main/res/layout/activity_fireproof_websites.xml new file mode 100644 index 000000000000..7ac64d10f48f --- /dev/null +++ b/app/src/main/res/layout/activity_fireproof_websites.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + diff --git a/app/src/main/res/layout/content_bookmarks.xml b/app/src/main/res/layout/content_bookmarks.xml index 0a7e29e35397..dc72e71752ac 100644 --- a/app/src/main/res/layout/content_bookmarks.xml +++ b/app/src/main/res/layout/content_bookmarks.xml @@ -24,8 +24,8 @@ android:id="@+id/recycler" android:layout_width="match_parent" android:layout_height="match_parent" - android:paddingTop="18dp" - android:paddingBottom="18dp" + android:paddingTop="8dp" + android:paddingBottom="8dp" app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" tools:listItem="@layout/view_bookmark_entry" /> diff --git a/app/src/main/res/layout/content_fireproof_websites.xml b/app/src/main/res/layout/content_fireproof_websites.xml new file mode 100644 index 000000000000..be5239e7775e --- /dev/null +++ b/app/src/main/res/layout/content_fireproof_websites.xml @@ -0,0 +1,32 @@ + + + + + + + diff --git a/app/src/main/res/layout/content_settings_privacy.xml b/app/src/main/res/layout/content_settings_privacy.xml index 4e2f6569d95a..811e043ebeac 100644 --- a/app/src/main/res/layout/content_settings_privacy.xml +++ b/app/src/main/res/layout/content_settings_privacy.xml @@ -23,13 +23,24 @@ + android:text="@string/settingsHeadingPrivacy" + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent"/> + + diff --git a/app/src/main/res/layout/layout_browser_bottom_navigation_bar.xml b/app/src/main/res/layout/layout_browser_bottom_navigation_bar.xml index 69faea48654f..6abf11a8dcb2 100644 --- a/app/src/main/res/layout/layout_browser_bottom_navigation_bar.xml +++ b/app/src/main/res/layout/layout_browser_bottom_navigation_bar.xml @@ -65,6 +65,6 @@ android:layout_weight="1" android:background="?selectableItemBackgroundBorderless" android:contentDescription="@string/back" - android:src="@drawable/ic_overflow_bookmarks_24dp" /> + android:src="@drawable/ic_overflow_24dp" /> \ No newline at end of file diff --git a/app/src/main/res/layout/layout_tabs_bottom_navigation_bar.xml b/app/src/main/res/layout/layout_tabs_bottom_navigation_bar.xml index ac6e55476990..76a2eb1b47e3 100644 --- a/app/src/main/res/layout/layout_tabs_bottom_navigation_bar.xml +++ b/app/src/main/res/layout/layout_tabs_bottom_navigation_bar.xml @@ -69,6 +69,6 @@ android:layout_weight="1" android:background="?selectableItemBackgroundBorderless" android:contentDescription="@string/back" - android:src="@drawable/ic_overflow_bookmarks_24dp" /> + android:src="@drawable/ic_overflow_24dp" /> \ No newline at end of file diff --git a/app/src/main/res/layout/popup_window_browser_bottom_tab_menu.xml b/app/src/main/res/layout/popup_window_browser_bottom_tab_menu.xml index e267ce816c83..a701a718af27 100644 --- a/app/src/main/res/layout/popup_window_browser_bottom_tab_menu.xml +++ b/app/src/main/res/layout/popup_window_browser_bottom_tab_menu.xml @@ -52,6 +52,11 @@ style="@style/BrowserTextMenuItem" android:text="@string/addBookmarkMenuTitle" /> + + + + - + app:layout_constraintTop_toTopOf="parent"> + + + @@ -57,16 +67,15 @@ android:id="@+id/url" android:layout_width="0dp" android:layout_height="wrap_content" - android:layout_marginStart="22dp" android:fontFamily="sans-serif" android:paddingTop="2dp" android:paddingBottom="4dp" android:textColor="?attr/bookmarkSubtitleTextColor" - android:textSize="12sp" + android:textSize="14sp" android:textStyle="normal" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toStartOf="@+id/overflowMenu" - app:layout_constraintStart_toEndOf="@id/favicon" + app:layout_constraintStart_toStartOf="@id/title" app:layout_constraintTop_toBottomOf="@id/title" tools:text="Bookmark" /> @@ -75,10 +84,9 @@ android:layout_width="wrap_content" android:layout_height="0dp" android:background="?android:attr/selectableItemBackground" - android:paddingStart="14dp" - android:paddingEnd="14dp" android:scaleType="center" - android:src="@drawable/ic_overflow_bookmarks_24dp" + android:tint="?attr/bookmarkIconColor" + android:src="@drawable/ic_overflow_24dp" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toTopOf="parent" diff --git a/app/src/main/res/layout/view_fireproof_website_description.xml b/app/src/main/res/layout/view_fireproof_website_description.xml new file mode 100644 index 000000000000..d5a923045d23 --- /dev/null +++ b/app/src/main/res/layout/view_fireproof_website_description.xml @@ -0,0 +1,33 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/view_fireproof_website_entry.xml b/app/src/main/res/layout/view_fireproof_website_entry.xml new file mode 100644 index 000000000000..a13176728ca3 --- /dev/null +++ b/app/src/main/res/layout/view_fireproof_website_entry.xml @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/fireproof_website_individual_overflow_menu.xml b/app/src/main/res/menu/fireproof_website_individual_overflow_menu.xml new file mode 100644 index 000000000000..19bd7581becb --- /dev/null +++ b/app/src/main/res/menu/fireproof_website_individual_overflow_menu.xml @@ -0,0 +1,23 @@ + + + + + + + \ 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 084377713189..d7cff297f4ea 100644 --- a/app/src/main/res/values/attrs.xml +++ b/app/src/main/res/values/attrs.xml @@ -33,6 +33,7 @@ + @@ -60,6 +61,7 @@ + diff --git a/app/src/main/res/values/string-untranslated.xml b/app/src/main/res/values/string-untranslated.xml index dd63faa8d0c7..445b1d1c85a3 100644 --- a/app/src/main/res/values/string-untranslated.xml +++ b/app/src/main/res/values/string-untranslated.xml @@ -32,4 +32,14 @@ Apply New Icon? The app may close to apply changes. Come on back after you\'ve admired your handsome new icon. + + Fireproof Websites + Fireproof Websites + Fireproof Website + <b>%s</b> is now fireproof! Visit Settings to learn more. + Undo + Are you sure you want to delete <b>%s</b>? + Websites rely on cookies to keep you signed in. When you Fireproof a site, cookies won\'t be erased and you\'ll stay signed in, even after using the Fire Button. + More options for fireproof website %s + Confirm diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index ccdbb44764b8..6f91cc708e24 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -53,7 +53,9 @@ @color/white @color/almostBlack @color/white + @color/almostBlack @color/white + @color/white @color/grayishTwo @color/white @color/midGray @@ -127,7 +129,9 @@ @color/warmerGray @color/white @color/grayishBrown + @color/whiteSix @color/almostBlack + @color/almostBlack @color/warmerGray @color/grayishBrown @color/pinkish_grey_two From 1019ac632d6491fb45413a6d9b8a67852946f583 Mon Sep 17 00:00:00 2001 From: Cristian Monforte Date: Wed, 13 May 2020 14:48:15 +0200 Subject: [PATCH 2/3] Remove cookies preserving fireproof websites (#808) * Implement logic to remove cookies preserving the ones related to fireproof website. - Try to directly remove cookies from WebView database, preserving the ones with hosts related to a fireproof website - If process fails, fallback to remove all the cookies to avoid any leak - Send pixels in the following scenarios: - database path not found - database can't be opened - delete query fails - database corruption --- .../app/fire/GetCookieHostsToPreserveTest.kt | 80 ++++++++++ .../duckduckgo/app/fire/RemoveCookiesTest.kt | 57 +++++++ .../app/fire/SQLCookieRemoverTest.kt | 144 ++++++++++++++++++ .../app/fire/WebViewCookieManagerTest.kt | 97 ++++++++---- .../app/fire/WebViewDatabaseLocatorTest.kt | 66 ++++++++ .../app/browser/BrowserTabFragment.kt | 1 + .../app/browser/di/BrowserModule.kt | 46 +++++- .../com/duckduckgo/app/fire/CookieRemover.kt | 129 ++++++++++++++++ .../duckduckgo/app/fire/DatabaseLocator.kt | 43 ++++++ .../app/fire/DuckDuckGoCookieManager.kt | 26 ++-- .../app/fire/GetCookieHostsToPreserve.kt | 37 +++++ .../app/fire/RemoveCookiesStrategy.kt | 33 ++++ .../data/FireproofWebsiteDao.kt | 3 + .../global/exception/ExceptionExtension.kt | 24 +++ .../global/exception/UncaughtExceptionDao.kt | 2 - .../exception/UncaughtExceptionRepository.kt | 11 +- .../app/statistics/api/OfflinePixelSender.kt | 76 ++++----- .../app/statistics/pixels/ExceptionPixel.kt | 47 ++++++ .../duckduckgo/app/statistics/pixels/Pixel.kt | 10 +- .../store/OfflinePixelCountDataStore.kt | 25 ++- 20 files changed, 861 insertions(+), 96 deletions(-) create mode 100644 app/src/androidTest/java/com/duckduckgo/app/fire/GetCookieHostsToPreserveTest.kt create mode 100644 app/src/androidTest/java/com/duckduckgo/app/fire/RemoveCookiesTest.kt create mode 100644 app/src/androidTest/java/com/duckduckgo/app/fire/SQLCookieRemoverTest.kt create mode 100644 app/src/androidTest/java/com/duckduckgo/app/fire/WebViewDatabaseLocatorTest.kt create mode 100644 app/src/main/java/com/duckduckgo/app/fire/CookieRemover.kt create mode 100644 app/src/main/java/com/duckduckgo/app/fire/DatabaseLocator.kt create mode 100644 app/src/main/java/com/duckduckgo/app/fire/GetCookieHostsToPreserve.kt create mode 100644 app/src/main/java/com/duckduckgo/app/fire/RemoveCookiesStrategy.kt create mode 100644 app/src/main/java/com/duckduckgo/app/global/exception/ExceptionExtension.kt create mode 100644 app/src/main/java/com/duckduckgo/app/statistics/pixels/ExceptionPixel.kt diff --git a/app/src/androidTest/java/com/duckduckgo/app/fire/GetCookieHostsToPreserveTest.kt b/app/src/androidTest/java/com/duckduckgo/app/fire/GetCookieHostsToPreserveTest.kt new file mode 100644 index 000000000000..20a65f159e77 --- /dev/null +++ b/app/src/androidTest/java/com/duckduckgo/app/fire/GetCookieHostsToPreserveTest.kt @@ -0,0 +1,80 @@ +/* + * 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.fire + +import androidx.room.Room +import androidx.test.platform.app.InstrumentationRegistry +import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteEntity +import com.duckduckgo.app.global.db.AppDatabase +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class GetCookieHostsToPreserveTest { + + private val context = InstrumentationRegistry.getInstrumentation().targetContext + private val db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java).build() + private val fireproofWebsiteDao = db.fireproofWebsiteDao() + private val getHostsToPreserve = GetCookieHostsToPreserve(fireproofWebsiteDao) + + @Test + fun whenSubDomainFireproofWebsiteThenExpectedListReturned() { + givenFireproofWebsitesStored(FireproofWebsiteEntity("mobile.twitter.com")) + val expectedList = listOf( + ".mobile.twitter.com", + "mobile.twitter.com", + ".twitter.com", + ".com" + ) + + val hostsToPreserve = getHostsToPreserve() + + assertTrue(expectedList.all { hostsToPreserve.contains(it) }) + } + + @Test + fun whenFireproofWebsiteThenExpectedListReturned() { + givenFireproofWebsitesStored(FireproofWebsiteEntity("twitter.com")) + val expectedList = listOf("twitter.com", ".twitter.com", ".com") + + val hostsToPreserve = getHostsToPreserve() + + assertTrue(expectedList.all { hostsToPreserve.contains(it) }) + } + + @Test + fun whenMultipleFireproofWebsiteWithSameTopLevelThenExpectedListReturned() { + givenFireproofWebsitesStored(FireproofWebsiteEntity("twitter.com")) + givenFireproofWebsitesStored(FireproofWebsiteEntity("example.com")) + val expectedList = listOf( + ".example.com", + "example.com", + "twitter.com", + ".twitter.com", + ".com" + ) + + val hostsToPreserve = getHostsToPreserve() + + assertEquals(expectedList.size, hostsToPreserve.size) + assertTrue(expectedList.all { hostsToPreserve.contains(it) }) + } + + private fun givenFireproofWebsitesStored(fireproofWebsiteEntity: FireproofWebsiteEntity) { + fireproofWebsiteDao.insert(fireproofWebsiteEntity) + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/duckduckgo/app/fire/RemoveCookiesTest.kt b/app/src/androidTest/java/com/duckduckgo/app/fire/RemoveCookiesTest.kt new file mode 100644 index 000000000000..957489e3e904 --- /dev/null +++ b/app/src/androidTest/java/com/duckduckgo/app/fire/RemoveCookiesTest.kt @@ -0,0 +1,57 @@ +/* + * 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.fire + +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.verify +import com.nhaarman.mockitokotlin2.verifyZeroInteractions +import com.nhaarman.mockitokotlin2.whenever +import kotlinx.coroutines.test.runBlockingTest +import org.junit.Test + +class RemoveCookiesTest { + + private val selectiveCookieRemover = mock() + private val cookieManagerRemover = mock() + private val removeCookies = RemoveCookies(cookieManagerRemover, selectiveCookieRemover) + + @Test + fun whenSelectiveCookieRemoverSucceedsThenNoMoreInteractions() = runBlockingTest { + selectiveCookieRemover.succeeds() + + removeCookies.removeCookies() + + verifyZeroInteractions(cookieManagerRemover) + } + + @Test + fun whenSelectiveCookieRemoverFailsThenFallbackToCookieManagerRemover() = runBlockingTest { + selectiveCookieRemover.fails() + + removeCookies.removeCookies() + + verify(cookieManagerRemover).removeCookies() + } + + private suspend fun CookieRemover.succeeds() { + whenever(this.removeCookies()).thenReturn(true) + } + + private suspend fun CookieRemover.fails() { + whenever(this.removeCookies()).thenReturn(false) + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/duckduckgo/app/fire/SQLCookieRemoverTest.kt b/app/src/androidTest/java/com/duckduckgo/app/fire/SQLCookieRemoverTest.kt new file mode 100644 index 000000000000..1bac9bdd9130 --- /dev/null +++ b/app/src/androidTest/java/com/duckduckgo/app/fire/SQLCookieRemoverTest.kt @@ -0,0 +1,144 @@ +/* + * 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.fire + +import android.webkit.CookieManager +import androidx.room.Room +import androidx.test.platform.app.InstrumentationRegistry +import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteEntity +import com.duckduckgo.app.global.DefaultDispatcherProvider +import com.duckduckgo.app.global.DispatcherProvider +import com.duckduckgo.app.global.db.AppDatabase +import com.duckduckgo.app.global.exception.RootExceptionFinder +import com.duckduckgo.app.statistics.pixels.ExceptionPixel +import com.duckduckgo.app.statistics.pixels.Pixel +import com.duckduckgo.app.statistics.store.OfflinePixelCountDataStore +import com.nhaarman.mockitokotlin2.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import org.junit.After +import org.junit.Assert.assertTrue +import org.junit.Test +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +class SQLCookieRemoverTest { + + private val context = InstrumentationRegistry.getInstrumentation().targetContext + private val db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java).build() + private val cookieManager = CookieManager.getInstance() + private val fireproofWebsiteDao = db.fireproofWebsiteDao() + private val mockPixel = mock() + private val mockOfflinePixelCountDataStore = mock() + private val webViewDatabaseLocator = WebViewDatabaseLocator(context) + private val getHostsToPreserve = GetCookieHostsToPreserve(fireproofWebsiteDao) + + @After + fun after() = runBlocking { + removeExistingCookies() + db.close() + } + + @Test + fun whenCookiesStoredAndRemoveExecutedThenResultTrue() = runBlocking { + givenDatabaseWithCookies() + val sqlCookieRemover = givenSQLCookieRemover() + + val success = sqlCookieRemover.removeCookies() + + assertTrue(success) + } + + @Test + fun whenNoCookiesStoredAndRemoveExecutedThenResultTrue() = runBlocking { + val sqlCookieRemover = givenSQLCookieRemover() + + val success = sqlCookieRemover.removeCookies() + + assertTrue(success) + } + + @Test + fun whenUserHasFireproofWebsitesAndRemoveExecutedThenResultTrue() = runBlocking { + val sqlCookieRemover = givenSQLCookieRemover() + givenDatabaseWithCookies() + givenFireproofWebsitesStored() + + val success = sqlCookieRemover.removeCookies() + + assertTrue(success) + } + + @Test + fun whenDatabasePathNotFoundThenPixelFired() = runBlocking { + val mockDatabaseLocator = mock { + on { getDatabasePath() } doReturn "" + } + val sqlCookieRemover = givenSQLCookieRemover(databaseLocator = mockDatabaseLocator) + + sqlCookieRemover.removeCookies() + + verify(mockOfflinePixelCountDataStore).cookieDatabaseNotFoundCount = 1 + } + + @Test + fun whenUnableToOpenDatabaseThenPixelFiredAndSaveOfflineCount() = runBlocking { + val mockDatabaseLocator = mock { + on { getDatabasePath() } doReturn "fakePath" + } + val sqlCookieRemover = givenSQLCookieRemover(databaseLocator = mockDatabaseLocator) + + sqlCookieRemover.removeCookies() + + verify(mockOfflinePixelCountDataStore).cookieDatabaseOpenErrorCount = 1 + verify(mockPixel).fire(eq(Pixel.PixelName.COOKIE_DATABASE_EXCEPTION_OPEN_ERROR), any(), any()) + } + + private fun givenFireproofWebsitesStored() { + fireproofWebsiteDao.insert(FireproofWebsiteEntity("example.com")) + } + + private fun givenDatabaseWithCookies() { + cookieManager.setCookie("example.com", "da=da") + cookieManager.flush() + } + + private suspend fun removeExistingCookies() { + withContext(Dispatchers.Main) { + suspendCoroutine { continuation -> + cookieManager.removeAllCookies { continuation.resume(Unit) } + } + } + } + + private fun givenSQLCookieRemover( + databaseLocator: DatabaseLocator = webViewDatabaseLocator, + cookieHostsToPreserve: GetCookieHostsToPreserve = getHostsToPreserve, + offlinePixelCountDataStore: OfflinePixelCountDataStore = mockOfflinePixelCountDataStore, + exceptionPixel: ExceptionPixel = ExceptionPixel(mockPixel, RootExceptionFinder()), + dispatcherProvider: DispatcherProvider = DefaultDispatcherProvider() + ): SQLCookieRemover { + return SQLCookieRemover( + databaseLocator, + cookieHostsToPreserve, + offlinePixelCountDataStore, + exceptionPixel, + dispatcherProvider + ) + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/duckduckgo/app/fire/WebViewCookieManagerTest.kt b/app/src/androidTest/java/com/duckduckgo/app/fire/WebViewCookieManagerTest.kt index f9b01b3fce24..e921854b03b6 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/fire/WebViewCookieManagerTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/fire/WebViewCookieManagerTest.kt @@ -17,66 +17,105 @@ package com.duckduckgo.app.fire import android.webkit.CookieManager +import android.webkit.ValueCallback +import com.duckduckgo.app.CoroutineTestRule +import com.duckduckgo.app.runBlocking +import com.nhaarman.mockitokotlin2.* import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.withContext -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue import org.junit.Before +import org.junit.Rule import org.junit.Test -import kotlin.coroutines.resume -import kotlin.coroutines.suspendCoroutine -@Suppress("RemoveExplicitTypeArguments") -class WebViewCookieManagerTest { +private data class Cookie(val url: String, val value: String) - private lateinit var testee: WebViewCookieManager +@ExperimentalCoroutinesApi +class WebViewCookieManagerTest { + @get:Rule + @Suppress("unused") + val coroutineRule = CoroutineTestRule() - private val cookieManager: CookieManager = CookieManager.getInstance() + private val removeCookieStrategy = mock() + private val cookieManager = mock() + private val ddgCookie = Cookie(DDG_HOST, "da=abc") + private val externalHostCookie = Cookie("example.com", "dz=zyx") + private val testee: WebViewCookieManager = WebViewCookieManager( + cookieManager, + DDG_HOST, + removeCookieStrategy, + coroutineRule.testDispatcherProvider + ) @Before - fun setup() = runBlocking { - removeExistingCookies() - testee = WebViewCookieManager(cookieManager, host) + fun setup() { + whenever(cookieManager.setCookie(any(), any(), any())).then { + (it.getArgument(2) as ValueCallback).onReceiveValue(true) + } } - private suspend fun removeExistingCookies() { + @Test + fun whenCookiesRemovedThenInternalCookiesRecreated() = coroutineRule.runBlocking { + givenCookieManagerWithCookies(ddgCookie, externalHostCookie) + withContext(Dispatchers.Main) { - suspendCoroutine { continuation -> - cookieManager.removeAllCookies { continuation.resume(Unit) } - } + testee.removeExternalCookies() } + + verify(cookieManager).setCookie(eq(ddgCookie.url), eq(ddgCookie.value), any()) } @Test - fun whenExternalCookiesClearedThenInternalCookiesRecreated() = runBlocking { - cookieManager.setCookie(host, "da=abc") - cookieManager.setCookie(externalHost, "dz=zyx") + fun whenCookiesStoredThenRemoveCookiesExecuted() = coroutineRule.runBlocking { + givenCookieManagerWithCookies(ddgCookie, externalHostCookie) withContext(Dispatchers.Main) { testee.removeExternalCookies() } - val actualCookies = cookieManager.getCookie(host)?.split(";").orEmpty() - assertEquals(1, actualCookies.size) - assertTrue(actualCookies.contains("da=abc")) + verify(removeCookieStrategy).removeCookies() } @Test - fun whenExternalCookiesClearedThenExternalCookiesAreNotRecreated() = runBlocking { - cookieManager.setCookie(host, "da=abc") - cookieManager.setCookie(externalHost, "dz=zyx") + fun whenCookiesStoredThenFlushBeforeAndAfterInteractingWithCookieManager() = coroutineRule.runBlocking { + givenCookieManagerWithCookies(ddgCookie, externalHostCookie) withContext(Dispatchers.Main) { testee.removeExternalCookies() } - val actualCookies = cookieManager.getCookie(externalHost)?.split(";").orEmpty() - assertEquals(0, actualCookies.size) + cookieManager.inOrder { + verify().flush() + verify().getCookie(DDG_HOST) + verify().hasCookies() + verify().setCookie(eq(DDG_HOST), any(), any()) + verify().flush() + } + } + + @Test + fun whenNoCookiesThenRemoveProcessNotExecuted() = coroutineRule.runBlocking { + givenCookieManagerWithCookies() + + withContext(Dispatchers.Main) { + testee.removeExternalCookies() + } + + verifyZeroInteractions(removeCookieStrategy) + } + + private fun givenCookieManagerWithCookies(vararg cookies: Cookie) { + if (cookies.isEmpty()) { + whenever(cookieManager.hasCookies()).thenReturn(false) + } else { + whenever(cookieManager.hasCookies()).thenReturn(true) + cookies.forEach { cookie -> + whenever(cookieManager.getCookie(cookie.url)).thenReturn(cookie.value) + } + } } companion object { - private const val host = "duckduckgo.com" - private const val externalHost = "example.com" + private const val DDG_HOST = "duckduckgo.com" } } \ No newline at end of file diff --git a/app/src/androidTest/java/com/duckduckgo/app/fire/WebViewDatabaseLocatorTest.kt b/app/src/androidTest/java/com/duckduckgo/app/fire/WebViewDatabaseLocatorTest.kt new file mode 100644 index 000000000000..d4940fd4d9a1 --- /dev/null +++ b/app/src/androidTest/java/com/duckduckgo/app/fire/WebViewDatabaseLocatorTest.kt @@ -0,0 +1,66 @@ +/* + * 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.fire + +import android.content.Context +import android.content.pm.ApplicationInfo +import androidx.test.platform.app.InstrumentationRegistry +import com.nhaarman.mockitokotlin2.doReturn +import com.nhaarman.mockitokotlin2.mock +import org.junit.Assert.assertTrue +import org.junit.Test + +class WebViewDatabaseLocatorTest { + + @Test + fun whenGetDatabasePathOnDeviceThenPathNotEmpty() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val webViewDatabaseLocator = WebViewDatabaseLocator(context) + + val databasePath = webViewDatabaseLocator.getDatabasePath() + + //If this test fails, it means WebViewDatabase path has changed its location + //If so, add a new database location to knownLocations list + assertTrue(databasePath.isNotEmpty()) + } + + @Test + fun whenDatabasePathFoundThenReturnedAbsolutePathToFile() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val dataDir = context.applicationInfo.dataDir + val webViewDatabaseLocator = WebViewDatabaseLocator(context) + + val databasePath = webViewDatabaseLocator.getDatabasePath() + + assertTrue(databasePath.startsWith(dataDir)) + } + + @Test + fun whenDatabasePathNotFoundThenReturnsEmpty() { + val mockApplicationInfo = mock().apply { + dataDir = "nonExistingDir" + } + val context = mock { + on { applicationInfo } doReturn mockApplicationInfo + } + val webViewDatabaseLocator = WebViewDatabaseLocator(context) + + val databasePath = webViewDatabaseLocator.getDatabasePath() + + assertTrue(databasePath.isEmpty()) + } +} \ No newline at end of file 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 da93b27f236f..598c02ff7bf8 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt @@ -192,6 +192,7 @@ import kotlinx.android.synthetic.main.popup_window_browser_menu.view.refreshPopu import kotlinx.android.synthetic.main.popup_window_browser_menu.view.requestDesktopSiteCheckMenuItem import kotlinx.android.synthetic.main.popup_window_browser_menu.view.settingsPopupMenuItem import kotlinx.android.synthetic.main.popup_window_browser_menu.view.sharePageMenuItem +import kotlinx.android.synthetic.main.popup_window_browser_menu.view.fireproofWebsitePopupMenuItem import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope diff --git a/app/src/main/java/com/duckduckgo/app/browser/di/BrowserModule.kt b/app/src/main/java/com/duckduckgo/app/browser/di/BrowserModule.kt index fdce382e76cd..7351a33cc9d6 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/di/BrowserModule.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/di/BrowserModule.kt @@ -31,9 +31,10 @@ import com.duckduckgo.app.browser.tabpreview.FileBasedWebViewPreviewGenerator import com.duckduckgo.app.browser.tabpreview.FileBasedWebViewPreviewPersister import com.duckduckgo.app.browser.tabpreview.WebViewPreviewGenerator import com.duckduckgo.app.browser.tabpreview.WebViewPreviewPersister -import com.duckduckgo.app.fire.DuckDuckGoCookieManager -import com.duckduckgo.app.fire.WebViewCookieManager +import com.duckduckgo.app.fire.* +import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteDao import com.duckduckgo.app.global.AppUrl +import com.duckduckgo.app.global.DispatcherProvider import com.duckduckgo.app.global.exception.UncaughtExceptionRepository import com.duckduckgo.app.global.file.FileDeleter import com.duckduckgo.app.global.install.AppInstallStore @@ -41,6 +42,7 @@ import com.duckduckgo.app.httpsupgrade.HttpsUpgrader import com.duckduckgo.app.privacy.db.PrivacyProtectionCountDao import com.duckduckgo.app.referral.AppReferrerDataStore import com.duckduckgo.app.statistics.VariantManager +import com.duckduckgo.app.statistics.pixels.ExceptionPixel import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.statistics.store.OfflinePixelCountDataStore import com.duckduckgo.app.statistics.store.StatisticsDataStore @@ -138,8 +140,44 @@ class BrowserModule { ): RequestInterceptor = WebViewRequestInterceptor(resourceSurrogates, trackerDetector, httpsUpgrader, privacyProtectionCountDao) @Provides - fun cookieManager(cookieManager: CookieManager): DuckDuckGoCookieManager { - return WebViewCookieManager(cookieManager, AppUrl.Url.HOST) + fun cookieManager( + cookieManager: CookieManager, + removeCookies: RemoveCookies, + dispatcherProvider: DispatcherProvider + ): DuckDuckGoCookieManager { + return WebViewCookieManager(cookieManager, AppUrl.Url.HOST, removeCookies, dispatcherProvider) + } + + @Provides + fun removeCookiesStrategy( + cookieManagerRemover: CookieManagerRemover, + sqlCookieRemover: SQLCookieRemover + ): RemoveCookies { + return RemoveCookies(cookieManagerRemover, sqlCookieRemover) + } + + @Provides + fun sqlCookieRemover( + webViewDatabaseLocator: WebViewDatabaseLocator, + getCookieHostsToPreserve: GetCookieHostsToPreserve, + offlinePixelCountDataStore: OfflinePixelCountDataStore, + exceptionPixel: ExceptionPixel, + dispatcherProvider: DispatcherProvider + ): SQLCookieRemover { + return SQLCookieRemover(webViewDatabaseLocator, getCookieHostsToPreserve, offlinePixelCountDataStore, exceptionPixel, dispatcherProvider) + } + + @Provides + fun webViewDatabaseLocator(context: Context): WebViewDatabaseLocator = WebViewDatabaseLocator(context) + + @Provides + fun getCookieHostsToPreserve(fireproofWebsiteDao: FireproofWebsiteDao): GetCookieHostsToPreserve = GetCookieHostsToPreserve(fireproofWebsiteDao) + + @Provides + fun cookieManagerRemover( + cookieManager: CookieManager + ): CookieManagerRemover { + return CookieManagerRemover(cookieManager) } @Singleton diff --git a/app/src/main/java/com/duckduckgo/app/fire/CookieRemover.kt b/app/src/main/java/com/duckduckgo/app/fire/CookieRemover.kt new file mode 100644 index 000000000000..cb68c450e237 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/fire/CookieRemover.kt @@ -0,0 +1,129 @@ +/* + * 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.fire + +import android.database.DatabaseErrorHandler +import android.database.DefaultDatabaseErrorHandler +import android.database.sqlite.SQLiteDatabase +import android.webkit.CookieManager +import com.duckduckgo.app.global.DispatcherProvider +import com.duckduckgo.app.statistics.pixels.ExceptionPixel +import com.duckduckgo.app.statistics.pixels.Pixel +import com.duckduckgo.app.statistics.store.OfflinePixelCountDataStore +import kotlinx.coroutines.withContext +import timber.log.Timber +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +interface CookieRemover { + suspend fun removeCookies(): Boolean +} + +class CookieManagerRemover(private val cookieManager: CookieManager) : CookieRemover { + override suspend fun removeCookies(): Boolean { + suspendCoroutine { continuation -> + cookieManager.removeAllCookies { + Timber.v("All cookies removed; restoring DDG cookies") + continuation.resume(Unit) + } + } + return true + } +} + +class SQLCookieRemover( + private val webViewDatabaseLocator: DatabaseLocator, + private val getCookieHostsToPreserve: GetCookieHostsToPreserve, + private val offlinePixelCountDataStore: OfflinePixelCountDataStore, + private val exceptionPixel: ExceptionPixel, + private val dispatcherProvider: DispatcherProvider +) : CookieRemover { + + private val databaseErrorHandler = PixelSenderDatabaseErrorHandler(offlinePixelCountDataStore) + + override suspend fun removeCookies(): Boolean { + return withContext(dispatcherProvider.io()) { + val databasePath: String = webViewDatabaseLocator.getDatabasePath() + if (databasePath.isNotEmpty()) { + val excludedHosts = getCookieHostsToPreserve() + return@withContext removeCookies(databasePath, excludedHosts) + } else { + offlinePixelCountDataStore.cookieDatabaseNotFoundCount += 1 + } + return@withContext false + } + } + + private fun openReadableDatabase(databasePath: String): SQLiteDatabase? { + return try { + SQLiteDatabase.openDatabase(databasePath, null, SQLiteDatabase.OPEN_READWRITE, databaseErrorHandler) + } catch (exception: Exception) { + offlinePixelCountDataStore.cookieDatabaseOpenErrorCount += 1 + exceptionPixel.sendExceptionPixel(Pixel.PixelName.COOKIE_DATABASE_EXCEPTION_OPEN_ERROR, exception) + null + } + } + + private fun removeCookies(databasePath: String, excludedSites: List): Boolean { + var deleteExecuted = false + openReadableDatabase(databasePath)?.apply { + try { + val whereClause = buildSQLWhereClause(excludedSites) + val number = delete(COOKIES_TABLE_NAME, whereClause, excludedSites.toTypedArray()) + deleteExecuted = true + Timber.v("$number cookies removed") + } catch (exception: Exception) { + Timber.e(exception) + offlinePixelCountDataStore.cookieDatabaseDeleteErrorCount += 1 + exceptionPixel.sendExceptionPixel(Pixel.PixelName.COOKIE_DATABASE_EXCEPTION_DELETE_ERROR, exception) + } finally { + close() + } + } + return deleteExecuted + } + + private fun buildSQLWhereClause(excludedSites: List): String { + if (excludedSites.isEmpty()) { + return "" + } + return excludedSites.foldIndexed("", { pos, acc, _ -> + if (pos == 0) { + "host_key NOT LIKE ?" + } else { + "$acc AND host_key NOT LIKE ?" + } + }) + } + + companion object { + private const val COOKIES_TABLE_NAME = "cookies" + } + + private class PixelSenderDatabaseErrorHandler( + private val offlinePixelCountDataStore: OfflinePixelCountDataStore + ) : DatabaseErrorHandler { + + private val delegate = DefaultDatabaseErrorHandler() + + override fun onCorruption(dbObj: SQLiteDatabase?) { + delegate.onCorruption(dbObj) + offlinePixelCountDataStore.cookieDatabaseCorruptedCount += 1 + } + } +} + diff --git a/app/src/main/java/com/duckduckgo/app/fire/DatabaseLocator.kt b/app/src/main/java/com/duckduckgo/app/fire/DatabaseLocator.kt new file mode 100644 index 000000000000..8730d5fc169e --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/fire/DatabaseLocator.kt @@ -0,0 +1,43 @@ +/* + * 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.fire + +import android.content.Context +import java.io.File + +interface DatabaseLocator { + fun getDatabasePath(): String +} + +class WebViewDatabaseLocator(private val context: Context) : DatabaseLocator { + + private val knownLocations = listOf("/app_webview/Default/Cookies", "/app_webview/Cookies") + + override fun getDatabasePath(): String { + val dataDir = context.applicationInfo.dataDir + val detectedPath = knownLocations.find { knownPath -> + val file = File(dataDir, knownPath) + file.exists() + } + + return detectedPath + .takeUnless { it.isNullOrEmpty() } + ?.let { nonEmptyPath -> + "$dataDir$nonEmptyPath" + }.orEmpty() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/duckduckgo/app/fire/DuckDuckGoCookieManager.kt b/app/src/main/java/com/duckduckgo/app/fire/DuckDuckGoCookieManager.kt index 02d4d9135aaf..628fc51e9bf8 100644 --- a/app/src/main/java/com/duckduckgo/app/fire/DuckDuckGoCookieManager.kt +++ b/app/src/main/java/com/duckduckgo/app/fire/DuckDuckGoCookieManager.kt @@ -17,13 +17,12 @@ package com.duckduckgo.app.fire import android.webkit.CookieManager -import kotlinx.coroutines.Dispatchers +import com.duckduckgo.app.global.DispatcherProvider import kotlinx.coroutines.withContext import timber.log.Timber import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine - interface DuckDuckGoCookieManager { suspend fun removeExternalCookies() fun flush() @@ -31,22 +30,21 @@ interface DuckDuckGoCookieManager { class WebViewCookieManager( private val cookieManager: CookieManager, - private val host: String + private val host: String, + private val removeCookies: RemoveCookiesStrategy, + private val dispatcher: DispatcherProvider ) : DuckDuckGoCookieManager { - override suspend fun removeExternalCookies() { + override suspend fun removeExternalCookies() { + withContext(dispatcher.io()) { + flush() + } val ddgCookies = getDuckDuckGoCookies() - - suspendCoroutine { continuation -> - cookieManager.removeAllCookies { - Timber.v("All cookies removed; restoring ${ddgCookies.size} DDG cookies") - continuation.resume(Unit) - } + if (cookieManager.hasCookies()) { + removeCookies.removeCookies() + storeDuckDuckGoCookies(ddgCookies) } - - storeDuckDuckGoCookies(ddgCookies) - - withContext(Dispatchers.IO) { + withContext(dispatcher.io()) { flush() } } diff --git a/app/src/main/java/com/duckduckgo/app/fire/GetCookieHostsToPreserve.kt b/app/src/main/java/com/duckduckgo/app/fire/GetCookieHostsToPreserve.kt new file mode 100644 index 000000000000..eec910a7250d --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/fire/GetCookieHostsToPreserve.kt @@ -0,0 +1,37 @@ +/* + * 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.fire + +import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteDao + +class GetCookieHostsToPreserve(private val fireproofWebsiteDao: FireproofWebsiteDao) { + operator fun invoke(): List { + val fireproofWebsites = fireproofWebsiteDao.fireproofWebsitesSync() + return fireproofWebsites.flatMap { entity -> + val acceptedHosts = mutableSetOf() + val host = entity.domain + acceptedHosts.add(host) + host.split(".") + .foldRight("", { next, acc -> + val acceptedHost = ".$next$acc" + acceptedHosts.add(acceptedHost) + acceptedHost + }) + acceptedHosts + }.distinct() + } +} diff --git a/app/src/main/java/com/duckduckgo/app/fire/RemoveCookiesStrategy.kt b/app/src/main/java/com/duckduckgo/app/fire/RemoveCookiesStrategy.kt new file mode 100644 index 000000000000..8f173c0d7658 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/fire/RemoveCookiesStrategy.kt @@ -0,0 +1,33 @@ +/* + * 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.fire + +interface RemoveCookiesStrategy { + suspend fun removeCookies() +} + +class RemoveCookies( + private val cookieManagerRemover: CookieRemover, + private val selectiveCookieRemover: CookieRemover +) : RemoveCookiesStrategy { + override suspend fun removeCookies() { + val removeSuccess = selectiveCookieRemover.removeCookies() + if (!removeSuccess) { + cookieManagerRemover.removeCookies() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/duckduckgo/app/fire/fireproofwebsite/data/FireproofWebsiteDao.kt b/app/src/main/java/com/duckduckgo/app/fire/fireproofwebsite/data/FireproofWebsiteDao.kt index d9c5eb26d2ba..23412a474498 100644 --- a/app/src/main/java/com/duckduckgo/app/fire/fireproofwebsite/data/FireproofWebsiteDao.kt +++ b/app/src/main/java/com/duckduckgo/app/fire/fireproofwebsite/data/FireproofWebsiteDao.kt @@ -22,6 +22,9 @@ import androidx.room.* @Dao interface FireproofWebsiteDao { + @Query("select * from fireproofWebsites") + fun fireproofWebsitesSync(): List + @Query("select * from fireproofWebsites") fun fireproofWebsitesEntities(): LiveData> diff --git a/app/src/main/java/com/duckduckgo/app/global/exception/ExceptionExtension.kt b/app/src/main/java/com/duckduckgo/app/global/exception/ExceptionExtension.kt new file mode 100644 index 000000000000..4daca3476e06 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/global/exception/ExceptionExtension.kt @@ -0,0 +1,24 @@ +/* + * 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.global.exception + +fun Throwable?.extractExceptionCause(): String { + if (this == null) { + return "Exception missing" + } + return "${this.javaClass.name} - ${this.stackTrace?.firstOrNull()}" +} \ No newline at end of file diff --git a/app/src/main/java/com/duckduckgo/app/global/exception/UncaughtExceptionDao.kt b/app/src/main/java/com/duckduckgo/app/global/exception/UncaughtExceptionDao.kt index fcb22a28aa0e..823aa5c3836f 100644 --- a/app/src/main/java/com/duckduckgo/app/global/exception/UncaughtExceptionDao.kt +++ b/app/src/main/java/com/duckduckgo/app/global/exception/UncaughtExceptionDao.kt @@ -35,7 +35,6 @@ abstract class UncaughtExceptionDao { @Query("DELETE FROM UncaughtExceptionEntity WHERE id=:id") abstract fun delete(id: Long) - } enum class UncaughtExceptionSource { @@ -59,5 +58,4 @@ class UncaughtExceptionSourceConverter { @TypeConverter fun convertFromDb(value: String): UncaughtExceptionSource? = UncaughtExceptionSource.valueOf(value) - } \ No newline at end of file diff --git a/app/src/main/java/com/duckduckgo/app/global/exception/UncaughtExceptionRepository.kt b/app/src/main/java/com/duckduckgo/app/global/exception/UncaughtExceptionRepository.kt index 3fd62ba1ad4b..19f5035ff258 100644 --- a/app/src/main/java/com/duckduckgo/app/global/exception/UncaughtExceptionRepository.kt +++ b/app/src/main/java/com/duckduckgo/app/global/exception/UncaughtExceptionRepository.kt @@ -20,7 +20,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import timber.log.Timber - interface UncaughtExceptionRepository { suspend fun recordUncaughtException(e: Throwable?, exceptionSource: UncaughtExceptionSource) suspend fun getExceptions(): List @@ -43,7 +42,7 @@ class UncaughtExceptionRepositoryDb( Timber.e(e, "Uncaught exception - $exceptionSource") val rootCause = rootExceptionFinder.findRootException(e) - val exceptionEntity = UncaughtExceptionEntity(message = extractExceptionCause(rootCause), exceptionSource = exceptionSource) + val exceptionEntity = UncaughtExceptionEntity(message = rootCause.extractExceptionCause(), exceptionSource = exceptionSource) uncaughtExceptionDao.add(exceptionEntity) lastSeenException = e @@ -56,14 +55,6 @@ class UncaughtExceptionRepositoryDb( } } - private fun extractExceptionCause(e: Throwable?): String { - if (e == null) { - return "Exception missing" - } - - return "${e.javaClass.name} - ${e.stackTrace?.firstOrNull()}" - } - override suspend fun deleteException(id: Long) { return withContext(Dispatchers.IO) { uncaughtExceptionDao.delete(id) diff --git a/app/src/main/java/com/duckduckgo/app/statistics/api/OfflinePixelSender.kt b/app/src/main/java/com/duckduckgo/app/statistics/api/OfflinePixelSender.kt index 65b68fc40cf2..1a0c8e9abb11 100644 --- a/app/src/main/java/com/duckduckgo/app/statistics/api/OfflinePixelSender.kt +++ b/app/src/main/java/com/duckduckgo/app/statistics/api/OfflinePixelSender.kt @@ -27,11 +27,13 @@ import com.duckduckgo.app.statistics.pixels.Pixel.PixelParameter.EXCEPTION_MESSA import com.duckduckgo.app.statistics.pixels.Pixel.PixelParameter.EXCEPTION_TIMESTAMP import com.duckduckgo.app.statistics.store.OfflinePixelCountDataStore import io.reactivex.Completable -import io.reactivex.Completable.* +import io.reactivex.Completable.complete +import io.reactivex.Completable.defer +import io.reactivex.Completable.mergeDelayError import kotlinx.coroutines.runBlocking import timber.log.Timber import javax.inject.Inject - +import kotlin.reflect.KMutableProperty0 /** * Most pixels are "send and forget" however we sometimes need to guarantee that a pixel will be sent. @@ -49,51 +51,41 @@ class OfflinePixelSender @Inject constructor( sendApplicationKilledPixel(), sendWebRendererCrashedPixel(), sendWebRendererKilledPixel(), + sendCookieDatabaseNotFoundPixel(), + sendCookieDatabaseOpenErrorPixel(), + sendCookieDatabaseDeleteErrorPixel(), + sendCookieDatabaseCorruptedErrorPixel(), sendUncaughtExceptionsPixel() ) ) } private fun sendApplicationKilledPixel(): Completable { - return defer { - val count = offlineCountCountDataStore.applicationCrashCount - if (count == 0) { - return@defer complete() - } - val params = mapOf(COUNT to count.toString()) - pixel.fireCompletable(APPLICATION_CRASH.pixelName, params).andThen { - Timber.v("Offline pixel sent ${APPLICATION_CRASH.pixelName} count: $count") - offlineCountCountDataStore.applicationCrashCount = 0 - } - } + return sendPixelCount(offlineCountCountDataStore::applicationCrashCount, APPLICATION_CRASH) } private fun sendWebRendererCrashedPixel(): Completable { - return defer { - val count = offlineCountCountDataStore.webRendererGoneCrashCount - if (count == 0) { - return@defer complete() - } - val params = mapOf(COUNT to count.toString()) - pixel.fireCompletable(WEB_RENDERER_GONE_CRASH.pixelName, params).andThen { - Timber.v("Offline pixel sent ${WEB_RENDERER_GONE_CRASH.pixelName} count: $count") - offlineCountCountDataStore.webRendererGoneCrashCount = 0 - } - } + return sendPixelCount(offlineCountCountDataStore::webRendererGoneCrashCount, WEB_RENDERER_GONE_CRASH) } private fun sendWebRendererKilledPixel(): Completable { - return defer { - val count = offlineCountCountDataStore.webRendererGoneKilledCount - if (count == 0) { - return@defer complete() - } - val params = mapOf(COUNT to count.toString()) - pixel.fireCompletable(WEB_RENDERER_GONE_KILLED.pixelName, params).andThen { - Timber.v("Offline pixel sent ${WEB_RENDERER_GONE_KILLED.pixelName} count: $count") - offlineCountCountDataStore.webRendererGoneKilledCount = 0 - } - } + return sendPixelCount(offlineCountCountDataStore::webRendererGoneKilledCount, WEB_RENDERER_GONE_KILLED) + } + + private fun sendCookieDatabaseDeleteErrorPixel(): Completable { + return sendPixelCount(offlineCountCountDataStore::cookieDatabaseDeleteErrorCount, COOKIE_DATABASE_DELETE_ERROR) + } + + private fun sendCookieDatabaseOpenErrorPixel(): Completable { + return sendPixelCount(offlineCountCountDataStore::cookieDatabaseOpenErrorCount, COOKIE_DATABASE_OPEN_ERROR) + } + + private fun sendCookieDatabaseNotFoundPixel(): Completable { + return sendPixelCount(offlineCountCountDataStore::cookieDatabaseNotFoundCount, COOKIE_DATABASE_NOT_FOUND) + } + + private fun sendCookieDatabaseCorruptedErrorPixel(): Completable { + return sendPixelCount(offlineCountCountDataStore::cookieDatabaseCorruptedCount, COOKIE_DATABASE_CORRUPTED_ERROR) } private fun sendUncaughtExceptionsPixel(): Completable { @@ -139,4 +131,18 @@ class OfflinePixelSender @Inject constructor( SHOW_FILE_CHOOSER -> APPLICATION_CRASH_WEBVIEW_SHOW_FILE_CHOOSER }.pixelName } + + private fun sendPixelCount(counter: KMutableProperty0, pixelName: Pixel.PixelName): Completable { + return defer { + val count = counter.get() + if (count == 0) { + return@defer complete() + } + val params = mapOf(COUNT to count.toString()) + pixel.fireCompletable(pixelName.pixelName, params).andThen { + Timber.v("Offline pixel sent ${pixelName.pixelName} count: $count") + counter.set(0) + } + } + } } diff --git a/app/src/main/java/com/duckduckgo/app/statistics/pixels/ExceptionPixel.kt b/app/src/main/java/com/duckduckgo/app/statistics/pixels/ExceptionPixel.kt new file mode 100644 index 000000000000..9341e5be76f0 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/statistics/pixels/ExceptionPixel.kt @@ -0,0 +1,47 @@ +/* + * 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.statistics.pixels + +import com.duckduckgo.app.global.exception.RootExceptionFinder +import com.duckduckgo.app.global.exception.extractExceptionCause +import javax.inject.Inject + +/** + * This is a temporary class: At some point we will introduce a new class to log handled exception or illegal states + * to be stored and send as offline pixels + */ +@Suppress("MemberVisibilityCanBePrivate", "unused") +class ExceptionPixel @Inject constructor(private val pixel: Pixel, private val rootExceptionFinder: RootExceptionFinder) { + + fun sendExceptionPixel(pixelName: Pixel.PixelName, throwable: Throwable) { + val params = getParams(throwable) + pixel.fire(pixelName, params) + } + + fun sendExceptionPixel(pixelName: String, throwable: Throwable) { + val params = getParams(throwable) + pixel.fire(pixelName, params) + } + + private fun getParams(throwable: Throwable): Map { + val rootCause = rootExceptionFinder.findRootException(throwable) + val exceptionCause = rootCause.extractExceptionCause() + return mapOf( + Pixel.PixelParameter.EXCEPTION_MESSAGE to exceptionCause + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/duckduckgo/app/statistics/pixels/Pixel.kt b/app/src/main/java/com/duckduckgo/app/statistics/pixels/Pixel.kt index 1cf587d04e70..e6b0c5e1428d 100644 --- a/app/src/main/java/com/duckduckgo/app/statistics/pixels/Pixel.kt +++ b/app/src/main/java/com/duckduckgo/app/statistics/pixels/Pixel.kt @@ -169,7 +169,15 @@ interface Pixel { MENU_ACTION_REFRESH_PRESSED("m_nav_r_p_%s"), MENU_ACTION_NEW_TAB_PRESSED("m_nav_nt_p_%s"), MENU_ACTION_BOOKMARKS_PRESSED("m_nav_b_p_%s"), - MENU_ACTION_SEARCH_PRESSED("m_nav_s_p_%s") + MENU_ACTION_SEARCH_PRESSED("m_nav_s_p_%s"), + + COOKIE_DATABASE_NOT_FOUND("m_cdb_nf"), + COOKIE_DATABASE_OPEN_ERROR("m_cdb_oe"), + COOKIE_DATABASE_DELETE_ERROR("m_cdb_de"), + COOKIE_DATABASE_CORRUPTED_ERROR("m_cdb_ce"), + + COOKIE_DATABASE_EXCEPTION_OPEN_ERROR("m_cdb_e_oe"), + COOKIE_DATABASE_EXCEPTION_DELETE_ERROR("m_cdb_e_de") } object PixelParameter { diff --git a/app/src/main/java/com/duckduckgo/app/statistics/store/OfflinePixelCountDataStore.kt b/app/src/main/java/com/duckduckgo/app/statistics/store/OfflinePixelCountDataStore.kt index 3be5e59c3839..b6933e3948ff 100644 --- a/app/src/main/java/com/duckduckgo/app/statistics/store/OfflinePixelCountDataStore.kt +++ b/app/src/main/java/com/duckduckgo/app/statistics/store/OfflinePixelCountDataStore.kt @@ -21,11 +21,14 @@ import android.content.SharedPreferences import androidx.core.content.edit import javax.inject.Inject - interface OfflinePixelCountDataStore { var applicationCrashCount: Int var webRendererGoneCrashCount: Int var webRendererGoneKilledCount: Int + var cookieDatabaseNotFoundCount: Int + var cookieDatabaseOpenErrorCount: Int + var cookieDatabaseCorruptedCount: Int + var cookieDatabaseDeleteErrorCount: Int } class OfflinePixelCountSharedPreferences @Inject constructor(private val context: Context) : OfflinePixelCountDataStore { @@ -42,6 +45,22 @@ class OfflinePixelCountSharedPreferences @Inject constructor(private val context get() = preferences.getInt(KEY_WEB_RENDERER_GONE_KILLED_COUNT, 0) set(value) = preferences.edit(true) { putInt(KEY_WEB_RENDERER_GONE_KILLED_COUNT, value) } + override var cookieDatabaseNotFoundCount: Int + get() = preferences.getInt(KEY_COOKIE_DATABASE_NOT_FOUND_COUNT, 0) + set(value) = preferences.edit(true) { putInt(KEY_COOKIE_DATABASE_NOT_FOUND_COUNT, value) } + + override var cookieDatabaseOpenErrorCount: Int + get() = preferences.getInt(KEY_COOKIE_DATABASE_OPEN_ERROR_COUNT, 0) + set(value) = preferences.edit(true) { putInt(KEY_COOKIE_DATABASE_OPEN_ERROR_COUNT, value) } + + override var cookieDatabaseDeleteErrorCount: Int + get() = preferences.getInt(KEY_COOKIE_DATABASE_DELETE_ERROR_COUNT, 0) + set(value) = preferences.edit(true) { putInt(KEY_COOKIE_DATABASE_DELETE_ERROR_COUNT, value) } + + override var cookieDatabaseCorruptedCount: Int + get() = preferences.getInt(KEY_COOKIE_DATABASE_CORRUPTED_COUNT, 0) + set(value) = preferences.edit(true) { putInt(KEY_COOKIE_DATABASE_CORRUPTED_COUNT, value) } + private val preferences: SharedPreferences get() = context.getSharedPreferences(FILENAME, Context.MODE_PRIVATE) @@ -50,5 +69,9 @@ class OfflinePixelCountSharedPreferences @Inject constructor(private val context private const val KEY_APPLICATION_CRASH_COUNT = "APPLICATION_CRASH_COUNT" private const val KEY_WEB_RENDERER_GONE_CRASH_COUNT = "WEB_RENDERER_GONE_CRASH_COUNT" private const val KEY_WEB_RENDERER_GONE_KILLED_COUNT = "WEB_RENDERER_GONE_KILLED_COUNT" + private const val KEY_COOKIE_DATABASE_NOT_FOUND_COUNT = "COOKIE_DATABASE_NOT_FOUND_COUNT" + private const val KEY_COOKIE_DATABASE_OPEN_ERROR_COUNT = "COOKIE_DATABASE_OPEN_ERROR_COUNT" + private const val KEY_COOKIE_DATABASE_DELETE_ERROR_COUNT = "COOKIE_DATABASE_DELETE_ERROR_COUNT" + private const val KEY_COOKIE_DATABASE_CORRUPTED_COUNT = "COOKIE_DATABASE_CORRUPTED_COUNT" } } \ No newline at end of file From 48df41ff9a103dac4a0d438455ca63dee820fe34 Mon Sep 17 00:00:00 2001 From: Cristian Monforte Date: Wed, 13 May 2020 15:21:25 +0200 Subject: [PATCH 3/3] Fireproof websites empty state and Feature pixels (#810) * Empty state for fireproof websites screen * Feature pixels - User clicks on "fireproof a website" - User undo "fireproof website" action (confirmation snackbar after fireproffing a website) - User removed a website from "fireproof websites" --- .../app/browser/BrowserTabViewModelTest.kt | 17 +++++++ .../ui/FireproofWebsitesViewModelTest.kt | 14 +++++- .../app/browser/BrowserTabViewModel.kt | 8 +-- .../ui/FireproofWebsiteAdapter.kt | 49 +++++++++++++------ .../ui/FireproofWebsitesActivity.kt | 2 +- .../ui/FireproofWebsitesViewModel.kt | 6 ++- .../duckduckgo/app/global/ViewModelFactory.kt | 3 +- .../duckduckgo/app/statistics/pixels/Pixel.kt | 6 ++- .../app/tabs/ui/TabSwitcherActivity.kt | 2 +- .../view_fireproof_website_description.xml | 2 + .../view_fireproof_website_empty_hint.xml | 38 ++++++++++++++ .../main/res/values/string-untranslated.xml | 1 + 12 files changed, 121 insertions(+), 27 deletions(-) create mode 100644 app/src/main/res/layout/view_fireproof_website_empty_hint.xml 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 f9d73c482c77..39ae8d726163 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt @@ -1754,6 +1754,13 @@ class BrowserTabViewModelTest { assertFalse(browserViewState().canFireproofSite) } + @Test + fun whenFireproofWebsiteAddedThenPixelSent() { + loadUrl("http://example.com/", isBrowserShowing = true) + testee.onFireproofWebsiteClicked() + verify(mockPixel).fire(Pixel.PixelName.FIREPROOF_WEBSITE_ADDED) + } + @Test fun whenUserClicksOnFireproofWebsiteSnackbarUndoActionThenFireproofWebsiteIsRemoved() { loadUrl("http://example.com/", isBrowserShowing = true) @@ -1764,6 +1771,16 @@ class BrowserTabViewModelTest { assertTrue(browserViewState().canFireproofSite) } + @Test + fun whenUserClicksOnFireproofWebsiteSnackbarUndoActionThenPixelSent() { + loadUrl("http://example.com/", isBrowserShowing = true) + testee.onFireproofWebsiteClicked() + assertCommandIssued { + testee.onFireproofWebsiteSnackbarUndoClicked(this.fireproofWebsiteEntity) + } + verify(mockPixel).fire(Pixel.PixelName.FIREPROOF_WEBSITE_UNDO) + } + private inline fun assertCommandIssued(instanceAssertions: T.() -> Unit = {}) { verify(mockCommandObserver, atLeastOnce()).onChanged(commandCaptor.capture()) val issuedCommand = commandCaptor.allValues.find { it is T } diff --git a/app/src/androidTest/java/com/duckduckgo/app/fire/fireproofwebsite/ui/FireproofWebsitesViewModelTest.kt b/app/src/androidTest/java/com/duckduckgo/app/fire/fireproofwebsite/ui/FireproofWebsitesViewModelTest.kt index 5b97b2c2c2bb..13cb15068311 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/fire/fireproofwebsite/ui/FireproofWebsitesViewModelTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/fire/fireproofwebsite/ui/FireproofWebsitesViewModelTest.kt @@ -26,6 +26,7 @@ import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteDao import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteEntity import com.duckduckgo.app.fire.fireproofwebsite.ui.FireproofWebsitesViewModel.Command.ConfirmDeleteFireproofWebsite import com.duckduckgo.app.global.db.AppDatabase +import com.duckduckgo.app.statistics.pixels.Pixel import com.nhaarman.mockitokotlin2.atLeastOnce import com.nhaarman.mockitokotlin2.mock import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -63,13 +64,15 @@ class FireproofWebsitesViewModelTest { private val mockViewStateObserver: Observer = mock() + private val mockPixel: Pixel = mock() + @Before fun before() { db = Room.inMemoryDatabaseBuilder(InstrumentationRegistry.getInstrumentation().targetContext, AppDatabase::class.java) .allowMainThreadQueries() .build() fireproofWebsiteDao = db.fireproofWebsiteDao() - viewModel = FireproofWebsitesViewModel(fireproofWebsiteDao, coroutineRule.testDispatcherProvider) + viewModel = FireproofWebsitesViewModel(fireproofWebsiteDao, coroutineRule.testDispatcherProvider, mockPixel) viewModel.command.observeForever(mockCommandObserver) viewModel.viewState.observeForever(mockViewStateObserver) } @@ -101,6 +104,15 @@ class FireproofWebsitesViewModelTest { assertTrue(viewStateCaptor.value.fireproofWebsitesEntities.isEmpty()) } + @Test + fun whenUserConfirmsToDeleteThenPixelSent() { + givenFireproofWebsiteDomain("domain.com") + + viewModel.delete(FireproofWebsiteEntity("domain.com")) + + verify(mockPixel).fire(Pixel.PixelName.FIREPROOF_WEBSITE_DELETED) + } + @Test fun whenViewModelInitialisedThenViewStateShowsCurrentFireproofWebsites() { givenFireproofWebsiteDomain("domain.com") 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 975daa475d3b..0016e472031c 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt @@ -722,16 +722,16 @@ class BrowserTabViewModel( fireproofWebsiteDao.insert(fireproofWebsiteEntity) } if (id >= 0) { + pixel.fire(PixelName.FIREPROOF_WEBSITE_ADDED) command.value = ShowFireproofWebSiteConfirmation(fireproofWebsiteEntity = fireproofWebsiteEntity) } } } fun onFireproofWebsiteSnackbarUndoClicked(fireproofWebsiteEntity: FireproofWebsiteEntity) { - viewModelScope.launch { - withContext(dispatchers.io()) { - fireproofWebsiteDao.delete(fireproofWebsiteEntity) - } + viewModelScope.launch(dispatchers.io()) { + fireproofWebsiteDao.delete(fireproofWebsiteEntity) + pixel.fire(PixelName.FIREPROOF_WEBSITE_UNDO) } } 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 97f5b5886a7b..42f83ba264cf 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 @@ -22,26 +22,26 @@ import android.view.View import android.view.ViewGroup import android.widget.ImageView import android.widget.PopupMenu -import androidx.annotation.StringRes import androidx.recyclerview.widget.RecyclerView import com.duckduckgo.app.browser.R import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteEntity import com.duckduckgo.app.fire.fireproofwebsite.data.website import com.duckduckgo.app.global.faviconLocation import com.duckduckgo.app.global.image.GlideApp -import kotlinx.android.synthetic.main.view_fireproof_website_description.view.* import kotlinx.android.synthetic.main.view_fireproof_website_entry.view.* import timber.log.Timber -import java.lang.IllegalArgumentException class FireproofWebsiteAdapter( - private val viewModel: FireproofWebsitesViewModel, - @StringRes private val listDescriptionStringRes: Int + private val viewModel: FireproofWebsitesViewModel ) : RecyclerView.Adapter() { - companion object Type { + companion object { const val FIREPROOF_WEBSITE_TYPE = 0 const val DESCRIPTION_TYPE = 1 + const val EMPTY_STATE_TYPE = 2 + + const val DESCRIPTION_ITEM_SIZE = 1 + const val EMPTY_HINT_ITEM_SIZE = 1 } var fireproofWebsites: List = emptyList() @@ -57,6 +57,10 @@ class FireproofWebsiteAdapter( val view = inflater.inflate(R.layout.view_fireproof_website_entry, parent, false) FireproofWebSiteViewHolder.FireproofWebsiteItemViewHolder(view, viewModel) } + EMPTY_STATE_TYPE -> { + val view = inflater.inflate(R.layout.view_fireproof_website_empty_hint, parent, false) + FireproofWebSiteViewHolder.FireproofWebsiteEmptyHintViewHolder(view) + } DESCRIPTION_TYPE -> { val view = inflater.inflate(R.layout.view_fireproof_website_description, parent, false) FireproofWebSiteViewHolder.FireproofWebsiteDescriptionViewHolder(view) @@ -66,32 +70,45 @@ class FireproofWebsiteAdapter( } override fun getItemViewType(position: Int): Int { - return if ((fireproofWebsites.size - 1) < position) { + return if (position == 0) { DESCRIPTION_TYPE } else { - FIREPROOF_WEBSITE_TYPE + getListItemType() } } override fun onBindViewHolder(holder: FireproofWebSiteViewHolder, position: Int) { when (holder) { - is FireproofWebSiteViewHolder.FireproofWebsiteDescriptionViewHolder -> holder.bind(listDescriptionStringRes) - is FireproofWebSiteViewHolder.FireproofWebsiteItemViewHolder -> holder.bind(fireproofWebsites[position]) + is FireproofWebSiteViewHolder.FireproofWebsiteItemViewHolder -> holder.bind(fireproofWebsites[getWebsiteItemPosition(position)]) } } override fun getItemCount(): Int { - return fireproofWebsites.size + 1 + return getItemsSize() + DESCRIPTION_ITEM_SIZE } -} -sealed class FireproofWebSiteViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + private fun getItemsSize() = if (fireproofWebsites.isEmpty()) { + EMPTY_HINT_ITEM_SIZE + } else { + fireproofWebsites.size + } + + private fun getWebsiteItemPosition(position: Int) = position - DESCRIPTION_ITEM_SIZE - class FireproofWebsiteDescriptionViewHolder(itemView: View) : FireproofWebSiteViewHolder(itemView) { - fun bind(@StringRes text: Int) = with(itemView) { - fireproofWebsiteDescription.setText(text) + private fun getListItemType(): Int { + return if (fireproofWebsites.isEmpty()) { + EMPTY_STATE_TYPE + } else { + FIREPROOF_WEBSITE_TYPE } } +} + +sealed class FireproofWebSiteViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + + class FireproofWebsiteDescriptionViewHolder(itemView: View) : FireproofWebSiteViewHolder(itemView) + + class FireproofWebsiteEmptyHintViewHolder(itemView: View) : FireproofWebSiteViewHolder(itemView) class FireproofWebsiteItemViewHolder(itemView: View, private val viewModel: FireproofWebsitesViewModel) : FireproofWebSiteViewHolder(itemView) { diff --git a/app/src/main/java/com/duckduckgo/app/fire/fireproofwebsite/ui/FireproofWebsitesActivity.kt b/app/src/main/java/com/duckduckgo/app/fire/fireproofwebsite/ui/FireproofWebsitesActivity.kt index 5f9b4df37f4a..3c7b1ead0ac4 100644 --- a/app/src/main/java/com/duckduckgo/app/fire/fireproofwebsite/ui/FireproofWebsitesActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/fire/fireproofwebsite/ui/FireproofWebsitesActivity.kt @@ -45,7 +45,7 @@ class FireproofWebsitesActivity : DuckDuckGoActivity() { } private fun setupFireproofWebsiteRecycler() { - adapter = FireproofWebsiteAdapter(viewModel, R.string.fireproofWebsiteFeatureDescription) + adapter = FireproofWebsiteAdapter(viewModel) recycler.adapter = adapter } diff --git a/app/src/main/java/com/duckduckgo/app/fire/fireproofwebsite/ui/FireproofWebsitesViewModel.kt b/app/src/main/java/com/duckduckgo/app/fire/fireproofwebsite/ui/FireproofWebsitesViewModel.kt index 661f98033be3..21d31bada978 100644 --- a/app/src/main/java/com/duckduckgo/app/fire/fireproofwebsite/ui/FireproofWebsitesViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/fire/fireproofwebsite/ui/FireproofWebsitesViewModel.kt @@ -22,11 +22,14 @@ import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteEntity import com.duckduckgo.app.fire.fireproofwebsite.ui.FireproofWebsitesViewModel.Command.ConfirmDeleteFireproofWebsite import com.duckduckgo.app.global.DispatcherProvider import com.duckduckgo.app.global.SingleLiveEvent +import com.duckduckgo.app.statistics.pixels.Pixel +import com.duckduckgo.app.statistics.pixels.Pixel.PixelName.FIREPROOF_WEBSITE_DELETED import kotlinx.coroutines.launch class FireproofWebsitesViewModel( private val dao: FireproofWebsiteDao, - private val dispatcherProvider: DispatcherProvider + private val dispatcherProvider: DispatcherProvider, + private val pixel: Pixel ) : ViewModel() { data class ViewState( @@ -66,6 +69,7 @@ class FireproofWebsitesViewModel( fun delete(entity: FireproofWebsiteEntity) { viewModelScope.launch(dispatcherProvider.io()) { dao.delete(entity) + pixel.fire(FIREPROOF_WEBSITE_DELETED) } } } \ No newline at end of file diff --git a/app/src/main/java/com/duckduckgo/app/global/ViewModelFactory.kt b/app/src/main/java/com/duckduckgo/app/global/ViewModelFactory.kt index a2c578f742d9..c619547fe661 100644 --- a/app/src/main/java/com/duckduckgo/app/global/ViewModelFactory.kt +++ b/app/src/main/java/com/duckduckgo/app/global/ViewModelFactory.kt @@ -210,6 +210,7 @@ class ViewModelFactory @Inject constructor( private fun fireproofWebsiteViewModel() = FireproofWebsitesViewModel( dao = fireproofWebsiteDao, - dispatcherProvider = dispatcherProvider + dispatcherProvider = dispatcherProvider, + pixel = pixel ) } diff --git a/app/src/main/java/com/duckduckgo/app/statistics/pixels/Pixel.kt b/app/src/main/java/com/duckduckgo/app/statistics/pixels/Pixel.kt index e6b0c5e1428d..97e77d5e155d 100644 --- a/app/src/main/java/com/duckduckgo/app/statistics/pixels/Pixel.kt +++ b/app/src/main/java/com/duckduckgo/app/statistics/pixels/Pixel.kt @@ -175,9 +175,11 @@ interface Pixel { COOKIE_DATABASE_OPEN_ERROR("m_cdb_oe"), COOKIE_DATABASE_DELETE_ERROR("m_cdb_de"), COOKIE_DATABASE_CORRUPTED_ERROR("m_cdb_ce"), - COOKIE_DATABASE_EXCEPTION_OPEN_ERROR("m_cdb_e_oe"), - COOKIE_DATABASE_EXCEPTION_DELETE_ERROR("m_cdb_e_de") + COOKIE_DATABASE_EXCEPTION_DELETE_ERROR("m_cdb_e_de"), + FIREPROOF_WEBSITE_ADDED("m_fw_a"), + FIREPROOF_WEBSITE_DELETED("m_fw_d"), + FIREPROOF_WEBSITE_UNDO("m_fw_u") } object PixelParameter { diff --git a/app/src/main/java/com/duckduckgo/app/tabs/ui/TabSwitcherActivity.kt b/app/src/main/java/com/duckduckgo/app/tabs/ui/TabSwitcherActivity.kt index fa1241e2ebba..025dc8ec97fc 100644 --- a/app/src/main/java/com/duckduckgo/app/tabs/ui/TabSwitcherActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/tabs/ui/TabSwitcherActivity.kt @@ -85,7 +85,7 @@ class TabSwitcherActivity : DuckDuckGoActivity(), TabSwitcherListener, Coroutine setContentView(R.layout.activity_tab_switcher) extractIntentExtras() configureViewReferences() - setupToolbar(toolbar) + setupToolbar(toolbar) configureRecycler() configureObservers() } diff --git a/app/src/main/res/layout/view_fireproof_website_description.xml b/app/src/main/res/layout/view_fireproof_website_description.xml index d5a923045d23..d71b535a5153 100644 --- a/app/src/main/res/layout/view_fireproof_website_description.xml +++ b/app/src/main/res/layout/view_fireproof_website_description.xml @@ -29,5 +29,7 @@ android:textColor="?attr/settingsMinorTextColor" android:textSize="14sp" android:textStyle="normal" + android:justificationMode="inter_word" + android:text="@string/fireproofWebsiteFeatureDescription" tools:text="Lorem ipsum dolor sit amet" /> \ No newline at end of file diff --git a/app/src/main/res/layout/view_fireproof_website_empty_hint.xml b/app/src/main/res/layout/view_fireproof_website_empty_hint.xml new file mode 100644 index 000000000000..82c3d0bdde5c --- /dev/null +++ b/app/src/main/res/layout/view_fireproof_website_empty_hint.xml @@ -0,0 +1,38 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/string-untranslated.xml b/app/src/main/res/values/string-untranslated.xml index 445b1d1c85a3..7c5debe22455 100644 --- a/app/src/main/res/values/string-untranslated.xml +++ b/app/src/main/res/values/string-untranslated.xml @@ -39,6 +39,7 @@ <b>%s</b> is now fireproof! Visit Settings to learn more. Undo Are you sure you want to delete <b>%s</b>? + No websites fireproofed yet Websites rely on cookies to keep you signed in. When you Fireproof a site, cookies won\'t be erased and you\'ll stay signed in, even after using the Fire Button. More options for fireproof website %s Confirm