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 ce5d0f897903..0d3c0bcabce7 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,6 +21,7 @@ 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 @@ -47,6 +48,7 @@ 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 { @@ -81,13 +83,24 @@ class DuckDuckGoIndexedDBManager @Inject constructor( private fun getExcludedFolders( rootFolder: File, allowedDomains: List, + clearDuckAiData: Boolean = settingsDataStore.clearDuckAiData, ): List { return (rootFolder.listFiles() ?: emptyArray()) .filter { // IndexedDB folders have this format: __.indexeddb.leveldb val host = it.name.split("_").getOrNull(1) ?: return@filter false - allowedDomains.any { domain -> sameOrSubdomain(host, domain) } + val isAllowed = allowedDomains.any { domain -> sameOrSubdomain(host, domain) } + + if (clearDuckAiData && sameOrSubdomain(host, DUCKDUCKGO_DOMAIN)) { + false + } else { + isAllowed + } } .map { it.name } } + + companion object { + const val DUCKDUCKGO_DOMAIN = "duckduckgo.com" + } } 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 c50e0a18898b..f1d1ca9c9423 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 @@ -19,6 +19,7 @@ package com.duckduckgo.app.browser.weblocalstorage import android.content.Context import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteRepository 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 @@ -48,9 +49,11 @@ class DuckDuckGoWebLocalStorageManager @Inject constructor( private val webLocalStorageSettingsJsonParser: WebLocalStorageSettingsJsonParser, private val fireproofWebsiteRepository: FireproofWebsiteRepository, private val dispatcherProvider: DispatcherProvider, + private val settingsDataStore: SettingsDataStore, ) : WebLocalStorageManager { private var domains = emptyList() + private var allowedKeys = emptyList() private var matchingRegex = emptyList() override fun clearWebLocalStorage() = runBlocking { @@ -66,9 +69,11 @@ class DuckDuckGoWebLocalStorageManager @Inject constructor( } domains = webLocalStorageSettings.domains.list + fireproofedDomains + allowedKeys = webLocalStorageSettings.allowedKeys.list matchingRegex = webLocalStorageSettings.matchingRegex.list logcat { "WebLocalStorageManager: Allowed domains: $domains" } + logcat { "WebLocalStorageManager: Allowed keys: $allowedKeys" } logcat { "WebLocalStorageManager: Matching regex: $matchingRegex" } val db = databaseProvider.get() @@ -79,22 +84,35 @@ class DuckDuckGoWebLocalStorageManager @Inject constructor( val entry = iterator.next() val key = String(entry.key, StandardCharsets.UTF_8) - if (!isAllowedKey(key)) { + val domainForMatchingAllowedKey = getDomainForMatchingAllowedKey(key) + if (domainForMatchingAllowedKey == null) { db.delete(entry.key) logcat { "WebLocalStorageManager: Deleted key: $key" } + } else if (settingsDataStore.clearDuckAiData && domainForMatchingAllowedKey == DUCKDUCKGO_DOMAIN) { + if (allowedKeys.none { key.endsWith(it) }) { + db.delete(entry.key) + logcat { "WebLocalStorageManager: Deleted key: $key" } + } } } } } - private fun isAllowedKey(key: String): Boolean { - val regexPatterns = domains.flatMap { domain -> + private fun getDomainForMatchingAllowedKey(key: String): String? { + for (domain in domains) { val escapedDomain = Regex.escape(domain) - matchingRegex.map { pattern -> + val regexPatterns = matchingRegex.map { pattern -> pattern.replace("{domain}", escapedDomain) } + if (regexPatterns.any { pattern -> Regex(pattern).matches(key) }) { + return domain + } } - return regexPatterns.any { pattern -> Regex(pattern).matches(key) } + return null + } + + companion object { + const val DUCKDUCKGO_DOMAIN = "duckduckgo.com" } } diff --git a/app/src/main/java/com/duckduckgo/app/browser/weblocalstorage/WebLocalStorageSettingsJsonParser.kt b/app/src/main/java/com/duckduckgo/app/browser/weblocalstorage/WebLocalStorageSettingsJsonParser.kt index 388f8c85b70a..97024a787400 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/weblocalstorage/WebLocalStorageSettingsJsonParser.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/weblocalstorage/WebLocalStorageSettingsJsonParser.kt @@ -26,10 +26,12 @@ import javax.inject.Inject import kotlinx.coroutines.withContext data class Domains(val list: List = emptyList()) +data class AllowedKeys(val list: List = emptyList()) data class MatchingRegex(val list: List = emptyList()) data class WebLocalStorageSettings( val domains: Domains = Domains(), + val allowedKeys: AllowedKeys = AllowedKeys(), val matchingRegex: MatchingRegex = MatchingRegex(), ) @@ -50,26 +52,32 @@ class WebLocalStorageSettingsJsonParserImpl @Inject constructor( } override suspend fun parseJson(json: String?): WebLocalStorageSettings = withContext(dispatcherProvider.io()) { - if (json == null) return@withContext WebLocalStorageSettings(Domains(), MatchingRegex()) + if (json == null) return@withContext WebLocalStorageSettings(Domains(), AllowedKeys(), MatchingRegex()) kotlin.runCatching { val parsed = jsonAdapter.fromJson(json) val domains = parsed?.asDomains() ?: Domains() + val allowedKeys = parsed?.asAllowedKeys() ?: AllowedKeys() val matchingRegex = parsed?.asMatchingRegex() ?: MatchingRegex() - WebLocalStorageSettings(domains, matchingRegex) - }.getOrDefault(WebLocalStorageSettings(Domains(), MatchingRegex())) + WebLocalStorageSettings(domains, allowedKeys, matchingRegex) + }.getOrDefault(WebLocalStorageSettings(Domains(), AllowedKeys(), MatchingRegex())) } private fun SettingsJson.asDomains(): Domains { - return Domains(domains.map { it }) + return Domains(domains ?: emptyList()) + } + + private fun SettingsJson.asAllowedKeys(): AllowedKeys { + return AllowedKeys(allowedKeys ?: emptyList()) } private fun SettingsJson.asMatchingRegex(): MatchingRegex { - return MatchingRegex(matchingRegex.map { it }) + return MatchingRegex(matchingRegex ?: emptyList()) } private data class SettingsJson( - val domains: List, - val matchingRegex: List, + val domains: List?, + val allowedKeys: List?, + val matchingRegex: List?, ) } diff --git a/app/src/main/java/com/duckduckgo/app/firebutton/FireButtonActivity.kt b/app/src/main/java/com/duckduckgo/app/firebutton/FireButtonActivity.kt index 2300011c56c5..8f0d9ba350fa 100644 --- a/app/src/main/java/com/duckduckgo/app/firebutton/FireButtonActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/firebutton/FireButtonActivity.kt @@ -20,6 +20,7 @@ import android.app.ActivityOptions import android.content.Context import android.content.Intent import android.os.Bundle +import android.view.View import androidx.annotation.StringRes import androidx.lifecycle.Lifecycle import androidx.lifecycle.flowWithLifecycle @@ -84,6 +85,7 @@ class FireButtonActivity : DuckDuckGoActivity() { automaticallyClearWhatSetting.setClickListener { viewModel.onAutomaticallyClearWhatClicked() } automaticallyClearWhenSetting.setClickListener { viewModel.onAutomaticallyClearWhenClicked() } selectedFireAnimationSetting.setClickListener { viewModel.userRequestedToChangeFireAnimation() } + clearDuckAiDataSetting.setOnCheckedChangeListener { _, isChecked -> viewModel.onClearDuckAiDataToggled(isChecked) } } } @@ -94,6 +96,7 @@ class FireButtonActivity : DuckDuckGoActivity() { viewState.let { updateAutomaticClearDataOptions(it.automaticallyClearData) updateSelectedFireAnimation(it.selectedFireAnimation) + updateClearDuckAiDataSetting(it.clearDuckAiData, it.showClearDuckAiDataSetting) } }.launchIn(lifecycleScope) @@ -119,6 +122,11 @@ class FireButtonActivity : DuckDuckGoActivity() { binding.selectedFireAnimationSetting.setSecondaryText(subtitle) } + private fun updateClearDuckAiDataSetting(enabled: Boolean, isVisible: Boolean) { + binding.clearDuckAiDataSetting.setIsChecked(enabled) + binding.clearDuckAiDataSetting.visibility = if (isVisible) View.VISIBLE else View.GONE + } + private fun processCommand(it: Command) { when (it) { is Command.LaunchFireproofWebsites -> launchFireproofWebsites() diff --git a/app/src/main/java/com/duckduckgo/app/firebutton/FireButtonViewModel.kt b/app/src/main/java/com/duckduckgo/app/firebutton/FireButtonViewModel.kt index a70ab4987ee5..4594e6cc4d5c 100644 --- a/app/src/main/java/com/duckduckgo/app/firebutton/FireButtonViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/firebutton/FireButtonViewModel.kt @@ -28,6 +28,7 @@ import com.duckduckgo.app.settings.clear.getPixelValue import com.duckduckgo.app.settings.db.SettingsDataStore import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.di.scopes.ActivityScope +import com.duckduckgo.duckchat.api.DuckChat import javax.inject.Inject import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel @@ -44,6 +45,7 @@ class FireButtonViewModel @Inject constructor( private val settingsDataStore: SettingsDataStore, private val fireAnimationLoader: FireAnimationLoader, private val pixel: Pixel, + private val duckChat: DuckChat, ) : ViewModel() { data class ViewState( @@ -52,6 +54,8 @@ class FireButtonViewModel @Inject constructor( ClearWhenOption.APP_EXIT_ONLY, ), val selectedFireAnimation: FireAnimation = FireAnimation.HeroFire, + val clearDuckAiData: Boolean = false, + val showClearDuckAiDataSetting: Boolean = false, ) data class AutomaticallyClearData( @@ -84,6 +88,8 @@ class FireButtonViewModel @Inject constructor( automaticallyClearWhenEnabled, ), selectedFireAnimation = settingsDataStore.selectedFireAnimation, + clearDuckAiData = settingsDataStore.clearDuckAiData, + showClearDuckAiDataSetting = duckChat.wasOpenedBefore(), ), ) } @@ -176,6 +182,24 @@ class FireButtonViewModel @Inject constructor( pixel.fire(pixelName) } + fun onClearDuckAiDataToggled(enabled: Boolean) { + if (settingsDataStore.clearDuckAiData == enabled) { + logcat(VERBOSE) { "User selected same thing they already have set: clearDuckAiData=$enabled; no need to do anything else" } + return + } + + settingsDataStore.clearDuckAiData = enabled + viewModelScope.launch { + viewState.emit(currentViewState().copy(clearDuckAiData = enabled)) + } + + if (enabled) { + pixel.fire(AppPixelName.SETTINGS_CLEAR_DUCK_AI_DATA_TOGGLED_ON) + } else { + pixel.fire(AppPixelName.SETTINGS_CLEAR_DUCK_AI_DATA_TOGGLED_OFF) + } + } + private fun ClearWhatOption.pixelEvent(): Pixel.PixelName { return when (this) { ClearWhatOption.CLEAR_NONE -> AppPixelName.AUTOMATIC_CLEAR_DATA_WHAT_OPTION_NONE diff --git a/app/src/main/java/com/duckduckgo/app/pixels/AppPixelName.kt b/app/src/main/java/com/duckduckgo/app/pixels/AppPixelName.kt index 24408a9a52fd..cdb1a1ef7681 100644 --- a/app/src/main/java/com/duckduckgo/app/pixels/AppPixelName.kt +++ b/app/src/main/java/com/duckduckgo/app/pixels/AppPixelName.kt @@ -157,6 +157,8 @@ enum class AppPixelName(override val pixelName: String) : Pixel.PixelName { SETTINGS_PRIVATE_SEARCH_MORE_SEARCH_SETTINGS_PRESSED("ms_private_search_more_search_settings_pressed"), SETTINGS_COOKIE_POPUP_PROTECTION_PRESSED("ms_cookie_popup_protection_setting_pressed"), SETTINGS_FIRE_BUTTON_PRESSED("ms_fire_button_setting_pressed"), + SETTINGS_CLEAR_DUCK_AI_DATA_TOGGLED_ON("ms_clear_duck_ai_data_toggled_on"), + SETTINGS_CLEAR_DUCK_AI_DATA_TOGGLED_OFF("ms_clear_duck_ai_data_toggled_off"), SETTINGS_GENERAL_APP_LAUNCH_PRESSED("m_settings_general_app_launch_pressed"), SURVEY_CTA_SHOWN(pixelName = "mus_cs"), diff --git a/app/src/main/java/com/duckduckgo/app/settings/db/SettingsDataStore.kt b/app/src/main/java/com/duckduckgo/app/settings/db/SettingsDataStore.kt index a18e600df334..96fa917e0cdf 100644 --- a/app/src/main/java/com/duckduckgo/app/settings/db/SettingsDataStore.kt +++ b/app/src/main/java/com/duckduckgo/app/settings/db/SettingsDataStore.kt @@ -84,6 +84,7 @@ interface SettingsDataStore { var notifyMeInDownloadsDismissed: Boolean var experimentalWebsiteDarkMode: Boolean var isFullUrlEnabled: Boolean + var clearDuckAiData: Boolean fun isCurrentlySelected(clearWhatOption: ClearWhatOption): Boolean fun isCurrentlySelected(clearWhenOption: ClearWhenOption): Boolean @@ -217,6 +218,10 @@ class SettingsSharedPreferences @Inject constructor( get() = preferences.getBoolean(KEY_IS_FULL_URL_ENABLED, true) set(enabled) = preferences.edit { putBoolean(KEY_IS_FULL_URL_ENABLED, enabled) } + override var clearDuckAiData: Boolean + get() = preferences.getBoolean(KEY_CLEAR_DUCK_AI_DATA, false) + set(enabled) = preferences.edit { putBoolean(KEY_CLEAR_DUCK_AI_DATA, enabled) } + override fun hasBackgroundTimestampRecorded(): Boolean = preferences.contains(KEY_APP_BACKGROUNDED_TIMESTAMP) override fun clearAppBackgroundTimestamp() = preferences.edit { remove(KEY_APP_BACKGROUNDED_TIMESTAMP) } @@ -293,6 +298,7 @@ class SettingsSharedPreferences @Inject constructor( const val KEY_EXPERIMENTAL_SITE_DARK_MODE = "KEY_EXPERIMENTAL_SITE_DARK_MODE" const val KEY_OMNIBAR_POSITION = "KEY_OMNIBAR_POSITION" const val KEY_IS_FULL_URL_ENABLED = "KEY_IS_FULL_URL_ENABLED" + const val KEY_CLEAR_DUCK_AI_DATA = "KEY_CLEAR_DUCK_AI_DATA" } private class FireAnimationPrefsMapper { diff --git a/app/src/main/res/layout/activity_data_clearing.xml b/app/src/main/res/layout/activity_data_clearing.xml index c97fc1ff183a..12cc902e92d2 100644 --- a/app/src/main/res/layout/activity_data_clearing.xml +++ b/app/src/main/res/layout/activity_data_clearing.xml @@ -72,6 +72,13 @@ app:primaryText="@string/settingsAutomaticallyClearWhen" tools:secondaryText="test test test" /> + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ff42a88f3b2c..2561a0c667e7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -220,6 +220,7 @@ App exit, inactive for 15 minutes App exit, inactive for 30 minutes App exit, inactive for 1 hour + Clear recent Duck.ai chats About DuckDuckGo diff --git a/app/src/test/java/com/duckduckgo/app/Fakes.kt b/app/src/test/java/com/duckduckgo/app/Fakes.kt index 71825e7c0317..ae7e54d615f9 100644 --- a/app/src/test/java/com/duckduckgo/app/Fakes.kt +++ b/app/src/test/java/com/duckduckgo/app/Fakes.kt @@ -134,6 +134,10 @@ class FakeSettingsDataStore : SettingsDataStore, AutoCompleteSettings { get() = store["isFullUrlEnabled"] as Boolean? ?: false set(value) { store["isFullUrlEnabled"] = value } + override var clearDuckAiData: Boolean + get() = store["clearDuckAiData"] as Boolean? ?: false + set(value) { store["clearDuckAiData"] = value } + override fun isCurrentlySelected(clearWhatOption: ClearWhatOption): Boolean { val currentlySelected = store["automaticallyClearWhatOption"] as ClearWhatOption? return currentlySelected == clearWhatOption 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 0de1227f2915..1fe3e8d8fbba 100644 --- a/app/src/test/java/com/duckduckgo/app/browser/DuckDuckGoWebLocalStorageManagerTest.kt +++ b/app/src/test/java/com/duckduckgo/app/browser/DuckDuckGoWebLocalStorageManagerTest.kt @@ -24,6 +24,7 @@ import com.duckduckgo.app.browser.weblocalstorage.WebLocalStorageSettingsJsonPar import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteEntity import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteRepository 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 dagger.Lazy @@ -52,6 +53,7 @@ class DuckDuckGoWebLocalStorageManagerTest { private val mockWebLocalStorageToggle: Toggle = mock() private val mockFireproofedWebLocalStorageToggle: Toggle = mock() private val mockFireproofWebsiteRepository: FireproofWebsiteRepository = mock() + private val mockSettingsDataStore: SettingsDataStore = mock() private val testee = DuckDuckGoWebLocalStorageManager( mockDatabaseProvider, @@ -59,6 +61,7 @@ class DuckDuckGoWebLocalStorageManagerTest { mockWebLocalStorageSettingsJsonParser, mockFireproofWebsiteRepository, coroutineRule.testDispatcherProvider, + mockSettingsDataStore, ) @Before 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 f28b789420dc..2370a0198985 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,6 +22,7 @@ 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 @@ -50,6 +51,7 @@ 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 @@ -61,6 +63,7 @@ class IndexedDBManagerTest { mockFileDeleter, moshi, dispatcherProvider, + mockSettingsDataStore, ) } diff --git a/app/src/test/java/com/duckduckgo/app/firebutton/FireButtonViewModelTest.kt b/app/src/test/java/com/duckduckgo/app/firebutton/FireButtonViewModelTest.kt index 7d09e6152b3c..1fd75e9c9830 100644 --- a/app/src/test/java/com/duckduckgo/app/firebutton/FireButtonViewModelTest.kt +++ b/app/src/test/java/com/duckduckgo/app/firebutton/FireButtonViewModelTest.kt @@ -27,6 +27,8 @@ import com.duckduckgo.app.settings.clear.FireAnimation import com.duckduckgo.app.settings.db.SettingsDataStore import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.duckchat.api.DuckChat +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Before @@ -59,6 +61,9 @@ internal class FireButtonViewModelTest { @Mock private lateinit var mockPixel: Pixel + @Mock + private lateinit var mockDuckChat: DuckChat + @Before fun before() { MockitoAnnotations.openMocks(this) @@ -66,11 +71,15 @@ internal class FireButtonViewModelTest { whenever(mockAppSettingsDataStore.automaticallyClearWhenOption).thenReturn(ClearWhenOption.APP_EXIT_ONLY) whenever(mockAppSettingsDataStore.automaticallyClearWhatOption).thenReturn(ClearWhatOption.CLEAR_NONE) whenever(mockAppSettingsDataStore.selectedFireAnimation).thenReturn(FireAnimation.HeroFire) + runBlocking { + whenever(mockDuckChat.wasOpenedBefore()).thenReturn(false) + } testee = FireButtonViewModel( mockAppSettingsDataStore, mockFireAnimationLoader, mockPixel, + mockDuckChat, ) }