diff --git a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt index 4fbaad47dcb3..d921cd9a6fb8 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt @@ -175,6 +175,7 @@ import com.duckduckgo.app.statistics.pixels.Pixel.PixelValues.DAX_FIRE_DIALOG_CT import com.duckduckgo.app.surrogates.SurrogateResponse import com.duckduckgo.app.tabs.model.TabEntity import com.duckduckgo.app.tabs.model.TabRepository +import com.duckduckgo.app.tabs.store.TabStatsBucketing import com.duckduckgo.app.trackerdetection.EntityLookup import com.duckduckgo.app.trackerdetection.model.TrackerStatus import com.duckduckgo.app.trackerdetection.model.TrackerType @@ -494,6 +495,7 @@ class BrowserTabViewModelTest { private val mockAutocompleteTabsFeature: AutocompleteTabsFeature = mock() private val fakeCustomHeadersPlugin = FakeCustomHeadersProvider(emptyMap()) private val mockBrokenSitePrompt: BrokenSitePrompt = mock() + private val mockTabStatsBucketing: TabStatsBucketing = mock() @Before fun before() = runTest { @@ -661,6 +663,7 @@ class BrowserTabViewModelTest { showOnAppLaunchOptionHandler = mockShowOnAppLaunchHandler, customHeadersProvider = fakeCustomHeadersPlugin, brokenSitePrompt = mockBrokenSitePrompt, + tabStatsBucketing = mockTabStatsBucketing, ) testee.loadData("abc", null, false, false) @@ -5442,12 +5445,32 @@ class BrowserTabViewModelTest { } @Test - fun whenUserLaunchingTabSwitcherThenLaunchTabSwitcherCommandSentAndPixelFired() { + fun whenUserLaunchingTabSwitcherThenLaunchTabSwitcherCommandSentAndPixelFired() = runTest { + val tabCount = "61-80" + val active7d = "21+" + val inactive1w = "11-20" + val inactive2w = "6-10" + val inactive3w = "0" + + whenever(mockTabStatsBucketing.getNumberOfOpenTabs()).thenReturn(tabCount) + whenever(mockTabStatsBucketing.getTabsActiveLastWeek()).thenReturn(active7d) + whenever(mockTabStatsBucketing.getTabsActiveOneWeekAgo()).thenReturn(inactive1w) + whenever(mockTabStatsBucketing.getTabsActiveTwoWeeksAgo()).thenReturn(inactive2w) + whenever(mockTabStatsBucketing.getTabsActiveMoreThanThreeWeeksAgo()).thenReturn(inactive3w) + + val params = mapOf( + PixelParameter.TAB_COUNT to tabCount, + PixelParameter.TAB_ACTIVE_7D to active7d, + PixelParameter.TAB_INACTIVE_1W to inactive1w, + PixelParameter.TAB_INACTIVE_2W to inactive2w, + PixelParameter.TAB_INACTIVE_3W to inactive3w, + ) + testee.userLaunchingTabSwitcher() assertCommandIssued() verify(mockPixel).fire(AppPixelName.TAB_MANAGER_CLICKED) - verify(mockPixel).fire(AppPixelName.TAB_MANAGER_CLICKED_DAILY, emptyMap(), emptyMap(), Daily()) + verify(mockPixel).fire(AppPixelName.TAB_MANAGER_CLICKED_DAILY, params, emptyMap(), Daily()) } @Test diff --git a/app/src/androidTest/java/com/duckduckgo/app/tabs/model/TabDataRepositoryTest.kt b/app/src/androidTest/java/com/duckduckgo/app/tabs/model/TabDataRepositoryTest.kt index 12f0256e3f3d..514ebe75a2c4 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/tabs/model/TabDataRepositoryTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/tabs/model/TabDataRepositoryTest.kt @@ -37,6 +37,8 @@ import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.common.test.InstantSchedulersRule import com.duckduckgo.duckplayer.api.DuckPlayer import com.duckduckgo.privacy.config.api.ContentBlocking +import java.time.LocalDateTime +import java.time.ZoneOffset import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.consumeAsFlow import kotlinx.coroutines.launch @@ -420,6 +422,146 @@ class TabDataRepositoryTest { job.cancel() } + @Test + fun getOpenTabCountReturnsCorrectCount() = runTest { + // Arrange: Add some tabs to the repository + whenever(mockDao.tabs()).thenReturn( + listOf( + TabEntity(tabId = "tab1"), + TabEntity(tabId = "tab2"), + TabEntity(tabId = "tab3"), + ), + ) + val testee = tabDataRepository() + + val openTabCount = testee.getOpenTabCount() + + // Assert: Verify the count is correct + assertEquals(3, openTabCount) + } + + @Test + fun getActiveTabCountReturnsZeroWhenNoTabs() = runTest { + // Arrange: No tabs in the repository + whenever(mockDao.tabs()).thenReturn(emptyList()) + val testee = tabDataRepository() + + val inactiveTabCount = testee.countTabsAccessedWithinRange(0, 7) + + // Assert: Verify the count is zero + assertEquals(0, inactiveTabCount) + } + + @Test + fun getActiveTabCountReturnsZeroWhenNullTabs() = runTest { + // Arrange: Only null tabs in the repository + val tab1 = TabEntity(tabId = "tab1") + whenever(mockDao.tabs()).thenReturn(listOf(tab1)) + val testee = tabDataRepository() + + val inactiveTabCount = testee.countTabsAccessedWithinRange(0, 7) + + // Assert: Verify the count is zero + assertEquals(0, inactiveTabCount) + } + + @Test + fun getActiveTabCountReturnsCorrectCountWhenTabsYoungerThanSpecifiedDay() = runTest { + // Arrange: No tabs in the repository + val now = LocalDateTime.now(ZoneOffset.UTC) + val tab1 = TabEntity(tabId = "tab1", lastAccessTime = now.minusDays(6)) + val tab2 = TabEntity(tabId = "tab2", lastAccessTime = now.minusDays(8)) + val tab3 = TabEntity(tabId = "tab3", lastAccessTime = now.minusDays(10)) + val tab4 = TabEntity(tabId = "tab4") + whenever(mockDao.tabs()).thenReturn(listOf(tab1, tab2, tab3, tab4)) + val testee = tabDataRepository() + + val inactiveTabCount = testee.countTabsAccessedWithinRange(0, 9) + + // Assert: Verify the count is 2 + assertEquals(2, inactiveTabCount) + } + + @Test + fun getInactiveTabCountReturnsZeroWhenNoTabs() = runTest { + // Arrange: No tabs in the repository + whenever(mockDao.tabs()).thenReturn(emptyList()) + val testee = tabDataRepository() + + val inactiveTabCount = testee.countTabsAccessedWithinRange(7, 12) + + // Assert: Verify the count is zero + assertEquals(0, inactiveTabCount) + } + + @Test + fun getInactiveTabCountReturnsCorrectCountWhenAllTabsOlderThanSpecifiedDay() = runTest { + // Arrange: Add some tabs with different last access times + val now = LocalDateTime.now(ZoneOffset.UTC) + val tab1 = TabEntity(tabId = "tab1", lastAccessTime = now.minusDays(8)) + val tab2 = TabEntity(tabId = "tab2", lastAccessTime = now.minusDays(10)) + val tab3 = TabEntity(tabId = "tab3", lastAccessTime = now.minusDays(9)) + val tab4 = TabEntity(tabId = "tab4") + whenever(mockDao.tabs()).thenReturn(listOf(tab1, tab2, tab3, tab4)) + val testee = tabDataRepository() + + val inactiveTabCount = testee.countTabsAccessedWithinRange(9) + + // Assert: Verify the count is correct + assertEquals(2, inactiveTabCount) + } + + @Test + fun getInactiveTabCountReturnsCorrectCountWhenAllTabsInactiveWithinRange() = runTest { + // Arrange: Add some tabs with different last access times + val now = LocalDateTime.now(ZoneOffset.UTC) + val tab1 = TabEntity(tabId = "tab1", lastAccessTime = now.minusDays(8)) + val tab2 = TabEntity(tabId = "tab2", lastAccessTime = now.minusDays(10)) + val tab3 = TabEntity(tabId = "tab3", lastAccessTime = now.minusDays(9)) + val tab4 = TabEntity(tabId = "tab4") + whenever(mockDao.tabs()).thenReturn(listOf(tab1, tab2, tab3, tab4)) + val testee = tabDataRepository() + + val inactiveTabCount = testee.countTabsAccessedWithinRange(7, 12) + + // Assert: Verify the count is correct + assertEquals(3, inactiveTabCount) + } + + @Test + fun getInactiveTabCountReturnsZeroWhenNoTabsInactiveWithinRange() = runTest { + // Arrange: Add some tabs with different last access times + val now = LocalDateTime.now(ZoneOffset.UTC) + val tab1 = TabEntity(tabId = "tab1", lastAccessTime = now.minusDays(5)) + val tab2 = TabEntity(tabId = "tab2", lastAccessTime = now.minusDays(6)) + val tab3 = TabEntity(tabId = "tab3", lastAccessTime = now.minusDays(13)) + val tab4 = TabEntity(tabId = "tab4") + whenever(mockDao.tabs()).thenReturn(listOf(tab1, tab2, tab3, tab4)) + val testee = tabDataRepository() + + val inactiveTabCount = testee.countTabsAccessedWithinRange(7, 12) + + // Assert: Verify the count is zero + assertEquals(0, inactiveTabCount) + } + + @Test + fun getInactiveTabCountReturnsCorrectCountWhenSomeTabsInactiveWithinRange() = runTest { + // Arrange: Add some tabs with different last access times + val now = LocalDateTime.now(ZoneOffset.UTC) + val tab1 = TabEntity(tabId = "tab1", lastAccessTime = now.minusDays(5)) + val tab2 = TabEntity(tabId = "tab2", lastAccessTime = now.minusDays(10)) + val tab3 = TabEntity(tabId = "tab3", lastAccessTime = now.minusDays(15)) + val tab4 = TabEntity(tabId = "tab4") + whenever(mockDao.tabs()).thenReturn(listOf(tab1, tab2, tab3, tab4)) + val testee = tabDataRepository() + + val inactiveTabCount = testee.countTabsAccessedWithinRange(7, 12) + + // Assert: Verify the count is correct + assertEquals(1, inactiveTabCount) + } + private fun tabDataRepository( dao: TabsDao = mockDatabase(), entityLookup: EntityLookup = mock(), 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 f9a1c5199cbf..200ec9a02c5e 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt @@ -272,6 +272,8 @@ open class BrowserActivity : DuckDuckGoActivity() { lastActiveTabs.add(tab.tabId) + viewModel.onTabActivated(tab.tabId) + val fragment = supportFragmentManager.findFragmentByTag(tab.tabId) as? BrowserTabFragment if (fragment == null) { openNewTab(tab.tabId, tab.url, tab.skipHome, intent?.getBooleanExtra(LAUNCH_FROM_EXTERNAL_EXTRA, false) ?: false) diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt index accbe08084aa..d43be5f95105 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt @@ -232,6 +232,7 @@ import com.duckduckgo.app.pixels.AppPixelName.AUTOCOMPLETE_SEARCH_WEBSITE_SELECT import com.duckduckgo.app.pixels.AppPixelName.ONBOARDING_DAX_CTA_CANCEL_BUTTON import com.duckduckgo.app.pixels.AppPixelName.ONBOARDING_SEARCH_CUSTOM import com.duckduckgo.app.pixels.AppPixelName.ONBOARDING_VISIT_SITE_CUSTOM +import com.duckduckgo.app.pixels.AppPixelName.TAB_MANAGER_CLICKED_DAILY import com.duckduckgo.app.pixels.remoteconfig.AndroidBrowserConfigFeature import com.duckduckgo.app.privacy.db.NetworkLeaderboardDao import com.duckduckgo.app.privacy.db.UserAllowListRepository @@ -246,6 +247,7 @@ import com.duckduckgo.app.statistics.pixels.Pixel.PixelValues.DAX_FIRE_DIALOG_CT import com.duckduckgo.app.surrogates.SurrogateResponse import com.duckduckgo.app.tabs.model.TabEntity import com.duckduckgo.app.tabs.model.TabRepository +import com.duckduckgo.app.tabs.store.TabStatsBucketing import com.duckduckgo.app.trackerdetection.model.TrackingEvent import com.duckduckgo.app.usage.search.SearchCountDao import com.duckduckgo.autofill.api.AutofillCapabilityChecker @@ -344,6 +346,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job +import kotlinx.coroutines.async import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -430,6 +433,7 @@ class BrowserTabViewModel @Inject constructor( private val showOnAppLaunchOptionHandler: ShowOnAppLaunchOptionHandler, private val customHeadersProvider: CustomHeadersProvider, private val brokenSitePrompt: BrokenSitePrompt, + private val tabStatsBucketing: TabStatsBucketing, ) : WebViewClientListener, EditSavedSiteListener, DeleteBookmarkListener, @@ -2825,7 +2829,26 @@ class BrowserTabViewModel @Inject constructor( fun userLaunchingTabSwitcher() { command.value = LaunchTabSwitcher pixel.fire(AppPixelName.TAB_MANAGER_CLICKED) - pixel.fire(AppPixelName.TAB_MANAGER_CLICKED_DAILY, emptyMap(), emptyMap(), Daily()) + fireDailyLaunchPixel() + } + + private fun fireDailyLaunchPixel() { + val tabCount = viewModelScope.async(dispatchers.io()) { tabStatsBucketing.getNumberOfOpenTabs() } + val activeTabCount = viewModelScope.async(dispatchers.io()) { tabStatsBucketing.getTabsActiveLastWeek() } + val inactive1w = viewModelScope.async(dispatchers.io()) { tabStatsBucketing.getTabsActiveOneWeekAgo() } + val inactive2w = viewModelScope.async(dispatchers.io()) { tabStatsBucketing.getTabsActiveTwoWeeksAgo() } + val inactive3w = viewModelScope.async(dispatchers.io()) { tabStatsBucketing.getTabsActiveMoreThanThreeWeeksAgo() } + + viewModelScope.launch(dispatchers.io()) { + val params = mapOf( + PixelParameter.TAB_COUNT to tabCount.await(), + PixelParameter.TAB_ACTIVE_7D to activeTabCount.await(), + PixelParameter.TAB_INACTIVE_1W to inactive1w.await(), + PixelParameter.TAB_INACTIVE_2W to inactive2w.await(), + PixelParameter.TAB_INACTIVE_3W to inactive3w.await(), + ) + pixel.fire(TAB_MANAGER_CLICKED_DAILY, params, emptyMap(), Daily()) + } } private fun isFireproofWebsite(domain: String? = site?.domain): Boolean { diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserViewModel.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserViewModel.kt index fcf92bc0d38f..b21bb639abd3 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserViewModel.kt @@ -303,6 +303,12 @@ class BrowserViewModel @Inject constructor( } } } + + fun onTabActivated(tabId: String) { + viewModelScope.launch(dispatchers.io()) { + tabRepository.updateTabLastAccess(tabId) + } + } } /** diff --git a/app/src/main/java/com/duckduckgo/app/global/db/AppDatabase.kt b/app/src/main/java/com/duckduckgo/app/global/db/AppDatabase.kt index 99985184defe..d9fad2a5e178 100644 --- a/app/src/main/java/com/duckduckgo/app/global/db/AppDatabase.kt +++ b/app/src/main/java/com/duckduckgo/app/global/db/AppDatabase.kt @@ -56,6 +56,7 @@ import com.duckduckgo.app.statistics.store.PendingPixelDao import com.duckduckgo.app.survey.db.SurveyDao import com.duckduckgo.app.survey.model.Survey import com.duckduckgo.app.tabs.db.TabsDao +import com.duckduckgo.app.tabs.model.LocalDateTimeTypeConverter import com.duckduckgo.app.tabs.model.TabEntity import com.duckduckgo.app.tabs.model.TabSelectionEntity import com.duckduckgo.app.trackerdetection.db.* @@ -72,7 +73,7 @@ import com.duckduckgo.savedsites.store.SavedSitesRelationsDao @Database( exportSchema = true, - version = 55, + version = 56, entities = [ TdsTracker::class, TdsEntity::class, @@ -122,6 +123,7 @@ import com.duckduckgo.savedsites.store.SavedSitesRelationsDao LocationPermissionTypeConverter::class, QueryParamsTypeConverter::class, EntityTypeConverter::class, + LocalDateTimeTypeConverter::class, ) abstract class AppDatabase : RoomDatabase() { @@ -674,6 +676,12 @@ class MigrationsProvider(val context: Context, val settingsDataStore: SettingsDa } } + private val MIGRATION_55_TO_56: Migration = object : Migration(55, 56) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("ALTER TABLE `tabs` ADD COLUMN `lastAccessTime` TEXT") + } + } + /** * WARNING ⚠️ * This needs to happen because Room doesn't support UNIQUE (...) ON CONFLICT REPLACE when creating the bookmarks table. @@ -754,6 +762,7 @@ class MigrationsProvider(val context: Context, val settingsDataStore: SettingsDa MIGRATION_52_TO_53, MIGRATION_53_TO_54, MIGRATION_54_TO_55, + MIGRATION_55_TO_56, ) @Deprecated( diff --git a/app/src/main/java/com/duckduckgo/app/tabs/db/TabsDao.kt b/app/src/main/java/com/duckduckgo/app/tabs/db/TabsDao.kt index caa0cb4caa9f..32cedd2d886f 100644 --- a/app/src/main/java/com/duckduckgo/app/tabs/db/TabsDao.kt +++ b/app/src/main/java/com/duckduckgo/app/tabs/db/TabsDao.kt @@ -23,6 +23,8 @@ import com.duckduckgo.app.tabs.model.TabSelectionEntity import com.duckduckgo.common.utils.swap import com.duckduckgo.di.scopes.AppScope import dagger.SingleInstanceIn +import java.time.LocalDateTime +import java.time.ZoneOffset import kotlinx.coroutines.flow.Flow @Dao @@ -165,6 +167,12 @@ abstract class TabsDao { return tabs().lastOrNull() } + @Query("update tabs set lastAccessTime=:lastAccessTime where tabId=:tabId") + abstract fun updateTabLastAccess( + tabId: String, + lastAccessTime: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), + ) + @Query("update tabs set url=:url, title=:title, viewed=:viewed where tabId=:tabId") abstract fun updateUrlAndTitle( tabId: String, diff --git a/app/src/main/java/com/duckduckgo/app/tabs/model/TabDataRepository.kt b/app/src/main/java/com/duckduckgo/app/tabs/model/TabDataRepository.kt index 0338a282c400..6e35c1172fca 100644 --- a/app/src/main/java/com/duckduckgo/app/tabs/model/TabDataRepository.kt +++ b/app/src/main/java/com/duckduckgo/app/tabs/model/TabDataRepository.kt @@ -35,6 +35,8 @@ import com.duckduckgo.di.scopes.AppScope import dagger.SingleInstanceIn import io.reactivex.Scheduler import io.reactivex.schedulers.Schedulers +import java.time.LocalDateTime +import java.time.ZoneOffset import java.util.UUID import javax.inject.Inject import kotlinx.coroutines.CoroutineScope @@ -192,6 +194,19 @@ class TabDataRepository @Inject constructor( tabSwitcherDataStore.setTabLayoutType(layoutType) } + override fun getOpenTabCount(): Int { + return tabsDao.tabs().size + } + + override fun countTabsAccessedWithinRange(accessOlderThan: Long, accessNotMoreThan: Long?): Int { + val now = LocalDateTime.now(ZoneOffset.UTC) + val start = now.minusDays(accessOlderThan) + val end = accessNotMoreThan?.let { now.minusDays(it).minusSeconds(1) } // subtracted a second to make the end limit inclusive + return tabsDao.tabs().filter { + it.lastAccessTime?.isBefore(start) == true && (end == null || it.lastAccessTime?.isAfter(end) == true) + }.size + } + override suspend fun addNewTabAfterExistingTab( url: String?, tabId: String, @@ -228,6 +243,12 @@ class TabDataRepository @Inject constructor( } } + override suspend fun updateTabLastAccess(tabId: String) { + databaseExecutor().scheduleDirect { + tabsDao.updateTabLastAccess(tabId) + } + } + override fun retrieveSiteData(tabId: String): MutableLiveData { val storedData = siteData[tabId] if (storedData != null) { @@ -335,8 +356,7 @@ class TabDataRepository @Inject constructor( Timber.w("Cannot find tab for tab ID") return@scheduleDirect } - tab.tabPreviewFile = fileName - tabsDao.updateTab(tab) + tabsDao.updateTab(tab.copy(tabPreviewFile = fileName)) Timber.i("Updated tab preview image. $tabId now uses $fileName") deleteOldPreviewImages(tabId, fileName) diff --git a/app/src/main/java/com/duckduckgo/app/tabs/store/TabStatsBucketing.kt b/app/src/main/java/com/duckduckgo/app/tabs/store/TabStatsBucketing.kt new file mode 100644 index 000000000000..d31686965504 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/tabs/store/TabStatsBucketing.kt @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2024 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.store + +import com.duckduckgo.app.tabs.model.TabRepository +import com.duckduckgo.app.tabs.store.TabStatsBucketing.Companion.ACTIVITY_BUCKETS +import com.duckduckgo.app.tabs.store.TabStatsBucketing.Companion.ONE_WEEK_IN_DAYS +import com.duckduckgo.app.tabs.store.TabStatsBucketing.Companion.TAB_COUNT_BUCKETS +import com.duckduckgo.app.tabs.store.TabStatsBucketing.Companion.THREE_WEEKS_IN_DAYS +import com.duckduckgo.app.tabs.store.TabStatsBucketing.Companion.TWO_WEEKS_IN_DAYS +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import javax.inject.Inject + +interface TabStatsBucketing { + suspend fun getNumberOfOpenTabs(): String + suspend fun getTabsActiveLastWeek(): String + suspend fun getTabsActiveOneWeekAgo(): String + suspend fun getTabsActiveTwoWeeksAgo(): String + suspend fun getTabsActiveMoreThanThreeWeeksAgo(): String + + companion object { + const val ONE_WEEK_IN_DAYS = 7L + const val TWO_WEEKS_IN_DAYS = 14L + const val THREE_WEEKS_IN_DAYS = 21L + + val TAB_COUNT_BUCKETS = listOf( + 0..1, + 2..5, + 6..10, + 11..20, + 21..40, + 41..60, + 61..80, + 81..100, + 101..125, + 126..150, + 151..250, + 251..500, + 501..Int.MAX_VALUE, + ) + + val ACTIVITY_BUCKETS = listOf( + 0..0, + 1..5, + 6..10, + 11..20, + 21..Int.MAX_VALUE, + ) + } +} + +@ContributesBinding(AppScope::class) +class DefaultTabStatsBucketing @Inject constructor( + private val tabRepository: TabRepository, +) : TabStatsBucketing { + override suspend fun getNumberOfOpenTabs(): String { + val count = tabRepository.getOpenTabCount() + return getBucketLabel(count, TAB_COUNT_BUCKETS) + } + + override suspend fun getTabsActiveLastWeek(): String { + val count = tabRepository.countTabsAccessedWithinRange(accessOlderThan = 0, accessNotMoreThan = ONE_WEEK_IN_DAYS) + return getBucketLabel(count, ACTIVITY_BUCKETS) + } + + override suspend fun getTabsActiveOneWeekAgo(): String { + val count = tabRepository.countTabsAccessedWithinRange(accessOlderThan = ONE_WEEK_IN_DAYS, accessNotMoreThan = TWO_WEEKS_IN_DAYS) + return getBucketLabel(count, ACTIVITY_BUCKETS) + } + + override suspend fun getTabsActiveTwoWeeksAgo(): String { + val count = tabRepository.countTabsAccessedWithinRange(accessOlderThan = TWO_WEEKS_IN_DAYS, accessNotMoreThan = THREE_WEEKS_IN_DAYS) + return getBucketLabel(count, ACTIVITY_BUCKETS) + } + + override suspend fun getTabsActiveMoreThanThreeWeeksAgo(): String { + val count = tabRepository.countTabsAccessedWithinRange(accessOlderThan = THREE_WEEKS_IN_DAYS) + return getBucketLabel(count, ACTIVITY_BUCKETS) + } + + private fun getBucketLabel(count: Int, buckets: List): String { + val bucket = buckets.first { bucket -> + count in bucket + } + return when (bucket) { + buckets.first() -> { + bucket.last.toString() + } + buckets.last() -> { + "${bucket.first}+" + } + else -> { + "${bucket.first}-${bucket.last}" + } + } + } +} diff --git a/app/src/test/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchOptionHandlerImplTest.kt b/app/src/test/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchOptionHandlerImplTest.kt index d663c75048eb..0067de644847 100644 --- a/app/src/test/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchOptionHandlerImplTest.kt +++ b/app/src/test/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchOptionHandlerImplTest.kt @@ -790,6 +790,10 @@ class ShowOnAppLaunchOptionHandlerImplTest { TODO("Not yet implemented") } + override suspend fun updateTabLastAccess(tabId: String) { + TODO("Not yet implemented") + } + override fun retrieveSiteData(tabId: String): MutableLiveData { TODO("Not yet implemented") } @@ -851,5 +855,16 @@ class ShowOnAppLaunchOptionHandlerImplTest { override suspend fun setTabLayoutType(layoutType: LayoutType) { TODO("Not yet implemented") } + + override fun getOpenTabCount(): Int { + TODO("Not yet implemented") + } + + override fun countTabsAccessedWithinRange( + accessOlderThan: Long, + accessNotMoreThan: Long?, + ): Int { + TODO("Not yet implemented") + } } } diff --git a/app/src/test/java/com/duckduckgo/app/tabs/store/TabStatsBucketingTest.kt b/app/src/test/java/com/duckduckgo/app/tabs/store/TabStatsBucketingTest.kt new file mode 100644 index 000000000000..511ee02756d8 --- /dev/null +++ b/app/src/test/java/com/duckduckgo/app/tabs/store/TabStatsBucketingTest.kt @@ -0,0 +1,313 @@ +package com.duckduckgo.app.tabs.store +/* + * Copyright (c) 2024 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. + */ + +import com.duckduckgo.app.tabs.model.TabRepository +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.mockito.Mockito.mock +import org.mockito.kotlin.whenever + +class DefaultTabStatsBucketingTest { + + private val tabRepository = mock() + + private lateinit var defaultTabStatsBucketing: DefaultTabStatsBucketing + + @Before + fun setup() { + defaultTabStatsBucketing = DefaultTabStatsBucketing(tabRepository) + } + + @Test + fun testGetNumberOfOpenTabsExactly1() = runTest { + whenever(tabRepository.getOpenTabCount()).thenReturn(1) + val result = defaultTabStatsBucketing.getNumberOfOpenTabs() + assertEquals("1", result) + } + + @Test + fun testGetNumberOfOpenTabsZero() = runTest { + whenever(tabRepository.getOpenTabCount()).thenReturn(0) + val result = defaultTabStatsBucketing.getNumberOfOpenTabs() + assertEquals("1", result) + } + + @Test + fun testGetNumberOfOpenTabs() = runTest { + whenever(tabRepository.getOpenTabCount()).thenReturn(5) + val result = defaultTabStatsBucketing.getNumberOfOpenTabs() + assertEquals("2-5", result) + } + + @Test + fun testGetNumberOfOpenTabs6To10() = runTest { + whenever(tabRepository.getOpenTabCount()).thenReturn(8) + val result = defaultTabStatsBucketing.getNumberOfOpenTabs() + assertEquals("6-10", result) + } + + @Test + fun testGetNumberOfOpenTabs11To20() = runTest { + whenever(tabRepository.getOpenTabCount()).thenReturn(11) + val result = defaultTabStatsBucketing.getNumberOfOpenTabs() + assertEquals("11-20", result) + } + + @Test + fun testGetNumberOfOpenTabsMoreThan20() = runTest { + whenever(tabRepository.getOpenTabCount()).thenReturn(40) + val result = defaultTabStatsBucketing.getNumberOfOpenTabs() + assertEquals("21-40", result) + } + + @Test + fun testGetNumberOfOpenTabs41To60() = runTest { + whenever(tabRepository.getOpenTabCount()).thenReturn(50) + val result = defaultTabStatsBucketing.getNumberOfOpenTabs() + assertEquals("41-60", result) + } + + @Test + fun testGetNumberOfOpenTabs61To80() = runTest { + whenever(tabRepository.getOpenTabCount()).thenReturn(70) + val result = defaultTabStatsBucketing.getNumberOfOpenTabs() + assertEquals("61-80", result) + } + + @Test + fun testGetNumberOfOpenTabs81To100() = runTest { + whenever(tabRepository.getOpenTabCount()).thenReturn(90) + val result = defaultTabStatsBucketing.getNumberOfOpenTabs() + assertEquals("81-100", result) + } + + @Test + fun testGetNumberOfOpenTabs101To125() = runTest { + whenever(tabRepository.getOpenTabCount()).thenReturn(110) + val result = defaultTabStatsBucketing.getNumberOfOpenTabs() + assertEquals("101-125", result) + } + + @Test + fun testGetNumberOfOpenTabs126To150() = runTest { + whenever(tabRepository.getOpenTabCount()).thenReturn(130) + val result = defaultTabStatsBucketing.getNumberOfOpenTabs() + assertEquals("126-150", result) + } + + @Test + fun testGetNumberOfOpenTabs151To250() = runTest { + whenever(tabRepository.getOpenTabCount()).thenReturn(200) + val result = defaultTabStatsBucketing.getNumberOfOpenTabs() + assertEquals("151-250", result) + } + + @Test + fun testGetNumberOfOpenTabs251To500() = runTest { + whenever(tabRepository.getOpenTabCount()).thenReturn(300) + val result = defaultTabStatsBucketing.getNumberOfOpenTabs() + assertEquals("251-500", result) + } + + @Test + fun testGetNumberOfOpenTabsMaxValue() = runTest { + whenever(tabRepository.getOpenTabCount()).thenReturn(600) + val result = defaultTabStatsBucketing.getNumberOfOpenTabs() + assertEquals("501+", result) + } + + @Test + fun testGetTabsActiveLastWeekZero() = runTest { + whenever(tabRepository.countTabsAccessedWithinRange(0, TabStatsBucketing.ONE_WEEK_IN_DAYS)).thenReturn(0) + val result = defaultTabStatsBucketing.getTabsActiveLastWeek() + assertEquals("0", result) + } + + @Test + fun testGetTabsActiveLastWeekExactly1() = runTest { + whenever(tabRepository.countTabsAccessedWithinRange(0, TabStatsBucketing.ONE_WEEK_IN_DAYS)).thenReturn(1) + val result = defaultTabStatsBucketing.getTabsActiveLastWeek() + assertEquals("1-5", result) + } + + @Test + fun testGetTabsActiveLastWeek1To5() = runTest { + whenever(tabRepository.countTabsAccessedWithinRange(0, TabStatsBucketing.ONE_WEEK_IN_DAYS)).thenReturn(2) + val result = defaultTabStatsBucketing.getTabsActiveLastWeek() + assertEquals("1-5", result) + } + + @Test + fun testGetTabsActiveLastWeek6To10() = runTest { + whenever(tabRepository.countTabsAccessedWithinRange(0, TabStatsBucketing.ONE_WEEK_IN_DAYS)).thenReturn(10) + val result = defaultTabStatsBucketing.getTabsActiveLastWeek() + assertEquals("6-10", result) + } + + @Test + fun testGetTabsActiveLastWeek11To20() = runTest { + whenever(tabRepository.countTabsAccessedWithinRange(0, TabStatsBucketing.ONE_WEEK_IN_DAYS)).thenReturn(15) + val result = defaultTabStatsBucketing.getTabsActiveLastWeek() + assertEquals("11-20", result) + } + + @Test + fun testGetTabsActiveLastWeekMoreThan20() = runTest { + whenever(tabRepository.countTabsAccessedWithinRange(0, TabStatsBucketing.ONE_WEEK_IN_DAYS)).thenReturn(25) + val result = defaultTabStatsBucketing.getTabsActiveLastWeek() + assertEquals("21+", result) + } + + @Test + fun testGetTabsActiveLastWeekALotMoreThan20() = runTest { + whenever(tabRepository.countTabsAccessedWithinRange(0, TabStatsBucketing.ONE_WEEK_IN_DAYS)).thenReturn(250) + val result = defaultTabStatsBucketing.getTabsActiveLastWeek() + assertEquals("21+", result) + } + + @Test + fun testGetTabsActiveOneWeekAgoZero() = runTest { + whenever(tabRepository.countTabsAccessedWithinRange(TabStatsBucketing.ONE_WEEK_IN_DAYS, TabStatsBucketing.TWO_WEEKS_IN_DAYS)).thenReturn(0) + val result = defaultTabStatsBucketing.getTabsActiveOneWeekAgo() + assertEquals("0", result) + } + + @Test + fun testGet1WeeksInactiveTabBucketExactly1() = runTest { + whenever(tabRepository.countTabsAccessedWithinRange(TabStatsBucketing.ONE_WEEK_IN_DAYS, TabStatsBucketing.TWO_WEEKS_IN_DAYS)).thenReturn(1) + val result = defaultTabStatsBucketing.getTabsActiveOneWeekAgo() + assertEquals("1-5", result) + } + + @Test + fun testGet1WeeksInactiveTabBucket1To5() = runTest { + whenever(tabRepository.countTabsAccessedWithinRange(TabStatsBucketing.ONE_WEEK_IN_DAYS, TabStatsBucketing.TWO_WEEKS_IN_DAYS)).thenReturn(3) + val result = defaultTabStatsBucketing.getTabsActiveOneWeekAgo() + assertEquals("1-5", result) + } + + @Test + fun testGetTabsActiveOneWeekAgo6To10() = runTest { + whenever(tabRepository.countTabsAccessedWithinRange(TabStatsBucketing.ONE_WEEK_IN_DAYS, TabStatsBucketing.TWO_WEEKS_IN_DAYS)).thenReturn(8) + val result = defaultTabStatsBucketing.getTabsActiveOneWeekAgo() + assertEquals("6-10", result) + } + + @Test + fun testGetTabsActiveOneWeekAgo11To20() = runTest { + whenever(tabRepository.countTabsAccessedWithinRange(TabStatsBucketing.ONE_WEEK_IN_DAYS, TabStatsBucketing.TWO_WEEKS_IN_DAYS)).thenReturn(15) + val result = defaultTabStatsBucketing.getTabsActiveOneWeekAgo() + assertEquals("11-20", result) + } + + @Test + fun testGetTabsActiveOneWeekAgoMoreThan20() = runTest { + whenever(tabRepository.countTabsAccessedWithinRange(TabStatsBucketing.ONE_WEEK_IN_DAYS, TabStatsBucketing.TWO_WEEKS_IN_DAYS)).thenReturn(25) + val result = defaultTabStatsBucketing.getTabsActiveOneWeekAgo() + assertEquals("21+", result) + } + + @Test + fun testGetTabsActiveTwoWeeksAgoZero() = runTest { + whenever(tabRepository.countTabsAccessedWithinRange(TabStatsBucketing.TWO_WEEKS_IN_DAYS, TabStatsBucketing.THREE_WEEKS_IN_DAYS)).thenReturn(0) + val result = defaultTabStatsBucketing.getTabsActiveTwoWeeksAgo() + assertEquals("0", result) + } + + @Test + fun testGetTabsActiveTwoWeeksAgoExactly1() = runTest { + whenever(tabRepository.countTabsAccessedWithinRange(TabStatsBucketing.TWO_WEEKS_IN_DAYS, TabStatsBucketing.THREE_WEEKS_IN_DAYS)).thenReturn(1) + val result = defaultTabStatsBucketing.getTabsActiveTwoWeeksAgo() + assertEquals("1-5", result) + } + + @Test + fun testGetTabsActiveTwoWeeksAgo1To5() = runTest { + whenever(tabRepository.countTabsAccessedWithinRange(TabStatsBucketing.TWO_WEEKS_IN_DAYS, TabStatsBucketing.THREE_WEEKS_IN_DAYS)).thenReturn(5) + val result = defaultTabStatsBucketing.getTabsActiveTwoWeeksAgo() + assertEquals("1-5", result) + } + + @Test + fun testGetTabsActiveTwoWeeksAgo6To10() = runTest { + whenever(tabRepository.countTabsAccessedWithinRange(TabStatsBucketing.TWO_WEEKS_IN_DAYS, TabStatsBucketing.THREE_WEEKS_IN_DAYS)).thenReturn(6) + val result = defaultTabStatsBucketing.getTabsActiveTwoWeeksAgo() + assertEquals("6-10", result) + } + + @Test + fun testGetTabsActiveTwoWeeksAgo11To20() = runTest { + whenever(tabRepository.countTabsAccessedWithinRange(TabStatsBucketing.TWO_WEEKS_IN_DAYS, TabStatsBucketing.THREE_WEEKS_IN_DAYS)).thenReturn( + 20, + ) + val result = defaultTabStatsBucketing.getTabsActiveTwoWeeksAgo() + assertEquals("11-20", result) + } + + @Test + fun testGetTabsActiveTwoWeeksAgoMoreThan20() = runTest { + whenever(tabRepository.countTabsAccessedWithinRange(TabStatsBucketing.TWO_WEEKS_IN_DAYS, TabStatsBucketing.THREE_WEEKS_IN_DAYS)).thenReturn( + 199, + ) + val result = defaultTabStatsBucketing.getTabsActiveTwoWeeksAgo() + assertEquals("21+", result) + } + + @Test + fun testGetTabsActiveMoreThanThreeWeeksAgoZero() = runTest { + whenever(tabRepository.countTabsAccessedWithinRange(TabStatsBucketing.THREE_WEEKS_IN_DAYS)).thenReturn(0) + val result = defaultTabStatsBucketing.getTabsActiveMoreThanThreeWeeksAgo() + assertEquals("0", result) + } + + @Test + fun testGetTabsActiveMoreThanThreeWeeksAgoExactly1() = runTest { + whenever(tabRepository.countTabsAccessedWithinRange(TabStatsBucketing.THREE_WEEKS_IN_DAYS)).thenReturn(1) + val result = defaultTabStatsBucketing.getTabsActiveMoreThanThreeWeeksAgo() + assertEquals("1-5", result) + } + + @Test + fun testGetTabsActiveMoreThanThreeWeeksAgo1To5() = runTest { + whenever(tabRepository.countTabsAccessedWithinRange(TabStatsBucketing.THREE_WEEKS_IN_DAYS)).thenReturn(5) + val result = defaultTabStatsBucketing.getTabsActiveMoreThanThreeWeeksAgo() + assertEquals("1-5", result) + } + + @Test + fun testGetTabsActiveMoreThanThreeWeeksAgo6To10() = runTest { + whenever(tabRepository.countTabsAccessedWithinRange(TabStatsBucketing.THREE_WEEKS_IN_DAYS)).thenReturn(10) + val result = defaultTabStatsBucketing.getTabsActiveMoreThanThreeWeeksAgo() + assertEquals("6-10", result) + } + + @Test + fun testGetTabsActiveMoreThanThreeWeeksAgo11To20() = runTest { + whenever(tabRepository.countTabsAccessedWithinRange(TabStatsBucketing.THREE_WEEKS_IN_DAYS)).thenReturn(11) + val result = defaultTabStatsBucketing.getTabsActiveMoreThanThreeWeeksAgo() + assertEquals("11-20", result) + } + + @Test + fun testGetTabsActiveMoreThanThreeWeeksAgoMoreThan20() = runTest { + whenever(tabRepository.countTabsAccessedWithinRange(TabStatsBucketing.THREE_WEEKS_IN_DAYS)).thenReturn(21) + val result = defaultTabStatsBucketing.getTabsActiveMoreThanThreeWeeksAgo() + assertEquals("21+", result) + } +} diff --git a/browser-api/src/main/java/com/duckduckgo/app/tabs/model/TabEntitiy.kt b/browser-api/src/main/java/com/duckduckgo/app/tabs/model/TabEntitiy.kt index 0aa047f4677e..677797119841 100644 --- a/browser-api/src/main/java/com/duckduckgo/app/tabs/model/TabEntitiy.kt +++ b/browser-api/src/main/java/com/duckduckgo/app/tabs/model/TabEntitiy.kt @@ -20,6 +20,9 @@ import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.Index import androidx.room.PrimaryKey +import androidx.room.TypeConverter +import com.duckduckgo.common.utils.formatters.time.DatabaseDateFormatter +import java.time.LocalDateTime @Entity( tableName = "tabs", @@ -37,16 +40,25 @@ import androidx.room.PrimaryKey ], ) data class TabEntity( - @PrimaryKey var tabId: String, - var url: String? = null, - var title: String? = null, - var skipHome: Boolean = false, - var viewed: Boolean = true, - var position: Int, - var tabPreviewFile: String? = null, - var sourceTabId: String? = null, - var deletable: Boolean = false, + @PrimaryKey val tabId: String, + val url: String? = null, + val title: String? = null, + val skipHome: Boolean = false, + val viewed: Boolean = true, + val position: Int = 0, + val tabPreviewFile: String? = null, + val sourceTabId: String? = null, + val deletable: Boolean = false, + val lastAccessTime: LocalDateTime? = null, ) val TabEntity.isBlank: Boolean get() = title == null && url == null + +class LocalDateTimeTypeConverter { + @TypeConverter + fun convertForDb(date: LocalDateTime): String = DatabaseDateFormatter.timestamp(date) + + @TypeConverter + fun convertFromDb(value: String?): LocalDateTime? = value?.let { LocalDateTime.parse(it) } +} diff --git a/browser-api/src/main/java/com/duckduckgo/app/tabs/model/TabRepository.kt b/browser-api/src/main/java/com/duckduckgo/app/tabs/model/TabRepository.kt index 0e7e877c8020..41b0eff8d37e 100644 --- a/browser-api/src/main/java/com/duckduckgo/app/tabs/model/TabRepository.kt +++ b/browser-api/src/main/java/com/duckduckgo/app/tabs/model/TabRepository.kt @@ -71,6 +71,8 @@ interface TabRepository { suspend fun updateTabPosition(from: Int, to: Int) + suspend fun updateTabLastAccess(tabId: String) + /** * @return record if it exists, otherwise a new one */ @@ -114,4 +116,15 @@ interface TabRepository { suspend fun setIsUserNew(isUserNew: Boolean) suspend fun setTabLayoutType(layoutType: LayoutType) + + fun getOpenTabCount(): Int + + /** + * Returns the number of tabs, given a range of days within which the tab was last accessed. + * + * @param accessOlderThan the minimum number of days (exclusive) since the tab was last accessed + * @param accessNotMoreThan the maximum number of days (inclusive) since the tab was last accessed (optional) + * @return the number of tabs that are inactive + */ + fun countTabsAccessedWithinRange(accessOlderThan: Long, accessNotMoreThan: Long? = null): Int } diff --git a/statistics/statistics-api/src/main/java/com/duckduckgo/app/statistics/pixels/Pixel.kt b/statistics/statistics-api/src/main/java/com/duckduckgo/app/statistics/pixels/Pixel.kt index 8de07d743664..38d710cfab8e 100644 --- a/statistics/statistics-api/src/main/java/com/duckduckgo/app/statistics/pixels/Pixel.kt +++ b/statistics/statistics-api/src/main/java/com/duckduckgo/app/statistics/pixels/Pixel.kt @@ -61,6 +61,11 @@ interface Pixel { const val FROM_ONBOARDING = "from_onboarding" const val ADDRESS_BAR = "address_bar" const val LAUNCH_SCREEN = "launch_screen" + const val TAB_COUNT = "tab_count" + const val TAB_ACTIVE_7D = "tab_active_7d" + const val TAB_INACTIVE_1W = "tab_inactive_1w" + const val TAB_INACTIVE_2W = "tab_inactive_2w" + const val TAB_INACTIVE_3W = "tab_inactive_3w" } object PixelValues {