diff --git a/app/src/androidTest/java/com/duckduckgo/app/global/view/ClearPersonalDataActionTest.kt b/app/src/androidTest/java/com/duckduckgo/app/global/view/ClearPersonalDataActionTest.kt index 0357ad5c82ef..7be1f7128beb 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/global/view/ClearPersonalDataActionTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/global/view/ClearPersonalDataActionTest.kt @@ -27,7 +27,9 @@ import com.duckduckgo.app.fire.UnsentForgetAllPixelStore import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteEntity import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteRepository import com.duckduckgo.app.settings.db.SettingsDataStore +import com.duckduckgo.app.tabs.TabSwitcherAnimationFeature import com.duckduckgo.app.tabs.model.TabRepository +import com.duckduckgo.app.tabs.store.TabSwitcherDataStore import com.duckduckgo.cookies.api.DuckDuckGoCookieManager import com.duckduckgo.history.api.NavigationHistory import com.duckduckgo.privacyprotectionspopup.api.PrivacyProtectionsPopupDataClearer @@ -38,6 +40,7 @@ import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Test import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify @@ -63,6 +66,10 @@ class ClearPersonalDataActionTest { private val mockSitePermissionsManager: SitePermissionsManager = mock() private val mockPrivacyProtectionsPopupDataClearer: PrivacyProtectionsPopupDataClearer = mock() private val mockNavigationHistory: NavigationHistory = mock() + private val mockTabSwitcherAnimationFeature: TabSwitcherAnimationFeature = mock { + on { self() } doReturn mock() + } + private val mockTabSwitcherDataStore: TabSwitcherDataStore = mock() private val fireproofWebsites: LiveData> = MutableLiveData() @@ -84,6 +91,8 @@ class ClearPersonalDataActionTest { privacyProtectionsPopupDataClearer = mockPrivacyProtectionsPopupDataClearer, sitePermissionsManager = mockSitePermissionsManager, navigationHistory = mockNavigationHistory, + tabSwitcherAnimationFeature = mockTabSwitcherAnimationFeature, + tabSwitcherDataStore = mockTabSwitcherDataStore, ) whenever(mockFireproofWebsiteRepository.getFireproofWebsites()).thenReturn(fireproofWebsites) whenever(mockDeviceSyncState.isUserSignedInOnDevice()).thenReturn(true) @@ -155,4 +164,21 @@ class ClearPersonalDataActionTest { testee.clearTabsAndAllDataAsync(appInForeground = false, shouldFireDataClearPixel = false) verify(mockPrivacyProtectionsPopupDataClearer).clearPersonalData() } + + @Test + fun whenClearCalledAndAnimationTileEnabledThenAnimationTileIsReset() = runTest { + whenever(mockTabSwitcherAnimationFeature.self().isEnabled(any())).thenReturn(true) + + testee.clearTabsAndAllDataAsync(appInForeground = false, shouldFireDataClearPixel = false) + verify(mockTabSwitcherDataStore, never()).setIsAnimationTileDismissed(false) + verify(mockTabSwitcherDataStore).setAnimationTileSeen(false) + } + + @Test + fun whenClearCalledAndAnimationTileDisabledThenAnimationTileIsNotReset() = runTest { + whenever(mockTabSwitcherAnimationFeature.self().isEnabled(any())).thenReturn(false) + + testee.clearTabsAndAllDataAsync(appInForeground = false, shouldFireDataClearPixel = false) + verifyNoInteractions(mockTabSwitcherDataStore) + } } diff --git a/app/src/internal/java/com/duckduckgo/app/dev/settings/DevSettingsActivity.kt b/app/src/internal/java/com/duckduckgo/app/dev/settings/DevSettingsActivity.kt index 2012518ef5c6..c7146d79f1e3 100644 --- a/app/src/internal/java/com/duckduckgo/app/dev/settings/DevSettingsActivity.kt +++ b/app/src/internal/java/com/duckduckgo/app/dev/settings/DevSettingsActivity.kt @@ -109,6 +109,7 @@ class DevSettingsActivity : DuckDuckGoActivity() { binding.customTabs.setOnClickListener { viewModel.customTabsClicked() } binding.notifications.setOnClickListener { viewModel.notificationsClicked() } binding.tabs.setOnClickListener { viewModel.tabsClicked() } + binding.showTabSwitcherAnimatedTile.setOnClickListener { viewModel.showAnimatedTileClicked() } } private fun observeViewModel() { @@ -139,6 +140,7 @@ class DevSettingsActivity : DuckDuckGoActivity() { is CustomTabs -> showCustomTabs() Notifications -> showNotifications() Tabs -> showTabs() + is Command.Toast -> showToast(it.message) } } @@ -187,6 +189,10 @@ class DevSettingsActivity : DuckDuckGoActivity() { startActivity(DevTabsActivity.intent(this)) } + private fun showToast(message: String) { + Toast.makeText(this, message, Toast.LENGTH_SHORT).show() + } + companion object { fun intent(context: Context): Intent { return Intent(context, DevSettingsActivity::class.java) diff --git a/app/src/internal/java/com/duckduckgo/app/dev/settings/DevSettingsViewModel.kt b/app/src/internal/java/com/duckduckgo/app/dev/settings/DevSettingsViewModel.kt index acce1e3e5d0d..744fcce90b0a 100644 --- a/app/src/internal/java/com/duckduckgo/app/dev/settings/DevSettingsViewModel.kt +++ b/app/src/internal/java/com/duckduckgo/app/dev/settings/DevSettingsViewModel.kt @@ -22,6 +22,7 @@ import com.duckduckgo.anvil.annotations.ContributesViewModel import com.duckduckgo.app.dev.settings.db.DevSettingsDataStore import com.duckduckgo.app.dev.settings.db.UAOverride import com.duckduckgo.app.survey.api.SurveyEndpointDataStore +import com.duckduckgo.app.tabs.store.TabSwitcherPrefsDataStore import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.ActivityScope import com.duckduckgo.savedsites.api.SavedSitesRepository @@ -45,6 +46,7 @@ class DevSettingsViewModel @Inject constructor( private val savedSitesRepository: SavedSitesRepository, private val dispatcherProvider: DispatcherProvider, private val surveyEndpointDataStore: SurveyEndpointDataStore, + private val tabSwitcherPrefsDataStore: TabSwitcherPrefsDataStore, ) : ViewModel() { data class ViewState( @@ -62,6 +64,7 @@ class DevSettingsViewModel @Inject constructor( object CustomTabs : Command() data object Notifications : Command() data object Tabs : Command() + data class Toast(val message: String) : Command() } private val viewState = MutableStateFlow(ViewState()) @@ -147,4 +150,12 @@ class DevSettingsViewModel @Inject constructor( fun tabsClicked() { viewModelScope.launch { command.send(Command.Tabs) } } + + fun showAnimatedTileClicked() { + viewModelScope.launch { + tabSwitcherPrefsDataStore.setIsAnimationTileDismissed(isDismissed = false) + tabSwitcherPrefsDataStore.setAnimationTileSeen(isSeen = false) + command.send(Command.Toast("Animated tile dismissal has been reset")) + } + } } diff --git a/app/src/internal/res/layout/activity_dev_settings.xml b/app/src/internal/res/layout/activity_dev_settings.xml index a461c1421792..b3f3f52f1340 100644 --- a/app/src/internal/res/layout/activity_dev_settings.xml +++ b/app/src/internal/res/layout/activity_dev_settings.xml @@ -102,6 +102,13 @@ app:primaryText="@string/devSettingsScreenTabs" app:secondaryText="@string/devSettingsScreenTabsSubtitle" /> + + Firefox Default UA WebView + Reset TabSwitcher Animated Tile + Click here to reset the animated tile if you have previously dismissed it Tabs diff --git a/app/src/main/java/com/duckduckgo/app/di/PrivacyModule.kt b/app/src/main/java/com/duckduckgo/app/di/PrivacyModule.kt index 3a01a4feacfc..933541cb5777 100644 --- a/app/src/main/java/com/duckduckgo/app/di/PrivacyModule.kt +++ b/app/src/main/java/com/duckduckgo/app/di/PrivacyModule.kt @@ -36,7 +36,9 @@ import com.duckduckgo.app.location.data.LocationPermissionsDao import com.duckduckgo.app.location.data.LocationPermissionsRepository import com.duckduckgo.app.location.data.LocationPermissionsRepositoryImpl import com.duckduckgo.app.settings.db.SettingsDataStore +import com.duckduckgo.app.tabs.TabSwitcherAnimationFeature import com.duckduckgo.app.tabs.model.TabRepository +import com.duckduckgo.app.tabs.store.TabSwitcherPrefsDataStore import com.duckduckgo.app.trackerdetection.EntityLookup import com.duckduckgo.app.trackerdetection.TdsEntityLookup import com.duckduckgo.app.trackerdetection.db.TdsDomainEntityDao @@ -84,6 +86,8 @@ object PrivacyModule { privacyProtectionsPopupDataClearer: PrivacyProtectionsPopupDataClearer, navigationHistory: NavigationHistory, dispatcherProvider: DispatcherProvider, + tabSwitcherAnimationFeature: TabSwitcherAnimationFeature, + tabSwitcherPrefsDataStore: TabSwitcherPrefsDataStore, ): ClearDataAction { return ClearPersonalDataAction( context, @@ -102,6 +106,8 @@ object PrivacyModule { privacyProtectionsPopupDataClearer, navigationHistory, dispatcherProvider, + tabSwitcherAnimationFeature, + tabSwitcherPrefsDataStore, ) } diff --git a/app/src/main/java/com/duckduckgo/app/global/view/ClearPersonalDataAction.kt b/app/src/main/java/com/duckduckgo/app/global/view/ClearPersonalDataAction.kt index e78546ffd9c9..ec2f2c6381e6 100644 --- a/app/src/main/java/com/duckduckgo/app/global/view/ClearPersonalDataAction.kt +++ b/app/src/main/java/com/duckduckgo/app/global/view/ClearPersonalDataAction.kt @@ -29,7 +29,9 @@ import com.duckduckgo.app.fire.FireActivity import com.duckduckgo.app.fire.UnsentForgetAllPixelStore import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteRepository import com.duckduckgo.app.settings.db.SettingsDataStore +import com.duckduckgo.app.tabs.TabSwitcherAnimationFeature import com.duckduckgo.app.tabs.model.TabRepository +import com.duckduckgo.app.tabs.store.TabSwitcherDataStore import com.duckduckgo.common.utils.DefaultDispatcherProvider import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.cookies.api.DuckDuckGoCookieManager @@ -73,6 +75,8 @@ class ClearPersonalDataAction( private val privacyProtectionsPopupDataClearer: PrivacyProtectionsPopupDataClearer, private val navigationHistory: NavigationHistory, private val dispatchers: DispatcherProvider = DefaultDispatcherProvider(), + private val tabSwitcherAnimationFeature: TabSwitcherAnimationFeature, + private val tabSwitcherDataStore: TabSwitcherDataStore, ) : ClearDataAction { override fun killAndRestartProcess(notifyDataCleared: Boolean, enableTransitionAnimation: Boolean) { @@ -121,6 +125,9 @@ class ClearPersonalDataAction( dataManager.clearWebViewSessions() tabRepository.deleteAll() adClickManager.clearAll() + if (tabSwitcherAnimationFeature.self().isEnabled()) { + tabSwitcherDataStore.setAnimationTileSeen(isSeen = false) + } setAppUsedSinceLastClearFlag(appInForeground) Timber.d("Finished clearing tabs") } diff --git a/app/src/main/java/com/duckduckgo/app/tabs/store/TabSwitcherDataStore.kt b/app/src/main/java/com/duckduckgo/app/tabs/store/TabSwitcherDataStore.kt index 587bdb144254..83621c622842 100644 --- a/app/src/main/java/com/duckduckgo/app/tabs/store/TabSwitcherDataStore.kt +++ b/app/src/main/java/com/duckduckgo/app/tabs/store/TabSwitcherDataStore.kt @@ -18,6 +18,7 @@ package com.duckduckgo.app.tabs.store import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey import com.duckduckgo.app.tabs.model.TabSwitcherData @@ -34,6 +35,10 @@ interface TabSwitcherDataStore { suspend fun setUserState(userState: UserState) suspend fun setTabLayoutType(layoutType: LayoutType) + fun isAnimationTileDismissed(): Flow + suspend fun setIsAnimationTileDismissed(isDismissed: Boolean) + fun hasAnimationTileBeenSeen(): Flow + suspend fun setAnimationTileSeen(isSeen: Boolean) } @ContributesBinding(AppScope::class) @@ -43,6 +48,8 @@ class TabSwitcherPrefsDataStore @Inject constructor( companion object { const val KEY_USER_STATE = "KEY_USER_STATE" const val KEY_LAYOUT_TYPE = "KEY_LAYOUT_TYPE" + const val KEY_IS_ANIMATION_TILE_DISMISSED = "KEY_IS_ANIMATION_TILE_DISMISSED" + const val KEY_HAS_ANIMATION_TILE_BEEN_SEEN = "KEY_HAS_ANIMATION_TILE_BEEN_SEEN" } override val data: Flow = store.data.map { preferences -> @@ -63,4 +70,28 @@ class TabSwitcherPrefsDataStore @Inject constructor( preferences[stringPreferencesKey(KEY_LAYOUT_TYPE)] = layoutType.name } } + + override fun isAnimationTileDismissed(): Flow { + return store.data.map { preferences -> + preferences[booleanPreferencesKey(KEY_IS_ANIMATION_TILE_DISMISSED)] ?: false + } + } + + override suspend fun setIsAnimationTileDismissed(isDismissed: Boolean) { + store.edit { preferences -> + preferences[booleanPreferencesKey(KEY_IS_ANIMATION_TILE_DISMISSED)] = isDismissed + } + } + + override fun hasAnimationTileBeenSeen(): Flow { + return store.data.map { preferences -> + preferences[booleanPreferencesKey(KEY_HAS_ANIMATION_TILE_BEEN_SEEN)] ?: false + } + } + + override suspend fun setAnimationTileSeen(isSeen: Boolean) { + store.edit { preferences -> + preferences[booleanPreferencesKey(KEY_HAS_ANIMATION_TILE_BEEN_SEEN)] = isSeen + } + } } 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 2cfc82df10a6..1fbb750dfcb9 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 @@ -156,6 +156,12 @@ class TabSwitcherActivity : DuckDuckGoActivity(), TabSwitcherListener, Coroutine firstTimeLoadingTabsList = savedInstanceState?.getBoolean(KEY_FIRST_TIME_LOADING) ?: true + if (tabSwitcherAnimationFeature.self().isEnabled()) { + tabsAdapter.setAnimationTileCloseClickListener { + viewModel.onTrackerAnimationTileCloseClicked() + } + } + extractIntentExtras() configureViewReferences() setupToolbar(toolbar) diff --git a/app/src/main/java/com/duckduckgo/app/tabs/ui/TabSwitcherAdapter.kt b/app/src/main/java/com/duckduckgo/app/tabs/ui/TabSwitcherAdapter.kt index d6f396dfc062..713fd48daab8 100644 --- a/app/src/main/java/com/duckduckgo/app/tabs/ui/TabSwitcherAdapter.kt +++ b/app/src/main/java/com/duckduckgo/app/tabs/ui/TabSwitcherAdapter.kt @@ -77,6 +77,7 @@ class TabSwitcherAdapter( private val list = mutableListOf() private var isDragging: Boolean = false private var layoutType: LayoutType = LayoutType.GRID + private var onAnimationTileCloseClickListener: (() -> Unit)? = null init { setHasStableIds(true) @@ -147,7 +148,7 @@ class TabSwitcherAdapter( trackerTextView = holder.binding.text, ) holder.binding.close.setOnClickListener { - // TODO delete + onAnimationTileCloseClickListener?.invoke() } } is TabSwitcherViewHolder.ListTrackerAnimationTileViewHolder -> { @@ -160,7 +161,7 @@ class TabSwitcherAdapter( trackerTextView = holder.binding.title, ) holder.binding.close.setOnClickListener { - // TODO delete + onAnimationTileCloseClickListener?.invoke() } } else -> throw IllegalArgumentException("Unknown ViewHolder type: $holder") @@ -383,6 +384,10 @@ class TabSwitcherAdapter( notifyDataSetChanged() } + fun setAnimationTileCloseClickListener(onClick: () -> Unit) { + onAnimationTileCloseClickListener = onClick + } + companion object { private const val DUCKDUCKGO_TITLE_SUFFIX = "at DuckDuckGo" } diff --git a/app/src/main/java/com/duckduckgo/app/tabs/ui/TabSwitcherTileAnimationMonitor.kt b/app/src/main/java/com/duckduckgo/app/tabs/ui/TabSwitcherTileAnimationMonitor.kt new file mode 100644 index 000000000000..2db708635fef --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/tabs/ui/TabSwitcherTileAnimationMonitor.kt @@ -0,0 +1,56 @@ +/* + * 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.tabs.ui + +import com.duckduckgo.app.tabs.model.TabDataRepository +import com.duckduckgo.app.tabs.store.TabSwitcherDataStore +import com.duckduckgo.app.trackerdetection.api.WebTrackersBlockedAppRepository +import com.duckduckgo.common.utils.DispatcherProvider +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOn + +private const val MINIMUM_TRACKER_COUNT = 10 +private const val MINIMUM_TAB_COUNT = 2 + +class TabSwitcherTileAnimationMonitor @Inject constructor( + private val dispatchProvider: DispatcherProvider, + private val tabSwitcherPrefsDataStore: TabSwitcherDataStore, + private val tabDataRepository: TabDataRepository, + private val webTrackersBlockedAppRepository: WebTrackersBlockedAppRepository, +) { + + fun observeAnimationTileVisibility(): Flow = combine( + tabSwitcherPrefsDataStore.isAnimationTileDismissed(), + tabSwitcherPrefsDataStore.hasAnimationTileBeenSeen(), + ) { isAnimationTileDismissed, hasAnimationTileBeenSeen -> + when { + isAnimationTileDismissed -> false + hasAnimationTileBeenSeen -> true + else -> shouldDisplayAnimationTile() + } + }.flowOn(dispatchProvider.io()) + + private suspend fun shouldDisplayAnimationTile(): Boolean { + val openedTabs = tabDataRepository.getOpenTabCount() + val trackerCountForLast7Days = webTrackersBlockedAppRepository.getTrackerCountForLast7Days() + + return trackerCountForLast7Days >= MINIMUM_TRACKER_COUNT && + openedTabs >= MINIMUM_TAB_COUNT + } +} diff --git a/app/src/main/java/com/duckduckgo/app/tabs/ui/TabSwitcherViewModel.kt b/app/src/main/java/com/duckduckgo/app/tabs/ui/TabSwitcherViewModel.kt index 9a2f70afdf61..c235169b4bf7 100644 --- a/app/src/main/java/com/duckduckgo/app/tabs/ui/TabSwitcherViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/tabs/ui/TabSwitcherViewModel.kt @@ -17,6 +17,7 @@ package com.duckduckgo.app.tabs.ui import androidx.lifecycle.LiveData +import androidx.lifecycle.LiveDataScope import androidx.lifecycle.ViewModel import androidx.lifecycle.asLiveData import androidx.lifecycle.liveData @@ -34,6 +35,8 @@ import com.duckduckgo.app.tabs.model.TabEntity import com.duckduckgo.app.tabs.model.TabRepository import com.duckduckgo.app.tabs.model.TabSwitcherData.LayoutType.GRID import com.duckduckgo.app.tabs.model.TabSwitcherData.LayoutType.LIST +import com.duckduckgo.app.tabs.store.TabSwitcherPrefsDataStore +import com.duckduckgo.app.tabs.ui.TabSwitcherItem.Tab import com.duckduckgo.app.tabs.ui.TabSwitcherItem.TrackerAnimationTile import com.duckduckgo.app.trackerdetection.api.WebTrackersBlockedAppRepository import com.duckduckgo.common.utils.DispatcherProvider @@ -42,7 +45,6 @@ import com.duckduckgo.common.utils.extensions.toBinaryString import com.duckduckgo.di.scopes.ActivityScope import com.duckduckgo.duckchat.api.DuckChat import com.duckduckgo.duckchat.impl.DuckChatPixelName -import java.time.LocalDateTime import javax.inject.Inject import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.first @@ -61,19 +63,17 @@ class TabSwitcherViewModel @Inject constructor( private val duckChat: DuckChat, private val tabSwitcherAnimationFeature: TabSwitcherAnimationFeature, private val webTrackersBlockedAppRepository: WebTrackersBlockedAppRepository, + private val tabSwitcherPrefsDataStore: TabSwitcherPrefsDataStore, + private val tabSwitcherTileAnimationMonitor: TabSwitcherTileAnimationMonitor, ) : ViewModel() { val tabSwitcherItems: LiveData> = tabRepository.liveTabs.switchMap { tabEntities -> - // TODO use dismissal logic and or test framework to determine whether to show tracker animation tile - if (tabSwitcherAnimationFeature.self().isEnabled()) { - liveData { - val trackerAnimationTile = createTrackerAnimationTile() - val tabItems = tabEntities.map { TabSwitcherItem.Tab(it) } - emit(listOf(trackerAnimationTile) + tabItems) - } - } else { - liveData { - val tabItems = tabEntities.map { TabSwitcherItem.Tab(it) } + // TODO use test framework to determine whether to show tracker animation tile + liveData { + if (tabSwitcherAnimationFeature.self().isEnabled()) { + collectTabItemsWithOptionalAnimationTile(tabEntities) + } else { + val tabItems = tabEntities.map { Tab(it) } emit(tabItems) } } @@ -97,7 +97,7 @@ class TabSwitcherViewModel @Inject constructor( suspend fun onNewTabRequested(fromOverflowMenu: Boolean) { if (swipingTabsFeature.isEnabled) { - val tabItemList = tabSwitcherItems.value?.filterIsInstance() + val tabItemList = tabSwitcherItems.value?.filterIsInstance() val emptyTabItem = tabItemList?.firstOrNull { tabItem -> tabItem.tabEntity.url.isNullOrBlank() } val emptyTabId = emptyTabItem?.tabEntity?.tabId @@ -159,8 +159,10 @@ class TabSwitcherViewModel @Inject constructor( viewModelScope.launch(dispatcherProvider.io()) { tabSwitcherItems.value?.forEach { tabSwitcherItem -> when (tabSwitcherItem) { - is TabSwitcherItem.Tab -> onTabDeleted(tabSwitcherItem.tabEntity) - is TabSwitcherItem.TrackerAnimationTile -> Unit // TODO delete + is Tab -> onTabDeleted(tabSwitcherItem.tabEntity) + is TrackerAnimationTile -> { + tabSwitcherPrefsDataStore.setAnimationTileSeen(isSeen = false) + } } } // Make sure all exemptions are removed as all tabs are deleted. @@ -227,13 +229,29 @@ class TabSwitcherViewModel @Inject constructor( } } - private suspend fun createTrackerAnimationTile(): TrackerAnimationTile { - val now = LocalDateTime.now() - val trackerCount = - webTrackersBlockedAppRepository.getTrackersCountBetween( - startTime = now.minusDays(7), - endTime = now, - ) - return TrackerAnimationTile(trackerCount) + fun onTrackerAnimationTileCloseClicked() { + viewModelScope.launch(dispatcherProvider.io()) { + tabSwitcherPrefsDataStore.setIsAnimationTileDismissed(isDismissed = true) + } + } + + private suspend fun LiveDataScope>.collectTabItemsWithOptionalAnimationTile( + tabEntities: List, + ) { + tabSwitcherTileAnimationMonitor.observeAnimationTileVisibility().collect { isVisible -> + val tabItems = tabEntities.map { Tab(it) } + + val tabSwitcherItems = if (isVisible) { + if (tabSwitcherItems.value?.first() !is TrackerAnimationTile) { + tabSwitcherPrefsDataStore.setAnimationTileSeen(isSeen = true) + } + val trackerCountForLast7Days = webTrackersBlockedAppRepository.getTrackerCountForLast7Days() + + listOf(TrackerAnimationTile(trackerCountForLast7Days)) + tabItems + } else { + tabItems + } + emit(tabSwitcherItems) + } } } diff --git a/app/src/main/java/com/duckduckgo/app/tabs/ui/TrackerCountAnimator.kt b/app/src/main/java/com/duckduckgo/app/tabs/ui/TrackerCountAnimator.kt index dcb3795b8c20..e5ac246e66fb 100644 --- a/app/src/main/java/com/duckduckgo/app/tabs/ui/TrackerCountAnimator.kt +++ b/app/src/main/java/com/duckduckgo/app/tabs/ui/TrackerCountAnimator.kt @@ -28,11 +28,11 @@ import javax.inject.Inject import kotlin.math.roundToInt import kotlin.time.Duration.Companion.seconds -private const val TRACKER_COUNT_LOWER_THRESHOLD_PERCENTAGE = 0.25f -private const val TRACKER_COUNT_UPPER_THRESHOLD_PERCENTAGE = 0.75f +private const val TRACKER_COUNT_LOWER_THRESHOLD_PERCENTAGE = 0.75f +private const val TRACKER_COUNT_UPPER_THRESHOLD_PERCENTAGE = 0.85f private const val TRACKER_COUNT_UPPER_THRESHOLD = 40 private const val TRACKER_TOTAL_MAX_LIMIT = 9999 -private val TRACKER_COUNT_LOWER_THRESHOLD_ANIMATION_DURATION = 0.5.seconds +private val TRACKER_COUNT_LOWER_THRESHOLD_ANIMATION_DURATION = 1.seconds private val TRACKER_COUNT_UPPER_THRESHOLD_ANIMATION_DURATION = 1.seconds class TrackerCountAnimator @Inject constructor() { diff --git a/app/src/main/java/com/duckduckgo/app/trackerdetection/api/WebTrackersBlockedAppRepository.kt b/app/src/main/java/com/duckduckgo/app/trackerdetection/api/WebTrackersBlockedAppRepository.kt index 798e65f5cdfb..3846867f2cb3 100644 --- a/app/src/main/java/com/duckduckgo/app/trackerdetection/api/WebTrackersBlockedAppRepository.kt +++ b/app/src/main/java/com/duckduckgo/app/trackerdetection/api/WebTrackersBlockedAppRepository.kt @@ -42,7 +42,14 @@ class WebTrackersBlockedAppRepository @Inject constructor(appDatabase: AppDataba } // TODO move to public API if experiment kept - suspend fun getTrackersCountBetween( + suspend fun getTrackerCountForLast7Days(): Int { + return getTrackersCountBetween( + startTime = LocalDateTime.now().minusDays(7), + endTime = LocalDateTime.now(), + ) + } + + private suspend fun getTrackersCountBetween( startTime: LocalDateTime, endTime: LocalDateTime, ): Int = dao.getTrackersCountBetween( diff --git a/app/src/main/res/drawable/background_animated_tile.xml b/app/src/main/res/drawable/background_animated_tile.xml new file mode 100644 index 000000000000..ae44256962a1 --- /dev/null +++ b/app/src/main/res/drawable/background_animated_tile.xml @@ -0,0 +1,22 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_grid_tracker_animation_tile.xml b/app/src/main/res/layout/item_grid_tracker_animation_tile.xml index 933550625671..d1da2178bb82 100644 --- a/app/src/main/res/layout/item_grid_tracker_animation_tile.xml +++ b/app/src/main/res/layout/item_grid_tracker_animation_tile.xml @@ -19,12 +19,12 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_margin="@dimen/keyline_2" - app:cardBackgroundColor="@color/green0" app:cardCornerRadius="10dp" tools:ignore="InvalidColorAttribute"> @@ -32,13 +32,14 @@ android:id="@+id/close" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:background="@drawable/selectable_circular_ripple" + android:background="@drawable/selectable_circular_ripple_dark" android:contentDescription="@string/closeContentDescription" android:padding="@dimen/keyline_2" android:scaleType="center" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toTopOf="parent" - app:srcCompat="@drawable/ic_close_24_small" /> + app:srcCompat="@drawable/ic_close_24_small" + app:tint="?attr/daxColorBlack" /> + app:lottie_rawRes="@raw/shield_tabswitcher" /> @@ -31,6 +30,7 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:paddingStart="0dp" + android:background="@drawable/background_animated_tile" android:paddingTop="@dimen/twoLineItemVerticalPadding" android:paddingEnd="@dimen/twoLineItemVerticalPadding" android:paddingBottom="@dimen/twoLineItemVerticalPadding"> @@ -45,7 +45,7 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="@+id/title" app:lottie_autoPlay="true" - app:lottie_rawRes="@raw/tab_shield" /> + app:lottie_rawRes="@raw/shield_tabswitcher" /> + tools:ignore="DeprecatedWidgetInXml,SpUsage,UnusedAttribute" + tools:text="93 Trackers blocked" + tools:textStyle="bold" /> + tools:ignore="DeprecatedWidgetInXml,SpUsage,UnusedAttribute" /> + get() = flowOf() // No-op + + override suspend fun setUserState(userState: UserState) { + // No-op + } + + override suspend fun setTabLayoutType(layoutType: LayoutType) { + // No-op + } + + override fun isAnimationTileDismissed(): Flow = _isAnimationTileDismissedFlow + + override suspend fun setIsAnimationTileDismissed(isDismissed: Boolean) { + _isAnimationTileDismissedFlow.value = isDismissed + } + + override fun hasAnimationTileBeenSeen(): Flow = _hasAnimationTileBeenSeenFlow + + override suspend fun setAnimationTileSeen(isSeen: Boolean) { + _hasAnimationTileBeenSeenFlow.value = true + } + } +} diff --git a/app/src/test/java/com/duckduckgo/app/tabs/ui/TabSwitcherViewModelTest.kt b/app/src/test/java/com/duckduckgo/app/tabs/ui/TabSwitcherViewModelTest.kt index f4271bf63a59..a2986bfbd720 100644 --- a/app/src/test/java/com/duckduckgo/app/tabs/ui/TabSwitcherViewModelTest.kt +++ b/app/src/test/java/com/duckduckgo/app/tabs/ui/TabSwitcherViewModelTest.kt @@ -37,6 +37,7 @@ import com.duckduckgo.app.tabs.model.TabSwitcherData.LayoutType.GRID import com.duckduckgo.app.tabs.model.TabSwitcherData.LayoutType.LIST import com.duckduckgo.app.tabs.model.TabSwitcherData.UserState.EXISTING import com.duckduckgo.app.tabs.model.TabSwitcherData.UserState.NEW +import com.duckduckgo.app.tabs.store.TabSwitcherPrefsDataStore import com.duckduckgo.app.tabs.ui.TabSwitcherViewModel.Command import com.duckduckgo.app.trackerdetection.api.WebTrackersBlockedAppRepository import com.duckduckgo.common.test.CoroutineTestRule @@ -102,6 +103,12 @@ class TabSwitcherViewModelTest { @Mock private lateinit var mockWebTrackersBlockedAppRepository: WebTrackersBlockedAppRepository + @Mock + private lateinit var mockTabSwitcherPrefsDataStore: TabSwitcherPrefsDataStore + + @Mock + private lateinit var mockTabSwitcherTileAnimationMonitor: TabSwitcherTileAnimationMonitor + private val swipingTabsFeature = FakeFeatureToggleFactory.create(SwipingTabsFeature::class.java) private val tabSwitcherAnimationFeature = FakeFeatureToggleFactory.create(TabSwitcherAnimationFeature::class.java) @@ -146,6 +153,8 @@ class TabSwitcherViewModelTest { duckChatMock, tabSwitcherAnimationFeature, mockWebTrackersBlockedAppRepository, + mockTabSwitcherPrefsDataStore, + mockTabSwitcherTileAnimationMonitor, ) testee.command.observeForever(mockCommandObserver) } @@ -429,4 +438,65 @@ class TabSwitcherViewModelTest { verify(mockPixel).fire(DuckChatPixelName.DUCK_CHAT_OPEN_NEW_TAB_MENU, mapOf("was_used_before" to "1")) verify(duckChatMock).openDuckChat() } + + @Test + fun `when Animation Tile Visible then Tab Switcher Items Include Animation Tile And Tabs`() = runTest { + tabSwitcherAnimationFeature.self().setRawStoredState(State(enable = true)) + + val tab1 = TabEntity("1", position = 1) + val tab2 = TabEntity("2", position = 2) + tabs.value = listOf(tab1, tab2) + + whenever(mockTabSwitcherTileAnimationMonitor.observeAnimationTileVisibility()).thenReturn(flowOf(true)) + whenever(mockWebTrackersBlockedAppRepository.getTrackerCountForLast7Days()).thenReturn(15) + + initializeViewModel() + + val items = testee.tabSwitcherItems.blockingObserve() ?: listOf() + + assertEquals(3, items.size) + assert(items.first() is TabSwitcherItem.TrackerAnimationTile) + assert(items[1] is TabSwitcherItem.Tab) + assert(items[2] is TabSwitcherItem.Tab) + } + + @Test + fun `when Animation Tile Not Visible then Tab Switcher Items Contain Only Tabs`() = runTest { + tabSwitcherAnimationFeature.self().setRawStoredState(State(enable = true)) + + val tab1 = TabEntity("1", position = 1) + val tab2 = TabEntity("2", position = 2) + tabs.value = listOf(tab1, tab2) + + whenever(mockTabSwitcherTileAnimationMonitor.observeAnimationTileVisibility()).thenReturn(flowOf(false)) + + initializeViewModel() + + val items = testee.tabSwitcherItems.blockingObserve() ?: listOf() + + assertEquals(2, items.size) + items.forEach { item -> + assert(item is TabSwitcherItem.Tab) + } + } + + @Test + fun `when Tab Switcher Animation Feature disabled then Tab Switcher Items Contain Only Tabs`() = runTest { + tabSwitcherAnimationFeature.self().setRawStoredState(State(enable = false)) + + val tab1 = TabEntity("1", position = 1) + val tab2 = TabEntity("2", position = 2) + tabs.value = listOf(tab1, tab2) + + whenever(mockTabSwitcherTileAnimationMonitor.observeAnimationTileVisibility()).thenReturn(flowOf(true)) + + initializeViewModel() + + val items = testee.tabSwitcherItems.blockingObserve() ?: listOf() + + assertEquals(2, items.size) + items.forEach { item -> + assert(item is TabSwitcherItem.Tab) + } + } } diff --git a/common/common-ui/src/main/res/drawable/selectable_circular_ripple_dark.xml b/common/common-ui/src/main/res/drawable/selectable_circular_ripple_dark.xml new file mode 100644 index 000000000000..855212c5b396 --- /dev/null +++ b/common/common-ui/src/main/res/drawable/selectable_circular_ripple_dark.xml @@ -0,0 +1,23 @@ + + + + + + + + \ No newline at end of file