From f712e0b9b418374ee5e9c19788ed41b845824088 Mon Sep 17 00:00:00 2001 From: 0nko Date: Sun, 14 Dec 2025 13:40:48 +0100 Subject: [PATCH 01/15] Add new DataClearing component --- .../com/duckduckgo/app/fire/DataClearing.kt | 47 ++ .../duckduckgo/app/fire/RealDataClearing.kt | 156 ++++++ .../duckduckgo/app/fire/DataClearingTest.kt | 515 ++++++++++++++++++ 3 files changed, 718 insertions(+) create mode 100644 app/src/main/java/com/duckduckgo/app/fire/DataClearing.kt create mode 100644 app/src/main/java/com/duckduckgo/app/fire/RealDataClearing.kt create mode 100644 app/src/test/java/com/duckduckgo/app/fire/DataClearingTest.kt diff --git a/app/src/main/java/com/duckduckgo/app/fire/DataClearing.kt b/app/src/main/java/com/duckduckgo/app/fire/DataClearing.kt new file mode 100644 index 000000000000..bcc1ddb3ec59 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/fire/DataClearing.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2025 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.fire + +interface DataClearing { + /** + * Clears data when user requests data clearing using the FireDialog. + * @param shouldRestartProcess whether to restart the app process after clearing data + */ + suspend fun clearDataUsingManualFireOptions(shouldRestartProcess: Boolean = false) + + /** + * Clears data automatically based on auto-clear settings. + * @param killProcessIfNeeded whether to kill the app process after clearing data and + * + * @return true if process should be restarted later, false otherwise + */ + suspend fun clearDataUsingAutomaticFireOptions(killProcessIfNeeded: Boolean = true): Boolean + + /** + * Determines whether data should be cleared based on auto-clear settings. + * @param isFreshAppLaunch true if the app has been freshly launched, false otherwise + * @param appUsedSinceLastClear true if the app has been used since the last data clear, false otherwise + * @param appIconChanged true if the app icon has changed since the last + * + * @return true if data should be cleared automatically, false otherwise + */ + suspend fun shouldClearDataAutomatically( + isFreshAppLaunch: Boolean, + appUsedSinceLastClear: Boolean, + appIconChanged: Boolean, + ): Boolean +} diff --git a/app/src/main/java/com/duckduckgo/app/fire/RealDataClearing.kt b/app/src/main/java/com/duckduckgo/app/fire/RealDataClearing.kt new file mode 100644 index 000000000000..8d68d4a7bf91 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/fire/RealDataClearing.kt @@ -0,0 +1,156 @@ +/* + * Copyright (c) 2025 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.fire + +import com.duckduckgo.app.fire.store.FireDataStore +import com.duckduckgo.app.global.view.ClearDataAction +import com.duckduckgo.app.settings.clear.ClearWhenOption +import com.duckduckgo.app.settings.clear.FireClearOption +import com.duckduckgo.app.settings.db.SettingsDataStore +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import dagger.SingleInstanceIn +import logcat.LogPriority.WARN +import logcat.logcat +import javax.inject.Inject + +/** + * Implementation of DataClearing that provides granular data clearing capabilities. + * This uses the FireDataStore to determine which data to clear based on user preferences. + */ +@ContributesBinding(AppScope::class) +@SingleInstanceIn(AppScope::class) +class RealDataClearing @Inject constructor( + private val fireDataStore: FireDataStore, + private val clearDataAction: ClearDataAction, + private val settingsDataStore: SettingsDataStore, + private val dataClearerTimeKeeper: BackgroundTimeKeeper, +) : DataClearing { + + override suspend fun clearDataUsingManualFireOptions(shouldRestartProcess: Boolean) { + val options = fireDataStore.getManualClearOptions() + performGranularClear( + options = options, + shouldFireDataClearPixel = true, + ) + + val wasDataCleared = options.contains(FireClearOption.DATA) || options.contains(FireClearOption.DUCKAI_CHATS) + if (shouldRestartProcess && wasDataCleared) { + clearDataAction.setAppUsedSinceLastClearFlag(false) + clearDataAction.killAndRestartProcess(notifyDataCleared = false) + } else { + clearDataAction.setAppUsedSinceLastClearFlag(true) + } + } + + override suspend fun clearDataUsingAutomaticFireOptions(killProcessIfNeeded: Boolean): Boolean { + val options = fireDataStore.getAutomaticClearOptions() + performGranularClear( + options = options, + shouldFireDataClearPixel = false, + ) + + clearDataAction.setAppUsedSinceLastClearFlag(!killProcessIfNeeded) + + val wasDataCleared = options.contains(FireClearOption.DATA) || options.contains(FireClearOption.DUCKAI_CHATS) + if (killProcessIfNeeded && wasDataCleared) { + clearDataAction.killProcess() + return false + } else { + return wasDataCleared + } + } + + override suspend fun shouldClearDataAutomatically( + isFreshAppLaunch: Boolean, + appUsedSinceLastClear: Boolean, + appIconChanged: Boolean, + ): Boolean { + val clearWhenOption = fireDataStore.getAutomaticallyClearWhenOption() + + logcat { "Determining if data should be cleared for option $clearWhenOption" } + + if (fireDataStore.getAutomaticClearOptions().isEmpty()) { + logcat { "No automatic clear options selected; will not clear data" } + return false + } + + if (!appUsedSinceLastClear) { + logcat { "App hasn't been used since last clear; no need to clear again" } + return false + } + + logcat { "App has been used since last clear" } + + if (isFreshAppLaunch) { + logcat { "This is a fresh app launch, so will clear the data" } + return true + } + + if (appIconChanged) { + logcat { "No data will be cleared as the app icon was just changed" } + return false + } + + if (clearWhenOption == ClearWhenOption.APP_EXIT_ONLY) { + logcat { "This is NOT a fresh app launch, and the configuration is for app exit only. Not clearing the data" } + return false + } + if (!settingsDataStore.hasBackgroundTimestampRecorded()) { + logcat { "No background timestamp recorded; will not clear the data" } + logcat(WARN) { "No background timestamp recorded; will not clear the data" } + return false + } + + val enoughTimePassed = dataClearerTimeKeeper.hasEnoughTimeElapsed( + backgroundedTimestamp = settingsDataStore.appBackgroundedTimestamp, + clearWhenOption = clearWhenOption, + ) + logcat { "Has enough time passed to trigger the data clear? $enoughTimePassed" } + + return enoughTimePassed + } + + /** + * Performs granular data clearing based on the provided options + * @return true if process needs to be restarted + */ + private suspend fun performGranularClear( + options: Set, + shouldFireDataClearPixel: Boolean, + ) { + logcat { "Performing granular clear with options: $options" } + + val shouldClearTabs = FireClearOption.TABS in options + val shouldClearData = FireClearOption.DATA in options + val shouldClearDuckAiChats = FireClearOption.DUCKAI_CHATS in options + + if (shouldClearTabs) { + clearDataAction.clearTabsOnly() + } + + if (shouldClearData) { + clearDataAction.clearBrowserDataOnly(shouldFireDataClearPixel) + } + + if (shouldClearDuckAiChats) { + clearDataAction.clearDuckAiChatsOnly() + } + + logcat { "Granular clear completed" } + } +} diff --git a/app/src/test/java/com/duckduckgo/app/fire/DataClearingTest.kt b/app/src/test/java/com/duckduckgo/app/fire/DataClearingTest.kt new file mode 100644 index 000000000000..34037b1764f3 --- /dev/null +++ b/app/src/test/java/com/duckduckgo/app/fire/DataClearingTest.kt @@ -0,0 +1,515 @@ +/* + * Copyright (c) 2025 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.fire + +import com.duckduckgo.app.fire.store.FireDataStore +import com.duckduckgo.app.global.view.ClearDataAction +import com.duckduckgo.app.settings.clear.ClearWhenOption +import com.duckduckgo.app.settings.clear.FireClearOption +import com.duckduckgo.app.settings.db.SettingsDataStore +import com.duckduckgo.common.test.CoroutineTestRule +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.mockito.Mock +import org.mockito.MockitoAnnotations +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class DataClearingTest { + + @get:Rule + val coroutineTestRule: CoroutineTestRule = CoroutineTestRule() + + private lateinit var testee: RealDataClearing + + @Mock + private lateinit var mockFireDataStore: FireDataStore + + @Mock + private lateinit var mockClearDataAction: ClearDataAction + + @Mock + private lateinit var mockSettingsDataStore: SettingsDataStore + + @Mock + private lateinit var mockTimeKeeper: BackgroundTimeKeeper + + @Before + fun setup() { + MockitoAnnotations.openMocks(this) + testee = RealDataClearing( + fireDataStore = mockFireDataStore, + clearDataAction = mockClearDataAction, + settingsDataStore = mockSettingsDataStore, + dataClearerTimeKeeper = mockTimeKeeper, + ) + } + + @Test + fun whenManualClearWithTabsOnly_thenOnlyClearTabs() = runTest { + configureManualOptions(setOf(FireClearOption.TABS)) + + testee.clearDataUsingManualFireOptions(shouldRestartProcess = false) + + verify(mockClearDataAction).clearTabsOnly() + verify(mockClearDataAction, never()).clearBrowserDataOnly(any()) + verify(mockClearDataAction, never()).clearDuckAiChatsOnly() + verify(mockClearDataAction).setAppUsedSinceLastClearFlag(true) + verify(mockClearDataAction, never()).killAndRestartProcess(any(), any()) + } + + @Test + fun whenManualClearWithDataOnly_thenClearDataAndSetFlag() = runTest { + configureManualOptions(setOf(FireClearOption.DATA)) + + testee.clearDataUsingManualFireOptions(shouldRestartProcess = false) + + verify(mockClearDataAction, never()).clearTabsOnly() + verify(mockClearDataAction).clearBrowserDataOnly(true) + verify(mockClearDataAction, never()).clearDuckAiChatsOnly() + verify(mockClearDataAction).setAppUsedSinceLastClearFlag(true) + verify(mockClearDataAction, never()).killAndRestartProcess(any(), any()) + } + + @Test + fun whenManualClearWithTabsAndData_thenClearBothAndSetFlag() = runTest { + configureManualOptions(setOf(FireClearOption.TABS, FireClearOption.DATA)) + + testee.clearDataUsingManualFireOptions(shouldRestartProcess = false) + + verify(mockClearDataAction).clearTabsOnly() + verify(mockClearDataAction).clearBrowserDataOnly(true) + verify(mockClearDataAction, never()).clearDuckAiChatsOnly() + verify(mockClearDataAction).setAppUsedSinceLastClearFlag(true) + verify(mockClearDataAction, never()).killAndRestartProcess(any(), any()) + } + + @Test + fun whenManualClearWithDuckAiChatsOnly_thenClearChatsAndSetFlag() = runTest { + configureManualOptions(setOf(FireClearOption.DUCKAI_CHATS)) + + testee.clearDataUsingManualFireOptions(shouldRestartProcess = false) + + verify(mockClearDataAction, never()).clearTabsOnly() + verify(mockClearDataAction, never()).clearBrowserDataOnly(any()) + verify(mockClearDataAction).clearDuckAiChatsOnly() + verify(mockClearDataAction).setAppUsedSinceLastClearFlag(true) + verify(mockClearDataAction, never()).killAndRestartProcess(any(), any()) + } + + @Test + fun whenManualClearWithAllOptions_thenClearAllAndSetFlag() = runTest { + configureManualOptions(setOf(FireClearOption.TABS, FireClearOption.DATA, FireClearOption.DUCKAI_CHATS)) + + testee.clearDataUsingManualFireOptions(shouldRestartProcess = false) + + verify(mockClearDataAction).clearTabsOnly() + verify(mockClearDataAction).clearBrowserDataOnly(true) + verify(mockClearDataAction).clearDuckAiChatsOnly() + verify(mockClearDataAction).setAppUsedSinceLastClearFlag(true) + verify(mockClearDataAction, never()).killAndRestartProcess(any(), any()) + } + + @Test + fun whenManualClearWithNoOptionsSelected_thenOnlySetFlag() = runTest { + configureManualOptions(emptySet()) + + testee.clearDataUsingManualFireOptions(shouldRestartProcess = false) + + verify(mockClearDataAction, never()).clearTabsOnly() + verify(mockClearDataAction, never()).clearBrowserDataOnly(any()) + verify(mockClearDataAction, never()).clearDuckAiChatsOnly() + verify(mockClearDataAction).setAppUsedSinceLastClearFlag(true) + verify(mockClearDataAction, never()).killAndRestartProcess(any(), any()) + } + + @Test + fun whenManualClearWithNoOptionsSelectedAndShouldRestartProcess_thenOnlySetFlagAndDoNotRestart() = runTest { + configureManualOptions(emptySet()) + + testee.clearDataUsingManualFireOptions(shouldRestartProcess = true) + + verify(mockClearDataAction, never()).clearTabsOnly() + verify(mockClearDataAction, never()).clearBrowserDataOnly(any()) + verify(mockClearDataAction, never()).clearDuckAiChatsOnly() + verify(mockClearDataAction).setAppUsedSinceLastClearFlag(true) + verify(mockClearDataAction, never()).killAndRestartProcess(any(), any()) + } + + @Test + fun whenManualClearWithTabsOnlyAndShouldRestartProcess_thenDoNotRestart() = runTest { + configureManualOptions(setOf(FireClearOption.TABS)) + + testee.clearDataUsingManualFireOptions(shouldRestartProcess = true) + + verify(mockClearDataAction).clearTabsOnly() + verify(mockClearDataAction).setAppUsedSinceLastClearFlag(true) + verify(mockClearDataAction, never()).killAndRestartProcess(any(), any()) + } + + @Test + fun whenManualClearWithDataAndShouldRestartProcess_thenRestartProcess() = runTest { + configureManualOptions(setOf(FireClearOption.DATA)) + + testee.clearDataUsingManualFireOptions(shouldRestartProcess = true) + + verify(mockClearDataAction).clearBrowserDataOnly(true) + verify(mockClearDataAction).setAppUsedSinceLastClearFlag(false) + verify(mockClearDataAction).killAndRestartProcess(notifyDataCleared = false) + } + + @Test + fun whenManualClearWithTabsAndDataAndShouldRestartProcess_thenRestartProcess() = runTest { + configureManualOptions(setOf(FireClearOption.TABS, FireClearOption.DATA)) + + testee.clearDataUsingManualFireOptions(shouldRestartProcess = true) + + verify(mockClearDataAction).clearTabsOnly() + verify(mockClearDataAction).clearBrowserDataOnly(true) + verify(mockClearDataAction).setAppUsedSinceLastClearFlag(false) + verify(mockClearDataAction).killAndRestartProcess(notifyDataCleared = false) + } + + @Test + fun whenManualClearWithDuckAiChatsAndShouldRestartProcess_thenRestartProcess() = runTest { + configureManualOptions(setOf(FireClearOption.DUCKAI_CHATS)) + + testee.clearDataUsingManualFireOptions(shouldRestartProcess = true) + + verify(mockClearDataAction).clearDuckAiChatsOnly() + verify(mockClearDataAction).setAppUsedSinceLastClearFlag(false) + verify(mockClearDataAction).killAndRestartProcess(notifyDataCleared = false) + } + + @Test + fun whenAutomaticClearWithTabsOnlyAndKillProcessIfNeeded_thenClearTabsAndReturnFalse() = runTest { + configureAutomaticOptions(setOf(FireClearOption.TABS)) + + val result = testee.clearDataUsingAutomaticFireOptions(killProcessIfNeeded = true) + + assertFalse(result) + verify(mockClearDataAction).clearTabsOnly() + verify(mockClearDataAction, never()).clearBrowserDataOnly(any()) + verify(mockClearDataAction, never()).clearDuckAiChatsOnly() + verify(mockClearDataAction).setAppUsedSinceLastClearFlag(false) + verify(mockClearDataAction, never()).killProcess() + } + + @Test + fun whenAutomaticClearWithTabsOnlyAndNoKillProcess_thenClearTabsAndReturnFalse() = runTest { + configureAutomaticOptions(setOf(FireClearOption.TABS)) + + val result = testee.clearDataUsingAutomaticFireOptions(killProcessIfNeeded = false) + + assertFalse(result) + verify(mockClearDataAction).clearTabsOnly() + verify(mockClearDataAction).setAppUsedSinceLastClearFlag(true) + verify(mockClearDataAction, never()).killProcess() + } + + @Test + fun whenAutomaticClearWithDataAndKillProcessIfNeeded_thenClearDataAndKillProcess() = runTest { + configureAutomaticOptions(setOf(FireClearOption.DATA)) + + val result = testee.clearDataUsingAutomaticFireOptions(killProcessIfNeeded = true) + + assertFalse(result) + verify(mockClearDataAction).clearBrowserDataOnly(false) + verify(mockClearDataAction).setAppUsedSinceLastClearFlag(false) + verify(mockClearDataAction).killProcess() + } + + @Test + fun whenAutomaticClearWithDataAndNoKillProcess_thenClearDataAndReturnTrue() = runTest { + configureAutomaticOptions(setOf(FireClearOption.DATA)) + + val result = testee.clearDataUsingAutomaticFireOptions(killProcessIfNeeded = false) + + assertTrue(result) + verify(mockClearDataAction).clearBrowserDataOnly(false) + verify(mockClearDataAction).setAppUsedSinceLastClearFlag(true) + verify(mockClearDataAction, never()).killProcess() + } + + @Test + fun whenAutomaticClearWithTabsAndDataAndKillProcessIfNeeded_thenClearBothAndKillProcess() = runTest { + configureAutomaticOptions(setOf(FireClearOption.TABS, FireClearOption.DATA)) + + val result = testee.clearDataUsingAutomaticFireOptions(killProcessIfNeeded = true) + + assertFalse(result) + verify(mockClearDataAction).clearTabsOnly() + verify(mockClearDataAction).clearBrowserDataOnly(false) + verify(mockClearDataAction).setAppUsedSinceLastClearFlag(false) + verify(mockClearDataAction).killProcess() + } + + @Test + fun whenAutomaticClearWithTabsAndDataAndNoKillProcess_thenClearBothAndReturnTrue() = runTest { + configureAutomaticOptions(setOf(FireClearOption.TABS, FireClearOption.DATA)) + + val result = testee.clearDataUsingAutomaticFireOptions(killProcessIfNeeded = false) + + assertTrue(result) + verify(mockClearDataAction).clearTabsOnly() + verify(mockClearDataAction).clearBrowserDataOnly(false) + verify(mockClearDataAction).setAppUsedSinceLastClearFlag(true) + verify(mockClearDataAction, never()).killProcess() + } + + @Test + fun whenAutomaticClearWithDuckAiChatsAndKillProcessIfNeeded_thenClearChatsAndKillProcess() = runTest { + configureAutomaticOptions(setOf(FireClearOption.DUCKAI_CHATS)) + + val result = testee.clearDataUsingAutomaticFireOptions(killProcessIfNeeded = true) + + assertFalse(result) + verify(mockClearDataAction).clearDuckAiChatsOnly() + verify(mockClearDataAction).setAppUsedSinceLastClearFlag(false) + verify(mockClearDataAction).killProcess() + } + + @Test + fun whenAutomaticClearWithDuckAiChatsAndNoKillProcess_thenClearChatsAndReturnTrue() = runTest { + configureAutomaticOptions(setOf(FireClearOption.DUCKAI_CHATS)) + + val result = testee.clearDataUsingAutomaticFireOptions(killProcessIfNeeded = false) + + assertTrue(result) + verify(mockClearDataAction).clearDuckAiChatsOnly() + verify(mockClearDataAction).setAppUsedSinceLastClearFlag(true) + verify(mockClearDataAction, never()).killProcess() + } + + @Test + fun whenAutomaticClearWithAllOptionsAndKillProcessIfNeeded_thenClearAllAndKillProcess() = runTest { + configureAutomaticOptions(setOf(FireClearOption.TABS, FireClearOption.DATA, FireClearOption.DUCKAI_CHATS)) + + val result = testee.clearDataUsingAutomaticFireOptions(killProcessIfNeeded = true) + + assertFalse(result) + verify(mockClearDataAction).clearTabsOnly() + verify(mockClearDataAction).clearBrowserDataOnly(false) + verify(mockClearDataAction).clearDuckAiChatsOnly() + verify(mockClearDataAction).setAppUsedSinceLastClearFlag(false) + verify(mockClearDataAction).killProcess() + } + + @Test + fun whenAutomaticClearWithNoOptionsSelectedAndKillProcessIfNeeded_thenOnlySetFlagAndReturnFalse() = runTest { + configureAutomaticOptions(emptySet()) + + val result = testee.clearDataUsingAutomaticFireOptions(killProcessIfNeeded = true) + + assertFalse(result) + verify(mockClearDataAction, never()).clearTabsOnly() + verify(mockClearDataAction, never()).clearBrowserDataOnly(any()) + verify(mockClearDataAction, never()).clearDuckAiChatsOnly() + verify(mockClearDataAction).setAppUsedSinceLastClearFlag(false) + verify(mockClearDataAction, never()).killProcess() + } + + @Test + fun whenAutomaticClearWithNoOptionsSelectedAndNoKillProcess_thenOnlySetFlagAndReturnFalse() = runTest { + configureAutomaticOptions(emptySet()) + + val result = testee.clearDataUsingAutomaticFireOptions(killProcessIfNeeded = false) + + assertFalse(result) + verify(mockClearDataAction, never()).clearTabsOnly() + verify(mockClearDataAction, never()).clearBrowserDataOnly(any()) + verify(mockClearDataAction, never()).clearDuckAiChatsOnly() + verify(mockClearDataAction).setAppUsedSinceLastClearFlag(true) + verify(mockClearDataAction, never()).killProcess() + } + + @Test + fun whenNoAutomaticClearOptionsSelected_thenReturnFalse() = runTest { + configureAutomaticOptions(emptySet()) + configureTimeKeeper(ClearWhenOption.APP_EXIT_ONLY, enoughTimePassed = true) + + val result = testee.shouldClearDataAutomatically( + isFreshAppLaunch = true, + appUsedSinceLastClear = true, + appIconChanged = false, + ) + + assertFalse(result) + } + + @Test + fun whenAppNotUsedSinceLastClear_thenReturnFalse() = runTest { + configureAutomaticOptions(setOf(FireClearOption.DATA)) + configureWhenOption(ClearWhenOption.APP_EXIT_ONLY) + + val result = testee.shouldClearDataAutomatically( + isFreshAppLaunch = true, + appUsedSinceLastClear = false, + appIconChanged = false, + ) + + assertFalse(result) + } + + @Test + fun whenFreshAppLaunchAndAppUsedSinceLastClear_thenReturnTrue() = runTest { + configureAutomaticOptions(setOf(FireClearOption.DATA)) + configureWhenOption(ClearWhenOption.APP_EXIT_ONLY) + + val result = testee.shouldClearDataAutomatically( + isFreshAppLaunch = true, + appUsedSinceLastClear = true, + appIconChanged = false, + ) + + assertTrue(result) + } + + @Test + fun whenAppIconChanged_thenReturnFalse() = runTest { + configureAutomaticOptions(setOf(FireClearOption.DATA)) + configureWhenOption(ClearWhenOption.APP_EXIT_ONLY) + + val result = testee.shouldClearDataAutomatically( + isFreshAppLaunch = false, + appUsedSinceLastClear = true, + appIconChanged = true, + ) + + assertFalse(result) + } + + @Test + fun whenNotFreshAppLaunchAndAppExitOnly_thenReturnFalse() = runTest { + configureAutomaticOptions(setOf(FireClearOption.DATA)) + configureWhenOption(ClearWhenOption.APP_EXIT_ONLY) + + val result = testee.shouldClearDataAutomatically( + isFreshAppLaunch = false, + appUsedSinceLastClear = true, + appIconChanged = false, + ) + + assertFalse(result) + } + + @Test + fun whenNotFreshAppLaunchAndNoBackgroundTimestampRecorded_thenReturnFalse() = runTest { + configureAutomaticOptions(setOf(FireClearOption.DATA)) + configureWhenOption(ClearWhenOption.APP_EXIT_OR_15_MINS) + whenever(mockSettingsDataStore.hasBackgroundTimestampRecorded()).thenReturn(false) + + val result = testee.shouldClearDataAutomatically( + isFreshAppLaunch = false, + appUsedSinceLastClear = true, + appIconChanged = false, + ) + + assertFalse(result) + } + + @Test + fun whenNotFreshAppLaunchAndNotEnoughTimeElapsed_thenReturnFalse() = runTest { + configureAutomaticOptions(setOf(FireClearOption.DATA)) + configureWhenOption(ClearWhenOption.APP_EXIT_OR_15_MINS) + configureTimeKeeper(ClearWhenOption.APP_EXIT_OR_15_MINS, enoughTimePassed = false) + + val result = testee.shouldClearDataAutomatically( + isFreshAppLaunch = false, + appUsedSinceLastClear = true, + appIconChanged = false, + ) + + assertFalse(result) + } + + @Test + fun whenNotFreshAppLaunchAndEnoughTimeElapsed_thenReturnTrue() = runTest { + configureAutomaticOptions(setOf(FireClearOption.DATA)) + configureWhenOption(ClearWhenOption.APP_EXIT_OR_15_MINS) + configureTimeKeeper(ClearWhenOption.APP_EXIT_OR_15_MINS, enoughTimePassed = true) + + val result = testee.shouldClearDataAutomatically( + isFreshAppLaunch = false, + appUsedSinceLastClear = true, + appIconChanged = false, + ) + + assertTrue(result) + } + + @Test + fun whenNotFreshAppLaunchAndEnoughTimeElapsedFor5Mins_thenReturnTrue() = runTest { + configureAutomaticOptions(setOf(FireClearOption.DATA)) + configureWhenOption(ClearWhenOption.APP_EXIT_OR_5_MINS) + configureTimeKeeper(ClearWhenOption.APP_EXIT_OR_5_MINS, enoughTimePassed = true) + + val result = testee.shouldClearDataAutomatically( + isFreshAppLaunch = false, + appUsedSinceLastClear = true, + appIconChanged = false, + ) + + assertTrue(result) + } + + @Test + fun whenNotFreshAppLaunchAndEnoughTimeElapsedFor60Mins_thenReturnTrue() = runTest { + configureAutomaticOptions(setOf(FireClearOption.DATA)) + configureWhenOption(ClearWhenOption.APP_EXIT_OR_60_MINS) + configureTimeKeeper(ClearWhenOption.APP_EXIT_OR_60_MINS, enoughTimePassed = true) + + val result = testee.shouldClearDataAutomatically( + isFreshAppLaunch = false, + appUsedSinceLastClear = true, + appIconChanged = false, + ) + + assertTrue(result) + } + + private suspend fun configureManualOptions(options: Set) { + whenever(mockFireDataStore.getManualClearOptions()).thenReturn(options) + } + + private suspend fun configureAutomaticOptions(options: Set) { + whenever(mockFireDataStore.getAutomaticClearOptions()).thenReturn(options) + } + + private suspend fun configureWhenOption(option: ClearWhenOption) { + whenever(mockFireDataStore.getAutomaticallyClearWhenOption()).thenReturn(option) + } + + private fun configureTimeKeeper(clearWhenOption: ClearWhenOption, enoughTimePassed: Boolean) { + whenever(mockSettingsDataStore.hasBackgroundTimestampRecorded()).thenReturn(true) + whenever(mockSettingsDataStore.appBackgroundedTimestamp).thenReturn(12345L) + whenever( + mockTimeKeeper.hasEnoughTimeElapsed( + any(), + eq(12345L), + eq(clearWhenOption), + ), + ).thenReturn(enoughTimePassed) + } +} From 5b01ae4ea0b5237ae99bc5003e0c64f9d2f99821 Mon Sep 17 00:00:00 2001 From: 0nko Date: Sun, 14 Dec 2025 13:42:29 +0100 Subject: [PATCH 02/15] Update ClearDataAction --- .../view/ClearPersonalDataActionTest.kt | 31 ++++++++++++++++--- .../global/view/ClearPersonalDataAction.kt | 18 +++++++++-- 2 files changed, 41 insertions(+), 8 deletions(-) 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 5e5ba2cc1292..38855d487c09 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 @@ -170,22 +170,43 @@ class ClearPersonalDataActionTest { @Test fun whenClearTabsOnlyCalledThenTabsAndSessionsCleared() = runTest { - testee.clearTabsOnly(appInForeground = false) + testee.clearTabsAsync(appInForeground = false) verify(mockTabRepository).deleteAll() } @Test - fun whenClearTabsOnlyCalledWithAppInForegroundThenAppUsedFlagSetToTrue() = runTest { - testee.clearTabsOnly(appInForeground = true) + fun whenClearTabsAsyncCalledWithAppInForegroundThenAppUsedFlagSetToTrue() = runTest { + testee.clearTabsAsync(appInForeground = true) verify(mockSettingsDataStore).appUsedSinceLastClear = true } @Test - fun whenClearTabsOnlyCalledWithAppNotInForegroundThenAppUsedFlagSetToFalse() = runTest { - testee.clearTabsOnly(appInForeground = false) + fun whenClearTabsAsyncCalledWithAppNotInForegroundThenAppUsedFlagSetToFalse() = runTest { + testee.clearTabsAsync(appInForeground = false) verify(mockSettingsDataStore).appUsedSinceLastClear = false } + @Test + fun whenClearTabsOnlyCalledThenTabsCleared() = runTest { + testee.clearTabsOnly() + verify(mockTabRepository).deleteAll() + } + + @Test + fun whenClearTabsOnlyCalledThenNoBrowserDataCleared() = runTest { + testee.clearTabsOnly() + verify(mockDataManager, never()).clearData(any(), any()) + verify(mockDataManager, never()).clearData(any(), any(), any(), any()) + verifyNoInteractions(mockAppCacheClearer) + verifyNoInteractions(mockCookieManager) + verifyNoInteractions(mockThirdPartyCookieManager) + verifyNoInteractions(mockSitePermissionsManager) + verifyNoInteractions(mockNavigationHistory) + verifyNoInteractions(mockWebTrackersBlockedRepository) + verifyNoInteractions(mockPrivacyProtectionsPopupDataClearer) + verifyNoInteractions(mockClearingUnsentForgetAllPixelStore) + } + @Test fun whenClearBrowserDataOnlyCalledWithPixelIncrementSetToTrueThenPixelCountIncremented() = runTest { testee.clearBrowserDataOnly(shouldFireDataClearPixel = true) 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 dd5fc5d206de..6eb46d7e0562 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 @@ -55,7 +55,12 @@ interface ClearDataAction { * Clears tabs and associated data. * @param appInForeground whether the app is in foreground */ - suspend fun clearTabsOnly(appInForeground: Boolean) + suspend fun clearTabsAsync(appInForeground: Boolean) + + /** + * Clears tabs and associated data. + */ + suspend fun clearTabsOnly() /** * Clears browser data except tabs and chats. @@ -133,7 +138,7 @@ class ClearPersonalDataAction( privacyProtectionsPopupDataClearer.clearPersonalData() - clearTabsOnly(appInForeground) + clearTabsAsync(appInForeground) webTrackersBlockedRepository.deleteAll() @@ -145,7 +150,7 @@ class ClearPersonalDataAction( logcat(INFO) { "Finished clearing everything" } } - override suspend fun clearTabsOnly(appInForeground: Boolean) { + override suspend fun clearTabsAsync(appInForeground: Boolean) { withContext(dispatchers.io()) { tabRepository.deleteAll() setAppUsedSinceLastClearFlag(appInForeground) @@ -153,6 +158,13 @@ class ClearPersonalDataAction( } } + override suspend fun clearTabsOnly() { + withContext(dispatchers.io()) { + tabRepository.deleteAll() + logcat { "Finished clearing tabs" } + } + } + override suspend fun clearBrowserDataOnly(shouldFireDataClearPixel: Boolean) { withContext(dispatchers.io()) { val fireproofDomains = fireproofWebsiteRepository.fireproofWebsitesSync().map { it.domain } From 82fcb4391aeaeaeec173248ba55d8cd47906769e Mon Sep 17 00:00:00 2001 From: 0nko Date: Sun, 14 Dec 2025 13:43:17 +0100 Subject: [PATCH 03/15] Update AutomaticDataClearer --- .../app/fire/AutomaticDataClearerTest.kt | 186 +++++++++++++++++- .../app/fire/AutomaticDataClearer.kt | 98 ++++++--- 2 files changed, 256 insertions(+), 28 deletions(-) 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 00239ea38e10..fd3c72ef77b7 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/fire/AutomaticDataClearerTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/fire/AutomaticDataClearerTest.kt @@ -18,16 +18,20 @@ package com.duckduckgo.app.fire +import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.test.annotation.UiThreadTest import androidx.test.platform.app.InstrumentationRegistry import androidx.work.WorkManager import com.duckduckgo.app.global.view.ClearDataAction +import com.duckduckgo.app.pixels.remoteconfig.AndroidBrowserConfigFeature import com.duckduckgo.app.settings.clear.ClearWhatOption import com.duckduckgo.app.settings.clear.ClearWhenOption import com.duckduckgo.app.settings.db.SettingsDataStore import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.common.test.InstantSchedulersRule +import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory +import com.duckduckgo.feature.toggles.api.Toggle.State import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Rule @@ -37,9 +41,13 @@ import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever +import kotlin.jvm.java class AutomaticDataClearerTest { + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + @get:Rule val coroutineTestRule: CoroutineTestRule = CoroutineTestRule() @@ -52,6 +60,8 @@ class AutomaticDataClearerTest { private val mockClearAction: ClearDataAction = mock() private val mockTimeKeeper: BackgroundTimeKeeper = mock() private val mockWorkManager: WorkManager = mock() + private val mockDataClearing: DataClearing = mock() + private val fakeAndroidBrowserConfigFeature = FakeFeatureToggleFactory.create(AndroidBrowserConfigFeature::class.java) private val pixel: Pixel = mock() private val dataClearerForegroundAppRestartPixel = DataClearerForegroundAppRestartPixel( @@ -69,6 +79,8 @@ class AutomaticDataClearerTest { workManager = mockWorkManager, settingsDataStore = mockSettingsDataStore, clearDataAction = mockClearAction, + dataClearing = mockDataClearing, + androidBrowserConfigFeature = fakeAndroidBrowserConfigFeature, dataClearerTimeKeeper = mockTimeKeeper, dataClearerForegroundAppRestartPixel = dataClearerForegroundAppRestartPixel, dispatchers = coroutineTestRule.testDispatcherProvider, @@ -573,11 +585,11 @@ class AutomaticDataClearerTest { } private suspend fun verifyTabsCleared() { - verify(mockClearAction).clearTabsOnly(any()) + verify(mockClearAction).clearTabsAsync(any()) } private suspend fun verifyTabsNotCleared() { - verify(mockClearAction, never()).clearTabsOnly(any()) + verify(mockClearAction, never()).clearTabsAsync(any()) } private suspend fun verifyEverythingCleared() { @@ -591,4 +603,174 @@ class AutomaticDataClearerTest { private fun verifyAppIconFlagReset() { verify(mockSettingsDataStore).appIconChanged = false } + + @UiThreadTest + @Test + fun whenGranularFeatureEnabledAndEmptyOptions_thenNoClearing() = runTest { + enableGranularFeature() + configureShouldClearAutomatically(false) + configureAppUsedSinceLastClear() + + simulateLifecycle(isFreshAppLaunch = true) + + verify(mockDataClearing).shouldClearDataAutomatically(any(), any(), any()) + verify(mockDataClearing, never()).clearDataUsingAutomaticFireOptions(any()) + } + + @UiThreadTest + @Test + fun whenGranularFeatureEnabledAndTabsOnlyAndFreshLaunch_thenClearTabsWithoutRestart() = runTest { + enableGranularFeature() + configureShouldClearAutomatically(true) + configureAppUsedSinceLastClear() + whenever(mockDataClearing.clearDataUsingAutomaticFireOptions(false)).thenReturn(false) + + simulateLifecycle(isFreshAppLaunch = true) + + verify(mockDataClearing).shouldClearDataAutomatically(true, true, false) + verify(mockDataClearing).clearDataUsingAutomaticFireOptions(false) + verify(mockClearAction, never()).killAndRestartProcess(any(), any()) + } + + @UiThreadTest + @Test + fun whenGranularFeatureEnabledAndTabsOnlyAndNotFreshLaunch_thenClearTabsWithoutRestart() = runTest { + enableGranularFeature() + configureShouldClearAutomatically(true) + configureAppUsedSinceLastClear() + whenever(mockDataClearing.clearDataUsingAutomaticFireOptions(false)).thenReturn(false) + + simulateLifecycle(isFreshAppLaunch = false) + + verify(mockDataClearing).shouldClearDataAutomatically(false, true, false) + verify(mockDataClearing).clearDataUsingAutomaticFireOptions(false) + verify(mockClearAction, never()).killAndRestartProcess(any(), any()) + } + + @UiThreadTest + @Test + fun whenGranularFeatureEnabledAndDataAndFreshLaunch_thenClearDataWithoutRestart() = runTest { + enableGranularFeature() + configureShouldClearAutomatically(true) + configureAppUsedSinceLastClear() + whenever(mockDataClearing.clearDataUsingAutomaticFireOptions(false)).thenReturn(true) + + simulateLifecycle(isFreshAppLaunch = true) + + verify(mockDataClearing).shouldClearDataAutomatically(true, true, false) + verify(mockDataClearing).clearDataUsingAutomaticFireOptions(false) + verify(mockClearAction, never()).setAppUsedSinceLastClearFlag(false) + verify(mockClearAction, never()).killAndRestartProcess(any(), any()) + } + + @Test + fun whenGranularFeatureEnabledAndDataAndNotFreshLaunch_thenClearDataAndRestart() = runTest { + enableGranularFeature() + configureShouldClearAutomatically(true) + configureAppUsedSinceLastClear() + whenever(mockDataClearing.clearDataUsingAutomaticFireOptions(false)).thenReturn(true) + + simulateLifecycle(isFreshAppLaunch = false) + + // Wait for Handler.postDelayed callback to execute on main thread + Thread.sleep(200) + InstrumentationRegistry.getInstrumentation().waitForIdleSync() + + verify(mockDataClearing).shouldClearDataAutomatically(false, true, false) + verify(mockDataClearing).clearDataUsingAutomaticFireOptions(false) + verify(mockClearAction).setAppUsedSinceLastClearFlag(false) + verify(mockClearAction).killAndRestartProcess(notifyDataCleared = true) + } + + @Test + fun whenGranularFeatureEnabledAndTabsAndDataAndNotFreshLaunch_thenClearBothAndRestart() = runTest { + enableGranularFeature() + configureShouldClearAutomatically(true) + configureAppUsedSinceLastClear() + whenever(mockDataClearing.clearDataUsingAutomaticFireOptions(false)).thenReturn(true) + + simulateLifecycle(isFreshAppLaunch = false) + + // Wait for Handler.postDelayed callback to execute on main thread + Thread.sleep(200) + InstrumentationRegistry.getInstrumentation().waitForIdleSync() + + verify(mockDataClearing).shouldClearDataAutomatically(false, true, false) + verify(mockDataClearing).clearDataUsingAutomaticFireOptions(false) + verify(mockClearAction).setAppUsedSinceLastClearFlag(false) + verify(mockClearAction).killAndRestartProcess(notifyDataCleared = true) + } + + @Test + fun whenGranularFeatureEnabledAndDuckAiChatsAndNotFreshLaunch_thenClearChatsAndRestart() = runTest { + enableGranularFeature() + configureShouldClearAutomatically(true) + configureAppUsedSinceLastClear() + whenever(mockDataClearing.clearDataUsingAutomaticFireOptions(false)).thenReturn(true) + + simulateLifecycle(isFreshAppLaunch = false) + + // Wait for Handler.postDelayed callback to execute on main thread + Thread.sleep(200) + InstrumentationRegistry.getInstrumentation().waitForIdleSync() + + verify(mockDataClearing).shouldClearDataAutomatically(false, true, false) + verify(mockDataClearing).clearDataUsingAutomaticFireOptions(false) + verify(mockClearAction).setAppUsedSinceLastClearFlag(false) + verify(mockClearAction).killAndRestartProcess(notifyDataCleared = true) + } + + @UiThreadTest + @Test + fun whenGranularFeatureEnabledAndAppIconChanged_thenNoClearing() = runTest { + enableGranularFeature() + configureShouldClearAutomatically(false) + configureAppUsedSinceLastClear() + configureAppIconJustChanged() + + simulateLifecycle(isFreshAppLaunch = false) + + verify(mockDataClearing).shouldClearDataAutomatically(false, true, true) + verify(mockDataClearing, never()).clearDataUsingAutomaticFireOptions(any()) + } + + @UiThreadTest + @Test + fun whenGranularFeatureEnabledAndAppNotUsedSinceLastClear_thenNoClearing() = runTest { + enableGranularFeature() + configureShouldClearAutomatically(false) + configureAppNotUsedSinceLastClear() + + simulateLifecycle(isFreshAppLaunch = true) + + verify(mockDataClearing).shouldClearDataAutomatically(true, false, false) + verify(mockDataClearing, never()).clearDataUsingAutomaticFireOptions(any()) + } + + @UiThreadTest + @Test + fun whenGranularFeatureDisabled_thenUseLegacyFlow() = runTest { + disableGranularFeature() + configureUserOptions(ClearWhatOption.CLEAR_TABS_AND_DATA, ClearWhenOption.APP_EXIT_ONLY) + configureEnoughTimePassed() + configureAppUsedSinceLastClear() + + simulateLifecycle(isFreshAppLaunch = true) + + verify(mockDataClearing, never()).shouldClearDataAutomatically(any(), any(), any()) + verify(mockDataClearing, never()).clearDataUsingAutomaticFireOptions(any()) + verify(mockClearAction).clearTabsAndAllDataAsync(any(), any()) + } + + private fun enableGranularFeature() { + fakeAndroidBrowserConfigFeature.moreGranularDataClearingOptions().setRawStoredState(State(true)) + } + + private fun disableGranularFeature() { + fakeAndroidBrowserConfigFeature.moreGranularDataClearingOptions().setRawStoredState(State(false)) + } + + private suspend fun configureShouldClearAutomatically(shouldClear: Boolean) { + whenever(mockDataClearing.shouldClearDataAutomatically(any(), any(), any())).thenReturn(shouldClear) + } } 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 722429f81578..1c1117a151c1 100644 --- a/app/src/main/java/com/duckduckgo/app/fire/AutomaticDataClearer.kt +++ b/app/src/main/java/com/duckduckgo/app/fire/AutomaticDataClearer.kt @@ -17,6 +17,7 @@ package com.duckduckgo.app.fire import android.os.Handler +import android.os.Looper import android.os.SystemClock import androidx.annotation.UiThread import androidx.annotation.VisibleForTesting @@ -29,6 +30,7 @@ import com.duckduckgo.app.global.ApplicationClearDataState import com.duckduckgo.app.global.ApplicationClearDataState.FINISHED import com.duckduckgo.app.global.ApplicationClearDataState.INITIALIZING import com.duckduckgo.app.global.view.ClearDataAction +import com.duckduckgo.app.pixels.remoteconfig.AndroidBrowserConfigFeature import com.duckduckgo.app.settings.clear.ClearWhatOption import com.duckduckgo.app.settings.clear.ClearWhenOption import com.duckduckgo.app.settings.db.SettingsDataStore @@ -66,6 +68,8 @@ class AutomaticDataClearer @Inject constructor( private val workManager: WorkManager, private val settingsDataStore: SettingsDataStore, private val clearDataAction: ClearDataAction, + private val dataClearing: DataClearing, + private val androidBrowserConfigFeature: AndroidBrowserConfigFeature, private val dataClearerTimeKeeper: BackgroundTimeKeeper, private val dataClearerForegroundAppRestartPixel: DataClearerForegroundAppRestartPixel, private val dispatchers: DispatcherProvider, @@ -104,23 +108,10 @@ class AutomaticDataClearer @Inject constructor( val appIconChanged = settingsDataStore.appIconChanged settingsDataStore.appIconChanged = false - val clearWhat = settingsDataStore.automaticallyClearWhatOption - val clearWhen = settingsDataStore.automaticallyClearWhenOption - logcat { "Currently configured to automatically clear $clearWhat / $clearWhen" } - - if (clearWhat == ClearWhatOption.CLEAR_NONE) { - logcat { "No data will be cleared as it's configured to clear nothing automatically" } - postDataClearerState(FINISHED) + if (androidBrowserConfigFeature.moreGranularDataClearingOptions().isEnabled()) { + clearDataIfNeeded(appUsedSinceLastClear, appIconChanged) } else { - if (shouldClearData(clearWhen, appUsedSinceLastClear, appIconChanged)) { - logcat { "Decided data should be cleared" } - withContext(dispatchers.main()) { - clearDataWhenAppInForeground(clearWhat) - } - } else { - logcat { "Decided not to clear data at this time" } - postDataClearerState(FINISHED) - } + clearDataIfNeededLegacy(appUsedSinceLastClear, appIconChanged) } isFreshAppLaunch = false @@ -128,6 +119,41 @@ class AutomaticDataClearer @Inject constructor( } } + private suspend fun clearDataIfNeeded( + appUsedSinceLastClear: Boolean, + appIconChanged: Boolean, + ) { + if (dataClearing.shouldClearDataAutomatically(isFreshAppLaunch, appUsedSinceLastClear, appIconChanged)) { + logcat { "Decided data should be cleared" } + clearDataWhenAppInForeground() + } else { + logcat { "Decided not to clear data at this time" } + postDataClearerState(FINISHED) + } + } + + private suspend fun clearDataIfNeededLegacy( + appUsedSinceLastClear: Boolean, + appIconChanged: Boolean, + ) { + val clearWhat = settingsDataStore.automaticallyClearWhatOption + val clearWhen = settingsDataStore.automaticallyClearWhenOption + logcat { "Currently configured to automatically clear $clearWhat / $clearWhen" } + + if (clearWhat == ClearWhatOption.CLEAR_NONE) { + logcat { "No data will be cleared as it's configured to clear nothing automatically" } + postDataClearerState(FINISHED) + } else { + if (shouldClearData(clearWhen, appUsedSinceLastClear, appIconChanged)) { + logcat { "Decided data should be cleared" } + clearDataWhenAppInForegroundLegacy(clearWhat) + } else { + logcat { "Decided not to clear data at this time" } + postDataClearerState(FINISHED) + } + } + } + private suspend fun postDataClearerState(state: ApplicationClearDataState) { withContext(dispatchers.main()) { dataClearerState.value = state @@ -157,10 +183,7 @@ class AutomaticDataClearer @Inject constructor( } override fun onExit() { - // the app does not have any activity in CREATED state we kill the process - if (settingsDataStore.automaticallyClearWhatOption != ClearWhatOption.CLEAR_NONE) { - clearDataAction.killProcess() - } + clearDataAction.killProcess() } private fun scheduleBackgroundTimerToTriggerClear(durationMillis: Long) { @@ -177,24 +200,47 @@ class AutomaticDataClearer @Inject constructor( } } + private suspend fun clearDataWhenAppInForeground() { + withContext(dispatchers.main()) { + logcat { "Clearing data automatically in foreground with new flow" } + + val shouldRestart = dataClearing.clearDataUsingAutomaticFireOptions(killProcessIfNeeded = false) + val needsRestart = !isFreshAppLaunch && shouldRestart + if (needsRestart) { + withContext(dispatchers.io()) { + clearDataAction.setAppUsedSinceLastClearFlag(false) + dataClearerForegroundAppRestartPixel.incrementCount() + } + + // need a moment to draw background color (reduces flickering UX) + Handler(Looper.getMainLooper()).postDelayed(100) { + clearDataAction.killAndRestartProcess(notifyDataCleared = true) + } + } else { + postDataClearerState(FINISHED) + } + } + } + @UiThread @Suppress("NON_EXHAUSTIVE_WHEN") - private suspend fun clearDataWhenAppInForeground(clearWhat: ClearWhatOption) { + private suspend fun clearDataWhenAppInForegroundLegacy(clearWhat: ClearWhatOption) { withContext(dispatchers.main()) { logcat { "Clearing data when app is in the foreground: $clearWhat" } + // Use legacy clearing + val processNeedsRestarted = !isFreshAppLaunch && clearWhat == ClearWhatOption.CLEAR_TABS_AND_DATA + logcat { "App is in foreground; restart needed? $processNeedsRestarted" } + when (clearWhat) { ClearWhatOption.CLEAR_TABS_ONLY -> { - clearDataAction.clearTabsOnly(true) + clearDataAction.clearTabsAsync(true) logcat { "Notifying listener that clearing has finished" } postDataClearerState(FINISHED) } ClearWhatOption.CLEAR_TABS_AND_DATA -> { - val processNeedsRestarted = !isFreshAppLaunch - logcat { "App is in foreground; restart needed? $processNeedsRestarted" } - clearDataAction.clearTabsAndAllDataAsync(appInForeground = true, shouldFireDataClearPixel = false) logcat { "All data now cleared, will restart process? $processNeedsRestarted" } @@ -205,7 +251,7 @@ class AutomaticDataClearer @Inject constructor( } // need a moment to draw background color (reduces flickering UX) - Handler().postDelayed(100) { + Handler(Looper.getMainLooper()).postDelayed(100) { logcat { "Will now restart process" } clearDataAction.killAndRestartProcess(notifyDataCleared = true) } From 6af4960a5a7c18d56a0e80550c352f8bce378d04 Mon Sep 17 00:00:00 2001 From: 0nko Date: Sun, 14 Dec 2025 13:45:29 +0100 Subject: [PATCH 04/15] Update all fire call sites to use the new DataClearing when FF enabled --- .../duckduckgo/app/browser/BrowserActivity.kt | 22 +++++++++++++++---- .../duckduckgo/app/fire/DataClearingWorker.kt | 20 ++++++++++++++--- .../duckduckgo/app/global/view/FireDialog.kt | 18 +++++++++++---- .../app/global/view/FireDialogProvider.kt | 14 ++++++++++-- 4 files changed, 61 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt index f527badce1e6..e34243980d52 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt @@ -75,6 +75,7 @@ import com.duckduckgo.app.downloads.DownloadsScreens.DownloadsScreenNoParams import com.duckduckgo.app.feedback.ui.common.FeedbackActivity import com.duckduckgo.app.fire.DataClearer import com.duckduckgo.app.fire.DataClearerForegroundAppRestartPixel +import com.duckduckgo.app.fire.DataClearing import com.duckduckgo.app.global.ApplicationClearDataState import com.duckduckgo.app.global.intentText import com.duckduckgo.app.global.rating.PromptCount @@ -85,6 +86,7 @@ import com.duckduckgo.app.global.view.renderIfChanged import com.duckduckgo.app.onboarding.ui.page.DefaultBrowserPage import com.duckduckgo.app.pixels.AppPixelName import com.duckduckgo.app.pixels.AppPixelName.FIRE_DIALOG_CANCEL +import com.duckduckgo.app.pixels.remoteconfig.AndroidBrowserConfigFeature import com.duckduckgo.app.settings.db.SettingsDataStore import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.statistics.pixels.Pixel.PixelParameter @@ -141,7 +143,13 @@ open class BrowserActivity : DuckDuckGoActivity() { lateinit var settingsDataStore: SettingsDataStore @Inject - lateinit var clearPersonalDataAction: ClearDataAction + lateinit var clearDataAction: ClearDataAction + + @Inject + lateinit var dataClearing: DataClearing + + @Inject + lateinit var androidBrowserConfigFeature: AndroidBrowserConfigFeature @Inject lateinit var dataClearer: DataClearer @@ -545,9 +553,15 @@ open class BrowserActivity : DuckDuckGoActivity() { if (intent.getBooleanExtra(PERFORM_FIRE_ON_ENTRY_EXTRA, false)) { logcat(INFO) { "Clearing everything as a result of $PERFORM_FIRE_ON_ENTRY_EXTRA flag being set" } appCoroutineScope.launch(dispatcherProvider.io()) { - clearPersonalDataAction.clearTabsAndAllDataAsync(appInForeground = true, shouldFireDataClearPixel = true) - clearPersonalDataAction.setAppUsedSinceLastClearFlag(false) - clearPersonalDataAction.killAndRestartProcess(notifyDataCleared = false) + if (androidBrowserConfigFeature.moreGranularDataClearingOptions().isEnabled()) { + // Use new granular clearing - will automatically clear and restart + dataClearing.clearDataUsingManualFireOptions(shouldRestartProcess = true) + } else { + // Use legacy clearing + clearDataAction.clearTabsAndAllDataAsync(appInForeground = true, shouldFireDataClearPixel = true) + clearDataAction.setAppUsedSinceLastClearFlag(false) + clearDataAction.killAndRestartProcess(notifyDataCleared = false) + } } return 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 64b92a011669..7482b5b4b723 100644 --- a/app/src/main/java/com/duckduckgo/app/fire/DataClearingWorker.kt +++ b/app/src/main/java/com/duckduckgo/app/fire/DataClearingWorker.kt @@ -23,6 +23,7 @@ import androidx.work.ListenableWorker.Result.success import androidx.work.WorkerParameters import com.duckduckgo.anvil.annotations.ContributesWorker import com.duckduckgo.app.global.view.ClearDataAction +import com.duckduckgo.app.pixels.remoteconfig.AndroidBrowserConfigFeature import com.duckduckgo.app.settings.clear.ClearWhatOption import com.duckduckgo.app.settings.db.SettingsDataStore import com.duckduckgo.common.utils.DispatcherProvider @@ -46,6 +47,12 @@ class DataClearingWorker( @Inject lateinit var clearDataAction: ClearDataAction + @Inject + lateinit var dataClearing: DataClearing + + @Inject + lateinit var androidBrowserConfigFeature: AndroidBrowserConfigFeature + @Inject lateinit var dispatchers: DispatcherProvider @@ -58,7 +65,14 @@ class DataClearingWorker( settingsDataStore.lastExecutedJobId = id.toString() - clearData(settingsDataStore.automaticallyClearWhatOption) + withContext(dispatchers.io()) { + if (androidBrowserConfigFeature.moreGranularDataClearingOptions().isEnabled()) { + // Use new granular clearing - will automatically kill the process + dataClearing.clearDataUsingAutomaticFireOptions() + } else { + clearData(settingsDataStore.automaticallyClearWhatOption) + } + } logcat(INFO) { "Clear data job finished; returning SUCCESS" } return success() @@ -81,14 +95,14 @@ class DataClearingWorker( when (clearWhat) { ClearWhatOption.CLEAR_NONE -> logcat(WARN) { "Automatically clear data invoked, but set to clear nothing" } - ClearWhatOption.CLEAR_TABS_ONLY -> clearDataAction.clearTabsOnly(appInForeground = false) + ClearWhatOption.CLEAR_TABS_ONLY -> clearDataAction.clearTabsAsync(appInForeground = false) ClearWhatOption.CLEAR_TABS_AND_DATA -> clearEverything() } } private suspend fun clearEverything() { - logcat(INFO) { "App is in background, so just outright killing the process" } withContext(dispatchers.main()) { + // Use legacy clearing clearDataAction.clearTabsAndAllDataAsync(appInForeground = false, shouldFireDataClearPixel = false) clearDataAction.setAppUsedSinceLastClearFlag(false) diff --git a/app/src/main/java/com/duckduckgo/app/global/view/FireDialog.kt b/app/src/main/java/com/duckduckgo/app/global/view/FireDialog.kt index 7db3dd8694b0..42e425cdc150 100644 --- a/app/src/main/java/com/duckduckgo/app/global/view/FireDialog.kt +++ b/app/src/main/java/com/duckduckgo/app/global/view/FireDialog.kt @@ -33,6 +33,7 @@ import androidx.core.view.WindowInsetsControllerCompat import androidx.core.view.updatePadding import com.airbnb.lottie.RenderMode import com.duckduckgo.app.browser.databinding.SheetFireClearDataBinding +import com.duckduckgo.app.fire.DataClearing import com.duckduckgo.app.firebutton.FireButtonStore import com.duckduckgo.app.global.events.db.UserEventKey import com.duckduckgo.app.global.events.db.UserEventsStore @@ -41,6 +42,7 @@ import com.duckduckgo.app.global.view.FireDialog.FireDialogClearAllEvent.ClearAl import com.duckduckgo.app.pixels.AppPixelName import com.duckduckgo.app.pixels.AppPixelName.FIRE_DIALOG_ANIMATION import com.duckduckgo.app.pixels.AppPixelName.FIRE_DIALOG_CLEAR_PRESSED +import com.duckduckgo.app.pixels.remoteconfig.AndroidBrowserConfigFeature import com.duckduckgo.app.pixels.AppPixelName.PRODUCT_TELEMETRY_SURFACE_DATA_CLEARING import com.duckduckgo.app.settings.clear.getPixelValue import com.duckduckgo.app.settings.db.SettingsDataStore @@ -67,7 +69,9 @@ private const val ANIMATION_SPEED_INCREMENT = 0.15f @SuppressLint("NoBottomSheetDialog") class FireDialog( context: Context, - private val clearPersonalDataAction: ClearDataAction, + private val clearDataAction: ClearDataAction, + private val dataClearing: DataClearing, + private val androidBrowserConfigFeature: AndroidBrowserConfigFeature, private val pixel: Pixel, private val settingsDataStore: SettingsDataStore, private val userEventsStore: UserEventsStore, @@ -173,8 +177,13 @@ class FireDialog( appCoroutineScope.launch(dispatcherProvider.io()) { fireButtonStore.incrementFireButtonUseCount() userEventsStore.registerUserEvent(UserEventKey.FIRE_BUTTON_EXECUTED) - clearPersonalDataAction.clearTabsAndAllDataAsync(appInForeground = true, shouldFireDataClearPixel = true) - clearPersonalDataAction.setAppUsedSinceLastClearFlag(false) + + if (androidBrowserConfigFeature.moreGranularDataClearingOptions().isEnabled()) { + dataClearing.clearDataUsingManualFireOptions() + } else { + clearDataAction.clearTabsAndAllDataAsync(appInForeground = true, shouldFireDataClearPixel = true) + clearDataAction.setAppUsedSinceLastClearFlag(false) + } onFireDialogClearAllEvent(ClearAllDataFinished) } } @@ -227,7 +236,8 @@ class FireDialog( binding.fireAnimationView.addAnimatorUpdateListener(accelerateAnimatorUpdateListener) } } else { - clearPersonalDataAction.killAndRestartProcess(notifyDataCleared = false, enableTransitionAnimation = false) + // Both clearing and animation are done, now restart + clearDataAction.killAndRestartProcess(notifyDataCleared = false, enableTransitionAnimation = false) } } diff --git a/app/src/main/java/com/duckduckgo/app/global/view/FireDialogProvider.kt b/app/src/main/java/com/duckduckgo/app/global/view/FireDialogProvider.kt index 0164d482792f..823baa81e34b 100644 --- a/app/src/main/java/com/duckduckgo/app/global/view/FireDialogProvider.kt +++ b/app/src/main/java/com/duckduckgo/app/global/view/FireDialogProvider.kt @@ -18,8 +18,10 @@ package com.duckduckgo.app.global.view import android.content.Context import com.duckduckgo.app.di.AppCoroutineScope +import com.duckduckgo.app.fire.DataClearing import com.duckduckgo.app.firebutton.FireButtonStore import com.duckduckgo.app.global.events.db.UserEventsStore +import com.duckduckgo.app.pixels.remoteconfig.AndroidBrowserConfigFeature import com.duckduckgo.app.settings.db.SettingsDataStore import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.appbuildconfig.api.AppBuildConfig @@ -39,7 +41,13 @@ interface FireDialogProvider { class FireDialogLauncherImpl @Inject constructor() : FireDialogProvider { @Inject - lateinit var clearPersonalDataAction: ClearDataAction + lateinit var clearDataAction: ClearDataAction + + @Inject + lateinit var dataClearing: DataClearing + + @Inject + lateinit var androidBrowserConfigFeature: AndroidBrowserConfigFeature @Inject lateinit var pixel: Pixel @@ -65,7 +73,9 @@ class FireDialogLauncherImpl @Inject constructor() : FireDialogProvider { override fun createFireDialog(context: Context): FireDialog = FireDialog( context = context, - clearPersonalDataAction = clearPersonalDataAction, + clearDataAction = clearDataAction, + dataClearing = dataClearing, + androidBrowserConfigFeature = androidBrowserConfigFeature, pixel = pixel, settingsDataStore = settingsDataStore, userEventsStore = userEventsStore, From c0cc579c495ac6a8fb394e8784f1a3007566a472 Mon Sep 17 00:00:00 2001 From: 0nko Date: Sun, 14 Dec 2025 14:23:43 +0100 Subject: [PATCH 05/15] Extract process killing logic to the DataClearing interface --- .../app/fire/AutomaticDataClearer.kt | 13 ++++++- .../com/duckduckgo/app/fire/DataClearing.kt | 7 ++++ .../duckduckgo/app/fire/RealDataClearing.kt | 4 +++ .../duckduckgo/app/fire/DataClearingTest.kt | 36 +++++++++++++++++++ 4 files changed, 59 insertions(+), 1 deletion(-) 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 1c1117a151c1..d7522693313d 100644 --- a/app/src/main/java/com/duckduckgo/app/fire/AutomaticDataClearer.kt +++ b/app/src/main/java/com/duckduckgo/app/fire/AutomaticDataClearer.kt @@ -183,7 +183,18 @@ class AutomaticDataClearer @Inject constructor( } override fun onExit() { - clearDataAction.killProcess() + launch(dispatchers.io()) { + // the app does not have any activity in CREATED state we kill the process + val shouldKillProcess = if (androidBrowserConfigFeature.moreGranularDataClearingOptions().isEnabled()) { + dataClearing.shouldKillProcessOnExit() + } else { + settingsDataStore.automaticallyClearWhatOption != ClearWhatOption.CLEAR_NONE + } + + if (shouldKillProcess) { + clearDataAction.killProcess() + } + } } private fun scheduleBackgroundTimerToTriggerClear(durationMillis: Long) { diff --git a/app/src/main/java/com/duckduckgo/app/fire/DataClearing.kt b/app/src/main/java/com/duckduckgo/app/fire/DataClearing.kt index bcc1ddb3ec59..69dcb9687dd5 100644 --- a/app/src/main/java/com/duckduckgo/app/fire/DataClearing.kt +++ b/app/src/main/java/com/duckduckgo/app/fire/DataClearing.kt @@ -44,4 +44,11 @@ interface DataClearing { appUsedSinceLastClear: Boolean, appIconChanged: Boolean, ): Boolean + + /** + * Determines whether the process should be killed when the app exits after automatic data-clearing. + * + * @return true if process should be killed on exit, false otherwise + */ + suspend fun shouldKillProcessOnExit(): Boolean } diff --git a/app/src/main/java/com/duckduckgo/app/fire/RealDataClearing.kt b/app/src/main/java/com/duckduckgo/app/fire/RealDataClearing.kt index 8d68d4a7bf91..81a6d72b5aff 100644 --- a/app/src/main/java/com/duckduckgo/app/fire/RealDataClearing.kt +++ b/app/src/main/java/com/duckduckgo/app/fire/RealDataClearing.kt @@ -125,6 +125,10 @@ class RealDataClearing @Inject constructor( return enoughTimePassed } + override suspend fun shouldKillProcessOnExit(): Boolean { + return fireDataStore.getAutomaticClearOptions().isNotEmpty() + } + /** * Performs granular data clearing based on the provided options * @return true if process needs to be restarted diff --git a/app/src/test/java/com/duckduckgo/app/fire/DataClearingTest.kt b/app/src/test/java/com/duckduckgo/app/fire/DataClearingTest.kt index 34037b1764f3..deb542dfc115 100644 --- a/app/src/test/java/com/duckduckgo/app/fire/DataClearingTest.kt +++ b/app/src/test/java/com/duckduckgo/app/fire/DataClearingTest.kt @@ -489,6 +489,42 @@ class DataClearingTest { assertTrue(result) } + @Test + fun whenAutomaticClearOptionsConfigured_thenShouldKillProcessOnExit() = runTest { + configureAutomaticOptions(setOf(FireClearOption.DATA)) + + val result = testee.shouldKillProcessOnExit() + + assertTrue(result) + } + + @Test + fun whenNoAutomaticClearOptionsConfigured_thenShouldNotKillProcessOnExit() = runTest { + configureAutomaticOptions(emptySet()) + + val result = testee.shouldKillProcessOnExit() + + assertFalse(result) + } + + @Test + fun whenAutomaticClearOptionsConfiguredWithTabsOnly_thenShouldKillProcessOnExit() = runTest { + configureAutomaticOptions(setOf(FireClearOption.TABS)) + + val result = testee.shouldKillProcessOnExit() + + assertTrue(result) + } + + @Test + fun whenAutomaticClearOptionsConfiguredWithMultipleOptions_thenShouldKillProcessOnExit() = runTest { + configureAutomaticOptions(setOf(FireClearOption.TABS, FireClearOption.DATA, FireClearOption.DUCKAI_CHATS)) + + val result = testee.shouldKillProcessOnExit() + + assertTrue(result) + } + private suspend fun configureManualOptions(options: Set) { whenever(mockFireDataStore.getManualClearOptions()).thenReturn(options) } From 41a1e32aee259e084f1576daded4498f27ee152e Mon Sep 17 00:00:00 2001 From: 0nko Date: Sun, 14 Dec 2025 15:00:27 +0100 Subject: [PATCH 06/15] Update app last used flag value updates --- .../app/fire/AutomaticDataClearer.kt | 2 +- .../com/duckduckgo/app/fire/DataClearing.kt | 7 ++-- .../duckduckgo/app/fire/RealDataClearing.kt | 9 +++-- .../duckduckgo/app/fire/DataClearingTest.kt | 36 +++++++++---------- 4 files changed, 27 insertions(+), 27 deletions(-) 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 d7522693313d..5d7444a0a45e 100644 --- a/app/src/main/java/com/duckduckgo/app/fire/AutomaticDataClearer.kt +++ b/app/src/main/java/com/duckduckgo/app/fire/AutomaticDataClearer.kt @@ -186,7 +186,7 @@ class AutomaticDataClearer @Inject constructor( launch(dispatchers.io()) { // the app does not have any activity in CREATED state we kill the process val shouldKillProcess = if (androidBrowserConfigFeature.moreGranularDataClearingOptions().isEnabled()) { - dataClearing.shouldKillProcessOnExit() + dataClearing.shouldKillProcessAfterAutomaticDataClearing() } else { settingsDataStore.automaticallyClearWhatOption != ClearWhatOption.CLEAR_NONE } diff --git a/app/src/main/java/com/duckduckgo/app/fire/DataClearing.kt b/app/src/main/java/com/duckduckgo/app/fire/DataClearing.kt index 69dcb9687dd5..8c01a5c281c2 100644 --- a/app/src/main/java/com/duckduckgo/app/fire/DataClearing.kt +++ b/app/src/main/java/com/duckduckgo/app/fire/DataClearing.kt @@ -20,8 +20,9 @@ interface DataClearing { /** * Clears data when user requests data clearing using the FireDialog. * @param shouldRestartProcess whether to restart the app process after clearing data + * @param wasAppUsedSinceLastClear whether the app was used since the last data clear */ - suspend fun clearDataUsingManualFireOptions(shouldRestartProcess: Boolean = false) + suspend fun clearDataUsingManualFireOptions(shouldRestartProcess: Boolean = false, wasAppUsedSinceLastClear: Boolean = false) /** * Clears data automatically based on auto-clear settings. @@ -46,9 +47,9 @@ interface DataClearing { ): Boolean /** - * Determines whether the process should be killed when the app exits after automatic data-clearing. + * Determines whether the process should be killed after automatic data-clearing. * * @return true if process should be killed on exit, false otherwise */ - suspend fun shouldKillProcessOnExit(): Boolean + suspend fun shouldKillProcessAfterAutomaticDataClearing(): Boolean } diff --git a/app/src/main/java/com/duckduckgo/app/fire/RealDataClearing.kt b/app/src/main/java/com/duckduckgo/app/fire/RealDataClearing.kt index 81a6d72b5aff..a59519738723 100644 --- a/app/src/main/java/com/duckduckgo/app/fire/RealDataClearing.kt +++ b/app/src/main/java/com/duckduckgo/app/fire/RealDataClearing.kt @@ -41,19 +41,18 @@ class RealDataClearing @Inject constructor( private val dataClearerTimeKeeper: BackgroundTimeKeeper, ) : DataClearing { - override suspend fun clearDataUsingManualFireOptions(shouldRestartProcess: Boolean) { + override suspend fun clearDataUsingManualFireOptions(shouldRestartProcess: Boolean, wasAppUsedSinceLastClear: Boolean) { val options = fireDataStore.getManualClearOptions() performGranularClear( options = options, shouldFireDataClearPixel = true, ) + clearDataAction.setAppUsedSinceLastClearFlag(wasAppUsedSinceLastClear) + val wasDataCleared = options.contains(FireClearOption.DATA) || options.contains(FireClearOption.DUCKAI_CHATS) if (shouldRestartProcess && wasDataCleared) { - clearDataAction.setAppUsedSinceLastClearFlag(false) clearDataAction.killAndRestartProcess(notifyDataCleared = false) - } else { - clearDataAction.setAppUsedSinceLastClearFlag(true) } } @@ -125,7 +124,7 @@ class RealDataClearing @Inject constructor( return enoughTimePassed } - override suspend fun shouldKillProcessOnExit(): Boolean { + override suspend fun shouldKillProcessAfterAutomaticDataClearing(): Boolean { return fireDataStore.getAutomaticClearOptions().isNotEmpty() } diff --git a/app/src/test/java/com/duckduckgo/app/fire/DataClearingTest.kt b/app/src/test/java/com/duckduckgo/app/fire/DataClearingTest.kt index deb542dfc115..d9848b96233e 100644 --- a/app/src/test/java/com/duckduckgo/app/fire/DataClearingTest.kt +++ b/app/src/test/java/com/duckduckgo/app/fire/DataClearingTest.kt @@ -70,7 +70,7 @@ class DataClearingTest { fun whenManualClearWithTabsOnly_thenOnlyClearTabs() = runTest { configureManualOptions(setOf(FireClearOption.TABS)) - testee.clearDataUsingManualFireOptions(shouldRestartProcess = false) + testee.clearDataUsingManualFireOptions(shouldRestartProcess = false, wasAppUsedSinceLastClear = true) verify(mockClearDataAction).clearTabsOnly() verify(mockClearDataAction, never()).clearBrowserDataOnly(any()) @@ -83,7 +83,7 @@ class DataClearingTest { fun whenManualClearWithDataOnly_thenClearDataAndSetFlag() = runTest { configureManualOptions(setOf(FireClearOption.DATA)) - testee.clearDataUsingManualFireOptions(shouldRestartProcess = false) + testee.clearDataUsingManualFireOptions(shouldRestartProcess = false, wasAppUsedSinceLastClear = true) verify(mockClearDataAction, never()).clearTabsOnly() verify(mockClearDataAction).clearBrowserDataOnly(true) @@ -96,7 +96,7 @@ class DataClearingTest { fun whenManualClearWithTabsAndData_thenClearBothAndSetFlag() = runTest { configureManualOptions(setOf(FireClearOption.TABS, FireClearOption.DATA)) - testee.clearDataUsingManualFireOptions(shouldRestartProcess = false) + testee.clearDataUsingManualFireOptions(shouldRestartProcess = false, wasAppUsedSinceLastClear = true) verify(mockClearDataAction).clearTabsOnly() verify(mockClearDataAction).clearBrowserDataOnly(true) @@ -109,7 +109,7 @@ class DataClearingTest { fun whenManualClearWithDuckAiChatsOnly_thenClearChatsAndSetFlag() = runTest { configureManualOptions(setOf(FireClearOption.DUCKAI_CHATS)) - testee.clearDataUsingManualFireOptions(shouldRestartProcess = false) + testee.clearDataUsingManualFireOptions(shouldRestartProcess = false, wasAppUsedSinceLastClear = true) verify(mockClearDataAction, never()).clearTabsOnly() verify(mockClearDataAction, never()).clearBrowserDataOnly(any()) @@ -122,7 +122,7 @@ class DataClearingTest { fun whenManualClearWithAllOptions_thenClearAllAndSetFlag() = runTest { configureManualOptions(setOf(FireClearOption.TABS, FireClearOption.DATA, FireClearOption.DUCKAI_CHATS)) - testee.clearDataUsingManualFireOptions(shouldRestartProcess = false) + testee.clearDataUsingManualFireOptions(shouldRestartProcess = false, wasAppUsedSinceLastClear = true) verify(mockClearDataAction).clearTabsOnly() verify(mockClearDataAction).clearBrowserDataOnly(true) @@ -135,7 +135,7 @@ class DataClearingTest { fun whenManualClearWithNoOptionsSelected_thenOnlySetFlag() = runTest { configureManualOptions(emptySet()) - testee.clearDataUsingManualFireOptions(shouldRestartProcess = false) + testee.clearDataUsingManualFireOptions(shouldRestartProcess = false, wasAppUsedSinceLastClear = true) verify(mockClearDataAction, never()).clearTabsOnly() verify(mockClearDataAction, never()).clearBrowserDataOnly(any()) @@ -148,7 +148,7 @@ class DataClearingTest { fun whenManualClearWithNoOptionsSelectedAndShouldRestartProcess_thenOnlySetFlagAndDoNotRestart() = runTest { configureManualOptions(emptySet()) - testee.clearDataUsingManualFireOptions(shouldRestartProcess = true) + testee.clearDataUsingManualFireOptions(shouldRestartProcess = true, wasAppUsedSinceLastClear = true) verify(mockClearDataAction, never()).clearTabsOnly() verify(mockClearDataAction, never()).clearBrowserDataOnly(any()) @@ -161,7 +161,7 @@ class DataClearingTest { fun whenManualClearWithTabsOnlyAndShouldRestartProcess_thenDoNotRestart() = runTest { configureManualOptions(setOf(FireClearOption.TABS)) - testee.clearDataUsingManualFireOptions(shouldRestartProcess = true) + testee.clearDataUsingManualFireOptions(shouldRestartProcess = true, wasAppUsedSinceLastClear = true) verify(mockClearDataAction).clearTabsOnly() verify(mockClearDataAction).setAppUsedSinceLastClearFlag(true) @@ -172,7 +172,7 @@ class DataClearingTest { fun whenManualClearWithDataAndShouldRestartProcess_thenRestartProcess() = runTest { configureManualOptions(setOf(FireClearOption.DATA)) - testee.clearDataUsingManualFireOptions(shouldRestartProcess = true) + testee.clearDataUsingManualFireOptions(shouldRestartProcess = true, wasAppUsedSinceLastClear = false) verify(mockClearDataAction).clearBrowserDataOnly(true) verify(mockClearDataAction).setAppUsedSinceLastClearFlag(false) @@ -183,7 +183,7 @@ class DataClearingTest { fun whenManualClearWithTabsAndDataAndShouldRestartProcess_thenRestartProcess() = runTest { configureManualOptions(setOf(FireClearOption.TABS, FireClearOption.DATA)) - testee.clearDataUsingManualFireOptions(shouldRestartProcess = true) + testee.clearDataUsingManualFireOptions(shouldRestartProcess = true, wasAppUsedSinceLastClear = false) verify(mockClearDataAction).clearTabsOnly() verify(mockClearDataAction).clearBrowserDataOnly(true) @@ -195,7 +195,7 @@ class DataClearingTest { fun whenManualClearWithDuckAiChatsAndShouldRestartProcess_thenRestartProcess() = runTest { configureManualOptions(setOf(FireClearOption.DUCKAI_CHATS)) - testee.clearDataUsingManualFireOptions(shouldRestartProcess = true) + testee.clearDataUsingManualFireOptions(shouldRestartProcess = true, wasAppUsedSinceLastClear = false) verify(mockClearDataAction).clearDuckAiChatsOnly() verify(mockClearDataAction).setAppUsedSinceLastClearFlag(false) @@ -490,10 +490,10 @@ class DataClearingTest { } @Test - fun whenAutomaticClearOptionsConfigured_thenShouldKillProcessOnExit() = runTest { + fun whenAutomaticClearOptionsConfigured_thenShouldKillProcessAfterAutomaticDataClearing() = runTest { configureAutomaticOptions(setOf(FireClearOption.DATA)) - val result = testee.shouldKillProcessOnExit() + val result = testee.shouldKillProcessAfterAutomaticDataClearing() assertTrue(result) } @@ -502,25 +502,25 @@ class DataClearingTest { fun whenNoAutomaticClearOptionsConfigured_thenShouldNotKillProcessOnExit() = runTest { configureAutomaticOptions(emptySet()) - val result = testee.shouldKillProcessOnExit() + val result = testee.shouldKillProcessAfterAutomaticDataClearing() assertFalse(result) } @Test - fun whenAutomaticClearOptionsConfiguredWithTabsOnly_thenShouldKillProcessOnExit() = runTest { + fun whenAutomaticClearOptionsConfiguredWithTabsOnly_thenShouldKillProcessAfterAutomaticDataClearing() = runTest { configureAutomaticOptions(setOf(FireClearOption.TABS)) - val result = testee.shouldKillProcessOnExit() + val result = testee.shouldKillProcessAfterAutomaticDataClearing() assertTrue(result) } @Test - fun whenAutomaticClearOptionsConfiguredWithMultipleOptions_thenShouldKillProcessOnExit() = runTest { + fun whenAutomaticClearOptionsConfiguredWithMultipleOptions_thenShouldKillProcessAfterAutomaticDataClearing() = runTest { configureAutomaticOptions(setOf(FireClearOption.TABS, FireClearOption.DATA, FireClearOption.DUCKAI_CHATS)) - val result = testee.shouldKillProcessOnExit() + val result = testee.shouldKillProcessAfterAutomaticDataClearing() assertTrue(result) } From 938f2930f3c1ea4054f3def96a4551efe7ba1c8d Mon Sep 17 00:00:00 2001 From: 0nko Date: Sun, 14 Dec 2025 15:09:50 +0100 Subject: [PATCH 07/15] Execut the onExit code synchronously --- .../main/java/com/duckduckgo/app/fire/AutomaticDataClearer.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 5d7444a0a45e..f176c2a9d543 100644 --- a/app/src/main/java/com/duckduckgo/app/fire/AutomaticDataClearer.kt +++ b/app/src/main/java/com/duckduckgo/app/fire/AutomaticDataClearer.kt @@ -43,6 +43,7 @@ import dagger.SingleInstanceIn import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import logcat.LogPriority.WARN import logcat.logcat @@ -183,7 +184,7 @@ class AutomaticDataClearer @Inject constructor( } override fun onExit() { - launch(dispatchers.io()) { + runBlocking(dispatchers.io()) { // the app does not have any activity in CREATED state we kill the process val shouldKillProcess = if (androidBrowserConfigFeature.moreGranularDataClearingOptions().isEnabled()) { dataClearing.shouldKillProcessAfterAutomaticDataClearing() From 56e0ec15e649b9257a39022ad04bf4ea7ad41bd0 Mon Sep 17 00:00:00 2001 From: 0nko Date: Sun, 14 Dec 2025 15:58:11 +0100 Subject: [PATCH 08/15] Separate DataClearing into manual and automatic clearing interface --- .../app/fire/AutomaticDataClearerTest.kt | 2 +- .../duckduckgo/app/browser/BrowserActivity.kt | 4 +- .../app/fire/AutomaticDataClearer.kt | 2 +- .../app/fire/AutomaticDataClearing.kt | 51 ++++++ .../com/duckduckgo/app/fire/DataClearing.kt | 167 +++++++++++++++--- .../duckduckgo/app/fire/DataClearingWorker.kt | 2 +- .../duckduckgo/app/fire/ManualDataClearing.kt | 29 +++ .../duckduckgo/app/fire/RealDataClearing.kt | 159 ----------------- .../duckduckgo/app/global/view/FireDialog.kt | 4 +- .../app/global/view/FireDialogProvider.kt | 4 +- .../duckduckgo/app/fire/DataClearingTest.kt | 4 +- 11 files changed, 230 insertions(+), 198 deletions(-) create mode 100644 app/src/main/java/com/duckduckgo/app/fire/AutomaticDataClearing.kt create mode 100644 app/src/main/java/com/duckduckgo/app/fire/ManualDataClearing.kt delete mode 100644 app/src/main/java/com/duckduckgo/app/fire/RealDataClearing.kt 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 fd3c72ef77b7..b7ea0b27df7b 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/fire/AutomaticDataClearerTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/fire/AutomaticDataClearerTest.kt @@ -60,7 +60,7 @@ class AutomaticDataClearerTest { private val mockClearAction: ClearDataAction = mock() private val mockTimeKeeper: BackgroundTimeKeeper = mock() private val mockWorkManager: WorkManager = mock() - private val mockDataClearing: DataClearing = mock() + private val mockDataClearing: AutomaticDataClearing = mock() private val fakeAndroidBrowserConfigFeature = FakeFeatureToggleFactory.create(AndroidBrowserConfigFeature::class.java) private val pixel: Pixel = mock() private val dataClearerForegroundAppRestartPixel = diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt index e34243980d52..a5a394f7216b 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt @@ -75,7 +75,7 @@ import com.duckduckgo.app.downloads.DownloadsScreens.DownloadsScreenNoParams import com.duckduckgo.app.feedback.ui.common.FeedbackActivity import com.duckduckgo.app.fire.DataClearer import com.duckduckgo.app.fire.DataClearerForegroundAppRestartPixel -import com.duckduckgo.app.fire.DataClearing +import com.duckduckgo.app.fire.ManualDataClearing import com.duckduckgo.app.global.ApplicationClearDataState import com.duckduckgo.app.global.intentText import com.duckduckgo.app.global.rating.PromptCount @@ -146,7 +146,7 @@ open class BrowserActivity : DuckDuckGoActivity() { lateinit var clearDataAction: ClearDataAction @Inject - lateinit var dataClearing: DataClearing + lateinit var dataClearing: ManualDataClearing @Inject lateinit var androidBrowserConfigFeature: AndroidBrowserConfigFeature 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 f176c2a9d543..38e9e2ec41a3 100644 --- a/app/src/main/java/com/duckduckgo/app/fire/AutomaticDataClearer.kt +++ b/app/src/main/java/com/duckduckgo/app/fire/AutomaticDataClearer.kt @@ -69,7 +69,7 @@ class AutomaticDataClearer @Inject constructor( private val workManager: WorkManager, private val settingsDataStore: SettingsDataStore, private val clearDataAction: ClearDataAction, - private val dataClearing: DataClearing, + private val dataClearing: AutomaticDataClearing, private val androidBrowserConfigFeature: AndroidBrowserConfigFeature, private val dataClearerTimeKeeper: BackgroundTimeKeeper, private val dataClearerForegroundAppRestartPixel: DataClearerForegroundAppRestartPixel, diff --git a/app/src/main/java/com/duckduckgo/app/fire/AutomaticDataClearing.kt b/app/src/main/java/com/duckduckgo/app/fire/AutomaticDataClearing.kt new file mode 100644 index 000000000000..61f2b0f71f52 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/fire/AutomaticDataClearing.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2025 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.fire + +/** + * Interface for automatic data clearing operations triggered by app lifecycle events. + */ +interface AutomaticDataClearing { + /** + * Clears data automatically based on auto-clear settings. + * @param killProcessIfNeeded whether to kill the app process after clearing data and + * + * @return true if process should be restarted later, false otherwise + */ + suspend fun clearDataUsingAutomaticFireOptions(killProcessIfNeeded: Boolean = true): Boolean + + /** + * Determines whether data should be cleared based on auto-clear settings. + * @param isFreshAppLaunch true if the app has been freshly launched, false otherwise + * @param appUsedSinceLastClear true if the app has been used since the last data clear, false otherwise + * @param appIconChanged true if the app icon has changed since the last + * + * @return true if data should be cleared automatically, false otherwise + */ + suspend fun shouldClearDataAutomatically( + isFreshAppLaunch: Boolean, + appUsedSinceLastClear: Boolean, + appIconChanged: Boolean, + ): Boolean + + /** + * Determines whether the process should be killed after automatic data-clearing. + * + * @return true if process should be killed on exit, false otherwise + */ + suspend fun shouldKillProcessAfterAutomaticDataClearing(): Boolean +} diff --git a/app/src/main/java/com/duckduckgo/app/fire/DataClearing.kt b/app/src/main/java/com/duckduckgo/app/fire/DataClearing.kt index 8c01a5c281c2..ebc6db0e0b50 100644 --- a/app/src/main/java/com/duckduckgo/app/fire/DataClearing.kt +++ b/app/src/main/java/com/duckduckgo/app/fire/DataClearing.kt @@ -16,40 +16,151 @@ package com.duckduckgo.app.fire -interface DataClearing { - /** - * Clears data when user requests data clearing using the FireDialog. - * @param shouldRestartProcess whether to restart the app process after clearing data - * @param wasAppUsedSinceLastClear whether the app was used since the last data clear - */ - suspend fun clearDataUsingManualFireOptions(shouldRestartProcess: Boolean = false, wasAppUsedSinceLastClear: Boolean = false) +import com.duckduckgo.app.fire.store.FireDataStore +import com.duckduckgo.app.global.view.ClearDataAction +import com.duckduckgo.app.settings.clear.ClearWhenOption +import com.duckduckgo.app.settings.clear.FireClearOption +import com.duckduckgo.app.settings.db.SettingsDataStore +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import dagger.SingleInstanceIn +import logcat.LogPriority.WARN +import logcat.logcat +import javax.inject.Inject - /** - * Clears data automatically based on auto-clear settings. - * @param killProcessIfNeeded whether to kill the app process after clearing data and - * - * @return true if process should be restarted later, false otherwise - */ - suspend fun clearDataUsingAutomaticFireOptions(killProcessIfNeeded: Boolean = true): Boolean +/** + * Implementation that provides granular data clearing capabilities for both manual and automatic clearing. + * This uses the FireDataStore to determine which data to clear based on user preferences. + */ +@ContributesBinding( + scope = AppScope::class, + boundType = ManualDataClearing::class, +) +@ContributesBinding( + scope = AppScope::class, + boundType = AutomaticDataClearing::class, +) +@SingleInstanceIn(AppScope::class) +class DataClearing @Inject constructor( + private val fireDataStore: FireDataStore, + private val clearDataAction: ClearDataAction, + private val settingsDataStore: SettingsDataStore, + private val dataClearerTimeKeeper: BackgroundTimeKeeper, +) : ManualDataClearing, AutomaticDataClearing { - /** - * Determines whether data should be cleared based on auto-clear settings. - * @param isFreshAppLaunch true if the app has been freshly launched, false otherwise - * @param appUsedSinceLastClear true if the app has been used since the last data clear, false otherwise - * @param appIconChanged true if the app icon has changed since the last - * - * @return true if data should be cleared automatically, false otherwise - */ - suspend fun shouldClearDataAutomatically( + override suspend fun clearDataUsingManualFireOptions(shouldRestartProcess: Boolean, wasAppUsedSinceLastClear: Boolean) { + val options = fireDataStore.getManualClearOptions() + performGranularClear( + options = options, + shouldFireDataClearPixel = true, + ) + + clearDataAction.setAppUsedSinceLastClearFlag(wasAppUsedSinceLastClear) + + val wasDataCleared = options.contains(FireClearOption.DATA) || options.contains(FireClearOption.DUCKAI_CHATS) + if (shouldRestartProcess && wasDataCleared) { + clearDataAction.killAndRestartProcess(notifyDataCleared = false) + } + } + + override suspend fun clearDataUsingAutomaticFireOptions(killProcessIfNeeded: Boolean): Boolean { + val options = fireDataStore.getAutomaticClearOptions() + performGranularClear( + options = options, + shouldFireDataClearPixel = false, + ) + + clearDataAction.setAppUsedSinceLastClearFlag(!killProcessIfNeeded) + + val wasDataCleared = options.contains(FireClearOption.DATA) || options.contains(FireClearOption.DUCKAI_CHATS) + if (killProcessIfNeeded && wasDataCleared) { + clearDataAction.killProcess() + return false + } else { + return wasDataCleared + } + } + + override suspend fun shouldClearDataAutomatically( isFreshAppLaunch: Boolean, appUsedSinceLastClear: Boolean, appIconChanged: Boolean, - ): Boolean + ): Boolean { + val clearWhenOption = fireDataStore.getAutomaticallyClearWhenOption() + + logcat { "Determining if data should be cleared for option $clearWhenOption" } + + if (fireDataStore.getAutomaticClearOptions().isEmpty()) { + logcat { "No automatic clear options selected; will not clear data" } + return false + } + + if (!appUsedSinceLastClear) { + logcat { "App hasn't been used since last clear; no need to clear again" } + return false + } + + logcat { "App has been used since last clear" } + + if (isFreshAppLaunch) { + logcat { "This is a fresh app launch, so will clear the data" } + return true + } + + if (appIconChanged) { + logcat { "No data will be cleared as the app icon was just changed" } + return false + } + + if (clearWhenOption == ClearWhenOption.APP_EXIT_ONLY) { + logcat { "This is NOT a fresh app launch, and the configuration is for app exit only. Not clearing the data" } + return false + } + if (!settingsDataStore.hasBackgroundTimestampRecorded()) { + logcat { "No background timestamp recorded; will not clear the data" } + logcat(WARN) { "No background timestamp recorded; will not clear the data" } + return false + } + + val enoughTimePassed = dataClearerTimeKeeper.hasEnoughTimeElapsed( + backgroundedTimestamp = settingsDataStore.appBackgroundedTimestamp, + clearWhenOption = clearWhenOption, + ) + logcat { "Has enough time passed to trigger the data clear? $enoughTimePassed" } + + return enoughTimePassed + } + + override suspend fun shouldKillProcessAfterAutomaticDataClearing(): Boolean { + return fireDataStore.getAutomaticClearOptions().isNotEmpty() + } /** - * Determines whether the process should be killed after automatic data-clearing. - * - * @return true if process should be killed on exit, false otherwise + * Performs granular data clearing based on the provided options + * @return true if process needs to be restarted */ - suspend fun shouldKillProcessAfterAutomaticDataClearing(): Boolean + private suspend fun performGranularClear( + options: Set, + shouldFireDataClearPixel: Boolean, + ) { + logcat { "Performing granular clear with options: $options" } + + val shouldClearTabs = FireClearOption.TABS in options + val shouldClearData = FireClearOption.DATA in options + val shouldClearDuckAiChats = FireClearOption.DUCKAI_CHATS in options + + if (shouldClearTabs) { + clearDataAction.clearTabsOnly() + } + + if (shouldClearData) { + clearDataAction.clearBrowserDataOnly(shouldFireDataClearPixel) + } + + if (shouldClearDuckAiChats) { + clearDataAction.clearDuckAiChatsOnly() + } + + logcat { "Granular clear completed" } + } } 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 7482b5b4b723..9eeef91cba89 100644 --- a/app/src/main/java/com/duckduckgo/app/fire/DataClearingWorker.kt +++ b/app/src/main/java/com/duckduckgo/app/fire/DataClearingWorker.kt @@ -48,7 +48,7 @@ class DataClearingWorker( lateinit var clearDataAction: ClearDataAction @Inject - lateinit var dataClearing: DataClearing + lateinit var dataClearing: AutomaticDataClearing @Inject lateinit var androidBrowserConfigFeature: AndroidBrowserConfigFeature diff --git a/app/src/main/java/com/duckduckgo/app/fire/ManualDataClearing.kt b/app/src/main/java/com/duckduckgo/app/fire/ManualDataClearing.kt new file mode 100644 index 000000000000..cbe7aefeb9b6 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/fire/ManualDataClearing.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.fire + +/** + * Interface for manual data clearing operations triggered by user actions (e.g., Fire button). + */ +interface ManualDataClearing { + /** + * Clears data when user requests data clearing using the FireDialog. + * @param shouldRestartProcess whether to restart the app process after clearing data + * @param wasAppUsedSinceLastClear whether the app was used since the last data clear + */ + suspend fun clearDataUsingManualFireOptions(shouldRestartProcess: Boolean = false, wasAppUsedSinceLastClear: Boolean = false) +} diff --git a/app/src/main/java/com/duckduckgo/app/fire/RealDataClearing.kt b/app/src/main/java/com/duckduckgo/app/fire/RealDataClearing.kt deleted file mode 100644 index a59519738723..000000000000 --- a/app/src/main/java/com/duckduckgo/app/fire/RealDataClearing.kt +++ /dev/null @@ -1,159 +0,0 @@ -/* - * Copyright (c) 2025 DuckDuckGo - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.duckduckgo.app.fire - -import com.duckduckgo.app.fire.store.FireDataStore -import com.duckduckgo.app.global.view.ClearDataAction -import com.duckduckgo.app.settings.clear.ClearWhenOption -import com.duckduckgo.app.settings.clear.FireClearOption -import com.duckduckgo.app.settings.db.SettingsDataStore -import com.duckduckgo.di.scopes.AppScope -import com.squareup.anvil.annotations.ContributesBinding -import dagger.SingleInstanceIn -import logcat.LogPriority.WARN -import logcat.logcat -import javax.inject.Inject - -/** - * Implementation of DataClearing that provides granular data clearing capabilities. - * This uses the FireDataStore to determine which data to clear based on user preferences. - */ -@ContributesBinding(AppScope::class) -@SingleInstanceIn(AppScope::class) -class RealDataClearing @Inject constructor( - private val fireDataStore: FireDataStore, - private val clearDataAction: ClearDataAction, - private val settingsDataStore: SettingsDataStore, - private val dataClearerTimeKeeper: BackgroundTimeKeeper, -) : DataClearing { - - override suspend fun clearDataUsingManualFireOptions(shouldRestartProcess: Boolean, wasAppUsedSinceLastClear: Boolean) { - val options = fireDataStore.getManualClearOptions() - performGranularClear( - options = options, - shouldFireDataClearPixel = true, - ) - - clearDataAction.setAppUsedSinceLastClearFlag(wasAppUsedSinceLastClear) - - val wasDataCleared = options.contains(FireClearOption.DATA) || options.contains(FireClearOption.DUCKAI_CHATS) - if (shouldRestartProcess && wasDataCleared) { - clearDataAction.killAndRestartProcess(notifyDataCleared = false) - } - } - - override suspend fun clearDataUsingAutomaticFireOptions(killProcessIfNeeded: Boolean): Boolean { - val options = fireDataStore.getAutomaticClearOptions() - performGranularClear( - options = options, - shouldFireDataClearPixel = false, - ) - - clearDataAction.setAppUsedSinceLastClearFlag(!killProcessIfNeeded) - - val wasDataCleared = options.contains(FireClearOption.DATA) || options.contains(FireClearOption.DUCKAI_CHATS) - if (killProcessIfNeeded && wasDataCleared) { - clearDataAction.killProcess() - return false - } else { - return wasDataCleared - } - } - - override suspend fun shouldClearDataAutomatically( - isFreshAppLaunch: Boolean, - appUsedSinceLastClear: Boolean, - appIconChanged: Boolean, - ): Boolean { - val clearWhenOption = fireDataStore.getAutomaticallyClearWhenOption() - - logcat { "Determining if data should be cleared for option $clearWhenOption" } - - if (fireDataStore.getAutomaticClearOptions().isEmpty()) { - logcat { "No automatic clear options selected; will not clear data" } - return false - } - - if (!appUsedSinceLastClear) { - logcat { "App hasn't been used since last clear; no need to clear again" } - return false - } - - logcat { "App has been used since last clear" } - - if (isFreshAppLaunch) { - logcat { "This is a fresh app launch, so will clear the data" } - return true - } - - if (appIconChanged) { - logcat { "No data will be cleared as the app icon was just changed" } - return false - } - - if (clearWhenOption == ClearWhenOption.APP_EXIT_ONLY) { - logcat { "This is NOT a fresh app launch, and the configuration is for app exit only. Not clearing the data" } - return false - } - if (!settingsDataStore.hasBackgroundTimestampRecorded()) { - logcat { "No background timestamp recorded; will not clear the data" } - logcat(WARN) { "No background timestamp recorded; will not clear the data" } - return false - } - - val enoughTimePassed = dataClearerTimeKeeper.hasEnoughTimeElapsed( - backgroundedTimestamp = settingsDataStore.appBackgroundedTimestamp, - clearWhenOption = clearWhenOption, - ) - logcat { "Has enough time passed to trigger the data clear? $enoughTimePassed" } - - return enoughTimePassed - } - - override suspend fun shouldKillProcessAfterAutomaticDataClearing(): Boolean { - return fireDataStore.getAutomaticClearOptions().isNotEmpty() - } - - /** - * Performs granular data clearing based on the provided options - * @return true if process needs to be restarted - */ - private suspend fun performGranularClear( - options: Set, - shouldFireDataClearPixel: Boolean, - ) { - logcat { "Performing granular clear with options: $options" } - - val shouldClearTabs = FireClearOption.TABS in options - val shouldClearData = FireClearOption.DATA in options - val shouldClearDuckAiChats = FireClearOption.DUCKAI_CHATS in options - - if (shouldClearTabs) { - clearDataAction.clearTabsOnly() - } - - if (shouldClearData) { - clearDataAction.clearBrowserDataOnly(shouldFireDataClearPixel) - } - - if (shouldClearDuckAiChats) { - clearDataAction.clearDuckAiChatsOnly() - } - - logcat { "Granular clear completed" } - } -} diff --git a/app/src/main/java/com/duckduckgo/app/global/view/FireDialog.kt b/app/src/main/java/com/duckduckgo/app/global/view/FireDialog.kt index 42e425cdc150..06bc653838a0 100644 --- a/app/src/main/java/com/duckduckgo/app/global/view/FireDialog.kt +++ b/app/src/main/java/com/duckduckgo/app/global/view/FireDialog.kt @@ -33,7 +33,7 @@ import androidx.core.view.WindowInsetsControllerCompat import androidx.core.view.updatePadding import com.airbnb.lottie.RenderMode import com.duckduckgo.app.browser.databinding.SheetFireClearDataBinding -import com.duckduckgo.app.fire.DataClearing +import com.duckduckgo.app.fire.ManualDataClearing import com.duckduckgo.app.firebutton.FireButtonStore import com.duckduckgo.app.global.events.db.UserEventKey import com.duckduckgo.app.global.events.db.UserEventsStore @@ -70,7 +70,7 @@ private const val ANIMATION_SPEED_INCREMENT = 0.15f class FireDialog( context: Context, private val clearDataAction: ClearDataAction, - private val dataClearing: DataClearing, + private val dataClearing: ManualDataClearing, private val androidBrowserConfigFeature: AndroidBrowserConfigFeature, private val pixel: Pixel, private val settingsDataStore: SettingsDataStore, diff --git a/app/src/main/java/com/duckduckgo/app/global/view/FireDialogProvider.kt b/app/src/main/java/com/duckduckgo/app/global/view/FireDialogProvider.kt index 823baa81e34b..0fbf11d4eda9 100644 --- a/app/src/main/java/com/duckduckgo/app/global/view/FireDialogProvider.kt +++ b/app/src/main/java/com/duckduckgo/app/global/view/FireDialogProvider.kt @@ -18,7 +18,7 @@ package com.duckduckgo.app.global.view import android.content.Context import com.duckduckgo.app.di.AppCoroutineScope -import com.duckduckgo.app.fire.DataClearing +import com.duckduckgo.app.fire.ManualDataClearing import com.duckduckgo.app.firebutton.FireButtonStore import com.duckduckgo.app.global.events.db.UserEventsStore import com.duckduckgo.app.pixels.remoteconfig.AndroidBrowserConfigFeature @@ -44,7 +44,7 @@ class FireDialogLauncherImpl @Inject constructor() : FireDialogProvider { lateinit var clearDataAction: ClearDataAction @Inject - lateinit var dataClearing: DataClearing + lateinit var dataClearing: ManualDataClearing @Inject lateinit var androidBrowserConfigFeature: AndroidBrowserConfigFeature diff --git a/app/src/test/java/com/duckduckgo/app/fire/DataClearingTest.kt b/app/src/test/java/com/duckduckgo/app/fire/DataClearingTest.kt index d9848b96233e..6ca8fb304da8 100644 --- a/app/src/test/java/com/duckduckgo/app/fire/DataClearingTest.kt +++ b/app/src/test/java/com/duckduckgo/app/fire/DataClearingTest.kt @@ -41,7 +41,7 @@ class DataClearingTest { @get:Rule val coroutineTestRule: CoroutineTestRule = CoroutineTestRule() - private lateinit var testee: RealDataClearing + private lateinit var testee: DataClearing @Mock private lateinit var mockFireDataStore: FireDataStore @@ -58,7 +58,7 @@ class DataClearingTest { @Before fun setup() { MockitoAnnotations.openMocks(this) - testee = RealDataClearing( + testee = DataClearing( fireDataStore = mockFireDataStore, clearDataAction = mockClearDataAction, settingsDataStore = mockSettingsDataStore, From 588c8579607d3b0aa54656d39eb3b061a7e41f99 Mon Sep 17 00:00:00 2001 From: 0nko Date: Tue, 16 Dec 2025 00:32:13 +0100 Subject: [PATCH 09/15] Optimize imports --- app/src/main/java/com/duckduckgo/app/global/view/FireDialog.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/duckduckgo/app/global/view/FireDialog.kt b/app/src/main/java/com/duckduckgo/app/global/view/FireDialog.kt index 06bc653838a0..54f4adc548f1 100644 --- a/app/src/main/java/com/duckduckgo/app/global/view/FireDialog.kt +++ b/app/src/main/java/com/duckduckgo/app/global/view/FireDialog.kt @@ -42,8 +42,8 @@ import com.duckduckgo.app.global.view.FireDialog.FireDialogClearAllEvent.ClearAl import com.duckduckgo.app.pixels.AppPixelName import com.duckduckgo.app.pixels.AppPixelName.FIRE_DIALOG_ANIMATION import com.duckduckgo.app.pixels.AppPixelName.FIRE_DIALOG_CLEAR_PRESSED -import com.duckduckgo.app.pixels.remoteconfig.AndroidBrowserConfigFeature import com.duckduckgo.app.pixels.AppPixelName.PRODUCT_TELEMETRY_SURFACE_DATA_CLEARING +import com.duckduckgo.app.pixels.remoteconfig.AndroidBrowserConfigFeature import com.duckduckgo.app.settings.clear.getPixelValue import com.duckduckgo.app.settings.db.SettingsDataStore import com.duckduckgo.app.statistics.pixels.Pixel From 01b309ff055670cad5503c63f11232a6e4110307 Mon Sep 17 00:00:00 2001 From: 0nko Date: Tue, 16 Dec 2025 01:21:48 +0100 Subject: [PATCH 10/15] Fix a bug with scheduling automatic clearing --- .../app/fire/AutomaticDataClearerTest.kt | 129 ++++++++++++++++++ .../app/fire/AutomaticDataClearer.kt | 25 +++- .../app/fire/AutomaticDataClearing.kt | 4 +- .../com/duckduckgo/app/fire/DataClearing.kt | 2 +- .../duckduckgo/app/fire/DataClearingTest.kt | 14 +- 5 files changed, 158 insertions(+), 16 deletions(-) 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 b7ea0b27df7b..0c53968e4624 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/fire/AutomaticDataClearerTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/fire/AutomaticDataClearerTest.kt @@ -22,10 +22,13 @@ import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.test.annotation.UiThreadTest import androidx.test.platform.app.InstrumentationRegistry import androidx.work.WorkManager +import androidx.work.WorkRequest +import com.duckduckgo.app.fire.store.FireDataStore import com.duckduckgo.app.global.view.ClearDataAction import com.duckduckgo.app.pixels.remoteconfig.AndroidBrowserConfigFeature import com.duckduckgo.app.settings.clear.ClearWhatOption import com.duckduckgo.app.settings.clear.ClearWhenOption +import com.duckduckgo.app.settings.clear.FireClearOption import com.duckduckgo.app.settings.db.SettingsDataStore import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.common.test.CoroutineTestRule @@ -61,6 +64,7 @@ class AutomaticDataClearerTest { private val mockTimeKeeper: BackgroundTimeKeeper = mock() private val mockWorkManager: WorkManager = mock() private val mockDataClearing: AutomaticDataClearing = mock() + private val mockFireDataStore: FireDataStore = mock() private val fakeAndroidBrowserConfigFeature = FakeFeatureToggleFactory.create(AndroidBrowserConfigFeature::class.java) private val pixel: Pixel = mock() private val dataClearerForegroundAppRestartPixel = @@ -84,6 +88,7 @@ class AutomaticDataClearerTest { dataClearerTimeKeeper = mockTimeKeeper, dataClearerForegroundAppRestartPixel = dataClearerForegroundAppRestartPixel, dispatchers = coroutineTestRule.testDispatcherProvider, + fireDataStore = mockFireDataStore, ) } @@ -773,4 +778,128 @@ class AutomaticDataClearerTest { private suspend fun configureShouldClearAutomatically(shouldClear: Boolean) { whenever(mockDataClearing.shouldClearDataAutomatically(any(), any(), any())).thenReturn(shouldClear) } + + @Test + fun whenOnExitWithGranularFeatureEnabledAndOptionsSelected_thenKillProcess() = runTest { + enableGranularFeature() + whenever(mockDataClearing.isAutomaticDataClearingOptionSelected()).thenReturn(true) + + testee.onExit() + + verify(mockClearAction).killProcess() + } + + @Test + fun whenOnExitWithGranularFeatureEnabledAndNoOptionsSelected_thenDoNotKillProcess() = runTest { + enableGranularFeature() + whenever(mockDataClearing.isAutomaticDataClearingOptionSelected()).thenReturn(false) + + testee.onExit() + + verify(mockClearAction, never()).killProcess() + } + + @Test + fun whenOnExitWithGranularFeatureDisabledAndClearNone_thenDoNotKillProcess() = runTest { + disableGranularFeature() + whenever(mockSettingsDataStore.automaticallyClearWhatOption).thenReturn(ClearWhatOption.CLEAR_NONE) + + testee.onExit() + + verify(mockClearAction, never()).killProcess() + } + + @Test + fun whenOnExitWithGranularFeatureDisabledAndClearTabsOnly_thenKillProcess() = runTest { + disableGranularFeature() + whenever(mockSettingsDataStore.automaticallyClearWhatOption).thenReturn(ClearWhatOption.CLEAR_TABS_ONLY) + + testee.onExit() + + verify(mockClearAction).killProcess() + } + + @Test + fun whenOnCloseWithGranularFeatureEnabledAndNoOptions_thenDoNotScheduleTimer() = runTest { + enableGranularFeature() + whenever(mockFireDataStore.getAutomaticClearOptions()).thenReturn(emptySet()) + whenever(mockFireDataStore.getAutomaticallyClearWhenOption()).thenReturn(ClearWhenOption.APP_EXIT_OR_15_MINS) + + testee.onClose() + + // Wait for coroutine to complete + coroutineTestRule.testDispatcher.scheduler.advanceUntilIdle() + + verify(mockWorkManager, never()).enqueue(any()) + } + + @Test + fun whenOnCloseWithGranularFeatureEnabledAndAppExitOnly_thenDoNotScheduleTimer() = runTest { + enableGranularFeature() + whenever(mockFireDataStore.getAutomaticClearOptions()).thenReturn(setOf(FireClearOption.DATA)) + whenever(mockFireDataStore.getAutomaticallyClearWhenOption()).thenReturn(ClearWhenOption.APP_EXIT_ONLY) + + testee.onClose() + + // Wait for coroutine to complete + coroutineTestRule.testDispatcher.scheduler.advanceUntilIdle() + + verify(mockWorkManager, never()).enqueue(any()) + } + + @Test + fun whenOnCloseWithGranularFeatureEnabledAndOptionsWithTimer_thenScheduleTimer() = runTest { + enableGranularFeature() + whenever(mockFireDataStore.getAutomaticClearOptions()).thenReturn(setOf(FireClearOption.DATA)) + whenever(mockFireDataStore.getAutomaticallyClearWhenOption()).thenReturn(ClearWhenOption.APP_EXIT_OR_15_MINS) + + testee.onClose() + + // Wait for coroutine to complete + coroutineTestRule.testDispatcher.scheduler.advanceUntilIdle() + + verify(mockWorkManager).enqueue(any()) + } + + @Test + fun whenOnCloseWithGranularFeatureDisabledAndClearNone_thenDoNotScheduleTimer() = runTest { + disableGranularFeature() + whenever(mockSettingsDataStore.automaticallyClearWhatOption).thenReturn(ClearWhatOption.CLEAR_NONE) + whenever(mockSettingsDataStore.automaticallyClearWhenOption).thenReturn(ClearWhenOption.APP_EXIT_OR_15_MINS) + + testee.onClose() + + // Wait for coroutine to complete + coroutineTestRule.testDispatcher.scheduler.advanceUntilIdle() + + verify(mockWorkManager, never()).enqueue(any()) + } + + @Test + fun whenOnCloseWithGranularFeatureDisabledAndAppExitOnly_thenDoNotScheduleTimer() = runTest { + disableGranularFeature() + whenever(mockSettingsDataStore.automaticallyClearWhatOption).thenReturn(ClearWhatOption.CLEAR_TABS_AND_DATA) + whenever(mockSettingsDataStore.automaticallyClearWhenOption).thenReturn(ClearWhenOption.APP_EXIT_ONLY) + + testee.onClose() + + // Wait for coroutine to complete + coroutineTestRule.testDispatcher.scheduler.advanceUntilIdle() + + verify(mockWorkManager, never()).enqueue(any()) + } + + @Test + fun whenOnCloseWithGranularFeatureDisabledAndOptionsWithTimer_thenScheduleTimer() = runTest { + disableGranularFeature() + whenever(mockSettingsDataStore.automaticallyClearWhatOption).thenReturn(ClearWhatOption.CLEAR_TABS_AND_DATA) + whenever(mockSettingsDataStore.automaticallyClearWhenOption).thenReturn(ClearWhenOption.APP_EXIT_OR_15_MINS) + + testee.onClose() + + // Wait for coroutine to complete + coroutineTestRule.testDispatcher.scheduler.advanceUntilIdle() + + verify(mockWorkManager).enqueue(any()) + } } 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 38e9e2ec41a3..dca22e2faaba 100644 --- a/app/src/main/java/com/duckduckgo/app/fire/AutomaticDataClearer.kt +++ b/app/src/main/java/com/duckduckgo/app/fire/AutomaticDataClearer.kt @@ -26,6 +26,7 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkManager +import com.duckduckgo.app.fire.store.FireDataStore import com.duckduckgo.app.global.ApplicationClearDataState import com.duckduckgo.app.global.ApplicationClearDataState.FINISHED import com.duckduckgo.app.global.ApplicationClearDataState.INITIALIZING @@ -74,6 +75,7 @@ class AutomaticDataClearer @Inject constructor( private val dataClearerTimeKeeper: BackgroundTimeKeeper, private val dataClearerForegroundAppRestartPixel: DataClearerForegroundAppRestartPixel, private val dispatchers: DispatcherProvider, + private val fireDataStore: FireDataStore, ) : DataClearer, BrowserLifecycleObserver, CoroutineScope { private val clearJob: Job = Job() @@ -171,13 +173,24 @@ class AutomaticDataClearer @Inject constructor( withContext(dispatchers.io()) { settingsDataStore.appBackgroundedTimestamp = timeNow - val clearWhenOption = settingsDataStore.automaticallyClearWhenOption - val clearWhatOption = settingsDataStore.automaticallyClearWhatOption + if (androidBrowserConfigFeature.moreGranularDataClearingOptions().isEnabled()) { + val clearWhenOption = fireDataStore.getAutomaticallyClearWhenOption() + val clearOptions = fireDataStore.getAutomaticClearOptions() - if (clearWhatOption == ClearWhatOption.CLEAR_NONE || clearWhenOption == ClearWhenOption.APP_EXIT_ONLY) { - logcat { "No background timer required for current configuration: $clearWhatOption / $clearWhenOption" } + if (clearOptions.isEmpty() || clearWhenOption == ClearWhenOption.APP_EXIT_ONLY) { + logcat { "No background timer required for current configuration: $clearOptions / $clearWhenOption" } + } else { + scheduleBackgroundTimerToTriggerClear(clearWhenOption.durationMilliseconds()) + } } else { - scheduleBackgroundTimerToTriggerClear(clearWhenOption.durationMilliseconds()) + val clearWhenOption = settingsDataStore.automaticallyClearWhenOption + val clearWhatOption = settingsDataStore.automaticallyClearWhatOption + + if (clearWhatOption == ClearWhatOption.CLEAR_NONE || clearWhenOption == ClearWhenOption.APP_EXIT_ONLY) { + logcat { "No background timer required for current configuration: $clearWhatOption / $clearWhenOption" } + } else { + scheduleBackgroundTimerToTriggerClear(clearWhenOption.durationMilliseconds()) + } } } } @@ -187,7 +200,7 @@ class AutomaticDataClearer @Inject constructor( runBlocking(dispatchers.io()) { // the app does not have any activity in CREATED state we kill the process val shouldKillProcess = if (androidBrowserConfigFeature.moreGranularDataClearingOptions().isEnabled()) { - dataClearing.shouldKillProcessAfterAutomaticDataClearing() + dataClearing.isAutomaticDataClearingOptionSelected() } else { settingsDataStore.automaticallyClearWhatOption != ClearWhatOption.CLEAR_NONE } diff --git a/app/src/main/java/com/duckduckgo/app/fire/AutomaticDataClearing.kt b/app/src/main/java/com/duckduckgo/app/fire/AutomaticDataClearing.kt index 61f2b0f71f52..82cc1662fed2 100644 --- a/app/src/main/java/com/duckduckgo/app/fire/AutomaticDataClearing.kt +++ b/app/src/main/java/com/duckduckgo/app/fire/AutomaticDataClearing.kt @@ -43,9 +43,9 @@ interface AutomaticDataClearing { ): Boolean /** - * Determines whether the process should be killed after automatic data-clearing. + * Checks if the user has selected any automatic data clearing option. * * @return true if process should be killed on exit, false otherwise */ - suspend fun shouldKillProcessAfterAutomaticDataClearing(): Boolean + suspend fun isAutomaticDataClearingOptionSelected(): Boolean } diff --git a/app/src/main/java/com/duckduckgo/app/fire/DataClearing.kt b/app/src/main/java/com/duckduckgo/app/fire/DataClearing.kt index ebc6db0e0b50..7c20f49cdc44 100644 --- a/app/src/main/java/com/duckduckgo/app/fire/DataClearing.kt +++ b/app/src/main/java/com/duckduckgo/app/fire/DataClearing.kt @@ -131,7 +131,7 @@ class DataClearing @Inject constructor( return enoughTimePassed } - override suspend fun shouldKillProcessAfterAutomaticDataClearing(): Boolean { + override suspend fun isAutomaticDataClearingOptionSelected(): Boolean { return fireDataStore.getAutomaticClearOptions().isNotEmpty() } diff --git a/app/src/test/java/com/duckduckgo/app/fire/DataClearingTest.kt b/app/src/test/java/com/duckduckgo/app/fire/DataClearingTest.kt index 6ca8fb304da8..51644c15d5ce 100644 --- a/app/src/test/java/com/duckduckgo/app/fire/DataClearingTest.kt +++ b/app/src/test/java/com/duckduckgo/app/fire/DataClearingTest.kt @@ -490,10 +490,10 @@ class DataClearingTest { } @Test - fun whenAutomaticClearOptionsConfigured_thenShouldKillProcessAfterAutomaticDataClearing() = runTest { + fun whenAutomaticClearOptionsConfigured_thenIsAutomaticDataClearingOptionSelected() = runTest { configureAutomaticOptions(setOf(FireClearOption.DATA)) - val result = testee.shouldKillProcessAfterAutomaticDataClearing() + val result = testee.isAutomaticDataClearingOptionSelected() assertTrue(result) } @@ -502,25 +502,25 @@ class DataClearingTest { fun whenNoAutomaticClearOptionsConfigured_thenShouldNotKillProcessOnExit() = runTest { configureAutomaticOptions(emptySet()) - val result = testee.shouldKillProcessAfterAutomaticDataClearing() + val result = testee.isAutomaticDataClearingOptionSelected() assertFalse(result) } @Test - fun whenAutomaticClearOptionsConfiguredWithTabsOnly_thenShouldKillProcessAfterAutomaticDataClearing() = runTest { + fun whenAutomaticClearOptionsConfiguredWithTabsOnly_thenIsAutomaticDataClearingOptionSelected() = runTest { configureAutomaticOptions(setOf(FireClearOption.TABS)) - val result = testee.shouldKillProcessAfterAutomaticDataClearing() + val result = testee.isAutomaticDataClearingOptionSelected() assertTrue(result) } @Test - fun whenAutomaticClearOptionsConfiguredWithMultipleOptions_thenShouldKillProcessAfterAutomaticDataClearing() = runTest { + fun whenAutomaticClearOptionsConfiguredWithMultipleOptions_thenIsAutomaticDataClearingOptionSelected() = runTest { configureAutomaticOptions(setOf(FireClearOption.TABS, FireClearOption.DATA, FireClearOption.DUCKAI_CHATS)) - val result = testee.shouldKillProcessAfterAutomaticDataClearing() + val result = testee.isAutomaticDataClearingOptionSelected() assertTrue(result) } From ca581c577af747b84171426cd7f76b3f01723d1d Mon Sep 17 00:00:00 2001 From: 0nko Date: Tue, 16 Dec 2025 11:40:27 +0100 Subject: [PATCH 11/15] Rename the interface parameter to better reflect the behavior --- .../duckduckgo/app/browser/BrowserActivity.kt | 4 +--- .../com/duckduckgo/app/fire/DataClearing.kt | 4 ++-- .../duckduckgo/app/fire/ManualDataClearing.kt | 4 ++-- .../duckduckgo/app/fire/DataClearingTest.kt | 22 +++++++++---------- 4 files changed, 16 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt index a5a394f7216b..b6815fe9e4a0 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt @@ -554,10 +554,8 @@ open class BrowserActivity : DuckDuckGoActivity() { logcat(INFO) { "Clearing everything as a result of $PERFORM_FIRE_ON_ENTRY_EXTRA flag being set" } appCoroutineScope.launch(dispatcherProvider.io()) { if (androidBrowserConfigFeature.moreGranularDataClearingOptions().isEnabled()) { - // Use new granular clearing - will automatically clear and restart - dataClearing.clearDataUsingManualFireOptions(shouldRestartProcess = true) + dataClearing.clearDataUsingManualFireOptions(shouldRestartIfRequired = true) } else { - // Use legacy clearing clearDataAction.clearTabsAndAllDataAsync(appInForeground = true, shouldFireDataClearPixel = true) clearDataAction.setAppUsedSinceLastClearFlag(false) clearDataAction.killAndRestartProcess(notifyDataCleared = false) diff --git a/app/src/main/java/com/duckduckgo/app/fire/DataClearing.kt b/app/src/main/java/com/duckduckgo/app/fire/DataClearing.kt index 7c20f49cdc44..28351f0000af 100644 --- a/app/src/main/java/com/duckduckgo/app/fire/DataClearing.kt +++ b/app/src/main/java/com/duckduckgo/app/fire/DataClearing.kt @@ -48,7 +48,7 @@ class DataClearing @Inject constructor( private val dataClearerTimeKeeper: BackgroundTimeKeeper, ) : ManualDataClearing, AutomaticDataClearing { - override suspend fun clearDataUsingManualFireOptions(shouldRestartProcess: Boolean, wasAppUsedSinceLastClear: Boolean) { + override suspend fun clearDataUsingManualFireOptions(shouldRestartIfRequired: Boolean, wasAppUsedSinceLastClear: Boolean) { val options = fireDataStore.getManualClearOptions() performGranularClear( options = options, @@ -58,7 +58,7 @@ class DataClearing @Inject constructor( clearDataAction.setAppUsedSinceLastClearFlag(wasAppUsedSinceLastClear) val wasDataCleared = options.contains(FireClearOption.DATA) || options.contains(FireClearOption.DUCKAI_CHATS) - if (shouldRestartProcess && wasDataCleared) { + if (shouldRestartIfRequired && wasDataCleared) { clearDataAction.killAndRestartProcess(notifyDataCleared = false) } } diff --git a/app/src/main/java/com/duckduckgo/app/fire/ManualDataClearing.kt b/app/src/main/java/com/duckduckgo/app/fire/ManualDataClearing.kt index cbe7aefeb9b6..497c97e5b598 100644 --- a/app/src/main/java/com/duckduckgo/app/fire/ManualDataClearing.kt +++ b/app/src/main/java/com/duckduckgo/app/fire/ManualDataClearing.kt @@ -22,8 +22,8 @@ package com.duckduckgo.app.fire interface ManualDataClearing { /** * Clears data when user requests data clearing using the FireDialog. - * @param shouldRestartProcess whether to restart the app process after clearing data + * @param shouldRestartIfRequired whether to restart the app process after clearing data, if required (when data or chats cleared) * @param wasAppUsedSinceLastClear whether the app was used since the last data clear */ - suspend fun clearDataUsingManualFireOptions(shouldRestartProcess: Boolean = false, wasAppUsedSinceLastClear: Boolean = false) + suspend fun clearDataUsingManualFireOptions(shouldRestartIfRequired: Boolean = false, wasAppUsedSinceLastClear: Boolean = false) } diff --git a/app/src/test/java/com/duckduckgo/app/fire/DataClearingTest.kt b/app/src/test/java/com/duckduckgo/app/fire/DataClearingTest.kt index 51644c15d5ce..f6298974d85d 100644 --- a/app/src/test/java/com/duckduckgo/app/fire/DataClearingTest.kt +++ b/app/src/test/java/com/duckduckgo/app/fire/DataClearingTest.kt @@ -70,7 +70,7 @@ class DataClearingTest { fun whenManualClearWithTabsOnly_thenOnlyClearTabs() = runTest { configureManualOptions(setOf(FireClearOption.TABS)) - testee.clearDataUsingManualFireOptions(shouldRestartProcess = false, wasAppUsedSinceLastClear = true) + testee.clearDataUsingManualFireOptions(shouldRestartIfRequired = false, wasAppUsedSinceLastClear = true) verify(mockClearDataAction).clearTabsOnly() verify(mockClearDataAction, never()).clearBrowserDataOnly(any()) @@ -83,7 +83,7 @@ class DataClearingTest { fun whenManualClearWithDataOnly_thenClearDataAndSetFlag() = runTest { configureManualOptions(setOf(FireClearOption.DATA)) - testee.clearDataUsingManualFireOptions(shouldRestartProcess = false, wasAppUsedSinceLastClear = true) + testee.clearDataUsingManualFireOptions(shouldRestartIfRequired = false, wasAppUsedSinceLastClear = true) verify(mockClearDataAction, never()).clearTabsOnly() verify(mockClearDataAction).clearBrowserDataOnly(true) @@ -96,7 +96,7 @@ class DataClearingTest { fun whenManualClearWithTabsAndData_thenClearBothAndSetFlag() = runTest { configureManualOptions(setOf(FireClearOption.TABS, FireClearOption.DATA)) - testee.clearDataUsingManualFireOptions(shouldRestartProcess = false, wasAppUsedSinceLastClear = true) + testee.clearDataUsingManualFireOptions(shouldRestartIfRequired = false, wasAppUsedSinceLastClear = true) verify(mockClearDataAction).clearTabsOnly() verify(mockClearDataAction).clearBrowserDataOnly(true) @@ -109,7 +109,7 @@ class DataClearingTest { fun whenManualClearWithDuckAiChatsOnly_thenClearChatsAndSetFlag() = runTest { configureManualOptions(setOf(FireClearOption.DUCKAI_CHATS)) - testee.clearDataUsingManualFireOptions(shouldRestartProcess = false, wasAppUsedSinceLastClear = true) + testee.clearDataUsingManualFireOptions(shouldRestartIfRequired = false, wasAppUsedSinceLastClear = true) verify(mockClearDataAction, never()).clearTabsOnly() verify(mockClearDataAction, never()).clearBrowserDataOnly(any()) @@ -122,7 +122,7 @@ class DataClearingTest { fun whenManualClearWithAllOptions_thenClearAllAndSetFlag() = runTest { configureManualOptions(setOf(FireClearOption.TABS, FireClearOption.DATA, FireClearOption.DUCKAI_CHATS)) - testee.clearDataUsingManualFireOptions(shouldRestartProcess = false, wasAppUsedSinceLastClear = true) + testee.clearDataUsingManualFireOptions(shouldRestartIfRequired = false, wasAppUsedSinceLastClear = true) verify(mockClearDataAction).clearTabsOnly() verify(mockClearDataAction).clearBrowserDataOnly(true) @@ -135,7 +135,7 @@ class DataClearingTest { fun whenManualClearWithNoOptionsSelected_thenOnlySetFlag() = runTest { configureManualOptions(emptySet()) - testee.clearDataUsingManualFireOptions(shouldRestartProcess = false, wasAppUsedSinceLastClear = true) + testee.clearDataUsingManualFireOptions(shouldRestartIfRequired = false, wasAppUsedSinceLastClear = true) verify(mockClearDataAction, never()).clearTabsOnly() verify(mockClearDataAction, never()).clearBrowserDataOnly(any()) @@ -148,7 +148,7 @@ class DataClearingTest { fun whenManualClearWithNoOptionsSelectedAndShouldRestartProcess_thenOnlySetFlagAndDoNotRestart() = runTest { configureManualOptions(emptySet()) - testee.clearDataUsingManualFireOptions(shouldRestartProcess = true, wasAppUsedSinceLastClear = true) + testee.clearDataUsingManualFireOptions(shouldRestartIfRequired = true, wasAppUsedSinceLastClear = true) verify(mockClearDataAction, never()).clearTabsOnly() verify(mockClearDataAction, never()).clearBrowserDataOnly(any()) @@ -161,7 +161,7 @@ class DataClearingTest { fun whenManualClearWithTabsOnlyAndShouldRestartProcess_thenDoNotRestart() = runTest { configureManualOptions(setOf(FireClearOption.TABS)) - testee.clearDataUsingManualFireOptions(shouldRestartProcess = true, wasAppUsedSinceLastClear = true) + testee.clearDataUsingManualFireOptions(shouldRestartIfRequired = true, wasAppUsedSinceLastClear = true) verify(mockClearDataAction).clearTabsOnly() verify(mockClearDataAction).setAppUsedSinceLastClearFlag(true) @@ -172,7 +172,7 @@ class DataClearingTest { fun whenManualClearWithDataAndShouldRestartProcess_thenRestartProcess() = runTest { configureManualOptions(setOf(FireClearOption.DATA)) - testee.clearDataUsingManualFireOptions(shouldRestartProcess = true, wasAppUsedSinceLastClear = false) + testee.clearDataUsingManualFireOptions(shouldRestartIfRequired = true, wasAppUsedSinceLastClear = false) verify(mockClearDataAction).clearBrowserDataOnly(true) verify(mockClearDataAction).setAppUsedSinceLastClearFlag(false) @@ -183,7 +183,7 @@ class DataClearingTest { fun whenManualClearWithTabsAndDataAndShouldRestartProcess_thenRestartProcess() = runTest { configureManualOptions(setOf(FireClearOption.TABS, FireClearOption.DATA)) - testee.clearDataUsingManualFireOptions(shouldRestartProcess = true, wasAppUsedSinceLastClear = false) + testee.clearDataUsingManualFireOptions(shouldRestartIfRequired = true, wasAppUsedSinceLastClear = false) verify(mockClearDataAction).clearTabsOnly() verify(mockClearDataAction).clearBrowserDataOnly(true) @@ -195,7 +195,7 @@ class DataClearingTest { fun whenManualClearWithDuckAiChatsAndShouldRestartProcess_thenRestartProcess() = runTest { configureManualOptions(setOf(FireClearOption.DUCKAI_CHATS)) - testee.clearDataUsingManualFireOptions(shouldRestartProcess = true, wasAppUsedSinceLastClear = false) + testee.clearDataUsingManualFireOptions(shouldRestartIfRequired = true, wasAppUsedSinceLastClear = false) verify(mockClearDataAction).clearDuckAiChatsOnly() verify(mockClearDataAction).setAppUsedSinceLastClearFlag(false) From 10861956531c056cf7af86c85570b693f8b7be03 Mon Sep 17 00:00:00 2001 From: 0nko Date: Tue, 16 Dec 2025 11:40:49 +0100 Subject: [PATCH 12/15] Fix the clear data notification handling for the FF enabled case --- .../model/ClearDataNotificationTest.kt | 56 ++++++++++++++++++- .../duckduckgo/app/di/NotificationModule.kt | 15 ++++- .../model/ClearDataNotification.kt | 24 ++++++-- 3 files changed, 88 insertions(+), 7 deletions(-) diff --git a/app/src/androidTest/java/com/duckduckgo/app/notification/model/ClearDataNotificationTest.kt b/app/src/androidTest/java/com/duckduckgo/app/notification/model/ClearDataNotificationTest.kt index e3b70fa9f32e..c40e6f46c179 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/notification/model/ClearDataNotificationTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/notification/model/ClearDataNotificationTest.kt @@ -19,13 +19,20 @@ package com.duckduckgo.app.notification.model import androidx.test.platform.app.InstrumentationRegistry +import com.duckduckgo.app.fire.AutomaticDataClearing import com.duckduckgo.app.notification.db.NotificationDao +import com.duckduckgo.app.pixels.remoteconfig.AndroidBrowserConfigFeature import com.duckduckgo.app.settings.clear.ClearWhatOption import com.duckduckgo.app.settings.db.SettingsDataStore +import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory +import com.duckduckgo.feature.toggles.api.Toggle import kotlinx.coroutines.test.runTest import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Before +import org.junit.Rule import org.junit.Test import org.mockito.kotlin.any import org.mockito.kotlin.mock @@ -33,19 +40,33 @@ import org.mockito.kotlin.whenever class ClearDataNotificationTest { + @get:Rule + var coroutineRule = CoroutineTestRule() + private val context = InstrumentationRegistry.getInstrumentation().targetContext private val notificationsDao: NotificationDao = mock() private val settingsDataStore: SettingsDataStore = mock() + private val automaticDataClearing: AutomaticDataClearing = mock() + private val androidBrowserConfigFeature = FakeFeatureToggleFactory.create(AndroidBrowserConfigFeature::class.java) + private val dispatcherProvider: DispatcherProvider = coroutineRule.testDispatcherProvider private lateinit var testee: ClearDataNotification @Before fun before() { - testee = ClearDataNotification(context, notificationsDao, settingsDataStore) + testee = ClearDataNotification( + context, + notificationsDao, + settingsDataStore, + automaticDataClearing, + androidBrowserConfigFeature, + dispatcherProvider, + ) } @Test fun whenNotificationNotSeenAndOptionNotSetThenCanShowIsTrue() = runTest { + givenFeatureFlagDisabled() whenever(notificationsDao.exists(any())).thenReturn(false) whenever(settingsDataStore.automaticallyClearWhatOption).thenReturn(ClearWhatOption.CLEAR_NONE) assertTrue(testee.canShow()) @@ -53,6 +74,7 @@ class ClearDataNotificationTest { @Test fun whenNotificationNotSeenButOptionAlreadySetThenCanShowIsFalse() = runTest { + givenFeatureFlagDisabled() whenever(notificationsDao.exists(any())).thenReturn(false) whenever(settingsDataStore.automaticallyClearWhatOption).thenReturn(ClearWhatOption.CLEAR_TABS_ONLY) assertFalse(testee.canShow()) @@ -60,8 +82,40 @@ class ClearDataNotificationTest { @Test fun whenNotificationAlreadySeenAndOptionNotSetThenCanShowIsFalse() = runTest { + givenFeatureFlagDisabled() whenever(notificationsDao.exists(any())).thenReturn(true) whenever(settingsDataStore.automaticallyClearWhatOption).thenReturn(ClearWhatOption.CLEAR_NONE) assertFalse(testee.canShow()) } + + @Test + fun whenFeatureFlagEnabledAndNotificationNotSeenAndNoAutomaticClearOptionThenCanShowIsTrue() = runTest { + givenFeatureFlagEnabled() + whenever(notificationsDao.exists(any())).thenReturn(false) + whenever(automaticDataClearing.isAutomaticDataClearingOptionSelected()).thenReturn(false) + assertTrue(testee.canShow()) + } + + @Test + fun whenFeatureFlagEnabledAndNotificationNotSeenButAutomaticClearOptionSetThenCanShowIsFalse() = runTest { + givenFeatureFlagEnabled() + whenever(notificationsDao.exists(any())).thenReturn(false) + whenever(automaticDataClearing.isAutomaticDataClearingOptionSelected()).thenReturn(true) + assertFalse(testee.canShow()) + } + + @Test + fun whenFeatureFlagEnabledAndNotificationAlreadySeenThenCanShowIsFalse() = runTest { + givenFeatureFlagEnabled() + whenever(notificationsDao.exists(any())).thenReturn(true) + assertFalse(testee.canShow()) + } + + private fun givenFeatureFlagEnabled() { + androidBrowserConfigFeature.moreGranularDataClearingOptions().setRawStoredState(Toggle.State(true)) + } + + private fun givenFeatureFlagDisabled() { + androidBrowserConfigFeature.moreGranularDataClearingOptions().setRawStoredState(Toggle.State(false)) + } } diff --git a/app/src/main/java/com/duckduckgo/app/di/NotificationModule.kt b/app/src/main/java/com/duckduckgo/app/di/NotificationModule.kt index 3208a75f1ffe..d375afa78cef 100644 --- a/app/src/main/java/com/duckduckgo/app/di/NotificationModule.kt +++ b/app/src/main/java/com/duckduckgo/app/di/NotificationModule.kt @@ -20,6 +20,7 @@ import android.content.Context import androidx.core.app.NotificationManagerCompat import androidx.localbroadcastmanager.content.LocalBroadcastManager import androidx.work.WorkManager +import com.duckduckgo.app.fire.AutomaticDataClearing import com.duckduckgo.app.notification.* import com.duckduckgo.app.notification.AndroidNotificationScheduler import com.duckduckgo.app.notification.NotificationScheduler @@ -27,8 +28,10 @@ import com.duckduckgo.app.notification.db.NotificationDao import com.duckduckgo.app.notification.model.ClearDataNotification import com.duckduckgo.app.notification.model.PrivacyProtectionNotification import com.duckduckgo.app.notification.model.SchedulableNotificationPlugin +import com.duckduckgo.app.pixels.remoteconfig.AndroidBrowserConfigFeature import com.duckduckgo.app.privacy.db.PrivacyProtectionCountDao import com.duckduckgo.app.settings.db.SettingsDataStore +import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.common.utils.plugins.PluginPoint import com.duckduckgo.di.scopes.AppScope import dagger.Module @@ -55,8 +58,18 @@ object NotificationModule { context: Context, notificationDao: NotificationDao, settingsDataStore: SettingsDataStore, + automaticDataClearing: AutomaticDataClearing, + androidBrowserConfigFeature: AndroidBrowserConfigFeature, + dispatcherProvider: DispatcherProvider, ): ClearDataNotification { - return ClearDataNotification(context, notificationDao, settingsDataStore) + return ClearDataNotification( + context, + notificationDao, + settingsDataStore, + automaticDataClearing, + androidBrowserConfigFeature, + dispatcherProvider, + ) } @Provides diff --git a/app/src/main/java/com/duckduckgo/app/notification/model/ClearDataNotification.kt b/app/src/main/java/com/duckduckgo/app/notification/model/ClearDataNotification.kt index 604f8d1623b3..e13b52bf7b29 100644 --- a/app/src/main/java/com/duckduckgo/app/notification/model/ClearDataNotification.kt +++ b/app/src/main/java/com/duckduckgo/app/notification/model/ClearDataNotification.kt @@ -21,11 +21,13 @@ import android.content.Context import android.os.Bundle import com.duckduckgo.app.browser.R import com.duckduckgo.app.di.AppCoroutineScope +import com.duckduckgo.app.fire.AutomaticDataClearing import com.duckduckgo.app.firebutton.FireButtonActivity import com.duckduckgo.app.notification.NotificationRegistrar import com.duckduckgo.app.notification.TaskStackBuilderFactory import com.duckduckgo.app.notification.db.NotificationDao import com.duckduckgo.app.pixels.AppPixelName +import com.duckduckgo.app.pixels.remoteconfig.AndroidBrowserConfigFeature import com.duckduckgo.app.settings.clear.ClearWhatOption import com.duckduckgo.app.settings.db.SettingsDataStore import com.duckduckgo.app.statistics.pixels.Pixel @@ -36,6 +38,7 @@ import com.squareup.anvil.annotations.ContributesMultibinding import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.async import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext import logcat.LogPriority.VERBOSE import logcat.logcat import javax.inject.Inject @@ -44,6 +47,9 @@ class ClearDataNotification( private val context: Context, private val notificationDao: NotificationDao, private val settingsDataStore: SettingsDataStore, + private val automaticDataClearing: AutomaticDataClearing, + private val androidBrowserConfigFeature: AndroidBrowserConfigFeature, + private val dispatcherProvider: DispatcherProvider, ) : SchedulableNotification { override val id = "com.duckduckgo.privacytips.autoclear" @@ -54,12 +60,20 @@ class ClearDataNotification( return false } - if (settingsDataStore.automaticallyClearWhatOption != ClearWhatOption.CLEAR_NONE) { - logcat(VERBOSE) { "No need for notification, user already has clear option set" } - return false + return withContext(dispatcherProvider.io()) { + if (androidBrowserConfigFeature.moreGranularDataClearingOptions().isEnabled()) { + if (automaticDataClearing.isAutomaticDataClearingOptionSelected()) { + logcat(VERBOSE) { "No need for notification, user already has automatic data clearing option set" } + return@withContext false + } + } else { + if (settingsDataStore.automaticallyClearWhatOption != ClearWhatOption.CLEAR_NONE) { + logcat(VERBOSE) { "No need for notification, user already has clear option set" } + return@withContext false + } + } + return@withContext true } - - return true } override suspend fun buildSpecification(): NotificationSpec { From 5004fd45823977202fb9c2cdcb33383da0eb365b Mon Sep 17 00:00:00 2001 From: 0nko Date: Tue, 16 Dec 2025 12:22:04 +0100 Subject: [PATCH 13/15] Fix the unit tests --- .../app/fire/AutomaticDataClearerTest.kt | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) 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 0c53968e4624..85e43e862671 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/fire/AutomaticDataClearerTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/fire/AutomaticDataClearerTest.kt @@ -21,6 +21,7 @@ package com.duckduckgo.app.fire import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.test.annotation.UiThreadTest import androidx.test.platform.app.InstrumentationRegistry +import androidx.work.OneTimeWorkRequest import androidx.work.WorkManager import androidx.work.WorkRequest import com.duckduckgo.app.fire.store.FireDataStore @@ -40,6 +41,7 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.mockito.kotlin.any +import org.mockito.kotlin.argThat import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify @@ -830,7 +832,7 @@ class AutomaticDataClearerTest { // Wait for coroutine to complete coroutineTestRule.testDispatcher.scheduler.advanceUntilIdle() - verify(mockWorkManager, never()).enqueue(any()) + verify(mockWorkManager, never()).enqueue(argThat> { size == 1 && first() is OneTimeWorkRequest }) } @Test @@ -844,7 +846,7 @@ class AutomaticDataClearerTest { // Wait for coroutine to complete coroutineTestRule.testDispatcher.scheduler.advanceUntilIdle() - verify(mockWorkManager, never()).enqueue(any()) + verify(mockWorkManager, never()).enqueue(argThat> { size == 1 && first() is OneTimeWorkRequest }) } @Test @@ -858,7 +860,7 @@ class AutomaticDataClearerTest { // Wait for coroutine to complete coroutineTestRule.testDispatcher.scheduler.advanceUntilIdle() - verify(mockWorkManager).enqueue(any()) + verify(mockWorkManager).enqueue(argThat> { size == 1 && first() is OneTimeWorkRequest }) } @Test @@ -872,7 +874,7 @@ class AutomaticDataClearerTest { // Wait for coroutine to complete coroutineTestRule.testDispatcher.scheduler.advanceUntilIdle() - verify(mockWorkManager, never()).enqueue(any()) + verify(mockWorkManager, never()).enqueue(argThat> { size == 1 && first() is OneTimeWorkRequest }) } @Test @@ -886,7 +888,7 @@ class AutomaticDataClearerTest { // Wait for coroutine to complete coroutineTestRule.testDispatcher.scheduler.advanceUntilIdle() - verify(mockWorkManager, never()).enqueue(any()) + verify(mockWorkManager, never()).enqueue(argThat> { size == 1 && first() is OneTimeWorkRequest }) } @Test @@ -900,6 +902,6 @@ class AutomaticDataClearerTest { // Wait for coroutine to complete coroutineTestRule.testDispatcher.scheduler.advanceUntilIdle() - verify(mockWorkManager).enqueue(any()) + verify(mockWorkManager).enqueue(argThat> { size == 1 && first() is OneTimeWorkRequest }) } } From 68929ed3df90f1455a8c49b4038b2a0f8da215ca Mon Sep 17 00:00:00 2001 From: 0nko Date: Tue, 16 Dec 2025 12:37:10 +0100 Subject: [PATCH 14/15] Run the onExit asynchronously --- .../main/java/com/duckduckgo/app/fire/AutomaticDataClearer.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 dca22e2faaba..57428a673eda 100644 --- a/app/src/main/java/com/duckduckgo/app/fire/AutomaticDataClearer.kt +++ b/app/src/main/java/com/duckduckgo/app/fire/AutomaticDataClearer.kt @@ -197,7 +197,7 @@ class AutomaticDataClearer @Inject constructor( } override fun onExit() { - runBlocking(dispatchers.io()) { + launch (dispatchers.io()) { // the app does not have any activity in CREATED state we kill the process val shouldKillProcess = if (androidBrowserConfigFeature.moreGranularDataClearingOptions().isEnabled()) { dataClearing.isAutomaticDataClearingOptionSelected() From ea6128cd3bbc09ed9dddb4d9ac830f8fa56527f0 Mon Sep 17 00:00:00 2001 From: 0nko Date: Wed, 17 Dec 2025 15:40:40 +0100 Subject: [PATCH 15/15] Fix ktlint issues --- .../main/java/com/duckduckgo/app/fire/AutomaticDataClearer.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 57428a673eda..5efec33a0872 100644 --- a/app/src/main/java/com/duckduckgo/app/fire/AutomaticDataClearer.kt +++ b/app/src/main/java/com/duckduckgo/app/fire/AutomaticDataClearer.kt @@ -44,7 +44,6 @@ import dagger.SingleInstanceIn import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import logcat.LogPriority.WARN import logcat.logcat @@ -197,7 +196,7 @@ class AutomaticDataClearer @Inject constructor( } override fun onExit() { - launch (dispatchers.io()) { + launch(dispatchers.io()) { // the app does not have any activity in CREATED state we kill the process val shouldKillProcess = if (androidBrowserConfigFeature.moreGranularDataClearingOptions().isEnabled()) { dataClearing.isAutomaticDataClearingOptionSelected()