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 @@ + + + + + +