From d818a4e55ecc68a222c3356da1da52df6e768ce1 Mon Sep 17 00:00:00 2001 From: Josh Leibstein Date: Tue, 8 Oct 2024 17:35:14 +0100 Subject: [PATCH] Add pixels for multiple refreshes (#5094) Task/Issue URL: https://app.asana.com/0/72649045549333/1208470923077006/f ### Description This adds two pixels: - One for when the user refreshes twice within 12 seconds. - And another when the user refreshes three times within 20 seconds. It also refactors all the refresh pixel sending logic into `RefreshPixelSender`, removing it from `BrowserTabFragment`. ### Steps to test this PR Filter by `m_reload` _Two refreshes in twelve seconds_ - [x] Refresh once - [x] Verify no pixel sent - [x] Refresh within 12 seconds - [x] Verify `m_reload_twice_within_12_seconds` is sent _Three refreshes in twenty seconds_ - [x] Refresh once - [x] Refresh again immediately (`m_reload_twice_within_12_seconds` will be sent) - [x] Wait 12 seconds - [x] Verify only `m_reload_three_times_within_20_seconds` is sent _Three refreshes in twenty seconds and two refreshes in twelve seconds_ - [x] Refresh once - [x] Refresh again immediately (`m_reload_twice_within_12_seconds` will be sent) - [x] Refresh again immediately - [x] Verify `m_reload_twice_within_12_seconds` is sent - [x] Verify `m_reload_three_times_within_20_seconds` is sent _Two refreshes in twelve seconds after killing the app_ - [x] Refresh once - [x] Verify no pixel sent - [x] Kill the app - [x] Refresh within 12 seconds - [x] Verify `m_reload_twice_within_12_seconds` is sent --- .../app/browser/BrowserTabViewModelTest.kt | 25 ++ .../app/browser/BrowserTabFragment.kt | 38 +-- .../app/browser/BrowserTabViewModel.kt | 14 + .../app/browser/di/BrowserModule.kt | 7 + .../app/browser/refreshpixels/RefreshDao.kt | 42 +++ .../browser/refreshpixels/RefreshEntity.kt | 26 ++ .../refreshpixels/RefreshPixelSender.kt | 122 +++++++ .../duckduckgo/app/global/db/AppDatabase.kt | 17 +- .../com/duckduckgo/app/pixels/AppPixelName.kt | 3 + .../refreshpixels/RefreshPixelSenderTest.kt | 317 ++++++++++++++++++ 10 files changed, 575 insertions(+), 36 deletions(-) create mode 100644 app/src/main/java/com/duckduckgo/app/browser/refreshpixels/RefreshDao.kt create mode 100644 app/src/main/java/com/duckduckgo/app/browser/refreshpixels/RefreshEntity.kt create mode 100644 app/src/main/java/com/duckduckgo/app/browser/refreshpixels/RefreshPixelSender.kt create mode 100644 app/src/test/java/com/duckduckgo/app/browser/refreshpixels/RefreshPixelSenderTest.kt 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 a52af4ecfc46..8b3f4e1aa619 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt @@ -104,6 +104,7 @@ import com.duckduckgo.app.browser.newtab.FavoritesQuickAccessAdapter.QuickAccess import com.duckduckgo.app.browser.omnibar.OmnibarEntryConverter import com.duckduckgo.app.browser.omnibar.model.OmnibarPosition.BOTTOM import com.duckduckgo.app.browser.omnibar.model.OmnibarPosition.TOP +import com.duckduckgo.app.browser.refreshpixels.RefreshPixelSender import com.duckduckgo.app.browser.remotemessage.RemoteMessagingModel import com.duckduckgo.app.browser.session.WebViewSessionStorage import com.duckduckgo.app.browser.viewstate.BrowserViewState @@ -469,6 +470,8 @@ class BrowserTabViewModelTest { private val subscriptions: Subscriptions = mock() + private val refreshPixelSender: RefreshPixelSender = mock() + private val privacyProtectionsPopupExperimentExternalPixels: PrivacyProtectionsPopupExperimentExternalPixels = mock { runBlocking { whenever(mock.getPixelParams()).thenReturn(emptyMap()) } } @@ -639,6 +642,7 @@ class BrowserTabViewModelTest { duckPlayer = mockDuckPlayer, duckPlayerJSHelper = DuckPlayerJSHelper(mockDuckPlayer, mockAppBuildConfig, mockPixel, mockDuckDuckGoUrlDetector), loadingBarExperimentManager = loadingBarExperimentManager, + refreshPixelSender = refreshPixelSender, ) testee.loadData("abc", null, false, false) @@ -5871,6 +5875,27 @@ class BrowserTabViewModelTest { testee.omnibarViewState.removeObserver { observer(it) } } + @Test + fun whenHandleMenuRefreshActionThenSendMenuRefreshPixels() { + testee.handleMenuRefreshAction() + + verify(refreshPixelSender).sendMenuRefreshPixels() + } + + @Test + fun whenHandlePullToRefreshActionThenSendPullToRefreshPixels() { + testee.handlePullToRefreshAction() + + verify(refreshPixelSender).sendPullToRefreshPixels() + } + + @Test + fun whenFireCustomTabRefreshPixelThenSendCustomTabRefreshPixel() { + testee.fireCustomTabRefreshPixel() + + verify(refreshPixelSender).sendCustomTabRefreshPixel() + } + private fun aCredential(): LoginCredentials { return LoginCredentials(domain = null, username = null, password = null) } diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt index 9d2491a96ad1..e99af942ab55 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt @@ -192,8 +192,6 @@ import com.duckduckgo.app.privatesearch.PrivateSearchScreenNoParams import com.duckduckgo.app.settings.db.SettingsDataStore import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.statistics.pixels.Pixel.PixelParameter.FIRE_BUTTON_STATE -import com.duckduckgo.app.statistics.pixels.Pixel.PixelParameter.LOADING_BAR_EXPERIMENT -import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Daily import com.duckduckgo.app.tabs.ui.GridViewColumnCalculator import com.duckduckgo.app.tabs.ui.TabSwitcherActivity import com.duckduckgo.app.widget.AddWidgetLauncher @@ -252,7 +250,6 @@ import com.duckduckgo.common.utils.KeyboardVisibilityUtil import com.duckduckgo.common.utils.extensions.hideKeyboard import com.duckduckgo.common.utils.extensions.html import com.duckduckgo.common.utils.extensions.showKeyboard -import com.duckduckgo.common.utils.extensions.toBinaryString import com.duckduckgo.common.utils.extensions.websiteFromGeoLocationsApiOrigin import com.duckduckgo.common.utils.extractDomain import com.duckduckgo.common.utils.playstore.PlayStoreUtils @@ -966,23 +963,9 @@ class BrowserTabFragment : onMenuItemClicked(refreshMenuItem) { viewModel.onRefreshRequested(triggeredByUser = true) if (isActiveCustomTab()) { - pixel.fire(CustomTabPixelNames.CUSTOM_TABS_MENU_REFRESH) + viewModel.fireCustomTabRefreshPixel() } else { - // Loading Bar Experiment - if (loadingBarExperimentManager.isExperimentEnabled()) { - pixel.fire( - AppPixelName.MENU_ACTION_REFRESH_PRESSED.pixelName, - mapOf(LOADING_BAR_EXPERIMENT to loadingBarExperimentManager.variant.toBinaryString()), - ) - pixel.fire( - AppPixelName.REFRESH_ACTION_DAILY_PIXEL.pixelName, - mapOf(LOADING_BAR_EXPERIMENT to loadingBarExperimentManager.variant.toBinaryString()), - type = Daily(), - ) - } else { - pixel.fire(AppPixelName.MENU_ACTION_REFRESH_PRESSED.pixelName) - pixel.fire(AppPixelName.REFRESH_ACTION_DAILY_PIXEL.pixelName, type = Daily()) - } + viewModel.handleMenuRefreshAction() } } onMenuItemClicked(newTabMenuItem) { @@ -2759,22 +2742,7 @@ class BrowserTabFragment : binding.swipeRefreshContainer.setOnRefreshListener { onRefreshRequested() - - // Loading Bar Experiment - if (loadingBarExperimentManager.isExperimentEnabled()) { - pixel.fire( - AppPixelName.BROWSER_PULL_TO_REFRESH.pixelName, - mapOf(LOADING_BAR_EXPERIMENT to loadingBarExperimentManager.variant.toBinaryString()), - ) - pixel.fire( - AppPixelName.REFRESH_ACTION_DAILY_PIXEL.pixelName, - mapOf(LOADING_BAR_EXPERIMENT to loadingBarExperimentManager.variant.toBinaryString()), - type = Daily(), - ) - } else { - pixel.fire(AppPixelName.BROWSER_PULL_TO_REFRESH.pixelName) - pixel.fire(AppPixelName.REFRESH_ACTION_DAILY_PIXEL.pixelName, type = Daily()) - } + viewModel.handlePullToRefreshAction() } binding.swipeRefreshContainer.setCanChildScrollUpCallback { 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 ea73ed4c4350..ec390c69e400 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt @@ -178,6 +178,7 @@ import com.duckduckgo.app.browser.omnibar.QueryOrigin.FromAutocomplete import com.duckduckgo.app.browser.omnibar.model.OmnibarPosition import com.duckduckgo.app.browser.omnibar.model.OmnibarPosition.BOTTOM import com.duckduckgo.app.browser.omnibar.model.OmnibarPosition.TOP +import com.duckduckgo.app.browser.refreshpixels.RefreshPixelSender import com.duckduckgo.app.browser.session.WebViewSessionStorage import com.duckduckgo.app.browser.urlextraction.UrlExtractionListener import com.duckduckgo.app.browser.viewstate.AccessibilityViewState @@ -415,6 +416,7 @@ class BrowserTabViewModel @Inject constructor( private val duckPlayer: DuckPlayer, private val duckPlayerJSHelper: DuckPlayerJSHelper, private val loadingBarExperimentManager: LoadingBarExperimentManager, + private val refreshPixelSender: RefreshPixelSender, ) : WebViewClientListener, EditSavedSiteListener, DeleteBookmarkListener, @@ -3763,6 +3765,18 @@ class BrowserTabViewModel @Inject constructor( newTabPixels.get().fireNewTabDisplayed() } + fun handleMenuRefreshAction() { + refreshPixelSender.sendMenuRefreshPixels() + } + + fun handlePullToRefreshAction() { + refreshPixelSender.sendPullToRefreshPixels() + } + + fun fireCustomTabRefreshPixel() { + refreshPixelSender.sendCustomTabRefreshPixel() + } + companion object { private const val FIXED_PROGRESS = 50 diff --git a/app/src/main/java/com/duckduckgo/app/browser/di/BrowserModule.kt b/app/src/main/java/com/duckduckgo/app/browser/di/BrowserModule.kt index 274fc0c747cf..a593eac0d5d5 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/di/BrowserModule.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/di/BrowserModule.kt @@ -45,6 +45,7 @@ import com.duckduckgo.app.browser.mediaplayback.store.MediaPlaybackDao import com.duckduckgo.app.browser.mediaplayback.store.MediaPlaybackDatabase import com.duckduckgo.app.browser.pageloadpixel.PageLoadedPixelDao import com.duckduckgo.app.browser.pageloadpixel.firstpaint.PagePaintedPixelDao +import com.duckduckgo.app.browser.refreshpixels.RefreshDao import com.duckduckgo.app.browser.session.WebViewSessionInMemoryStorage import com.duckduckgo.app.browser.session.WebViewSessionStorage import com.duckduckgo.app.browser.tabpreview.FileBasedWebViewPreviewGenerator @@ -365,6 +366,12 @@ class BrowserModule { fun provideIndonesiaNewTabSectionDataStore(context: Context): DataStore { return context.indonesiaNewTabSectionDataStore } + + @Provides + @SingleInstanceIn(AppScope::class) + fun provideRefreshDao(appDatabase: AppDatabase): RefreshDao { + return appDatabase.refreshDao() + } } @Qualifier diff --git a/app/src/main/java/com/duckduckgo/app/browser/refreshpixels/RefreshDao.kt b/app/src/main/java/com/duckduckgo/app/browser/refreshpixels/RefreshDao.kt new file mode 100644 index 000000000000..dcadf390b265 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/browser/refreshpixels/RefreshDao.kt @@ -0,0 +1,42 @@ +/* + * 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.browser.refreshpixels + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.Query +import androidx.room.Transaction + +@Dao +abstract class RefreshDao { + + @Transaction + open fun updateRecentRefreshes(minTime: Long, currentTime: Long): List { + insert(RefreshEntity(timestamp = currentTime)) + deleteInvalidRefreshes(minTime, currentTime) + return all() + } + + @Insert + abstract fun insert(entity: RefreshEntity) + + @Query("DELETE FROM refreshes WHERE timestamp NOT BETWEEN :minTime AND :currentTime") + abstract fun deleteInvalidRefreshes(minTime: Long, currentTime: Long) + + @Query("SELECT * FROM refreshes") + abstract fun all(): List +} diff --git a/app/src/main/java/com/duckduckgo/app/browser/refreshpixels/RefreshEntity.kt b/app/src/main/java/com/duckduckgo/app/browser/refreshpixels/RefreshEntity.kt new file mode 100644 index 000000000000..a9d98cf7efaf --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/browser/refreshpixels/RefreshEntity.kt @@ -0,0 +1,26 @@ +/* + * 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.browser.refreshpixels + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "refreshes") +class RefreshEntity( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val timestamp: Long, +) diff --git a/app/src/main/java/com/duckduckgo/app/browser/refreshpixels/RefreshPixelSender.kt b/app/src/main/java/com/duckduckgo/app/browser/refreshpixels/RefreshPixelSender.kt new file mode 100644 index 000000000000..6984ea7239e3 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/browser/refreshpixels/RefreshPixelSender.kt @@ -0,0 +1,122 @@ +/* + * 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.browser.refreshpixels + +import com.duckduckgo.app.browser.customtabs.CustomTabPixelNames +import com.duckduckgo.app.di.AppCoroutineScope +import com.duckduckgo.app.pixels.AppPixelName +import com.duckduckgo.app.pixels.AppPixelName.RELOAD_THREE_TIMES_WITHIN_20_SECONDS +import com.duckduckgo.app.pixels.AppPixelName.RELOAD_TWICE_WITHIN_12_SECONDS +import com.duckduckgo.app.statistics.pixels.Pixel +import com.duckduckgo.app.statistics.pixels.Pixel.PixelParameter.LOADING_BAR_EXPERIMENT +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Daily +import com.duckduckgo.common.utils.CurrentTimeProvider +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.common.utils.extensions.toBinaryString +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.experiments.api.loadingbarexperiment.LoadingBarExperimentManager +import com.squareup.anvil.annotations.ContributesBinding +import dagger.SingleInstanceIn +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +interface RefreshPixelSender { + fun sendMenuRefreshPixels() + fun sendCustomTabRefreshPixel() + fun sendPullToRefreshPixels() +} + +@ContributesBinding(AppScope::class) +@SingleInstanceIn(AppScope::class) +class DuckDuckGoRefreshPixelSender @Inject constructor( + private val pixel: Pixel, + private val dao: RefreshDao, + private val loadingBarExperimentManager: LoadingBarExperimentManager, + private val currentTimeProvider: CurrentTimeProvider, + @AppCoroutineScope private val appCoroutineScope: CoroutineScope, + private val dispatcherProvider: DispatcherProvider, +) : RefreshPixelSender { + + override fun sendMenuRefreshPixels() { + sendTimeBasedPixels() + + // Loading Bar Experiment + if (loadingBarExperimentManager.isExperimentEnabled()) { + pixel.fire( + AppPixelName.MENU_ACTION_REFRESH_PRESSED, + mapOf(LOADING_BAR_EXPERIMENT to loadingBarExperimentManager.variant.toBinaryString()), + ) + pixel.fire( + AppPixelName.REFRESH_ACTION_DAILY_PIXEL, + mapOf(LOADING_BAR_EXPERIMENT to loadingBarExperimentManager.variant.toBinaryString()), + type = Daily(), + ) + } else { + pixel.fire(AppPixelName.MENU_ACTION_REFRESH_PRESSED) + pixel.fire(AppPixelName.REFRESH_ACTION_DAILY_PIXEL, type = Daily()) + } + } + + override fun sendPullToRefreshPixels() { + sendTimeBasedPixels() + + // Loading Bar Experiment + if (loadingBarExperimentManager.isExperimentEnabled()) { + pixel.fire( + AppPixelName.BROWSER_PULL_TO_REFRESH, + mapOf(LOADING_BAR_EXPERIMENT to loadingBarExperimentManager.variant.toBinaryString()), + ) + pixel.fire( + AppPixelName.REFRESH_ACTION_DAILY_PIXEL, + mapOf(LOADING_BAR_EXPERIMENT to loadingBarExperimentManager.variant.toBinaryString()), + type = Daily(), + ) + } else { + pixel.fire(AppPixelName.BROWSER_PULL_TO_REFRESH) + pixel.fire(AppPixelName.REFRESH_ACTION_DAILY_PIXEL, type = Daily()) + } + } + + override fun sendCustomTabRefreshPixel() { + sendTimeBasedPixels() + + pixel.fire(CustomTabPixelNames.CUSTOM_TABS_MENU_REFRESH) + } + + private fun sendTimeBasedPixels() { + appCoroutineScope.launch(dispatcherProvider.io()) { + val now = currentTimeProvider.currentTimeMillis() + val twelveSecondsAgo = now - TWELVE_SECONDS + val twentySecondsAgo = now - TWENTY_SECONDS + + val refreshes = dao.updateRecentRefreshes(twentySecondsAgo, now) + + if (refreshes.count { it.timestamp >= twelveSecondsAgo } >= 2) { + pixel.fire(RELOAD_TWICE_WITHIN_12_SECONDS) + } + if (refreshes.size >= 3) { + pixel.fire(RELOAD_THREE_TIMES_WITHIN_20_SECONDS) + } + } + } + + companion object { + const val TWENTY_SECONDS = 20000L + const val TWELVE_SECONDS = 12000L + } +} 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 929332bdf658..99985184defe 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 @@ -31,6 +31,8 @@ import com.duckduckgo.app.browser.pageloadpixel.PageLoadedPixelEntity import com.duckduckgo.app.browser.pageloadpixel.firstpaint.PagePaintedPixelDao import com.duckduckgo.app.browser.pageloadpixel.firstpaint.PagePaintedPixelEntity import com.duckduckgo.app.browser.rating.db.* +import com.duckduckgo.app.browser.refreshpixels.RefreshDao +import com.duckduckgo.app.browser.refreshpixels.RefreshEntity import com.duckduckgo.app.cta.db.DismissedCtaDao import com.duckduckgo.app.cta.model.DismissedCta import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteDao @@ -70,7 +72,7 @@ import com.duckduckgo.savedsites.store.SavedSitesRelationsDao @Database( exportSchema = true, - version = 54, + version = 55, entities = [ TdsTracker::class, TdsEntity::class, @@ -103,6 +105,7 @@ import com.duckduckgo.savedsites.store.SavedSitesRelationsDao AuthCookieAllowedDomainEntity::class, Entity::class, Relation::class, + RefreshEntity::class, ], ) @@ -154,6 +157,8 @@ abstract class AppDatabase : RoomDatabase() { abstract fun syncEntitiesDao(): SavedSitesEntitiesDao abstract fun syncRelationsDao(): SavedSitesRelationsDao + + abstract fun refreshDao(): RefreshDao } @Suppress("PropertyName") @@ -660,6 +665,15 @@ class MigrationsProvider(val context: Context, val settingsDataStore: SettingsDa } } + private val MIGRATION_54_TO_55: Migration = object : Migration(54, 55) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL( + "CREATE TABLE IF NOT EXISTS `refreshes` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " + + "`timestamp` INTEGER NOT NULL)", + ) + } + } + /** * WARNING ⚠️ * This needs to happen because Room doesn't support UNIQUE (...) ON CONFLICT REPLACE when creating the bookmarks table. @@ -739,6 +753,7 @@ class MigrationsProvider(val context: Context, val settingsDataStore: SettingsDa MIGRATION_51_TO_52, MIGRATION_52_TO_53, MIGRATION_53_TO_54, + MIGRATION_54_TO_55, ) @Deprecated( diff --git a/app/src/main/java/com/duckduckgo/app/pixels/AppPixelName.kt b/app/src/main/java/com/duckduckgo/app/pixels/AppPixelName.kt index 737422bcc7bf..31ee31dc8f65 100644 --- a/app/src/main/java/com/duckduckgo/app/pixels/AppPixelName.kt +++ b/app/src/main/java/com/duckduckgo/app/pixels/AppPixelName.kt @@ -365,5 +365,8 @@ enum class AppPixelName(override val pixelName: String) : Pixel.PixelName { INDONESIA_MESSAGE_DISMISSED("m_indonesia_message_dismissed"), REFRESH_ACTION_DAILY_PIXEL("m_refresh_action_daily"), + RELOAD_TWICE_WITHIN_12_SECONDS("m_reload_twice_within_12_seconds"), + RELOAD_THREE_TIMES_WITHIN_20_SECONDS("m_reload_three_times_within_20_seconds"), + URI_LOADED("m_uri_loaded"), } diff --git a/app/src/test/java/com/duckduckgo/app/browser/refreshpixels/RefreshPixelSenderTest.kt b/app/src/test/java/com/duckduckgo/app/browser/refreshpixels/RefreshPixelSenderTest.kt new file mode 100644 index 000000000000..cfda951d5a71 --- /dev/null +++ b/app/src/test/java/com/duckduckgo/app/browser/refreshpixels/RefreshPixelSenderTest.kt @@ -0,0 +1,317 @@ +package com.duckduckgo.app.browser.refreshpixels + +import androidx.room.Room +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.duckduckgo.app.browser.customtabs.CustomTabPixelNames +import com.duckduckgo.app.global.db.AppDatabase +import com.duckduckgo.app.pixels.AppPixelName +import com.duckduckgo.app.statistics.pixels.Pixel +import com.duckduckgo.app.statistics.pixels.Pixel.PixelParameter.LOADING_BAR_EXPERIMENT +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Daily +import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.common.utils.CurrentTimeProvider +import com.duckduckgo.experiments.api.loadingbarexperiment.LoadingBarExperimentManager +import junit.framework.TestCase.assertTrue +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.mock +import org.mockito.Mockito.never +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.mockito.kotlin.whenever + +@RunWith(AndroidJUnit4::class) +class RefreshPixelSenderTest { + + @get:Rule + var coroutineTestRule = CoroutineTestRule() + + private lateinit var db: AppDatabase + private lateinit var refreshDao: RefreshDao + private val mockPixel: Pixel = mock() + private val mockLoadingBarExperimentManager: LoadingBarExperimentManager = mock() + private val mockCurrentTimeProvider: CurrentTimeProvider = mock() + + private lateinit var testee: DuckDuckGoRefreshPixelSender + + @Before + fun setUp() { + db = Room.inMemoryDatabaseBuilder(InstrumentationRegistry.getInstrumentation().targetContext, AppDatabase::class.java) + .allowMainThreadQueries() + .build() + + refreshDao = db.refreshDao() + + whenever(mockCurrentTimeProvider.currentTimeMillis()).thenReturn(CURRENT_TIME) + + testee = DuckDuckGoRefreshPixelSender( + pixel = mockPixel, + dao = refreshDao, + loadingBarExperimentManager = mockLoadingBarExperimentManager, + currentTimeProvider = mockCurrentTimeProvider, + appCoroutineScope = coroutineTestRule.testScope, + dispatcherProvider = coroutineTestRule.testDispatcherProvider, + ) + } + + @After + fun after() { + db.close() + } + + @Test + fun whenSendMenuRefreshPixelsAndExperimentEnabledAndIsTestVariantThenTestVariantPixelsFired() { + whenever(mockLoadingBarExperimentManager.isExperimentEnabled()).thenReturn(true) + whenever(mockLoadingBarExperimentManager.variant).thenReturn(true) + + testee.sendMenuRefreshPixels() + + verify(mockPixel).fire( + pixel = AppPixelName.MENU_ACTION_REFRESH_PRESSED, + parameters = mapOf(LOADING_BAR_EXPERIMENT to "1"), + ) + verify(mockPixel).fire( + pixel = AppPixelName.REFRESH_ACTION_DAILY_PIXEL, + parameters = mapOf(LOADING_BAR_EXPERIMENT to "1"), + type = Daily(), + ) + } + + @Test + fun whenSendMenuRefreshPixelsAndExperimentEnabledAndIsControlVariantThenControlVariantPixelsFired() { + whenever(mockLoadingBarExperimentManager.isExperimentEnabled()).thenReturn(true) + whenever(mockLoadingBarExperimentManager.variant).thenReturn(false) + + testee.sendMenuRefreshPixels() + + verify(mockPixel).fire( + pixel = AppPixelName.MENU_ACTION_REFRESH_PRESSED, + parameters = mapOf(LOADING_BAR_EXPERIMENT to "0"), + ) + verify(mockPixel).fire( + pixel = AppPixelName.REFRESH_ACTION_DAILY_PIXEL, + parameters = mapOf(LOADING_BAR_EXPERIMENT to "0"), + type = Daily(), + ) + } + + @Test + fun whenSendMenuRefreshPixelsAndExperimentDisabledThenDefaultPixelsFired() { + whenever(mockLoadingBarExperimentManager.isExperimentEnabled()).thenReturn(false) + + testee.sendMenuRefreshPixels() + + verify(mockPixel).fire( + pixel = AppPixelName.MENU_ACTION_REFRESH_PRESSED, + ) + verify(mockPixel).fire( + pixel = AppPixelName.REFRESH_ACTION_DAILY_PIXEL, + type = Daily(), + ) + } + + @Test + fun whenSendPullToRefreshPixelsAndExperimentEnabledAndIsTestVariantThenTestVariantPixelsFired() { + whenever(mockLoadingBarExperimentManager.isExperimentEnabled()).thenReturn(true) + whenever(mockLoadingBarExperimentManager.variant).thenReturn(true) + + testee.sendPullToRefreshPixels() + + verify(mockPixel).fire( + pixel = AppPixelName.BROWSER_PULL_TO_REFRESH, + parameters = mapOf(LOADING_BAR_EXPERIMENT to "1"), + ) + verify(mockPixel).fire( + pixel = AppPixelName.REFRESH_ACTION_DAILY_PIXEL, + parameters = mapOf(LOADING_BAR_EXPERIMENT to "1"), + type = Daily(), + ) + } + + @Test + fun whenSendPullToRefreshPixelsAndExperimentEnabledAndIsControlVariantThenControlVariantPixelsFired() { + whenever(mockLoadingBarExperimentManager.isExperimentEnabled()).thenReturn(true) + whenever(mockLoadingBarExperimentManager.variant).thenReturn(false) + + testee.sendPullToRefreshPixels() + + verify(mockPixel).fire( + pixel = AppPixelName.BROWSER_PULL_TO_REFRESH, + parameters = mapOf(LOADING_BAR_EXPERIMENT to "0"), + ) + verify(mockPixel).fire( + pixel = AppPixelName.REFRESH_ACTION_DAILY_PIXEL, + parameters = mapOf(LOADING_BAR_EXPERIMENT to "0"), + type = Daily(), + ) + } + + @Test + fun whenSendPullToRefreshPixelsAndExperimentDisabledThenDefaultPixelsFired() { + whenever(mockLoadingBarExperimentManager.isExperimentEnabled()).thenReturn(false) + + testee.sendPullToRefreshPixels() + + verify(mockPixel).fire( + pixel = AppPixelName.BROWSER_PULL_TO_REFRESH, + ) + verify(mockPixel).fire( + pixel = AppPixelName.REFRESH_ACTION_DAILY_PIXEL, + type = Daily(), + ) + } + + @Test + fun whenSendCustomTabRefreshPixelThenCorrectPixelFired() { + testee.sendCustomTabRefreshPixel() + + verify(mockPixel).fire(CustomTabPixelNames.CUSTOM_TABS_MENU_REFRESH) + } + + @Test + fun whenSendMenuRefreshPixelsThenTimeBasedPixelsFired() = runTest { + val mockDao: RefreshDao = mock() + + testee = DuckDuckGoRefreshPixelSender( + pixel = mockPixel, + dao = mockDao, + loadingBarExperimentManager = mockLoadingBarExperimentManager, + currentTimeProvider = mockCurrentTimeProvider, + appCoroutineScope = coroutineTestRule.testScope, + dispatcherProvider = coroutineTestRule.testDispatcherProvider, + ) + + testee.sendMenuRefreshPixels() + + verify(mockDao).updateRecentRefreshes(CURRENT_TIME - 20000, CURRENT_TIME) + } + + @Test + fun whenSendPullToRefreshPixelsThenTimeBasedPixelsFired() = runTest { + val mockDao: RefreshDao = mock() + + testee = DuckDuckGoRefreshPixelSender( + pixel = mockPixel, + dao = mockDao, + loadingBarExperimentManager = mockLoadingBarExperimentManager, + currentTimeProvider = mockCurrentTimeProvider, + appCoroutineScope = coroutineTestRule.testScope, + dispatcherProvider = coroutineTestRule.testDispatcherProvider, + ) + + testee.sendPullToRefreshPixels() + + verify(mockDao).updateRecentRefreshes(CURRENT_TIME - 20000, CURRENT_TIME) + } + + @Test + fun whenSendCustomTabRefreshPixelThenTimeBasedPixelsFired() = runTest { + val mockDao: RefreshDao = mock() + + testee = DuckDuckGoRefreshPixelSender( + pixel = mockPixel, + dao = mockDao, + loadingBarExperimentManager = mockLoadingBarExperimentManager, + currentTimeProvider = mockCurrentTimeProvider, + appCoroutineScope = coroutineTestRule.testScope, + dispatcherProvider = coroutineTestRule.testDispatcherProvider, + ) + + testee.sendCustomTabRefreshPixel() + + verify(mockDao).updateRecentRefreshes(CURRENT_TIME - 20000, CURRENT_TIME) + } + + @Test + fun whenRefreshedOnceThenNoPixelFired() = runTest { + testee.sendMenuRefreshPixels() + + verify(mockPixel, never()).fire(AppPixelName.RELOAD_TWICE_WITHIN_12_SECONDS) + verify(mockPixel, never()).fire(AppPixelName.RELOAD_THREE_TIMES_WITHIN_20_SECONDS) + assertTrue(refreshDao.all().size == 1) + } + + @Test + fun whenRefreshedTwiceThenReloadTwicePixelFired() = runTest { + testee.sendMenuRefreshPixels() + testee.sendMenuRefreshPixels() + + verify(mockPixel).fire(AppPixelName.RELOAD_TWICE_WITHIN_12_SECONDS) + verify(mockPixel, never()).fire(AppPixelName.RELOAD_THREE_TIMES_WITHIN_20_SECONDS) + assertTrue(refreshDao.all().size == 2) + } + + @Test + fun whenRefreshedThreeTimesThenReloadTwicePixelFiredTwiceAndReloadThricePixelFired() = runTest { + testee.sendMenuRefreshPixels() + testee.sendMenuRefreshPixels() + testee.sendMenuRefreshPixels() + + verify(mockPixel, times(2)).fire(AppPixelName.RELOAD_TWICE_WITHIN_12_SECONDS) + verify(mockPixel).fire(AppPixelName.RELOAD_THREE_TIMES_WITHIN_20_SECONDS) + assertTrue(refreshDao.all().size == 3) + } + + @Test + fun whenSendTimeBasedPixelsAndNoRecentRefreshesThenNoPixelsFired() = runTest { + refreshDao.insert(RefreshEntity(timestamp = CURRENT_TIME - 75000)) + refreshDao.insert(RefreshEntity(timestamp = CURRENT_TIME - 50000)) + refreshDao.insert(RefreshEntity(timestamp = CURRENT_TIME - 25000)) + + testee.sendMenuRefreshPixels() + + verify(mockPixel, never()).fire(AppPixelName.RELOAD_TWICE_WITHIN_12_SECONDS) + verify(mockPixel, never()).fire(AppPixelName.RELOAD_THREE_TIMES_WITHIN_20_SECONDS) + assertTrue(refreshDao.all().size == 1) + } + + @Test + fun whenSendTimeBasedPixelsAndTwoRefreshesWithinTwelveSecondsThenReloadTwicePixelFired() = runTest { + refreshDao.insert(RefreshEntity(timestamp = CURRENT_TIME - 75000)) + refreshDao.insert(RefreshEntity(timestamp = CURRENT_TIME - 50000)) + refreshDao.insert(RefreshEntity(timestamp = CURRENT_TIME - 11000)) + + testee.sendMenuRefreshPixels() + + verify(mockPixel).fire(AppPixelName.RELOAD_TWICE_WITHIN_12_SECONDS) + verify(mockPixel, never()).fire(AppPixelName.RELOAD_THREE_TIMES_WITHIN_20_SECONDS) + assertTrue(refreshDao.all().size == 2) + } + + @Test + fun whenSendTimeBasedPixelsAndThreeRefreshesWithinTwentySecondsThenReloadThricePixelFired() = runTest { + refreshDao.insert(RefreshEntity(timestamp = CURRENT_TIME - 75000)) + refreshDao.insert(RefreshEntity(timestamp = CURRENT_TIME - 50000)) + refreshDao.insert(RefreshEntity(timestamp = CURRENT_TIME - 19000)) + refreshDao.insert(RefreshEntity(timestamp = CURRENT_TIME - 15000)) + + testee.sendMenuRefreshPixels() + + verify(mockPixel, never()).fire(AppPixelName.RELOAD_TWICE_WITHIN_12_SECONDS) + verify(mockPixel).fire(AppPixelName.RELOAD_THREE_TIMES_WITHIN_20_SECONDS) + assertTrue(refreshDao.all().size == 3) + } + + @Test + fun whenSendTimeBasedPixelsAndThreeRefreshesWithinTwentySecondsAndTwoRefreshesWithinTwelveSecondsThenBothPixelsFired() = runTest { + refreshDao.insert(RefreshEntity(timestamp = CURRENT_TIME - 75000)) + refreshDao.insert(RefreshEntity(timestamp = CURRENT_TIME - 50000)) + refreshDao.insert(RefreshEntity(timestamp = CURRENT_TIME - 19000)) + refreshDao.insert(RefreshEntity(timestamp = CURRENT_TIME - 5000)) + + testee.sendMenuRefreshPixels() + + verify(mockPixel).fire(AppPixelName.RELOAD_TWICE_WITHIN_12_SECONDS) + verify(mockPixel).fire(AppPixelName.RELOAD_THREE_TIMES_WITHIN_20_SECONDS) + assertTrue(refreshDao.all().size == 3) + } + + companion object { + private const val CURRENT_TIME = 100000L + } +}