diff --git a/app/schemas/com.duckduckgo.app.global.db.AppDatabase/21.json b/app/schemas/com.duckduckgo.app.global.db.AppDatabase/21.json new file mode 100644 index 000000000000..c72a49108d06 --- /dev/null +++ b/app/schemas/com.duckduckgo.app.global.db.AppDatabase/21.json @@ -0,0 +1,720 @@ +{ + "formatVersion": 1, + "database": { + "version": 21, + "identityHash": "731dc1f9e0785f4eda1db2708d1bf9eb", + "entities": [ + { + "tableName": "tds_tracker", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`domain` TEXT NOT NULL, `defaultAction` TEXT NOT NULL, `ownerName` TEXT NOT NULL, `categories` TEXT NOT NULL, `rules` TEXT NOT NULL, PRIMARY KEY(`domain`))", + "fields": [ + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaultAction", + "columnName": "defaultAction", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ownerName", + "columnName": "ownerName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "categories", + "columnName": "categories", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rules", + "columnName": "rules", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "domain" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "tds_entity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `displayName` TEXT NOT NULL, `prevalence` REAL NOT NULL, PRIMARY KEY(`name`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "prevalence", + "columnName": "prevalence", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "name" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "tds_domain_entity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`domain` TEXT NOT NULL, `entityName` TEXT NOT NULL, PRIMARY KEY(`domain`))", + "fields": [ + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "entityName", + "columnName": "entityName", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "domain" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "temporary_tracking_whitelist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`domain` TEXT NOT NULL, PRIMARY KEY(`domain`))", + "fields": [ + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "domain" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "user_whitelist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`domain` TEXT NOT NULL, PRIMARY KEY(`domain`))", + "fields": [ + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "domain" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "https_bloom_filter_spec", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `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, '731dc1f9e0785f4eda1db2708d1bf9eb')" + ] + } +} \ 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 3ced1d5b5516..09f4e1c556b1 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt @@ -50,6 +50,8 @@ 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.global.install.AppInstallStore import com.duckduckgo.app.global.model.SiteFactory @@ -188,6 +190,8 @@ class BrowserTabViewModelTest { private lateinit var testee: BrowserTabViewModel + private lateinit var fireproofWebsiteDao: FireproofWebsiteDao + private val selectedTabLiveData = MutableLiveData() @Before @@ -197,6 +201,7 @@ class BrowserTabViewModelTest { db = Room.inMemoryDatabaseBuilder(getInstrumentation().targetContext, AppDatabase::class.java) .allowMainThreadQueries() .build() + fireproofWebsiteDao = db.fireproofWebsiteDao() mockAutoCompleteApi = AutoCompleteApi(mockAutoCompleteService, mockBookmarksDao) @@ -243,7 +248,8 @@ class BrowserTabViewModelTest { ctaViewModel = ctaViewModel, searchCountDao = mockSearchCountDao, pixel = mockPixel, - dispatchers = coroutineRule.testDispatcherProvider + dispatchers = coroutineRule.testDispatcherProvider, + fireproofWebsiteDao = fireproofWebsiteDao ) testee.loadData("abc", null, false) @@ -1085,7 +1091,7 @@ class BrowserTabViewModelTest { } @Test - fun whenUserTogglesWhitelsitedSiteThenSiteRemovedFromWhitelistAndPixelSentAndPageRefreshed() = coroutineRule.runBlocking { + fun whenUserTogglesWhitelsitedSiteThenSiteRemovedFromWhitelistAndPixelSentAndPageRefreshed() = coroutineRule.runBlocking { whenever(mockUserWhitelistDao.contains("www.example.com")).thenReturn(true) loadUrl("http://www.example.com/home.html") testee.onWhitelistSelected() @@ -1259,6 +1265,7 @@ class BrowserTabViewModelTest { assertFalse(browserViewState().canGoForward) assertFalse(browserViewState().canReportSite) assertFalse(browserViewState().canChangeBrowsingMode) + assertFalse(browserViewState().canFireproofSite) assertFalse(findInPageViewState().canFindInPage) } @@ -1610,6 +1617,100 @@ class BrowserTabViewModelTest { assertCommandIssued() } + @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 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) + testee.onFireproofWebsiteClicked() + assertCommandIssued { + testee.onFireproofWebsiteSnackbarUndoClicked(this.fireproofWebsiteEntity) + } + 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 } @@ -1644,6 +1745,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/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/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..13cb15068311 --- /dev/null +++ b/app/src/androidTest/java/com/duckduckgo/app/fire/fireproofwebsite/ui/FireproofWebsitesViewModelTest.kt @@ -0,0 +1,136 @@ +/* + * 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.duckduckgo.app.statistics.pixels.Pixel +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() + + 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, mockPixel) + 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 whenUserConfirmsToDeleteThenPixelSent() { + givenFireproofWebsiteDomain("domain.com") + + viewModel.delete(FireproofWebsiteEntity("domain.com")) + + verify(mockPixel).fire(Pixel.PixelName.FIREPROOF_WEBSITE_DELETED) + } + + @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 025b634dbc39..d3306f8e2bec 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 @@ -211,6 +211,11 @@ class AppDatabaseTest { assertEquals(0, database().uncaughtExceptionDao().count()) } + @Test + fun whenMigratingFromVersion20To21ThenValidationSucceeds() { + createDatabaseAndMigrate(20, 21, migrationsProvider.MIGRATION_20_TO_21) + } + private fun createDatabase(version: Int) { testHelper.createDatabase(TEST_DB_NAME, version).close() } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7a3aa59f914b..0a891e013a8e 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 e4cca8a8e248..3fc90ca086f2 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 @@ -29,8 +29,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() { @@ -127,10 +123,12 @@ class BookmarksActivity : DuckDuckGoActivity() { private fun confirmDeleteBookmark(bookmark: BookmarkEntity) { val message = getString(R.string.bookmarkDeleteConfirmMessage, bookmark.title).html(this) val title = getString(R.string.dialogConfirmTitle) - 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() } @@ -238,7 +236,6 @@ class BookmarksActivity : DuckDuckGoActivity() { popup.show() } - private fun editBookmark(bookmark: BookmarkEntity) { Timber.i("Editing bookmark ${bookmark.title}") viewModel.onEditBookmarkRequested(bookmark) 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 cd979cf4f7e1..055cf2566e30 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt @@ -47,6 +47,8 @@ import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.content.ContextCompat import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.view.* +import androidx.core.text.HtmlCompat +import androidx.core.text.HtmlCompat.FROM_HTML_MODE_LEGACY import androidx.fragment.app.Fragment import androidx.fragment.app.transaction import androidx.lifecycle.Lifecycle @@ -78,6 +80,8 @@ import com.duckduckgo.app.browser.tabpreview.WebViewPreviewPersister import com.duckduckgo.app.browser.ui.HttpAuthenticationDialogFragment import com.duckduckgo.app.browser.useragent.UserAgentProvider import com.duckduckgo.app.cta.ui.* +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 @@ -121,6 +125,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.whitelistPopupMenuItem +import kotlinx.android.synthetic.main.popup_window_browser_menu.view.fireproofWebsitePopupMenuItem import kotlinx.coroutines.* import org.jetbrains.anko.longToast import org.jetbrains.anko.share @@ -490,6 +495,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) } @@ -924,6 +930,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, "") } @@ -1293,6 +1311,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.whitelistPopupMenuItem) { viewModel.onWhitelistSelected() } @@ -1329,6 +1348,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.whitelistPopupMenuItem) { viewModel.onWhitelistSelected() } onMenuItemClicked(view.brokenSitePopupMenuItem) { viewModel.onBrokenSiteSelected() } @@ -1628,6 +1648,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 whitelistPopupMenuItem?.isEnabled = viewState.canWhitelist whitelistPopupMenuItem?.text = getText(if (viewState.isWhitelisted) R.string.whitelistRemove else R.string.whitelistAdd) 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 4a4e2d120224..db5688517d0a 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 @@ -57,6 +58,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 @@ -96,6 +99,7 @@ class BrowserTabViewModel( private val userWhitelistDao: UserWhitelistDao, private val networkLeaderboardDao: NetworkLeaderboardDao, private val bookmarksDao: BookmarksDao, + private val fireproofWebsiteDao: FireproofWebsiteDao, private val autoComplete: AutoComplete, private val appSettingsPreferencesStore: SettingsDataStore, private val longPressHandler: LongPressHandler, @@ -133,6 +137,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 canWhitelist: Boolean = false, @@ -186,6 +191,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() @@ -230,15 +236,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) { @@ -302,6 +313,7 @@ class BrowserTabViewModel( buildingSiteFactoryJob?.cancel() autoCompleteDisposable?.dispose() autoCompleteDisposable = null + fireproofWebsiteState.removeObserver(fireproofWebsitesObserver) super.onCleared() } @@ -451,6 +463,7 @@ class BrowserTabViewModel( browserViewState.value = currentBrowserViewState().copy( browserShowing = false, canGoBack = false, + canFireproofSite = false, canGoForward = currentGlobalLayoutState() !is Invalidated ) omnibarViewState.value = currentOmnibarViewState().copy(omnibarText = "", shouldMoveCaretToEnd = false) @@ -472,7 +485,6 @@ class BrowserTabViewModel( } override fun navigationStateChanged(newWebNavigationState: WebNavigationState) { - val stateChange = newWebNavigationState.compare(webNavigationState) webNavigationState = newWebNavigationState @@ -493,7 +505,6 @@ class BrowserTabViewModel( } private fun pageChanged(url: String, title: String?) { - Timber.v("Page changed: $url") buildSiteFactory(url, title) @@ -515,7 +526,8 @@ class BrowserTabViewModel( canWhitelist = canWhitelist, isWhitelisted = false, showSearchIcon = false, - showClearButton = false + showClearButton = false, + canFireproofSite = canFireproofWebsite() ) Timber.d("showPrivacyGrade=true, showSearchIcon=false, showClearButton=false") @@ -553,6 +565,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 { @@ -577,7 +590,8 @@ class BrowserTabViewModel( showPrivacyGrade = false, canReportSite = false, showSearchIcon = true, - showClearButton = true + showClearButton = true, + canFireproofSite = false ) Timber.d("showPrivacyGrade=false, showSearchIcon=true, showClearButton=true") } @@ -737,6 +751,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) { + pixel.fire(PixelName.FIREPROOF_WEBSITE_ADDED) + command.value = ShowFireproofWebSiteConfirmation(fireproofWebsiteEntity = fireproofWebsiteEntity) + } + } + } + + fun onFireproofWebsiteSnackbarUndoClicked(fireproofWebsiteEntity: FireproofWebsiteEntity) { + viewModelScope.launch(dispatchers.io()) { + fireproofWebsiteDao.delete(fireproofWebsiteEntity) + pixel.fire(PixelName.FIREPROOF_WEBSITE_UNDO) + } + } + override fun onBookmarkEdited(id: Long, title: String, url: String) { viewModelScope.launch(dispatchers.io()) { editBookmark(id, title, url) @@ -1094,6 +1130,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() @@ -1105,7 +1147,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/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/di/AndroidBindingModule.kt b/app/src/main/java/com/duckduckgo/app/di/AndroidBindingModule.kt index aad4d9e66657..0d51b4f4f24c 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 @@ -123,6 +124,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 424ef2501f3d..7c745869351f 100644 --- a/app/src/main/java/com/duckduckgo/app/di/DaoModule.kt +++ b/app/src/main/java/com/duckduckgo/app/di/DaoModule.kt @@ -79,4 +79,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/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 new file mode 100644 index 000000000000..23412a474498 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/fire/fireproofwebsite/data/FireproofWebsiteDao.kt @@ -0,0 +1,36 @@ +/* + * 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 fireproofWebsitesSync(): List + + @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..42f83ba264cf --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/fire/fireproofwebsite/ui/FireproofWebsiteAdapter.kt @@ -0,0 +1,162 @@ +/* + * 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.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_entry.view.* +import timber.log.Timber + +class FireproofWebsiteAdapter( + private val viewModel: FireproofWebsitesViewModel +) : RecyclerView.Adapter() { + + 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() + 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) + } + 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) + } + else -> throw IllegalArgumentException("viewType not found") + } + } + + override fun getItemViewType(position: Int): Int { + return if (position == 0) { + DESCRIPTION_TYPE + } else { + getListItemType() + } + } + + override fun onBindViewHolder(holder: FireproofWebSiteViewHolder, position: Int) { + when (holder) { + is FireproofWebSiteViewHolder.FireproofWebsiteItemViewHolder -> holder.bind(fireproofWebsites[getWebsiteItemPosition(position)]) + } + } + + override fun getItemCount(): Int { + return getItemsSize() + DESCRIPTION_ITEM_SIZE + } + + private fun getItemsSize() = if (fireproofWebsites.isEmpty()) { + EMPTY_HINT_ITEM_SIZE + } else { + fireproofWebsites.size + } + + private fun getWebsiteItemPosition(position: Int) = position - DESCRIPTION_ITEM_SIZE + + 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) { + + 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..29ecc94a6b5d --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/fire/fireproofwebsite/ui/FireproofWebsitesActivity.kt @@ -0,0 +1,88 @@ +/* + * 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.lifecycle.Observer +import com.duckduckgo.app.browser.R +import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteEntity +import com.duckduckgo.app.global.DuckDuckGoActivity +import com.duckduckgo.app.global.view.html +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) + 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 = getString(R.string.fireproofWebsiteDeleteConfirmMessage, entity.domain).html(this) + val title = getString(R.string.dialogConfirmTitle) + 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..21d31bada978 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/fire/fireproofwebsite/ui/FireproofWebsitesViewModel.kt @@ -0,0 +1,75 @@ +/* + * 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 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 pixel: Pixel +) : 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) + 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 0efeb63d178e..d93f513c62f3 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 @@ -84,6 +86,7 @@ class ViewModelFactory @Inject constructor( private val userWhitelistDao: UserWhitelistDao, private val networkLeaderboardDao: NetworkLeaderboardDao, private val bookmarksDao: BookmarksDao, + private val fireproofWebsiteDao: FireproofWebsiteDao, private val surveyDao: SurveyDao, private val autoCompleteApi: AutoCompleteApi, private val deviceAppLookup: DeviceAppLookup, @@ -137,6 +140,7 @@ class ViewModelFactory @Inject constructor( isAssignableFrom(BrokenSiteNegativeFeedbackViewModel::class.java) -> BrokenSiteNegativeFeedbackViewModel() isAssignableFrom(DefaultBrowserPageViewModel::class.java) -> defaultBrowserPage() isAssignableFrom(ChangeIconViewModel::class.java) -> changeAppIconViewModel() + isAssignableFrom(FireproofWebsitesViewModel::class.java) -> fireproofWebsiteViewModel() else -> throw IllegalArgumentException("Unknown ViewModel class: ${modelClass.name}") } @@ -182,6 +186,7 @@ class ViewModelFactory @Inject constructor( userWhitelistDao = userWhitelistDao, networkLeaderboardDao = networkLeaderboardDao, bookmarksDao = bookmarksDao, + fireproofWebsiteDao = fireproofWebsiteDao, autoComplete = autoCompleteApi, appSettingsPreferencesStore = appSettingsPreferencesStore, longPressHandler = webViewLongPressHandler, @@ -196,4 +201,11 @@ class ViewModelFactory @Inject constructor( private fun changeAppIconViewModel() = ChangeIconViewModel(settingsDataStore = appSettingsPreferencesStore, appIconModifier = appIconModifier, pixel = pixel) + + private fun fireproofWebsiteViewModel() = + FireproofWebsitesViewModel( + dao = fireproofWebsiteDao, + dispatcherProvider = dispatcherProvider, + pixel = pixel + ) } 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 c167dded6683..a5d3cf98a238 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 @@ -56,7 +58,7 @@ import com.duckduckgo.app.usage.search.SearchCountDao import com.duckduckgo.app.usage.search.SearchCountEntity @Database( - exportSchema = true, version = 20, entities = [ + exportSchema = true, version = 21, entities = [ TdsTracker::class, TdsEntity::class, TdsDomainEntity::class, @@ -78,7 +80,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") @@ -282,6 +286,12 @@ class MigrationsProvider(val context: Context) { } } + val MIGRATION_20_TO_21: Migration = object : Migration(20, 21) { + 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, @@ -302,7 +312,8 @@ class MigrationsProvider(val context: Context) { MIGRATION_16_TO_17, MIGRATION_17_TO_18, MIGRATION_18_TO_19, - MIGRATION_19_TO_20 + MIGRATION_19_TO_20, + MIGRATION_20_TO_21 ) @Deprecated( 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/settings/SettingsActivity.kt b/app/src/main/java/com/duckduckgo/app/settings/SettingsActivity.kt index 9e1bcf87782f..cb43eadc24a1 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 @@ -83,6 +84,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) @@ -135,6 +137,7 @@ class SettingsActivity : DuckDuckGoActivity(), SettingsAutomaticallyClearWhatFra private fun processCommand(it: Command?) { when (it) { is Command.LaunchFeedback -> launchFeedback() + is Command.LaunchFireproofWebsites -> launchFireproofWebsites() is Command.LaunchWhitelist -> launchWhitelist() is Command.LaunchAppIcon -> launchAppIconChange() is Command.UpdateTheme -> sendThemeChangedBroadcast() @@ -163,6 +166,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 launchWhitelist() { val options = ActivityOptions.makeSceneTransitionAnimation(this).toBundle() startActivity(WhitelistActivity.intent(this), 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 cbd1e84f3518..c7fe6230ce54 100644 --- a/app/src/main/java/com/duckduckgo/app/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/settings/SettingsViewModel.kt @@ -59,7 +59,8 @@ class SettingsViewModel @Inject constructor( sealed class Command { object LaunchFeedback : Command() - object LaunchWhitelist: Command() + object LaunchFireproofWebsites : Command() + object LaunchWhitelist : Command() object LaunchAppIcon : Command() object UpdateTheme : Command() } @@ -102,6 +103,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/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 2e81ca74f834..4be178a2d6ff 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 @@ -174,7 +174,17 @@ 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"), + 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/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 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/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 7147020613b3..088020d792ad 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/popup_window_browser_bottom_tab_menu.xml b/app/src/main/res/layout/popup_window_browser_bottom_tab_menu.xml index 3d668afe269f..a62062ebee55 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" /> 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..d71b535a5153 --- /dev/null +++ b/app/src/main/res/layout/view_fireproof_website_description.xml @@ -0,0 +1,35 @@ + + + + + + \ 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/layout/view_fireproof_website_entry.xml b/app/src/main/res/layout/view_fireproof_website_entry.xml new file mode 100644 index 000000000000..9275e862f5a3 --- /dev/null +++ b/app/src/main/res/layout/view_fireproof_website_entry.xml @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + \ 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 e900106ba74c..5e603d90e193 100644 --- a/app/src/main/res/values/attrs.xml +++ b/app/src/main/res/values/attrs.xml @@ -37,6 +37,7 @@ + @@ -64,6 +65,7 @@ + diff --git a/app/src/main/res/values/string-untranslated.xml b/app/src/main/res/values/string-untranslated.xml index bb147d602d09..e2171ac54d50 100644 --- a/app/src/main/res/values/string-untranslated.xml +++ b/app/src/main/res/values/string-untranslated.xml @@ -59,4 +59,14 @@ Privacy Protection Whitelist + + 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>? + 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 diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index e55ffc510dd6..82ec8a53cce2 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -57,7 +57,9 @@ @color/almostBlack @color/white @color/white + @color/almostBlack @color/white + @color/white @color/grayishTwo @color/white @color/midGray @@ -131,7 +133,9 @@ @color/white @color/almostBlack @color/grayishBrown + @color/whiteSix @color/almostBlack + @color/almostBlack @color/warmerGray @color/grayishBrown @color/pinkish_grey_two