diff --git a/android-design-system/design-system/src/main/res/drawable/background_large_rounded_surface.xml b/android-design-system/design-system/src/main/res/drawable/background_large_rounded_surface.xml
new file mode 100644
index 000000000000..de023a47ff42
--- /dev/null
+++ b/android-design-system/design-system/src/main/res/drawable/background_large_rounded_surface.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/android-design-system/design-system/src/main/res/drawable/img_fire_96.xml b/android-design-system/design-system/src/main/res/drawable/img_fire_96.xml
new file mode 100644
index 000000000000..f6ee848e8791
--- /dev/null
+++ b/android-design-system/design-system/src/main/res/drawable/img_fire_96.xml
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 b6815fe9e4a0..f65fb37ddece 100644
--- a/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt
+++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt
@@ -81,6 +81,7 @@ import com.duckduckgo.app.global.intentText
import com.duckduckgo.app.global.rating.PromptCount
import com.duckduckgo.app.global.sanitize
import com.duckduckgo.app.global.view.ClearDataAction
+import com.duckduckgo.app.global.view.FireDialog
import com.duckduckgo.app.global.view.FireDialogProvider
import com.duckduckgo.app.global.view.renderIfChanged
import com.duckduckgo.app.onboarding.ui.page.DefaultBrowserPage
@@ -346,6 +347,7 @@ open class BrowserActivity : DuckDuckGoActivity() {
// LiveData observers are restarted on each showWebContent() call; we want to subscribe to
// flows only once, so a separate initialization is necessary
configureFlowCollectors()
+ setupFireDialogListener()
viewModel.viewState
.flowWithLifecycle(lifecycle, Lifecycle.State.RESUMED)
@@ -399,6 +401,24 @@ open class BrowserActivity : DuckDuckGoActivity() {
}
}
+ private fun setupFireDialogListener() {
+ supportFragmentManager.setFragmentResultListener(FireDialog.REQUEST_KEY, this) { _, bundle ->
+ when (bundle.getString(FireDialog.RESULT_KEY_EVENT)) {
+ FireDialog.EVENT_ON_SHOW -> {
+ currentTab?.onFireDialogVisibilityChanged(isVisible = true)
+ }
+ FireDialog.EVENT_ON_CANCEL -> {
+ pixel.fire(FIRE_DIALOG_CANCEL)
+ currentTab?.onFireDialogVisibilityChanged(isVisible = false)
+ }
+ FireDialog.EVENT_ON_CLEAR_STARTED -> {
+ isDataClearingInProgress = true
+ removeObservers()
+ }
+ }
+ }
+ }
+
override fun onStart() {
super.onStart()
duckAiAnimDelayJob =
@@ -801,17 +821,10 @@ open class BrowserActivity : DuckDuckGoActivity() {
val params = mapOf(PixelParameter.FROM_FOCUSED_NTP to launchedFromFocusedNtp.toString())
pixel.fire(AppPixelName.FORGET_ALL_PRESSED_BROWSING, params)
- val dialog = fireDialogProvider.createFireDialog(context = this)
- dialog.setOnShowListener { currentTab?.onFireDialogVisibilityChanged(isVisible = true) }
- dialog.setOnCancelListener {
- pixel.fire(FIRE_DIALOG_CANCEL)
- currentTab?.onFireDialogVisibilityChanged(isVisible = false)
- }
- dialog.clearStarted = {
- isDataClearingInProgress = true
- removeObservers()
+ lifecycleScope.launch {
+ val dialog = fireDialogProvider.createFireDialog()
+ dialog.show(supportFragmentManager)
}
- dialog.show()
}
fun launchSettings() {
diff --git a/app/src/main/java/com/duckduckgo/app/browser/weblocalstorage/WebLocalStorageManager.kt b/app/src/main/java/com/duckduckgo/app/browser/weblocalstorage/WebLocalStorageManager.kt
index 10cd57c91a9e..af8f4ab6753a 100644
--- a/app/src/main/java/com/duckduckgo/app/browser/weblocalstorage/WebLocalStorageManager.kt
+++ b/app/src/main/java/com/duckduckgo/app/browser/weblocalstorage/WebLocalStorageManager.kt
@@ -71,39 +71,9 @@ class DuckDuckGoWebLocalStorageManager @Inject constructor(
private var matchingRegex = emptyList()
override suspend fun clearWebLocalStorage() = withContext(dispatcherProvider.io()) {
- val settings = androidBrowserConfigFeature.webLocalStorage().getSettings()
- val webLocalStorageSettings = webLocalStorageSettingsJsonParser.parseJson(settings)
-
- val fireproofedDomains = fireproofWebsiteRepository.fireproofWebsitesSync().map { it.domain }
-
- domains = webLocalStorageSettings.domains.list + fireproofedDomains
- keysToDelete = webLocalStorageSettings.keysToDelete.list
- matchingRegex = webLocalStorageSettings.matchingRegex.list
-
- logcat { "WebLocalStorageManager: Allowed domains: $domains" }
- logcat { "WebLocalStorageManager: Keys to delete: $keysToDelete" }
- logcat { "WebLocalStorageManager: Matching regex: $matchingRegex" }
-
- val db = databaseProvider.get()
- db.iterator().use { iterator ->
- iterator.seekToFirst()
-
- while (iterator.hasNext()) {
- val entry = iterator.next()
- val key = String(entry.key, StandardCharsets.UTF_8)
-
- val domainForMatchingAllowedKey = getDomainForMatchingAllowedKey(key)
- if (domainForMatchingAllowedKey == null) {
- db.delete(entry.key)
- logcat { "WebLocalStorageManager: Deleted key: $key" }
- } else if (settingsDataStore.clearDuckAiData && DUCKDUCKGO_DOMAINS.contains(domainForMatchingAllowedKey)) {
- if (keysToDelete.any { key.endsWith(it) }) {
- db.delete(entry.key)
- logcat { "WebLocalStorageManager: Deleted key: $key" }
- }
- }
- }
- }
+ val shouldClearBrowserData = true // As per legacy behavior, we always clear browser data
+ val shouldClearDuckAiData = settingsDataStore.clearDuckAiData
+ clearWebLocalStorage(shouldClearBrowserData, shouldClearDuckAiData)
}
override suspend fun clearWebLocalStorage(
diff --git a/app/src/main/java/com/duckduckgo/app/firebutton/FireButtonActivity.kt b/app/src/main/java/com/duckduckgo/app/firebutton/FireButtonActivity.kt
index f9dcad299d4d..62828d591c04 100644
--- a/app/src/main/java/com/duckduckgo/app/firebutton/FireButtonActivity.kt
+++ b/app/src/main/java/com/duckduckgo/app/firebutton/FireButtonActivity.kt
@@ -49,6 +49,7 @@ import com.duckduckgo.common.ui.viewbinding.viewBinding
import com.duckduckgo.di.scopes.ActivityScope
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.launch
import logcat.logcat
import javax.inject.Inject
@@ -288,8 +289,10 @@ class FireButtonActivity : DuckDuckGoActivity() {
}
private fun launchFireDialog() {
- val dialog = fireDialogProvider.createFireDialog(context = this)
- dialog.show()
+ lifecycleScope.launch {
+ val dialog = fireDialogProvider.createFireDialog()
+ dialog.show(supportFragmentManager)
+ }
}
companion object {
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 54f4adc548f1..bceb66b53bfa 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
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2018 DuckDuckGo
+ * 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.
@@ -16,248 +16,27 @@
package com.duckduckgo.app.global.view
-import android.animation.Animator
-import android.animation.ValueAnimator
-import android.annotation.SuppressLint
-import android.content.Context
-import android.os.Build
-import android.os.Bundle
-import android.provider.Settings
-import android.provider.Settings.Global.ANIMATOR_DURATION_SCALE
-import android.view.LayoutInflater
-import android.view.View
-import android.view.WindowManager
-import androidx.core.view.ViewCompat
-import androidx.core.view.WindowInsetsCompat.Type
-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.ManualDataClearing
-import com.duckduckgo.app.firebutton.FireButtonStore
-import com.duckduckgo.app.global.events.db.UserEventKey
-import com.duckduckgo.app.global.events.db.UserEventsStore
-import com.duckduckgo.app.global.view.FireDialog.FireDialogClearAllEvent.AnimationFinished
-import com.duckduckgo.app.global.view.FireDialog.FireDialogClearAllEvent.ClearAllDataFinished
-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.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
-import com.duckduckgo.app.statistics.pixels.Pixel.PixelParameter.FIRE_ANIMATION
-import com.duckduckgo.appbuildconfig.api.AppBuildConfig
-import com.duckduckgo.common.ui.view.gone
-import com.duckduckgo.common.ui.view.setAndPropagateUpFitsSystemWindows
-import com.duckduckgo.common.ui.view.show
-import com.duckduckgo.common.utils.DispatcherProvider
-import com.google.android.material.bottomsheet.BottomSheetBehavior
-import com.google.android.material.bottomsheet.BottomSheetDialog
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.launch
-import java.time.Instant
-import java.time.ZoneOffset
-import java.time.format.DateTimeFormatter
-import com.duckduckgo.mobile.android.R as CommonR
-import com.google.android.material.R as MaterialR
+import androidx.fragment.app.FragmentManager
-private const val ANIMATION_MAX_SPEED = 1.4f
-private const val ANIMATION_SPEED_INCREMENT = 0.15f
-
-@SuppressLint("NoBottomSheetDialog")
-class FireDialog(
- context: Context,
- private val clearDataAction: ClearDataAction,
- private val dataClearing: ManualDataClearing,
- private val androidBrowserConfigFeature: AndroidBrowserConfigFeature,
- private val pixel: Pixel,
- private val settingsDataStore: SettingsDataStore,
- private val userEventsStore: UserEventsStore,
- private val appCoroutineScope: CoroutineScope,
- private val dispatcherProvider: DispatcherProvider,
- private val fireButtonStore: FireButtonStore,
- private val appBuildConfig: AppBuildConfig,
-) : BottomSheetDialog(context, CommonR.style.Widget_DuckDuckGo_FireDialog) {
-
- private lateinit var binding: SheetFireClearDataBinding
-
- var clearStarted: (() -> Unit) = {}
-
- private val accelerateAnimatorUpdateListener = object : ValueAnimator.AnimatorUpdateListener {
- override fun onAnimationUpdate(animation: ValueAnimator) {
- binding.fireAnimationView.speed += ANIMATION_SPEED_INCREMENT
- if (binding.fireAnimationView.speed > ANIMATION_MAX_SPEED) {
- binding.fireAnimationView.removeUpdateListener(this)
- }
- }
- }
- private var canRestart = !animationEnabled()
- private var onClearDataOptionsDismissed: () -> Unit = {}
-
- init {
- val inflater = LayoutInflater.from(context)
- binding = SheetFireClearDataBinding.inflate(inflater)
- setContentView(binding.root)
- }
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
-
- binding.clearAllOption.setOnClickListener {
- onClearOptionClicked()
- }
- binding.cancelOption.setOnClickListener {
- cancel()
- }
-
- if (settingsDataStore.clearDuckAiData) {
- binding.clearAllOption.setPrimaryText(context.getString(com.duckduckgo.app.browser.R.string.fireClearAllPlusDuckChats))
- }
-
- if (appBuildConfig.sdkInt == Build.VERSION_CODES.O) {
- window?.navigationBarColor = context.resources.getColor(CommonR.color.translucentDark, null)
- } else if (appBuildConfig.sdkInt > Build.VERSION_CODES.O && appBuildConfig.sdkInt < Build.VERSION_CODES.R) {
- window?.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS)
- }
-
- removeTopPadding()
- addBottomPaddingToButtons()
-
- if (animationEnabled()) {
- configureFireAnimationView()
- }
- behavior.state = BottomSheetBehavior.STATE_EXPANDED
- }
-
- private fun removeTopPadding() {
- findViewById(MaterialR.id.design_bottom_sheet)?.apply {
- ViewCompat.setOnApplyWindowInsetsListener(this) { view, insets ->
- view.updatePadding(top = 0)
- insets
- }
- }
- }
-
- private fun addBottomPaddingToButtons() {
- binding.fireDialogRootView.apply {
- ViewCompat.setOnApplyWindowInsetsListener(this) { view, insets ->
- view.updatePadding(bottom = insets.getInsets(Type.systemBars()).bottom)
- insets
- }
- }
- }
-
- private fun configureFireAnimationView() {
- binding.fireAnimationView.setAnimation(settingsDataStore.selectedFireAnimation.resId)
- /**
- * BottomSheetDialog wraps provided Layout into a CoordinatorLayout.
- * We need to set FitsSystemWindows false programmatically to all parents in order to render layout and animation full screen
- */
- binding.fireAnimationView.setAndPropagateUpFitsSystemWindows(false)
- binding.fireAnimationView.setRenderMode(RenderMode.SOFTWARE)
- binding.fireAnimationView.enableMergePathsForKitKatAndAbove(true)
- }
-
- private fun onClearOptionClicked() {
- trySendDailyClearOptionClicked()
- pixel.enqueueFire(FIRE_DIALOG_CLEAR_PRESSED)
- pixel.enqueueFire(PRODUCT_TELEMETRY_SURFACE_DATA_CLEARING)
- pixel.enqueueFire(
- pixel = FIRE_DIALOG_ANIMATION,
- parameters = mapOf(FIRE_ANIMATION to settingsDataStore.selectedFireAnimation.getPixelValue()),
- )
- hideClearDataOptions()
- if (animationEnabled()) {
- playAnimation()
- }
- clearStarted()
-
- appCoroutineScope.launch(dispatcherProvider.io()) {
- fireButtonStore.incrementFireButtonUseCount()
- userEventsStore.registerUserEvent(UserEventKey.FIRE_BUTTON_EXECUTED)
-
- if (androidBrowserConfigFeature.moreGranularDataClearingOptions().isEnabled()) {
- dataClearing.clearDataUsingManualFireOptions()
- } else {
- clearDataAction.clearTabsAndAllDataAsync(appInForeground = true, shouldFireDataClearPixel = true)
- clearDataAction.setAppUsedSinceLastClearFlag(false)
- }
- onFireDialogClearAllEvent(ClearAllDataFinished)
- }
- }
-
- private fun animationEnabled() = settingsDataStore.fireAnimationEnabled && animatorDurationEnabled()
-
- private fun animatorDurationEnabled(): Boolean {
- val animatorScale = Settings.Global.getFloat(context.contentResolver, ANIMATOR_DURATION_SCALE, 1.0f)
- return animatorScale != 0.0f
- }
-
- private fun playAnimation() {
- window?.apply {
- WindowInsetsControllerCompat(this, binding.root).apply {
- isAppearanceLightStatusBars = false
- isAppearanceLightNavigationBars = false
- }
- }
- setCancelable(false)
- setCanceledOnTouchOutside(false)
- binding.fireAnimationView.show()
- binding.fireAnimationView.playAnimation()
- binding.fireAnimationView.addAnimatorListener(
- object : Animator.AnimatorListener {
- override fun onAnimationRepeat(animation: Animator) {}
- override fun onAnimationCancel(animation: Animator) {}
- override fun onAnimationStart(animation: Animator) {}
- override fun onAnimationEnd(animation: Animator) {
- onFireDialogClearAllEvent(AnimationFinished)
- }
- },
- )
- }
-
- private fun hideClearDataOptions() {
- binding.fireDialogRootView.gone()
- onClearDataOptionsDismissed()
- /*
- * Avoid calling callback twice when view is detached.
- * We handle this callback here to ensure pixel is sent before process restarts
- */
- onClearDataOptionsDismissed = {}
- }
-
- @Synchronized
- private fun onFireDialogClearAllEvent(event: FireDialogClearAllEvent) {
- if (!canRestart) {
- canRestart = true
- if (event is ClearAllDataFinished) {
- binding.fireAnimationView.addAnimatorUpdateListener(accelerateAnimatorUpdateListener)
- }
- } else {
- // Both clearing and animation are done, now restart
- clearDataAction.killAndRestartProcess(notifyDataCleared = false, enableTransitionAnimation = false)
- }
- }
-
- private fun trySendDailyClearOptionClicked() {
- val now = getUtcIsoLocalDate()
- val timestamp = fireButtonStore.lastEventSendTime
-
- if (timestamp == null || now > timestamp) {
- fireButtonStore.storeLastFireButtonClearEventTime(now)
- pixel.enqueueFire(AppPixelName.PRODUCT_TELEMETRY_SURFACE_DATA_CLEARING_DAILY)
- }
- }
-
- private fun getUtcIsoLocalDate(): String {
- // returns YYYY-MM-dd
- return Instant.now().atOffset(ZoneOffset.UTC).format(DateTimeFormatter.ISO_LOCAL_DATE)
- }
-
- private sealed class FireDialogClearAllEvent {
- data object AnimationFinished : FireDialogClearAllEvent()
- data object ClearAllDataFinished : FireDialogClearAllEvent()
+/**
+ * Common interface for Fire dialog variants (Legacy and Granular).
+ *
+ * To receive lifecycle events from the dialog, use FragmentManager.setFragmentResultListener
+ * with REQUEST_KEY and check RESULT_KEY_EVENT for the event type.
+ */
+interface FireDialog {
+ /**
+ * Shows the Fire dialog.
+ * @param fragmentManager The FragmentManager to use for showing the dialog
+ * @param tag Optional tag for the fragment transaction
+ */
+ fun show(fragmentManager: FragmentManager, tag: String? = "fire_dialog")
+
+ companion object {
+ const val REQUEST_KEY = "FireDialogRequestKey"
+ const val RESULT_KEY_EVENT = "event"
+ const val EVENT_ON_SHOW = "onShow"
+ const val EVENT_ON_CANCEL = "onCancel"
+ const val EVENT_ON_CLEAR_STARTED = "onClearStarted"
}
}
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 0fbf11d4eda9..d429fb254f78 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
@@ -16,72 +16,46 @@
package com.duckduckgo.app.global.view
-import android.content.Context
-import com.duckduckgo.app.di.AppCoroutineScope
-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
-import com.duckduckgo.app.settings.db.SettingsDataStore
-import com.duckduckgo.app.statistics.pixels.Pixel
-import com.duckduckgo.appbuildconfig.api.AppBuildConfig
import com.duckduckgo.common.utils.DispatcherProvider
import com.duckduckgo.di.scopes.AppScope
import com.squareup.anvil.annotations.ContributesBinding
import dagger.SingleInstanceIn
-import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.withContext
import javax.inject.Inject
+/**
+ * Provider for creating Fire dialog instances.
+ * Returns the appropriate dialog variant based on feature flag (Simple or Granular).
+ *
+ * To receive lifecycle events from the dialog (onShow, onCancel, onClearStarted),
+ * use FragmentManager.setFragmentResultListener with the appropriate REQUEST_KEY
+ * from GranularFireDialog or LegacyFireDialog.
+ */
interface FireDialogProvider {
- fun createFireDialog(context: Context): FireDialog
+ /**
+ * Creates a Fire dialog instance.
+ * @return Instance of FireDialog (either Simple or Granular variant)
+ */
+ suspend fun createFireDialog(): FireDialog
}
@ContributesBinding(scope = AppScope::class)
@SingleInstanceIn(scope = AppScope::class)
-class FireDialogLauncherImpl @Inject constructor() : FireDialogProvider {
-
- @Inject
- lateinit var clearDataAction: ClearDataAction
-
- @Inject
- lateinit var dataClearing: ManualDataClearing
-
- @Inject
- lateinit var androidBrowserConfigFeature: AndroidBrowserConfigFeature
-
- @Inject
- lateinit var pixel: Pixel
-
- @Inject
- lateinit var settingsDataStore: SettingsDataStore
-
- @Inject
- lateinit var userEventsStore: UserEventsStore
-
- @AppCoroutineScope
- @Inject
- lateinit var appCoroutineScope: CoroutineScope
-
- @Inject
- lateinit var dispatcherProvider: DispatcherProvider
-
- @Inject
- lateinit var fireButtonStore: FireButtonStore
-
- @Inject
- lateinit var appBuildConfig: AppBuildConfig
-
- override fun createFireDialog(context: Context): FireDialog = FireDialog(
- context = context,
- clearDataAction = clearDataAction,
- dataClearing = dataClearing,
- androidBrowserConfigFeature = androidBrowserConfigFeature,
- pixel = pixel,
- settingsDataStore = settingsDataStore,
- userEventsStore = userEventsStore,
- appCoroutineScope = appCoroutineScope,
- dispatcherProvider = dispatcherProvider,
- fireButtonStore = fireButtonStore,
- appBuildConfig = appBuildConfig,
- )
+class FireDialogProviderImpl @Inject constructor(
+ private val androidBrowserConfigFeature: AndroidBrowserConfigFeature,
+ private val dispatcherProvider: DispatcherProvider,
+) : FireDialogProvider {
+
+ override suspend fun createFireDialog(): FireDialog {
+ val isGranularMode = withContext(dispatcherProvider.io()) {
+ androidBrowserConfigFeature.moreGranularDataClearingOptions().isEnabled()
+ }
+
+ return if (isGranularMode) {
+ GranularFireDialog.newInstance()
+ } else {
+ LegacyFireDialog.newInstance()
+ }
+ }
}
diff --git a/app/src/main/java/com/duckduckgo/app/global/view/GranularFireDialog.kt b/app/src/main/java/com/duckduckgo/app/global/view/GranularFireDialog.kt
new file mode 100644
index 000000000000..7ab9aecd4295
--- /dev/null
+++ b/app/src/main/java/com/duckduckgo/app/global/view/GranularFireDialog.kt
@@ -0,0 +1,384 @@
+/*
+ * 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.global.view
+
+import android.animation.Animator
+import android.animation.ValueAnimator
+import android.app.Dialog
+import android.content.Context
+import android.content.DialogInterface
+import android.os.Bundle
+import android.provider.Settings
+import android.provider.Settings.Global.ANIMATOR_DURATION_SCALE
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.view.WindowManager
+import androidx.core.content.ContextCompat
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowInsetsCompat.Type
+import androidx.core.view.WindowInsetsControllerCompat
+import androidx.core.view.isVisible
+import androidx.core.view.updatePadding
+import androidx.fragment.app.FragmentManager
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import com.airbnb.lottie.RenderMode
+import com.duckduckgo.anvil.annotations.InjectWith
+import com.duckduckgo.app.browser.databinding.SheetFireClearDataGranularBinding
+import com.duckduckgo.app.global.view.GranularFireDialogViewModel.Command
+import com.duckduckgo.app.settings.clear.FireClearOption
+import com.duckduckgo.app.settings.db.SettingsDataStore
+import com.duckduckgo.appbuildconfig.api.AppBuildConfig
+import com.duckduckgo.common.ui.view.gone
+import com.duckduckgo.common.ui.view.setAndPropagateUpFitsSystemWindows
+import com.duckduckgo.common.ui.view.show
+import com.duckduckgo.common.utils.FragmentViewModelFactory
+import com.duckduckgo.di.scopes.FragmentScope
+import com.google.android.material.bottomsheet.BottomSheetBehavior
+import com.google.android.material.bottomsheet.BottomSheetDialog
+import com.google.android.material.bottomsheet.BottomSheetDialogFragment
+import dagger.android.support.AndroidSupportInjection
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+import com.duckduckgo.mobile.android.R as CommonR
+import com.google.android.material.R as MaterialR
+
+private const val ANIMATION_MAX_SPEED = 1.4f
+private const val ANIMATION_SPEED_INCREMENT = 0.15f
+
+/**
+ * Granular Fire dialog that allows users to select which data to clear.
+ */
+@InjectWith(FragmentScope::class)
+class GranularFireDialog : BottomSheetDialogFragment(), FireDialog {
+ @Inject
+ lateinit var settingsDataStore: SettingsDataStore
+
+ @Inject
+ lateinit var appBuildConfig: AppBuildConfig
+
+ @Inject
+ lateinit var clearDataAction: ClearDataAction
+
+ @Inject
+ lateinit var viewModelFactory: FragmentViewModelFactory
+
+ private val viewModel: GranularFireDialogViewModel by lazy {
+ ViewModelProvider(this, viewModelFactory)[GranularFireDialogViewModel::class.java]
+ }
+
+ private var _binding: SheetFireClearDataGranularBinding? = null
+ private val binding get() = _binding!!
+
+ private val accelerateAnimatorUpdateListener = object : ValueAnimator.AnimatorUpdateListener {
+ override fun onAnimationUpdate(animation: ValueAnimator) {
+ binding.fireAnimationView.let {
+ it.speed += ANIMATION_SPEED_INCREMENT
+ if (it.speed > ANIMATION_MAX_SPEED) {
+ it.removeUpdateListener(this)
+ }
+ }
+ }
+ }
+
+ private var hasRestarted = false
+ private var isAnimationComplete = false
+ private var isClearingComplete = false
+
+ override fun onAttach(context: Context) {
+ AndroidSupportInjection.inject(this)
+ super.onAttach(context)
+ }
+
+ override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+ return BottomSheetDialog(requireContext(), CommonR.style.Widget_DuckDuckGo_FireDialog)
+ }
+
+ @Suppress("DEPRECATION")
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?,
+ ): View {
+ _binding = SheetFireClearDataGranularBinding.inflate(inflater, container, false)
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ hasRestarted = false
+
+ setupLayout()
+ configureBottomSheet()
+ observeViewModel()
+
+ if (appBuildConfig.sdkInt == 26) {
+ dialog?.window?.navigationBarColor = ContextCompat.getColor(requireContext(), CommonR.color.translucentDark)
+ } else if (appBuildConfig.sdkInt in 27..<30) {
+ dialog?.window?.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS)
+ }
+
+ removeTopPadding()
+ addBottomPaddingToButtons()
+
+ if (isAnimationEnabled()) {
+ configureFireAnimationView()
+ }
+ }
+
+ override fun onStart() {
+ super.onStart()
+ viewModel.onShow()
+ }
+
+ override fun onCancel(dialog: DialogInterface) {
+ super.onCancel(dialog)
+ viewModel.onCancel()
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ _binding = null
+ }
+
+ override fun show(fragmentManager: FragmentManager, tag: String?) {
+ super.show(fragmentManager, tag)
+ }
+
+ private fun setupLayout() {
+ binding.apply {
+ deleteButton.setOnClickListener {
+ hideClearDataOptions()
+ viewModel.onDeleteClicked()
+ }
+
+ cancelButton.setOnClickListener {
+ viewModel.onCancel()
+ }
+ }
+ }
+
+ private fun configureBottomSheet() {
+ (dialog as? BottomSheetDialog)?.behavior?.apply {
+ state = BottomSheetBehavior.STATE_EXPANDED
+ }
+ }
+
+ private fun observeViewModel() {
+ viewLifecycleOwner.lifecycleScope.launch {
+ viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
+ viewModel.viewState.collect { state ->
+ render(state)
+ }
+ }
+ }
+
+ viewModel.commands()
+ .onEach { command ->
+ when (command) {
+ is Command.PlayAnimation -> {
+ if (isAnimationEnabled()) {
+ playAnimation()
+ }
+ }
+ is Command.ClearingComplete -> {
+ onClearingComplete()
+ }
+ is Command.OnShow -> {
+ parentFragmentManager.setFragmentResult(
+ FireDialog.REQUEST_KEY,
+ Bundle().apply {
+ putString(FireDialog.RESULT_KEY_EVENT, FireDialog.EVENT_ON_SHOW)
+ },
+ )
+ }
+ is Command.OnCancel -> {
+ parentFragmentManager.setFragmentResult(
+ FireDialog.REQUEST_KEY,
+ Bundle().apply {
+ putString(FireDialog.RESULT_KEY_EVENT, FireDialog.EVENT_ON_CANCEL)
+ },
+ )
+ dismiss()
+ }
+ is Command.OnClearStarted -> {
+ parentFragmentManager.setFragmentResult(
+ FireDialog.REQUEST_KEY,
+ Bundle().apply {
+ putString(FireDialog.RESULT_KEY_EVENT, FireDialog.EVENT_ON_CLEAR_STARTED)
+ },
+ )
+ }
+ }
+ }
+ .launchIn(viewLifecycleOwner.lifecycleScope)
+ }
+
+ private fun render(state: GranularFireDialogViewModel.ViewState) {
+ binding.apply {
+ val tabsDescription = resources.getQuantityString(
+ com.duckduckgo.app.browser.R.plurals.fireDialogOptionTabsDescription,
+ state.tabCount,
+ state.tabCount,
+ )
+ tabsOption.setSecondaryText(tabsDescription)
+
+ val dataDescription = if (state.isHistoryEnabled) {
+ if (state.siteCount > 0) {
+ resources.getQuantityString(
+ com.duckduckgo.app.browser.R.plurals.fireDialogOptionDataDescription,
+ state.siteCount,
+ state.siteCount,
+ )
+ } else {
+ getString(com.duckduckgo.app.browser.R.string.fireDialogOptionDataDescriptionNoSites)
+ }
+ } else {
+ getString(com.duckduckgo.app.browser.R.string.fireDialogOptionDataDescriptionNoHistory)
+ }
+ dataOption.setSecondaryText(dataDescription)
+
+ deleteButton.isEnabled = state.isDeleteButtonEnabled
+
+ val tabsListener: (android.widget.CompoundButton, Boolean) -> Unit = { _, isChecked ->
+ viewModel.onOptionToggled(FireClearOption.TABS, isChecked)
+ }
+
+ val dataListener: (android.widget.CompoundButton, Boolean) -> Unit = { _, isChecked ->
+ viewModel.onOptionToggled(FireClearOption.DATA, isChecked)
+ }
+
+ val duckAiChatsListener: (android.widget.CompoundButton, Boolean) -> Unit = { _, isChecked ->
+ viewModel.onOptionToggled(FireClearOption.DUCKAI_CHATS, isChecked)
+ }
+
+ tabsOption.quietlySetIsChecked(state.selectedOptions.contains(FireClearOption.TABS), tabsListener)
+ dataOption.quietlySetIsChecked(state.selectedOptions.contains(FireClearOption.DATA), dataListener)
+ duckAiChatsOption.quietlySetIsChecked(state.selectedOptions.contains(FireClearOption.DUCKAI_CHATS), duckAiChatsListener)
+
+ tabsOption.setOnCheckedChangeListener(tabsListener)
+ dataOption.setOnCheckedChangeListener(dataListener)
+ duckAiChatsOption.setOnCheckedChangeListener(duckAiChatsListener)
+
+ duckAiChatsOptionContainer.isVisible = state.isDuckChatClearingEnabled
+ }
+ }
+
+ private fun onClearingComplete() {
+ isClearingComplete = true
+ // Add accelerator listener when clearing finishes to speed up remaining animation
+ if (isAnimationEnabled() && !isAnimationComplete) {
+ binding.fireAnimationView.addAnimatorUpdateListener(accelerateAnimatorUpdateListener)
+ }
+ checkIfShouldRestart()
+ }
+
+ private fun removeTopPadding() {
+ dialog?.findViewById(MaterialR.id.design_bottom_sheet)?.apply {
+ ViewCompat.setOnApplyWindowInsetsListener(this) { view, insets ->
+ view.updatePadding(top = 0)
+ insets
+ }
+ }
+ }
+
+ private fun addBottomPaddingToButtons() {
+ binding.fireDialogRootView.apply {
+ ViewCompat.setOnApplyWindowInsetsListener(this) { view, insets ->
+ view.updatePadding(bottom = insets.getInsets(Type.systemBars()).bottom)
+ insets
+ }
+ }
+ }
+
+ private fun configureFireAnimationView() {
+ binding.fireAnimationView.apply {
+ setAnimation(settingsDataStore.selectedFireAnimation.resId)
+ setAndPropagateUpFitsSystemWindows(false)
+ renderMode = RenderMode.SOFTWARE
+ enableMergePathsForKitKatAndAbove(true)
+ }
+ }
+
+ private fun isAnimationEnabled() = settingsDataStore.fireAnimationEnabled && isAnimatorDurationEnabled()
+
+ private fun isAnimatorDurationEnabled(): Boolean {
+ val animatorScale = Settings.Global.getFloat(requireContext().contentResolver, ANIMATOR_DURATION_SCALE, 1.0f)
+ return animatorScale != 0.0f
+ }
+
+ private fun playAnimation() {
+ dialog?.window?.apply {
+ WindowInsetsControllerCompat(this, binding.root).apply {
+ isAppearanceLightStatusBars = false
+ isAppearanceLightNavigationBars = false
+ }
+ }
+ isCancelable = false
+ binding.fireAnimationView.show()
+ binding.fireAnimationView.playAnimation()
+ binding.fireAnimationView.addAnimatorListener(
+ object : Animator.AnimatorListener {
+ override fun onAnimationRepeat(animation: Animator) {}
+ override fun onAnimationCancel(animation: Animator) {}
+ override fun onAnimationStart(animation: Animator) {}
+ override fun onAnimationEnd(animation: Animator) {
+ onAnimationComplete()
+ }
+ },
+ )
+ }
+
+ private fun onAnimationComplete() {
+ isAnimationComplete = true
+ checkIfShouldRestart()
+ }
+
+ private fun hideClearDataOptions() {
+ binding.fireDialogRootView.gone()
+ }
+
+ @Synchronized
+ private fun checkIfShouldRestart() {
+ val allTasksComplete = if (isAnimationEnabled()) {
+ isAnimationComplete && isClearingComplete
+ } else {
+ isClearingComplete
+ }
+
+ if (allTasksComplete && !hasRestarted) {
+ hasRestarted = true
+ if (viewModel.viewState.value.shouldRestartAfterClearing) {
+ clearDataAction.killAndRestartProcess(notifyDataCleared = false, enableTransitionAnimation = false)
+ } else {
+ dismiss()
+ }
+ }
+ }
+
+ companion object {
+ fun newInstance(): GranularFireDialog {
+ return GranularFireDialog()
+ }
+ }
+}
diff --git a/app/src/main/java/com/duckduckgo/app/global/view/GranularFireDialogViewModel.kt b/app/src/main/java/com/duckduckgo/app/global/view/GranularFireDialogViewModel.kt
new file mode 100644
index 000000000000..642e42323488
--- /dev/null
+++ b/app/src/main/java/com/duckduckgo/app/global/view/GranularFireDialogViewModel.kt
@@ -0,0 +1,197 @@
+/*
+ * 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.global.view
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.duckduckgo.anvil.annotations.ContributesViewModel
+import com.duckduckgo.app.fire.ManualDataClearing
+import com.duckduckgo.app.fire.store.FireDataStore
+import com.duckduckgo.app.firebutton.FireButtonStore
+import com.duckduckgo.app.global.events.db.UserEventKey
+import com.duckduckgo.app.global.events.db.UserEventsStore
+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.AppPixelName.PRODUCT_TELEMETRY_SURFACE_DATA_CLEARING
+import com.duckduckgo.app.settings.clear.FireClearOption
+import com.duckduckgo.app.settings.clear.getPixelValue
+import com.duckduckgo.app.settings.db.SettingsDataStore
+import com.duckduckgo.app.statistics.pixels.Pixel
+import com.duckduckgo.app.statistics.pixels.Pixel.PixelParameter.FIRE_ANIMATION
+import com.duckduckgo.app.tabs.model.TabRepository
+import com.duckduckgo.common.utils.DateProvider
+import com.duckduckgo.common.utils.DispatcherProvider
+import com.duckduckgo.di.scopes.FragmentScope
+import com.duckduckgo.duckchat.api.DuckChat
+import com.duckduckgo.history.api.NavigationHistory
+import kotlinx.coroutines.channels.BufferOverflow
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import javax.inject.Inject
+
+@ContributesViewModel(FragmentScope::class)
+class GranularFireDialogViewModel @Inject constructor(
+ tabRepository: TabRepository,
+ private val fireDataStore: FireDataStore,
+ private val dataClearing: ManualDataClearing,
+ private val pixel: Pixel,
+ private val settingsDataStore: SettingsDataStore,
+ private val userEventsStore: UserEventsStore,
+ private val fireButtonStore: FireButtonStore,
+ private val dispatcherProvider: DispatcherProvider,
+ private val duckChat: DuckChat,
+ private val navigationHistory: NavigationHistory,
+ private val dateProvider: DateProvider,
+) : ViewModel() {
+
+ data class ViewState(
+ val tabCount: Int = 0,
+ val siteCount: Int = 0,
+ val isHistoryEnabled: Boolean = false,
+ val selectedOptions: Set = emptySet(),
+ val isDuckChatClearingEnabled: Boolean = false,
+ ) {
+ val isDeleteButtonEnabled: Boolean = selectedOptions.isNotEmpty()
+ val shouldRestartAfterClearing: Boolean =
+ selectedOptions.contains(FireClearOption.DATA) || selectedOptions.contains(FireClearOption.DUCKAI_CHATS)
+ }
+
+ sealed class Command {
+ data object PlayAnimation : Command()
+ data object ClearingComplete : Command()
+ data object OnShow : Command()
+ data object OnCancel : Command()
+ data object OnClearStarted : Command()
+ }
+
+ private val _siteCount = MutableStateFlow(0)
+ private val _isHistoryEnabled = MutableStateFlow(false)
+ private val _chatClearingEnabled = MutableStateFlow(false)
+
+ // Capacity set to 3 to handle the onDeleteClicked() command burst:
+ // OnClearStarted -> PlayAnimation -> ClearingComplete
+ private val command = Channel(3, BufferOverflow.DROP_OLDEST)
+
+ val viewState: StateFlow = combine(
+ tabRepository.flowTabs,
+ fireDataStore.getManualClearOptionsFlow(),
+ _siteCount,
+ _isHistoryEnabled,
+ _chatClearingEnabled,
+ ) { tabs, selectedOptions, siteCount, isHistoryEnabled, chatClearingEnabled ->
+ ViewState(
+ tabCount = tabs.size,
+ siteCount = siteCount,
+ isHistoryEnabled = isHistoryEnabled,
+ selectedOptions = selectedOptions,
+ isDuckChatClearingEnabled = chatClearingEnabled,
+ )
+ }.stateIn(
+ scope = viewModelScope,
+ started = SharingStarted.WhileSubscribed(),
+ initialValue = ViewState(),
+ )
+
+ fun commands(): Flow = command.receiveAsFlow()
+
+ init {
+ viewModelScope.launch(dispatcherProvider.io()) {
+ val isEnabled = duckChat.wasOpenedBefore()
+ _chatClearingEnabled.update { isEnabled }
+ }
+
+ viewModelScope.launch(dispatcherProvider.io()) {
+ val historyEnabled = navigationHistory.isHistoryUserEnabled()
+ if (historyEnabled) {
+ _isHistoryEnabled.update { true }
+ navigationHistory.getHistory().collect { historyEntries ->
+ val uniqueDomains = historyEntries.mapNotNull { it.url.host }.toSet()
+ _siteCount.update { uniqueDomains.size }
+ }
+ } else {
+ _isHistoryEnabled.update { false }
+ }
+ }
+ }
+
+ fun onOptionToggled(option: FireClearOption, isChecked: Boolean) {
+ viewModelScope.launch(dispatcherProvider.io()) {
+ if (isChecked) {
+ fireDataStore.addManualClearOption(option)
+ } else {
+ fireDataStore.removeManualClearOption(option)
+ }
+ }
+ }
+
+ fun onShow() {
+ viewModelScope.launch {
+ command.send(Command.OnShow)
+ }
+ }
+
+ fun onCancel() {
+ viewModelScope.launch {
+ command.send(Command.OnCancel)
+ }
+ }
+
+ fun onDeleteClicked() {
+ viewModelScope.launch {
+ command.send(Command.OnClearStarted)
+ trySendDailyDeleteClicked()
+ pixel.enqueueFire(FIRE_DIALOG_CLEAR_PRESSED)
+ pixel.enqueueFire(PRODUCT_TELEMETRY_SURFACE_DATA_CLEARING)
+ pixel.enqueueFire(
+ pixel = FIRE_DIALOG_ANIMATION,
+ parameters = mapOf(FIRE_ANIMATION to settingsDataStore.selectedFireAnimation.getPixelValue()),
+ )
+
+ if (settingsDataStore.fireAnimationEnabled) {
+ command.send(Command.PlayAnimation)
+ }
+
+ withContext(dispatcherProvider.io()) {
+ fireButtonStore.incrementFireButtonUseCount()
+ userEventsStore.registerUserEvent(UserEventKey.FIRE_BUTTON_EXECUTED)
+ dataClearing.clearDataUsingManualFireOptions()
+ }
+
+ command.send(Command.ClearingComplete)
+ }
+ }
+
+ private fun trySendDailyDeleteClicked() {
+ val now = dateProvider.getUtcIsoLocalDate()
+ val timestamp = fireButtonStore.lastEventSendTime
+
+ if (timestamp == null || now > timestamp) {
+ fireButtonStore.storeLastFireButtonClearEventTime(now)
+ pixel.enqueueFire(AppPixelName.PRODUCT_TELEMETRY_SURFACE_DATA_CLEARING_DAILY)
+ }
+ }
+}
diff --git a/app/src/main/java/com/duckduckgo/app/global/view/LegacyFireDialog.kt b/app/src/main/java/com/duckduckgo/app/global/view/LegacyFireDialog.kt
new file mode 100644
index 000000000000..444093ee8beb
--- /dev/null
+++ b/app/src/main/java/com/duckduckgo/app/global/view/LegacyFireDialog.kt
@@ -0,0 +1,335 @@
+/*
+ * 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.global.view
+
+import android.animation.Animator
+import android.animation.ValueAnimator
+import android.app.Dialog
+import android.content.Context
+import android.content.DialogInterface
+import android.os.Bundle
+import android.provider.Settings
+import android.provider.Settings.Global.ANIMATOR_DURATION_SCALE
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.view.WindowManager
+import androidx.core.content.ContextCompat
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowInsetsCompat.Type
+import androidx.core.view.WindowInsetsControllerCompat
+import androidx.core.view.updatePadding
+import androidx.fragment.app.FragmentManager
+import com.airbnb.lottie.RenderMode
+import com.duckduckgo.anvil.annotations.InjectWith
+import com.duckduckgo.app.browser.databinding.SheetFireClearDataBinding
+import com.duckduckgo.app.di.AppCoroutineScope
+import com.duckduckgo.app.firebutton.FireButtonStore
+import com.duckduckgo.app.global.events.db.UserEventKey
+import com.duckduckgo.app.global.events.db.UserEventsStore
+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.AppPixelName.PRODUCT_TELEMETRY_SURFACE_DATA_CLEARING
+import com.duckduckgo.app.settings.clear.getPixelValue
+import com.duckduckgo.app.settings.db.SettingsDataStore
+import com.duckduckgo.app.statistics.pixels.Pixel
+import com.duckduckgo.app.statistics.pixels.Pixel.PixelParameter.FIRE_ANIMATION
+import com.duckduckgo.appbuildconfig.api.AppBuildConfig
+import com.duckduckgo.common.ui.view.gone
+import com.duckduckgo.common.ui.view.setAndPropagateUpFitsSystemWindows
+import com.duckduckgo.common.ui.view.show
+import com.duckduckgo.common.utils.DateProvider
+import com.duckduckgo.common.utils.DispatcherProvider
+import com.duckduckgo.di.scopes.FragmentScope
+import com.google.android.material.bottomsheet.BottomSheetBehavior
+import com.google.android.material.bottomsheet.BottomSheetDialog
+import com.google.android.material.bottomsheet.BottomSheetDialogFragment
+import dagger.android.support.AndroidSupportInjection
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+import com.duckduckgo.mobile.android.R as CommonR
+import com.google.android.material.R as MaterialR
+
+private const val ANIMATION_MAX_SPEED = 1.4f
+private const val ANIMATION_SPEED_INCREMENT = 0.15f
+
+@InjectWith(FragmentScope::class)
+class LegacyFireDialog : BottomSheetDialogFragment(), FireDialog {
+ @AppCoroutineScope
+ @Inject
+ lateinit var appCoroutineScope: CoroutineScope
+
+ @Inject
+ lateinit var clearDataAction: ClearDataAction
+
+ @Inject
+ lateinit var pixel: Pixel
+
+ @Inject
+ lateinit var settingsDataStore: SettingsDataStore
+
+ @Inject
+ lateinit var userEventsStore: UserEventsStore
+
+ @Inject
+ lateinit var dispatcherProvider: DispatcherProvider
+
+ @Inject
+ lateinit var fireButtonStore: FireButtonStore
+
+ @Inject
+ lateinit var appBuildConfig: AppBuildConfig
+
+ @Inject
+ lateinit var dateProvider: DateProvider
+
+ private var _binding: SheetFireClearDataBinding? = null
+ private val binding get() = _binding!!
+
+ private val accelerateAnimatorUpdateListener = object : ValueAnimator.AnimatorUpdateListener {
+ override fun onAnimationUpdate(animation: ValueAnimator) {
+ binding.fireAnimationView.let {
+ it.speed += ANIMATION_SPEED_INCREMENT
+ if (it.speed > ANIMATION_MAX_SPEED) {
+ it.removeUpdateListener(this)
+ }
+ }
+ }
+ }
+
+ private var canRestart = false
+
+ override fun onAttach(context: Context) {
+ AndroidSupportInjection.inject(this)
+ super.onAttach(context)
+ }
+
+ override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+ return BottomSheetDialog(requireContext(), CommonR.style.Widget_DuckDuckGo_FireDialog)
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?,
+ ): View {
+ _binding = SheetFireClearDataBinding.inflate(inflater, container, false)
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ canRestart = !animationEnabled()
+
+ setupLayout()
+ configureBottomSheet()
+
+ if (appBuildConfig.sdkInt == 26) {
+ dialog?.window?.navigationBarColor = ContextCompat.getColor(requireContext(), CommonR.color.translucentDark)
+ } else if (appBuildConfig.sdkInt in 27..<30) {
+ dialog?.window?.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS)
+ }
+
+ removeTopPadding()
+ addBottomPaddingToButtons()
+
+ if (animationEnabled()) {
+ configureFireAnimationView()
+ }
+ }
+
+ override fun onStart() {
+ super.onStart()
+ parentFragmentManager.setFragmentResult(
+ FireDialog.REQUEST_KEY,
+ Bundle().apply {
+ putString(FireDialog.RESULT_KEY_EVENT, FireDialog.EVENT_ON_SHOW)
+ },
+ )
+ }
+
+ override fun onCancel(dialog: DialogInterface) {
+ super.onCancel(dialog)
+ parentFragmentManager.setFragmentResult(
+ FireDialog.REQUEST_KEY,
+ Bundle().apply {
+ putString(FireDialog.RESULT_KEY_EVENT, FireDialog.EVENT_ON_CANCEL)
+ },
+ )
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ _binding = null
+ }
+
+ override fun show(fragmentManager: FragmentManager, tag: String?) {
+ super.show(fragmentManager, tag)
+ }
+
+ private fun setupLayout() {
+ binding.apply {
+ clearAllOption.setOnClickListener {
+ onClearOptionClicked()
+ }
+ cancelOption.setOnClickListener {
+ parentFragmentManager.setFragmentResult(
+ FireDialog.REQUEST_KEY,
+ Bundle().apply {
+ putString(FireDialog.RESULT_KEY_EVENT, FireDialog.EVENT_ON_CANCEL)
+ },
+ )
+ dismiss()
+ }
+
+ if (settingsDataStore.clearDuckAiData) {
+ clearAllOption.setPrimaryText(requireContext().getString(com.duckduckgo.app.browser.R.string.fireClearAllPlusDuckChats))
+ }
+ }
+ }
+
+ private fun configureBottomSheet() {
+ (dialog as? BottomSheetDialog)?.behavior?.apply {
+ state = BottomSheetBehavior.STATE_EXPANDED
+ }
+ }
+
+ private fun removeTopPadding() {
+ dialog?.findViewById(MaterialR.id.design_bottom_sheet)?.apply {
+ ViewCompat.setOnApplyWindowInsetsListener(this) { view, insets ->
+ view.updatePadding(top = 0)
+ insets
+ }
+ }
+ }
+
+ private fun addBottomPaddingToButtons() {
+ binding.fireDialogRootView.apply {
+ ViewCompat.setOnApplyWindowInsetsListener(this) { view, insets ->
+ view.updatePadding(bottom = insets.getInsets(Type.systemBars()).bottom)
+ insets
+ }
+ }
+ }
+
+ private fun configureFireAnimationView() {
+ binding.fireAnimationView.apply {
+ setAnimation(settingsDataStore.selectedFireAnimation.resId)
+ setAndPropagateUpFitsSystemWindows(false)
+ renderMode = RenderMode.SOFTWARE
+ enableMergePathsForKitKatAndAbove(true)
+ }
+ }
+
+ private fun onClearOptionClicked() {
+ trySendDailyClearOptionClicked()
+ pixel.enqueueFire(FIRE_DIALOG_CLEAR_PRESSED)
+ pixel.enqueueFire(PRODUCT_TELEMETRY_SURFACE_DATA_CLEARING)
+ pixel.enqueueFire(
+ pixel = FIRE_DIALOG_ANIMATION,
+ parameters = mapOf(FIRE_ANIMATION to settingsDataStore.selectedFireAnimation.getPixelValue()),
+ )
+ hideClearDataOptions()
+ if (animationEnabled()) {
+ playAnimation()
+ }
+ parentFragmentManager.setFragmentResult(
+ FireDialog.REQUEST_KEY,
+ Bundle().apply {
+ putString(FireDialog.RESULT_KEY_EVENT, FireDialog.EVENT_ON_CLEAR_STARTED)
+ },
+ )
+
+ appCoroutineScope.launch(dispatcherProvider.io()) {
+ fireButtonStore.incrementFireButtonUseCount()
+ userEventsStore.registerUserEvent(UserEventKey.FIRE_BUTTON_EXECUTED)
+ clearDataAction.clearTabsAndAllDataAsync(appInForeground = true, shouldFireDataClearPixel = true)
+ clearDataAction.setAppUsedSinceLastClearFlag(false)
+ onFireDialogClearAllEvent(FireDialogClearAllEvent.ClearAllDataFinished)
+ }
+ }
+
+ private fun animationEnabled() = settingsDataStore.fireAnimationEnabled && animatorDurationEnabled()
+
+ private fun animatorDurationEnabled(): Boolean {
+ val animatorScale = Settings.Global.getFloat(requireContext().contentResolver, ANIMATOR_DURATION_SCALE, 1.0f)
+ return animatorScale != 0.0f
+ }
+
+ private fun playAnimation() {
+ dialog?.window?.apply {
+ WindowInsetsControllerCompat(this, binding.root).apply {
+ isAppearanceLightStatusBars = false
+ isAppearanceLightNavigationBars = false
+ }
+ }
+ isCancelable = false
+ binding.fireAnimationView.show()
+ binding.fireAnimationView.playAnimation()
+ binding.fireAnimationView.addAnimatorListener(
+ object : Animator.AnimatorListener {
+ override fun onAnimationRepeat(animation: Animator) {}
+ override fun onAnimationCancel(animation: Animator) {}
+ override fun onAnimationStart(animation: Animator) {}
+ override fun onAnimationEnd(animation: Animator) {
+ onFireDialogClearAllEvent(FireDialogClearAllEvent.AnimationFinished)
+ }
+ },
+ )
+ }
+
+ private fun hideClearDataOptions() {
+ binding.fireDialogRootView.gone()
+ }
+
+ @Synchronized
+ private fun onFireDialogClearAllEvent(event: FireDialogClearAllEvent) {
+ if (!canRestart) {
+ canRestart = true
+ if (event is FireDialogClearAllEvent.ClearAllDataFinished) {
+ binding.fireAnimationView.addAnimatorUpdateListener(accelerateAnimatorUpdateListener)
+ }
+ } else {
+ // Both clearing and animation are done, now restart
+ clearDataAction.killAndRestartProcess(notifyDataCleared = false, enableTransitionAnimation = false)
+ }
+ }
+
+ private fun trySendDailyClearOptionClicked() {
+ val now = dateProvider.getUtcIsoLocalDate()
+ val timestamp = fireButtonStore.lastEventSendTime
+
+ if (timestamp == null || now > timestamp) {
+ fireButtonStore.storeLastFireButtonClearEventTime(now)
+ pixel.enqueueFire(AppPixelName.PRODUCT_TELEMETRY_SURFACE_DATA_CLEARING_DAILY)
+ }
+ }
+
+ private sealed class FireDialogClearAllEvent {
+ data object AnimationFinished : FireDialogClearAllEvent()
+ data object ClearAllDataFinished : FireDialogClearAllEvent()
+ }
+
+ companion object {
+ fun newInstance(): LegacyFireDialog {
+ return LegacyFireDialog()
+ }
+ }
+}
diff --git a/app/src/main/java/com/duckduckgo/app/tabs/ui/TabSwitcherActivity.kt b/app/src/main/java/com/duckduckgo/app/tabs/ui/TabSwitcherActivity.kt
index 2b6e630ba2ed..861df2d668d3 100644
--- a/app/src/main/java/com/duckduckgo/app/tabs/ui/TabSwitcherActivity.kt
+++ b/app/src/main/java/com/duckduckgo/app/tabs/ui/TabSwitcherActivity.kt
@@ -617,8 +617,10 @@ class TabSwitcherActivity :
}
private fun onFireButtonClicked() {
- val dialog = fireDialogProvider.createFireDialog(context = this)
- dialog.show()
+ lifecycleScope.launch {
+ val dialog = fireDialogProvider.createFireDialog()
+ dialog.show(supportFragmentManager)
+ }
}
override fun onNewTabRequested(fromOverflowMenu: Boolean) {
diff --git a/app/src/main/res/drawable/rounded_top_corners_bottom_sheet_background.xml b/app/src/main/res/drawable/rounded_top_corners_bottom_sheet_background.xml
new file mode 100644
index 000000000000..2b331b9e7dfe
--- /dev/null
+++ b/app/src/main/res/drawable/rounded_top_corners_bottom_sheet_background.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/sheet_fire_clear_data_granular.xml b/app/src/main/res/layout/sheet_fire_clear_data_granular.xml
new file mode 100644
index 000000000000..d8323400ee99
--- /dev/null
+++ b/app/src/main/res/layout/sheet_fire_clear_data_granular.xml
@@ -0,0 +1,167 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values/donottranslate.xml b/app/src/main/res/values/donottranslate.xml
index e331172ffaf1..95eb632c6dd7 100644
--- a/app/src/main/res/values/donottranslate.xml
+++ b/app/src/main/res/values/donottranslate.xml
@@ -76,4 +76,22 @@
}
}]
+
+
+ Choose What To Delete
+ Tabs
+ Cookies and Site Data
+ Duck.ai Chats
+ Delete all chats
+ Delete
+
+ - Close 1 tab
+ - Close all %1$d tabs
+
+ May sign you out of accounts.
+ None
+
+ - Delete from 1 site. May sign you out of accounts.
+ - Delete from %1$d sites. May sign you out of accounts.
+
diff --git a/app/src/test/java/com/duckduckgo/app/global/view/GranularFireDialogViewModelTest.kt b/app/src/test/java/com/duckduckgo/app/global/view/GranularFireDialogViewModelTest.kt
new file mode 100644
index 000000000000..534325eca320
--- /dev/null
+++ b/app/src/test/java/com/duckduckgo/app/global/view/GranularFireDialogViewModelTest.kt
@@ -0,0 +1,551 @@
+/*
+ * 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.global.view
+
+import android.net.Uri
+import app.cash.turbine.test
+import com.duckduckgo.app.fire.ManualDataClearing
+import com.duckduckgo.app.fire.store.FireDataStore
+import com.duckduckgo.app.firebutton.FireButtonStore
+import com.duckduckgo.app.global.events.db.UserEventKey
+import com.duckduckgo.app.global.events.db.UserEventsStore
+import com.duckduckgo.app.global.view.GranularFireDialogViewModel.Command
+import com.duckduckgo.app.pixels.AppPixelName.FIRE_DIALOG_ANIMATION
+import com.duckduckgo.app.pixels.AppPixelName.FIRE_DIALOG_CLEAR_PRESSED
+import com.duckduckgo.app.pixels.AppPixelName.PRODUCT_TELEMETRY_SURFACE_DATA_CLEARING
+import com.duckduckgo.app.pixels.AppPixelName.PRODUCT_TELEMETRY_SURFACE_DATA_CLEARING_DAILY
+import com.duckduckgo.app.settings.clear.FireAnimation
+import com.duckduckgo.app.settings.clear.FireClearOption
+import com.duckduckgo.app.settings.db.SettingsDataStore
+import com.duckduckgo.app.statistics.pixels.Pixel
+import com.duckduckgo.app.statistics.pixels.Pixel.PixelParameter.FIRE_ANIMATION
+import com.duckduckgo.app.tabs.model.TabEntity
+import com.duckduckgo.app.tabs.model.TabRepository
+import com.duckduckgo.common.test.CoroutineTestRule
+import com.duckduckgo.common.utils.DateProvider
+import com.duckduckgo.common.utils.DispatcherProvider
+import com.duckduckgo.duckchat.api.DuckChat
+import com.duckduckgo.history.api.HistoryEntry
+import com.duckduckgo.history.api.NavigationHistory
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+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
+import org.mockito.kotlin.never
+import org.mockito.kotlin.verify
+import org.mockito.kotlin.whenever
+import java.time.LocalDateTime
+
+class GranularFireDialogViewModelTest {
+ @get:Rule
+ val coroutineTestRule: CoroutineTestRule = CoroutineTestRule()
+
+ private lateinit var testee: GranularFireDialogViewModel
+
+ private val mockTabRepository: TabRepository = mock()
+ private val mockFireDataStore: FireDataStore = mock()
+ private val mockDataClearing: ManualDataClearing = mock()
+ private val mockPixel: Pixel = mock()
+ private val mockSettingsDataStore: SettingsDataStore = mock()
+ private val mockUserEventsStore: UserEventsStore = mock()
+ private val mockFireButtonStore: FireButtonStore = mock()
+ private val mockDispatcherProvider: DispatcherProvider = mock()
+ private val mockDuckChat: DuckChat = mock()
+ private val mockNavigationHistory: NavigationHistory = mock()
+ private val mockDateProvider: DateProvider = mock()
+
+ private val tabsFlow = MutableStateFlow>(emptyList())
+ private val selectedOptionsFlow = MutableStateFlow>(emptySet())
+
+ @Before
+ fun setup() {
+ whenever(mockTabRepository.flowTabs).thenReturn(tabsFlow)
+ whenever(mockFireDataStore.getManualClearOptionsFlow()).thenReturn(selectedOptionsFlow)
+ whenever(mockDispatcherProvider.io()).thenReturn(coroutineTestRule.testDispatcherProvider.io())
+ whenever(mockSettingsDataStore.selectedFireAnimation).thenReturn(FireAnimation.HeroFire)
+ whenever(mockSettingsDataStore.fireAnimationEnabled).thenReturn(true)
+ whenever(mockDateProvider.getUtcIsoLocalDate()).thenReturn("2025-12-15")
+
+ runTest {
+ whenever(mockDuckChat.wasOpenedBefore()).thenReturn(false)
+ whenever(mockNavigationHistory.isHistoryUserEnabled()).thenReturn(false)
+ whenever(mockNavigationHistory.getHistory()).thenReturn(flowOf(emptyList()))
+ }
+ }
+
+ private fun createViewModel() = GranularFireDialogViewModel(
+ tabRepository = mockTabRepository,
+ fireDataStore = mockFireDataStore,
+ dataClearing = mockDataClearing,
+ pixel = mockPixel,
+ settingsDataStore = mockSettingsDataStore,
+ userEventsStore = mockUserEventsStore,
+ fireButtonStore = mockFireButtonStore,
+ dispatcherProvider = mockDispatcherProvider,
+ duckChat = mockDuckChat,
+ navigationHistory = mockNavigationHistory,
+ dateProvider = mockDateProvider,
+ )
+
+ @Test
+ fun `when initialized then viewState emits default values`() = runTest {
+ testee = createViewModel()
+
+ testee.viewState.test {
+ val state = awaitItem()
+
+ assertEquals(0, state.tabCount)
+ assertEquals(0, state.siteCount)
+ assertFalse(state.isHistoryEnabled)
+ assertTrue(state.selectedOptions.isEmpty())
+ assertFalse(state.isDuckChatClearingEnabled)
+ assertFalse(state.isDeleteButtonEnabled)
+
+ cancelAndConsumeRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `when tabs are present then viewState reflects tab count`() = runTest {
+ testee = createViewModel()
+ val tabs = listOf(
+ TabEntity(tabId = "1", url = "https://example.com", title = "Example"),
+ TabEntity(tabId = "2", url = "https://test.com", title = "Test"),
+ )
+
+ testee.viewState.test {
+ skipItems(1) // Skip initial state
+
+ tabsFlow.value = tabs
+
+ val state = awaitItem()
+ assertEquals(2, state.tabCount)
+
+ cancelAndConsumeRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `when options are selected then viewState reflects selected options`() = runTest {
+ testee = createViewModel()
+ val options = setOf(FireClearOption.TABS, FireClearOption.DATA)
+
+ testee.viewState.test {
+ skipItems(1) // Skip initial state
+
+ selectedOptionsFlow.value = options
+
+ val state = awaitItem()
+ assertEquals(options, state.selectedOptions)
+ assertTrue(state.isDeleteButtonEnabled)
+
+ cancelAndConsumeRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `when duck chat was opened before then viewState shows duck chat clearing enabled`() = runTest {
+ whenever(mockDuckChat.wasOpenedBefore()).thenReturn(true)
+
+ testee = createViewModel()
+
+ testee.viewState.test {
+ val state = awaitItem()
+
+ assertTrue(state.isDuckChatClearingEnabled)
+
+ cancelAndConsumeRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `when duck chat was not opened then viewState shows duck chat clearing disabled`() = runTest {
+ whenever(mockDuckChat.wasOpenedBefore()).thenReturn(false)
+
+ testee = createViewModel()
+
+ testee.viewState.test {
+ val state = awaitItem()
+
+ assertFalse(state.isDuckChatClearingEnabled)
+
+ cancelAndConsumeRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `when history is enabled then viewState reflects history enabled and site count`() = runTest {
+ val uri1 = mock().apply {
+ whenever(host).thenReturn("example.com")
+ }
+ val uri2 = mock().apply {
+ whenever(host).thenReturn("test.com")
+ }
+ val historyEntries = listOf(
+ HistoryEntry.VisitedPage(
+ url = uri1,
+ title = "Example",
+ visits = listOf(LocalDateTime.now()),
+ ),
+ HistoryEntry.VisitedPage(
+ url = uri2,
+ title = "Test",
+ visits = listOf(LocalDateTime.now()),
+ ),
+ )
+ whenever(mockNavigationHistory.isHistoryUserEnabled()).thenReturn(true)
+ whenever(mockNavigationHistory.getHistory()).thenReturn(flowOf(historyEntries))
+
+ testee = createViewModel()
+
+ testee.viewState.test {
+ val state = awaitItem()
+
+ assertTrue(state.isHistoryEnabled)
+ assertEquals(2, state.siteCount)
+
+ cancelAndConsumeRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `when history is disabled then viewState reflects history disabled and zero site count`() = runTest {
+ whenever(mockNavigationHistory.isHistoryUserEnabled()).thenReturn(false)
+
+ testee = createViewModel()
+
+ testee.viewState.test {
+ val state = awaitItem()
+
+ assertFalse(state.isHistoryEnabled)
+ assertEquals(0, state.siteCount)
+
+ cancelAndConsumeRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `when history has duplicate domains then site count reflects unique domains`() = runTest {
+ val uri1 = mock().apply {
+ whenever(host).thenReturn("example.com")
+ }
+ val uri2 = mock().apply {
+ whenever(host).thenReturn("example.com")
+ }
+ val uri3 = mock().apply {
+ whenever(host).thenReturn("test.com")
+ }
+ val historyEntries = listOf(
+ HistoryEntry.VisitedPage(
+ url = uri1,
+ title = "Page 1",
+ visits = listOf(LocalDateTime.now()),
+ ),
+ HistoryEntry.VisitedPage(
+ url = uri2,
+ title = "Page 2",
+ visits = listOf(LocalDateTime.now()),
+ ),
+ HistoryEntry.VisitedPage(
+ url = uri3,
+ title = "Test",
+ visits = listOf(LocalDateTime.now()),
+ ),
+ )
+ whenever(mockNavigationHistory.isHistoryUserEnabled()).thenReturn(true)
+ whenever(mockNavigationHistory.getHistory()).thenReturn(flowOf(historyEntries))
+
+ testee = createViewModel()
+
+ testee.viewState.test {
+ val state = awaitItem()
+
+ assertEquals(2, state.siteCount)
+
+ cancelAndConsumeRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `when no options selected then delete button is disabled`() = runTest {
+ testee = createViewModel()
+
+ testee.viewState.test {
+ val state = awaitItem()
+
+ assertTrue(state.selectedOptions.isEmpty())
+ assertFalse(state.isDeleteButtonEnabled)
+
+ cancelAndConsumeRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `when options selected then delete button is enabled`() = runTest {
+ testee = createViewModel()
+
+ testee.viewState.test {
+ skipItems(1)
+
+ selectedOptionsFlow.value = setOf(FireClearOption.TABS)
+
+ val state = awaitItem()
+ assertTrue(state.isDeleteButtonEnabled)
+
+ cancelAndConsumeRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `when option toggled on then option is added to store`() = runTest {
+ testee = createViewModel()
+
+ testee.onOptionToggled(FireClearOption.TABS, isChecked = true)
+
+ coroutineTestRule.testScope.testScheduler.advanceUntilIdle()
+
+ verify(mockFireDataStore).addManualClearOption(FireClearOption.TABS)
+ }
+
+ @Test
+ fun `when option toggled off then option is removed from store`() = runTest {
+ testee = createViewModel()
+
+ testee.onOptionToggled(FireClearOption.DATA, isChecked = false)
+
+ coroutineTestRule.testScope.testScheduler.advanceUntilIdle()
+
+ verify(mockFireDataStore).removeManualClearOption(FireClearOption.DATA)
+ }
+
+ @Test
+ fun `when delete clicked then pixels are fired`() = runTest {
+ testee = createViewModel()
+
+ testee.onDeleteClicked()
+
+ coroutineTestRule.testScope.testScheduler.advanceUntilIdle()
+
+ verify(mockPixel).enqueueFire(FIRE_DIALOG_CLEAR_PRESSED)
+ verify(mockPixel).enqueueFire(PRODUCT_TELEMETRY_SURFACE_DATA_CLEARING)
+ verify(mockPixel).enqueueFire(
+ pixel = FIRE_DIALOG_ANIMATION,
+ parameters = mapOf(FIRE_ANIMATION to Pixel.PixelValues.FIRE_ANIMATION_INFERNO),
+ )
+ }
+
+ @Test
+ fun `when delete clicked for first time then daily pixel is fired and timestamp is stored`() = runTest {
+ val today = "2025-12-15"
+ whenever(mockFireButtonStore.lastEventSendTime).thenReturn(null)
+ whenever(mockDateProvider.getUtcIsoLocalDate()).thenReturn(today)
+ testee = createViewModel()
+
+ testee.onDeleteClicked()
+
+ coroutineTestRule.testScope.testScheduler.advanceUntilIdle()
+
+ verify(mockFireButtonStore).storeLastFireButtonClearEventTime(any())
+ verify(mockPixel).enqueueFire(PRODUCT_TELEMETRY_SURFACE_DATA_CLEARING_DAILY)
+ }
+
+ @Test
+ fun `when delete clicked on same day then daily pixel is not fired again`() = runTest {
+ val today = "2025-12-15"
+ whenever(mockFireButtonStore.lastEventSendTime).thenReturn(today)
+ whenever(mockDateProvider.getUtcIsoLocalDate()).thenReturn(today)
+ testee = createViewModel()
+
+ testee.onDeleteClicked()
+
+ coroutineTestRule.testScope.testScheduler.advanceUntilIdle()
+
+ verify(mockPixel).enqueueFire(FIRE_DIALOG_CLEAR_PRESSED)
+ verify(mockPixel).enqueueFire(PRODUCT_TELEMETRY_SURFACE_DATA_CLEARING)
+ verify(mockPixel, never()).enqueueFire(PRODUCT_TELEMETRY_SURFACE_DATA_CLEARING_DAILY)
+ }
+
+ @Test
+ fun `when delete clicked on different day then daily pixel is fired and timestamp is updated`() = runTest {
+ val yesterday = "2025-12-14"
+ val today = "2025-12-15"
+ whenever(mockFireButtonStore.lastEventSendTime).thenReturn(yesterday)
+ whenever(mockDateProvider.getUtcIsoLocalDate()).thenReturn(today)
+ testee = createViewModel()
+
+ testee.onDeleteClicked()
+
+ coroutineTestRule.testScope.testScheduler.advanceUntilIdle()
+
+ verify(mockFireButtonStore).storeLastFireButtonClearEventTime(any())
+ verify(mockPixel).enqueueFire(PRODUCT_TELEMETRY_SURFACE_DATA_CLEARING_DAILY)
+ }
+
+ @Test
+ fun `when delete clicked with animation enabled then play animation command is sent`() = runTest {
+ whenever(mockSettingsDataStore.fireAnimationEnabled).thenReturn(true)
+ testee = createViewModel()
+
+ testee.commands().test {
+ testee.onDeleteClicked()
+
+ coroutineTestRule.testScope.testScheduler.advanceUntilIdle()
+
+ assertEquals(Command.OnClearStarted, awaitItem())
+ assertEquals(Command.PlayAnimation, awaitItem())
+ assertEquals(Command.ClearingComplete, awaitItem())
+
+ cancelAndConsumeRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `when delete clicked with animation disabled then play animation command is not sent`() = runTest {
+ whenever(mockSettingsDataStore.fireAnimationEnabled).thenReturn(false)
+ testee = createViewModel()
+
+ testee.commands().test {
+ testee.onDeleteClicked()
+
+ coroutineTestRule.testScope.testScheduler.advanceUntilIdle()
+
+ assertEquals(Command.OnClearStarted, awaitItem())
+ assertEquals(Command.ClearingComplete, awaitItem())
+
+ cancelAndConsumeRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `when delete clicked then data is cleared`() = runTest {
+ testee = createViewModel()
+
+ testee.onDeleteClicked()
+
+ coroutineTestRule.testScope.testScheduler.advanceUntilIdle()
+
+ verify(mockDataClearing).clearDataUsingManualFireOptions()
+ }
+
+ @Test
+ fun `when delete clicked then fire button use count is incremented`() = runTest {
+ testee = createViewModel()
+
+ testee.onDeleteClicked()
+
+ coroutineTestRule.testScope.testScheduler.advanceUntilIdle()
+
+ verify(mockFireButtonStore).incrementFireButtonUseCount()
+ }
+
+ @Test
+ fun `when delete clicked then user event is registered`() = runTest {
+ testee = createViewModel()
+
+ testee.onDeleteClicked()
+
+ coroutineTestRule.testScope.testScheduler.advanceUntilIdle()
+
+ verify(mockUserEventsStore).registerUserEvent(UserEventKey.FIRE_BUTTON_EXECUTED)
+ }
+
+ @Test
+ fun `when delete clicked then clearing complete command is sent`() = runTest {
+ testee = createViewModel()
+
+ testee.commands().test {
+ testee.onDeleteClicked()
+
+ coroutineTestRule.testScope.testScheduler.advanceUntilIdle()
+
+ awaitItem() // Skip OnClearStarted
+ awaitItem() // Skip PlayAnimation
+
+ assertEquals(Command.ClearingComplete, awaitItem())
+
+ cancelAndConsumeRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `when delete clicked with multiple options then all operations execute in correct order`() = runTest {
+ whenever(mockSettingsDataStore.fireAnimationEnabled).thenReturn(true)
+ testee = createViewModel()
+ selectedOptionsFlow.value = setOf(FireClearOption.TABS, FireClearOption.DATA, FireClearOption.DUCKAI_CHATS)
+
+ testee.commands().test {
+ testee.onDeleteClicked()
+
+ coroutineTestRule.testScope.testScheduler.advanceUntilIdle()
+
+ verify(mockPixel).enqueueFire(FIRE_DIALOG_CLEAR_PRESSED)
+ verify(mockPixel).enqueueFire(PRODUCT_TELEMETRY_SURFACE_DATA_CLEARING)
+ verify(mockPixel).enqueueFire(
+ pixel = FIRE_DIALOG_ANIMATION,
+ parameters = mapOf(FIRE_ANIMATION to Pixel.PixelValues.FIRE_ANIMATION_INFERNO),
+ )
+
+ assertEquals(Command.OnClearStarted, awaitItem())
+ assertEquals(Command.PlayAnimation, awaitItem())
+
+ verify(mockFireButtonStore).incrementFireButtonUseCount()
+ verify(mockUserEventsStore).registerUserEvent(UserEventKey.FIRE_BUTTON_EXECUTED)
+ verify(mockDataClearing).clearDataUsingManualFireOptions()
+
+ assertEquals(Command.ClearingComplete, awaitItem())
+
+ cancelAndConsumeRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `when onShow called then OnShow command is sent`() = runTest {
+ testee = createViewModel()
+
+ testee.commands().test {
+ testee.onShow()
+
+ coroutineTestRule.testScope.testScheduler.advanceUntilIdle()
+
+ assertEquals(Command.OnShow, awaitItem())
+
+ cancelAndConsumeRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `when onCancel called then OnCancel command is sent`() = runTest {
+ testee = createViewModel()
+
+ testee.commands().test {
+ testee.onCancel()
+
+ coroutineTestRule.testScope.testScheduler.advanceUntilIdle()
+
+ assertEquals(Command.OnCancel, awaitItem())
+
+ cancelAndConsumeRemainingEvents()
+ }
+ }
+}
diff --git a/common/common-utils/src/main/java/com/duckduckgo/common/utils/DateProvider.kt b/common/common-utils/src/main/java/com/duckduckgo/common/utils/DateProvider.kt
new file mode 100644
index 000000000000..a24c2e351988
--- /dev/null
+++ b/common/common-utils/src/main/java/com/duckduckgo/common/utils/DateProvider.kt
@@ -0,0 +1,42 @@
+/*
+ * 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.common.utils
+
+import com.duckduckgo.di.scopes.AppScope
+import com.squareup.anvil.annotations.ContributesBinding
+import java.time.Instant
+import java.time.ZoneOffset
+import java.time.format.DateTimeFormatter
+import javax.inject.Inject
+
+/**
+ * Provider for getting the current UTC ISO local date string (YYYY-MM-dd format).
+ */
+interface DateProvider {
+ /**
+ * Returns the current UTC ISO local date string in YYYY-MM-dd format.
+ */
+ fun getUtcIsoLocalDate(): String
+}
+
+@ContributesBinding(AppScope::class)
+class RealDateProvider @Inject constructor() : DateProvider {
+ override fun getUtcIsoLocalDate(): String {
+ // returns YYYY-MM-dd
+ return Instant.now().atOffset(ZoneOffset.UTC).format(DateTimeFormatter.ISO_LOCAL_DATE)
+ }
+}
diff --git a/duckchat/duckchat-impl/src/main/res/drawable/ic_cookie_24.xml b/duckchat/duckchat-impl/src/main/res/drawable/ic_cookie_24.xml
new file mode 100644
index 000000000000..b28f4b1fe8da
--- /dev/null
+++ b/duckchat/duckchat-impl/src/main/res/drawable/ic_cookie_24.xml
@@ -0,0 +1,29 @@
+
+
+
+
+
+