diff --git a/app/schemas/com.duckduckgo.app.global.db.AppDatabase/31.json b/app/schemas/com.duckduckgo.app.global.db.AppDatabase/31.json new file mode 100644 index 000000000000..b87f96f614d8 --- /dev/null +++ b/app/schemas/com.duckduckgo.app.global.db.AppDatabase/31.json @@ -0,0 +1,866 @@ +{ + "formatVersion": 1, + "database": { + "version": 31, + "identityHash": "be6380785dcbf8f6793f852097f0d224", + "entities": [ + { + "tableName": "tds_tracker", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`domain` TEXT NOT NULL, `defaultAction` TEXT NOT NULL, `ownerName` TEXT NOT NULL, `categories` TEXT NOT NULL, `rules` TEXT NOT NULL, PRIMARY KEY(`domain`))", + "fields": [ + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaultAction", + "columnName": "defaultAction", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ownerName", + "columnName": "ownerName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "categories", + "columnName": "categories", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rules", + "columnName": "rules", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "domain" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "tds_entity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `displayName` TEXT NOT NULL, `prevalence` REAL NOT NULL, PRIMARY KEY(`name`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "prevalence", + "columnName": "prevalence", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "name" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "tds_domain_entity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`domain` TEXT NOT NULL, `entityName` TEXT NOT NULL, PRIMARY KEY(`domain`))", + "fields": [ + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "entityName", + "columnName": "entityName", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "domain" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "temporary_tracking_whitelist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`domain` TEXT NOT NULL, PRIMARY KEY(`domain`))", + "fields": [ + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "domain" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "user_whitelist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`domain` TEXT NOT NULL, PRIMARY KEY(`domain`))", + "fields": [ + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "domain" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "https_bloom_filter_spec", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `bitCount` INTEGER NOT NULL, `errorRate` REAL NOT NULL, `totalEntries` INTEGER NOT NULL, `sha256` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bitCount", + "columnName": "bitCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "errorRate", + "columnName": "errorRate", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "totalEntries", + "columnName": "totalEntries", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sha256", + "columnName": "sha256", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "https_false_positive_domain", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`domain` TEXT NOT NULL, PRIMARY KEY(`domain`))", + "fields": [ + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "domain" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "network_leaderboard", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkName` TEXT NOT NULL, `count` INTEGER NOT NULL, PRIMARY KEY(`networkName`))", + "fields": [ + { + "fieldPath": "networkName", + "columnName": "networkName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "count", + "columnName": "count", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "networkName" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "sites_visited", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `count` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "count", + "columnName": "count", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "key" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "tabs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tabId` TEXT NOT NULL, `url` TEXT, `title` TEXT, `skipHome` INTEGER NOT NULL, `viewed` INTEGER NOT NULL, `position` INTEGER NOT NULL, `tabPreviewFile` TEXT, `sourceTabId` TEXT, `deletable` INTEGER NOT NULL, PRIMARY KEY(`tabId`), FOREIGN KEY(`sourceTabId`) REFERENCES `tabs`(`tabId`) ON UPDATE SET NULL ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "tabId", + "columnName": "tabId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "skipHome", + "columnName": "skipHome", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewed", + "columnName": "viewed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tabPreviewFile", + "columnName": "tabPreviewFile", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sourceTabId", + "columnName": "sourceTabId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "deletable", + "columnName": "deletable", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "tabId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_tabs_tabId", + "unique": false, + "columnNames": [ + "tabId" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tabs_tabId` ON `${TABLE_NAME}` (`tabId`)" + } + ], + "foreignKeys": [ + { + "table": "tabs", + "onDelete": "SET NULL", + "onUpdate": "SET NULL", + "columns": [ + "sourceTabId" + ], + "referencedColumns": [ + "tabId" + ] + } + ] + }, + { + "tableName": "tab_selection", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `tabId` TEXT, PRIMARY KEY(`id`), FOREIGN KEY(`tabId`) REFERENCES `tabs`(`tabId`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tabId", + "columnName": "tabId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_tab_selection_tabId", + "unique": false, + "columnNames": [ + "tabId" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tab_selection_tabId` ON `${TABLE_NAME}` (`tabId`)" + } + ], + "foreignKeys": [ + { + "table": "tabs", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "tabId" + ], + "referencedColumns": [ + "tabId" + ] + } + ] + }, + { + "tableName": "bookmarks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT, `url` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "survey", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`surveyId` TEXT NOT NULL, `url` TEXT, `daysInstalled` INTEGER, `status` TEXT NOT NULL, PRIMARY KEY(`surveyId`))", + "fields": [ + { + "fieldPath": "surveyId", + "columnName": "surveyId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "daysInstalled", + "columnName": "daysInstalled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "surveyId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "dismissed_cta", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`ctaId` TEXT NOT NULL, PRIMARY KEY(`ctaId`))", + "fields": [ + { + "fieldPath": "ctaId", + "columnName": "ctaId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "ctaId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "search_count", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `count` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "count", + "columnName": "count", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "key" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "app_days_used", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`date` TEXT NOT NULL, PRIMARY KEY(`date`))", + "fields": [ + { + "fieldPath": "date", + "columnName": "date", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "date" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "app_enjoyment", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`eventType` INTEGER NOT NULL, `promptCount` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `primaryKey` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", + "fields": [ + { + "fieldPath": "eventType", + "columnName": "eventType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "promptCount", + "columnName": "promptCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "primaryKey", + "columnName": "primaryKey", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "primaryKey" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "notification", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`notificationId` TEXT NOT NULL, PRIMARY KEY(`notificationId`))", + "fields": [ + { + "fieldPath": "notificationId", + "columnName": "notificationId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "notificationId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "privacy_protection_count", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `blocked_tracker_count` INTEGER NOT NULL, `upgrade_count` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "blockedTrackerCount", + "columnName": "blocked_tracker_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "upgradeCount", + "columnName": "upgrade_count", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "key" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "UncaughtExceptionEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `exceptionSource` TEXT NOT NULL, `message` TEXT NOT NULL, `version` TEXT NOT NULL, `timestamp` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "exceptionSource", + "columnName": "exceptionSource", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "tdsMetadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `eTag` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "eTag", + "columnName": "eTag", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "userStage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` INTEGER NOT NULL, `appStage` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "appStage", + "columnName": "appStage", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "key" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "fireproofWebsites", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`domain` TEXT NOT NULL, PRIMARY KEY(`domain`))", + "fields": [ + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "domain" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "user_events", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "locationPermissions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`domain` TEXT NOT NULL, `permission` INTEGER NOT NULL, PRIMARY KEY(`domain`))", + "fields": [ + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "permission", + "columnName": "permission", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "domain" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "pixel_store", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `pixelName` TEXT NOT NULL, `atb` TEXT NOT NULL, `additionalQueryParams` TEXT NOT NULL, `encodedQueryParams` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pixelName", + "columnName": "pixelName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "atb", + "columnName": "atb", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "additionalQueryParams", + "columnName": "additionalQueryParams", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "encodedQueryParams", + "columnName": "encodedQueryParams", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "auth_cookies_allowed_domains", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`domain` TEXT NOT NULL, PRIMARY KEY(`domain`))", + "fields": [ + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "domain" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'be6380785dcbf8f6793f852097f0d224')" + ] + } +} \ 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 06aca8461993..e73fe89580c3 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt @@ -114,6 +114,8 @@ import io.reactivex.Observable import io.reactivex.Single import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.consumeAsFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.runBlocking @@ -173,7 +175,7 @@ class BrowserTabViewModelTest { private lateinit var mockOmnibarConverter: OmnibarEntryConverter @Mock - private lateinit var mockTabsRepository: TabRepository + private lateinit var mockTabRepository: TabRepository @Mock private lateinit var webViewSessionStorage: WebViewSessionStorage @@ -229,9 +231,6 @@ class BrowserTabViewModelTest { @Mock private lateinit var mockFileDownloader: FileDownloader - @Mock - private lateinit var mockTabRepository: TabRepository - @Mock private lateinit var geoLocationPermissions: GeoLocationPermissions @@ -263,6 +262,10 @@ class BrowserTabViewModelTest { private val dismissedCtaDaoChannel = Channel>() + private val childClosedTabsSharedFlow = MutableSharedFlow() + + private val childClosedTabsFlow = childClosedTabsSharedFlow.asSharedFlow() + @Before fun before() { MockitoAnnotations.openMocks(this) @@ -299,9 +302,10 @@ class BrowserTabViewModelTest { whenever(mockOmnibarConverter.convertQueryToUrl(any(), any())).thenReturn("duckduckgo.com") whenever(mockVariantManager.getVariant()).thenReturn(DEFAULT_VARIANT) - whenever(mockTabsRepository.liveSelectedTab).thenReturn(selectedTabLiveData) + whenever(mockTabRepository.liveSelectedTab).thenReturn(selectedTabLiveData) whenever(mockNavigationAwareLoginDetector.loginEventLiveData).thenReturn(loginEventLiveData) - whenever(mockTabsRepository.retrieveSiteData(any())).thenReturn(MutableLiveData()) + whenever(mockTabRepository.retrieveSiteData(any())).thenReturn(MutableLiveData()) + whenever(mockTabRepository.childClosedTabs).thenReturn(childClosedTabsFlow) whenever(mockPrivacyPractices.privacyPracticesFor(any())).thenReturn(PrivacyPractices.UNKNOWN) whenever(mockAppInstallStore.installTimestamp).thenReturn(System.currentTimeMillis() - TimeUnit.DAYS.toMillis(1)) whenever(mockUserWhitelistDao.contains(anyString())).thenReturn(false) @@ -312,7 +316,7 @@ class BrowserTabViewModelTest { queryUrlConverter = mockOmnibarConverter, duckDuckGoUrlDetector = DuckDuckGoUrlDetector(), siteFactory = siteFactory, - tabRepository = mockTabsRepository, + tabRepository = mockTabRepository, userWhitelistDao = mockUserWhitelistDao, networkLeaderboardDao = mockNetworkLeaderboardDao, autoComplete = mockAutoCompleteApi, @@ -389,7 +393,7 @@ class BrowserTabViewModelTest { verify(mockCommandObserver, atLeastOnce()).onChanged(commandCaptor.capture()) assertTrue(commandCaptor.lastValue is Command.OpenInNewBackgroundTab) - verify(mockTabsRepository).addNewTabAfterExistingTab(url, "abc") + verify(mockTabRepository).addNewTabAfterExistingTab(url, "abc") } @Test @@ -553,7 +557,7 @@ class BrowserTabViewModelTest { testee.onUserSubmittedQuery("foo") coroutineRule.runBlocking { - verify(mockTabsRepository).deleteTabAndSelectSource(selectedTabLiveData.value!!.tabId) + verify(mockTabRepository).deleteTabAndSelectSource(selectedTabLiveData.value!!.tabId) } } @@ -1218,7 +1222,7 @@ class BrowserTabViewModelTest { testee.onRefreshRequested() coroutineRule.runBlocking { - verify(mockTabsRepository).deleteTabAndSelectSource(selectedTabLiveData.value!!.tabId) + verify(mockTabRepository).deleteTabAndSelectSource(selectedTabLiveData.value!!.tabId) } } @@ -1519,7 +1523,7 @@ class BrowserTabViewModelTest { showErrorWithAction.action() coroutineRule.runBlocking { - verify(mockTabsRepository).deleteTabAndSelectSource(selectedTabLiveData.value!!.tabId) + verify(mockTabRepository).deleteTabAndSelectSource(selectedTabLiveData.value!!.tabId) } } @@ -1691,7 +1695,7 @@ class BrowserTabViewModelTest { fun whenCloseCurrentTabSelectedThenTabDeletedFromRepository() = runBlocking { givenOneActiveTabSelected() testee.closeCurrentTab() - verify(mockTabsRepository).deleteTabAndSelectSource(selectedTabLiveData.value!!.tabId) + verify(mockTabRepository).deleteTabAndSelectSource(selectedTabLiveData.value!!.tabId) } @Test @@ -1723,7 +1727,7 @@ class BrowserTabViewModelTest { testee.onUserPressedBack() - verify(mockTabsRepository).deleteTabAndSelectSource("TAB_ID") + verify(mockTabRepository).deleteTabAndSelectSource("TAB_ID") } @Test @@ -2870,7 +2874,7 @@ class BrowserTabViewModelTest { testee.prefetchFavicon(url) - verify(mockTabsRepository).updateTabFavicon("TAB_ID", file.name) + verify(mockTabRepository).updateTabFavicon("TAB_ID", file.name) } @Test @@ -2879,7 +2883,7 @@ class BrowserTabViewModelTest { testee.prefetchFavicon("url") - verify(mockTabsRepository, never()).updateTabFavicon(any(), any()) + verify(mockTabRepository, never()).updateTabFavicon(any(), any()) } @Test @@ -2901,7 +2905,7 @@ class BrowserTabViewModelTest { testee.iconReceived(bitmap) - verify(mockTabsRepository).updateTabFavicon("TAB_ID", file.name) + verify(mockTabRepository).updateTabFavicon("TAB_ID", file.name) } @Test @@ -2912,7 +2916,7 @@ class BrowserTabViewModelTest { testee.iconReceived(bitmap) - verify(mockTabsRepository, never()).updateTabFavicon(any(), any()) + verify(mockTabRepository, never()).updateTabFavicon(any(), any()) } @Test @@ -3136,6 +3140,24 @@ class BrowserTabViewModelTest { } } + @Test + fun whenChildrenTabClosedIfViewModelIsParentThenChildTabClosedCommandSent() = coroutineRule.runBlocking { + givenOneActiveTabSelected() + + childClosedTabsSharedFlow.emit("TAB_ID") + + assertCommandIssued() + } + + @Test + fun whenChildrenTabClosedIfViewModelIsNotParentThenChildTabClosedCommandNotSent() = coroutineRule.runBlocking { + givenOneActiveTabSelected() + + childClosedTabsSharedFlow.emit("other_tab") + + assertCommandNotIssued() + } + private suspend fun givenFireButtonPulsing() { whenever(mockUserStageStore.getUserAppStage()).thenReturn(AppStage.DAX_ONBOARDING) dismissedCtaDaoChannel.send(listOf(DismissedCta(CtaId.DAX_DIALOG_TRACKERS_FOUND))) @@ -3222,7 +3244,7 @@ class BrowserTabViewModelTest { whenever(site.url).thenReturn(USE_OUR_APP_DOMAIN) val siteLiveData = MutableLiveData() siteLiveData.value = site - whenever(mockTabsRepository.retrieveSiteData("TAB_ID")).thenReturn(siteLiveData) + whenever(mockTabRepository.retrieveSiteData("TAB_ID")).thenReturn(siteLiveData) testee.loadData("TAB_ID", USE_OUR_APP_DOMAIN, false) } @@ -3232,7 +3254,7 @@ class BrowserTabViewModelTest { whenever(site.url).thenReturn("example.com") val siteLiveData = MutableLiveData() siteLiveData.value = site - whenever(mockTabsRepository.retrieveSiteData("TAB_ID")).thenReturn(siteLiveData) + whenever(mockTabRepository.retrieveSiteData("TAB_ID")).thenReturn(siteLiveData) testee.loadData("TAB_ID", "example.com", false) } @@ -3250,7 +3272,7 @@ class BrowserTabViewModelTest { whenever(site.uri).thenReturn(Uri.parse(domain)) val siteLiveData = MutableLiveData() siteLiveData.value = site - whenever(mockTabsRepository.retrieveSiteData("TAB_ID")).thenReturn(siteLiveData) + whenever(mockTabRepository.retrieveSiteData("TAB_ID")).thenReturn(siteLiveData) testee.loadData("TAB_ID", domain, false) } diff --git a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserWebViewClientTest.kt b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserWebViewClientTest.kt index 96c83ff0416e..381fe042eaf1 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserWebViewClientTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserWebViewClientTest.kt @@ -19,11 +19,13 @@ package com.duckduckgo.app.browser import android.content.Context import android.os.Build import android.webkit.* +import androidx.core.net.toUri import androidx.test.annotation.UiThreadTest import androidx.test.filters.SdkSuppress import androidx.test.platform.app.InstrumentationRegistry import com.duckduckgo.app.CoroutineTestRule import com.duckduckgo.app.browser.certificates.rootstore.TrustedCertificateStore +import com.duckduckgo.app.browser.cookies.ThirdPartyCookieManager import com.duckduckgo.app.browser.httpauth.WebViewHttpAuthStore import com.duckduckgo.app.browser.logindetection.DOMLoginDetector import com.duckduckgo.app.browser.logindetection.WebNavigationEvent @@ -34,6 +36,7 @@ import com.duckduckgo.app.runBlocking import com.duckduckgo.app.statistics.store.OfflinePixelCountDataStore import com.nhaarman.mockitokotlin2.* import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.GlobalScope import org.junit.Before import org.junit.Rule import org.junit.Test @@ -59,6 +62,7 @@ class BrowserWebViewClientTest { private val globalPrivacyControl: GlobalPrivacyControl = mock() private val trustedCertificateStore: TrustedCertificateStore = mock() private val webViewHttpAuthStore: WebViewHttpAuthStore = mock() + private val thirdPartyCookieManager: ThirdPartyCookieManager = mock() @UiThreadTest @Before @@ -75,7 +79,9 @@ class BrowserWebViewClientTest { cookieManager, loginDetector, dosDetector, - globalPrivacyControl + globalPrivacyControl, + thirdPartyCookieManager, + GlobalScope ) testee.webViewClientListener = listener } @@ -117,6 +123,13 @@ class BrowserWebViewClientTest { verify(globalPrivacyControl).injectDoNotSellToDom(webView) } + @UiThreadTest + @Test + fun whenOnPageStartedCalledThenProcessUriForThirdPartyCookiesCalled() = coroutinesTestRule.runBlocking { + testee.onPageStarted(webView, EXAMPLE_URL, null) + verify(thirdPartyCookieManager).processUriForThirdPartyCookies(webView, EXAMPLE_URL.toUri()) + } + @UiThreadTest @Test fun whenOnPageFinishedCalledThenListenerInstructedToUpdateNavigationState() { diff --git a/app/src/androidTest/java/com/duckduckgo/app/browser/cookies/AppThirdPartyCookieManagerTest.kt b/app/src/androidTest/java/com/duckduckgo/app/browser/cookies/AppThirdPartyCookieManagerTest.kt new file mode 100644 index 000000000000..6e7c0eb565c9 --- /dev/null +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/cookies/AppThirdPartyCookieManagerTest.kt @@ -0,0 +1,186 @@ +/* + * Copyright (c) 2021 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.browser.cookies + +import android.content.Context +import android.webkit.CookieManager +import android.webkit.WebView +import androidx.core.net.toUri +import androidx.room.Room +import androidx.test.annotation.UiThreadTest +import androidx.test.platform.app.InstrumentationRegistry +import com.duckduckgo.app.CoroutineTestRule +import com.duckduckgo.app.browser.cookies.AppThirdPartyCookieManager.Companion.USER_ID_COOKIE +import com.duckduckgo.app.browser.cookies.db.AuthCookiesAllowedDomainsDao +import com.duckduckgo.app.browser.cookies.db.AuthCookiesAllowedDomainsRepository +import com.duckduckgo.app.global.db.AppDatabase +import com.duckduckgo.app.runBlocking +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.withContext +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +@ExperimentalCoroutinesApi +class AppThirdPartyCookieManagerTest { + + @get:Rule + var coroutinesTestRule = CoroutineTestRule() + + private val cookieManager = CookieManager.getInstance() + private lateinit var db: AppDatabase + private lateinit var authCookiesAllowedDomainsDao: AuthCookiesAllowedDomainsDao + private lateinit var authCookiesAllowedDomainsRepository: AuthCookiesAllowedDomainsRepository + private lateinit var testee: AppThirdPartyCookieManager + private lateinit var webView: WebView + + @UiThreadTest + @Before + fun setup() { + db = Room.inMemoryDatabaseBuilder(InstrumentationRegistry.getInstrumentation().targetContext, AppDatabase::class.java) + .allowMainThreadQueries() + .build() + authCookiesAllowedDomainsDao = db.authCookiesAllowedDomainsDao() + authCookiesAllowedDomainsRepository = AuthCookiesAllowedDomainsRepository(authCookiesAllowedDomainsDao, coroutinesTestRule.testDispatcherProvider) + webView = TestWebView(InstrumentationRegistry.getInstrumentation().targetContext) + + testee = AppThirdPartyCookieManager(cookieManager, authCookiesAllowedDomainsRepository) + } + + @UiThreadTest + @After + fun after() { + cookieManager.removeAllCookies { } + } + + @UiThreadTest + @Test + fun whenProcessUriForThirdPartyCookiesIfDomainIsNotGoogleAuthAndIsNotInTheListThenThirdPartyCookiesDisabled() = coroutinesTestRule.runBlocking { + testee.processUriForThirdPartyCookies(webView, EXAMPLE_URI) + + assertFalse(cookieManager.acceptThirdPartyCookies(webView)) + } + + @UiThreadTest + @Test + fun whenProcessUriForThirdPartyCookiesIfDomainIsNotGoogleAuthAndIsInTheListAndHasCookieThenThirdPartyCookiesEnabled() = coroutinesTestRule.runBlocking { + givenDomainIsInTheThirdPartyCookieList(EXAMPLE_URI.host!!) + givenUserIdCookieIsSet() + + testee.processUriForThirdPartyCookies(webView, EXAMPLE_URI) + + assertTrue(cookieManager.acceptThirdPartyCookies(webView)) + } + + @UiThreadTest + @Test + fun whenProcessUriForThirdPartyCookiesIfDomainIsNotGoogleAuthAndIsInTheListAndDoesNotHaveCookieThenThirdPartyCookiesDisabled() = coroutinesTestRule.runBlocking { + givenDomainIsInTheThirdPartyCookieList(EXAMPLE_URI.host!!) + + testee.processUriForThirdPartyCookies(webView, EXAMPLE_URI) + + assertFalse(cookieManager.acceptThirdPartyCookies(webView)) + } + + @UiThreadTest + @Test + fun whenProcessUriForThirdPartyCookiesIfDomainIsInTheListAndCookieIsSetThenDomainRemovedFromList() = coroutinesTestRule.runBlocking { + givenUserIdCookieIsSet() + givenDomainIsInTheThirdPartyCookieList(EXAMPLE_URI.host!!) + + testee.processUriForThirdPartyCookies(webView, EXAMPLE_URI) + + assertNull(authCookiesAllowedDomainsRepository.getDomain(EXAMPLE_URI.host!!)) + } + + @UiThreadTest + @Test + fun whenProcessUriForThirdPartyCookiesIfDomainIsInTheListAndCookieIsNotSetThenDomainRemovedFromList() = coroutinesTestRule.runBlocking { + givenDomainIsInTheThirdPartyCookieList(EXAMPLE_URI.host!!) + + testee.processUriForThirdPartyCookies(webView, EXAMPLE_URI) + + assertNull(authCookiesAllowedDomainsRepository.getDomain(EXAMPLE_URI.host!!)) + } + + @UiThreadTest + @Test + fun whenProcessUriForThirdPartyCookiesIfDomainIsInTheListAndIsFromExceptionListThenDomainNotRemovedFromList() = coroutinesTestRule.runBlocking { + givenDomainIsInTheThirdPartyCookieList(EXCLUDED_DOMAIN_URI.host!!) + + testee.processUriForThirdPartyCookies(webView, EXCLUDED_DOMAIN_URI) + + assertNotNull(authCookiesAllowedDomainsRepository.getDomain(EXCLUDED_DOMAIN_URI.host!!)) + } + + @UiThreadTest + @Test + fun whenProcessUriForThirdPartyCookiesIfUrlIsGoogleAuthAndIsTokenTypeThenDomainAddedToTheList() = coroutinesTestRule.runBlocking { + testee.processUriForThirdPartyCookies(webView, THIRD_PARTY_AUTH_URI) + + assertNotNull(authCookiesAllowedDomainsRepository.getDomain(EXAMPLE_URI.host!!)) + } + + @UiThreadTest + @Test + fun whenProcessUriForThirdPartyCookiesIfUrlIsGoogleAuthAndIsNotTokenTypeThenDomainNotAddedToTheList() = coroutinesTestRule.runBlocking { + testee.processUriForThirdPartyCookies(webView, NON_THIRD_PARTY_AUTH_URI) + + assertNull(authCookiesAllowedDomainsRepository.getDomain(EXAMPLE_URI.host!!)) + } + + @Test + fun whenClearAllDataThenDomainDeletedFromDatabase() = coroutinesTestRule.runBlocking { + givenDomainIsInTheThirdPartyCookieList(EXAMPLE_URI.host!!) + + testee.clearAllData() + + assertNull(authCookiesAllowedDomainsRepository.getDomain(EXAMPLE_URI.host!!)) + } + + @Test + fun whenClearAllDataIfDomainIsInExclusionListThenDomainNotDeletedFromDatabase() = coroutinesTestRule.runBlocking { + givenDomainIsInTheThirdPartyCookieList(EXCLUDED_DOMAIN_URI.host!!) + + testee.clearAllData() + + assertNotNull(authCookiesAllowedDomainsRepository.getDomain(EXCLUDED_DOMAIN_URI.host!!)) + } + + private suspend fun givenDomainIsInTheThirdPartyCookieList(domain: String) = coroutinesTestRule.runBlocking { + withContext(coroutinesTestRule.testDispatcherProvider.io()) { + authCookiesAllowedDomainsRepository.addDomain(domain) + } + } + + private suspend fun givenUserIdCookieIsSet() { + withContext(coroutinesTestRule.testDispatcherProvider.main()) { + cookieManager.setCookie("https://accounts.google.com", "$USER_ID_COOKIE=test") + } + } + + private class TestWebView(context: Context) : WebView(context) + + companion object { + val EXCLUDED_DOMAIN_URI = "http://home.nest.com".toUri() + val EXAMPLE_URI = "http://example.com".toUri() + val THIRD_PARTY_AUTH_URI = "https://accounts.google.com/o/oauth2/auth/identifier?response_type=permission%20id_token&ss_domain=https%3A%2F%2Fexample.com".toUri() + val NON_THIRD_PARTY_AUTH_URI = "https://accounts.google.com/o/oauth2/auth/identifier?response_type=code&ss_domain=https%3A%2F%2Fexample.com".toUri() + } +} diff --git a/app/src/androidTest/java/com/duckduckgo/app/browser/cookies/db/AuthCookiesAllowedDomainsRepositoryTest.kt b/app/src/androidTest/java/com/duckduckgo/app/browser/cookies/db/AuthCookiesAllowedDomainsRepositoryTest.kt new file mode 100644 index 000000000000..d13a286ad021 --- /dev/null +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/cookies/db/AuthCookiesAllowedDomainsRepositoryTest.kt @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2021 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.browser.cookies.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.global.db.AppDatabase +import com.duckduckgo.app.runBlocking +import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +@ExperimentalCoroutinesApi +class AuthCookiesAllowedDomainsRepositoryTest { + @get:Rule + @Suppress("unused") + val coroutineRule = CoroutineTestRule() + + @get:Rule + @Suppress("unused") + var instantTaskExecutorRule = InstantTaskExecutorRule() + + private lateinit var db: AppDatabase + private lateinit var authCookiesAllowedDomainsDao: AuthCookiesAllowedDomainsDao + private lateinit var authCookiesAllowedDomainsRepository: AuthCookiesAllowedDomainsRepository + + @Before + fun before() { + db = Room.inMemoryDatabaseBuilder(InstrumentationRegistry.getInstrumentation().targetContext, AppDatabase::class.java) + .allowMainThreadQueries() + .build() + authCookiesAllowedDomainsDao = db.authCookiesAllowedDomainsDao() + authCookiesAllowedDomainsRepository = AuthCookiesAllowedDomainsRepository(authCookiesAllowedDomainsDao, coroutineRule.testDispatcherProvider) + } + + @After + fun after() { + db.close() + } + + @Test + fun whenAddDomainIfIsEmptyThenReturnNull() = coroutineRule.runBlocking { + assertNull(authCookiesAllowedDomainsRepository.addDomain("")) + } + + @Test + fun whenAddDomainAndDomainNotValidThenReturnNull() = coroutineRule.runBlocking { + assertNull(authCookiesAllowedDomainsRepository.addDomain("https://example.com")) + } + + @Test + fun whenAddValidDomainThenReturnNonNull() = coroutineRule.runBlocking { + assertNotNull(authCookiesAllowedDomainsRepository.addDomain("example.com")) + } + + @Test + fun whenGetDomainIfDomainExistsThenReturnAllowedDomainEntity() = coroutineRule.runBlocking { + givenAuthCookieAllowedDomain("example.com") + + val authCookieAllowedDomainEntity = authCookiesAllowedDomainsRepository.getDomain("example.com") + assertEquals("example.com", authCookieAllowedDomainEntity?.domain) + } + + @Test + fun whenGetDomainIfDomainDoesNotExistThenReturnNull() = coroutineRule.runBlocking { + val authCookieAllowedDomainEntity = authCookiesAllowedDomainsRepository.getDomain("example.com") + assertNull(authCookieAllowedDomainEntity) + } + + @Test + fun whenRemoveDomainThenDomainDeletedFromDatabase() = coroutineRule.runBlocking { + givenAuthCookieAllowedDomain("example.com") + val authCookieAllowedDomainEntity = authCookiesAllowedDomainsRepository.getDomain("example.com") + + authCookiesAllowedDomainsRepository.removeDomain(authCookieAllowedDomainEntity!!) + + val deletedEntity = authCookiesAllowedDomainsRepository.getDomain("example.com") + assertNull(deletedEntity) + } + + @Test + fun whenDeleteAllThenAllDomainsDeletedExceptFromTheExceptionList() = coroutineRule.runBlocking { + givenAuthCookieAllowedDomain("example.com", "example2.com") + + authCookiesAllowedDomainsRepository.deleteAll(listOf("example.com")) + + assertNull(authCookiesAllowedDomainsRepository.getDomain("example2.com")) + assertNotNull(authCookiesAllowedDomainsRepository.getDomain("example.com")) + } + + private fun givenAuthCookieAllowedDomain(vararg allowedDomain: String) { + allowedDomain.forEach { + authCookiesAllowedDomainsDao.insert(AuthCookieAllowedDomainEntity(domain = it)) + } + } +} diff --git a/app/src/androidTest/java/com/duckduckgo/app/global/db/AppDatabaseTest.kt b/app/src/androidTest/java/com/duckduckgo/app/global/db/AppDatabaseTest.kt index 71cd045e6fc0..8447a8da6841 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 @@ -345,6 +345,11 @@ class AppDatabaseTest { } } + @Test + fun whenMigratingFromVersion30To31ThenValidationSucceeds() { + createDatabaseAndMigrate(30, 31, migrationsProvider.MIGRATION_30_TO_31) + } + private fun givenUserStageIs(database: SupportSQLiteDatabase, appStage: AppStage) { database.execSQL("INSERT INTO `userStage` values (1, '${appStage.name}') ") } diff --git a/app/src/androidTest/java/com/duckduckgo/app/global/view/ClearPersonalDataActionTest.kt b/app/src/androidTest/java/com/duckduckgo/app/global/view/ClearPersonalDataActionTest.kt index 2608f6031ad7..ac8e5770d480 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/global/view/ClearPersonalDataActionTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/global/view/ClearPersonalDataActionTest.kt @@ -18,6 +18,7 @@ package com.duckduckgo.app.global.view import androidx.test.platform.app.InstrumentationRegistry import com.duckduckgo.app.browser.WebDataManager +import com.duckduckgo.app.browser.cookies.ThirdPartyCookieManager import com.duckduckgo.app.fire.AppCacheClearer import com.duckduckgo.app.fire.DuckDuckGoCookieManager import com.duckduckgo.app.fire.UnsentForgetAllPixelStore @@ -44,6 +45,7 @@ class ClearPersonalDataActionTest { private val mockCookieManager: DuckDuckGoCookieManager = mock() private val mockAppCacheClearer: AppCacheClearer = mock() private val mockGeoLocationPermissions: GeoLocationPermissions = mock() + private val mockThirdPartyCookieManager: ThirdPartyCookieManager = mock() @Before fun setup() { @@ -55,7 +57,8 @@ class ClearPersonalDataActionTest { mockSettingsDataStore, mockCookieManager, mockAppCacheClearer, - mockGeoLocationPermissions + mockGeoLocationPermissions, + mockThirdPartyCookieManager ) } @@ -100,4 +103,10 @@ class ClearPersonalDataActionTest { testee.clearTabsAndAllDataAsync(appInForeground = false, shouldFireDataClearPixel = false) verify(mockGeoLocationPermissions).clearAllButFireproofed() } + + @Test + fun whenClearCalledThenThirdPartyCookieSitesAreCleared() = runBlocking { + testee.clearTabsAndAllDataAsync(appInForeground = false, shouldFireDataClearPixel = false) + verify(mockThirdPartyCookieManager).clearAllData() + } } diff --git a/app/src/androidTest/java/com/duckduckgo/app/tabs/model/TabDataRepositoryTest.kt b/app/src/androidTest/java/com/duckduckgo/app/tabs/model/TabDataRepositoryTest.kt index 6f14724b4b7f..53615eaf7401 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/tabs/model/TabDataRepositoryTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/tabs/model/TabDataRepositoryTest.kt @@ -423,6 +423,27 @@ class TabDataRepositoryTest { job.cancel() } + @Test + fun whenDeleteTabAndSelectSourceIfTabHadAParentThenEmitParentTabId() = runBlocking { + val db = createDatabase() + val dao = db.tabsDao() + val sourceTab = TabEntity(tabId = "sourceId", url = "http://www.example.com", position = 0) + val tabToDelete = TabEntity(tabId = "tabToDeleteId", url = "http://www.example.com", position = 1, sourceTabId = "sourceId") + dao.addAndSelectTab(sourceTab) + dao.addAndSelectTab(tabToDelete) + testee = tabDataRepository(dao) + + testee.deleteTabAndSelectSource("tabToDeleteId") + + val job = launch { + testee.childClosedTabs.collect { + assertEquals("sourceId", it) + } + } + + job.cancel() + } + private fun tabDataRepository(dao: TabsDao): TabDataRepository { return TabDataRepository( dao, 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 3d0f3095a6b3..29f9840eb8f5 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt @@ -65,6 +65,7 @@ import com.duckduckgo.app.browser.BrowserTabViewModel.* import com.duckduckgo.app.browser.BrowserTabViewModel.Command.DownloadCommand import com.duckduckgo.app.browser.DownloadConfirmationFragment.DownloadConfirmationDialogListener import com.duckduckgo.app.browser.autocomplete.BrowserAutoCompleteSuggestionsAdapter +import com.duckduckgo.app.browser.cookies.ThirdPartyCookieManager import com.duckduckgo.app.browser.downloader.BlobConverterInjector import com.duckduckgo.app.browser.downloader.DownloadFailReason import com.duckduckgo.app.browser.downloader.FileDownloadNotificationManager @@ -197,6 +198,9 @@ class BrowserTabFragment : @Inject lateinit var webViewHttpAuthStore: WebViewHttpAuthStore + @Inject + lateinit var thirdPartyCookieManager: ThirdPartyCookieManager + var messageFromPreviousTab: Message? = null private val initialUrl get() = requireArguments().getString(URL_EXTRA_ARG) @@ -612,6 +616,16 @@ class BrowserTabFragment : is DownloadCommand -> processDownloadCommand(it) is Command.ConvertBlobToDataUri -> convertBlobToDataUri(it) is Command.RequestFileDownload -> requestFileDownload(it.url, it.contentDisposition, it.mimeType, it.requestUserConfirmation) + is Command.ChildTabClosed -> processUriForThirdPartyCookies() + } + } + + private fun processUriForThirdPartyCookies() { + webView?.let { + val url = it.url ?: return + launch { + thirdPartyCookieManager.processUriForThirdPartyCookies(it, url.toUri()) + } } } 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 52896c3820f7..b8e081ffd0bb 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt @@ -113,6 +113,7 @@ import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.disposables.Disposable import io.reactivex.schedulers.Schedulers import kotlinx.coroutines.* +import kotlinx.coroutines.flow.collect import timber.log.Timber import java.io.File import java.util.* @@ -284,6 +285,7 @@ class BrowserTabViewModel( class ShowDomainHasPermissionMessage(val domain: String) : Command() class ConvertBlobToDataUri(val url: String, val mimeType: String) : Command() class RequestFileDownload(val url: String, val contentDisposition: String?, val mimeType: String, val requestUserConfirmation: Boolean) : Command() + object ChildTabClosed : Command() sealed class DaxCommand : Command() { object FinishTrackerAnimation : DaxCommand() class HideDaxDialog(val cta: Cta) : DaxCommand() @@ -395,6 +397,13 @@ class BrowserTabViewModel( fireproofDialogsEventHandler.event.observeForever(fireproofDialogEventObserver) navigationAwareLoginDetector.loginEventLiveData.observeForever(loginDetectionObserver) showPulseAnimation.observeForever(fireButtonAnimation) + viewModelScope.launch { + tabRepository.childClosedTabs.collect { closedTab -> + if (this@BrowserTabViewModel::tabId.isInitialized && tabId == closedTab) { + command.value = ChildTabClosed + } + } + } } fun loadData(tabId: String, initialUrl: String?, skipHome: Boolean) { diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserWebViewClient.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserWebViewClient.kt index 832f8559b6ea..3f76fc36e2b3 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserWebViewClient.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserWebViewClient.kt @@ -25,13 +25,16 @@ import android.webkit.* import androidx.annotation.RequiresApi import androidx.annotation.UiThread import androidx.annotation.WorkerThread +import androidx.core.net.toUri import com.duckduckgo.app.browser.certificates.rootstore.CertificateValidationState import com.duckduckgo.app.browser.certificates.rootstore.TrustedCertificateStore +import com.duckduckgo.app.browser.cookies.ThirdPartyCookieManager import com.duckduckgo.app.browser.httpauth.WebViewHttpAuthStore import com.duckduckgo.app.browser.logindetection.DOMLoginDetector import com.duckduckgo.app.browser.logindetection.WebNavigationEvent import com.duckduckgo.app.browser.model.BasicAuthenticationRequest import com.duckduckgo.app.browser.navigation.safeCopyBackForwardList +import com.duckduckgo.app.di.AppCoroutineScope import com.duckduckgo.app.global.exception.UncaughtExceptionRepository import com.duckduckgo.app.global.exception.UncaughtExceptionSource.* import com.duckduckgo.app.globalprivacycontrol.GlobalPrivacyControl @@ -51,7 +54,9 @@ class BrowserWebViewClient( private val cookieManager: CookieManager, private val loginDetector: DOMLoginDetector, private val dosDetector: DosDetector, - private val globalPrivacyControl: GlobalPrivacyControl + private val globalPrivacyControl: GlobalPrivacyControl, + private val thirdPartyCookieManager: ThirdPartyCookieManager, + @AppCoroutineScope private val appCoroutineScope: CoroutineScope ) : WebViewClient() { var webViewClientListener: WebViewClientListener? = null @@ -140,6 +145,11 @@ class BrowserWebViewClient( override fun onPageStarted(webView: WebView, url: String?, favicon: Bitmap?) { try { Timber.v("onPageStarted webViewUrl: ${webView.url} URL: $url") + url?.let { + appCoroutineScope.launch { + thirdPartyCookieManager.processUriForThirdPartyCookies(webView, url.toUri()) + } + } val navigationList = webView.safeCopyBackForwardList() ?: return webViewClientListener?.navigationStateChanged(WebViewNavigationState(navigationList)) if (url != null && url == lastPageStarted) { diff --git a/app/src/main/java/com/duckduckgo/app/browser/cookies/ThirdPartyCookieManager.kt b/app/src/main/java/com/duckduckgo/app/browser/cookies/ThirdPartyCookieManager.kt new file mode 100644 index 000000000000..33682fa17a97 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/browser/cookies/ThirdPartyCookieManager.kt @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2021 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.browser.cookies + +import android.net.Uri +import android.webkit.CookieManager +import android.webkit.WebView +import androidx.core.net.toUri +import com.duckduckgo.app.browser.cookies.db.AuthCookieAllowedDomainEntity +import com.duckduckgo.app.browser.cookies.db.AuthCookiesAllowedDomainsRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import timber.log.Timber + +interface ThirdPartyCookieManager { + suspend fun processUriForThirdPartyCookies(webView: WebView, uri: Uri) + suspend fun clearAllData() +} + +class AppThirdPartyCookieManager( + private val cookieManager: CookieManager, + private val authCookiesAllowedDomainsRepository: AuthCookiesAllowedDomainsRepository +) : ThirdPartyCookieManager { + + override suspend fun processUriForThirdPartyCookies(webView: WebView, uri: Uri) { + if (uri.host == GOOGLE_ACCOUNTS_HOST) { + addHostToList(uri) + } else { + processThirdPartyCookiesSetting(webView, uri) + } + } + + override suspend fun clearAllData() { + authCookiesAllowedDomainsRepository.deleteAll(hostsThatAlwaysRequireThirdPartyCookies) + } + + private suspend fun processThirdPartyCookiesSetting(webView: WebView, uri: Uri) { + val host = uri.host ?: return + val domain = authCookiesAllowedDomainsRepository.getDomain(host) + withContext(Dispatchers.Main) { + if (domain != null && hasUserIdCookie()) { + Timber.d("Cookies enabled for $uri") + cookieManager.setAcceptThirdPartyCookies(webView, true) + } else { + Timber.d("Cookies disabled for $uri") + cookieManager.setAcceptThirdPartyCookies(webView, false) + } + domain?.let { deleteHost(it) } + } + } + + private suspend fun deleteHost(authCookieAllowedDomainEntity: AuthCookieAllowedDomainEntity) { + if (hostsThatAlwaysRequireThirdPartyCookies.contains(authCookieAllowedDomainEntity.domain)) return + authCookiesAllowedDomainsRepository.removeDomain(authCookieAllowedDomainEntity) + } + + private suspend fun addHostToList(uri: Uri) { + val ssDomain = uri.getQueryParameter(SS_DOMAIN) + val accessType = uri.getQueryParameter(RESPONSE_TYPE) + ssDomain?.let { + if (accessType?.contains(CODE) == false) { + ssDomain.toUri().host?.let { + authCookiesAllowedDomainsRepository.addDomain(it) + } + } + } + } + + private fun hasUserIdCookie(): Boolean { + return cookieManager.getCookie(GOOGLE_ACCOUNTS_URL)?.split(";")?.firstOrNull { + it.contains(USER_ID_COOKIE) + } != null + } + + // See https://app.asana.com/0/1125189844152671/1200029737431978 for mor context about the below values + companion object { + private const val SS_DOMAIN = "ss_domain" + private const val RESPONSE_TYPE = "response_type" + private const val CODE = "code" + const val USER_ID_COOKIE = "user_id" + const val GOOGLE_ACCOUNTS_URL = "https://accounts.google.com" + const val GOOGLE_ACCOUNTS_HOST = "accounts.google.com" + val hostsThatAlwaysRequireThirdPartyCookies = listOf("home.nest.com") + } +} diff --git a/app/src/main/java/com/duckduckgo/app/browser/cookies/db/AuthCookieAllowedDomainEntity.kt b/app/src/main/java/com/duckduckgo/app/browser/cookies/db/AuthCookieAllowedDomainEntity.kt new file mode 100644 index 000000000000..7914f1dfbdcb --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/browser/cookies/db/AuthCookieAllowedDomainEntity.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2021 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.browser.cookies.db + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "auth_cookies_allowed_domains") +data class AuthCookieAllowedDomainEntity( + @PrimaryKey + var domain: String +) diff --git a/app/src/main/java/com/duckduckgo/app/browser/cookies/db/AuthCookiesAllowedDomainsDao.kt b/app/src/main/java/com/duckduckgo/app/browser/cookies/db/AuthCookiesAllowedDomainsDao.kt new file mode 100644 index 000000000000..3509fd90c7b6 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/browser/cookies/db/AuthCookiesAllowedDomainsDao.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2021 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.browser.cookies.db + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query + +@Dao +interface AuthCookiesAllowedDomainsDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insert(authCookieAllowedDomainEntity: AuthCookieAllowedDomainEntity): Long + + @Delete + fun delete(authCookieAllowedDomainEntity: AuthCookieAllowedDomainEntity) + + @Query("SELECT * FROM auth_cookies_allowed_domains WHERE domain = :host limit 1") + fun getDomain(host: String): AuthCookieAllowedDomainEntity? + + @Query("DELETE FROM auth_cookies_allowed_domains WHERE domain NOT IN (:exceptionList)") + fun deleteAll(exceptionList: String) +} diff --git a/app/src/main/java/com/duckduckgo/app/browser/cookies/db/AuthCookiesAllowedDomainsRepository.kt b/app/src/main/java/com/duckduckgo/app/browser/cookies/db/AuthCookiesAllowedDomainsRepository.kt new file mode 100644 index 000000000000..a1c18c02f149 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/browser/cookies/db/AuthCookiesAllowedDomainsRepository.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2021 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.browser.cookies.db + +import com.duckduckgo.app.global.DispatcherProvider +import com.duckduckgo.app.global.UriString +import kotlinx.coroutines.withContext +import javax.inject.Inject + +class AuthCookiesAllowedDomainsRepository @Inject constructor( + private val authCookiesAllowedDomainsDao: AuthCookiesAllowedDomainsDao, + private val dispatcherProvider: DispatcherProvider +) { + + suspend fun addDomain(domain: String): Long? { + if (!UriString.isValidDomain(domain)) return null + + val authCookieAllowedDomainEntity = AuthCookieAllowedDomainEntity(domain = domain) + + val id = withContext(dispatcherProvider.io()) { + authCookiesAllowedDomainsDao.insert(authCookieAllowedDomainEntity) + } + + return if (id >= 0) { + id + } else { + null + } + } + + suspend fun getDomain(domain: String): AuthCookieAllowedDomainEntity? { + return withContext(dispatcherProvider.io()) { + authCookiesAllowedDomainsDao.getDomain(domain) + } + } + + suspend fun removeDomain(authCookieAllowedDomainEntity: AuthCookieAllowedDomainEntity) { + withContext(dispatcherProvider.io()) { + authCookiesAllowedDomainsDao.delete(authCookieAllowedDomainEntity) + } + } + + suspend fun deleteAll(exceptionList: List = emptyList()) { + withContext(dispatcherProvider.io()) { + authCookiesAllowedDomainsDao.deleteAll(exceptionList.joinToString(",")) + } + } +} diff --git a/app/src/main/java/com/duckduckgo/app/browser/di/BrowserModule.kt b/app/src/main/java/com/duckduckgo/app/browser/di/BrowserModule.kt index 8e34d2d782b4..08895f119be9 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/di/BrowserModule.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/di/BrowserModule.kt @@ -24,6 +24,9 @@ import com.duckduckgo.app.browser.* import com.duckduckgo.app.browser.addtohome.AddToHomeCapabilityDetector import com.duckduckgo.app.browser.addtohome.AddToHomeSystemCapabilityDetector import com.duckduckgo.app.browser.certificates.rootstore.TrustedCertificateStore +import com.duckduckgo.app.browser.cookies.AppThirdPartyCookieManager +import com.duckduckgo.app.browser.cookies.ThirdPartyCookieManager +import com.duckduckgo.app.browser.cookies.db.AuthCookiesAllowedDomainsRepository import com.duckduckgo.app.browser.defaultbrowsing.AndroidDefaultBrowserDetector import com.duckduckgo.app.browser.defaultbrowsing.DefaultBrowserDetector import com.duckduckgo.app.browser.defaultbrowsing.DefaultBrowserObserver @@ -39,6 +42,7 @@ import com.duckduckgo.app.browser.tabpreview.FileBasedWebViewPreviewPersister import com.duckduckgo.app.browser.tabpreview.WebViewPreviewGenerator import com.duckduckgo.app.browser.tabpreview.WebViewPreviewPersister import com.duckduckgo.app.browser.useragent.UserAgentProvider +import com.duckduckgo.app.di.AppCoroutineScope import com.duckduckgo.app.fire.* import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteDao import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteRepository @@ -66,6 +70,7 @@ import com.duckduckgo.app.tabs.ui.GridViewColumnCalculator import com.duckduckgo.app.trackerdetection.TrackerDetector import dagger.Module import dagger.Provides +import kotlinx.coroutines.CoroutineScope import javax.inject.Named import javax.inject.Singleton @@ -94,7 +99,9 @@ class BrowserModule { cookieManager: CookieManager, loginDetector: DOMLoginDetector, dosDetector: DosDetector, - globalPrivacyControl: GlobalPrivacyControl + globalPrivacyControl: GlobalPrivacyControl, + thirdPartyCookieManager: ThirdPartyCookieManager, + @AppCoroutineScope appCoroutineScope: CoroutineScope ): BrowserWebViewClient { return BrowserWebViewClient( webViewHttpAuthStore, @@ -107,7 +114,9 @@ class BrowserModule { cookieManager, loginDetector, dosDetector, - globalPrivacyControl + globalPrivacyControl, + thirdPartyCookieManager, + appCoroutineScope ) } @@ -288,4 +297,10 @@ class BrowserModule { ): FireproofDialogsEventHandler { return BrowserTabFireproofDialogsEventHandler(userEventsStore, pixel, fireproofWebsiteRepository, appSettingsPreferencesStore, variantManager, dispatchers) } + + @Singleton + @Provides + fun thirdPartyCookieManager(cookieManager: CookieManager, authCookiesAllowedDomainsRepository: AuthCookiesAllowedDomainsRepository): ThirdPartyCookieManager { + return AppThirdPartyCookieManager(cookieManager, authCookiesAllowedDomainsRepository) + } } 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 013b46a209f4..58b231843e78 100644 --- a/app/src/main/java/com/duckduckgo/app/di/DaoModule.kt +++ b/app/src/main/java/com/duckduckgo/app/di/DaoModule.kt @@ -88,4 +88,7 @@ class DaoModule { @Provides fun locationPermissionsDao(database: AppDatabase) = database.locationPermissionsDao() + + @Provides + fun allowedDomainsDao(database: AppDatabase) = database.authCookiesAllowedDomainsDao() } 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 e87f66ea6fbf..8e790c2cadcc 100644 --- a/app/src/main/java/com/duckduckgo/app/di/PrivacyModule.kt +++ b/app/src/main/java/com/duckduckgo/app/di/PrivacyModule.kt @@ -19,6 +19,7 @@ package com.duckduckgo.app.di import android.content.Context import androidx.work.WorkManager import com.duckduckgo.app.browser.WebDataManager +import com.duckduckgo.app.browser.cookies.ThirdPartyCookieManager import com.duckduckgo.app.fire.* import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteRepository import com.duckduckgo.app.global.DispatcherProvider @@ -63,7 +64,8 @@ class PrivacyModule { settingsDataStore: SettingsDataStore, cookieManager: DuckDuckGoCookieManager, appCacheClearer: AppCacheClearer, - geoLocationPermissions: GeoLocationPermissions + geoLocationPermissions: GeoLocationPermissions, + thirdPartyCookieManager: ThirdPartyCookieManager ): ClearDataAction { return ClearPersonalDataAction( context, @@ -73,7 +75,8 @@ class PrivacyModule { settingsDataStore, cookieManager, appCacheClearer, - geoLocationPermissions + geoLocationPermissions, + thirdPartyCookieManager ) } 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 80f17e973976..c4ede9a905be 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 @@ -25,6 +25,8 @@ import androidx.sqlite.db.SupportSQLiteDatabase import com.duckduckgo.app.bookmarks.db.BookmarkEntity import com.duckduckgo.app.bookmarks.db.BookmarksDao import com.duckduckgo.app.browser.addtohome.AddToHomeCapabilityDetector +import com.duckduckgo.app.browser.cookies.db.AuthCookiesAllowedDomainsDao +import com.duckduckgo.app.browser.cookies.db.AuthCookieAllowedDomainEntity import com.duckduckgo.app.browser.rating.db.* import com.duckduckgo.app.cta.db.DismissedCtaDao import com.duckduckgo.app.cta.model.DismissedCta @@ -65,7 +67,7 @@ import com.duckduckgo.app.usage.search.SearchCountDao import com.duckduckgo.app.usage.search.SearchCountEntity @Database( - exportSchema = true, version = 30, + exportSchema = true, version = 31, entities = [ TdsTracker::class, TdsEntity::class, @@ -92,7 +94,8 @@ import com.duckduckgo.app.usage.search.SearchCountEntity FireproofWebsiteEntity::class, UserEventEntity::class, LocationPermissionEntity::class, - PixelEntity::class + PixelEntity::class, + AuthCookieAllowedDomainEntity::class ] ) @@ -136,6 +139,7 @@ abstract class AppDatabase : RoomDatabase() { abstract fun locationPermissionsDao(): LocationPermissionsDao abstract fun userEventsDao(): UserEventsDao abstract fun pixelDao(): PendingPixelDao + abstract fun authCookiesAllowedDomainsDao(): AuthCookiesAllowedDomainsDao } @Suppress("PropertyName") @@ -387,6 +391,12 @@ class MigrationsProvider( } } + val MIGRATION_30_TO_31: Migration = object : Migration(30, 31) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("CREATE TABLE IF NOT EXISTS `auth_cookies_allowed_domains` (`domain` TEXT PRIMARY KEY NOT NULL)") + } + } + val BOOKMARKS_DB_ON_CREATE = object : RoomDatabase.Callback() { override fun onCreate(database: SupportSQLiteDatabase) { MIGRATION_29_TO_30.migrate(database) @@ -423,7 +433,8 @@ class MigrationsProvider( MIGRATION_26_TO_27, MIGRATION_27_TO_28, MIGRATION_28_TO_29, - MIGRATION_29_TO_30 + MIGRATION_29_TO_30, + MIGRATION_30_TO_31 ) @Deprecated( diff --git a/app/src/main/java/com/duckduckgo/app/global/view/ClearPersonalDataAction.kt b/app/src/main/java/com/duckduckgo/app/global/view/ClearPersonalDataAction.kt index d870ea28e31e..8c34bea14f70 100644 --- a/app/src/main/java/com/duckduckgo/app/global/view/ClearPersonalDataAction.kt +++ b/app/src/main/java/com/duckduckgo/app/global/view/ClearPersonalDataAction.kt @@ -22,6 +22,7 @@ import android.webkit.WebView import androidx.annotation.UiThread import androidx.annotation.WorkerThread import com.duckduckgo.app.browser.WebDataManager +import com.duckduckgo.app.browser.cookies.ThirdPartyCookieManager import com.duckduckgo.app.fire.AppCacheClearer import com.duckduckgo.app.fire.DuckDuckGoCookieManager import com.duckduckgo.app.fire.FireActivity @@ -52,7 +53,8 @@ class ClearPersonalDataAction( private val settingsDataStore: SettingsDataStore, private val cookieManager: DuckDuckGoCookieManager, private val appCacheClearer: AppCacheClearer, - private val geoLocationPermissions: GeoLocationPermissions + private val geoLocationPermissions: GeoLocationPermissions, + private val thirdPartyCookieManager: ThirdPartyCookieManager ) : ClearDataAction { override fun killAndRestartProcess(notifyDataCleared: Boolean) { @@ -69,6 +71,7 @@ class ClearPersonalDataAction( withContext(Dispatchers.IO) { cookieManager.flush() geoLocationPermissions.clearAllButFireproofed() + thirdPartyCookieManager.clearAllData() clearTabsAsync(appInForeground) } diff --git a/app/src/main/java/com/duckduckgo/app/tabs/model/TabDataRepository.kt b/app/src/main/java/com/duckduckgo/app/tabs/model/TabDataRepository.kt index 1f1e8ed006a7..8adbd593da9d 100644 --- a/app/src/main/java/com/duckduckgo/app/tabs/model/TabDataRepository.kt +++ b/app/src/main/java/com/duckduckgo/app/tabs/model/TabDataRepository.kt @@ -30,6 +30,8 @@ import io.reactivex.Scheduler import io.reactivex.schedulers.Schedulers import kotlinx.coroutines.* import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.drop import timber.log.Timber @@ -51,6 +53,10 @@ class TabDataRepository @Inject constructor( override val flowTabs: Flow> = tabsDao.flowTabs() + private val childTabClosedSharedFlow = MutableSharedFlow() + + override val childClosedTabs = childTabClosedSharedFlow.asSharedFlow() + // We only want the new emissions when subscribing, however Room does not honour that contract so we // need to drop the first emission always (this is equivalent to the Observable semantics) @ExperimentalCoroutinesApi @@ -228,6 +234,12 @@ class TabDataRepository @Inject constructor( } tabsDao.deleteTabAndUpdateSelection(tabToDelete, tabToSelect) siteData.remove(tabToDelete.tabId) + + tabToSelect?.let { + appCoroutineScope.launch { + childTabClosedSharedFlow.emit(tabToSelect.tabId) + } + } } } diff --git a/app/src/main/java/com/duckduckgo/app/tabs/model/TabRepository.kt b/app/src/main/java/com/duckduckgo/app/tabs/model/TabRepository.kt index 2a28768d92b7..221e9837bd67 100644 --- a/app/src/main/java/com/duckduckgo/app/tabs/model/TabRepository.kt +++ b/app/src/main/java/com/duckduckgo/app/tabs/model/TabRepository.kt @@ -20,6 +20,7 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import com.duckduckgo.app.global.model.Site import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharedFlow interface TabRepository { @@ -30,6 +31,8 @@ interface TabRepository { val flowTabs: Flow> + val childClosedTabs: SharedFlow + /** * @return the tabs that are marked as "deletable" in the DB */