diff --git a/app/schemas/com.duckduckgo.app.global.db.AppDatabase/20.json b/app/schemas/com.duckduckgo.app.global.db.AppDatabase/20.json new file mode 100644 index 000000000000..83a9210c540d --- /dev/null +++ b/app/schemas/com.duckduckgo.app.global.db.AppDatabase/20.json @@ -0,0 +1,700 @@ +{ + "formatVersion": 1, + "database": { + "version": 20, + "identityHash": "4ebd15639b97a4edeb5417e725b91285", + "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": [] + } + ], + "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, '4ebd15639b97a4edeb5417e725b91285')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/duckduckgo/app/bookmarks/ui/BookmarksViewModelTest.kt b/app/src/androidTest/java/com/duckduckgo/app/bookmarks/ui/BookmarksViewModelTest.kt index ebbca94a3ee4..c5c7b7a568c2 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/bookmarks/ui/BookmarksViewModelTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/bookmarks/ui/BookmarksViewModelTest.kt @@ -25,6 +25,7 @@ import com.duckduckgo.app.bookmarks.db.BookmarksDao import com.nhaarman.mockitokotlin2.mock import com.nhaarman.mockitokotlin2.verify import com.nhaarman.mockitokotlin2.whenever +import org.junit.After import org.junit.Assert.assertNotNull import org.junit.Assert.assertTrue import org.junit.Before @@ -62,6 +63,12 @@ class BookmarksViewModelTest { whenever(bookmarksDao.bookmarks()).thenReturn(liveData) } + @After + fun after() { + testee.viewState.removeObserver(viewStateObserver) + testee.command.removeObserver(commandObserver) + } + @Test fun whenBookmarkDeletedThenDaoUpdated() { testee.delete(bookmark) @@ -94,5 +101,4 @@ class BookmarksViewModelTest { assertNotNull(captor.value) assertNotNull(captor.value.bookmarks) } - } \ No newline at end of file diff --git a/app/src/androidTest/java/com/duckduckgo/app/brokensite/BrokenSiteDataTest.kt b/app/src/androidTest/java/com/duckduckgo/app/brokensite/BrokenSiteDataTest.kt new file mode 100644 index 000000000000..77e7ec14bb36 --- /dev/null +++ b/app/src/androidTest/java/com/duckduckgo/app/brokensite/BrokenSiteDataTest.kt @@ -0,0 +1,119 @@ +/* + * 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.brokensite + +import com.duckduckgo.app.global.model.Site +import com.duckduckgo.app.global.model.SiteMonitor +import com.duckduckgo.app.surrogates.SurrogateResponse +import com.duckduckgo.app.trackerdetection.model.TrackingEvent +import org.junit.Assert.* +import org.junit.Test + +class BrokenSiteDataTest { + + @Test + fun whenSiteIsNullThenDataIsEmptyAndUpgradedIsFalse() { + val data = BrokenSiteData.fromSite(null) + assertTrue(data.url.isEmpty()) + assertTrue(data.blockedTrackers.isEmpty()) + assertTrue(data.surrogates.isEmpty()) + assertFalse(data.upgradedToHttps) + } + + @Test + fun whenSiteExistsThenDataContainsUrl() { + val site = buildSite(SITE_URL) + val data = BrokenSiteData.fromSite(site) + assertEquals(SITE_URL, data.url) + } + + @Test + fun whenSiteUpgradedThenHttpsUpgradedIsTrue() { + val site = buildSite(SITE_URL, httpsUpgraded = true) + val data = BrokenSiteData.fromSite(site) + assertTrue(data.upgradedToHttps) + } + + @Test + fun whenSiteNotUpgradedThenHttpsUpgradedIsFalse() { + val site = buildSite(SITE_URL, httpsUpgraded = false) + val data = BrokenSiteData.fromSite(site) + assertFalse(data.upgradedToHttps) + } + + @Test + fun whenSiteHasNoTrackersThenBlockedTrackersIsEmpty() { + val site = buildSite(SITE_URL) + val data = BrokenSiteData.fromSite(site) + assertTrue(data.blockedTrackers.isEmpty()) + } + + @Test + fun whenSiteHasBlockedTrackersThenBlockedTrackersExist() { + val site = buildSite(SITE_URL) + val event = TrackingEvent("http://www.example.com", "http://www.tracker.com/tracker.js", emptyList(), null, false) + val anotherEvent = TrackingEvent("http://www.example.com/test", "http://www.anothertracker.com/tracker.js", emptyList(), null, false) + site.trackerDetected(event) + site.trackerDetected(anotherEvent) + assertEquals("www.tracker.com,www.anothertracker.com", BrokenSiteData.fromSite(site).blockedTrackers) + } + + @Test + fun whenSiteHasSameHostBlockedTrackersThenOnlyUniqueTrackersIncludedInData() { + val site = buildSite(SITE_URL) + val event = TrackingEvent("http://www.example.com", "http://www.tracker.com/tracker.js", emptyList(), null, false) + val anotherEvent = TrackingEvent("http://www.example.com/test", "http://www.tracker.com/tracker2.js", emptyList(), null, false) + site.trackerDetected(event) + site.trackerDetected(anotherEvent) + assertEquals("www.tracker.com", BrokenSiteData.fromSite(site).blockedTrackers) + } + + @Test + fun whenSiteHasNoSurrogatesThenSurrogatesIsEmpty() { + val site = buildSite(SITE_URL) + val data = BrokenSiteData.fromSite(site) + assertTrue(data.surrogates.isEmpty()) + } + + @Test + fun whenSiteHasSurrogatesThenSurrogatesExist() { + val surrogate = SurrogateResponse(true, "surrogate.com/test.js", "", "") + val anotherSurrogate = SurrogateResponse(true, "anothersurrogate.com/test.js", "", "") + val site = buildSite(SITE_URL) + site.surrogateDetected(surrogate) + site.surrogateDetected(anotherSurrogate) + assertEquals("surrogate.com,anothersurrogate.com", BrokenSiteData.fromSite(site).surrogates) + } + + @Test + fun whenSiteHasSameHostSurrogatesThenOnlyUniqueSurrogateIncludedInData() { + val surrogate = SurrogateResponse(true, "surrogate.com/test.js", "", "") + val anotherSurrogate = SurrogateResponse(true, "surrogate.com/test2.js", "", "") + val site = buildSite(SITE_URL) + site.surrogateDetected(surrogate) + site.surrogateDetected(anotherSurrogate) + assertEquals("surrogate.com", BrokenSiteData.fromSite(site).surrogates) + } + + private fun buildSite(url: String, httpsUpgraded: Boolean = false): Site { + return SiteMonitor(url, "", upgradedHttps = httpsUpgraded) + } + + companion object { + private const val SITE_URL = "foo.com" + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt index e35704dc2e56..48760f43efdd 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt @@ -51,15 +51,15 @@ import com.duckduckgo.app.cta.model.CtaId import com.duckduckgo.app.cta.model.DismissedCta import com.duckduckgo.app.cta.ui.* import com.duckduckgo.app.global.db.AppDatabase -import com.duckduckgo.app.cta.ui.HomeTopPanelCta import com.duckduckgo.app.global.install.AppInstallStore import com.duckduckgo.app.global.model.SiteFactory import com.duckduckgo.app.onboarding.store.OnboardingStore import com.duckduckgo.app.onboarding.store.UserStageStore import com.duckduckgo.app.privacy.db.NetworkLeaderboardDao +import com.duckduckgo.app.privacy.db.UserWhitelistDao import com.duckduckgo.app.privacy.model.PrivacyPractices import com.duckduckgo.app.privacy.model.TestEntity -import com.duckduckgo.app.privacy.store.PrivacySettingsStore +import com.duckduckgo.app.privacy.model.UserWhitelistedDomain import com.duckduckgo.app.runBlocking import com.duckduckgo.app.settings.db.SettingsDataStore import com.duckduckgo.app.statistics.VariantManager @@ -172,10 +172,10 @@ class BrowserTabViewModelTest { private lateinit var mockWidgetCapabilities: WidgetCapabilities @Mock - private lateinit var mockPrivacySettingsStore: PrivacySettingsStore + private lateinit var mockUserStageStore: UserStageStore @Mock - private lateinit var mockUserStageStore: UserStageStore + private lateinit var mockUserWhitelistDao: UserWhitelistDao private lateinit var mockAutoCompleteApi: AutoCompleteApi @@ -206,10 +206,10 @@ class BrowserTabViewModelTest { mockSurveyDao, mockWidgetCapabilities, mockDismissedCtaDao, + mockUserWhitelistDao, mockVariantManager, mockSettingsStore, mockOnboardingStore, - mockPrivacySettingsStore, mockUserStageStore, coroutineRule.testDispatcherProvider ) @@ -222,7 +222,7 @@ class BrowserTabViewModelTest { whenever(mockTabsRepository.retrieveSiteData(any())).thenReturn(MutableLiveData()) whenever(mockPrivacyPractices.privacyPracticesFor(any())).thenReturn(PrivacyPractices.UNKNOWN) whenever(mockAppInstallStore.installTimestamp).thenReturn(System.currentTimeMillis() - TimeUnit.DAYS.toMillis(1)) - whenever(mockPrivacySettingsStore.privacyOn).thenReturn(true) + whenever(mockUserWhitelistDao.contains(anyString())).thenReturn(false) testee = BrowserTabViewModel( statisticsUpdater = mockStatisticsUpdater, @@ -230,6 +230,7 @@ class BrowserTabViewModelTest { duckDuckGoUrlDetector = DuckDuckGoUrlDetector(), siteFactory = siteFactory, tabRepository = mockTabsRepository, + userWhitelistDao = mockUserWhitelistDao, networkLeaderboardDao = mockNetworkLeaderboardDao, autoComplete = mockAutoCompleteApi, appSettingsPreferencesStore = mockSettingsStore, @@ -1061,11 +1062,23 @@ class BrowserTabViewModelTest { } @Test - fun whenUserSelectsToShareLinkThenShareLinkCommandSent() { - loadUrl("foo.com") - testee.onShareSelected() - val command = captureCommands().value as Command.ShareLink - assertEquals("foo.com", command.url) + fun whenUserTogglesNonWhitelistedSiteThenSiteAddedToWhitelistAndPixelSentAndPageRefreshed() = coroutineRule.runBlocking { + whenever(mockUserWhitelistDao.contains("www.example.com")).thenReturn(false) + loadUrl("http://www.example.com/home.html") + testee.onWhitelistSelected() + verify(mockUserWhitelistDao).insert(UserWhitelistedDomain("www.example.com")) + verify(mockPixel).fire(Pixel.PixelName.BROWSER_MENU_WHITELIST_ADD) + verify(mockCommandObserver).onChanged(Command.Refresh) + } + + @Test + fun whenUserTogglesWhitelsitedSiteThenSiteRemovedFromWhitelistAndPixelSentAndPageRefreshed() = coroutineRule.runBlocking { + whenever(mockUserWhitelistDao.contains("www.example.com")).thenReturn(true) + loadUrl("http://www.example.com/home.html") + testee.onWhitelistSelected() + verify(mockUserWhitelistDao).delete(UserWhitelistedDomain("www.example.com")) + verify(mockPixel).fire(Pixel.PixelName.BROWSER_MENU_WHITELIST_REMOVE) + verify(mockCommandObserver).onChanged(Command.Refresh) } @Test @@ -1073,14 +1086,22 @@ class BrowserTabViewModelTest { loadUrl("foo.com", isBrowserShowing = true) testee.onBrokenSiteSelected() val command = captureCommands().value as Command.BrokenSiteFeedback - assertEquals("foo.com", command.url) + assertEquals("foo.com", command.data.url) } @Test fun whenNoSiteAndBrokenSiteSelectedThenBrokenSiteFeedbackCommandSentWithoutUrl() { testee.onBrokenSiteSelected() val command = captureCommands().value as Command.BrokenSiteFeedback - assertEquals("", command.url) + assertEquals("", command.data.url) + } + + @Test + fun whenUserSelectsToShareLinkThenShareLinkCommandSent() { + loadUrl("foo.com") + testee.onShareSelected() + val command = captureCommands().value as Command.ShareLink + assertEquals("foo.com", command.url) } @Test @@ -1576,111 +1597,6 @@ class BrowserTabViewModelTest { assertCommandIssued() } - @Test - fun whenOnBrokenSiteSelectedAndNoHttpsUpgradedThenReturnHttpsUpgradedFalse() { - testee.onBrokenSiteSelected() - - val command = captureCommands().lastValue - assertTrue(command is Command.BrokenSiteFeedback) - - val brokenSiteFeedback = command as Command.BrokenSiteFeedback - assertFalse(brokenSiteFeedback.httpsUpgraded) - } - - @Test - fun whenOnBrokenSiteSelectedAndNoTrackersThenReturnBlockedTrackersEmptyString() { - givenOneActiveTabSelected() - - testee.onBrokenSiteSelected() - - val command = captureCommands().lastValue - assertTrue(command is Command.BrokenSiteFeedback) - - val brokenSiteFeedback = command as Command.BrokenSiteFeedback - assertEquals("", brokenSiteFeedback.blockedTrackers) - } - - @Test - fun whenOnBrokenSiteSelectedAndTrackersBlockedThenReturnBlockedTrackers() { - givenOneActiveTabSelected() - val event = TrackingEvent("http://www.example.com", "http://www.tracker.com/tracker.js", emptyList(), null, false) - val anotherEvent = TrackingEvent("http://www.example.com/test", "http://www.anothertracker.com/tracker.js", emptyList(), null, false) - - testee.trackerDetected(event) - testee.trackerDetected(anotherEvent) - testee.onBrokenSiteSelected() - - val command = captureCommands().lastValue - assertTrue(command is Command.BrokenSiteFeedback) - - val brokenSiteFeedback = command as Command.BrokenSiteFeedback - assertEquals("www.tracker.com,www.anothertracker.com", brokenSiteFeedback.blockedTrackers) - } - - @Test - fun whenOnBrokenSiteSelectedAndSameHostTrackersBlockedThenDoNotReturnDuplicatedBlockedTrackers() { - givenOneActiveTabSelected() - val event = TrackingEvent("http://www.example.com", "http://www.tracker.com/tracker.js", emptyList(), null, false) - val anotherEvent = TrackingEvent("http://www.example.com/test", "http://www.tracker.com/tracker2.js", emptyList(), null, false) - - testee.trackerDetected(event) - testee.trackerDetected(anotherEvent) - testee.onBrokenSiteSelected() - - val command = captureCommands().lastValue - assertTrue(command is Command.BrokenSiteFeedback) - - val brokenSiteFeedback = command as Command.BrokenSiteFeedback - assertEquals("www.tracker.com", brokenSiteFeedback.blockedTrackers) - } - - @Test - fun whenOnBrokenSiteSelectedAndNoSurrogatesThenReturnSurrogatesEmptyString() { - givenOneActiveTabSelected() - - testee.onBrokenSiteSelected() - - val command = captureCommands().lastValue - assertTrue(command is Command.BrokenSiteFeedback) - - val brokenSiteFeedback = command as Command.BrokenSiteFeedback - assertEquals("", brokenSiteFeedback.surrogates) - } - - @Test - fun whenOnBrokenSiteSelectedAndSurrogatesThenReturnSurrogates() { - givenOneActiveTabSelected() - val surrogate = SurrogateResponse(true, "surrogate.com/test.js", "", "") - val anotherSurrogate = SurrogateResponse(true, "anothersurrogate.com/test.js", "", "") - - testee.surrogateDetected(surrogate) - testee.surrogateDetected(anotherSurrogate) - testee.onBrokenSiteSelected() - - val command = captureCommands().lastValue - assertTrue(command is Command.BrokenSiteFeedback) - - val brokenSiteFeedback = command as Command.BrokenSiteFeedback - assertEquals("surrogate.com,anothersurrogate.com", brokenSiteFeedback.surrogates) - } - - @Test - fun whenOnBrokenSiteSelectedAndSameHostSurrogatesThenDoNotReturnDuplicatedSurrogates() { - givenOneActiveTabSelected() - val surrogate = SurrogateResponse(true, "surrogate.com/test.js", "", "") - val anotherSurrogate = SurrogateResponse(true, "surrogate.com/test2.js", "", "") - - testee.surrogateDetected(surrogate) - testee.surrogateDetected(anotherSurrogate) - testee.onBrokenSiteSelected() - - val command = captureCommands().lastValue - assertTrue(command is Command.BrokenSiteFeedback) - - val brokenSiteFeedback = command as Command.BrokenSiteFeedback - assertEquals("surrogate.com", brokenSiteFeedback.surrogates) - } - private inline fun assertCommandIssued(instanceAssertions: T.() -> Unit = {}) { verify(mockCommandObserver, atLeastOnce()).onChanged(commandCaptor.capture()) val issuedCommand = commandCaptor.allValues.find { it is T } diff --git a/app/src/androidTest/java/com/duckduckgo/app/cta/ui/CtaViewModelTest.kt b/app/src/androidTest/java/com/duckduckgo/app/cta/ui/CtaViewModelTest.kt index eeb12ce4c6a7..155d7fd6c4af 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/cta/ui/CtaViewModelTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/cta/ui/CtaViewModelTest.kt @@ -31,11 +31,11 @@ import com.duckduckgo.app.global.model.Site import com.duckduckgo.app.onboarding.store.AppStage import com.duckduckgo.app.onboarding.store.OnboardingStore import com.duckduckgo.app.onboarding.store.UserStageStore +import com.duckduckgo.app.privacy.db.UserWhitelistDao import com.duckduckgo.app.privacy.model.HttpsStatus import com.duckduckgo.app.privacy.model.PrivacyGrade import com.duckduckgo.app.privacy.model.PrivacyPractices import com.duckduckgo.app.privacy.model.TestEntity -import com.duckduckgo.app.privacy.store.PrivacySettingsStore import com.duckduckgo.app.runBlocking import com.duckduckgo.app.settings.db.SettingsDataStore import com.duckduckgo.app.statistics.VariantManager @@ -101,7 +101,7 @@ class CtaViewModelTest { private lateinit var mockOnboardingStore: OnboardingStore @Mock - private lateinit var mockPrivacySettingsStore: PrivacySettingsStore + private lateinit var mockUserWhitelistDao: UserWhitelistDao @Mock private lateinit var mockUserStageStore: UserStageStore @@ -126,7 +126,7 @@ class CtaViewModelTest { .build() whenever(mockAppInstallStore.installTimestamp).thenReturn(System.currentTimeMillis() - TimeUnit.DAYS.toMillis(1)) - whenever(mockPrivacySettingsStore.privacyOn).thenReturn(true) + whenever(mockUserWhitelistDao.contains(any())).thenReturn(false) testee = CtaViewModel( mockAppInstallStore, @@ -134,10 +134,10 @@ class CtaViewModelTest { mockSurveyDao, mockWidgetCapabilities, mockDismissedCtaDao, + mockUserWhitelistDao, mockVariantManager, mockSettingsDataStore, mockOnboardingStore, - mockPrivacySettingsStore, mockUserStageStore, coroutineRule.testDispatcherProvider ) @@ -301,9 +301,9 @@ class CtaViewModelTest { } @Test - fun whenRefreshCtaWhileBrowsingAndPrivacyOffThenReturnNull() = coroutineRule.runBlocking { + fun whenRefreshCtaWhileBrowsingAndPrivacyOffForSiteThenReturnNull() = coroutineRule.runBlocking { givenDaxOnboardingActive() - whenever(mockPrivacySettingsStore.privacyOn).thenReturn(false) + whenever(mockUserWhitelistDao.contains(any())).thenReturn(true) val site = site(url = "http://www.facebook.com", entity = TestEntity("Facebook", "Facebook", 9.0)) val value = testee.refreshCta(coroutineRule.testDispatcher, isBrowserShowing = true, site = site) diff --git a/app/src/androidTest/java/com/duckduckgo/app/feedback/ui/BrokenSiteViewModelTest.kt b/app/src/androidTest/java/com/duckduckgo/app/feedback/ui/BrokenSiteViewModelTest.kt index d2c8cefac664..a05976d3d3c4 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/feedback/ui/BrokenSiteViewModelTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/feedback/ui/BrokenSiteViewModelTest.kt @@ -8,9 +8,7 @@ import com.duckduckgo.app.brokensite.BrokenSiteViewModel.Command import com.duckduckgo.app.brokensite.api.BrokenSiteSender import com.duckduckgo.app.brokensite.model.BrokenSite import com.duckduckgo.app.statistics.pixels.Pixel -import com.nhaarman.mockitokotlin2.any import com.nhaarman.mockitokotlin2.mock -import com.nhaarman.mockitokotlin2.never import com.nhaarman.mockitokotlin2.verify import org.junit.After import org.junit.Assert.assertEquals diff --git a/app/src/androidTest/java/com/duckduckgo/app/global/db/AppDatabaseTest.kt b/app/src/androidTest/java/com/duckduckgo/app/global/db/AppDatabaseTest.kt index 503bf10f2999..025b634dbc39 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 @@ -185,14 +185,14 @@ class AppDatabaseTest { } @Test - fun whenMigratingFromVersion17To18IfUserDidNotSawOnboardingThenMigrateToNew() = coroutineRule.runBlocking { + fun whenMigratingFromVersion17To18IfUserDidNotSeeOnboardingThenMigrateToNew() = coroutineRule.runBlocking { givenUserNeverSawOnboarding() createDatabaseAndMigrate(17, 18, migrationsProvider.MIGRATION_17_TO_18) assertEquals(AppStage.NEW, database().userStageDao().currentUserAppStage()?.appStage) } @Test - fun whenMigratingFromVersion17To18IfUserSawOnboardingThenMigrateToEstablished() = coroutineRule.runBlocking { + fun whenMigratingFromVersion17To18IfUserSeeOnboardingThenMigrateToEstablished() = coroutineRule.runBlocking { givenUserSawOnboarding() createDatabaseAndMigrate(17, 18, migrationsProvider.MIGRATION_17_TO_18) assertEquals(AppStage.ESTABLISHED, database().userStageDao().currentUserAppStage()?.appStage) @@ -205,6 +205,12 @@ class AppDatabaseTest { assertEquals(0, database().uncaughtExceptionDao().count()) } + @Test + fun whenMigratingFromVersion19To20ThenValidationSucceeds() { + createDatabaseAndMigrate(19, 20, migrationsProvider.MIGRATION_19_TO_20) + assertEquals(0, database().uncaughtExceptionDao().count()) + } + private fun createDatabase(version: Int) { testHelper.createDatabase(TEST_DB_NAME, version).close() } diff --git a/app/src/androidTest/java/com/duckduckgo/app/httpsupgrade/HttpsUpgraderTest.kt b/app/src/androidTest/java/com/duckduckgo/app/httpsupgrade/HttpsUpgraderTest.kt index fbd0345db7a3..8665ae0d0ff9 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/httpsupgrade/HttpsUpgraderTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/httpsupgrade/HttpsUpgraderTest.kt @@ -20,6 +20,7 @@ import android.net.Uri import com.duckduckgo.app.httpsupgrade.api.HttpsBloomFilterFactory import com.duckduckgo.app.httpsupgrade.api.HttpsUpgradeService import com.duckduckgo.app.httpsupgrade.db.HttpsWhitelistDao +import com.duckduckgo.app.privacy.db.UserWhitelistDao import com.duckduckgo.app.statistics.pixels.Pixel import com.nhaarman.mockitokotlin2.any import com.nhaarman.mockitokotlin2.mock @@ -38,6 +39,7 @@ class HttpsUpgraderTest { private var mockHttpsBloomFilterFactory: HttpsBloomFilterFactory = mock() private var mockWhitelistDao: HttpsWhitelistDao = mock() + private var mockUserWhitelistDao: UserWhitelistDao = mock() private var mockUpgradeService: HttpsUpgradeService = mock() private var mockServiceCall: Call> = mock() @@ -47,7 +49,7 @@ class HttpsUpgraderTest { @Before fun before() { whenever(mockHttpsBloomFilterFactory.create()).thenReturn(bloomFilter) - testee = HttpsUpgraderImpl(mockWhitelistDao, mockHttpsBloomFilterFactory, mockUpgradeService, mockPixel) + testee = HttpsUpgraderImpl(mockWhitelistDao, mockUserWhitelistDao, mockHttpsBloomFilterFactory, mockUpgradeService, mockPixel) testee.reloadData() } @@ -68,6 +70,14 @@ class HttpsUpgraderTest { assertFalse(testee.shouldUpgrade(Uri.parse("http://www.local.url"))) } + @Test + fun whenHttpDomainIsUserWhitelistedThenShouldNotUpgradeAndNoLookupPixelIsSet() { + whenever(mockUserWhitelistDao.contains("www.local.url")).thenReturn(true) + bloomFilter.add("www.local.url") + assertFalse(testee.shouldUpgrade(Uri.parse("http://www.local.url"))) + mockPixel.fire(Pixel.PixelName.HTTPS_NO_LOOKUP) + } + @Test fun whenHttpUriIsInLocalListAndInWhitelistThenShouldNotUpgrade() { bloomFilter.add("www.local.url") diff --git a/app/src/androidTest/java/com/duckduckgo/app/privacy/db/UserWhitelistDaoTest.kt b/app/src/androidTest/java/com/duckduckgo/app/privacy/db/UserWhitelistDaoTest.kt new file mode 100644 index 000000000000..ffb3e1adb819 --- /dev/null +++ b/app/src/androidTest/java/com/duckduckgo/app/privacy/db/UserWhitelistDaoTest.kt @@ -0,0 +1,90 @@ +/* + * 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.privacy.db + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.room.Room +import androidx.test.platform.app.InstrumentationRegistry +import com.duckduckgo.app.CoroutineTestRule +import com.duckduckgo.app.blockingObserve +import com.duckduckgo.app.global.db.AppDatabase +import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +class UserWhitelistDaoTest { + + @get:Rule + @Suppress("unused") + var instantTaskExecutorRule = InstantTaskExecutorRule() + + @ExperimentalCoroutinesApi + @get:Rule + var coroutinesTestRule = CoroutineTestRule() + + private lateinit var db: AppDatabase + private lateinit var dao: UserWhitelistDao + + @Before + fun before() { + db = Room.inMemoryDatabaseBuilder(InstrumentationRegistry.getInstrumentation().targetContext, AppDatabase::class.java) + .allowMainThreadQueries() + .build() + dao = db.userWhitelistDao() + } + + @After + fun after() { + db.close() + } + + @Test + fun whenInitializedThenListIsEmpty() { + assertTrue(dao.all().blockingObserve()!!.isEmpty()) + } + + @Test + fun whenElementAddedThenListSizeIsOne() { + dao.insert(DOMAIN) + assertEquals(1, dao.all().blockingObserve()!!.size) + } + + @Test + fun whenElementAddedThenContainsIsTrue() { + dao.insert(DOMAIN) + assertTrue(dao.contains(DOMAIN)) + } + + @Test + fun wheElementDeletedThenContainsIsFalse() { + dao.insert(DOMAIN) + dao.delete(DOMAIN) + assertFalse(dao.contains(DOMAIN)) + } + + @Test + fun whenElementDoesNotExistThenContainsIsFalse() { + assertFalse(dao.contains(DOMAIN)) + } + + companion object { + const val DOMAIN = "www.example.com" + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/duckduckgo/app/privacy/ui/PrivacyDashboardViewModelTest.kt b/app/src/androidTest/java/com/duckduckgo/app/privacy/ui/PrivacyDashboardViewModelTest.kt index 18c3e3aca2f4..309010324c53 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/privacy/ui/PrivacyDashboardViewModelTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/privacy/ui/PrivacyDashboardViewModelTest.kt @@ -17,22 +17,26 @@ package com.duckduckgo.app.privacy.ui import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.core.net.toUri import androidx.lifecycle.LiveData import androidx.lifecycle.Observer +import com.duckduckgo.app.CoroutineTestRule import com.duckduckgo.app.global.model.Site import com.duckduckgo.app.privacy.db.NetworkLeaderboardDao import com.duckduckgo.app.privacy.db.NetworkLeaderboardEntry +import com.duckduckgo.app.privacy.db.UserWhitelistDao import com.duckduckgo.app.privacy.model.HttpsStatus import com.duckduckgo.app.privacy.model.PrivacyGrade import com.duckduckgo.app.privacy.model.PrivacyPractices import com.duckduckgo.app.privacy.model.PrivacyPractices.Summary.GOOD import com.duckduckgo.app.privacy.model.PrivacyPractices.Summary.UNKNOWN -import com.duckduckgo.app.privacy.store.PrivacySettingsStore +import com.duckduckgo.app.privacy.ui.PrivacyDashboardViewModel.Command +import com.duckduckgo.app.privacy.ui.PrivacyDashboardViewModel.Command.LaunchManageWhitelist +import com.duckduckgo.app.privacy.ui.PrivacyDashboardViewModel.Command.LaunchReportBrokenSite import com.duckduckgo.app.statistics.pixels.Pixel -import com.duckduckgo.app.statistics.pixels.Pixel.PixelName.PRIVACY_DASHBOARD_OPENED -import com.nhaarman.mockitokotlin2.mock -import com.nhaarman.mockitokotlin2.verify -import com.nhaarman.mockitokotlin2.whenever +import com.duckduckgo.app.statistics.pixels.Pixel.PixelName.* +import com.nhaarman.mockitokotlin2.* +import kotlinx.coroutines.ExperimentalCoroutinesApi import org.junit.After import org.junit.Assert.* import org.junit.Before @@ -45,16 +49,24 @@ class PrivacyDashboardViewModelTest { @Suppress("unused") var instantTaskExecutorRule = InstantTaskExecutorRule() + @ExperimentalCoroutinesApi + @get:Rule + var coroutineRule = CoroutineTestRule() + private var viewStateObserver: Observer = mock() - private var settingStore: PrivacySettingsStore = mock() + private var mockUserWhitelistDao: UserWhitelistDao = mock() private var networkLeaderboardDao: NetworkLeaderboardDao = mock() private var networkLeaderboardLiveData: LiveData> = mock() private var sitesVisitedLiveData: LiveData = mock() private var mockPixel: Pixel = mock() + private var commandObserver: Observer = mock() + private var commandCaptor: KArgumentCaptor = argumentCaptor() + private val testee: PrivacyDashboardViewModel by lazy { - val model = PrivacyDashboardViewModel(settingStore, networkLeaderboardDao, mockPixel) + val model = PrivacyDashboardViewModel(mockUserWhitelistDao, networkLeaderboardDao, mockPixel, coroutineRule.testDispatcherProvider) model.viewState.observeForever(viewStateObserver) + model.command.observeForever(commandObserver) model } @@ -69,6 +81,7 @@ class PrivacyDashboardViewModelTest { @After fun after() { testee.viewState.removeObserver(viewStateObserver) + testee.command.removeObserver(commandObserver) testee.onCleared() } @@ -90,30 +103,28 @@ class PrivacyDashboardViewModelTest { } @Test - fun whenPrivacyInitiallyOnAndSwitchedOffThenShouldReloadIsTrue() { - whenever(settingStore.privacyOn) - .thenReturn(true) - .thenReturn(false) + fun whenSitePrivacySwitchedOffThenShouldReloadIsTrue() { + givenSiteWithPrivacyOn() + testee.onPrivacyToggled(false) assertTrue(testee.viewState.value!!.shouldReloadPage) } @Test - fun whenPrivacyInitiallyOnAndUnchangedThenShouldReloadIsFalse() { - whenever(settingStore.privacyOn).thenReturn(true) + fun whenSitePrivacyOffAndUnchangedThenShouldReloadIsFalse() { + givenSiteWithPrivacyOff() assertFalse(testee.viewState.value!!.shouldReloadPage) } @Test - fun whenPrivacyInitiallyOffAndSwitchedOnThenShouldReloadIsTrue() { - whenever(settingStore.privacyOn) - .thenReturn(false) - .thenReturn(true) + fun whenSitePrivacySwitchedOnThenShouldReloadIsTrue() { + givenSiteWithPrivacyOff() + testee.onPrivacyToggled(true) assertTrue(testee.viewState.value!!.shouldReloadPage) } @Test - fun whenPrivacyInitiallyOffAndUnchangedThenShouldReloadIsFalse() { - whenever(settingStore.privacyOn).thenReturn(false) + fun whenSitePrivacyOnAndUnchangedThenShouldReloadIsFalse() { + givenSiteWithPrivacyOn() assertFalse(testee.viewState.value!!.shouldReloadPage) } @@ -207,6 +218,31 @@ class PrivacyDashboardViewModelTest { assertFalse(viewState.shouldShowTrackerNetworkLeaderboard) } + @Test + fun whenManageWhitelistSelectedThenPixelIsFiredAndCommandIsManageWhitelist() { + testee.onManageWhitelistSelected() + verify(mockPixel).fire(PRIVACY_DASHBOARD_MANAGE_WHITELIST) + verify(commandObserver).onChanged(LaunchManageWhitelist) + } + + @Test + fun whenBrokenSiteSelectedThenPixelIsFiredAndCommandIsLaunchBrokenSite() { + testee.onReportBrokenSiteSelected() + verify(mockPixel).fire(PRIVACY_DASHBOARD_REPORT_BROKEN_SITE) + verify(commandObserver).onChanged(commandCaptor.capture()) + assertTrue(commandCaptor.lastValue is LaunchReportBrokenSite) + } + + private fun givenSiteWithPrivacyOn() { + whenever(mockUserWhitelistDao.contains(any())).thenReturn(false) + testee.onSiteChanged(site()) + } + + private fun givenSiteWithPrivacyOff() { + whenever(mockUserWhitelistDao.contains(any())).thenReturn(true) + testee.onSiteChanged(site()) + } + private fun site( https: HttpsStatus = HttpsStatus.SECURE, trackerCount: Int = 0, @@ -216,6 +252,7 @@ class PrivacyDashboardViewModelTest { improvedGrade: PrivacyGrade = PrivacyGrade.UNKNOWN ): Site { val site: Site = mock() + whenever(site.uri).thenReturn("https://example.com".toUri()) whenever(site.https).thenReturn(https) whenever(site.trackerCount).thenReturn(trackerCount) whenever(site.allTrackersBlocked).thenReturn(allTrackersBlocked) @@ -223,5 +260,4 @@ class PrivacyDashboardViewModelTest { whenever(site.calculateGrades()).thenReturn(Site.SiteGrades(grade, improvedGrade)) return site } - } \ No newline at end of file diff --git a/app/src/androidTest/java/com/duckduckgo/app/privacy/ui/ScorecardViewModelTest.kt b/app/src/androidTest/java/com/duckduckgo/app/privacy/ui/ScorecardViewModelTest.kt index 1cba4fdc7567..cb360b068284 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/privacy/ui/ScorecardViewModelTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/privacy/ui/ScorecardViewModelTest.kt @@ -18,19 +18,23 @@ package com.duckduckgo.app.privacy.ui import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.lifecycle.Observer +import com.duckduckgo.app.CoroutineTestRule import com.duckduckgo.app.global.model.Site +import com.duckduckgo.app.privacy.db.UserWhitelistDao import com.duckduckgo.app.privacy.model.HttpsStatus import com.duckduckgo.app.privacy.model.PrivacyGrade import com.duckduckgo.app.privacy.model.PrivacyPractices import com.duckduckgo.app.privacy.model.PrivacyPractices.Practices import com.duckduckgo.app.privacy.model.PrivacyPractices.Summary.GOOD import com.duckduckgo.app.privacy.model.TestEntity -import com.duckduckgo.app.privacy.store.PrivacySettingsStore import com.duckduckgo.app.trackerdetection.model.Entity +import com.nhaarman.mockitokotlin2.any import com.nhaarman.mockitokotlin2.mock import com.nhaarman.mockitokotlin2.whenever +import kotlinx.coroutines.ExperimentalCoroutinesApi import org.junit.After import org.junit.Assert.* +import org.junit.Before import org.junit.Rule import org.junit.Test @@ -40,15 +44,24 @@ class ScorecardViewModelTest { @Suppress("unused") var instantTaskExecutorRule = InstantTaskExecutorRule() + @ExperimentalCoroutinesApi + @get:Rule + var coroutineRule = CoroutineTestRule() + private var viewStateObserver: Observer = mock() - private var settingStore: PrivacySettingsStore = mock() + private var userWhitelistDao: UserWhitelistDao = mock() private val testee: ScorecardViewModel by lazy { - val model = ScorecardViewModel(settingStore) + val model = ScorecardViewModel(userWhitelistDao, coroutineRule.testDispatcherProvider) model.viewState.observeForever(viewStateObserver) model } + @Before + fun before() { + whenever(userWhitelistDao.contains(any())).thenReturn(true) + } + @After fun after() { testee.viewState.removeObserver(viewStateObserver) diff --git a/app/src/androidTest/java/com/duckduckgo/app/privacy/ui/WhitelistViewModelTest.kt b/app/src/androidTest/java/com/duckduckgo/app/privacy/ui/WhitelistViewModelTest.kt new file mode 100644 index 000000000000..e4e702f9a44b --- /dev/null +++ b/app/src/androidTest/java/com/duckduckgo/app/privacy/ui/WhitelistViewModelTest.kt @@ -0,0 +1,154 @@ +/* + * 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.privacy.ui + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.Observer +import com.duckduckgo.app.CoroutineTestRule +import com.duckduckgo.app.privacy.db.UserWhitelistDao +import com.duckduckgo.app.privacy.model.UserWhitelistedDomain +import com.duckduckgo.app.privacy.ui.WhitelistViewModel.Command +import com.duckduckgo.app.privacy.ui.WhitelistViewModel.Command.ShowAdd +import com.duckduckgo.app.privacy.ui.WhitelistViewModel.Command.ShowWhitelistFormatError +import com.duckduckgo.app.runBlocking +import com.nhaarman.mockitokotlin2.* +import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +class WhitelistViewModelTest { + + @ExperimentalCoroutinesApi + @get:Rule + var coroutineRule = CoroutineTestRule() + + @get:Rule + @Suppress("unused") + var instantTaskExecutorRule = InstantTaskExecutorRule() + + private val mockDao: UserWhitelistDao = mock() + private val liveData = MutableLiveData>() + + private val mockCommandObserver: Observer = mock() + private var commandCaptor: KArgumentCaptor = argumentCaptor() + + private val testee by lazy { WhitelistViewModel(mockDao, coroutineRule.testDispatcherProvider) } + + @Before + fun before() = coroutineRule.runBlocking { + liveData.value = emptyList() + whenever(mockDao.all()).thenReturn(liveData) + testee.command.observeForever(mockCommandObserver) + } + + @After + fun after() { + testee.command.removeObserver(mockCommandObserver) + } + + @Test + fun whenWhitelistUpdatedWithDataThenViewStateIsUpdatedAndWhitelistDisplayed() { + val list = listOf(UserWhitelistedDomain(DOMAIN), UserWhitelistedDomain(NEW_DOMAIN)) + liveData.postValue(list) + val viewState = testee.viewState.value!! + assertEquals(list, viewState.whitelist) + assertTrue(viewState.showWhitelist) + } + + @Test + fun whenWhitelistUpdatedWithEmptyListThenViewStateIsUpdatedAndWhitelistNotDisplayed() { + liveData.postValue(emptyList()) + val viewState = testee.viewState.value!! + assertTrue(viewState.whitelist.isEmpty()) + assertFalse(viewState.showWhitelist) + } + + @Test + fun whenAddRequestedThenAddShown() { + testee.onAddRequested() + verify(mockCommandObserver).onChanged(ShowAdd) + } + + @Test + fun whenValidEntryAddedThenDaoUpdated() { + val entry = UserWhitelistedDomain(NEW_DOMAIN) + testee.onEntryAdded(entry) + verify(mockDao).insert(entry) + } + + @Test + fun whenInvalidEntryAddedThenErrorShownAndDaoNotUpdated() { + val entry = UserWhitelistedDomain(INVALID_DOMAIN) + testee.onEntryAdded(entry) + verify(mockCommandObserver).onChanged(ShowWhitelistFormatError) + verify(mockDao, never()).insert(entry) + } + + @Test + fun whenEditRequestedThenEditShown() { + val entry = UserWhitelistedDomain(DOMAIN) + testee.onEditRequested(entry) + verify(mockCommandObserver).onChanged(commandCaptor.capture()) + val lastValue = commandCaptor.lastValue as Command.ShowEdit + assertEquals(entry, lastValue.entry) + } + + @Test + fun whenValidEditSubmittedThenDaoUpdated() { + val old = UserWhitelistedDomain(DOMAIN) + val new = UserWhitelistedDomain(NEW_DOMAIN) + testee.onEntryEdited(old, new) + verify(mockDao).delete(old) + verify(mockDao).insert(new) + } + + @Test + fun whenValidEditSubmittedThenErrorShownAndDaoNotUpdated() { + val old = UserWhitelistedDomain(DOMAIN) + val new = UserWhitelistedDomain(INVALID_DOMAIN) + testee.onEntryEdited(old, new) + verify(mockCommandObserver).onChanged(ShowWhitelistFormatError) + verify(mockDao, never()).delete(old) + verify(mockDao, never()).insert(new) + } + + @Test + fun whenDeleteRequestedThenDeletionConfirmed() { + val entry = UserWhitelistedDomain(DOMAIN) + testee.onDeleteRequested(entry) + verify(mockCommandObserver).onChanged(commandCaptor.capture()) + val lastValue = commandCaptor.lastValue as Command.ConfirmDelete + assertEquals(entry, lastValue.entry) + } + + @Test + fun whenDeletedThenDaoUpdated() { + val entry = UserWhitelistedDomain(DOMAIN) + testee.onEntryDeleted(entry) + verify(mockDao).delete(entry) + } + + companion object { + private const val DOMAIN = "example.com" + private const val NEW_DOMAIN = "new.example.com" + private const val INVALID_DOMAIN = "_" + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/duckduckgo/app/settings/SettingsViewModelTest.kt b/app/src/androidTest/java/com/duckduckgo/app/settings/SettingsViewModelTest.kt index 29fd8de57796..18aa1e551f8b 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/settings/SettingsViewModelTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/settings/SettingsViewModelTest.kt @@ -204,6 +204,13 @@ class SettingsViewModelTest { assertTrue(latestViewState().showDefaultBrowserSetting) } + @Test + fun whenWhitelistSelectedThenPixelIsSentAndWhitelistLaunched() { + testee.onManageWhitelistSelected() + verify(mockPixel).fire(Pixel.PixelName.SETTINGS_MANAGE_WHITELIST) + verify(commandObserver).onChanged(Command.LaunchWhitelist) + } + @Test fun whenVariantIsEmptyThenEmptyVariantIncludedInSettings() { testee.start() diff --git a/app/src/androidTest/java/com/duckduckgo/app/trackerdetection/TrackerDetectorClientTypeTest.kt b/app/src/androidTest/java/com/duckduckgo/app/trackerdetection/TrackerDetectorClientTypeTest.kt index b86525158867..7740917b5339 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/trackerdetection/TrackerDetectorClientTypeTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/trackerdetection/TrackerDetectorClientTypeTest.kt @@ -16,7 +16,7 @@ package com.duckduckgo.app.trackerdetection -import com.duckduckgo.app.privacy.store.PrivacySettingsStore +import com.duckduckgo.app.privacy.db.UserWhitelistDao import com.duckduckgo.app.trackerdetection.model.TrackingEvent import com.nhaarman.mockitokotlin2.any import com.nhaarman.mockitokotlin2.eq @@ -32,13 +32,13 @@ class TrackerDetectorClientTypeTest { private var mockEntityLookup: EntityLookup = mock() private var mockBlockingClient: Client = mock() private var mockWhitelistClient: Client = mock() - private var mockSettingStore: PrivacySettingsStore = mock() + private var mockUserWhitelistDao: UserWhitelistDao = mock() - private var testee = TrackerDetectorImpl(mockEntityLookup, mockSettingStore) + private var testee = TrackerDetectorImpl(mockEntityLookup, mockUserWhitelistDao) @Before fun before() { - whenever(mockSettingStore.privacyOn).thenReturn(true) + whenever(mockUserWhitelistDao.contains(any())).thenReturn(false) whenever(mockBlockingClient.matches(eq(Url.BLOCKED), any())).thenReturn(Client.Result(true)) whenever(mockBlockingClient.matches(eq(Url.BLOCKED_AND_WHITELISTED), any())).thenReturn(Client.Result(true)) diff --git a/app/src/androidTest/java/com/duckduckgo/app/trackerdetection/TrackerDetectorTest.kt b/app/src/androidTest/java/com/duckduckgo/app/trackerdetection/TrackerDetectorTest.kt index d0ed2bb02c85..2a2bbe1a1215 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/trackerdetection/TrackerDetectorTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/trackerdetection/TrackerDetectorTest.kt @@ -16,7 +16,7 @@ package com.duckduckgo.app.trackerdetection -import com.duckduckgo.app.privacy.store.PrivacySettingsStore +import com.duckduckgo.app.privacy.db.UserWhitelistDao import com.duckduckgo.app.trackerdetection.Client.ClientName import com.duckduckgo.app.trackerdetection.Client.ClientName.EASYLIST import com.duckduckgo.app.trackerdetection.Client.ClientName.EASYPRIVACY @@ -30,8 +30,8 @@ import org.mockito.ArgumentMatchers.anyString class TrackerDetectorTest { private val mockEntityLookup: EntityLookup = mock() - private val settingStore: PrivacySettingsStore = mock() - private val trackerDetector = TrackerDetectorImpl(mockEntityLookup, settingStore) + private val mockUserWhitelistDao: UserWhitelistDao = mock() + private val trackerDetector = TrackerDetectorImpl(mockEntityLookup, mockUserWhitelistDao) @Test fun whenThereAreNoClientsThenClientCountIsZero() { @@ -75,8 +75,8 @@ class TrackerDetectorTest { } @Test - fun whenPrivacyOnAndAllClientsMatchThenEvaluateReturnsBlockedTrackingEvent() { - whenever(settingStore.privacyOn).thenReturn(true) + fun whenSiteIsNotUserWhitelistedAndAllClientsMatchThenEvaluateReturnsBlockedTrackingEvent() { + whenever(mockUserWhitelistDao.contains("example.com")).thenReturn(false) trackerDetector.addClient(alwaysMatchingClient(CLIENT_A)) trackerDetector.addClient(alwaysMatchingClient(CLIENT_B)) val expected = TrackingEvent("http://example.com/index.com", "http://thirdparty.com/update.js", null, null, true) @@ -85,8 +85,8 @@ class TrackerDetectorTest { } @Test - fun whenPrivacyOffAndAllClientsMatchThenEvaluateReturnsUnblockedTrackingEvent() { - whenever(settingStore.privacyOn).thenReturn(false) + fun whenSiteIsUserWhitelistedAndAllClientsMatchThenEvaluateReturnsUnblockedTrackingEvent() { + whenever(mockUserWhitelistDao.contains("example.com")).thenReturn(true) trackerDetector.addClient(alwaysMatchingClient(CLIENT_A)) trackerDetector.addClient(alwaysMatchingClient(CLIENT_B)) val expected = TrackingEvent("http://example.com/index.com", "http://thirdparty.com/update.js", null, null, false) @@ -95,8 +95,8 @@ class TrackerDetectorTest { } @Test - fun whenPrivacyOnAndSomeClientsMatchThenEvaluateReturnsBlockedTrackingEvent() { - whenever(settingStore.privacyOn).thenReturn(true) + fun whenSiteIsNotUserWhitelistedAndSomeClientsMatchThenEvaluateReturnsBlockedTrackingEvent() { + whenever(mockUserWhitelistDao.contains("example.com")).thenReturn(false) trackerDetector.addClient(neverMatchingClient(CLIENT_A)) trackerDetector.addClient(alwaysMatchingClient(CLIENT_B)) val expected = TrackingEvent("http://example.com/index.com", "http://thirdparty.com/update.js", null, null, true) @@ -105,8 +105,8 @@ class TrackerDetectorTest { } @Test - fun whenPrivacyOffAndSomeClientsMatchThenEvaluateReturnsUnblockedTrackingEvent() { - whenever(settingStore.privacyOn).thenReturn(false) + fun whenSiteIsUserWhitelistedAndSomeClientsMatchThenEvaluateReturnsUnblockedTrackingEvent() { + whenever(mockUserWhitelistDao.contains("example.com")).thenReturn(true) trackerDetector.addClient(neverMatchingClient(CLIENT_A)) trackerDetector.addClient(alwaysMatchingClient(CLIENT_B)) val expected = TrackingEvent("http://example.com/index.com", "http://thirdparty.com/update.js", null, null, false) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index fa0432c82b70..7a3aa59f914b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -291,6 +291,10 @@ android:name="com.duckduckgo.app.icon.ui.ChangeIconActivity" android:label="@string/changeIconActivityTitle" android:parentActivityName="com.duckduckgo.app.settings.SettingsActivity" /> + + .setPositiveButton(R.string.dialogSave) { _, _ -> userAcceptedDialog(titleInput, urlInput) } diff --git a/app/src/main/java/com/duckduckgo/app/brokensite/BrokenSiteActivity.kt b/app/src/main/java/com/duckduckgo/app/brokensite/BrokenSiteActivity.kt index 60dbfa5c4639..dbbea2dd65ce 100644 --- a/app/src/main/java/com/duckduckgo/app/brokensite/BrokenSiteActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/brokensite/BrokenSiteActivity.kt @@ -114,12 +114,12 @@ class BrokenSiteActivity : DuckDuckGoActivity() { private const val UPGRADED_TO_HTTPS_EXTRA = "UPGRADED_TO_HTTPS_EXTRA" private const val SURROGATES_EXTRA = "SURROGATES_EXTRA" - fun intent(context: Context, url: String, blockedTrackers: String, surrogates: String, upgradedToHttps: Boolean): Intent { + fun intent(context: Context, data: BrokenSiteData): Intent { val intent = Intent(context, BrokenSiteActivity::class.java) - intent.putExtra(URL_EXTRA, url) - intent.putExtra(BLOCKED_TRACKERS_EXTRA, blockedTrackers) - intent.putExtra(SURROGATES_EXTRA, surrogates) - intent.putExtra(UPGRADED_TO_HTTPS_EXTRA, upgradedToHttps) + intent.putExtra(URL_EXTRA, data.url) + intent.putExtra(BLOCKED_TRACKERS_EXTRA, data.blockedTrackers) + intent.putExtra(SURROGATES_EXTRA, data.surrogates) + intent.putExtra(UPGRADED_TO_HTTPS_EXTRA, data.upgradedToHttps) return intent } } diff --git a/app/src/main/java/com/duckduckgo/app/brokensite/BrokenSiteData.kt b/app/src/main/java/com/duckduckgo/app/brokensite/BrokenSiteData.kt new file mode 100644 index 000000000000..d94af5b02c58 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/brokensite/BrokenSiteData.kt @@ -0,0 +1,40 @@ +/* + * 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.brokensite + +import android.net.Uri +import com.duckduckgo.app.global.baseHost +import com.duckduckgo.app.global.model.Site + +data class BrokenSiteData( + val url: String, + val blockedTrackers: String, + val surrogates: String, + val upgradedToHttps: Boolean +) { + + companion object { + fun fromSite(site: Site?): BrokenSiteData { + val events = site?.trackingEvents + val blockedTrackers = events?.map { Uri.parse(it.trackerUrl).host }.orEmpty().distinct().joinToString(",") + val upgradedHttps = site?.upgradedHttps ?: false + val surrogates = site?.surrogates?.map { Uri.parse(it.name).baseHost }.orEmpty().distinct().joinToString(",") + val url = site?.url.orEmpty() + return BrokenSiteData(url, blockedTrackers, surrogates, upgradedHttps) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt index f30c1a232129..cd979cf4f7e1 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt @@ -23,37 +23,20 @@ import android.annotation.SuppressLint import android.app.Activity.RESULT_OK import android.app.ActivityOptions import android.appwidget.AppWidgetManager -import android.content.ClipData -import android.content.ClipboardManager -import android.content.ComponentName -import android.content.Context -import android.content.Intent +import android.content.* import android.content.pm.PackageManager import android.content.res.Configuration import android.media.MediaScannerConnection import android.net.Uri -import android.os.Build -import android.os.Bundle -import android.os.Environment -import android.os.Handler -import android.os.Message +import android.os.* import android.text.Editable -import android.view.ContextMenu -import android.view.KeyEvent -import android.view.LayoutInflater -import android.view.MenuItem -import android.view.View +import android.view.* import android.view.View.* -import android.view.ViewGroup import android.view.inputmethod.EditorInfo -import android.webkit.ValueCallback -import android.webkit.WebChromeClient -import android.webkit.WebSettings -import android.webkit.WebView +import android.webkit.* import android.webkit.WebView.FindListener import android.webkit.WebView.HitTestResult import android.webkit.WebView.HitTestResult.* -import android.webkit.WebViewDatabase import android.widget.EditText import android.widget.TextView import androidx.annotation.AnyThread @@ -63,11 +46,7 @@ import androidx.constraintlayout.widget.ConstraintSet import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.content.ContextCompat import androidx.core.content.pm.ShortcutManagerCompat -import androidx.core.view.isEmpty -import androidx.core.view.isInvisible -import androidx.core.view.isNotEmpty -import androidx.core.view.isVisible -import androidx.core.view.postDelayed +import androidx.core.view.* import androidx.fragment.app.Fragment import androidx.fragment.app.transaction import androidx.lifecycle.Lifecycle @@ -79,6 +58,7 @@ import androidx.recyclerview.widget.LinearLayoutManager import com.duckduckgo.app.autocomplete.api.AutoComplete.AutoCompleteSuggestion import com.duckduckgo.app.bookmarks.ui.EditBookmarkDialogFragment import com.duckduckgo.app.brokensite.BrokenSiteActivity +import com.duckduckgo.app.brokensite.BrokenSiteData import com.duckduckgo.app.browser.BrowserTabViewModel.* import com.duckduckgo.app.browser.autocomplete.BrowserAutoCompleteSuggestionsAdapter import com.duckduckgo.app.browser.downloader.FileDownloadNotificationManager @@ -97,32 +77,13 @@ import com.duckduckgo.app.browser.tabpreview.WebViewPreviewGenerator 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.Cta -import com.duckduckgo.app.cta.ui.CtaViewModel -import com.duckduckgo.app.cta.ui.DaxBubbleCta -import com.duckduckgo.app.cta.ui.DaxDialogCta -import com.duckduckgo.app.cta.ui.HomePanelCta -import com.duckduckgo.app.cta.ui.HomeTopPanelCta +import com.duckduckgo.app.cta.ui.* import com.duckduckgo.app.global.ViewModelFactory import com.duckduckgo.app.global.device.DeviceInfo import com.duckduckgo.app.global.model.orderedTrackingEntities -import com.duckduckgo.app.global.view.DaxDialog -import com.duckduckgo.app.global.view.DaxDialogListener -import com.duckduckgo.app.global.view.NonDismissibleBehavior -import com.duckduckgo.app.global.view.TextChangedWatcher -import com.duckduckgo.app.global.view.gone -import com.duckduckgo.app.global.view.hide -import com.duckduckgo.app.global.view.hideKeyboard -import com.duckduckgo.app.global.view.isDifferent -import com.duckduckgo.app.global.view.isImmersiveModeEnabled -import com.duckduckgo.app.global.view.renderIfChanged -import com.duckduckgo.app.global.view.show -import com.duckduckgo.app.global.view.showKeyboard -import com.duckduckgo.app.global.view.toPx -import com.duckduckgo.app.global.view.toggleFullScreen +import com.duckduckgo.app.global.view.* import com.duckduckgo.app.privacy.model.PrivacyGrade import com.duckduckgo.app.privacy.renderer.icon -import com.duckduckgo.app.privacy.store.PrivacySettingsStore import com.duckduckgo.app.statistics.VariantManager import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.survey.model.Survey @@ -134,52 +95,24 @@ import com.duckduckgo.app.widget.ui.AddWidgetInstructionsActivity import com.duckduckgo.widget.SearchWidgetLight import com.google.android.material.snackbar.Snackbar import dagger.android.support.AndroidSupportInjection -import kotlinx.android.synthetic.main.fragment_browser_tab.autoCompleteSuggestionsList -import kotlinx.android.synthetic.main.fragment_browser_tab.bottomNavigationBar -import kotlinx.android.synthetic.main.fragment_browser_tab.browserLayout -import kotlinx.android.synthetic.main.fragment_browser_tab.focusDummy -import kotlinx.android.synthetic.main.fragment_browser_tab.rootView -import kotlinx.android.synthetic.main.fragment_browser_tab.webViewContainer -import kotlinx.android.synthetic.main.fragment_browser_tab.webViewFullScreenContainer -import kotlinx.android.synthetic.main.include_add_widget_instruction_buttons.view.closeButton -import kotlinx.android.synthetic.main.include_cta_buttons.view.ctaDismissButton -import kotlinx.android.synthetic.main.include_cta_buttons.view.ctaOkButton -import kotlinx.android.synthetic.main.include_dax_dialog_cta.daxCtaContainer -import kotlinx.android.synthetic.main.include_dax_dialog_cta.dialogTextCta -import kotlinx.android.synthetic.main.include_find_in_page.closeFindInPagePanel -import kotlinx.android.synthetic.main.include_find_in_page.findInPageContainer -import kotlinx.android.synthetic.main.include_find_in_page.findInPageInput -import kotlinx.android.synthetic.main.include_find_in_page.findInPageMatches -import kotlinx.android.synthetic.main.include_find_in_page.nextSearchTermButton -import kotlinx.android.synthetic.main.include_find_in_page.previousSearchTermButton -import kotlinx.android.synthetic.main.include_new_browser_tab.ctaContainer -import kotlinx.android.synthetic.main.include_new_browser_tab.ctaTopContainer -import kotlinx.android.synthetic.main.include_new_browser_tab.ddgLogo -import kotlinx.android.synthetic.main.include_new_browser_tab.newTabLayout -import kotlinx.android.synthetic.main.include_omnibar_toolbar.appBarLayout -import kotlinx.android.synthetic.main.include_omnibar_toolbar.browserMenu -import kotlinx.android.synthetic.main.include_omnibar_toolbar.clearTextButton -import kotlinx.android.synthetic.main.include_omnibar_toolbar.networksContainer +import kotlinx.android.synthetic.main.fragment_browser_tab.* +import kotlinx.android.synthetic.main.include_add_widget_instruction_buttons.view.* +import kotlinx.android.synthetic.main.include_cta_buttons.view.* +import kotlinx.android.synthetic.main.include_dax_dialog_cta.* +import kotlinx.android.synthetic.main.include_find_in_page.* +import kotlinx.android.synthetic.main.include_new_browser_tab.* +import kotlinx.android.synthetic.main.include_omnibar_toolbar.* import kotlinx.android.synthetic.main.include_omnibar_toolbar.omnibarTextInput -import kotlinx.android.synthetic.main.include_omnibar_toolbar.pageLoadingIndicator -import kotlinx.android.synthetic.main.include_omnibar_toolbar.privacyGradeButton -import kotlinx.android.synthetic.main.include_omnibar_toolbar.searchIcon -import kotlinx.android.synthetic.main.include_omnibar_toolbar.toolbar -import kotlinx.android.synthetic.main.include_omnibar_toolbar.toolbarContainer import kotlinx.android.synthetic.main.include_omnibar_toolbar.view.browserMenu import kotlinx.android.synthetic.main.include_omnibar_toolbar.view.fireIconMenu import kotlinx.android.synthetic.main.include_omnibar_toolbar.view.privacyGradeButton import kotlinx.android.synthetic.main.include_omnibar_toolbar.view.tabsMenu -import kotlinx.android.synthetic.main.layout_browser_bottom_navigation_bar.bottomBarBookmarksItemOne -import kotlinx.android.synthetic.main.layout_browser_bottom_navigation_bar.bottomBarFireItem -import kotlinx.android.synthetic.main.layout_browser_bottom_navigation_bar.bottomBarOverflowItem -import kotlinx.android.synthetic.main.layout_browser_bottom_navigation_bar.bottomBarSearchItem -import kotlinx.android.synthetic.main.layout_browser_bottom_navigation_bar.bottomBarTabsItem -import kotlinx.android.synthetic.main.popup_window_browser_bottom_tab_menu.view.sharePopupMenuItem +import kotlinx.android.synthetic.main.layout_browser_bottom_navigation_bar.* +import kotlinx.android.synthetic.main.popup_window_browser_bottom_tab_menu.view.* +import kotlinx.android.synthetic.main.popup_window_browser_menu.view.* import kotlinx.android.synthetic.main.popup_window_browser_menu.view.addBookmarksPopupMenuItem import kotlinx.android.synthetic.main.popup_window_browser_menu.view.addToHome import kotlinx.android.synthetic.main.popup_window_browser_menu.view.backPopupMenuItem -import kotlinx.android.synthetic.main.popup_window_browser_menu.view.bookmarksPopupMenuItem import kotlinx.android.synthetic.main.popup_window_browser_menu.view.brokenSitePopupMenuItem import kotlinx.android.synthetic.main.popup_window_browser_menu.view.findInPageMenuItem import kotlinx.android.synthetic.main.popup_window_browser_menu.view.forwardPopupMenuItem @@ -187,20 +120,13 @@ import kotlinx.android.synthetic.main.popup_window_browser_menu.view.newTabPopup import kotlinx.android.synthetic.main.popup_window_browser_menu.view.refreshPopupMenuItem import kotlinx.android.synthetic.main.popup_window_browser_menu.view.requestDesktopSiteCheckMenuItem import kotlinx.android.synthetic.main.popup_window_browser_menu.view.settingsPopupMenuItem -import kotlinx.android.synthetic.main.popup_window_browser_menu.view.sharePageMenuItem -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext +import kotlinx.android.synthetic.main.popup_window_browser_menu.view.whitelistPopupMenuItem +import kotlinx.coroutines.* import org.jetbrains.anko.longToast import org.jetbrains.anko.share import timber.log.Timber import java.io.File -import java.util.Locale +import java.util.* import javax.inject.Inject import kotlin.concurrent.thread import kotlin.coroutines.CoroutineContext @@ -260,9 +186,6 @@ class BrowserTabFragment : Fragment(), FindListener, CoroutineScope, DaxDialogLi @Inject lateinit var variantManager: VariantManager - @Inject - lateinit var privacySettingsStore: PrivacySettingsStore - val tabId get() = requireArguments()[TAB_ID_ARG] as String lateinit var userAgentProvider: UserAgentProvider @@ -600,7 +523,7 @@ class BrowserTabFragment : Fragment(), FindListener, CoroutineScope, DaxDialogLi hideKeyboard() } is Command.BrokenSiteFeedback -> { - launchBrokenSiteFeedback(it.url, it.blockedTrackers, it.surrogates, it.httpsUpgraded) + launchBrokenSiteFeedback(it.data) } is Command.ShowFullScreen -> { webViewFullScreenContainer.addView( @@ -641,10 +564,10 @@ class BrowserTabFragment : Fragment(), FindListener, CoroutineScope, DaxDialogLi } } - private fun launchBrokenSiteFeedback(url: String, blockedTrackers: String, surrogates: String, upgradedHttps: Boolean) { + private fun launchBrokenSiteFeedback(data: BrokenSiteData) { context?.let { val options = ActivityOptions.makeSceneTransitionAnimation(browserActivity).toBundle() - startActivity(BrokenSiteActivity.intent(it, url, blockedTrackers, surrogates, upgradedHttps), options) + startActivity(BrokenSiteActivity.intent(it, data), options) } } @@ -1372,6 +1295,7 @@ class BrowserTabFragment : Fragment(), FindListener, CoroutineScope, DaxDialogLi } onMenuItemClicked(view.addBookmarksPopupMenuItem) { launch { viewModel.onBookmarkAddRequested() } } onMenuItemClicked(view.findInPageMenuItem) { viewModel.onFindInPageSelected() } + onMenuItemClicked(view.whitelistPopupMenuItem) { viewModel.onWhitelistSelected() } onMenuItemClicked(view.brokenSitePopupMenuItem) { viewModel.onBrokenSiteSelected() } onMenuItemClicked(view.settingsPopupMenuItem) { browserActivity?.launchSettings() } onMenuItemClicked(view.requestDesktopSiteCheckMenuItem) { viewModel.onDesktopSiteModeToggled(view.requestDesktopSiteCheckMenuItem.isChecked) } @@ -1406,6 +1330,7 @@ class BrowserTabFragment : Fragment(), FindListener, CoroutineScope, DaxDialogLi } onMenuItemClicked(view.addBookmarksPopupMenuItem) { launch { viewModel.onBookmarkAddRequested() } } onMenuItemClicked(view.findInPageMenuItem) { viewModel.onFindInPageSelected() } + onMenuItemClicked(view.whitelistPopupMenuItem) { viewModel.onWhitelistSelected() } onMenuItemClicked(view.brokenSitePopupMenuItem) { viewModel.onBrokenSiteSelected() } onMenuItemClicked(view.settingsPopupMenuItem) { browserActivity?.launchSettings() } onMenuItemClicked(view.requestDesktopSiteCheckMenuItem) { viewModel.onDesktopSiteModeToggled(view.requestDesktopSiteCheckMenuItem.isChecked) } @@ -1601,8 +1526,7 @@ class BrowserTabFragment : Fragment(), FindListener, CoroutineScope, DaxDialogLi smoothProgressAnimator.onNewProgress(viewState.progress) { if (!viewState.isLoading) hide() } } - if (privacySettingsStore.privacyOn) { - + if (viewState.privacyOn) { if (lastSeenOmnibarViewState?.isEditing == true) { cancelAllAnimations() } @@ -1705,6 +1629,8 @@ class BrowserTabFragment : Fragment(), FindListener, CoroutineScope, DaxDialogLi newTabPopupMenuItem.isEnabled = browserShowing addBookmarksPopupMenuItem?.isEnabled = viewState.canAddBookmarks sharePageMenuItem?.isEnabled = viewState.canSharePage + whitelistPopupMenuItem?.isEnabled = viewState.canWhitelist + whitelistPopupMenuItem?.text = getText(if (viewState.isWhitelisted) R.string.whitelistRemove else R.string.whitelistAdd) brokenSitePopupMenuItem?.isEnabled = viewState.canReportSite requestDesktopSiteCheckMenuItem?.isEnabled = viewState.canChangeBrowsingMode diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt index e4e0735dd60c..9eb567382a69 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt @@ -41,6 +41,7 @@ import com.duckduckgo.app.autocomplete.api.AutoComplete.AutoCompleteSuggestion.A import com.duckduckgo.app.bookmarks.db.BookmarkEntity import com.duckduckgo.app.bookmarks.db.BookmarksDao import com.duckduckgo.app.bookmarks.ui.EditBookmarkDialogFragment.EditBookmarkListener +import com.duckduckgo.app.brokensite.BrokenSiteData import com.duckduckgo.app.browser.BrowserTabViewModel.Command.* import com.duckduckgo.app.browser.BrowserTabViewModel.GlobalLayoutViewState.Browser import com.duckduckgo.app.browser.BrowserTabViewModel.GlobalLayoutViewState.Invalidated @@ -59,8 +60,10 @@ import com.duckduckgo.app.cta.ui.* import com.duckduckgo.app.global.* import com.duckduckgo.app.global.model.Site import com.duckduckgo.app.global.model.SiteFactory +import com.duckduckgo.app.global.model.domain import com.duckduckgo.app.global.model.domainMatchesUrl import com.duckduckgo.app.privacy.db.NetworkLeaderboardDao +import com.duckduckgo.app.privacy.db.UserWhitelistDao import com.duckduckgo.app.privacy.model.PrivacyGrade import com.duckduckgo.app.settings.db.SettingsDataStore import com.duckduckgo.app.statistics.api.StatisticsUpdater @@ -77,7 +80,7 @@ import com.jakewharton.rxrelay2.PublishRelay import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.disposables.Disposable import io.reactivex.schedulers.Schedulers -import kotlinx.android.synthetic.main.include_omnibar_toolbar.* +import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -90,6 +93,7 @@ class BrowserTabViewModel( private val duckDuckGoUrlDetector: DuckDuckGoUrlDetector, private val siteFactory: SiteFactory, private val tabRepository: TabRepository, + private val userWhitelistDao: UserWhitelistDao, private val networkLeaderboardDao: NetworkLeaderboardDao, private val bookmarksDao: BookmarksDao, private val autoComplete: AutoComplete, @@ -131,6 +135,8 @@ class BrowserTabViewModel( val canAddBookmarks: Boolean = false, val canGoBack: Boolean = false, val canGoForward: Boolean = false, + val canWhitelist: Boolean = false, + val isWhitelisted: Boolean = false, val canReportSite: Boolean = false, val addToHomeEnabled: Boolean = false, val addToHomeVisible: Boolean = false @@ -144,6 +150,7 @@ class BrowserTabViewModel( data class LoadingViewState( val isLoading: Boolean = false, + val privacyOn: Boolean = true, val progress: Int = 0 ) @@ -182,7 +189,7 @@ class BrowserTabViewModel( class ShareLink(val url: String) : Command() class CopyLink(val url: String) : Command() class FindInPageCommand(val searchTerm: String) : Command() - class BrokenSiteFeedback(val url: String, val blockedTrackers: String, val surrogates: String, val httpsUpgraded: Boolean) : Command() + class BrokenSiteFeedback(val data: BrokenSiteData) : Command() object DismissFindInPage : Command() class ShowFileChooser(val filePathCallback: ValueCallback>, val fileChooserParams: WebChromeClient.FileChooserParams) : Command() class HandleExternalAppLink(val appLink: IntentType) : Command() @@ -488,7 +495,10 @@ class BrowserTabViewModel( val currentOmnibarViewState = currentOmnibarViewState() omnibarViewState.value = currentOmnibarViewState.copy(omnibarText = omnibarTextForUrl(url), shouldMoveCaretToEnd = false) val currentBrowserViewState = currentBrowserViewState() + val domain = site?.domain + val canWhitelist = domain != null findInPageViewState.value = FindInPageViewState(visible = false, canFindInPage = true) + browserViewState.value = currentBrowserViewState.copy( browserShowing = true, canAddBookmarks = true, @@ -497,6 +507,8 @@ class BrowserTabViewModel( canSharePage = true, showPrivacyGrade = true, canReportSite = true, + canWhitelist = canWhitelist, + isWhitelisted = false, showSearchIcon = false, showClearButton = false ) @@ -507,9 +519,29 @@ class BrowserTabViewModel( statisticsUpdater.refreshSearchRetentionAtb() } + domain?.let { viewModelScope.launch { updateLoadingStatePrivacy(domain) } } + domain?.let { viewModelScope.launch { updateWhitelistedState(domain) } } registerSiteVisit() } + private suspend fun updateLoadingStatePrivacy(domain: String) { + val isWhitelisted = isWhitelisted(domain) + withContext(dispatchers.main()) { + loadingViewState.value = currentLoadingViewState().copy(privacyOn = !isWhitelisted) + } + } + + private suspend fun updateWhitelistedState(domain: String) { + val isWhitelisted = isWhitelisted(domain) + withContext(dispatchers.main()) { + browserViewState.value = currentBrowserViewState().copy(isWhitelisted = isWhitelisted) + } + } + + private suspend fun isWhitelisted(domain: String): Boolean { + return withContext(dispatchers.io()) { userWhitelistDao.contains(domain) } + } + private fun urlUpdated(url: String) { Timber.v("Page url updated: $url") site?.url = url @@ -713,13 +745,39 @@ class BrowserTabViewModel( } fun onBrokenSiteSelected() { - val events = site?.trackingEvents - val blockedTrackers = events?.map { Uri.parse(it.trackerUrl).host }.orEmpty().distinct().joinToString(",") - val upgradedHttps = site?.upgradedHttps ?: false - val surrogates = site?.surrogates?.map { Uri.parse(it.name).baseHost }.orEmpty().distinct().joinToString(",") - val url = url.orEmpty() + command.value = BrokenSiteFeedback(BrokenSiteData.fromSite(site)) + } + + fun onWhitelistSelected() { + val domain = site?.domain ?: return + GlobalScope.launch(dispatchers.io()) { + if (isWhitelisted(domain)) { + removeFromWhitelist(domain) + } else { + addToWhitelist(domain) + } + command.postValue(Refresh) + } + } - command.value = BrokenSiteFeedback(url, blockedTrackers, surrogates, upgradedHttps) + private suspend fun addToWhitelist(domain: String) { + pixel.fire(PixelName.BROWSER_MENU_WHITELIST_ADD) + withContext(dispatchers.io()) { + userWhitelistDao.insert(domain) + } + withContext(dispatchers.main()) { + browserViewState.value = currentBrowserViewState().copy(isWhitelisted = true) + } + } + + private suspend fun removeFromWhitelist(domain: String) { + pixel.fire(PixelName.BROWSER_MENU_WHITELIST_REMOVE) + withContext(dispatchers.io()) { + userWhitelistDao.delete(domain) + } + withContext(dispatchers.main()) { + browserViewState.value = currentBrowserViewState().copy(isWhitelisted = false) + } } fun onUserSelectedToEditQuery(query: String) { diff --git a/app/src/main/java/com/duckduckgo/app/cta/ui/CtaViewModel.kt b/app/src/main/java/com/duckduckgo/app/cta/ui/CtaViewModel.kt index 067b4b2e20d5..aee012098653 100644 --- a/app/src/main/java/com/duckduckgo/app/cta/ui/CtaViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/cta/ui/CtaViewModel.kt @@ -26,12 +26,13 @@ import com.duckduckgo.app.global.DispatcherProvider import com.duckduckgo.app.global.install.AppInstallStore import com.duckduckgo.app.global.install.daysInstalled import com.duckduckgo.app.global.model.Site +import com.duckduckgo.app.global.model.domain import com.duckduckgo.app.global.model.orderedTrackingEntities import com.duckduckgo.app.onboarding.store.AppStage import com.duckduckgo.app.onboarding.store.OnboardingStore import com.duckduckgo.app.onboarding.store.UserStageStore import com.duckduckgo.app.onboarding.store.daxOnboardingActive -import com.duckduckgo.app.privacy.store.PrivacySettingsStore +import com.duckduckgo.app.privacy.db.UserWhitelistDao import com.duckduckgo.app.settings.db.SettingsDataStore import com.duckduckgo.app.statistics.Variant import com.duckduckgo.app.statistics.VariantManager @@ -53,10 +54,10 @@ class CtaViewModel @Inject constructor( private val surveyDao: SurveyDao, private val widgetCapabilities: WidgetCapabilities, private val dismissedCtaDao: DismissedCtaDao, + private val userWhitelistDao: UserWhitelistDao, private val variantManager: VariantManager, private val settingsDataStore: SettingsDataStore, private val onboardingStore: OnboardingStore, - private val settingsPrivacySettingsStore: PrivacySettingsStore, private val userStageStore: UserStageStore, private val dispatchers: DispatcherProvider ) { @@ -202,14 +203,13 @@ class CtaViewModel @Inject constructor( @WorkerThread private suspend fun canShowDaxCtaEndOfJourney(): Boolean = daxOnboardingActive() && - hasPrivacySettingsOn() && !daxDialogEndShown() && daxDialogIntroShown() && !settingsDataStore.hideTips && (daxDialogNetworkShown() || daxDialogOtherShown() || daxDialogSerpShown() || daxDialogTrackersFoundShown()) private suspend fun canShowDaxDialogCta(): Boolean { - if (!daxOnboardingActive() || settingsDataStore.hideTips || !hasPrivacySettingsOn()) { + if (!daxOnboardingActive() || settingsDataStore.hideTips) { return false } return true @@ -219,10 +219,14 @@ class CtaViewModel @Inject constructor( private fun getDaxDialogCta(site: Site?): Cta? { val nonNullSite = site ?: return null + val host = nonNullSite.domain + if (host == null || userWhitelistDao.contains(host)) { + return null + } + nonNullSite.let { // Is major network - val host = it.uri?.host - if (it.entity != null && host != null) { + if (it.entity != null) { it.entity?.let { entity -> if (!daxDialogNetworkShown() && DaxDialogCta.mainTrackerNetworks.contains(entity.displayName)) { return DaxDialogCta.DaxMainNetworkCta(onboardingStore, appInstallStore, entity.displayName, host) @@ -235,7 +239,7 @@ class CtaViewModel @Inject constructor( } // Trackers blocked - return if (!daxDialogTrackersFoundShown() && !isSerpUrl(it.url) && it.orderedTrackingEntities().isNotEmpty() && host != null) { + return if (!daxDialogTrackersFoundShown() && !isSerpUrl(it.url) && it.orderedTrackingEntities().isNotEmpty()) { DaxDialogCta.DaxTrackersBlockedCta(onboardingStore, appInstallStore, it.orderedTrackingEntities(), host) } else if (!isSerpUrl(it.url) && !daxDialogOtherShown() && !daxDialogTrackersFoundShown() && !daxDialogNetworkShown()) { DaxDialogCta.DaxNoSerpCta(onboardingStore, appInstallStore) @@ -245,8 +249,6 @@ class CtaViewModel @Inject constructor( } } - private fun hasPrivacySettingsOn(): Boolean = settingsPrivacySettingsStore.privacyOn - private fun variant(): Variant = variantManager.getVariant() private fun daxDialogIntroShown(): Boolean = dismissedCtaDao.exists(CtaId.DAX_INTRO) diff --git a/app/src/main/java/com/duckduckgo/app/di/AndroidBindingModule.kt b/app/src/main/java/com/duckduckgo/app/di/AndroidBindingModule.kt index 70a801269412..aad4d9e66657 100644 --- a/app/src/main/java/com/duckduckgo/app/di/AndroidBindingModule.kt +++ b/app/src/main/java/com/duckduckgo/app/di/AndroidBindingModule.kt @@ -39,10 +39,7 @@ import com.duckduckgo.app.launch.LaunchBridgeActivity import com.duckduckgo.app.notification.NotificationHandlerService import com.duckduckgo.app.onboarding.ui.OnboardingActivity import com.duckduckgo.app.onboarding.ui.page.DefaultBrowserPage -import com.duckduckgo.app.privacy.ui.PrivacyDashboardActivity -import com.duckduckgo.app.privacy.ui.PrivacyPracticesActivity -import com.duckduckgo.app.privacy.ui.ScorecardActivity -import com.duckduckgo.app.privacy.ui.TrackerNetworksActivity +import com.duckduckgo.app.privacy.ui.* import com.duckduckgo.app.settings.SettingsActivity import com.duckduckgo.app.survey.ui.SurveyActivity import com.duckduckgo.app.systemsearch.SystemSearchActivity @@ -98,6 +95,10 @@ abstract class AndroidBindingModule { @ContributesAndroidInjector abstract fun privacyTermsActivity(): PrivacyPracticesActivity + @ActivityScoped + @ContributesAndroidInjector + abstract fun whitelistActivity(): WhitelistActivity + @ActivityScoped @ContributesAndroidInjector abstract fun feedbackActivity(): FeedbackActivity diff --git a/app/src/main/java/com/duckduckgo/app/di/DaoModule.kt b/app/src/main/java/com/duckduckgo/app/di/DaoModule.kt index 0ff94f2ef0bf..424ef2501f3d 100644 --- a/app/src/main/java/com/duckduckgo/app/di/DaoModule.kt +++ b/app/src/main/java/com/duckduckgo/app/di/DaoModule.kt @@ -41,6 +41,9 @@ class DaoModule { @Provides fun providesTemporaryTrackingWhitelist(database: AppDatabase) = database.temporaryTrackingWhitelistDao() + @Provides + fun providesUserWhitelist(database: AppDatabase) = database.userWhitelistDao() + @Provides fun providesNetworkLeaderboardDao(database: AppDatabase) = database.networkLeaderboardDao() diff --git a/app/src/main/java/com/duckduckgo/app/di/PrivacyModule.kt b/app/src/main/java/com/duckduckgo/app/di/PrivacyModule.kt index 77b56b0bbc73..d7e1a411af07 100644 --- a/app/src/main/java/com/duckduckgo/app/di/PrivacyModule.kt +++ b/app/src/main/java/com/duckduckgo/app/di/PrivacyModule.kt @@ -32,7 +32,6 @@ import com.duckduckgo.app.tabs.model.TabRepository import com.duckduckgo.app.trackerdetection.TdsEntityLookup import com.duckduckgo.app.trackerdetection.db.TdsDomainEntityDao import com.duckduckgo.app.trackerdetection.db.TdsEntityDao -import com.duckduckgo.app.trackerdetection.model.TdsEntity import dagger.Module import dagger.Provides import javax.inject.Singleton diff --git a/app/src/main/java/com/duckduckgo/app/di/StoreModule.kt b/app/src/main/java/com/duckduckgo/app/di/StoreModule.kt index f9b378f7038b..c3d7d8aa2a5e 100644 --- a/app/src/main/java/com/duckduckgo/app/di/StoreModule.kt +++ b/app/src/main/java/com/duckduckgo/app/di/StoreModule.kt @@ -40,9 +40,6 @@ abstract class StoreModule { @Binds abstract fun bindOnboardingStore(onboardingStore: OnboardingSharedPreferences): OnboardingStore - @Binds - abstract fun bindPrivacySettingsStore(privacySettingsStore: PrivacySettingsSharedPreferences): PrivacySettingsStore - @Binds abstract fun bindTermsOfServiceStore(termsOfServiceStore: TermsOfServiceRawStore): TermsOfServiceStore diff --git a/app/src/main/java/com/duckduckgo/app/di/VariantModule.kt b/app/src/main/java/com/duckduckgo/app/di/VariantModule.kt index 74c1b42c3469..f0c56a1023b4 100644 --- a/app/src/main/java/com/duckduckgo/app/di/VariantModule.kt +++ b/app/src/main/java/com/duckduckgo/app/di/VariantModule.kt @@ -16,10 +16,6 @@ package com.duckduckgo.app.di -import android.content.Context -import com.duckduckgo.app.global.shortcut.AppShortcutCreator -import com.duckduckgo.app.icon.api.AppIconModifier -import com.duckduckgo.app.icon.api.IconModifier import com.duckduckgo.app.statistics.ExperimentationVariantManager import com.duckduckgo.app.statistics.VariantManager import com.duckduckgo.app.statistics.WeightedRandomizer diff --git a/app/src/main/java/com/duckduckgo/app/global/UriString.kt b/app/src/main/java/com/duckduckgo/app/global/UriString.kt index 780fe67451eb..49b1d851d7fb 100644 --- a/app/src/main/java/com/duckduckgo/app/global/UriString.kt +++ b/app/src/main/java/com/duckduckgo/app/global/UriString.kt @@ -25,7 +25,8 @@ class UriString { private const val localhost = "localhost" private const val space = " " - private val webUrlRegex = PatternsCompat.WEB_URL.toRegex() + private val webUrlRegex by lazy { PatternsCompat.WEB_URL.toRegex() } + private val domainRegex by lazy { PatternsCompat.DOMAIN_NAME.toRegex() } fun host(uriString: String): String? { return Uri.parse(uriString).baseHost @@ -42,17 +43,16 @@ class UriString { val uri = Uri.parse(inputQuery).withScheme() if (uri.scheme != UrlScheme.http && uri.scheme != UrlScheme.https) return false if (uri.userInfo != null) return false - if (uri.host == null) return false - return isValidHost(uri.host) - } - private fun isValidHost(host: String): Boolean { + val host = uri.host ?: return false if (host == localhost) return true if (host.contains(space)) return false if (host.contains("!")) return false + return (webUrlRegex.containsMatchIn(host)) + } - if (webUrlRegex.containsMatchIn(host)) return true - return false + fun isValidDomain(domain: String): Boolean { + return domainRegex.matches(domain) } } } diff --git a/app/src/main/java/com/duckduckgo/app/global/ViewModelFactory.kt b/app/src/main/java/com/duckduckgo/app/global/ViewModelFactory.kt index 2a91633b3017..0efeb63d178e 100644 --- a/app/src/main/java/com/duckduckgo/app/global/ViewModelFactory.kt +++ b/app/src/main/java/com/duckduckgo/app/global/ViewModelFactory.kt @@ -45,19 +45,14 @@ import com.duckduckgo.app.icon.api.IconModifier import com.duckduckgo.app.icon.ui.ChangeIconViewModel import com.duckduckgo.app.launch.LaunchViewModel import com.duckduckgo.app.notification.AndroidNotificationScheduler -import com.duckduckgo.app.onboarding.store.OnboardingStore import com.duckduckgo.app.onboarding.store.UserStageStore import com.duckduckgo.app.onboarding.ui.OnboardingPageManager import com.duckduckgo.app.onboarding.ui.OnboardingViewModel import com.duckduckgo.app.onboarding.ui.page.DefaultBrowserPageViewModel -import com.duckduckgo.app.onboarding.ui.page.TrackerBlockingSelectionViewModel import com.duckduckgo.app.playstore.PlayStoreUtils import com.duckduckgo.app.privacy.db.NetworkLeaderboardDao -import com.duckduckgo.app.privacy.store.PrivacySettingsSharedPreferences -import com.duckduckgo.app.privacy.ui.PrivacyDashboardViewModel -import com.duckduckgo.app.privacy.ui.PrivacyPracticesViewModel -import com.duckduckgo.app.privacy.ui.ScorecardViewModel -import com.duckduckgo.app.privacy.ui.TrackerNetworksViewModel +import com.duckduckgo.app.privacy.db.UserWhitelistDao +import com.duckduckgo.app.privacy.ui.* import com.duckduckgo.app.referral.AppInstallationReferrerStateListener import com.duckduckgo.app.settings.SettingsViewModel import com.duckduckgo.app.settings.db.SettingsDataStore @@ -80,14 +75,13 @@ import javax.inject.Inject class ViewModelFactory @Inject constructor( private val statisticsUpdater: StatisticsUpdater, private val statisticsStore: StatisticsDataStore, - private val onboardingStore: OnboardingStore, private val userStageStore: UserStageStore, private val appInstallStore: AppInstallStore, private val queryUrlConverter: QueryUrlConverter, private val duckDuckGoUrlDetector: DuckDuckGoUrlDetector, private val tabRepository: TabRepository, - private val privacySettingsStore: PrivacySettingsSharedPreferences, private val siteFactory: SiteFactory, + private val userWhitelistDao: UserWhitelistDao, private val networkLeaderboardDao: NetworkLeaderboardDao, private val bookmarksDao: BookmarksDao, private val surveyDao: SurveyDao, @@ -127,9 +121,10 @@ class ViewModelFactory @Inject constructor( isAssignableFrom(BrowserTabViewModel::class.java) -> browserTabViewModel() isAssignableFrom(TabSwitcherViewModel::class.java) -> TabSwitcherViewModel(tabRepository, webViewSessionStorage) isAssignableFrom(PrivacyDashboardViewModel::class.java) -> privacyDashboardViewModel() - isAssignableFrom(ScorecardViewModel::class.java) -> ScorecardViewModel(privacySettingsStore) + isAssignableFrom(ScorecardViewModel::class.java) -> ScorecardViewModel(userWhitelistDao) isAssignableFrom(TrackerNetworksViewModel::class.java) -> TrackerNetworksViewModel() isAssignableFrom(PrivacyPracticesViewModel::class.java) -> PrivacyPracticesViewModel() + isAssignableFrom(WhitelistViewModel::class.java) -> WhitelistViewModel(userWhitelistDao) isAssignableFrom(FeedbackViewModel::class.java) -> FeedbackViewModel(playStoreUtils, feedbackSubmitter) isAssignableFrom(BrokenSiteViewModel::class.java) -> BrokenSiteViewModel(pixel, brokenSiteSender) isAssignableFrom(SurveyViewModel::class.java) -> SurveyViewModel(surveyDao, statisticsStore, appInstallStore) @@ -140,7 +135,6 @@ class ViewModelFactory @Inject constructor( isAssignableFrom(PositiveFeedbackLandingViewModel::class.java) -> PositiveFeedbackLandingViewModel() isAssignableFrom(ShareOpenEndedNegativeFeedbackViewModel::class.java) -> ShareOpenEndedNegativeFeedbackViewModel() isAssignableFrom(BrokenSiteNegativeFeedbackViewModel::class.java) -> BrokenSiteNegativeFeedbackViewModel() - isAssignableFrom(TrackerBlockingSelectionViewModel::class.java) -> TrackerBlockingSelectionViewModel(privacySettingsStore) isAssignableFrom(DefaultBrowserPageViewModel::class.java) -> defaultBrowserPage() isAssignableFrom(ChangeIconViewModel::class.java) -> changeAppIconViewModel() @@ -163,7 +157,7 @@ class ViewModelFactory @Inject constructor( private fun privacyDashboardViewModel(): PrivacyDashboardViewModel { return PrivacyDashboardViewModel( - privacySettingsStore, + userWhitelistDao, networkLeaderboardDao, pixel ) @@ -185,6 +179,7 @@ class ViewModelFactory @Inject constructor( duckDuckGoUrlDetector = duckDuckGoUrlDetector, siteFactory = siteFactory, tabRepository = tabRepository, + userWhitelistDao = userWhitelistDao, networkLeaderboardDao = networkLeaderboardDao, bookmarksDao = bookmarksDao, autoComplete = autoCompleteApi, diff --git a/app/src/main/java/com/duckduckgo/app/global/db/AppDatabase.kt b/app/src/main/java/com/duckduckgo/app/global/db/AppDatabase.kt index 47682646fb1c..c167dded6683 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 @@ -40,11 +40,9 @@ import com.duckduckgo.app.httpsupgrade.model.HttpsWhitelistedDomain import com.duckduckgo.app.notification.db.NotificationDao import com.duckduckgo.app.notification.model.Notification import com.duckduckgo.app.onboarding.store.* -import com.duckduckgo.app.privacy.db.NetworkLeaderboardDao -import com.duckduckgo.app.privacy.db.NetworkLeaderboardEntry -import com.duckduckgo.app.privacy.db.PrivacyProtectionCountDao -import com.duckduckgo.app.privacy.db.SitesVisitedEntity +import com.duckduckgo.app.privacy.db.* import com.duckduckgo.app.privacy.model.PrivacyProtectionCountsEntity +import com.duckduckgo.app.privacy.model.UserWhitelistedDomain import com.duckduckgo.app.survey.db.SurveyDao import com.duckduckgo.app.survey.model.Survey import com.duckduckgo.app.tabs.db.TabsDao @@ -58,11 +56,12 @@ import com.duckduckgo.app.usage.search.SearchCountDao import com.duckduckgo.app.usage.search.SearchCountEntity @Database( - exportSchema = true, version = 19, entities = [ + exportSchema = true, version = 20, entities = [ TdsTracker::class, TdsEntity::class, TdsDomainEntity::class, TemporaryTrackingWhitelistedDomain::class, + UserWhitelistedDomain::class, HttpsBloomFilterSpec::class, HttpsWhitelistedDomain::class, NetworkLeaderboardEntry::class, @@ -100,6 +99,7 @@ abstract class AppDatabase : RoomDatabase() { abstract fun tdsEntityDao(): TdsEntityDao abstract fun tdsDomainEntityDao(): TdsDomainEntityDao abstract fun temporaryTrackingWhitelistDao(): TemporaryTrackingWhitelistDao + abstract fun userWhitelistDao(): UserWhitelistDao abstract fun httpsWhitelistedDao(): HttpsWhitelistDao abstract fun httpsBloomFilterSpecDao(): HttpsBloomFilterSpecDao abstract fun networkLeaderboardDao(): NetworkLeaderboardDao @@ -276,6 +276,12 @@ class MigrationsProvider(val context: Context) { } } + val MIGRATION_19_TO_20: Migration = object : Migration(19, 20) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("CREATE TABLE IF NOT EXISTS `user_whitelist` (`domain` TEXT NOT NULL, PRIMARY KEY(`domain`))") + } + } + val ALL_MIGRATIONS: List get() = listOf( MIGRATION_1_TO_2, @@ -295,7 +301,8 @@ class MigrationsProvider(val context: Context) { MIGRATION_15_TO_16, MIGRATION_16_TO_17, MIGRATION_17_TO_18, - MIGRATION_18_TO_19 + MIGRATION_18_TO_19, + MIGRATION_19_TO_20 ) @Deprecated( diff --git a/app/src/main/java/com/duckduckgo/app/global/model/Site.kt b/app/src/main/java/com/duckduckgo/app/global/model/Site.kt index 1bf9bfe49ccc..96a17e76bf0a 100644 --- a/app/src/main/java/com/duckduckgo/app/global/model/Site.kt +++ b/app/src/main/java/com/duckduckgo/app/global/model/Site.kt @@ -69,4 +69,6 @@ fun Site.orderedTrackingEntities(): List = trackingEvents fun Site.domainMatchesUrl(matchingUrl: String): Boolean { return uri?.baseHost == matchingUrl.toUri().baseHost -} \ No newline at end of file +} + +val Site.domain get() = uri?.host \ No newline at end of file diff --git a/app/src/main/java/com/duckduckgo/app/httpsupgrade/HttpsUpgrader.kt b/app/src/main/java/com/duckduckgo/app/httpsupgrade/HttpsUpgrader.kt index 61b24777b50b..5eeb1afac529 100644 --- a/app/src/main/java/com/duckduckgo/app/httpsupgrade/HttpsUpgrader.kt +++ b/app/src/main/java/com/duckduckgo/app/httpsupgrade/HttpsUpgrader.kt @@ -25,6 +25,7 @@ import com.duckduckgo.app.global.toHttps import com.duckduckgo.app.httpsupgrade.api.HttpsBloomFilterFactory import com.duckduckgo.app.httpsupgrade.api.HttpsUpgradeService import com.duckduckgo.app.httpsupgrade.db.HttpsWhitelistDao +import com.duckduckgo.app.privacy.db.UserWhitelistDao import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.statistics.pixels.Pixel.PixelName.* import timber.log.Timber @@ -44,7 +45,8 @@ interface HttpsUpgrader { } class HttpsUpgraderImpl( - private val whitelistedDao: HttpsWhitelistDao, + private val httpsWhitelistDao: HttpsWhitelistDao, + private val userWhitelistDao: UserWhitelistDao, private val bloomFactory: HttpsBloomFilterFactory, private val httpsUpgradeService: HttpsUpgradeService, private val pixel: Pixel @@ -68,9 +70,15 @@ class HttpsUpgraderImpl( return false } - if (whitelistedDao.contains(host)) { + if (userWhitelistDao.contains(host)) { pixel.fire(HTTPS_NO_LOOKUP) - Timber.d("$host is in whitelist and so not upgradable") + Timber.d("$host is in user whitelist and so not upgradable") + return false + } + + if (httpsWhitelistDao.contains(host)) { + pixel.fire(HTTPS_NO_LOOKUP) + Timber.d("$host is in https whitelist and so not upgradable") return false } diff --git a/app/src/main/java/com/duckduckgo/app/httpsupgrade/di/HttpsUpgraderModule.kt b/app/src/main/java/com/duckduckgo/app/httpsupgrade/di/HttpsUpgraderModule.kt index f51a1a198ad7..b360b0dee927 100644 --- a/app/src/main/java/com/duckduckgo/app/httpsupgrade/di/HttpsUpgraderModule.kt +++ b/app/src/main/java/com/duckduckgo/app/httpsupgrade/di/HttpsUpgraderModule.kt @@ -24,6 +24,7 @@ import com.duckduckgo.app.httpsupgrade.api.HttpsBloomFilterFactoryImpl import com.duckduckgo.app.httpsupgrade.api.HttpsUpgradeService import com.duckduckgo.app.httpsupgrade.db.HttpsBloomFilterSpecDao import com.duckduckgo.app.httpsupgrade.db.HttpsWhitelistDao +import com.duckduckgo.app.privacy.db.UserWhitelistDao import com.duckduckgo.app.statistics.pixels.Pixel import dagger.Module import dagger.Provides @@ -36,11 +37,12 @@ class HttpsUpgraderModule { @Provides fun httpsUpgrader( whitelistDao: HttpsWhitelistDao, + userWhitelistDao: UserWhitelistDao, bloomFilterFactory: HttpsBloomFilterFactory, httpsUpgradeService: HttpsUpgradeService, pixel: Pixel ): HttpsUpgrader { - return HttpsUpgraderImpl(whitelistDao, bloomFilterFactory, httpsUpgradeService, pixel) + return HttpsUpgraderImpl(whitelistDao, userWhitelistDao, bloomFilterFactory, httpsUpgradeService, pixel) } @Provides diff --git a/app/src/main/java/com/duckduckgo/app/launch/LaunchViewModel.kt b/app/src/main/java/com/duckduckgo/app/launch/LaunchViewModel.kt index eb1b2f4ac2b8..5127d442a0ff 100644 --- a/app/src/main/java/com/duckduckgo/app/launch/LaunchViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/launch/LaunchViewModel.kt @@ -18,7 +18,6 @@ package com.duckduckgo.app.launch import androidx.lifecycle.ViewModel import com.duckduckgo.app.global.SingleLiveEvent -import com.duckduckgo.app.onboarding.store.AppStage import com.duckduckgo.app.onboarding.store.UserStageStore import com.duckduckgo.app.onboarding.store.isNewUser import com.duckduckgo.app.referral.AppInstallationReferrerStateListener diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/TrackerBlockingSelectionViewModel.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/TrackerBlockingSelectionViewModel.kt deleted file mode 100644 index 8ca36fc392fb..000000000000 --- a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/TrackerBlockingSelectionViewModel.kt +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright (c) 2019 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.onboarding.ui.page - -import androidx.lifecycle.ViewModel -import com.duckduckgo.app.privacy.store.PrivacySettingsStore - - -class TrackerBlockingSelectionViewModel(private val settingsStore: PrivacySettingsStore) : ViewModel() { - - fun onTrackerBlockingEnabled() { - settingsStore.privacyOn = true - } - - fun onTrackerBlockingDisabled() { - settingsStore.privacyOn = false - } - -} \ No newline at end of file diff --git a/app/src/main/java/com/duckduckgo/app/privacy/db/UserWhitelistDao.kt b/app/src/main/java/com/duckduckgo/app/privacy/db/UserWhitelistDao.kt new file mode 100644 index 000000000000..2ec6d9ae047e --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/privacy/db/UserWhitelistDao.kt @@ -0,0 +1,45 @@ +/* + * 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.privacy.db + +import androidx.lifecycle.LiveData +import androidx.room.* +import com.duckduckgo.app.privacy.model.UserWhitelistedDomain + +@Dao +abstract class UserWhitelistDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + abstract fun insert(domain: UserWhitelistedDomain) + + fun insert(domain: String) { + insert(UserWhitelistedDomain(domain)) + } + + @Delete + abstract fun delete(domain: UserWhitelistedDomain) + + fun delete(domain: String) { + delete(UserWhitelistedDomain(domain)) + } + + @Query("select * from user_whitelist") + abstract fun all(): LiveData> + + @Query("select count(1) > 0 from user_whitelist where domain = :domain") + abstract fun contains(domain: String): Boolean +} diff --git a/app/src/main/java/com/duckduckgo/app/privacy/store/PrivacySettingsStore.kt b/app/src/main/java/com/duckduckgo/app/privacy/model/UserWhitelistedDomain.kt similarity index 69% rename from app/src/main/java/com/duckduckgo/app/privacy/store/PrivacySettingsStore.kt rename to app/src/main/java/com/duckduckgo/app/privacy/model/UserWhitelistedDomain.kt index 478bfb0db544..e39dd5729a71 100644 --- a/app/src/main/java/com/duckduckgo/app/privacy/store/PrivacySettingsStore.kt +++ b/app/src/main/java/com/duckduckgo/app/privacy/model/UserWhitelistedDomain.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2017 DuckDuckGo + * 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. @@ -14,8 +14,12 @@ * limitations under the License. */ -package com.duckduckgo.app.privacy.store +package com.duckduckgo.app.privacy.model -interface PrivacySettingsStore { - var privacyOn: Boolean -} \ No newline at end of file +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "user_whitelist") +data class UserWhitelistedDomain( + @PrimaryKey val domain: String +) diff --git a/app/src/main/java/com/duckduckgo/app/privacy/store/PrivacySettingsSharedPreferences.kt b/app/src/main/java/com/duckduckgo/app/privacy/store/PrivacySettingsSharedPreferences.kt deleted file mode 100644 index b56bae41b439..000000000000 --- a/app/src/main/java/com/duckduckgo/app/privacy/store/PrivacySettingsSharedPreferences.kt +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright (c) 2017 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.privacy.store - -import android.content.Context -import android.content.Context.MODE_PRIVATE -import android.content.SharedPreferences -import javax.inject.Inject - -class PrivacySettingsSharedPreferences @Inject constructor(private val context: Context) : PrivacySettingsStore { - - override var privacyOn: Boolean - get() = preferences.getBoolean(KEY_PRIVACY_ON, true) - set(on) { - val editor = preferences.edit() - editor.putBoolean(KEY_PRIVACY_ON, on) - editor.apply() - } - - private val preferences: SharedPreferences - get() = context.getSharedPreferences(FILENAME, MODE_PRIVATE) - - - companion object { - private const val FILENAME = "com.duckduckgo.app.privacymonitor.settings" - private const val KEY_PRIVACY_ON = "com.duckduckgo.app.privacymonitor.privacyon" - } -} \ No newline at end of file diff --git a/app/src/main/java/com/duckduckgo/app/privacy/ui/PrivacyDashboardActivity.kt b/app/src/main/java/com/duckduckgo/app/privacy/ui/PrivacyDashboardActivity.kt index 4597f460e60a..aa7ff261a10b 100644 --- a/app/src/main/java/com/duckduckgo/app/privacy/ui/PrivacyDashboardActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/privacy/ui/PrivacyDashboardActivity.kt @@ -23,6 +23,8 @@ import android.os.Bundle import android.view.View import androidx.core.content.ContextCompat import androidx.lifecycle.Observer +import com.duckduckgo.app.brokensite.BrokenSiteActivity +import com.duckduckgo.app.brokensite.BrokenSiteData import com.duckduckgo.app.browser.R import com.duckduckgo.app.global.DuckDuckGoActivity import com.duckduckgo.app.global.model.Site @@ -30,6 +32,9 @@ import com.duckduckgo.app.global.view.hide import com.duckduckgo.app.global.view.html import com.duckduckgo.app.global.view.show import com.duckduckgo.app.privacy.renderer.* +import com.duckduckgo.app.privacy.ui.PrivacyDashboardViewModel.Command +import com.duckduckgo.app.privacy.ui.PrivacyDashboardViewModel.Command.LaunchManageWhitelist +import com.duckduckgo.app.privacy.ui.PrivacyDashboardViewModel.Command.LaunchReportBrokenSite import com.duckduckgo.app.privacy.ui.PrivacyDashboardViewModel.ViewState import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.statistics.pixels.Pixel.PixelName.* @@ -44,6 +49,7 @@ class PrivacyDashboardActivity : DuckDuckGoActivity() { @Inject lateinit var repository: TabRepository + @Inject lateinit var pixel: Pixel @@ -56,19 +62,35 @@ class PrivacyDashboardActivity : DuckDuckGoActivity() { super.onCreate(savedInstanceState) setContentView(R.layout.activity_privacy_dashboard) setupToolbar(toolbar) + setupObservers() + setupClickListeners() + } - viewModel.viewState.observe(this, Observer { + private fun setupObservers() { + viewModel.viewState.observe(this, Observer { it?.let { render(it) } }) - + viewModel.command.observe(this, Observer { + it?.let { processCommand(it) } + }) repository.retrieveSiteData(intent.tabId!!).observe(this, Observer { viewModel.onSiteChanged(it) }) + } + private fun setupClickListeners() { privacyGrade.setOnClickListener { onScorecardClicked() } + whitelistButton.setOnClickListener { + viewModel.onManageWhitelistSelected() + } + + brokenSiteButton.setOnClickListener { + viewModel.onReportBrokenSiteSelected() + } + privacyToggle.setOnCheckedChangeListener { _, enabled -> viewModel.onPrivacyToggled(enabled) } @@ -78,16 +100,17 @@ class PrivacyDashboardActivity : DuckDuckGoActivity() { if (isFinishing) { return } - privacyBanner.setImageResource(viewState.afterGrade.banner(viewState.toggleEnabled)) + val toggle = viewState.toggleEnabled ?: true + privacyBanner.setImageResource(viewState.afterGrade.banner(toggle)) domain.text = viewState.domain - heading.text = upgradeRenderer.heading(this, viewState.beforeGrade, viewState.afterGrade, viewState.toggleEnabled).html(this) + heading.text = upgradeRenderer.heading(this, viewState.beforeGrade, viewState.afterGrade, toggle).html(this) httpsIcon.setImageResource(viewState.httpsStatus.icon()) httpsText.text = viewState.httpsStatus.text(this) networksIcon.setImageResource(trackersRenderer.networksIcon(viewState.allTrackersBlocked)) networksText.text = trackersRenderer.trackersText(this, viewState.trackerCount, viewState.allTrackersBlocked) practicesIcon.setImageResource(viewState.practices.icon()) practicesText.text = viewState.practices.text(this) - renderToggle(viewState.toggleEnabled) + renderToggle(toggle) renderTrackerNetworkLeaderboard(viewState) updateActivityResult(viewState.shouldReloadPage) } @@ -127,11 +150,26 @@ class PrivacyDashboardActivity : DuckDuckGoActivity() { privacyToggle.isChecked = enabled } + private fun processCommand(command: Command) { + when (command) { + is LaunchManageWhitelist -> launchWhitelistActivity() + is LaunchReportBrokenSite -> launchReportBrokenSite(command.data) + } + } + private fun onScorecardClicked() { pixel.fire(PRIVACY_DASHBOARD_SCORECARD) startActivity(ScorecardActivity.intent(this, intent.tabId!!)) } + private fun launchReportBrokenSite(data: BrokenSiteData) { + startActivity(BrokenSiteActivity.intent(this, data)) + } + + private fun launchWhitelistActivity() { + startActivity(WhitelistActivity.intent(this)) + } + fun onEncryptionClicked(@Suppress("UNUSED_PARAMETER") view: View) { pixel.fire(PRIVACY_DASHBOARD_ENCRYPTION) } @@ -169,5 +207,4 @@ class PrivacyDashboardActivity : DuckDuckGoActivity() { } } - } diff --git a/app/src/main/java/com/duckduckgo/app/privacy/ui/PrivacyDashboardViewModel.kt b/app/src/main/java/com/duckduckgo/app/privacy/ui/PrivacyDashboardViewModel.kt index bbe0b45638fd..038807ebe117 100644 --- a/app/src/main/java/com/duckduckgo/app/privacy/ui/PrivacyDashboardViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/privacy/ui/PrivacyDashboardViewModel.kt @@ -17,25 +17,33 @@ package com.duckduckgo.app.privacy.ui import androidx.annotation.VisibleForTesting -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.Observer -import androidx.lifecycle.ViewModel +import androidx.lifecycle.* +import com.duckduckgo.app.brokensite.BrokenSiteData +import com.duckduckgo.app.global.DefaultDispatcherProvider +import com.duckduckgo.app.global.DispatcherProvider +import com.duckduckgo.app.global.SingleLiveEvent import com.duckduckgo.app.global.model.Site +import com.duckduckgo.app.global.model.domain import com.duckduckgo.app.privacy.db.NetworkLeaderboardDao import com.duckduckgo.app.privacy.db.NetworkLeaderboardEntry +import com.duckduckgo.app.privacy.db.UserWhitelistDao import com.duckduckgo.app.privacy.model.HttpsStatus import com.duckduckgo.app.privacy.model.PrivacyGrade import com.duckduckgo.app.privacy.model.PrivacyPractices import com.duckduckgo.app.privacy.model.PrivacyPractices.Summary.UNKNOWN -import com.duckduckgo.app.privacy.store.PrivacySettingsStore +import com.duckduckgo.app.privacy.ui.PrivacyDashboardViewModel.Command.LaunchManageWhitelist +import com.duckduckgo.app.privacy.ui.PrivacyDashboardViewModel.Command.LaunchReportBrokenSite import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.statistics.pixels.Pixel.PixelName.* +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext class PrivacyDashboardViewModel( - private val settingsStore: PrivacySettingsStore, + private val userWhitelistDao: UserWhitelistDao, networkLeaderboardDao: NetworkLeaderboardDao, - private val pixel: Pixel + private val pixel: Pixel, + private val dispatchers: DispatcherProvider = DefaultDispatcherProvider() ) : ViewModel() { data class ViewState( @@ -46,14 +54,20 @@ class PrivacyDashboardViewModel( val trackerCount: Int, val allTrackersBlocked: Boolean, val practices: PrivacyPractices.Summary, - val toggleEnabled: Boolean, + val toggleEnabled: Boolean?, val shouldShowTrackerNetworkLeaderboard: Boolean, val sitesVisited: Int, val trackerNetworkEntries: List, val shouldReloadPage: Boolean ) + sealed class Command { + object LaunchManageWhitelist : Command() + class LaunchReportBrokenSite(val data: BrokenSiteData) : Command() + } + val viewState: MutableLiveData = MutableLiveData() + val command: SingleLiveEvent = SingleLiveEvent() private var site: Site? = null private val sitesVisited: LiveData = networkLeaderboardDao.sitesVisited() @@ -61,11 +75,6 @@ class PrivacyDashboardViewModel( private val trackerNetworkLeaderboard: LiveData> = networkLeaderboardDao.trackerNetworkLeaderboard() private val trackerNetworkActivityObserver = Observer> { onTrackerNetworkEntriesChanged(it) } - private val privacyInitiallyOn = settingsStore.privacyOn - - private val shouldReloadPage: Boolean - get() = privacyInitiallyOn != settingsStore.privacyOn - init { pixel.fire(PRIVACY_DASHBOARD_OPENED) resetViewState() @@ -107,7 +116,7 @@ class PrivacyDashboardViewModel( if (site == null) { resetViewState() } else { - updateSite(site) + viewModelScope.launch { updateSite(site) } } } @@ -119,43 +128,70 @@ class PrivacyDashboardViewModel( httpsStatus = HttpsStatus.SECURE, trackerCount = 0, allTrackersBlocked = true, - toggleEnabled = settingsStore.privacyOn, + toggleEnabled = null, practices = UNKNOWN, shouldShowTrackerNetworkLeaderboard = false, sitesVisited = 0, trackerNetworkEntries = emptyList(), - shouldReloadPage = shouldReloadPage + shouldReloadPage = false ) } - private fun updateSite(site: Site) { + private suspend fun updateSite(site: Site) { val grades = site.calculateGrades() + val domain = site.domain ?: "" + val toggleEnabled = withContext(dispatchers.io()) { !userWhitelistDao.contains(domain) } - viewState.value = viewState.value?.copy( - domain = site.uri?.host ?: "", - beforeGrade = grades.grade, - afterGrade = grades.improvedGrade, - httpsStatus = site.https, - trackerCount = site.trackerCount, - allTrackersBlocked = site.allTrackersBlocked, - practices = site.privacyPractices.summary - ) + withContext(dispatchers.main()) { + viewState.value = viewState.value?.copy( + domain = site.domain ?: "", + beforeGrade = grades.grade, + afterGrade = grades.improvedGrade, + httpsStatus = site.https, + trackerCount = site.trackerCount, + allTrackersBlocked = site.allTrackersBlocked, + toggleEnabled = toggleEnabled, + practices = site.privacyPractices.summary + ) + } } fun onPrivacyToggled(enabled: Boolean) { - if (enabled != viewState.value?.toggleEnabled) { + if (viewState.value?.toggleEnabled == null) { + return + } - settingsStore.privacyOn = enabled - val pixelName = if (enabled) TRACKER_BLOCKER_DASHBOARD_TURNED_ON else TRACKER_BLOCKER_DASHBOARD_TURNED_OFF - pixel.fire(pixelName) + if (enabled == viewState.value?.toggleEnabled) { + return + } - viewState.value = viewState.value?.copy( - toggleEnabled = enabled, - shouldReloadPage = shouldReloadPage - ) + viewState.value = viewState.value?.copy( + toggleEnabled = enabled, + shouldReloadPage = true + ) + + val domain = site?.domain ?: return + GlobalScope.launch(dispatchers.io()) { + if (enabled) { + userWhitelistDao.delete(domain) + pixel.fire(PRIVACY_DASHBOARD_WHITELIST_REMOVE) + } else { + userWhitelistDao.insert(domain) + pixel.fire(PRIVACY_DASHBOARD_WHITELIST_ADD) + } } } + fun onManageWhitelistSelected() { + pixel.fire(PRIVACY_DASHBOARD_MANAGE_WHITELIST) + command.value = LaunchManageWhitelist + } + + fun onReportBrokenSiteSelected() { + pixel.fire(PRIVACY_DASHBOARD_REPORT_BROKEN_SITE) + command.value = LaunchReportBrokenSite(BrokenSiteData.fromSite(site)) + } + private companion object { private const val LEADERBOARD_MIN_NETWORKS = 3 private const val LEADERBOARD_MIN_DOMAINS_EXCLUSIVE = 30 diff --git a/app/src/main/java/com/duckduckgo/app/privacy/ui/PrivacyPracticesViewModel.kt b/app/src/main/java/com/duckduckgo/app/privacy/ui/PrivacyPracticesViewModel.kt index ea4c133e0bad..0bb546c3c136 100644 --- a/app/src/main/java/com/duckduckgo/app/privacy/ui/PrivacyPracticesViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/privacy/ui/PrivacyPracticesViewModel.kt @@ -19,6 +19,7 @@ package com.duckduckgo.app.privacy.ui import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import com.duckduckgo.app.global.model.Site +import com.duckduckgo.app.global.model.domain import com.duckduckgo.app.privacy.model.PrivacyPractices import com.duckduckgo.app.privacy.model.PrivacyPractices.Summary.UNKNOWN @@ -52,7 +53,7 @@ class PrivacyPracticesViewModel : ViewModel() { return } viewState.value = viewState.value?.copy( - domain = site.uri?.host ?: "", + domain = site.domain ?: "", practices = site.privacyPractices.summary, goodTerms = site.privacyPractices.goodReasons, badTerms = site.privacyPractices.badReasons diff --git a/app/src/main/java/com/duckduckgo/app/privacy/ui/ScorecardViewModel.kt b/app/src/main/java/com/duckduckgo/app/privacy/ui/ScorecardViewModel.kt index 7b59c93130e1..390813aff25f 100644 --- a/app/src/main/java/com/duckduckgo/app/privacy/ui/ScorecardViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/privacy/ui/ScorecardViewModel.kt @@ -18,14 +18,23 @@ package com.duckduckgo.app.privacy.ui import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.duckduckgo.app.global.DefaultDispatcherProvider +import com.duckduckgo.app.global.DispatcherProvider import com.duckduckgo.app.global.model.Site +import com.duckduckgo.app.global.model.domain +import com.duckduckgo.app.privacy.db.UserWhitelistDao import com.duckduckgo.app.privacy.model.HttpsStatus import com.duckduckgo.app.privacy.model.PrivacyGrade import com.duckduckgo.app.privacy.model.PrivacyPractices import com.duckduckgo.app.privacy.model.PrivacyPractices.Summary.UNKNOWN -import com.duckduckgo.app.privacy.store.PrivacySettingsStore +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext -class ScorecardViewModel(private val settingsStore: PrivacySettingsStore) : ViewModel() { +class ScorecardViewModel( + private val userWhitelistDao: UserWhitelistDao, + private val dispatchers: DispatcherProvider = DefaultDispatcherProvider() +) : ViewModel() { data class ViewState( val domain: String, @@ -53,7 +62,7 @@ class ScorecardViewModel(private val settingsStore: PrivacySettingsStore) : View if (site == null) { resetViewState() } else { - updateSite(site) + viewModelScope.launch { updateSite(site) } } } @@ -67,28 +76,34 @@ class ScorecardViewModel(private val settingsStore: PrivacySettingsStore) : View majorNetworkCount = 0, allTrackersBlocked = true, practices = UNKNOWN, - privacyOn = settingsStore.privacyOn, + privacyOn = true, showIsMemberOfMajorNetwork = false, showEnhancedGrade = false ) } - private fun updateSite(site: Site) { + private suspend fun updateSite(site: Site) { + val domain = site.domain ?: "" val grades = site.calculateGrades() val grade = grades.grade val improvedGrade = grades.improvedGrade - viewState.value = viewState.value?.copy( - domain = site.uri?.host ?: "", - beforeGrade = grade, - afterGrade = improvedGrade, - trackerCount = site.trackerCount, - majorNetworkCount = site.majorNetworkCount, - httpsStatus = site.https, - allTrackersBlocked = site.allTrackersBlocked, - practices = site.privacyPractices.summary, - showIsMemberOfMajorNetwork = site.entity?.isMajor ?: false, - showEnhancedGrade = grade != improvedGrade - ) + val isWhitelisted = withContext(dispatchers.io()) { userWhitelistDao.contains(domain) } + + withContext(dispatchers.main()) { + viewState.value = viewState.value?.copy( + domain = domain, + beforeGrade = grade, + afterGrade = improvedGrade, + trackerCount = site.trackerCount, + majorNetworkCount = site.majorNetworkCount, + httpsStatus = site.https, + allTrackersBlocked = site.allTrackersBlocked, + practices = site.privacyPractices.summary, + privacyOn = !isWhitelisted, + showIsMemberOfMajorNetwork = site.entity?.isMajor ?: false, + showEnhancedGrade = grade != improvedGrade + ) + } } } \ No newline at end of file diff --git a/app/src/main/java/com/duckduckgo/app/privacy/ui/TrackerNetworksViewModel.kt b/app/src/main/java/com/duckduckgo/app/privacy/ui/TrackerNetworksViewModel.kt index a5b9f7282981..adab1f5966c4 100644 --- a/app/src/main/java/com/duckduckgo/app/privacy/ui/TrackerNetworksViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/privacy/ui/TrackerNetworksViewModel.kt @@ -21,6 +21,7 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import com.duckduckgo.app.global.baseHost import com.duckduckgo.app.global.model.Site +import com.duckduckgo.app.global.model.domain import com.duckduckgo.app.trackerdetection.model.Entity import com.duckduckgo.app.trackerdetection.model.TdsEntity import com.duckduckgo.app.trackerdetection.model.TrackingEvent @@ -56,7 +57,7 @@ class TrackerNetworksViewModel : ViewModel() { return } viewState.value = viewState.value?.copy( - domain = site.uri?.host ?: "", + domain = site.domain ?: "", trackerCount = site.trackerCount, allTrackersBlocked = site.allTrackersBlocked, trackingEventsByNetwork = distinctTrackersByEntity(site.trackingEvents) diff --git a/app/src/main/java/com/duckduckgo/app/privacy/ui/WhitelistActivity.kt b/app/src/main/java/com/duckduckgo/app/privacy/ui/WhitelistActivity.kt new file mode 100644 index 000000000000..bc9ea572a07d --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/privacy/ui/WhitelistActivity.kt @@ -0,0 +1,236 @@ +/* + * 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.privacy.ui + +import android.app.AlertDialog +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.view.* +import android.widget.* +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.VERTICAL +import com.duckduckgo.app.browser.R +import com.duckduckgo.app.global.DuckDuckGoActivity +import com.duckduckgo.app.global.view.gone +import com.duckduckgo.app.global.view.html +import com.duckduckgo.app.global.view.show +import com.duckduckgo.app.privacy.model.UserWhitelistedDomain +import com.duckduckgo.app.privacy.ui.WhitelistViewModel.Command.* +import kotlinx.android.synthetic.main.activity_whitelist.* +import kotlinx.android.synthetic.main.edit_whitelist.* +import kotlinx.android.synthetic.main.include_toolbar.* +import kotlinx.android.synthetic.main.view_whitelist_entry.view.* + +class WhitelistActivity : DuckDuckGoActivity() { + + lateinit var adapter: WhitelistAdapter + + private var dialog: AlertDialog? = null + private val viewModel: WhitelistViewModel by bindViewModel() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_whitelist) + setupToolbar(toolbar) + setupRecycler() + observeViewModel() + } + + override fun onCreateOptionsMenu(menu: Menu?): Boolean { + menuInflater.inflate(R.menu.whitelist_activity_menu, menu) + return true + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.add -> viewModel.onAddRequested() + } + return super.onOptionsItemSelected(item) + } + + private fun setupRecycler() { + adapter = WhitelistAdapter(viewModel) + recycler.adapter = adapter + + val separator = DividerItemDecoration(this, VERTICAL) + recycler.addItemDecoration(separator) + } + + private fun observeViewModel() { + viewModel.viewState.observe(this, Observer { viewState -> + viewState?.let { renderViewState(it) } + }) + + viewModel.command.observe(this, Observer { + processCommand(it) + }) + } + + private fun renderViewState(viewState: WhitelistViewModel.ViewState) { + adapter.entries = viewState.whitelist + if (viewState.showWhitelist) { + showList() + invalidateOptionsMenu() + } else { + hideList() + } + } + + private fun showList() { + recycler.show() + emptyWhitelist.gone() + } + + private fun hideList() { + recycler.gone() + emptyWhitelist.show() + } + + private fun processCommand(command: WhitelistViewModel.Command?) { + when (command) { + is ShowAdd -> showAddDialog() + is ShowEdit -> showEditDialog(command.entry) + is ConfirmDelete -> showDeleteDialog(command.entry) + is ShowWhitelistFormatError -> showWhitelistFormatError() + } + } + + private fun showAddDialog() { + val addDialog = AlertDialog.Builder(this).apply { + setTitle(R.string.dialogAddTitle) + setView(R.layout.edit_whitelist) + setPositiveButton(android.R.string.yes) { _, _ -> + val newText = dialog?.textInput?.text.toString() + viewModel.onEntryAdded(UserWhitelistedDomain(newText)) + } + setNegativeButton(android.R.string.no) { _, _ -> } + }.create() + + dialog?.dismiss() + dialog = addDialog + addDialog.show() + } + + private fun showEditDialog(entry: UserWhitelistedDomain) { + val editDialog = AlertDialog.Builder(this).apply { + setTitle(R.string.dialogEditTitle) + setView(R.layout.edit_whitelist) + setPositiveButton(android.R.string.yes) { _, _ -> + val newText = dialog?.textInput?.text.toString() + viewModel.onEntryEdited(entry, UserWhitelistedDomain(newText)) + } + setNegativeButton(android.R.string.no) { _, _ -> } + }.create() + + dialog?.dismiss() + dialog = editDialog + editDialog.show() + + editDialog.textInput.setText(entry.domain) + editDialog.textInput.setSelection(entry.domain.length) + } + + private fun showDeleteDialog(entry: UserWhitelistedDomain) { + val deleteDialog = AlertDialog.Builder(this).apply { + setTitle(R.string.dialogConfirmTitle) + setMessage(getString(R.string.whitelistEntryDeleteConfirmMessage, entry.domain).html(this.context)) + setPositiveButton(android.R.string.yes) { _, _ -> viewModel.onEntryDeleted(entry) } + setNegativeButton(android.R.string.no) { _, _ -> } + }.create() + + dialog?.dismiss() + dialog = deleteDialog + deleteDialog.show() + } + + private fun showWhitelistFormatError() { + Toast.makeText(this, R.string.whitelistFormatError, Toast.LENGTH_LONG).show() + } + + override fun onDestroy() { + dialog?.dismiss() + super.onDestroy() + } + + companion object { + fun intent(context: Context): Intent { + return Intent(context, WhitelistActivity::class.java) + } + } + + class WhitelistAdapter(private val viewModel: WhitelistViewModel) : Adapter() { + + var entries: List = emptyList() + set(value) { + field = value + notifyDataSetChanged() + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): WhitelistViewHolder { + val inflater = LayoutInflater.from(parent.context) + val view = inflater.inflate(R.layout.view_whitelist_entry, parent, false) + return WhitelistViewHolder(view, view.domain, view.overflowMenu) + } + + override fun onBindViewHolder(holder: WhitelistViewHolder, position: Int) { + val entry = entries[position] + holder.domain.text = entry.domain + holder.root.setOnClickListener { + viewModel.onEditRequested(entry) + } + + val overflowContentDescription = holder.overflowMenu.context.getString(R.string.whitelistEntryOverflowContentDescription, entry.domain) + holder.overflowMenu.contentDescription = overflowContentDescription + holder.overflowMenu.setOnClickListener { + showOverflowMenu(holder.overflowMenu, entry) + } + } + + private fun showOverflowMenu(overflowMenu: ImageView, entry: UserWhitelistedDomain) { + val popup = PopupMenu(overflowMenu.context, overflowMenu) + popup.inflate(R.menu.whitelist_individual_overflow_menu) + popup.setOnMenuItemClickListener { + when (it.itemId) { + R.id.edit -> { + viewModel.onEditRequested(entry) + true + } + R.id.delete -> { + viewModel.onDeleteRequested(entry) + true + } + else -> false + } + } + popup.show() + } + + override fun getItemCount(): Int { + return entries.size + } + } + + class WhitelistViewHolder( + val root: View, + val domain: TextView, + val overflowMenu: ImageView + ) : RecyclerView.ViewHolder(root) +} \ No newline at end of file diff --git a/app/src/main/java/com/duckduckgo/app/privacy/ui/WhitelistViewModel.kt b/app/src/main/java/com/duckduckgo/app/privacy/ui/WhitelistViewModel.kt new file mode 100644 index 000000000000..5d9dce0027cb --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/privacy/ui/WhitelistViewModel.kt @@ -0,0 +1,120 @@ +/* + * 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.privacy.ui + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.Observer +import androidx.lifecycle.ViewModel +import com.duckduckgo.app.global.DefaultDispatcherProvider +import com.duckduckgo.app.global.DispatcherProvider +import com.duckduckgo.app.global.SingleLiveEvent +import com.duckduckgo.app.global.UriString +import com.duckduckgo.app.privacy.db.UserWhitelistDao +import com.duckduckgo.app.privacy.model.UserWhitelistedDomain +import com.duckduckgo.app.privacy.ui.WhitelistViewModel.Command.* +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class WhitelistViewModel( + private val dao: UserWhitelistDao, + private val dispatchers: DispatcherProvider = DefaultDispatcherProvider() +) : ViewModel() { + + data class ViewState( + val showWhitelist: Boolean = true, + val whitelist: List = emptyList() + ) + + sealed class Command { + object ShowAdd : Command() + class ShowEdit(val entry: UserWhitelistedDomain) : Command() + class ConfirmDelete(val entry: UserWhitelistedDomain) : Command() + object ShowWhitelistFormatError : Command() + } + + val viewState: MutableLiveData = MutableLiveData() + val command: SingleLiveEvent = SingleLiveEvent() + + private val entries: LiveData> = dao.all() + private val observer = Observer> { onUserWhitelistChanged(it!!) } + + init { + viewState.value = ViewState() + entries.observeForever(observer) + } + + override fun onCleared() { + super.onCleared() + entries.removeObserver(observer) + } + + private fun onUserWhitelistChanged(entries: List) { + viewState.value = viewState.value?.copy( + showWhitelist = entries.isNotEmpty(), + whitelist = entries + ) + } + + fun onAddRequested() { + command.value = ShowAdd + } + + fun onEntryAdded(entry: UserWhitelistedDomain) { + if (!UriString.isValidDomain(entry.domain)) { + command.value = ShowWhitelistFormatError + return + } + GlobalScope.launch(dispatchers.io()) { + addEntryToDatabase(entry) + } + } + + fun onEditRequested(entry: UserWhitelistedDomain) { + command.value = ShowEdit(entry) + } + + fun onEntryEdited(old: UserWhitelistedDomain, new: UserWhitelistedDomain) { + if (!UriString.isValidDomain(new.domain)) { + command.value = ShowWhitelistFormatError + return + } + GlobalScope.launch(dispatchers.io()) { + deleteEntryFromDatabase(old) + addEntryToDatabase(new) + } + } + + fun onDeleteRequested(entry: UserWhitelistedDomain) { + command.value = ConfirmDelete(entry) + } + + fun onEntryDeleted(entry: UserWhitelistedDomain) { + GlobalScope.launch(dispatchers.io()) { + deleteEntryFromDatabase(entry) + } + } + + private suspend fun addEntryToDatabase(entry: UserWhitelistedDomain) { + withContext(dispatchers.io()) { dao.insert(entry) } + } + + private suspend fun deleteEntryFromDatabase(entry: UserWhitelistedDomain) { + withContext(dispatchers.io()) { dao.delete(entry) } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/duckduckgo/app/settings/SettingsActivity.kt b/app/src/main/java/com/duckduckgo/app/settings/SettingsActivity.kt index 4e6026ed7ddb..9e1bcf87782f 100644 --- a/app/src/main/java/com/duckduckgo/app/settings/SettingsActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/settings/SettingsActivity.kt @@ -35,23 +35,17 @@ import com.duckduckgo.app.global.DuckDuckGoActivity import com.duckduckgo.app.global.sendThemeChangedBroadcast import com.duckduckgo.app.global.view.launchDefaultAppActivity import com.duckduckgo.app.icon.ui.ChangeIconActivity +import com.duckduckgo.app.privacy.ui.WhitelistActivity import com.duckduckgo.app.settings.SettingsViewModel.AutomaticallyClearData import com.duckduckgo.app.settings.SettingsViewModel.Command import com.duckduckgo.app.settings.clear.ClearWhatOption import com.duckduckgo.app.settings.clear.ClearWhenOption import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.statistics.pixels.Pixel.PixelName -import kotlinx.android.synthetic.main.content_settings_general.autocompleteToggle -import kotlinx.android.synthetic.main.content_settings_general.changeAppIcon -import kotlinx.android.synthetic.main.content_settings_general.changeAppIconLabel -import kotlinx.android.synthetic.main.content_settings_general.lightThemeToggle -import kotlinx.android.synthetic.main.content_settings_general.setAsDefaultBrowserSetting -import kotlinx.android.synthetic.main.content_settings_other.about -import kotlinx.android.synthetic.main.content_settings_other.provideFeedback -import kotlinx.android.synthetic.main.content_settings_other.version -import kotlinx.android.synthetic.main.content_settings_privacy.automaticallyClearWhatSetting -import kotlinx.android.synthetic.main.content_settings_privacy.automaticallyClearWhenSetting -import kotlinx.android.synthetic.main.include_toolbar.toolbar +import kotlinx.android.synthetic.main.content_settings_general.* +import kotlinx.android.synthetic.main.content_settings_other.* +import kotlinx.android.synthetic.main.content_settings_privacy.* +import kotlinx.android.synthetic.main.include_toolbar.* import javax.inject.Inject class SettingsActivity : DuckDuckGoActivity(), SettingsAutomaticallyClearWhatFragment.Listener, SettingsAutomaticallyClearWhenFragment.Listener { @@ -95,6 +89,7 @@ class SettingsActivity : DuckDuckGoActivity(), SettingsAutomaticallyClearWhatFra setAsDefaultBrowserSetting.setOnCheckedChangeListener(defaultBrowserChangeListener) automaticallyClearWhatSetting.setOnClickListener { launchAutomaticallyClearWhatDialog() } automaticallyClearWhenSetting.setOnClickListener { launchAutomaticallyClearWhenDialog() } + whitelist.setOnClickListener { viewModel.onManageWhitelistSelected() } } private fun observeViewModel() { @@ -140,6 +135,7 @@ class SettingsActivity : DuckDuckGoActivity(), SettingsAutomaticallyClearWhatFra private fun processCommand(it: Command?) { when (it) { is Command.LaunchFeedback -> launchFeedback() + is Command.LaunchWhitelist -> launchWhitelist() is Command.LaunchAppIcon -> launchAppIconChange() is Command.UpdateTheme -> sendThemeChangedBroadcast() } @@ -167,6 +163,11 @@ class SettingsActivity : DuckDuckGoActivity(), SettingsAutomaticallyClearWhatFra startActivityForResult(Intent(FeedbackActivity.intent(this)), FEEDBACK_REQUEST_CODE, options) } + private fun launchWhitelist() { + val options = ActivityOptions.makeSceneTransitionAnimation(this).toBundle() + startActivity(WhitelistActivity.intent(this), options) + } + private fun launchAppIconChange() { val options = ActivityOptions.makeSceneTransitionAnimation(this).toBundle() startActivityForResult(Intent(ChangeIconActivity.intent(this)), CHANGE_APP_ICON_REQUEST_CODE, options) diff --git a/app/src/main/java/com/duckduckgo/app/settings/SettingsViewModel.kt b/app/src/main/java/com/duckduckgo/app/settings/SettingsViewModel.kt index 5f46f8c51c77..cbd1e84f3518 100644 --- a/app/src/main/java/com/duckduckgo/app/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/settings/SettingsViewModel.kt @@ -59,6 +59,7 @@ class SettingsViewModel @Inject constructor( sealed class Command { object LaunchFeedback : Command() + object LaunchWhitelist: Command() object LaunchAppIcon : Command() object UpdateTheme : Command() } @@ -164,6 +165,11 @@ class SettingsViewModel @Inject constructor( ) } + fun onManageWhitelistSelected() { + pixel.fire(SETTINGS_MANAGE_WHITELIST) + command.value = Command.LaunchWhitelist + } + private fun currentViewState(): ViewState { return viewState.value!! } diff --git a/app/src/main/java/com/duckduckgo/app/statistics/pixels/Pixel.kt b/app/src/main/java/com/duckduckgo/app/statistics/pixels/Pixel.kt index 1cf587d04e70..2e81ca74f834 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 @@ -70,6 +70,13 @@ interface Pixel { PRIVACY_DASHBOARD_GLOBAL_STATS("mp_s"), PRIVACY_DASHBOARD_PRIVACY_PRACTICES("mp_p"), PRIVACY_DASHBOARD_NETWORKS("mp_n"), + PRIVACY_DASHBOARD_WHITELIST_ADD("mp_wla"), + PRIVACY_DASHBOARD_WHITELIST_REMOVE("mp_wlr"), + PRIVACY_DASHBOARD_MANAGE_WHITELIST("mp_mw"), + PRIVACY_DASHBOARD_REPORT_BROKEN_SITE("mp_rb"), + + BROWSER_MENU_WHITELIST_ADD("mb_wla"), + BROWSER_MENU_WHITELIST_REMOVE("mb_wlr"), HTTPS_NO_LOOKUP("m_https_nl"), HTTPS_LOCAL_UPGRADE("m_https_lu"), @@ -78,9 +85,6 @@ interface Pixel { HTTPS_SERVICE_REQUEST_NO_UPGRADE("m_https_srn"), HTTPS_SERVICE_CACHE_NO_UPGRADE("m_https_scn"), - TRACKER_BLOCKER_DASHBOARD_TURNED_ON(pixelName = "m_tb_on_pd"), - TRACKER_BLOCKER_DASHBOARD_TURNED_OFF(pixelName = "m_tb_off_pd"), - DEFAULT_BROWSER_SET("m_db_s"), DEFAULT_BROWSER_NOT_SET("m_db_ns"), DEFAULT_BROWSER_UNSET("m_db_u"), @@ -117,6 +121,7 @@ interface Pixel { SETTINGS_OPENED("ms"), SETTINGS_THEME_TOGGLED_LIGHT("ms_tl"), SETTINGS_THEME_TOGGLED_DARK("ms_td"), + SETTINGS_MANAGE_WHITELIST("ms_mw"), SURVEY_CTA_SHOWN(pixelName = "mus_cs"), SURVEY_CTA_DISMISSED(pixelName = "mus_cd"), diff --git a/app/src/main/java/com/duckduckgo/app/trackerdetection/TrackerDataLoader.kt b/app/src/main/java/com/duckduckgo/app/trackerdetection/TrackerDataLoader.kt index ccf5964b6b9a..8113b1187b63 100644 --- a/app/src/main/java/com/duckduckgo/app/trackerdetection/TrackerDataLoader.kt +++ b/app/src/main/java/com/duckduckgo/app/trackerdetection/TrackerDataLoader.kt @@ -21,11 +21,7 @@ import androidx.annotation.WorkerThread import com.duckduckgo.app.browser.R import com.duckduckgo.app.global.db.AppDatabase import com.duckduckgo.app.trackerdetection.api.TdsJson -import com.duckduckgo.app.trackerdetection.db.TdsMetadataDao -import com.duckduckgo.app.trackerdetection.db.TdsDomainEntityDao -import com.duckduckgo.app.trackerdetection.db.TdsEntityDao -import com.duckduckgo.app.trackerdetection.db.TdsTrackerDao -import com.duckduckgo.app.trackerdetection.db.TemporaryTrackingWhitelistDao +import com.duckduckgo.app.trackerdetection.db.* import com.duckduckgo.app.trackerdetection.model.TdsMetadata import com.squareup.moshi.Moshi import timber.log.Timber diff --git a/app/src/main/java/com/duckduckgo/app/trackerdetection/TrackerDetector.kt b/app/src/main/java/com/duckduckgo/app/trackerdetection/TrackerDetector.kt index 2d42200c6763..70b39961c8f9 100644 --- a/app/src/main/java/com/duckduckgo/app/trackerdetection/TrackerDetector.kt +++ b/app/src/main/java/com/duckduckgo/app/trackerdetection/TrackerDetector.kt @@ -16,8 +16,9 @@ package com.duckduckgo.app.trackerdetection +import androidx.core.net.toUri import com.duckduckgo.app.global.UriString.Companion.sameOrSubdomain -import com.duckduckgo.app.privacy.store.PrivacySettingsStore +import com.duckduckgo.app.privacy.db.UserWhitelistDao import com.duckduckgo.app.trackerdetection.model.TrackingEvent import timber.log.Timber import java.util.concurrent.CopyOnWriteArrayList @@ -29,7 +30,7 @@ interface TrackerDetector { class TrackerDetectorImpl( private val entityLookup: EntityLookup, - private val settings: PrivacySettingsStore + private val userWhitelistDao: UserWhitelistDao ) : TrackerDetector { private val clients = CopyOnWriteArrayList() @@ -62,7 +63,7 @@ class TrackerDetectorImpl( if (result != null) { Timber.v("$documentUrl resource $url WAS identified as a tracker") val entity = if (result.entityName != null) entityLookup.entityForName(result.entityName) else null - return TrackingEvent(documentUrl, url, result.categories, entity, settings.privacyOn) + return TrackingEvent(documentUrl, url, result.categories, entity, !userWhitelistDao.isDocumentWhitelisted(documentUrl)) } Timber.v("$documentUrl resource $url was not identified as a tracker") @@ -83,4 +84,11 @@ class TrackerDetectorImpl( } val clientCount get() = clients.count() +} + +private fun UserWhitelistDao.isDocumentWhitelisted(document: String?): Boolean { + document?.toUri()?.host?.let { + return contains(it) + } + return false } \ No newline at end of file diff --git a/app/src/main/java/com/duckduckgo/app/trackerdetection/di/TrackerDetectionModule.kt b/app/src/main/java/com/duckduckgo/app/trackerdetection/di/TrackerDetectionModule.kt index d9df02dc2158..b34d2b72a520 100644 --- a/app/src/main/java/com/duckduckgo/app/trackerdetection/di/TrackerDetectionModule.kt +++ b/app/src/main/java/com/duckduckgo/app/trackerdetection/di/TrackerDetectionModule.kt @@ -16,7 +16,7 @@ package com.duckduckgo.app.trackerdetection.di -import com.duckduckgo.app.privacy.store.PrivacySettingsStore +import com.duckduckgo.app.privacy.db.UserWhitelistDao import com.duckduckgo.app.trackerdetection.EntityLookup import com.duckduckgo.app.trackerdetection.TrackerDetector import com.duckduckgo.app.trackerdetection.TrackerDetectorImpl @@ -30,7 +30,7 @@ class TrackerDetectionModule { @Provides @Singleton - fun trackerDetector(entityLookup: EntityLookup, settings: PrivacySettingsStore): TrackerDetector { - return TrackerDetectorImpl(entityLookup, settings) + fun trackerDetector(entityLookup: EntityLookup, userWhitelistDao: UserWhitelistDao): TrackerDetector { + return TrackerDetectorImpl(entityLookup, userWhitelistDao) } } \ No newline at end of file diff --git a/app/src/main/res/layout/activity_whitelist.xml b/app/src/main/res/layout/activity_whitelist.xml new file mode 100644 index 000000000000..91b131f77bf9 --- /dev/null +++ b/app/src/main/res/layout/activity_whitelist.xml @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/content_privacy_dashboard.xml b/app/src/main/res/layout/content_privacy_dashboard.xml index 23a81d865f4e..a96974485952 100644 --- a/app/src/main/res/layout/content_privacy_dashboard.xml +++ b/app/src/main/res/layout/content_privacy_dashboard.xml @@ -47,7 +47,8 @@ android:id="@+id/httpsContainer" android:layout_width="match_parent" android:layout_height="wrap_content" - android:onClick="onEncryptionClicked"> + android:onClick="onEncryptionClicked" + app:layout_constraintBottom_toTopOf="@id/networksContainer"> + + + + + + + + + + + app:layout_constraintTop_toBottomOf="@id/buttonContainer"> + + + \ No newline at end of file diff --git a/app/src/main/res/layout/edit_whitelist.xml b/app/src/main/res/layout/edit_whitelist.xml new file mode 100644 index 000000000000..1681f9718ba1 --- /dev/null +++ b/app/src/main/res/layout/edit_whitelist.xml @@ -0,0 +1,37 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/popup_window_browser_bottom_tab_menu.xml b/app/src/main/res/layout/popup_window_browser_bottom_tab_menu.xml index fd89068a7a8a..3d668afe269f 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 @@ -76,6 +76,11 @@ android:paddingEnd="0dp" android:text="@string/requestDesktopSiteMenuTitle" /> + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/whitelist_activity_menu.xml b/app/src/main/res/menu/whitelist_activity_menu.xml new file mode 100644 index 000000000000..c3809420cda1 --- /dev/null +++ b/app/src/main/res/menu/whitelist_activity_menu.xml @@ -0,0 +1,28 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/whitelist_individual_overflow_menu.xml b/app/src/main/res/menu/whitelist_individual_overflow_menu.xml new file mode 100644 index 000000000000..d66fde37780b --- /dev/null +++ b/app/src/main/res/menu/whitelist_individual_overflow_menu.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml index f2899a4ee0d6..0e19d7bfdd32 100644 --- a/app/src/main/res/values-bg/strings.xml +++ b/app/src/main/res/values-bg/strings.xml @@ -189,11 +189,11 @@ Отметки Отметки Наистина ли искате да изтриете отметка <b>%s</b>? - Потвърдете + Потвърдете Добавена е отметка Заглавие на отметката URL адрес на отметката - Запазване + Запазване Редактиране на отметка Все още няма добавени отметки Още опции за отметка %s diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 870cb5ddd88c..838a163c7759 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -193,11 +193,11 @@ Záložky Záložky Opravdu chcete smazat záložku <b>%s</b>? - Potvrdit + Potvrdit Záložka přidána Název záložky URL záložky - Uložit + Uložit Upravit záložku Zatím nebyly přidány žádné záložky. Další možnosti záložky %s diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 863215a75f78..069d0a5cc435 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -189,11 +189,11 @@ Bogmærker Bogmærker Er du sikker på, at du vil slette bogmærket <b>%s</b>? - Bekræft + Bekræft Bogmærke tilføjet Titel på bogmærke Bogmærkets URL - Gem + Gem Rediger bogmærke Ingen bogmærker tilføjet endnu Flere muligheder for bogmærke %s diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 2367fdc1622f..c293f9a66f2e 100755 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -189,11 +189,11 @@ Lesezeichen Lesezeichen Bist du sicher, dass du das Lesezeichen <b>%s</b> löschen möchtest? - Bestätigen + Bestätigen Lesezeichen hinzugefügt Lesezeichen-Titel Lesezeichen-URL - Speichern + Speichern Lesezeichen bearbeiten Noch keine Lesezeichen hinzugefügt Mehr Optionen für Lesezeichen %s diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index a6dfaef0a2ba..8495965059d9 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -189,11 +189,11 @@ Σελιδοδείκτες Σελιδοδείκτες Θέλετε σίγουρα να διαγράψετε τον σελιδοδείκτη <b>%s</b>; - Επιβεβαίωση + Επιβεβαίωση Ο σελιδοδείκτης προστέθηκε Τίτλος σελιδοδείκτη Διεύθυνση URL σελιδοδείκτη - Αποθήκευση + Αποθήκευση Επεξεργασία σελιδοδείκτη Δεν προστέθηκαν σελιδοδείκτες ακόμα Περισσότερες επιλογές για σελιδοδείκτη %s diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 0cfb064b144c..79209198d4f4 100755 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -189,11 +189,11 @@ Marcadores Marcadores ¿Estás seguro de que quieres eliminar el marcador <b>%s</b>? - Confirmar + Confirmar Marcador añadido Título del marcador URL del marcador - Guardar + Guardar Editar marcador No se han añadido marcadores todavía Más opciones para el marcador %s diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index e2f55f3e9b2a..963394d12e25 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -207,11 +207,11 @@ Järjehoidjad Järjehoidjad Kas soovite kindlasti kustutada järjehoidja <b>%s</b>? - Kinnitage + Kinnitage Järjehoidja lisatud Järjehoidja pealkiri Järjehoidja URL - Salvesta + Salvesta Redigeeri järjehoidjat Järjehoidjaid pole veel lisatud Veel järjehoidja %s valikuid diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index f7df4fc3585d..8de108a85b46 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -189,11 +189,11 @@ Kirjanmerkit Kirjanmerkit Haluatko varmasti poistaa kirjanmerkin <b>%s</b>? - Vahvista + Vahvista Kirjanmerkki lisätty Kirjanmerkin otsikko Kirjanmerkin URL-osoite - Tallenna + Tallenna Muokkaa kirjanmerkkiä Kirjanmerkkejä ei ole vielä lisätty Lisää vaihtoehtoja kirjanmerkille %s diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index eae8baf7747e..11d6db7f7201 100755 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -189,11 +189,11 @@ Signets Signets Êtes-vous sûr(e) de vouloir supprimer le signet <b>%s</b> ? - Confirmer + Confirmer Signet ajouté Titre du signet URL du signet - Enregistrer + Enregistrer Modifier le signet Aucun signet ajouté pour le moment Plus d\'options pour le signet %s diff --git a/app/src/main/res/values-hr/strings.xml b/app/src/main/res/values-hr/strings.xml index 50e8d2f8f3b3..590fe17773da 100644 --- a/app/src/main/res/values-hr/strings.xml +++ b/app/src/main/res/values-hr/strings.xml @@ -193,11 +193,11 @@ Knjižne oznake Knjižne oznake Sigurno želite izbrisati knjižnu oznaku <b>%s</b>? - Potvrdi + Potvrdi Knjižna oznaka je dodana Naslov knjižne oznake URL knjižne oznake - Spremi + Spremi Uredi knjižnu oznaku Još nema dodanih knjižnih oznaka Više opcija za knjižnu oznaku %s diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index fac8c1d04b86..1982f3e5284d 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -189,11 +189,11 @@ Könyvjelzők Könyvjelzők Biztos, hogy törlöd a(z) <b>%s</b> könyvjelzőt? - Megerősítés + Megerősítés Könyvjelző hozzáadva Könyvjelző címe Könyvjelző URL - Mentés + Mentés Könyvjelző szerkesztése Még nincsenek könyvjelzők hozzáadva %s könyvjelző további lehetőségei diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 7d377f896150..fe6b99819dc7 100755 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -205,11 +205,11 @@ Segnalibri Segnalibri Sei sicuro di voler eliminare il segnalibro <b>%s</b>? - Conferma + Conferma Segnalibro aggiunto Titolo del segnalibro URL del segnalibro - Salva + Salva Modifica segnalibro Non è ancora stato aggiunto nessun segnalibro Ulteriori opzioni per il segnalibro %s diff --git a/app/src/main/res/values-lt/strings.xml b/app/src/main/res/values-lt/strings.xml index c67aaa0bb38f..8d62bb4fb508 100644 --- a/app/src/main/res/values-lt/strings.xml +++ b/app/src/main/res/values-lt/strings.xml @@ -193,11 +193,11 @@ Žymelės Žymelės Ar tikrai norite ištrinti žymelę <b>%s</b>? - Patvirtinti + Patvirtinti Žymelė pridėta Žymelės antraštė Žymelės URL - Išsaugoti + Išsaugoti Redaguoti žymelę Žymelių dar nepridėta Daugiau parinkčių žymelei %s diff --git a/app/src/main/res/values-lv/strings.xml b/app/src/main/res/values-lv/strings.xml index 7072f9c95db2..362178164fc4 100644 --- a/app/src/main/res/values-lv/strings.xml +++ b/app/src/main/res/values-lv/strings.xml @@ -211,11 +211,11 @@ Grāmatzīmes Grāmatzīmes Vai tiešām vēlaties izdzēst grāmatzīmi <b>%s</b>? - Apstiprināt + Apstiprināt Pievienota grāmatzīme Grāmatzīmes nosaukums Grāmatzīmju URL - Saglabāt + Saglabāt Rediģēt grāmatzīmi Pagaidām nav pievienota neviena grāmatzīme Vairāk iespēju grāmatzīmei %s diff --git a/app/src/main/res/values-nb/strings.xml b/app/src/main/res/values-nb/strings.xml index fb0ab3abbc42..3f057d105950 100644 --- a/app/src/main/res/values-nb/strings.xml +++ b/app/src/main/res/values-nb/strings.xml @@ -189,11 +189,11 @@ Bokmerker Bokmerker Er du sikker på at du vil slette bokmerket <b>%s</b>? - Bekreft + Bekreft Bokmerke lagt til Bokmerketittel Bokmerke-URL - Lagre + Lagre Rediger bokmerke Ingen bokmerker lagt til ennå Flere alternativer for bokmerket %s diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 63c6871a20c9..1530690f127f 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -189,11 +189,11 @@ Bladwijzers Bladwijzers Weet je zeker dat je bladwijzer <b>%s</b> wilt verwijderen? - Bevestigen + Bevestigen Bladwijzer toegevoegd Naam bladwijzer URL bladwijzer - Opslaan + Opslaan Bladwijzer bewerken Nog geen bladwijzers toegevoegd Meer opties voor bladwijzer %s diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 8e6146cac7d0..3ae67b4886d2 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -197,11 +197,11 @@ Zakładki Zakładki Czy na pewno chcesz usunąć zakładkę <b>%s</b>? - Potwierdź + Potwierdź Zakładka została dodana Tytuł zakładki Adres URL zakładki - Zapisz + Zapisz Edytuj zakładkę Nie dodano jeszcze żadnych zakładek Więcej opcji dla zakładki %s diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index fa362009ab84..cf568ad1fe2f 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -189,11 +189,11 @@ Marcadores Marcadores Tem a certeza de que pretende eliminar o marcador <b>%s</b>? - Confirmar + Confirmar Marcador adicionado Título do marcador URL do marcador - Guardar + Guardar Editar marcador Ainda não foram adicionados marcadores Mais opções para o marcador %s diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index 828c398f3157..8a217986f3a3 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -193,11 +193,11 @@ Semne de carte Semne de carte Sunteți sigur că doriți să ștergeți semnul de carte <b>%s</b>? - Confirmare + Confirmare Semn de carte adăugat Titlu semn de carte Adresă URL semn de carte - Salvare + Salvare Editare semn de carte Nu s-a adăugat niciun semn de carte Mai multe opțiuni pentru semnul de carte %s diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 9c917e68e92d..98c9a046bc63 100755 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -212,11 +212,11 @@ Закладки Закладки Вы действительно хотите удалить закладку <b>%s</b>? - Подтвердить + Подтвердить Закладка добавлена Название закладки URL закладки - Сохранить + Сохранить Редактировать закладку Закладок пока нет Другие параметры управления закладкой %s diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml index dc3d9d69e352..3b2369eb21a6 100644 --- a/app/src/main/res/values-sk/strings.xml +++ b/app/src/main/res/values-sk/strings.xml @@ -193,11 +193,11 @@ Záložky Záložky Naozaj chcete odstrániť záložku <b>%s</b>? - Potvrdiť + Potvrdiť Záložka je pridaná Názov záložky Webová adresa záložky - Uložiť + Uložiť Upraviť záložku Zatiaľ neboli pridané žiadne záložky Viac možností pre záložku %s diff --git a/app/src/main/res/values-sl/strings.xml b/app/src/main/res/values-sl/strings.xml index 5e6f2f54eb32..8d2cb4d444e5 100644 --- a/app/src/main/res/values-sl/strings.xml +++ b/app/src/main/res/values-sl/strings.xml @@ -197,11 +197,11 @@ Zaznamki Zaznamki Ste prepričani, da želite izbrisati zaznamek <b>%s</b>? - Potrdi + Potrdi Zaznamek je dodan Naslov zaznamka URL zaznamka - Shrani + Shrani Uredi zaznamek Zaznamki še niso dodani Več možnosti za zaznamek %s diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 7922bde40558..3813081f1685 100755 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -205,11 +205,11 @@ Bokmärken Bokmärken Är du säker på att du vill radera bokmärket <b>%s</b>? - Bekräfta + Bekräfta Bokmärket har lagts till Bokmärkesrubrik Bokmärkets URL - Spara + Spara Redigera bokmärke Inga bokmärken har lagts till ännu Fler alternativ för bokmärke %s diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index ed61c4a1961e..79d3108efc39 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -210,11 +210,11 @@ Yer imleri Yer imleri <b>%s</b> yer imini silmek istediğinizden emin misiniz? - Onayla + Onayla Yer imi eklendi Yer imi başlığı Yer imi URL\'si - Kaydet + Kaydet Yer İşaretini Düzenle Henüz yer işareti eklenmedi %s yer imi için diğer seçenekler diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml index 39b958522514..e900106ba74c 100644 --- a/app/src/main/res/values/attrs.xml +++ b/app/src/main/res/values/attrs.xml @@ -35,6 +35,7 @@ + diff --git a/app/src/main/res/values/string-untranslated.xml b/app/src/main/res/values/string-untranslated.xml index dd63faa8d0c7..bb147d602d09 100644 --- a/app/src/main/res/values/string-untranslated.xml +++ b/app/src/main/res/values/string-untranslated.xml @@ -32,4 +32,31 @@ Apply New Icon? The app may close to apply changes. Come on back after you\'ve admired your handsome new icon. + + Site Privacy Protection + SITE PROTECTION ENABLED + SITE PROTECTION DISABLED + + + Manage Whitelist + Report Broken Site + + + Add + Edit + + + Whitelist + Add to Whitelist + Remove from Whitelist + More options for whitelist entry %s + Are you sure you want to delete <b>%s</b> from whitelist? + These whitelisted sites will not be upgraded by Privacy Protection + No whitelisted sites yet + www.example.com + Could not save, enter a domain like example.com + + + Privacy Protection Whitelist + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5f40bca8b3c0..43e4bdfefd92 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -78,8 +78,6 @@ Privacy Dashboard - PRIVACY PROTECTION ENABLED - PRIVACY PROTECTION DISABLED ENHANCED FROM <img src="%d" /> TO <img src="%d" /> Privacy Grade @@ -125,7 +123,6 @@ Privacy Practices results from ToS;DR Good Practice Icon Bad Practice Icon - Privacy Protection TRACKER NETWORK TOP OFFENDERS We\'re still collecting data to show how many trackers we\'ve blocked. @@ -196,17 +193,19 @@ Bookmarks Bookmarks Are you sure you want to delete bookmark <b>%s</b>? - Confirm Bookmark added Bookmark title Bookmark URL - Save Edit Bookmark No bookmarks added yet More options for bookmark %s + Bookmark added + + + Confirm + Save Delete Edit - Bookmark added Search DuckDuckGo diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 2a9f7d3271b6..e55ffc510dd6 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -55,6 +55,7 @@ @color/skyBlue @color/white @color/almostBlack + @color/white @color/white @color/white @color/grayishTwo @@ -128,6 +129,7 @@ @color/cornflowerBlue @color/warmerGray @color/white + @color/almostBlack @color/grayishBrown @color/almostBlack @color/warmerGray