diff --git a/app/src/androidTest/java/com/duckduckgo/app/browser/WebViewDataManagerTest.kt b/app/src/androidTest/java/com/duckduckgo/app/browser/WebViewDataManagerTest.kt index 8a46f3941a88..483563f55362 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/browser/WebViewDataManagerTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/WebViewDataManagerTest.kt @@ -24,10 +24,10 @@ import androidx.test.platform.app.InstrumentationRegistry import com.duckduckgo.anrs.api.CrashLogger import com.duckduckgo.app.browser.httpauth.WebViewHttpAuthStore import com.duckduckgo.app.browser.indexeddb.IndexedDBManager -import com.duckduckgo.app.browser.session.WebViewSessionInMemoryStorage import com.duckduckgo.app.browser.weblocalstorage.WebLocalStorageManager import com.duckduckgo.app.global.file.FileDeleter import com.duckduckgo.app.pixels.remoteconfig.AndroidBrowserConfigFeature +import com.duckduckgo.app.settings.db.SettingsDataStore import com.duckduckgo.appbuildconfig.api.AppBuildConfig import com.duckduckgo.appbuildconfig.api.BuildFlavor.INTERNAL import com.duckduckgo.appbuildconfig.api.BuildFlavor.PLAY @@ -39,6 +39,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest import kotlinx.coroutines.withContext +import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test import org.mockito.kotlin.mock @@ -48,7 +49,6 @@ import org.mockito.kotlin.verifyNoInteractions import org.mockito.kotlin.whenever import java.io.File -@Suppress("RemoveExplicitTypeArguments") @SuppressLint("NoHardcodedCoroutineDispatcher") class WebViewDataManagerTest { @@ -61,11 +61,11 @@ class WebViewDataManagerTest { private val mockIndexedDBManager: IndexedDBManager = mock() private val mockCrashLogger: CrashLogger = mock() private val mockAppBuildConfig: AppBuildConfig = mock() + private val mockSettingsDataStore: SettingsDataStore = mock() private val feature = FakeFeatureToggleFactory.create(AndroidBrowserConfigFeature::class.java) private val testee = WebViewDataManager( context, - WebViewSessionInMemoryStorage(), mockCookieManager, mockFileDeleter, mockWebViewHttpAuthStore, @@ -76,6 +76,7 @@ class WebViewDataManagerTest { TestScope(), CoroutineTestRule().testDispatcherProvider, mockAppBuildConfig, + mockSettingsDataStore, ) @Test @@ -246,6 +247,7 @@ class WebViewDataManagerTest { fun whenClearDataAndIndexedDBFeatureEnabledThenDefaultContentsDeletedExceptCookiesAndIndexedDB() = runTest { withContext(Dispatchers.Main) { feature.indexedDB().setRawStoredState(State(enable = true)) + whenever(mockSettingsDataStore.clearDuckAiData).thenReturn(false) val webView = TestWebView(context) testee.clearData(webView, mockStorage) @@ -254,14 +256,15 @@ class WebViewDataManagerTest { File(context.applicationInfo.dataDir, "app_webview/Default"), listOf("Cookies", "IndexedDB"), ) - verify(mockIndexedDBManager).clearIndexedDB() + verify(mockIndexedDBManager).clearIndexedDB(false) } } @SuppressLint("DenyListedApi") @Test fun whenClearDataAndIndexedDBThrowsExceptionThenDefaultContentsDeletedExceptCookies() = runTest { - whenever(mockIndexedDBManager.clearIndexedDB()).thenThrow(RuntimeException()) + whenever(mockSettingsDataStore.clearDuckAiData).thenReturn(false) + whenever(mockIndexedDBManager.clearIndexedDB(false)).thenThrow(RuntimeException()) withContext(Dispatchers.Main) { feature.indexedDB().setRawStoredState(State(enable = true)) val webView = TestWebView(context) @@ -275,6 +278,276 @@ class WebViewDataManagerTest { } } + @SuppressLint("DenyListedApi") + @Test + fun whenClearDataAndIndexedDBFeatureEnabledAndClearDuckAiDataTrueThenClearIndexedDBWithDuckAiData() = runTest { + withContext(Dispatchers.Main) { + feature.indexedDB().setRawStoredState(State(enable = true)) + whenever(mockSettingsDataStore.clearDuckAiData).thenReturn(true) + val webView = TestWebView(context) + + testee.clearData(webView, mockStorage) + + verify(mockFileDeleter).deleteContents( + File(context.applicationInfo.dataDir, "app_webview/Default"), + listOf("Cookies", "IndexedDB"), + ) + verify(mockIndexedDBManager).clearIndexedDB(true) + } + } + + @SuppressLint("DenyListedApi") + @Test + fun whenClearDataWithShouldClearDataTrueAndShouldClearChatsFalseThenWebStorageAndOtherDataCleared() = runTest { + withContext(Dispatchers.Main) { + feature.webLocalStorage().setRawStoredState(State(enable = true)) + val webView = TestWebView(context) + + testee.clearData(webView, mockStorage, shouldClearBrowserData = true, shouldClearDuckAiData = false) + + verify(mockWebLocalStorageManager).clearWebLocalStorage(true, false) + verify(mockStorage, never()).deleteAllData() + assertTrue(webView.historyCleared) + assertTrue(webView.cacheCleared) + assertTrue(webView.clearedFormData) + verify(mockWebViewHttpAuthStore).clearHttpAuthUsernamePassword(webView) + verify(mockCookieManager).removeExternalCookies() + } + } + + @SuppressLint("DenyListedApi") + @Test + fun whenClearDataWithShouldClearDataFalseAndShouldClearChatsTrueThenOnlyWebStorageCleared() = runTest { + withContext(Dispatchers.Main) { + feature.webLocalStorage().setRawStoredState(State(enable = true)) + val webView = TestWebView(context) + + testee.clearData(webView, mockStorage, shouldClearBrowserData = false, shouldClearDuckAiData = true) + + verify(mockWebLocalStorageManager).clearWebLocalStorage(false, true) + verify(mockStorage, never()).deleteAllData() + assertFalse(webView.historyCleared) + assertFalse(webView.cacheCleared) + assertFalse(webView.clearedFormData) + verify(mockWebViewHttpAuthStore, never()).clearHttpAuthUsernamePassword(webView) + verify(mockCookieManager, never()).removeExternalCookies() + } + } + + @SuppressLint("DenyListedApi") + @Test + fun whenClearDataWithBothTrueThenAllDataCleared() = runTest { + withContext(Dispatchers.Main) { + feature.webLocalStorage().setRawStoredState(State(enable = true)) + val webView = TestWebView(context) + + testee.clearData(webView, mockStorage, shouldClearBrowserData = true, shouldClearDuckAiData = true) + + verify(mockWebLocalStorageManager).clearWebLocalStorage(true, true) + verify(mockStorage, never()).deleteAllData() + assertTrue(webView.historyCleared) + assertTrue(webView.cacheCleared) + assertTrue(webView.clearedFormData) + verify(mockWebViewHttpAuthStore).clearHttpAuthUsernamePassword(webView) + verify(mockCookieManager).removeExternalCookies() + } + } + + @SuppressLint("DenyListedApi") + @Test + fun whenClearDataWithBothFalseThenNothingCleared() = runTest { + withContext(Dispatchers.Main) { + feature.webLocalStorage().setRawStoredState(State(enable = true)) + val webView = TestWebView(context) + + testee.clearData(webView, mockStorage, shouldClearBrowserData = false, shouldClearDuckAiData = false) + + verify(mockWebLocalStorageManager).clearWebLocalStorage(false, false) + verify(mockStorage, never()).deleteAllData() + assertFalse(webView.historyCleared) + assertFalse(webView.cacheCleared) + assertFalse(webView.clearedFormData) + verify(mockWebViewHttpAuthStore, never()).clearHttpAuthUsernamePassword(webView) + verify(mockCookieManager, never()).removeExternalCookies() + } + } + + @SuppressLint("DenyListedApi") + @Test + fun whenClearDataWithParametersAndThrowsExceptionAndShouldClearDataTrueThenFallbackToDeleteAllData() = runTest { + withContext(Dispatchers.Main) { + feature.webLocalStorage().setRawStoredState(State(enable = true)) + val exception = RuntimeException("test") + val webView = TestWebView(context) + whenever(mockAppBuildConfig.flavor).thenReturn(INTERNAL) + whenever(mockWebLocalStorageManager.clearWebLocalStorage(true, false)).thenThrow(exception) + + testee.clearData(webView, mockStorage, shouldClearBrowserData = true, shouldClearDuckAiData = false) + + verify(mockWebLocalStorageManager).clearWebLocalStorage(true, false) + verify(mockCrashLogger).logCrash(CrashLogger.Crash(shortName = "web_storage_on_clear_error", t = exception)) + verify(mockStorage).deleteAllData() + assertTrue(webView.historyCleared) + assertTrue(webView.cacheCleared) + } + } + + @SuppressLint("DenyListedApi") + @Test + fun whenClearDataWithParametersAndThrowsExceptionAndShouldClearDataFalseThenDoNotFallbackToDeleteAllData() = runTest { + withContext(Dispatchers.Main) { + feature.webLocalStorage().setRawStoredState(State(enable = true)) + val exception = RuntimeException("test") + val webView = TestWebView(context) + whenever(mockAppBuildConfig.flavor).thenReturn(INTERNAL) + whenever(mockWebLocalStorageManager.clearWebLocalStorage(false, true)).thenThrow(exception) + + testee.clearData(webView, mockStorage, shouldClearBrowserData = false, shouldClearDuckAiData = true) + + verify(mockWebLocalStorageManager).clearWebLocalStorage(false, true) + verify(mockCrashLogger).logCrash(CrashLogger.Crash(shortName = "web_storage_on_clear_error", t = exception)) + verify(mockStorage, never()).deleteAllData() + assertFalse(webView.historyCleared) + assertFalse(webView.cacheCleared) + } + } + + @SuppressLint("DenyListedApi") + @Test + fun whenClearDataWithParametersAndWebLocalStorageFeatureDisabledAndShouldClearDataTrueThenDeleteAllData() = runTest { + withContext(Dispatchers.Main) { + feature.webLocalStorage().setRawStoredState(State(enable = false)) + val webView = TestWebView(context) + + testee.clearData(webView, mockStorage, shouldClearBrowserData = true, shouldClearDuckAiData = false) + + verifyNoInteractions(mockWebLocalStorageManager) + verify(mockStorage).deleteAllData() + assertTrue(webView.historyCleared) + assertTrue(webView.cacheCleared) + } + } + + @SuppressLint("DenyListedApi") + @Test + fun whenClearDataWithParametersAndWebLocalStorageFeatureDisabledAndShouldClearDataFalseThenDoNotDeleteAllData() = runTest { + withContext(Dispatchers.Main) { + feature.webLocalStorage().setRawStoredState(State(enable = false)) + val webView = TestWebView(context) + + testee.clearData(webView, mockStorage, shouldClearBrowserData = false, shouldClearDuckAiData = true) + + verifyNoInteractions(mockWebLocalStorageManager) + verify(mockStorage, never()).deleteAllData() + assertFalse(webView.historyCleared) + assertFalse(webView.cacheCleared) + } + } + + @SuppressLint("DenyListedApi") + @Test + fun whenClearDataWithShouldClearDataTrueAndShouldClearChatsFalseThenWebViewDirectoriesCleared() = runTest { + withContext(Dispatchers.Main) { + feature.webLocalStorage().setRawStoredState(State(enable = true)) + val webView = TestWebView(context) + + testee.clearData(webView, mockStorage, shouldClearBrowserData = true, shouldClearDuckAiData = false) + + verify(mockFileDeleter).deleteContents( + File(context.applicationInfo.dataDir, "app_webview"), + listOf("Default", "Cookies"), + ) + verify(mockFileDeleter).deleteContents( + File(context.applicationInfo.dataDir, "app_webview/Default"), + listOf("Cookies", "Local Storage"), + ) + } + } + + @SuppressLint("DenyListedApi") + @Test + fun whenClearDataWithShouldClearDataFalseAndShouldClearChatsTrueThenWebViewDirectoriesNotCleared() = runTest { + withContext(Dispatchers.Main) { + feature.webLocalStorage().setRawStoredState(State(enable = true)) + val webView = TestWebView(context) + + testee.clearData(webView, mockStorage, shouldClearBrowserData = false, shouldClearDuckAiData = true) + + verifyNoInteractions(mockFileDeleter) + } + } + + @SuppressLint("DenyListedApi") + @Test + fun whenClearDataWithShouldClearChatsTrueThenClearOnlyDuckAiDataFromIndexedDB() = runTest { + withContext(Dispatchers.Main) { + feature.indexedDB().setRawStoredState(State(enable = true)) + val webView = TestWebView(context) + + testee.clearData(webView, mockStorage, shouldClearBrowserData = false, shouldClearDuckAiData = true) + + verify(mockIndexedDBManager).clearOnlyDuckAiData() + } + } + + @SuppressLint("DenyListedApi") + @Test + fun whenClearDataWithShouldClearChatsTrueAndIndexedDBFeatureDisabledThenDoNotClearIndexedDB() = runTest { + withContext(Dispatchers.Main) { + feature.indexedDB().setRawStoredState(State(enable = false)) + val webView = TestWebView(context) + + testee.clearData(webView, mockStorage, shouldClearBrowserData = false, shouldClearDuckAiData = true) + + verifyNoInteractions(mockIndexedDBManager) + } + } + + @SuppressLint("DenyListedApi") + @Test + fun whenClearDataWithShouldClearChatsTrueAndIndexedDBThrowsExceptionThenDoNotCrash() = runTest { + withContext(Dispatchers.Main) { + feature.indexedDB().setRawStoredState(State(enable = true)) + whenever(mockIndexedDBManager.clearOnlyDuckAiData()).thenThrow(RuntimeException("test")) + val webView = TestWebView(context) + + testee.clearData(webView, mockStorage, shouldClearBrowserData = false, shouldClearDuckAiData = true) + + verify(mockIndexedDBManager).clearOnlyDuckAiData() + // Test passes if no exception is thrown + } + } + + @SuppressLint("DenyListedApi") + @Test + fun whenClearDataWithBothFlagsAndIndexedDBEnabledThenClearBothTypesOfIndexedDBData() = runTest { + withContext(Dispatchers.Main) { + feature.indexedDB().setRawStoredState(State(enable = true)) + feature.webLocalStorage().setRawStoredState(State(enable = true)) + val webView = TestWebView(context) + + testee.clearData(webView, mockStorage, shouldClearBrowserData = true, shouldClearDuckAiData = true) + + verify(mockIndexedDBManager).clearIndexedDB(false) + verify(mockIndexedDBManager).clearOnlyDuckAiData() + } + } + + @SuppressLint("DenyListedApi") + @Test + fun whenClearDataWithShouldClearDataTrueAndIndexedDBEnabledThenClearIndexedDBWithoutDuckAiData() = runTest { + withContext(Dispatchers.Main) { + feature.indexedDB().setRawStoredState(State(enable = true)) + feature.webLocalStorage().setRawStoredState(State(enable = true)) + val webView = TestWebView(context) + + testee.clearData(webView, mockStorage, shouldClearBrowserData = true, shouldClearDuckAiData = false) + + verify(mockIndexedDBManager).clearIndexedDB(false) + verify(mockIndexedDBManager, never()).clearOnlyDuckAiData() + } + } + private class TestWebView(context: Context) : WebView(context) { var historyCleared: Boolean = false diff --git a/app/src/androidTest/java/com/duckduckgo/app/fire/AutomaticDataClearerTest.kt b/app/src/androidTest/java/com/duckduckgo/app/fire/AutomaticDataClearerTest.kt index 9b40076a27e1..00239ea38e10 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/fire/AutomaticDataClearerTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/fire/AutomaticDataClearerTest.kt @@ -573,11 +573,11 @@ class AutomaticDataClearerTest { } private suspend fun verifyTabsCleared() { - verify(mockClearAction).clearTabsAsync(any()) + verify(mockClearAction).clearTabsOnly(any()) } private suspend fun verifyTabsNotCleared() { - verify(mockClearAction, never()).clearTabsAsync(any()) + verify(mockClearAction, never()).clearTabsOnly(any()) } private suspend fun verifyEverythingCleared() { 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 e2393cc8cf6c..5e5ba2cc1292 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 @@ -19,7 +19,6 @@ package com.duckduckgo.app.global.view import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.test.platform.app.InstrumentationRegistry -import com.duckduckgo.adclick.api.AdClickManager import com.duckduckgo.app.browser.WebDataManager import com.duckduckgo.app.browser.cookies.ThirdPartyCookieManager import com.duckduckgo.app.fire.AppCacheClearer @@ -39,13 +38,13 @@ import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Test import org.mockito.kotlin.any +import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.verifyNoInteractions import org.mockito.kotlin.whenever -@Suppress("RemoveExplicitTypeArguments") class ClearPersonalDataActionTest { private lateinit var testee: ClearPersonalDataAction @@ -57,7 +56,6 @@ class ClearPersonalDataActionTest { private val mockCookieManager: DuckDuckGoCookieManager = mock() private val mockAppCacheClearer: AppCacheClearer = mock() private val mockThirdPartyCookieManager: ThirdPartyCookieManager = mock() - private val mockAdClickManager: AdClickManager = mock() private val mockFireproofWebsiteRepository: FireproofWebsiteRepository = mock() private val mockDeviceSyncState: DeviceSyncState = mock() private val mockSavedSitesRepository: SavedSitesRepository = mock() @@ -79,7 +77,6 @@ class ClearPersonalDataActionTest { cookieManager = mockCookieManager, appCacheClearer = mockAppCacheClearer, thirdPartyCookieManager = mockThirdPartyCookieManager, - adClickManager = mockAdClickManager, fireproofWebsiteRepository = mockFireproofWebsiteRepository, deviceSyncState = mockDeviceSyncState, savedSitesRepository = mockSavedSitesRepository, @@ -104,12 +101,6 @@ class ClearPersonalDataActionTest { verify(mockClearingUnsentForgetAllPixelStore, never()).incrementCount() } - @Test - fun whenClearCalledThenDataManagerClearsSessions() = runTest { - testee.clearTabsAndAllDataAsync(appInForeground = false, shouldFireDataClearPixel = false) - verify(mockDataManager).clearWebViewSessions() - } - @Test fun whenClearCalledThenDataManagerClearsData() = runTest { testee.clearTabsAndAllDataAsync(appInForeground = false, shouldFireDataClearPixel = false) @@ -164,4 +155,138 @@ class ClearPersonalDataActionTest { testee.clearTabsAndAllDataAsync(appInForeground = false, shouldFireDataClearPixel = false) verify(mockWebTrackersBlockedRepository).deleteAll() } + + @Test + fun whenClearTabsAndAllDataAsyncCalledThenNavigationHistoryCleared() = runTest { + testee.clearTabsAndAllDataAsync(appInForeground = false, shouldFireDataClearPixel = false) + verify(mockNavigationHistory).clearHistory() + } + + @Test + fun whenClearTabsAndAllDataAsyncCalledThenCookieManagerFlushed() = runTest { + testee.clearTabsAndAllDataAsync(appInForeground = false, shouldFireDataClearPixel = false) + verify(mockCookieManager).flush() + } + + @Test + fun whenClearTabsOnlyCalledThenTabsAndSessionsCleared() = runTest { + testee.clearTabsOnly(appInForeground = false) + verify(mockTabRepository).deleteAll() + } + + @Test + fun whenClearTabsOnlyCalledWithAppInForegroundThenAppUsedFlagSetToTrue() = runTest { + testee.clearTabsOnly(appInForeground = true) + verify(mockSettingsDataStore).appUsedSinceLastClear = true + } + + @Test + fun whenClearTabsOnlyCalledWithAppNotInForegroundThenAppUsedFlagSetToFalse() = runTest { + testee.clearTabsOnly(appInForeground = false) + verify(mockSettingsDataStore).appUsedSinceLastClear = false + } + + @Test + fun whenClearBrowserDataOnlyCalledWithPixelIncrementSetToTrueThenPixelCountIncremented() = runTest { + testee.clearBrowserDataOnly(shouldFireDataClearPixel = true) + verify(mockClearingUnsentForgetAllPixelStore).incrementCount() + } + + @Test + fun whenClearBrowserDataOnlyCalledWithPixelIncrementSetToFalseThenPixelCountNotIncremented() = runTest { + testee.clearBrowserDataOnly(shouldFireDataClearPixel = false) + verify(mockClearingUnsentForgetAllPixelStore, never()).incrementCount() + } + + @Test + fun whenClearBrowserDataOnlyCalledThenDataManagerClearsDataWithCorrectFlags() = runTest { + testee.clearBrowserDataOnly(shouldFireDataClearPixel = false) + verify(mockDataManager).clearData(any(), any(), shouldClearBrowserData = eq(true), shouldClearDuckAiData = eq(false)) + } + + @Test + fun whenClearBrowserDataOnlyCalledThenAppCacheClearerClearsCache() = runTest { + testee.clearBrowserDataOnly(shouldFireDataClearPixel = false) + verify(mockAppCacheClearer).clearCache() + } + + @Test + fun whenClearBrowserDataOnlyCalledThenGeoLocationPermissionsAreCleared() = runTest { + testee.clearBrowserDataOnly(shouldFireDataClearPixel = false) + verify(mockSitePermissionsManager).clearAllButFireproof(any()) + } + + @Test + fun whenClearBrowserDataOnlyCalledThenThirdPartyCookieSitesAreCleared() = runTest { + testee.clearBrowserDataOnly(shouldFireDataClearPixel = false) + verify(mockThirdPartyCookieManager).clearAllData() + } + + @Test + fun whenClearBrowserDataOnlyCalledAndSyncEnabledThenSavedSitesDoesNotPruneDeleted() = runTest { + testee.clearBrowserDataOnly(shouldFireDataClearPixel = false) + verifyNoInteractions(mockSavedSitesRepository) + } + + @Test + fun whenClearBrowserDataOnlyCalledAndSyncDisabledThenSavedSitesPruneDeleted() = runTest { + whenever(mockDeviceSyncState.isUserSignedInOnDevice()).thenReturn(false) + testee.clearBrowserDataOnly(shouldFireDataClearPixel = false) + verify(mockSavedSitesRepository).pruneDeleted() + } + + @Test + fun whenClearBrowserDataOnlyCalledThenPrivacyProtectionsPopupDataClearerIsInvoked() = runTest { + testee.clearBrowserDataOnly(shouldFireDataClearPixel = false) + verify(mockPrivacyProtectionsPopupDataClearer).clearPersonalData() + } + + @Test + fun whenClearBrowserDataOnlyCalledThenWebTrackersAreCleared() = runTest { + testee.clearBrowserDataOnly(shouldFireDataClearPixel = false) + verify(mockWebTrackersBlockedRepository).deleteAll() + } + + @Test + fun whenClearBrowserDataOnlyCalledThenNavigationHistoryCleared() = runTest { + testee.clearBrowserDataOnly(shouldFireDataClearPixel = false) + verify(mockNavigationHistory).clearHistory() + } + + @Test + fun whenClearBrowserDataOnlyCalledThenTabsNotCleared() = runTest { + testee.clearBrowserDataOnly(shouldFireDataClearPixel = false) + verify(mockTabRepository, never()).deleteAll() + } + + @Test + fun whenClearBrowserDataOnlyCalledThenCookieManagerFlushed() = runTest { + testee.clearBrowserDataOnly(shouldFireDataClearPixel = false) + verify(mockCookieManager).flush() + } + + @Test + fun whenClearDuckAiChatsOnlyCalledThenDataManagerClearsDataWithCorrectFlagsAndInteractions() = runTest { + testee.clearDuckAiChatsOnly() + verify(mockDataManager).clearData(any(), any(), shouldClearBrowserData = eq(false), shouldClearDuckAiData = eq(true)) + verifyNoInteractions(mockTabRepository) + verifyNoInteractions(mockAppCacheClearer) + verifyNoInteractions(mockCookieManager) + verifyNoInteractions(mockThirdPartyCookieManager) + verifyNoInteractions(mockSitePermissionsManager) + verifyNoInteractions(mockNavigationHistory) + verifyNoInteractions(mockClearingUnsentForgetAllPixelStore) + } + + @Test + fun whenSetAppUsedSinceLastClearFlagCalledWithTrueThenFlagSetToTrue() = runTest { + testee.setAppUsedSinceLastClearFlag(true) + verify(mockSettingsDataStore).appUsedSinceLastClear = true + } + + @Test + fun whenSetAppUsedSinceLastClearFlagCalledWithFalseThenFlagSetToFalse() = runTest { + testee.setAppUsedSinceLastClearFlag(false) + verify(mockSettingsDataStore).appUsedSinceLastClear = false + } } diff --git a/app/src/main/java/com/duckduckgo/app/browser/WebDataManager.kt b/app/src/main/java/com/duckduckgo/app/browser/WebDataManager.kt index d63f292bfa09..dba6c03bbf06 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/WebDataManager.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/WebDataManager.kt @@ -22,11 +22,11 @@ import android.webkit.WebView import com.duckduckgo.anrs.api.CrashLogger import com.duckduckgo.app.browser.httpauth.WebViewHttpAuthStore import com.duckduckgo.app.browser.indexeddb.IndexedDBManager -import com.duckduckgo.app.browser.session.WebViewSessionStorage import com.duckduckgo.app.browser.weblocalstorage.WebLocalStorageManager import com.duckduckgo.app.di.AppCoroutineScope import com.duckduckgo.app.global.file.FileDeleter import com.duckduckgo.app.pixels.remoteconfig.AndroidBrowserConfigFeature +import com.duckduckgo.app.settings.db.SettingsDataStore import com.duckduckgo.appbuildconfig.api.AppBuildConfig import com.duckduckgo.appbuildconfig.api.isInternalBuild import com.duckduckgo.common.utils.DispatcherProvider @@ -45,19 +45,31 @@ import java.io.File import javax.inject.Inject interface WebDataManager { + /** + * Clears all web data from the provided WebView and WebStorage (legacy full clear). + */ suspend fun clearData( webView: WebView, webStorage: WebStorage, ) - fun clearWebViewSessions() + /** + * Clears web data from the provided WebView and WebStorage based on the specified options. + * @param shouldClearBrowserData If true, clears browser web data (cache, history, form data, authentication, cookies, directories). + * @param shouldClearDuckAiData If true, clears chat-related data from WebStorage. + */ + suspend fun clearData( + webView: WebView, + webStorage: WebStorage, + shouldClearBrowserData: Boolean, + shouldClearDuckAiData: Boolean, + ) } @ContributesBinding(AppScope::class) @SingleInstanceIn(AppScope::class) class WebViewDataManager @Inject constructor( private val context: Context, - private val webViewSessionStorage: WebViewSessionStorage, private val cookieManager: DuckDuckGoCookieManager, private val fileDeleter: FileDeleter, private val webViewHttpAuthStore: WebViewHttpAuthStore, @@ -68,6 +80,7 @@ class WebViewDataManager @Inject constructor( @AppCoroutineScope private val appCoroutineScope: CoroutineScope, private val dispatcherProvider: DispatcherProvider, private val appBuildConfig: AppBuildConfig, + private val settingsDataStore: SettingsDataStore, ) : WebDataManager { override suspend fun clearData( @@ -76,11 +89,36 @@ class WebViewDataManager @Inject constructor( ) { clearWebViewCache(webView) clearHistory(webView) - clearWebStorage(webStorage) + clearEntireWebStorage(webStorage) clearFormData(webView) clearAuthentication(webView) clearExternalCookies() - clearWebViewDirectories() + val shouldClearDuckAiData = withContext(dispatcherProvider.io()) { + settingsDataStore.clearDuckAiData + } + clearWebViewDirectories(shouldClearDuckAiData) + } + + override suspend fun clearData( + webView: WebView, + webStorage: WebStorage, + shouldClearBrowserData: Boolean, + shouldClearDuckAiData: Boolean, + ) { + clearWebStorageGranularly(webStorage, shouldClearBrowserData, shouldClearDuckAiData) + + if (shouldClearBrowserData) { + clearWebViewCache(webView) + clearHistory(webView) + clearFormData(webView) + clearAuthentication(webView) + clearExternalCookies() + clearWebViewDirectories(false) + } + + if (shouldClearDuckAiData) { + clearOnlyDuckAiWebViewDirectories() + } } private fun clearWebViewCache(webView: WebView) { @@ -91,20 +129,51 @@ class WebViewDataManager @Inject constructor( webView.clearHistory() } - private suspend fun clearWebStorage(webStorage: WebStorage) = withContext(dispatcherProvider.io()) { - if (androidBrowserConfigFeature.webLocalStorage().isEnabled()) { - kotlin.runCatching { - webLocalStorageManager.clearWebLocalStorage() - }.onFailure { e -> - logcat(ERROR) { "WebDataManager: Could not selectively clear web storage: ${e.asLog()}" } - if (appBuildConfig.isInternalBuild()) { - sendCrashPixel(e) + private suspend fun clearEntireWebStorage(webStorage: WebStorage) { + withContext(dispatcherProvider.io()) { + if (androidBrowserConfigFeature.webLocalStorage().isEnabled()) { + kotlin.runCatching { + webLocalStorageManager.clearWebLocalStorage() + }.onFailure { e -> + logcat(ERROR) { "WebDataManager: Could not selectively clear web storage: ${e.asLog()}" } + if (appBuildConfig.isInternalBuild()) { + sendCrashPixel(e) + } + // fallback, if we crash we delete everything + deleteAllData(webStorage) } - // fallback, if we crash we delete everything + } else { deleteAllData(webStorage) } - } else { - deleteAllData(webStorage) + } + } + + private suspend fun clearWebStorageGranularly( + webStorage: WebStorage, + shouldClearBrowserData: Boolean, + shouldClearDuckAiData: Boolean, + ) { + withContext(dispatcherProvider.io()) { + if (androidBrowserConfigFeature.webLocalStorage().isEnabled()) { + kotlin.runCatching { + webLocalStorageManager.clearWebLocalStorage(shouldClearBrowserData, shouldClearDuckAiData) + }.onFailure { e -> + logcat(ERROR) { "WebDataManager: Could not selectively clear web storage: ${e.asLog()}" } + if (appBuildConfig.isInternalBuild()) { + sendCrashPixel(e) + } + if (shouldClearBrowserData) { + // fallback, if we crash we delete everything + deleteAllData(webStorage) + } + } + } else { + if (shouldClearBrowserData) { + deleteAllData(webStorage) + } else { + // No-op when shouldClearData is false + } + } } } @@ -134,7 +203,7 @@ class WebViewDataManager @Inject constructor( * * the excluded directories above are to avoid clearing unnecessary cookies and because localStorage is cleared using clearWebStorage */ - private suspend fun clearWebViewDirectories() { + private suspend fun clearWebViewDirectories(shouldClearDuckAiData: Boolean) = withContext(dispatcherProvider.io()) { val dataDir = context.applicationInfo.dataDir fileDeleter.deleteContents(File(dataDir, "app_webview"), listOf("Default", "Cookies")) @@ -146,7 +215,7 @@ class WebViewDataManager @Inject constructor( } if (androidBrowserConfigFeature.indexedDB().isEnabled()) { runCatching { - indexedDBManager.clearIndexedDB() + indexedDBManager.clearIndexedDB(shouldClearDuckAiData) }.onSuccess { excludedDirectories.add("IndexedDB") }.onFailure { t -> @@ -156,6 +225,23 @@ class WebViewDataManager @Inject constructor( fileDeleter.deleteContents(File(dataDir, "app_webview/Default"), excludedDirectories) } + /** + * Clears only DuckAI-related WebView directories. + * All other website data is preserved. + */ + private suspend fun clearOnlyDuckAiWebViewDirectories() { + withContext(dispatcherProvider.io()) { + // Clear DuckAI data from IndexedDB + if (androidBrowserConfigFeature.indexedDB().isEnabled()) { + runCatching { + indexedDBManager.clearOnlyDuckAiData() + }.onFailure { t -> + logcat(WARN) { "Failed to clear DuckAI data from IndexedDB: ${t.asLog()}" } + } + } + } + } + private suspend fun clearAuthentication(webView: WebView) { webViewHttpAuthStore.clearHttpAuthUsernamePassword(webView) webViewHttpAuthStore.cleanHttpAuthDatabase() @@ -164,8 +250,4 @@ class WebViewDataManager @Inject constructor( private suspend fun clearExternalCookies() { cookieManager.removeExternalCookies() } - - override fun clearWebViewSessions() { - webViewSessionStorage.deleteAllSessions() - } } diff --git a/app/src/main/java/com/duckduckgo/app/browser/indexeddb/IndexedDBManager.kt b/app/src/main/java/com/duckduckgo/app/browser/indexeddb/IndexedDBManager.kt index 65af5ae3f62f..004a1176228a 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/indexeddb/IndexedDBManager.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/indexeddb/IndexedDBManager.kt @@ -21,7 +21,6 @@ import com.duckduckgo.app.browser.UriString.Companion.sameOrSubdomain import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteRepository import com.duckduckgo.app.global.file.FileDeleter import com.duckduckgo.app.pixels.remoteconfig.AndroidBrowserConfigFeature -import com.duckduckgo.app.settings.db.SettingsDataStore import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.AppScope import com.squareup.anvil.annotations.ContributesBinding @@ -33,7 +32,20 @@ import java.io.File import javax.inject.Inject interface IndexedDBManager { - suspend fun clearIndexedDB() + /** + * Clears IndexedDB data based on predefined settings and fireproofed websites. + * + * Uses AndroidBrowserConfigFeature to determine which domains to preserve. + * @param shouldClearDuckAiData If true, clears DuckAI-related IndexedDB data (duckduckgo.com and duck.ai domains). + * All other domains are preserved. + */ + suspend fun clearIndexedDB(shouldClearDuckAiData: Boolean) + + /** + * Clears only DuckAI-related IndexedDB data (duckduckgo.com and duck.ai domains). + * All other domains are preserved. + */ + suspend fun clearOnlyDuckAiData() } data class IndexedDBSettings( @@ -48,23 +60,33 @@ class DuckDuckGoIndexedDBManager @Inject constructor( private val fileDeleter: FileDeleter, private val moshi: Moshi, private val dispatcherProvider: DispatcherProvider, - private val settingsDataStore: SettingsDataStore, ) : IndexedDBManager { private val jsonAdapter: JsonAdapter by lazy { moshi.adapter(IndexedDBSettings::class.java) } - override suspend fun clearIndexedDB() = withContext(dispatcherProvider.io()) { + override suspend fun clearIndexedDB(shouldClearDuckAiData: Boolean) = withContext(dispatcherProvider.io()) { val allowedDomains = getAllowedDomains() logcat { "IndexedDBManager: Allowed domains: $allowedDomains" } val rootFolder = File(context.applicationInfo.dataDir, "app_webview/Default/IndexedDB") - val excludedFolders = getExcludedFolders(rootFolder, allowedDomains) + val excludedFolders = getExcludedFolders(rootFolder, allowedDomains, shouldClearDuckAiData) fileDeleter.deleteContents(rootFolder, excludedFolders) } + override suspend fun clearOnlyDuckAiData() = withContext(dispatcherProvider.io()) { + val rootFolder = File(context.applicationInfo.dataDir, "app_webview/Default/IndexedDB") + val duckAiFolders = getDuckAiFolders(rootFolder) + + logcat { "IndexedDBManager: Clearing only DuckAI folders: $duckAiFolders" } + + duckAiFolders.forEach { folderName -> + fileDeleter.deleteContents(File(rootFolder, folderName), emptyList()) + } + } + private fun getAllowedDomains(): List { val settings = androidBrowserConfigFeature.indexedDB().getSettings()?.let { runCatching { jsonAdapter.fromJson(it) }.getOrNull() @@ -83,7 +105,7 @@ class DuckDuckGoIndexedDBManager @Inject constructor( private fun getExcludedFolders( rootFolder: File, allowedDomains: List, - clearDuckAiData: Boolean = settingsDataStore.clearDuckAiData, + shouldClearDuckAiData: Boolean, ): List { return (rootFolder.listFiles() ?: emptyArray()) .filter { @@ -91,7 +113,7 @@ class DuckDuckGoIndexedDBManager @Inject constructor( val host = it.name.split("_").getOrNull(1) ?: return@filter false val isAllowed = allowedDomains.any { domain -> sameOrSubdomain(host, domain) } - if (clearDuckAiData && isFromDuckDuckGoDomains(host)) { + if (shouldClearDuckAiData && isFromDuckDuckGoDomains(host)) { false } else { isAllowed @@ -100,6 +122,16 @@ class DuckDuckGoIndexedDBManager @Inject constructor( .map { it.name } } + private fun getDuckAiFolders(rootFolder: File): List { + return (rootFolder.listFiles() ?: emptyArray()) + .filter { + // IndexedDB folders have this format: __.indexeddb.leveldb + val host = it.name.split("_").getOrNull(1) ?: return@filter false + isFromDuckDuckGoDomains(host) + } + .map { it.name } + } + private fun isFromDuckDuckGoDomains(domain: String): Boolean { return DUCKDUCKGO_DOMAINS.any { sameOrSubdomain(domain, it) } } diff --git a/app/src/main/java/com/duckduckgo/app/browser/weblocalstorage/WebLocalStorageManager.kt b/app/src/main/java/com/duckduckgo/app/browser/weblocalstorage/WebLocalStorageManager.kt index 5389636f5902..10cd57c91a9e 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/weblocalstorage/WebLocalStorageManager.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/weblocalstorage/WebLocalStorageManager.kt @@ -38,7 +38,22 @@ import java.nio.charset.StandardCharsets import javax.inject.Inject interface WebLocalStorageManager { + /** + * Clears web local storage based on predefined settings and fireproofed websites (legacy). + * + * Uses settingsDataStore.clearDuckAiData to determine if DuckAi data should be cleared. + */ suspend fun clearWebLocalStorage() + + /** + * Clears web local storage based on the specified options. + * @param shouldClearBrowserData If true, clears browser web data (cache, history, form data, authentication, cookies, directories). + * @param shouldClearDuckAiData If true, clears chat-related data from WebStorage. + */ + suspend fun clearWebLocalStorage( + shouldClearBrowserData: Boolean, + shouldClearDuckAiData: Boolean, + ) } @ContributesBinding(AppScope::class) @@ -91,6 +106,47 @@ class DuckDuckGoWebLocalStorageManager @Inject constructor( } } + override suspend fun clearWebLocalStorage( + shouldClearBrowserData: Boolean, + shouldClearDuckAiData: Boolean, + ) { + withContext(dispatcherProvider.io()) { + val settings = androidBrowserConfigFeature.webLocalStorage().getSettings() + val webLocalStorageSettings = webLocalStorageSettingsJsonParser.parseJson(settings) + + val fireproofedDomains = fireproofWebsiteRepository.fireproofWebsitesSync().map { it.domain } + + domains = webLocalStorageSettings.domains.list + fireproofedDomains + keysToDelete = webLocalStorageSettings.keysToDelete.list + matchingRegex = webLocalStorageSettings.matchingRegex.list + + logcat { "WebLocalStorageManager: Allowed domains: $domains" } + logcat { "WebLocalStorageManager: Keys to delete: $keysToDelete" } + logcat { "WebLocalStorageManager: Matching regex: $matchingRegex" } + + val db = databaseProvider.get() + db.iterator().use { iterator -> + iterator.seekToFirst() + + while (iterator.hasNext()) { + val entry = iterator.next() + val key = String(entry.key, StandardCharsets.UTF_8) + + val domainForMatchingAllowedKey = getDomainForMatchingAllowedKey(key) + if (domainForMatchingAllowedKey == null && shouldClearBrowserData) { + db.delete(entry.key) + logcat { "WebLocalStorageManager: Deleted key: $key" } + } else if (shouldClearDuckAiData && DUCKDUCKGO_DOMAINS.contains(domainForMatchingAllowedKey)) { + if (keysToDelete.any { key.endsWith(it) }) { + db.delete(entry.key) + logcat { "WebLocalStorageManager: Deleted key: $key" } + } + } + } + } + } + } + private fun getDomainForMatchingAllowedKey(key: String): String? { for (domain in domains) { val escapedDomain = Regex.escape(domain) 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 99ab1c451f75..4bae446f9522 100644 --- a/app/src/main/java/com/duckduckgo/app/di/PrivacyModule.kt +++ b/app/src/main/java/com/duckduckgo/app/di/PrivacyModule.kt @@ -17,7 +17,6 @@ package com.duckduckgo.app.di import android.content.Context -import com.duckduckgo.adclick.api.AdClickManager import com.duckduckgo.app.browser.WebDataManager import com.duckduckgo.app.browser.cookies.ThirdPartyCookieManager import com.duckduckgo.app.browser.favicon.FaviconManager @@ -79,7 +78,6 @@ object PrivacyModule { cookieManager: DuckDuckGoCookieManager, appCacheClearer: AppCacheClearer, thirdPartyCookieManager: ThirdPartyCookieManager, - adClickManager: AdClickManager, fireproofWebsiteRepository: FireproofWebsiteRepository, sitePermissionsManager: SitePermissionsManager, deviceSyncState: DeviceSyncState, @@ -98,7 +96,6 @@ object PrivacyModule { cookieManager, appCacheClearer, thirdPartyCookieManager, - adClickManager, fireproofWebsiteRepository, sitePermissionsManager, deviceSyncState, diff --git a/app/src/main/java/com/duckduckgo/app/fire/AutomaticDataClearer.kt b/app/src/main/java/com/duckduckgo/app/fire/AutomaticDataClearer.kt index 7d56ed2fa673..722429f81578 100644 --- a/app/src/main/java/com/duckduckgo/app/fire/AutomaticDataClearer.kt +++ b/app/src/main/java/com/duckduckgo/app/fire/AutomaticDataClearer.kt @@ -185,7 +185,7 @@ class AutomaticDataClearer @Inject constructor( when (clearWhat) { ClearWhatOption.CLEAR_TABS_ONLY -> { - clearDataAction.clearTabsAsync(true) + clearDataAction.clearTabsOnly(true) logcat { "Notifying listener that clearing has finished" } postDataClearerState(FINISHED) diff --git a/app/src/main/java/com/duckduckgo/app/fire/DataClearingWorker.kt b/app/src/main/java/com/duckduckgo/app/fire/DataClearingWorker.kt index fdda804bc2df..64b92a011669 100644 --- a/app/src/main/java/com/duckduckgo/app/fire/DataClearingWorker.kt +++ b/app/src/main/java/com/duckduckgo/app/fire/DataClearingWorker.kt @@ -81,7 +81,7 @@ class DataClearingWorker( when (clearWhat) { ClearWhatOption.CLEAR_NONE -> logcat(WARN) { "Automatically clear data invoked, but set to clear nothing" } - ClearWhatOption.CLEAR_TABS_ONLY -> clearDataAction.clearTabsAsync(appInForeground = false) + ClearWhatOption.CLEAR_TABS_ONLY -> clearDataAction.clearTabsOnly(appInForeground = false) ClearWhatOption.CLEAR_TABS_AND_DATA -> clearEverything() } } 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 c08f44e612cf..dd5fc5d206de 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 @@ -19,9 +19,6 @@ package com.duckduckgo.app.global.view import android.content.Context import android.webkit.WebStorage import android.webkit.WebView -import androidx.annotation.UiThread -import androidx.annotation.WorkerThread -import com.duckduckgo.adclick.api.AdClickManager import com.duckduckgo.app.browser.WebDataManager import com.duckduckgo.app.browser.cookies.ThirdPartyCookieManager import com.duckduckgo.app.fire.AppCacheClearer @@ -44,17 +41,49 @@ import logcat.LogPriority.INFO import logcat.logcat interface ClearDataAction { - - @WorkerThread - suspend fun clearTabsAsync(appInForeground: Boolean) - + /** + * Clears tabs and all browser data (legacy full clear). + * @param appInForeground whether the app is in foreground + * @param shouldFireDataClearPixel whether to fire the data clear pixel + */ suspend fun clearTabsAndAllDataAsync( appInForeground: Boolean, shouldFireDataClearPixel: Boolean, ): Unit? + /** + * Clears tabs and associated data. + * @param appInForeground whether the app is in foreground + */ + suspend fun clearTabsOnly(appInForeground: Boolean) + + /** + * Clears browser data except tabs and chats. + * @param shouldFireDataClearPixel whether to fire the data clear pixel + */ + suspend fun clearBrowserDataOnly(shouldFireDataClearPixel: Boolean) + + /** + * Clears only DuckAi chats. + */ + suspend fun clearDuckAiChatsOnly() + + /** + * Sets the flag indicating whether the app has been used since the last data clear. + * @param appUsedSinceLastClear true if the app has been used since the last clear, false otherwise + */ suspend fun setAppUsedSinceLastClearFlag(appUsedSinceLastClear: Boolean) + + /** + * Kills the current process. + */ fun killProcess() + + /** + * Kills and restarts the current process. + * @param notifyDataCleared whether to notify that data has been cleared + * @param enableTransitionAnimation whether to enable transition animation during restart + */ fun killAndRestartProcess(notifyDataCleared: Boolean, enableTransitionAnimation: Boolean = true) } @@ -67,7 +96,6 @@ class ClearPersonalDataAction( private val cookieManager: DuckDuckGoCookieManager, private val appCacheClearer: AppCacheClearer, private val thirdPartyCookieManager: ThirdPartyCookieManager, - private val adClickManager: AdClickManager, private val fireproofWebsiteRepository: FireproofWebsiteRepository, private val sitePermissionsManager: SitePermissionsManager, private val deviceSyncState: DeviceSyncState, @@ -105,44 +133,92 @@ class ClearPersonalDataAction( privacyProtectionsPopupDataClearer.clearPersonalData() - clearTabsAsync(appInForeground) + clearTabsOnly(appInForeground) webTrackersBlockedRepository.deleteAll() navigationHistory.clearHistory() } - withContext(dispatchers.main()) { - clearDataAsync(shouldFireDataClearPixel) - } + clearDataAsync(shouldFireDataClearPixel) logcat(INFO) { "Finished clearing everything" } } - @WorkerThread - override suspend fun clearTabsAsync(appInForeground: Boolean) { + override suspend fun clearTabsOnly(appInForeground: Boolean) { withContext(dispatchers.io()) { - logcat(INFO) { "Clearing tabs" } - dataManager.clearWebViewSessions() tabRepository.deleteAll() - adClickManager.clearAll() setAppUsedSinceLastClearFlag(appInForeground) logcat { "Finished clearing tabs" } } } - @UiThread + override suspend fun clearBrowserDataOnly(shouldFireDataClearPixel: Boolean) { + withContext(dispatchers.io()) { + val fireproofDomains = fireproofWebsiteRepository.fireproofWebsitesSync().map { it.domain } + cookieManager.flush() + sitePermissionsManager.clearAllButFireproof(fireproofDomains) + thirdPartyCookieManager.clearAllData() + + // https://app.asana.com/0/69071770703008/1204375817149200/f + if (!deviceSyncState.isUserSignedInOnDevice()) { + savedSitesRepository.pruneDeleted() + } + + privacyProtectionsPopupDataClearer.clearPersonalData() + + webTrackersBlockedRepository.deleteAll() + + navigationHistory.clearHistory() + } + + clearDataGranularlyAsync(shouldFireDataClearPixel) + + logcat(INFO) { "Finished clearing browser data" } + } + + override suspend fun clearDuckAiChatsOnly() { + withContext(dispatchers.main()) { + dataManager.clearData( + webView = createWebView(), + webStorage = createWebStorage(), + shouldClearBrowserData = false, + shouldClearDuckAiData = true, + ) + + logcat(INFO) { "Finished clearing chats" } + } + } + private suspend fun clearDataAsync(shouldFireDataClearPixel: Boolean) { - logcat(INFO) { "Clearing data" } + withContext(dispatchers.main()) { + if (shouldFireDataClearPixel) { + clearingStore.incrementCount() + } - if (shouldFireDataClearPixel) { - clearingStore.incrementCount() + dataManager.clearData(createWebView(), createWebStorage()) + appCacheClearer.clearCache() + + logcat(INFO) { "Finished clearing data" } } + } - dataManager.clearData(createWebView(), createWebStorage()) - appCacheClearer.clearCache() + private suspend fun clearDataGranularlyAsync(shouldFireDataClearPixel: Boolean) { + withContext(dispatchers.main()) { + if (shouldFireDataClearPixel) { + clearingStore.incrementCount() + } - logcat(INFO) { "Finished clearing data" } + dataManager.clearData( + webView = createWebView(), + webStorage = createWebStorage(), + shouldClearBrowserData = true, + shouldClearDuckAiData = false, + ) + appCacheClearer.clearCache() + + logcat(INFO) { "Finished clearing data" } + } } private fun createWebView(): WebView { 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 bc100c2c49ea..814b02d8bb49 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 @@ -403,6 +403,8 @@ class TabDataRepository @Inject constructor( tabsDao.deleteAllTabs() webViewPreviewPersister.deleteAll() faviconManager.deleteAllTemp() + adClickManager.clearAll() + webViewSessionStorage.deleteAllSessions() siteData.clear() } diff --git a/app/src/test/java/com/duckduckgo/app/browser/DuckDuckGoWebLocalStorageManagerTest.kt b/app/src/test/java/com/duckduckgo/app/browser/DuckDuckGoWebLocalStorageManagerTest.kt index ffe645541124..fb91f5c4e471 100644 --- a/app/src/test/java/com/duckduckgo/app/browser/DuckDuckGoWebLocalStorageManagerTest.kt +++ b/app/src/test/java/com/duckduckgo/app/browser/DuckDuckGoWebLocalStorageManagerTest.kt @@ -69,7 +69,7 @@ class DuckDuckGoWebLocalStorageManagerTest { whenever(mockAndroidBrowserConfigFeature.webLocalStorage()).thenReturn(mockWebLocalStorageToggle) whenever(mockWebLocalStorageToggle.getSettings()).thenReturn("settings") - val domains = Domains(list = listOf("duckduckgo.com")) + val domains = Domains(list = listOf("duckduckgo.com", "duck.ai")) val matchingRegex = MatchingRegex( list = listOf( "^_https://([a-zA-Z0-9.-]+\\.)?{domain}\u0000\u0001.+$", @@ -77,7 +77,8 @@ class DuckDuckGoWebLocalStorageManagerTest { "^METAACCESS:https://([a-zA-Z0-9.-]+\\.)?{domain}$", ), ) - val webLocalStorageSettings = WebLocalStorageSettings(domains = domains, matchingRegex = matchingRegex) + val keysToDelete = com.duckduckgo.app.browser.weblocalstorage.KeysToDelete(list = listOf("chat-history", "ai-conversations")) + val webLocalStorageSettings = WebLocalStorageSettings(domains = domains, keysToDelete = keysToDelete, matchingRegex = matchingRegex) whenever(mockWebLocalStorageSettingsJsonParser.parseJson("settings")).thenReturn(webLocalStorageSettings) } @@ -195,6 +196,186 @@ class DuckDuckGoWebLocalStorageManagerTest { verify(mockDB).delete(key2) } + @Test + fun whenClearWebLocalStorageWithShouldClearDataTrueThenDeletesNonAllowedKeys() = runTest { + val key1 = bytes("_https://example.com\u0000\u0001key1") + val key2 = bytes("_https://duckduckgo.com\u0000\u0001chat-history") + val entry1 = createMockDBEntry(key1) + val entry2 = createMockDBEntry(key2) + + whenever(mockDB.iterator()).thenReturn(mockIterator) + whenever(mockIterator.hasNext()).thenReturn(true, true, false) + whenever(mockIterator.next()).thenReturn(entry1, entry2) + + testee.clearWebLocalStorage(shouldClearBrowserData = true, shouldClearDuckAiData = false) + + verify(mockDB).delete(key1) + verify(mockDB, never()).delete(key2) + } + + @Test + fun whenClearWebLocalStorageWithShouldClearDataFalseThenDoesNotDeleteNonAllowedKeys() = runTest { + val key1 = bytes("_https://example.com\u0000\u0001key1") + val key2 = bytes("_https://foo.com\u0000\u0001key2") + val key3 = bytes("_https://duckduckgo.com\u0000\u0001chat-history") + val entry1 = createMockDBEntry(key1) + val entry2 = createMockDBEntry(key2) + val entry3 = createMockDBEntry(key3) + + whenever(mockDB.iterator()).thenReturn(mockIterator) + whenever(mockIterator.hasNext()).thenReturn(true, true, true, false) + whenever(mockIterator.next()).thenReturn(entry1, entry2, entry3) + + testee.clearWebLocalStorage(shouldClearBrowserData = false, shouldClearDuckAiData = false) + + verify(mockDB, never()).delete(key1) + verify(mockDB, never()).delete(key2) + verify(mockDB, never()).delete(key3) + } + + @Test + fun whenClearWebLocalStorageWithShouldClearChatsTrueThenDeletesDuckDuckGoChatKeys() = runTest { + val key1 = bytes("_https://duckduckgo.com\u0000\u0001chat-history") + val key2 = bytes("_https://duckduckgo.com\u0000\u0001regular-key") + val entry1 = createMockDBEntry(key1) + val entry2 = createMockDBEntry(key2) + + whenever(mockDB.iterator()).thenReturn(mockIterator) + whenever(mockIterator.hasNext()).thenReturn(true, true, false) + whenever(mockIterator.next()).thenReturn(entry1, entry2) + + testee.clearWebLocalStorage(shouldClearBrowserData = false, shouldClearDuckAiData = true) + + verify(mockDB).delete(key1) + verify(mockDB, never()).delete(key2) + } + + @Test + fun whenClearWebLocalStorageWithShouldClearChatsTrueThenDeletesDuckAiChatKeys() = runTest { + val key1 = bytes("_https://duck.ai\u0000\u0001ai-conversations") + val key2 = bytes("_https://duck.ai\u0000\u0001regular-key") + val entry1 = createMockDBEntry(key1) + val entry2 = createMockDBEntry(key2) + + whenever(mockDB.iterator()).thenReturn(mockIterator) + whenever(mockIterator.hasNext()).thenReturn(true, true, false) + whenever(mockIterator.next()).thenReturn(entry1, entry2) + + testee.clearWebLocalStorage(shouldClearBrowserData = false, shouldClearDuckAiData = true) + + verify(mockDB).delete(key1) + verify(mockDB, never()).delete(key2) + } + + @Test + fun whenClearWebLocalStorageWithShouldClearChatsFalseThenDoesNotDeleteDuckDuckGoChatKeys() = runTest { + val key1 = bytes("_https://duckduckgo.com\u0000\u0001chat-history") + val key2 = bytes("_https://duckduckgo.com\u0000\u0001ai-conversations") + val entry1 = createMockDBEntry(key1) + val entry2 = createMockDBEntry(key2) + + whenever(mockDB.iterator()).thenReturn(mockIterator) + whenever(mockIterator.hasNext()).thenReturn(true, true, false) + whenever(mockIterator.next()).thenReturn(entry1, entry2) + + testee.clearWebLocalStorage(shouldClearBrowserData = false, shouldClearDuckAiData = false) + + verify(mockDB, never()).delete(key1) + verify(mockDB, never()).delete(key2) + } + + @Test + fun whenClearWebLocalStorageWithBothTrueThenDeletesBothDataAndChatKeys() = runTest { + val key1 = bytes("_https://example.com\u0000\u0001key1") + val key2 = bytes("_https://duckduckgo.com\u0000\u0001chat-history") + val key3 = bytes("_https://duckduckgo.com\u0000\u0001regular-key") + val entry1 = createMockDBEntry(key1) + val entry2 = createMockDBEntry(key2) + val entry3 = createMockDBEntry(key3) + + whenever(mockDB.iterator()).thenReturn(mockIterator) + whenever(mockIterator.hasNext()).thenReturn(true, true, true, false) + whenever(mockIterator.next()).thenReturn(entry1, entry2, entry3) + + testee.clearWebLocalStorage(shouldClearBrowserData = true, shouldClearDuckAiData = true) + + verify(mockDB).delete(key1) + verify(mockDB).delete(key2) + verify(mockDB, never()).delete(key3) + } + + @Test + fun whenClearWebLocalStorageWithBothFalseThenDoesNotDeleteAnything() = runTest { + val key1 = bytes("_https://example.com\u0000\u0001key1") + val key2 = bytes("_https://duckduckgo.com\u0000\u0001chat-history") + val entry1 = createMockDBEntry(key1) + val entry2 = createMockDBEntry(key2) + + whenever(mockDB.iterator()).thenReturn(mockIterator) + whenever(mockIterator.hasNext()).thenReturn(true, true, false) + whenever(mockIterator.next()).thenReturn(entry1, entry2) + + testee.clearWebLocalStorage(shouldClearBrowserData = false, shouldClearDuckAiData = false) + + verify(mockDB, never()).delete(key1) + verify(mockDB, never()).delete(key2) + } + + @Test + fun whenClearWebLocalStorageWithShouldClearDataTrueThenRespectsFireproofedDomains() = runTest { + whenever(mockFireproofWebsiteRepository.fireproofWebsitesSync()).thenReturn(listOf(FireproofWebsiteEntity("example.com"))) + + val key1 = bytes("_https://example.com\u0000\u0001key1") + val key2 = bytes("_https://foo.com\u0000\u0001key2") + val entry1 = createMockDBEntry(key1) + val entry2 = createMockDBEntry(key2) + + whenever(mockDB.iterator()).thenReturn(mockIterator) + whenever(mockIterator.hasNext()).thenReturn(true, true, false) + whenever(mockIterator.next()).thenReturn(entry1, entry2) + + testee.clearWebLocalStorage(shouldClearBrowserData = true, shouldClearDuckAiData = false) + + verify(mockDB, never()).delete(key1) + verify(mockDB).delete(key2) + } + + @Test + fun whenClearWebLocalStorageWithShouldClearChatsTrueThenOnlyDeletesChatKeysForDuckDuckGoDomains() = runTest { + val key1 = bytes("_https://example.com\u0000\u0001chat-history") + val key2 = bytes("_https://duckduckgo.com\u0000\u0001chat-history") + val entry1 = createMockDBEntry(key1) + val entry2 = createMockDBEntry(key2) + + whenever(mockDB.iterator()).thenReturn(mockIterator) + whenever(mockIterator.hasNext()).thenReturn(true, true, false) + whenever(mockIterator.next()).thenReturn(entry1, entry2) + + testee.clearWebLocalStorage(shouldClearBrowserData = false, shouldClearDuckAiData = true) + + verify(mockDB, never()).delete(key1) + verify(mockDB).delete(key2) + } + + @Test + fun whenClearWebLocalStorageWithParametersThenIteratorSeeksToFirst() = runTest { + whenever(mockDB.iterator()).thenReturn(mockIterator) + + testee.clearWebLocalStorage(shouldClearBrowserData = true, shouldClearDuckAiData = true) + + verify(mockIterator).seekToFirst() + } + + @Test + fun whenClearWebLocalStorageWithParametersThenIteratorIsClosed() = runTest { + whenever(mockDB.iterator()).thenReturn(mockIterator) + whenever(mockIterator.hasNext()).thenReturn(false) + + testee.clearWebLocalStorage(shouldClearBrowserData = true, shouldClearDuckAiData = false) + + verify(mockIterator).close() + } + private fun createMockDBEntry(key: ByteArray): MutableMap.MutableEntry { return object : MutableMap.MutableEntry { override val key: ByteArray = key diff --git a/app/src/test/java/com/duckduckgo/app/browser/indexeddb/IndexedDBManagerTest.kt b/app/src/test/java/com/duckduckgo/app/browser/indexeddb/IndexedDBManagerTest.kt index 0ea463f64979..332638aa11f4 100644 --- a/app/src/test/java/com/duckduckgo/app/browser/indexeddb/IndexedDBManagerTest.kt +++ b/app/src/test/java/com/duckduckgo/app/browser/indexeddb/IndexedDBManagerTest.kt @@ -22,7 +22,6 @@ import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteEntity import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteRepository import com.duckduckgo.app.global.file.FileDeleter import com.duckduckgo.app.pixels.remoteconfig.AndroidBrowserConfigFeature -import com.duckduckgo.app.settings.db.SettingsDataStore import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.feature.toggles.api.Toggle import com.squareup.moshi.Moshi @@ -31,6 +30,7 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith +import org.mockito.kotlin.any import org.mockito.kotlin.argThat import org.mockito.kotlin.eq import org.mockito.kotlin.mock @@ -51,7 +51,6 @@ class IndexedDBManagerTest { private val mockFireproofToggle: Toggle = mock() private val mockFireproofRepository: FireproofWebsiteRepository = mock() private val mockFileDeleter: FileDeleter = mock() - private val mockSettingsDataStore: SettingsDataStore = mock() private val moshi: Moshi = Moshi.Builder().build() private val dispatcherProvider = coroutineRule.testDispatcherProvider @@ -63,7 +62,6 @@ class IndexedDBManagerTest { mockFileDeleter, moshi, dispatcherProvider, - mockSettingsDataStore, ) } @@ -77,7 +75,7 @@ class IndexedDBManagerTest { @Test fun whenClearIndexedDBWithNoExclusionsThenDeleteContents() = runTest { - testee.clearIndexedDB() + testee.clearIndexedDB(shouldClearDuckAiData = false) verify(mockFileDeleter).deleteContents( eq(File(context.applicationInfo.dataDir, "app_webview/Default/IndexedDB")), @@ -97,7 +95,7 @@ class IndexedDBManagerTest { "https_foo.com_0.indexeddb.leveldb", ).forEach { File(rootFolder, it).mkdirs() } - testee.clearIndexedDB() + testee.clearIndexedDB(shouldClearDuckAiData = false) verify(mockFileDeleter).deleteContents( eq(rootFolder), @@ -119,7 +117,7 @@ class IndexedDBManagerTest { "https_bar.com_0.indexeddb.leveldb", ).forEach { File(rootFolder, it).mkdirs() } - testee.clearIndexedDB() + testee.clearIndexedDB(shouldClearDuckAiData = false) verify(mockFireproofRepository).fireproofWebsitesSync() verify(mockFileDeleter).deleteContents( @@ -146,7 +144,7 @@ class IndexedDBManagerTest { "https_bar.com_0.indexeddb.leveldb", ).forEach { File(rootFolder, it).mkdirs() } - testee.clearIndexedDB() + testee.clearIndexedDB(shouldClearDuckAiData = false) verify(mockFireproofRepository).fireproofWebsitesSync() verify(mockFileDeleter).deleteContents( @@ -172,7 +170,7 @@ class IndexedDBManagerTest { "https_other.com_0.indexeddb.leveldb", ).forEach { File(rootFolder, it).mkdirs() } - testee.clearIndexedDB() + testee.clearIndexedDB(shouldClearDuckAiData = false) verify(mockFileDeleter).deleteContents( eq(rootFolder), @@ -189,8 +187,218 @@ class IndexedDBManagerTest { fun whenFireproofedIndexDBDisabledThenDoNotFetchFireproofWebsites() = runTest { whenever(mockFireproofToggle.isEnabled()).thenReturn(false) - testee.clearIndexedDB() + testee.clearIndexedDB(shouldClearDuckAiData = false) verify(mockFireproofRepository, never()).fireproofWebsitesSync() } + + @Test + fun whenShouldClearDuckAiDataTrueThenDuckDuckGoDomainsNotExcluded() = runTest { + val rootFolder = File(context.applicationInfo.dataDir, "app_webview/Default/IndexedDB") + listOf( + "https_duckduckgo.com_0.indexeddb.leveldb", + "https_duck.ai_0.indexeddb.leveldb", + "https_example.com_0.indexeddb.leveldb", + ).forEach { File(rootFolder, it).mkdirs() } + + testee.clearIndexedDB(shouldClearDuckAiData = true) + + verify(mockFileDeleter).deleteContents( + eq(rootFolder), + eq(emptyList()), + ) + } + + @Test + fun whenShouldClearDuckAiDataFalseAndSettingsHasDuckDuckGoDomainsConfiguredThenDuckDuckGoDomainsExcluded() = runTest { + val json = """{"domains":["duckduckgo.com","duck.ai"]}""" + whenever(mockIndexedDBToggle.getSettings()).thenReturn(json) + whenever(mockFireproofToggle.isEnabled()).thenReturn(false) + + val rootFolder = File(context.applicationInfo.dataDir, "app_webview/Default/IndexedDB") + listOf( + "https_duckduckgo.com_0.indexeddb.leveldb", + "https_duck.ai_0.indexeddb.leveldb", + "https_example.com_0.indexeddb.leveldb", + ).forEach { File(rootFolder, it).mkdirs() } + + testee.clearIndexedDB(shouldClearDuckAiData = false) + + verify(mockFileDeleter).deleteContents( + eq(rootFolder), + argThat { list -> + list.toSet() == setOf( + "https_duckduckgo.com_0.indexeddb.leveldb", + "https_duck.ai_0.indexeddb.leveldb", + ) + }, + ) + } + + @Test + fun whenShouldClearDuckAiDataTrueAndSettingsHasDuckDuckGoDomainsConfiguredThenDuckDuckGoDomainsNotExcluded() = runTest { + val json = """{"domains":["duckduckgo.com","duck.ai"]}""" + whenever(mockIndexedDBToggle.getSettings()).thenReturn(json) + whenever(mockFireproofToggle.isEnabled()).thenReturn(false) + + val rootFolder = File(context.applicationInfo.dataDir, "app_webview/Default/IndexedDB") + listOf( + "https_duckduckgo.com_0.indexeddb.leveldb", + "https_duck.ai_0.indexeddb.leveldb", + "https_example.com_0.indexeddb.leveldb", + ).forEach { File(rootFolder, it).mkdirs() } + + testee.clearIndexedDB(shouldClearDuckAiData = true) + + verify(mockFileDeleter).deleteContents( + eq(rootFolder), + eq(emptyList()), + ) + } + + @Test + fun whenShouldClearDuckAiDataTrueAndSettingsHasOtherDomainsThenOtherDomainsExcludedButDuckDuckGoDomainsNotExcluded() = runTest { + val json = """{"domains":["example.com"]}""" + whenever(mockIndexedDBToggle.getSettings()).thenReturn(json) + whenever(mockFireproofToggle.isEnabled()).thenReturn(false) + + val rootFolder = File(context.applicationInfo.dataDir, "app_webview/Default/IndexedDB") + listOf( + "https_duckduckgo.com_0.indexeddb.leveldb", + "https_duck.ai_0.indexeddb.leveldb", + "https_example.com_0.indexeddb.leveldb", + "https_foo.com_0.indexeddb.leveldb", + ).forEach { File(rootFolder, it).mkdirs() } + + testee.clearIndexedDB(shouldClearDuckAiData = true) + + verify(mockFileDeleter).deleteContents( + eq(rootFolder), + eq(listOf("https_example.com_0.indexeddb.leveldb")), + ) + } + + @Test + fun whenShouldClearDuckAiDataFalseAndSettingsHasOtherDomainsThenAllDomainsExcluded() = runTest { + val json = """{"domains":["example.com","duckduckgo.com"]}""" + whenever(mockIndexedDBToggle.getSettings()).thenReturn(json) + whenever(mockFireproofToggle.isEnabled()).thenReturn(false) + + val rootFolder = File(context.applicationInfo.dataDir, "app_webview/Default/IndexedDB") + listOf( + "https_duckduckgo.com_0.indexeddb.leveldb", + "https_example.com_0.indexeddb.leveldb", + "https_foo.com_0.indexeddb.leveldb", + ).forEach { File(rootFolder, it).mkdirs() } + + testee.clearIndexedDB(shouldClearDuckAiData = false) + + verify(mockFileDeleter).deleteContents( + eq(rootFolder), + argThat { list -> + list.toSet() == setOf( + "https_duckduckgo.com_0.indexeddb.leveldb", + "https_example.com_0.indexeddb.leveldb", + ) + }, + ) + } + + @Test + fun whenShouldClearDuckAiDataTrueWithSubdomainsThenDuckDuckGoSubdomainsNotExcluded() = runTest { + val json = """{"domains":["example.com"]}""" + whenever(mockIndexedDBToggle.getSettings()).thenReturn(json) + whenever(mockFireproofToggle.isEnabled()).thenReturn(false) + + val rootFolder = File(context.applicationInfo.dataDir, "app_webview/Default/IndexedDB") + listOf( + "https_chat.duckduckgo.com_0.indexeddb.leveldb", + "https_www.duck.ai_0.indexeddb.leveldb", + "https_example.com_0.indexeddb.leveldb", + ).forEach { File(rootFolder, it).mkdirs() } + + testee.clearIndexedDB(shouldClearDuckAiData = true) + + verify(mockFileDeleter).deleteContents( + eq(rootFolder), + eq(listOf("https_example.com_0.indexeddb.leveldb")), + ) + } + + @Test + fun whenClearOnlyDuckAiDataCalledThenOnlyDuckDuckGoDomainsDeleted() = runTest { + val rootFolder = File(context.applicationInfo.dataDir, "app_webview/Default/IndexedDB") + val duckDuckGoFolder = File(rootFolder, "https_duckduckgo.com_0.indexeddb.leveldb") + val duckAiFolder = File(rootFolder, "https_duck.ai_0.indexeddb.leveldb") + val exampleFolder = File(rootFolder, "https_example.com_0.indexeddb.leveldb") + + listOf(duckDuckGoFolder, duckAiFolder, exampleFolder).forEach { it.mkdirs() } + + testee.clearOnlyDuckAiData() + + verify(mockFileDeleter).deleteContents(eq(duckDuckGoFolder), eq(emptyList())) + verify(mockFileDeleter).deleteContents(eq(duckAiFolder), eq(emptyList())) + verify(mockFileDeleter, never()).deleteContents(eq(exampleFolder), any()) + } + + @Test + fun whenClearOnlyDuckAiDataCalledWithSubdomainsThenDuckDuckGoSubdomainsDeleted() = runTest { + val rootFolder = File(context.applicationInfo.dataDir, "app_webview/Default/IndexedDB") + val chatDuckDuckGoFolder = File(rootFolder, "https_chat.duckduckgo.com_0.indexeddb.leveldb") + val wwwDuckAiFolder = File(rootFolder, "https_www.duck.ai_0.indexeddb.leveldb") + val exampleFolder = File(rootFolder, "https_example.com_0.indexeddb.leveldb") + + listOf(chatDuckDuckGoFolder, wwwDuckAiFolder, exampleFolder).forEach { it.mkdirs() } + + testee.clearOnlyDuckAiData() + + verify(mockFileDeleter).deleteContents(eq(chatDuckDuckGoFolder), eq(emptyList())) + verify(mockFileDeleter).deleteContents(eq(wwwDuckAiFolder), eq(emptyList())) + verify(mockFileDeleter, never()).deleteContents(eq(exampleFolder), any()) + } + + @Test + fun whenClearOnlyDuckAiDataCalledWithNoDuckDuckGoDomainsThemNothingDeleted() = runTest { + val rootFolder = File(context.applicationInfo.dataDir, "app_webview/Default/IndexedDB") + val exampleFolder = File(rootFolder, "https_example.com_0.indexeddb.leveldb") + val fooFolder = File(rootFolder, "https_foo.com_0.indexeddb.leveldb") + + listOf(exampleFolder, fooFolder).forEach { it.mkdirs() } + + testee.clearOnlyDuckAiData() + + verify(mockFileDeleter, never()).deleteContents(any(), any()) + } + + @Test + fun whenClearOnlyDuckAiDataCalledWithOnlyDuckDuckGoDomainsThenAllDeleted() = runTest { + val rootFolder = File(context.applicationInfo.dataDir, "app_webview/Default/IndexedDB") + val duckDuckGoFolder = File(rootFolder, "https_duckduckgo.com_0.indexeddb.leveldb") + val duckAiFolder = File(rootFolder, "https_duck.ai_0.indexeddb.leveldb") + + listOf(duckDuckGoFolder, duckAiFolder).forEach { it.mkdirs() } + + testee.clearOnlyDuckAiData() + + verify(mockFileDeleter).deleteContents(eq(duckDuckGoFolder), eq(emptyList())) + verify(mockFileDeleter).deleteContents(eq(duckAiFolder), eq(emptyList())) + } + + @Test + fun whenClearOnlyDuckAiDataCalledThenOtherDomainsPreserved() = runTest { + val rootFolder = File(context.applicationInfo.dataDir, "app_webview/Default/IndexedDB") + val duckDuckGoFolder = File(rootFolder, "https_duckduckgo.com_0.indexeddb.leveldb") + val exampleFolder = File(rootFolder, "https_example.com_0.indexeddb.leveldb") + val fooFolder = File(rootFolder, "https_foo.com_0.indexeddb.leveldb") + val barFolder = File(rootFolder, "https_bar.com_0.indexeddb.leveldb") + + listOf(duckDuckGoFolder, exampleFolder, fooFolder, barFolder).forEach { it.mkdirs() } + + testee.clearOnlyDuckAiData() + + verify(mockFileDeleter).deleteContents(eq(duckDuckGoFolder), eq(emptyList())) + verify(mockFileDeleter, never()).deleteContents(eq(exampleFolder), any()) + verify(mockFileDeleter, never()).deleteContents(eq(fooFolder), any()) + verify(mockFileDeleter, never()).deleteContents(eq(barFolder), any()) + } } diff --git a/app/src/test/java/com/duckduckgo/tabs/model/TabDataRepositoryTest.kt b/app/src/test/java/com/duckduckgo/tabs/model/TabDataRepositoryTest.kt index 190840150bdd..5c422d9ea363 100644 --- a/app/src/test/java/com/duckduckgo/tabs/model/TabDataRepositoryTest.kt +++ b/app/src/test/java/com/duckduckgo/tabs/model/TabDataRepositoryTest.kt @@ -97,6 +97,10 @@ class TabDataRepositoryTest { private val mockAdClickManager: AdClickManager = mock() + private val mockWebViewPreviewPersister: WebViewPreviewPersister = mock() + + private val mockFaviconManager: FaviconManager = mock() + @After fun after() { daoDeletableTabs.close() @@ -217,13 +221,20 @@ class TabDataRepositoryTest { @Test fun whenAllDeletedThenTabAndDataCleared() = runTest { - val testee = tabDataRepository() + val testee = tabDataRepository( + webViewPreviewPersister = mockWebViewPreviewPersister, + faviconManager = mockFaviconManager, + ) val addedTabId = testee.add() val siteData = testee.retrieveSiteData(addedTabId) testee.deleteAll() verify(mockDao).deleteAllTabs() + verify(mockWebViewPreviewPersister).deleteAll() + verify(mockFaviconManager).deleteAllTemp() + verify(mockAdClickManager).clearAll() + verify(mockWebViewSessionStorage).deleteAllSessions() assertNotSame(siteData, testee.retrieveSiteData(addedTabId)) }