diff --git a/ad-click/ad-click-impl/build.gradle b/ad-click/ad-click-impl/build.gradle index be7f56447034..97e22fcf009f 100644 --- a/ad-click/ad-click-impl/build.gradle +++ b/ad-click/ad-click-impl/build.gradle @@ -48,6 +48,7 @@ dependencies { implementation project(path: ':privacy-config-api') implementation project(path: ':feature-toggles-api') implementation project(path: ':app-build-config-api') + implementation project(path: ':attributed-metrics-api') implementation AndroidX.core.ktx @@ -97,6 +98,7 @@ dependencies { testImplementation Testing.robolectric testImplementation project(path: ':common-test') + testImplementation project(path: ':feature-toggles-test') coreLibraryDesugaring Android.tools.desugarJdkLibs } \ No newline at end of file diff --git a/ad-click/ad-click-impl/src/main/java/com/duckduckgo/adclick/impl/DuckDuckGoAdClickManager.kt b/ad-click/ad-click-impl/src/main/java/com/duckduckgo/adclick/impl/DuckDuckGoAdClickManager.kt index 387b60065a2f..c088aae9b5d8 100644 --- a/ad-click/ad-click-impl/src/main/java/com/duckduckgo/adclick/impl/DuckDuckGoAdClickManager.kt +++ b/ad-click/ad-click-impl/src/main/java/com/duckduckgo/adclick/impl/DuckDuckGoAdClickManager.kt @@ -17,6 +17,7 @@ package com.duckduckgo.adclick.impl import com.duckduckgo.adclick.api.AdClickManager +import com.duckduckgo.adclick.impl.metrics.AdClickCollector import com.duckduckgo.adclick.impl.pixels.AdClickPixelName import com.duckduckgo.adclick.impl.pixels.AdClickPixels import com.duckduckgo.app.browser.UriString @@ -33,6 +34,7 @@ class DuckDuckGoAdClickManager @Inject constructor( private val adClickData: AdClickData, private val adClickAttribution: AdClickAttribution, private val adClickPixels: AdClickPixels, + private val adClickCollector: AdClickCollector, ) : AdClickManager { private val publicSuffixDatabase = PublicSuffixDatabase() @@ -223,6 +225,7 @@ class DuckDuckGoAdClickManager @Inject constructor( exemptionDeadline = System.currentTimeMillis() + adClickAttribution.getTotalExpirationMillis(), ), ) + adClickCollector.onAdClick() adClickPixels.fireAdClickDetectedPixel( savedAdDomain = savedAdDomain, urlAdDomain = urlAdDomain, diff --git a/ad-click/ad-click-impl/src/main/java/com/duckduckgo/adclick/impl/metrics/AdClickAttributedMetric.kt b/ad-click/ad-click-impl/src/main/java/com/duckduckgo/adclick/impl/metrics/AdClickAttributedMetric.kt new file mode 100644 index 000000000000..0d165d17bca9 --- /dev/null +++ b/ad-click/ad-click-impl/src/main/java/com/duckduckgo/adclick/impl/metrics/AdClickAttributedMetric.kt @@ -0,0 +1,176 @@ +/* + * 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.adclick.impl.metrics + +import com.duckduckgo.app.attributed.metrics.api.AttributedMetric +import com.duckduckgo.app.attributed.metrics.api.AttributedMetricClient +import com.duckduckgo.app.attributed.metrics.api.AttributedMetricConfig +import com.duckduckgo.app.attributed.metrics.api.EventStats +import com.duckduckgo.app.attributed.metrics.api.MetricBucket +import com.duckduckgo.app.di.AppCoroutineScope +import com.duckduckgo.browser.api.install.AppInstall +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import com.squareup.anvil.annotations.ContributesMultibinding +import dagger.SingleInstanceIn +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart.LAZY +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import logcat.logcat +import java.time.Instant +import java.time.ZoneId +import java.time.temporal.ChronoUnit +import javax.inject.Inject +import kotlin.math.roundToInt + +interface AdClickCollector { + fun onAdClick() +} + +/** + * Ad clicks 7d avg Attributed Metric + * Trigger: on first Ad click of day + * Type: Daily pixel + * Report: 7d rolling average of ad clicks (bucketed value). Not sent if count is 0. + * Specs: https://app.asana.com/1/137249556945/project/1206716555947156/task/1211301604929610?focus=true + */ +@ContributesMultibinding(AppScope::class, AttributedMetric::class) +@ContributesBinding(AppScope::class, AdClickCollector::class) +@SingleInstanceIn(AppScope::class) +class RealAdClickAttributedMetric @Inject constructor( + @AppCoroutineScope private val appCoroutineScope: CoroutineScope, + private val dispatcherProvider: DispatcherProvider, + private val attributedMetricClient: AttributedMetricClient, + private val appInstall: AppInstall, + private val attributedMetricConfig: AttributedMetricConfig, +) : AttributedMetric, AdClickCollector { + + companion object { + private const val EVENT_NAME = "ad_click" + private const val PIXEL_NAME = "attributed_metric_average_ad_clicks_past_week" + private const val FEATURE_TOGGLE_NAME = "adClickCountAvg" + private const val FEATURE_EMIT_TOGGLE_NAME = "canEmitAdClickCountAvg" + private const val DAYS_WINDOW = 7 + } + + private val isEnabled: Deferred = appCoroutineScope.async(start = LAZY) { + getToggle(FEATURE_TOGGLE_NAME)?.isEnabled() ?: false + } + + private val canEmit: Deferred = appCoroutineScope.async(start = LAZY) { + getToggle(FEATURE_EMIT_TOGGLE_NAME)?.isEnabled() ?: false + } + + private val bucketConfig: Deferred = appCoroutineScope.async(start = LAZY) { + attributedMetricConfig.getBucketConfiguration()[PIXEL_NAME] ?: MetricBucket( + buckets = listOf(2, 5), + version = 0, + ) + } + + override fun onAdClick() { + appCoroutineScope.launch(dispatcherProvider.io()) { + if (!isEnabled.await()) return@launch + attributedMetricClient.collectEvent(EVENT_NAME) + if (shouldSendPixel().not()) { + logcat(tag = "AttributedMetrics") { + "AdClickCount7d: Skip emitting, not enough data or no events" + } + return@launch + } + + if (canEmit.await()) { + attributedMetricClient.emitMetric(this@RealAdClickAttributedMetric) + } + } + } + + override fun getPixelName(): String = PIXEL_NAME + + override suspend fun getMetricParameters(): Map { + val stats = getEventStats() + val params = mutableMapOf( + "count" to getBucketValue(stats.rollingAverage.roundToInt()).toString(), + "version" to bucketConfig.await().version.toString(), + ) + if (!hasCompleteDataWindow()) { + params["dayAverage"] = daysSinceInstalled().toString() + } + return params + } + + override suspend fun getTag(): String { + return daysSinceInstalled().toString() + } + + private suspend fun getBucketValue(avg: Int): Int { + val buckets = bucketConfig.await().buckets + return buckets.indexOfFirst { bucket -> avg <= bucket }.let { index -> + if (index == -1) buckets.size else index + } + } + + private suspend fun shouldSendPixel(): Boolean { + if (daysSinceInstalled() <= 0) { + // installation day, we don't emit + return false + } + + val eventStats = getEventStats() + if (eventStats.daysWithEvents == 0 || eventStats.rollingAverage == 0.0) { + // no events, nothing to emit + return false + } + + return true + } + + private fun hasCompleteDataWindow(): Boolean { + val daysSinceInstalled = daysSinceInstalled() + return daysSinceInstalled >= DAYS_WINDOW + } + + private suspend fun getEventStats(): EventStats { + val daysSinceInstall = daysSinceInstalled() + val stats = if (daysSinceInstall >= DAYS_WINDOW) { + attributedMetricClient.getEventStats(EVENT_NAME, DAYS_WINDOW) + } else { + attributedMetricClient.getEventStats(EVENT_NAME, daysSinceInstall) + } + + return stats + } + + private fun daysSinceInstalled(): Int { + val etZone = ZoneId.of("America/New_York") + val installInstant = Instant.ofEpochMilli(appInstall.getInstallationTimestamp()) + val nowInstant = Instant.now() + + val installInEt = installInstant.atZone(etZone) + val nowInEt = nowInstant.atZone(etZone) + + return ChronoUnit.DAYS.between(installInEt.toLocalDate(), nowInEt.toLocalDate()).toInt() + } + + private suspend fun getToggle(toggleName: String) = + attributedMetricConfig.metricsToggles().firstOrNull { toggle -> + toggle.featureName().name == toggleName + } +} diff --git a/ad-click/ad-click-impl/src/test/java/com/duckduckgo/adclick/impl/DuckDuckGoAdClickManagerTest.kt b/ad-click/ad-click-impl/src/test/java/com/duckduckgo/adclick/impl/DuckDuckGoAdClickManagerTest.kt index 42f65b813a67..eb860cc4648b 100644 --- a/ad-click/ad-click-impl/src/test/java/com/duckduckgo/adclick/impl/DuckDuckGoAdClickManagerTest.kt +++ b/ad-click/ad-click-impl/src/test/java/com/duckduckgo/adclick/impl/DuckDuckGoAdClickManagerTest.kt @@ -18,6 +18,7 @@ package com.duckduckgo.adclick.impl import androidx.test.ext.junit.runners.AndroidJUnit4 import com.duckduckgo.adclick.api.AdClickManager +import com.duckduckgo.adclick.impl.metrics.AdClickCollector import com.duckduckgo.adclick.impl.pixels.AdClickPixelName import com.duckduckgo.adclick.impl.pixels.AdClickPixels import org.junit.Assert.assertFalse @@ -40,11 +41,12 @@ class DuckDuckGoAdClickManagerTest { private val mockAdClickData: AdClickData = mock() private val mockAdClickAttribution: AdClickAttribution = mock() private val mockAdClickPixels: AdClickPixels = mock() + private val mockAdClickCollector: AdClickCollector = mock() private lateinit var testee: AdClickManager @Before fun before() { - testee = DuckDuckGoAdClickManager(mockAdClickData, mockAdClickAttribution, mockAdClickPixels) + testee = DuckDuckGoAdClickManager(mockAdClickData, mockAdClickAttribution, mockAdClickPixels, mockAdClickCollector) } @Test diff --git a/ad-click/ad-click-impl/src/test/java/com/duckduckgo/adclick/impl/metrics/RealAdClickAttributedMetricTest.kt b/ad-click/ad-click-impl/src/test/java/com/duckduckgo/adclick/impl/metrics/RealAdClickAttributedMetricTest.kt new file mode 100644 index 000000000000..e82dfbff24c8 --- /dev/null +++ b/ad-click/ad-click-impl/src/test/java/com/duckduckgo/adclick/impl/metrics/RealAdClickAttributedMetricTest.kt @@ -0,0 +1,291 @@ +/* + * 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.adclick.impl.metrics + +import android.annotation.SuppressLint +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.duckduckgo.app.attributed.metrics.api.AttributedMetricClient +import com.duckduckgo.app.attributed.metrics.api.AttributedMetricConfig +import com.duckduckgo.app.attributed.metrics.api.EventStats +import com.duckduckgo.app.attributed.metrics.api.MetricBucket +import com.duckduckgo.browser.api.install.AppInstall +import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory +import com.duckduckgo.feature.toggles.api.Toggle +import com.duckduckgo.feature.toggles.api.Toggle.DefaultFeatureValue +import com.duckduckgo.feature.toggles.api.Toggle.State +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import java.time.Instant +import java.time.ZoneId + +@SuppressLint("DenyListedApi") +@RunWith(AndroidJUnit4::class) +class RealAdClickAttributedMetricTest { + + @get:Rule val coroutineRule = CoroutineTestRule() + + private val attributedMetricClient: AttributedMetricClient = mock() + private val appInstall: AppInstall = mock() + private val attributedMetricConfig: AttributedMetricConfig = mock() + private val adClickToggle = FakeFeatureToggleFactory.create(FakeAttributedMetricsConfigFeature::class.java) + + private lateinit var testee: RealAdClickAttributedMetric + + @Before fun setup() = runTest { + adClickToggle.adClickCountAvg().setRawStoredState(State(true)) + adClickToggle.canEmitAdClickCountAvg().setRawStoredState(State(true)) + whenever(attributedMetricConfig.metricsToggles()).thenReturn( + listOf(adClickToggle.adClickCountAvg(), adClickToggle.canEmitAdClickCountAvg()), + ) + whenever(attributedMetricConfig.getBucketConfiguration()).thenReturn( + mapOf( + "attributed_metric_average_ad_clicks_past_week" to MetricBucket( + buckets = listOf(2, 5), + version = 0, + ), + ), + ) + testee = RealAdClickAttributedMetric( + appCoroutineScope = coroutineRule.testScope, + dispatcherProvider = coroutineRule.testDispatcherProvider, + attributedMetricClient = attributedMetricClient, + appInstall = appInstall, + attributedMetricConfig = attributedMetricConfig, + ) + } + + @Test fun whenPixelNameRequestedThenReturnCorrectName() { + assertEquals("attributed_metric_average_ad_clicks_past_week", testee.getPixelName()) + } + + @Test fun whenAdClickAndDaysInstalledIsZeroThenDoNotEmitMetric() = runTest { + givenDaysSinceInstalled(0) + + testee.onAdClick() + + verify(attributedMetricClient).collectEvent("ad_click") + verify(attributedMetricClient, never()).emitMetric(testee) + } + + @Test fun whenAdClickAndNoEventsThenDoNotEmitMetric() = runTest { + givenDaysSinceInstalled(7) + whenever(attributedMetricClient.getEventStats("ad_click", 7)).thenReturn( + EventStats( + daysWithEvents = 0, + rollingAverage = 0.0, + totalEvents = 0, + ), + ) + + testee.onAdClick() + + verify(attributedMetricClient).collectEvent("ad_click") + verify(attributedMetricClient, never()).emitMetric(testee) + } + + @Test fun whenAdClickAndHasEventsThenEmitMetric() = runTest { + givenDaysSinceInstalled(7) + whenever(attributedMetricClient.getEventStats("ad_click", 7)).thenReturn( + EventStats( + daysWithEvents = 1, + rollingAverage = 1.0, + totalEvents = 1, + ), + ) + + testee.onAdClick() + + verify(attributedMetricClient).collectEvent("ad_click") + verify(attributedMetricClient).emitMetric(testee) + } + + @Test + fun whenAdClickedButFFDisabledThenDoNotCollectAndDoNotEmitMetric() = runTest { + adClickToggle.adClickCountAvg().setRawStoredState(State(false)) + whenever(attributedMetricConfig.metricsToggles()).thenReturn( + listOf(adClickToggle.adClickCountAvg(), adClickToggle.canEmitAdClickCountAvg()), + ) + givenDaysSinceInstalled(7) + whenever(attributedMetricClient.getEventStats("ad_click", 7)).thenReturn( + EventStats( + daysWithEvents = 1, + rollingAverage = 1.0, + totalEvents = 1, + ), + ) + + testee.onAdClick() + + verify(attributedMetricClient, never()).collectEvent("ad_click") + verify(attributedMetricClient, never()).emitMetric(testee) + } + + @Test fun whenAdClickedButEmitDisabledThenCollectButDoNotEmitMetric() = runTest { + adClickToggle.adClickCountAvg().setRawStoredState(State(true)) + adClickToggle.canEmitAdClickCountAvg().setRawStoredState(State(false)) + whenever(attributedMetricConfig.metricsToggles()).thenReturn( + listOf(adClickToggle.adClickCountAvg(), adClickToggle.canEmitAdClickCountAvg()), + ) + givenDaysSinceInstalled(7) + whenever(attributedMetricClient.getEventStats("ad_click", 7)).thenReturn( + EventStats( + daysWithEvents = 1, + rollingAverage = 1.0, + totalEvents = 1, + ), + ) + + testee.onAdClick() + + verify(attributedMetricClient).collectEvent("ad_click") + verify(attributedMetricClient, never()).emitMetric(testee) + } + + @Test fun whenDaysInstalledLessThanWindowThenIncludeDayAverageParameter() = runTest { + givenDaysSinceInstalled(5) + whenever(attributedMetricClient.getEventStats("ad_click", 5)).thenReturn( + EventStats( + daysWithEvents = 1, + rollingAverage = 1.0, + totalEvents = 1, + ), + ) + + val params = testee.getMetricParameters() + + assertEquals("5", params["dayAverage"]) + } + + @Test fun whenDaysInstalledGreaterThanWindowThenOmitDayAverageParameter() = runTest { + givenDaysSinceInstalled(8) + whenever(attributedMetricClient.getEventStats("ad_click", 7)).thenReturn( + EventStats( + daysWithEvents = 1, + rollingAverage = 1.0, + totalEvents = 1, + ), + ) + + val params = testee.getMetricParameters() + + assertNull(params["dayAverage"]) + } + + @Test fun whenGetMetricParametersThenReturnCorrectBucketValue() = runTest { + // Map of average clicks to expected bucket value + // clicks avg -> bucket + val bucketRanges = mapOf( + 0.0 to 0, // 0 clicks -> bucket 0 (≤2) + 1.0 to 0, // 1 click -> bucket 0 (≤2) + 2.0 to 0, // 2 clicks -> bucket 0 (≤2) + 2.1 to 0, // 2.1 clicks rounds to 2 -> bucket 0 (≤2) + 2.5 to 1, // 2.5 clicks rounds to 3 -> bucket 1 (≤5) + 2.7 to 1, // 2.7 clicks rounds to 3 -> bucket 1 (≤5) + 3.0 to 1, // 3 clicks -> bucket 1 (≤5) + 5.0 to 1, // 5 clicks -> bucket 1 (≤5) + 5.1 to 1, // 5.1 clicks rounds to 5 -> bucket 1 (≤5) + 6.0 to 2, // 6 clicks -> bucket 2 (>5) + 10.0 to 2, // 10 clicks -> bucket 2 (>5) + ) + + bucketRanges.forEach { (clicksAvg, expectedBucket) -> + givenDaysSinceInstalled(8) + whenever(attributedMetricClient.getEventStats("ad_click", 7)).thenReturn( + EventStats( + daysWithEvents = 1, // not relevant for this test + rollingAverage = clicksAvg, + totalEvents = 1, // not relevant for this test + ), + ) + + val count = testee.getMetricParameters()["count"] + + assertEquals( + "For $clicksAvg clicks, should return bucket $expectedBucket", + expectedBucket.toString(), + count, + ) + } + } + + @Test fun whenDaysInstalledThenReturnCorrectTag() = runTest { + // Test different days + // days installed -> expected tag + val testCases = mapOf( + 0 to "0", + 1 to "1", + 7 to "7", + 30 to "30", + ) + + testCases.forEach { (days, expectedTag) -> + givenDaysSinceInstalled(days) + + val tag = testee.getTag() + + assertEquals( + "For $days days installed, should return tag $expectedTag", + expectedTag, + tag, + ) + } + } + + @Test fun whenGetMetricParametersThenReturnVersion() = runTest { + givenDaysSinceInstalled(7) + whenever(attributedMetricClient.getEventStats("ad_click", 7)).thenReturn( + EventStats( + daysWithEvents = 1, + rollingAverage = 1.0, + totalEvents = 1, + ), + ) + + val version = testee.getMetricParameters()["version"] + + assertEquals("0", version) + } + + private fun givenDaysSinceInstalled(days: Int) { + val etZone = ZoneId.of("America/New_York") + val now = Instant.now() + val nowInEt = now.atZone(etZone) + val installInEt = nowInEt.minusDays(days.toLong()) + whenever(appInstall.getInstallationTimestamp()).thenReturn(installInEt.toInstant().toEpochMilli()) + } +} + +interface FakeAttributedMetricsConfigFeature { + @Toggle.DefaultValue(DefaultFeatureValue.INTERNAL) + fun self(): Toggle + + @Toggle.DefaultValue(DefaultFeatureValue.INTERNAL) + fun adClickCountAvg(): Toggle + + @Toggle.DefaultValue(DefaultFeatureValue.INTERNAL) + fun canEmitAdClickCountAvg(): Toggle +} diff --git a/app/src/main/java/com/duckduckgo/app/referral/AppReferrerDataStore.kt b/app/src/main/java/com/duckduckgo/app/referral/AppReferrerDataStore.kt index 165e8a69325c..e140a7c70355 100644 --- a/app/src/main/java/com/duckduckgo/app/referral/AppReferrerDataStore.kt +++ b/app/src/main/java/com/duckduckgo/app/referral/AppReferrerDataStore.kt @@ -57,6 +57,8 @@ class AppReferenceSharePreferences @Inject constructor( } } + override fun getOriginAttributeCampaign(): String? = utmOriginAttributeCampaign + override var campaignSuffix: String? get() = preferences.getString(KEY_CAMPAIGN_SUFFIX, null) set(value) = preferences.edit(true) { putString(KEY_CAMPAIGN_SUFFIX, value) } diff --git a/attributed-metrics/attributed-metrics-api/build.gradle b/attributed-metrics/attributed-metrics-api/build.gradle index d3a1defd058d..43e681891350 100644 --- a/attributed-metrics/attributed-metrics-api/build.gradle +++ b/attributed-metrics/attributed-metrics-api/build.gradle @@ -33,4 +33,6 @@ kotlin { dependencies { implementation Kotlin.stdlib.jdk7 implementation KotlinX.coroutines.core + + implementation project(path: ':feature-toggles-api') } diff --git a/attributed-metrics/attributed-metrics-api/src/main/java/com/duckduckgo/app/attributed/metrics/api/AttributedMetricsClient.kt b/attributed-metrics/attributed-metrics-api/src/main/java/com/duckduckgo/app/attributed/metrics/api/AttributedMetricsClient.kt index 2e4132a39719..05238b7da283 100644 --- a/attributed-metrics/attributed-metrics-api/src/main/java/com/duckduckgo/app/attributed/metrics/api/AttributedMetricsClient.kt +++ b/attributed-metrics/attributed-metrics-api/src/main/java/com/duckduckgo/app/attributed/metrics/api/AttributedMetricsClient.kt @@ -16,6 +16,8 @@ package com.duckduckgo.app.attributed.metrics.api +import com.duckduckgo.feature.toggles.api.Toggle + /** * Client for collecting and emitting attributed metrics. */ @@ -84,3 +86,22 @@ interface AttributedMetric { */ suspend fun getTag(): String } + +interface AttributedMetricConfig { + /** + * Provides attributed Metrics subfeature Toggles. Each metric to find their toggle and react to enabled state. + * @return List of Toggles that belong to Attributed Metrics feature + */ + suspend fun metricsToggles(): List + + /** + * Provides metrics bucket configuration, on a key-value. Each metric to consume map, and obtain the bucket config from he metric they own. + * @return Map of metric keys to their bucket configurations + */ + suspend fun getBucketConfiguration(): Map +} + +data class MetricBucket( + val buckets: List, + val version: Int, +) diff --git a/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/AttributedMetricsConfigFeature.kt b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/AttributedMetricsConfigFeature.kt index 42306fa90d65..b16979c63129 100644 --- a/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/AttributedMetricsConfigFeature.kt +++ b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/AttributedMetricsConfigFeature.kt @@ -28,4 +28,46 @@ import com.duckduckgo.feature.toggles.api.Toggle.DefaultFeatureValue interface AttributedMetricsConfigFeature { @Toggle.DefaultValue(DefaultFeatureValue.INTERNAL) fun self(): Toggle + + @Toggle.DefaultValue(DefaultFeatureValue.INTERNAL) + fun emitAllMetrics(): Toggle + + @Toggle.DefaultValue(DefaultFeatureValue.INTERNAL) + fun retention(): Toggle + + @Toggle.DefaultValue(DefaultFeatureValue.INTERNAL) + fun canEmitRetention(): Toggle + + @Toggle.DefaultValue(DefaultFeatureValue.INTERNAL) + fun searchDaysAvg(): Toggle + + @Toggle.DefaultValue(DefaultFeatureValue.INTERNAL) + fun canEmitSearchDaysAvg(): Toggle + + @Toggle.DefaultValue(DefaultFeatureValue.INTERNAL) + fun searchCountAvg(): Toggle + + @Toggle.DefaultValue(DefaultFeatureValue.INTERNAL) + fun canEmitSearchCountAvg(): Toggle + + @Toggle.DefaultValue(DefaultFeatureValue.INTERNAL) + fun adClickCountAvg(): Toggle + + @Toggle.DefaultValue(DefaultFeatureValue.INTERNAL) + fun canEmitAdClickCountAvg(): Toggle + + @Toggle.DefaultValue(DefaultFeatureValue.INTERNAL) + fun aiUsageAvg(): Toggle + + @Toggle.DefaultValue(DefaultFeatureValue.INTERNAL) + fun canEmitAIUsageAvg(): Toggle + + @Toggle.DefaultValue(DefaultFeatureValue.INTERNAL) + fun subscriptionRetention(): Toggle + + @Toggle.DefaultValue(DefaultFeatureValue.INTERNAL) + fun canEmitSubscriptionRetention(): Toggle + + @Toggle.DefaultValue(DefaultFeatureValue.INTERNAL) + fun syncDevices(): Toggle } diff --git a/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/impl/AttributeMetricsConfig.kt b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/impl/AttributeMetricsConfig.kt new file mode 100644 index 000000000000..da37b9bae776 --- /dev/null +++ b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/impl/AttributeMetricsConfig.kt @@ -0,0 +1,79 @@ +/* + * 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.attributed.metrics.impl + +import com.duckduckgo.app.attributed.metrics.AttributedMetricsConfigFeature +import com.duckduckgo.app.attributed.metrics.api.AttributedMetricConfig +import com.duckduckgo.app.attributed.metrics.api.MetricBucket +import com.duckduckgo.app.di.AppCoroutineScope +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.feature.toggles.api.FeatureTogglesInventory +import com.duckduckgo.feature.toggles.api.Toggle +import com.squareup.anvil.annotations.ContributesBinding +import com.squareup.moshi.Json +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.Moshi +import com.squareup.moshi.Types +import dagger.SingleInstanceIn +import kotlinx.coroutines.CoroutineScope +import javax.inject.Inject + +@ContributesBinding(AppScope::class, AttributedMetricConfig::class) +@SingleInstanceIn(AppScope::class) +class AttributeMetricsConfig @Inject constructor( + @AppCoroutineScope private val appCoroutineScope: CoroutineScope, + private val dispatcherProvider: DispatcherProvider, + private val attributedMetricsConfigFeature: AttributedMetricsConfigFeature, + private val featureTogglesInvestory: FeatureTogglesInventory, + private val moshi: Moshi, +) : AttributedMetricConfig { + + private val jsonAdapter: JsonAdapter> by lazy { + val type = Types.newParameterizedType(Map::class.java, String::class.java, JsonMetricBucket::class.java) + moshi.adapter(type) + } + + data class JsonMetricBucket( + @Json(name = "buckets") val buckets: List, + @Json(name = "version") val version: Int, + ) + + override suspend fun metricsToggles(): List { + if (!attributedMetricsConfigFeature.self().isEnabled()) { + return emptyList() + } + return featureTogglesInvestory.getAllTogglesForParent(attributedMetricsConfigFeature.self().featureName().name) + } + + override suspend fun getBucketConfiguration(): Map { + if (!attributedMetricsConfigFeature.self().isEnabled()) { + return emptyMap() + } + + val metricConfigs = kotlin.runCatching { + attributedMetricsConfigFeature.self().getSettings()?.let { jsonAdapter.fromJson(it) } + }.getOrNull()?.map { entry -> + entry.key to MetricBucket( + buckets = entry.value.buckets, + version = entry.value.version, + ) + }?.toMap() ?: emptyMap() + + return metricConfigs + } +} diff --git a/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/impl/AttributedMetricsState.kt b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/impl/AttributedMetricsState.kt index dffeb53e00d5..df81b870c6d9 100644 --- a/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/impl/AttributedMetricsState.kt +++ b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/impl/AttributedMetricsState.kt @@ -20,13 +20,13 @@ import androidx.lifecycle.LifecycleOwner import com.duckduckgo.app.attributed.metrics.AttributedMetricsConfigFeature import com.duckduckgo.app.attributed.metrics.store.AttributedMetricsDataStore import com.duckduckgo.app.attributed.metrics.store.AttributedMetricsDateUtils +import com.duckduckgo.app.attributed.metrics.store.EventRepository import com.duckduckgo.app.di.AppCoroutineScope import com.duckduckgo.app.lifecycle.MainProcessLifecycleObserver import com.duckduckgo.app.statistics.api.AtbLifecyclePlugin import com.duckduckgo.appbuildconfig.api.AppBuildConfig import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.AppScope -import com.duckduckgo.privacy.config.api.PrivacyConfigCallbackPlugin import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesMultibinding import dagger.SingleInstanceIn @@ -44,6 +44,7 @@ import javax.inject.Inject */ interface AttributedMetricsState { suspend fun isActive(): Boolean + suspend fun canEmitMetrics(): Boolean } @ContributesBinding( @@ -58,10 +59,6 @@ interface AttributedMetricsState { scope = AppScope::class, boundType = AtbLifecyclePlugin::class, ) -@ContributesMultibinding( - scope = AppScope::class, - boundType = PrivacyConfigCallbackPlugin::class, -) @SingleInstanceIn(AppScope::class) class RealAttributedMetricsState @Inject constructor( @AppCoroutineScope private val appCoroutineScope: CoroutineScope, @@ -70,7 +67,8 @@ class RealAttributedMetricsState @Inject constructor( private val attributedMetricsConfigFeature: AttributedMetricsConfigFeature, private val appBuildConfig: AppBuildConfig, private val attributedMetricsDateUtils: AttributedMetricsDateUtils, -) : AttributedMetricsState, MainProcessLifecycleObserver, AtbLifecyclePlugin, PrivacyConfigCallbackPlugin { + private val eventRepository: EventRepository, +) : AttributedMetricsState, MainProcessLifecycleObserver, AtbLifecyclePlugin { override fun onCreate(owner: LifecycleOwner) { appCoroutineScope.launch(dispatcherProvider.io()) { @@ -84,7 +82,7 @@ class RealAttributedMetricsState @Inject constructor( logcat(tag = "AttributedMetrics") { "Detected New Install, try to initialize Attributed Metrics" } - if (attributedMetricsConfigFeature.self().isEnabled().not()) { + if (!isEnabled()) { logcat(tag = "AttributedMetrics") { "Client disabled from remote config, skipping initialization" } @@ -115,18 +113,9 @@ class RealAttributedMetricsState @Inject constructor( } } - override fun onPrivacyConfigDownloaded() { - appCoroutineScope.launch(dispatcherProvider.io()) { - val toggleEnabledState = attributedMetricsConfigFeature.self().isEnabled() - logcat(tag = "AttributedMetrics") { - "Privacy config downloaded, update client toggle state: $toggleEnabledState" - } - dataStore.setEnabled(toggleEnabledState) - logClientStatus() - } - } + override suspend fun isActive(): Boolean = isEnabled() && dataStore.isActive() && dataStore.getInitializationDate() != null - override suspend fun isActive(): Boolean = dataStore.isActive() && dataStore.isEnabled() && dataStore.getInitializationDate() != null + override suspend fun canEmitMetrics(): Boolean = isActive() && emitMetricsEnabled() private suspend fun checkCollectionPeriodAndUpdateState() { val initDate = dataStore.getInitializationDate() @@ -138,6 +127,8 @@ class RealAttributedMetricsState @Inject constructor( return } + if (dataStore.isActive().not()) return // if already inactive, no need to check further + val daysSinceInit = attributedMetricsDateUtils.daysSince(initDate) val isWithinPeriod = daysSinceInit <= COLLECTION_PERIOD_DAYS val newClientActiveState = isWithinPeriod && dataStore.isActive() @@ -146,14 +137,22 @@ class RealAttributedMetricsState @Inject constructor( "Updating client state to $newClientActiveState result of -> within period? $isWithinPeriod, client active? ${dataStore.isActive()}" } dataStore.setActive(newClientActiveState) + if (!isWithinPeriod) { + eventRepository.deleteAllEvents() + } + logClientStatus() } private suspend fun logClientStatus() = logcat(tag = "AttributedMetrics") { - "Client status running: ${isActive()} -> isActive: ${dataStore.isActive()}, isEnabled: ${dataStore.isEnabled()}," + + "Client status running: ${isActive()} -> isActive: ${dataStore.isActive()}, isEnabled: ${isEnabled()}," + " initializationDate: ${dataStore.getInitializationDate()}" } + private fun isEnabled(): Boolean = attributedMetricsConfigFeature.self().isEnabled() + + private fun emitMetricsEnabled(): Boolean = attributedMetricsConfigFeature.emitAllMetrics().isEnabled() + companion object { private const val COLLECTION_PERIOD_DAYS = 168 // 24 weeks * 7 days (6 months in weeks) } diff --git a/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/impl/RealAttributedMetricClient.kt b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/impl/RealAttributedMetricClient.kt index bb59766bcf47..a9710823fcf5 100644 --- a/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/impl/RealAttributedMetricClient.kt +++ b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/impl/RealAttributedMetricClient.kt @@ -19,10 +19,13 @@ package com.duckduckgo.app.attributed.metrics.impl import com.duckduckgo.app.attributed.metrics.api.AttributedMetric import com.duckduckgo.app.attributed.metrics.api.AttributedMetricClient import com.duckduckgo.app.attributed.metrics.api.EventStats +import com.duckduckgo.app.attributed.metrics.store.AttributedMetricsDateUtils import com.duckduckgo.app.attributed.metrics.store.EventRepository import com.duckduckgo.app.di.AppCoroutineScope import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Unique +import com.duckduckgo.browser.api.install.AppInstall +import com.duckduckgo.browser.api.referrer.AppReferrer import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.AppScope import com.squareup.anvil.annotations.ContributesBinding @@ -41,6 +44,9 @@ class RealAttributedMetricClient @Inject constructor( private val eventRepository: EventRepository, private val pixel: Pixel, private val metricsState: AttributedMetricsState, + private val appReferrer: AppReferrer, + private val dateUtils: AttributedMetricsDateUtils, + private val appInstall: AppInstall, ) : AttributedMetricClient { override fun collectEvent(eventName: String) { @@ -77,24 +83,36 @@ class RealAttributedMetricClient @Inject constructor( } } - // TODO: Pending adding default attributed metrics and removing default prefix from pixel names override fun emitMetric(metric: AttributedMetric) { appCoroutineScope.launch(dispatcherProvider.io()) { - if (!metricsState.isActive()) { + if (!metricsState.isActive() || !metricsState.canEmitMetrics()) { logcat(tag = "AttributedMetrics") { "Discard pixel, client not active" } return@launch } + val pixelName = metric.getPixelName() val params = metric.getMetricParameters() val tag = metric.getTag() val pixelTag = "${pixelName}_$tag" - pixel.fire(pixelName = pixelName, parameters = params, type = Unique(pixelTag)).also { + + val origin = appReferrer.getOriginAttributeCampaign() + val paramsMutableMap = params.toMutableMap() + if (!origin.isNullOrBlank()) { + paramsMutableMap["origin"] = origin + } else { + paramsMutableMap["install_date"] = getInstallDate() + } + pixel.fire(pixelName = pixelName, parameters = paramsMutableMap, type = Unique(pixelTag)).also { logcat(tag = "AttributedMetrics") { - "Fired pixel $pixelName with params $params" + "Fired pixel $pixelName with params $paramsMutableMap" } } } } + + private fun getInstallDate(): String { + return dateUtils.getDateFromTimestamp(appInstall.getInstallationTimestamp()) + } } diff --git a/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/impl/plugins/AttributedMetricPluginPoint.kt b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/impl/plugins/AttributedMetricPluginPoint.kt new file mode 100644 index 000000000000..80075fe00110 --- /dev/null +++ b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/impl/plugins/AttributedMetricPluginPoint.kt @@ -0,0 +1,28 @@ +/* + * 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.attributed.metrics.impl.plugins + +import com.duckduckgo.anvil.annotations.ContributesPluginPoint +import com.duckduckgo.app.attributed.metrics.api.AttributedMetric +import com.duckduckgo.di.scopes.AppScope + +@ContributesPluginPoint( + scope = AppScope::class, + boundType = AttributedMetric::class, +) +@Suppress("unused") +interface AttributedMetricPluginPoint diff --git a/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/pixels/AttributedMetricPixelInterceptor.kt b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/pixels/AttributedMetricPixelInterceptor.kt new file mode 100644 index 000000000000..8afe3cbaa52d --- /dev/null +++ b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/pixels/AttributedMetricPixelInterceptor.kt @@ -0,0 +1,53 @@ +/* + * 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.attributed.metrics.pixels + +import com.duckduckgo.common.utils.device.DeviceInfo +import com.duckduckgo.common.utils.plugins.pixel.PixelInterceptorPlugin +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesMultibinding +import logcat.logcat +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.Interceptor +import okhttp3.Response +import javax.inject.Inject + +@ContributesMultibinding( + scope = AppScope::class, + boundType = PixelInterceptorPlugin::class, +) +class AttributedMetricPixelInterceptor @Inject constructor() : Interceptor, PixelInterceptorPlugin { + + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request().newBuilder() + var url = chain.request().url + val pixel = chain.request().url.pathSegments.last() + if (pixel.startsWith(ATTRIBUTED_METRICS_PIXEL_PREFIX)) { + url = url.toUrl().toString().replace("android_${DeviceInfo.FormFactor.PHONE.description}", "android").toHttpUrl() + logcat(tag = "AttributedMetrics") { + "Pixel renamed to: $url" + } + } + return chain.proceed(request.url(url).build()) + } + + override fun getInterceptor() = this + + companion object { + const val ATTRIBUTED_METRICS_PIXEL_PREFIX = "attributed_metric" + } +} diff --git a/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/pixels/AttributedMetricPixelRemovalInterceptor.kt b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/pixels/AttributedMetricPixelRemovalInterceptor.kt new file mode 100644 index 000000000000..cb122a0f813a --- /dev/null +++ b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/pixels/AttributedMetricPixelRemovalInterceptor.kt @@ -0,0 +1,36 @@ +/* + * 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.attributed.metrics.pixels + +import com.duckduckgo.common.utils.plugins.pixel.PixelParamRemovalPlugin +import com.duckduckgo.common.utils.plugins.pixel.PixelParamRemovalPlugin.PixelParameter +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesMultibinding + +@ContributesMultibinding( + scope = AppScope::class, + boundType = PixelParamRemovalPlugin::class, +) +object AttributedMetricPixelRemovalInterceptor : PixelParamRemovalPlugin { + override fun names(): List>> { + return listOf( + ATTRIBUTED_METRICS_PIXEL_PREFIX to PixelParameter.removeAll(), + ) + } + + private const val ATTRIBUTED_METRICS_PIXEL_PREFIX = "attributed_metric" +} diff --git a/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/RetentionMonthAttributedMetric.kt b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/retention/RetentionMonthAttributedMetric.kt similarity index 52% rename from attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/RetentionMonthAttributedMetric.kt rename to attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/retention/RetentionMonthAttributedMetric.kt index 8a96dd9c1c86..b7a568ff6165 100644 --- a/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/RetentionMonthAttributedMetric.kt +++ b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/retention/RetentionMonthAttributedMetric.kt @@ -14,10 +14,12 @@ * limitations under the License. */ -package com.duckduckgo.app.attributed.metrics +package com.duckduckgo.app.attributed.metrics.retention import com.duckduckgo.app.attributed.metrics.api.AttributedMetric import com.duckduckgo.app.attributed.metrics.api.AttributedMetricClient +import com.duckduckgo.app.attributed.metrics.api.AttributedMetricConfig +import com.duckduckgo.app.attributed.metrics.api.MetricBucket import com.duckduckgo.app.attributed.metrics.store.AttributedMetricsDateUtils import com.duckduckgo.app.di.AppCoroutineScope import com.duckduckgo.app.statistics.api.AtbLifecyclePlugin @@ -27,6 +29,9 @@ import com.duckduckgo.di.scopes.AppScope import com.squareup.anvil.annotations.ContributesMultibinding import dagger.SingleInstanceIn import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart.LAZY +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.async import kotlinx.coroutines.launch import logcat.logcat import javax.inject.Inject @@ -40,12 +45,31 @@ class RetentionMonthAttributedMetric @Inject constructor( private val appInstall: AppInstall, private val attributedMetricClient: AttributedMetricClient, private val dateUtils: AttributedMetricsDateUtils, + private val attributedMetricConfig: AttributedMetricConfig, ) : AttributedMetric, AtbLifecyclePlugin { companion object { - private const val PIXEL_NAME_FIRST_MONTH = "user_retention_month" + private const val PIXEL_NAME_FIRST_MONTH = "attributed_metric_retention_month" private const val DAYS_IN_4_WEEKS = 28 // we consider 1 month after 4 weeks - private const val FIRST_MONTH_DAY_THRESHOLD = DAYS_IN_4_WEEKS + 1 + private const val MONTH_DAY_THRESHOLD = DAYS_IN_4_WEEKS + 1 + private const val START_MONTH_THRESHOLD = 2 + private const val FEATURE_TOGGLE_NAME = "retention" + private const val FEATURE_EMIT_TOGGLE_NAME = "canEmitRetention" + } + + private val isEnabled: Deferred = appCoroutineScope.async(start = LAZY) { + getToggle(FEATURE_TOGGLE_NAME)?.isEnabled() ?: false + } + + private val canEmit: Deferred = appCoroutineScope.async(start = LAZY) { + getToggle(FEATURE_EMIT_TOGGLE_NAME)?.isEnabled() ?: false + } + + private val bucketConfig: Deferred = appCoroutineScope.async(start = LAZY) { + attributedMetricConfig.getBucketConfiguration()[PIXEL_NAME_FIRST_MONTH] ?: MetricBucket( + buckets = listOf(2, 3, 4, 5), + version = 0, + ) } override fun onAppRetentionAtbRefreshed( @@ -53,6 +77,8 @@ class RetentionMonthAttributedMetric @Inject constructor( newAtb: String, ) { appCoroutineScope.launch(dispatcherProvider.io()) { + if (!isEnabled.await()) return@launch + if (oldAtb == newAtb) { logcat(tag = "AttributedMetrics") { "RetentionMonth: Skip emitting atb not changed" @@ -65,42 +91,60 @@ class RetentionMonthAttributedMetric @Inject constructor( } return@launch } - attributedMetricClient.emitMetric(this@RetentionMonthAttributedMetric) + if (canEmit.await()) { + attributedMetricClient.emitMetric(this@RetentionMonthAttributedMetric) + } } } override fun getPixelName(): String = PIXEL_NAME_FIRST_MONTH override suspend fun getMetricParameters(): Map { - val days = daysSinceInstalled() - if (days < FIRST_MONTH_DAY_THRESHOLD) return emptyMap() + val month = getMonthSinceInstall() + if (month < START_MONTH_THRESHOLD) return emptyMap() - val week = getPeriod(days) - return if (week > 0) { - mutableMapOf("count" to week.toString()) - } else { - emptyMap() - } + val params = mutableMapOf( + "count" to bucketMonth(month).toString(), + "version" to bucketConfig.await().version.toString(), + ) + return params } override suspend fun getTag(): String { - val days = daysSinceInstalled() - return getPeriod(days).toString() + val month = getMonthSinceInstall() + return bucketMonth(month).toString() } private fun shouldSendPixel(): Boolean { - val days = daysSinceInstalled() - if (days < FIRST_MONTH_DAY_THRESHOLD) return false + val month = getMonthSinceInstall() + if (month < START_MONTH_THRESHOLD) return false return true } - private fun getPeriod(day: Int): Int { - if (day < FIRST_MONTH_DAY_THRESHOLD) return 0 - return ((day - FIRST_MONTH_DAY_THRESHOLD) / DAYS_IN_4_WEEKS) + 1 + private fun getMonthSinceInstall(): Int { + val daysSinceInstall = daysSinceInstalled() + return if (daysSinceInstall < MONTH_DAY_THRESHOLD) { + return 1 + } else { + ((daysSinceInstall - MONTH_DAY_THRESHOLD) / DAYS_IN_4_WEEKS) + 2 + } + } + + private suspend fun bucketMonth(month: Int): Int { + if (month < START_MONTH_THRESHOLD) return -1 + val buckets = bucketConfig.await().buckets + return buckets.indexOfFirst { bucket -> month <= bucket }.let { index -> + if (index == -1) buckets.size else index + } } private fun daysSinceInstalled(): Int { return dateUtils.daysSince(appInstall.getInstallationTimestamp()) } + + private suspend fun getToggle(toggleName: String) = + attributedMetricConfig.metricsToggles().firstOrNull { toggle -> + toggle.featureName().name == toggleName + } } diff --git a/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/RetentionWeekAttributedMetric.kt b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/retention/RetentionWeekAttributedMetric.kt similarity index 54% rename from attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/RetentionWeekAttributedMetric.kt rename to attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/retention/RetentionWeekAttributedMetric.kt index 1e9fc191373d..a83de642ecf4 100644 --- a/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/RetentionWeekAttributedMetric.kt +++ b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/retention/RetentionWeekAttributedMetric.kt @@ -14,10 +14,12 @@ * limitations under the License. */ -package com.duckduckgo.app.attributed.metrics +package com.duckduckgo.app.attributed.metrics.retention import com.duckduckgo.app.attributed.metrics.api.AttributedMetric import com.duckduckgo.app.attributed.metrics.api.AttributedMetricClient +import com.duckduckgo.app.attributed.metrics.api.AttributedMetricConfig +import com.duckduckgo.app.attributed.metrics.api.MetricBucket import com.duckduckgo.app.attributed.metrics.store.AttributedMetricsDateUtils import com.duckduckgo.app.di.AppCoroutineScope import com.duckduckgo.app.statistics.api.AtbLifecyclePlugin @@ -27,6 +29,9 @@ import com.duckduckgo.di.scopes.AppScope import com.squareup.anvil.annotations.ContributesMultibinding import dagger.SingleInstanceIn import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart.LAZY +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.async import kotlinx.coroutines.launch import logcat.logcat import javax.inject.Inject @@ -40,10 +45,28 @@ class RetentionWeekAttributedMetric @Inject constructor( private val appInstall: AppInstall, private val attributedMetricClient: AttributedMetricClient, private val dateUtils: AttributedMetricsDateUtils, + private val attributedMetricConfig: AttributedMetricConfig, ) : AttributedMetric, AtbLifecyclePlugin { companion object { - private const val PIXEL_NAME_FIRST_MONTH = "user_retention_week" + private const val PIXEL_NAME_FIRST_WEEK = "attributed_metric_retention_week" + private const val FEATURE_TOGGLE_NAME = "retention" + private const val FEATURE_EMIT_TOGGLE_NAME = "canEmitRetention" + } + + private val isEnabled: Deferred = appCoroutineScope.async(start = LAZY) { + getToggle(FEATURE_TOGGLE_NAME)?.isEnabled() ?: false + } + + private val canEmit: Deferred = appCoroutineScope.async(start = LAZY) { + getToggle(FEATURE_EMIT_TOGGLE_NAME)?.isEnabled() ?: false + } + + private val bucketConfig: Deferred = appCoroutineScope.async(start = LAZY) { + attributedMetricConfig.getBucketConfiguration()[PIXEL_NAME_FIRST_WEEK] ?: MetricBucket( + buckets = listOf(1, 2, 3), + version = 0, + ) } override fun onAppRetentionAtbRefreshed( @@ -51,6 +74,7 @@ class RetentionWeekAttributedMetric @Inject constructor( newAtb: String, ) { appCoroutineScope.launch(dispatcherProvider.io()) { + if (!isEnabled.await()) return@launch if (oldAtb == newAtb) { logcat(tag = "AttributedMetrics") { "RetentionFirstMonth: Skip emitting atb not changed" @@ -63,47 +87,61 @@ class RetentionWeekAttributedMetric @Inject constructor( } return@launch } - attributedMetricClient.emitMetric(this@RetentionWeekAttributedMetric) + if (canEmit.await()) { + attributedMetricClient.emitMetric(this@RetentionWeekAttributedMetric) + } } } - override fun getPixelName(): String = PIXEL_NAME_FIRST_MONTH + override fun getPixelName(): String = PIXEL_NAME_FIRST_WEEK override suspend fun getMetricParameters(): Map { - val days = daysSinceInstalled() - if (days <= 0 || days >= 29) return emptyMap() - - val week = getWeekFromDays(days) - return if (week > 0) { - mutableMapOf("count" to week.toString()) - } else { - emptyMap() - } + val week = getWeekSinceInstall() + if (week == -1) return emptyMap() + + val params = mutableMapOf( + "count" to bucketValue(getWeekSinceInstall()).toString(), + "version" to bucketConfig.await().version.toString(), + ) + return params } override suspend fun getTag(): String { - val days = daysSinceInstalled() - return getWeekFromDays(days).toString() + return bucketValue(getWeekSinceInstall()).toString() } private fun shouldSendPixel(): Boolean { - val days = daysSinceInstalled() - if (days <= 0 || days >= 29) return false + val week = getWeekSinceInstall() + if (week == -1) return false return true } - private fun getWeekFromDays(days: Int): Int { - return when (days) { + private fun getWeekSinceInstall(): Int { + val installationDay = daysSinceInstalled() + return when (installationDay) { in 1..7 -> 1 in 8..14 -> 2 in 15..21 -> 3 in 22..28 -> 4 - else -> -1 + else -> -1 // outside of first month + } + } + + private suspend fun bucketValue(value: Int): Int { + if (value < 0) return -1 + val buckets = bucketConfig.await().buckets + return buckets.indexOfFirst { bucket -> value <= bucket }.let { index -> + if (index == -1) buckets.size else index } } private fun daysSinceInstalled(): Int { return dateUtils.daysSince(appInstall.getInstallationTimestamp()) } + + private suspend fun getToggle(toggleName: String) = + attributedMetricConfig.metricsToggles().firstOrNull { toggle -> + toggle.featureName().name == toggleName + } } diff --git a/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/SearchAttributedMetric.kt b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/search/SearchAttributedMetric.kt similarity index 65% rename from attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/SearchAttributedMetric.kt rename to attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/search/SearchAttributedMetric.kt index 0af3aa15ecb0..39695d0ea458 100644 --- a/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/SearchAttributedMetric.kt +++ b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/search/SearchAttributedMetric.kt @@ -14,11 +14,13 @@ * limitations under the License. */ -package com.duckduckgo.app.attributed.metrics +package com.duckduckgo.app.attributed.metrics.search import com.duckduckgo.app.attributed.metrics.api.AttributedMetric import com.duckduckgo.app.attributed.metrics.api.AttributedMetricClient +import com.duckduckgo.app.attributed.metrics.api.AttributedMetricConfig import com.duckduckgo.app.attributed.metrics.api.EventStats +import com.duckduckgo.app.attributed.metrics.api.MetricBucket import com.duckduckgo.app.attributed.metrics.store.AttributedMetricsDateUtils import com.duckduckgo.app.di.AppCoroutineScope import com.duckduckgo.app.statistics.api.AtbLifecyclePlugin @@ -29,9 +31,13 @@ import com.duckduckgo.di.scopes.AppScope import com.squareup.anvil.annotations.ContributesMultibinding import dagger.SingleInstanceIn import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart.LAZY +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.async import kotlinx.coroutines.launch import logcat.logcat import javax.inject.Inject +import kotlin.math.roundToInt /** * Search Count 7d avg Attributed Metric @@ -43,25 +49,46 @@ import javax.inject.Inject @ContributesMultibinding(AppScope::class, AtbLifecyclePlugin::class) @ContributesMultibinding(AppScope::class, AttributedMetric::class) @SingleInstanceIn(AppScope::class) -class RealSearchAttributedMetric @Inject constructor( +class SearchAttributedMetric @Inject constructor( @AppCoroutineScope private val appCoroutineScope: CoroutineScope, private val dispatcherProvider: DispatcherProvider, private val attributedMetricClient: AttributedMetricClient, private val appInstall: AppInstall, private val statisticsDataStore: StatisticsDataStore, private val dateUtils: AttributedMetricsDateUtils, + private val attributedMetricConfig: AttributedMetricConfig, ) : AttributedMetric, AtbLifecyclePlugin { companion object { private const val EVENT_NAME = "ddg_search" - private const val FIRST_MONTH_PIXEL = "user_average_searches_past_week_first_month" - private const val PAST_WEEK_PIXEL_NAME = "user_average_searches_past_week" + private const val FIRST_MONTH_PIXEL = "attributed_metric_average_searches_past_week_first_month" + private const val PAST_WEEK_PIXEL_NAME = "attributed_metric_average_searches_past_week" private const val DAYS_WINDOW = 7 private const val FIRST_MONTH_DAY_THRESHOLD = 28 // we consider 1 month after 4 weeks - private val SEARCH_BUCKETS = arrayOf( - 5, - 9, - ) // TODO: default bucket, remote bucket implementation will happen in future PRs + private const val FEATURE_TOGGLE_NAME = "searchCountAvg" + private const val FEATURE_EMIT_TOGGLE_NAME = "canEmitSearchCountAvg" + } + + private val isEnabled: Deferred = appCoroutineScope.async(start = LAZY) { + getToggle(FEATURE_TOGGLE_NAME)?.isEnabled() ?: false + } + + private val canEmit: Deferred = appCoroutineScope.async(start = LAZY) { + getToggle(FEATURE_EMIT_TOGGLE_NAME)?.isEnabled() ?: false + } + + private val bucketConfigFirstMonth: Deferred = appCoroutineScope.async(start = LAZY) { + attributedMetricConfig.getBucketConfiguration()[FIRST_MONTH_PIXEL] ?: MetricBucket( + buckets = listOf(5, 9), + version = 0, + ) + } + + private val bucketConfigPastWeek: Deferred = appCoroutineScope.async(start = LAZY) { + attributedMetricConfig.getBucketConfiguration()[PAST_WEEK_PIXEL_NAME] ?: MetricBucket( + buckets = listOf(5, 9), + version = 0, + ) } override fun onSearchRetentionAtbRefreshed( @@ -69,6 +96,7 @@ class RealSearchAttributedMetric @Inject constructor( newAtb: String, ) { appCoroutineScope.launch(dispatcherProvider.io()) { + if (!isEnabled.await()) return@launch attributedMetricClient.collectEvent(EVENT_NAME) if (oldAtb == newAtb) { @@ -83,7 +111,9 @@ class RealSearchAttributedMetric @Inject constructor( } return@launch } - attributedMetricClient.emitMetric(this@RealSearchAttributedMetric) + if (canEmit.await()) { + attributedMetricClient.emitMetric(this@SearchAttributedMetric) + } } } @@ -95,7 +125,8 @@ class RealSearchAttributedMetric @Inject constructor( override suspend fun getMetricParameters(): Map { val stats = getEventStats() val params = mutableMapOf( - "count" to getBucketValue(stats.rollingAverage.toInt()).toString(), + "count" to getBucketValue(stats.rollingAverage.roundToInt()).toString(), + "version" to getBucketConfig().version.toString(), ) if (!hasCompleteDataWindow()) { params["dayAverage"] = daysSinceInstalled().toString() @@ -110,9 +141,10 @@ class RealSearchAttributedMetric @Inject constructor( ?: "no-atb" // should not happen, but just in case } - private fun getBucketValue(searches: Int): Int { - return SEARCH_BUCKETS.indexOfFirst { bucket -> searches <= bucket }.let { index -> - if (index == -1) SEARCH_BUCKETS.size else index + private suspend fun getBucketValue(searches: Int): Int { + val buckets = getBucketConfig().buckets + return buckets.indexOfFirst { bucket -> searches <= bucket }.let { index -> + if (index == -1) buckets.size else index } } @@ -140,10 +172,14 @@ class RealSearchAttributedMetric @Inject constructor( daysSinceInstalled(), ) } - return stats } + private suspend fun getBucketConfig() = when (daysSinceInstalled()) { + in 0..FIRST_MONTH_DAY_THRESHOLD -> bucketConfigFirstMonth.await() + else -> bucketConfigPastWeek.await() + } + private fun hasCompleteDataWindow(): Boolean { val daysSinceInstalled = daysSinceInstalled() return daysSinceInstalled >= DAYS_WINDOW @@ -152,4 +188,9 @@ class RealSearchAttributedMetric @Inject constructor( private fun daysSinceInstalled(): Int { return dateUtils.daysSince(appInstall.getInstallationTimestamp()) } + + private suspend fun getToggle(toggleName: String) = + attributedMetricConfig.metricsToggles().firstOrNull { toggle -> + toggle.featureName().name == toggleName + } } diff --git a/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/SearchDaysAttributedMetric.kt b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/search/SearchDaysAttributedMetric.kt similarity index 70% rename from attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/SearchDaysAttributedMetric.kt rename to attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/search/SearchDaysAttributedMetric.kt index dd9d53215995..ec482f74f24e 100644 --- a/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/SearchDaysAttributedMetric.kt +++ b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/search/SearchDaysAttributedMetric.kt @@ -14,10 +14,12 @@ * limitations under the License. */ -package com.duckduckgo.app.attributed.metrics +package com.duckduckgo.app.attributed.metrics.search import com.duckduckgo.app.attributed.metrics.api.AttributedMetric import com.duckduckgo.app.attributed.metrics.api.AttributedMetricClient +import com.duckduckgo.app.attributed.metrics.api.AttributedMetricConfig +import com.duckduckgo.app.attributed.metrics.api.MetricBucket import com.duckduckgo.app.attributed.metrics.store.AttributedMetricsDateUtils import com.duckduckgo.app.di.AppCoroutineScope import com.duckduckgo.app.statistics.api.AtbLifecyclePlugin @@ -28,6 +30,9 @@ import com.duckduckgo.di.scopes.AppScope import com.squareup.anvil.annotations.ContributesMultibinding import dagger.SingleInstanceIn import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart.LAZY +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.async import kotlinx.coroutines.launch import logcat.logcat import javax.inject.Inject @@ -42,23 +47,37 @@ import javax.inject.Inject @ContributesMultibinding(AppScope::class, AtbLifecyclePlugin::class) @ContributesMultibinding(AppScope::class, AttributedMetric::class) @SingleInstanceIn(AppScope::class) -class RealSearchDaysAttributedMetric @Inject constructor( +class SearchDaysAttributedMetric @Inject constructor( @AppCoroutineScope private val appCoroutineScope: CoroutineScope, private val dispatcherProvider: DispatcherProvider, private val attributedMetricClient: AttributedMetricClient, private val appInstall: AppInstall, private val statisticsDataStore: StatisticsDataStore, private val dateUtils: AttributedMetricsDateUtils, + private val attributedMetricConfig: AttributedMetricConfig, ) : AttributedMetric, AtbLifecyclePlugin { companion object { private const val EVENT_NAME = "ddg_search_days" - private const val PIXEL_NAME = "user_active_past_week" + private const val PIXEL_NAME = "attributed_metric_active_past_week" private const val DAYS_WINDOW = 7 - private val DAYS_BUCKETS = arrayOf( - 2, - 4, - ) // TODO: default bucket, remote bucket implementation will happen in future PRs + private const val FEATURE_TOGGLE_NAME = "searchDaysAvg" + private const val FEATURE_EMIT_TOGGLE_NAME = "canEmitSearchDaysAvg" + } + + private val isEnabled: Deferred = appCoroutineScope.async(start = LAZY) { + getToggle(FEATURE_TOGGLE_NAME)?.isEnabled() ?: false + } + + private val canEmit: Deferred = appCoroutineScope.async(start = LAZY) { + getToggle(FEATURE_EMIT_TOGGLE_NAME)?.isEnabled() ?: false + } + + private val bucketConfiguration: Deferred = appCoroutineScope.async(start = LAZY) { + attributedMetricConfig.getBucketConfiguration()[PIXEL_NAME] ?: MetricBucket( + buckets = listOf(2, 4), + version = 0, + ) } override fun onAppRetentionAtbRefreshed( @@ -66,6 +85,8 @@ class RealSearchDaysAttributedMetric @Inject constructor( newAtb: String, ) { appCoroutineScope.launch(dispatcherProvider.io()) { + if (!isEnabled.await()) return@launch + if (oldAtb == newAtb) { logcat(tag = "AttributedMetrics") { "SearchDays: Skip emitting atb not changed" @@ -78,7 +99,10 @@ class RealSearchDaysAttributedMetric @Inject constructor( } return@launch } - attributedMetricClient.emitMetric(this@RealSearchDaysAttributedMetric) + + if (canEmit.await()) { + attributedMetricClient.emitMetric(this@SearchDaysAttributedMetric) + } } } @@ -87,6 +111,7 @@ class RealSearchDaysAttributedMetric @Inject constructor( newAtb: String, ) { appCoroutineScope.launch(dispatcherProvider.io()) { + if (!isEnabled.await()) return@launch attributedMetricClient.collectEvent(EVENT_NAME) } } @@ -99,6 +124,7 @@ class RealSearchDaysAttributedMetric @Inject constructor( val stats = attributedMetricClient.getEventStats(EVENT_NAME, DAYS_WINDOW) val params = mutableMapOf( "days" to getBucketValue(stats.daysWithEvents).toString(), + "version" to bucketConfiguration.await().version.toString(), ) if (!hasCompleteDataWindow) { params["daysSinceInstalled"] = daysSinceInstalled.toString() @@ -112,9 +138,10 @@ class RealSearchDaysAttributedMetric @Inject constructor( return statisticsDataStore.appRetentionAtb ?: "no-atb" // should not happen, but just in case } - private fun getBucketValue(days: Int): Int { - return DAYS_BUCKETS.indexOfFirst { bucket -> days <= bucket }.let { index -> - if (index == -1) DAYS_BUCKETS.size else index + private suspend fun getBucketValue(days: Int): Int { + val buckets = bucketConfiguration.await().buckets + return buckets.indexOfFirst { bucket -> days <= bucket }.let { index -> + if (index == -1) buckets.size else index } } @@ -136,4 +163,9 @@ class RealSearchDaysAttributedMetric @Inject constructor( private fun daysSinceInstalled(): Int { return dateUtils.daysSince(appInstall.getInstallationTimestamp()) } + + private suspend fun getToggle(toggleName: String) = + attributedMetricConfig.metricsToggles().firstOrNull { toggle -> + toggle.featureName().name == toggleName + } } diff --git a/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/store/AttributedMetricsDataStore.kt b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/store/AttributedMetricsDataStore.kt index ef27316cbc4f..bc5c7c7c9cdc 100644 --- a/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/store/AttributedMetricsDataStore.kt +++ b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/store/AttributedMetricsDataStore.kt @@ -31,10 +31,6 @@ import kotlinx.coroutines.flow.* import javax.inject.Inject interface AttributedMetricsDataStore { - suspend fun isEnabled(): Boolean - - suspend fun setEnabled(enabled: Boolean) - suspend fun isActive(): Boolean suspend fun setActive(active: Boolean) @@ -42,8 +38,6 @@ interface AttributedMetricsDataStore { suspend fun getInitializationDate(): String? suspend fun setInitializationDate(date: String?) - - fun observeEnabled(): Flow } @ContributesBinding(AppScope::class) @@ -53,25 +47,10 @@ class RealAttributedMetricsDataStore @Inject constructor( @AppCoroutineScope private val appCoroutineScope: CoroutineScope, ) : AttributedMetricsDataStore { private object Keys { - val IS_ENABLED = booleanPreferencesKey("is_enabled") val IS_ACTIVE = booleanPreferencesKey("is_active") val INIT_DATE = stringPreferencesKey("client_init_date") } - private val enabledState: StateFlow = - store.data - .map { prefs -> prefs[Keys.IS_ENABLED] ?: false } - .distinctUntilChanged() - .stateIn(appCoroutineScope, SharingStarted.Eagerly, false) - - override suspend fun isEnabled(): Boolean = store.data.firstOrNull()?.get(Keys.IS_ENABLED) ?: false - - override suspend fun setEnabled(enabled: Boolean) { - store.edit { preferences -> - preferences[Keys.IS_ENABLED] = enabled - } - } - override suspend fun getInitializationDate(): String? = store.data.firstOrNull()?.get(Keys.INIT_DATE) override suspend fun setInitializationDate(date: String?) { @@ -91,6 +70,4 @@ class RealAttributedMetricsDataStore @Inject constructor( preferences[Keys.IS_ACTIVE] = active } } - - override fun observeEnabled(): Flow = enabledState } diff --git a/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/store/AttributedMetricsDateUtils.kt b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/store/AttributedMetricsDateUtils.kt index 8fdae5efbafd..f932f1816609 100644 --- a/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/store/AttributedMetricsDateUtils.kt +++ b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/store/AttributedMetricsDateUtils.kt @@ -97,6 +97,14 @@ interface AttributedMetricsDateUtils { * @return The calculated date as a string in "yyyy-MM-dd" format (in ET) */ fun getDateMinusDays(days: Int): String + + /** + * Converts a timestamp to a formatted date string in Eastern Time. + * + * @param timestamp The timestamp in milliseconds since epoch (Unix timestamp) + * @return The date as a string in "yyyy-MM-dd" format (in ET) + */ + fun getDateFromTimestamp(timestamp: Long): String } @ContributesBinding(AppScope::class) @@ -126,6 +134,12 @@ class RealAttributedMetricsDateUtils @Inject constructor() : AttributedMetricsDa override fun getDateMinusDays(days: Int): String = getCurrentZonedDateTime().minusDays(days.toLong()).format(DATE_FORMATTER) + override fun getDateFromTimestamp(timestamp: Long): String { + val instant = Instant.ofEpochMilli(timestamp) + val zonedDateTime = instant.atZone(ET_ZONE) + return zonedDateTime.format(DATE_FORMATTER) + } + private fun getCurrentZonedDateTime(): ZonedDateTime = ZonedDateTime.now(ET_ZONE) companion object { diff --git a/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/store/EventDao.kt b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/store/EventDao.kt index a38349ba16e0..a6c6d940730a 100644 --- a/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/store/EventDao.kt +++ b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/store/EventDao.kt @@ -65,6 +65,6 @@ interface EventDao { day: String, ): Int? - @Query("DELETE FROM event_metrics WHERE day < :day") - suspend fun deleteEventsOlderThan(day: String) + @Query("delete from event_metrics") + suspend fun deleteAllEvents() } diff --git a/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/store/EventRepository.kt b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/store/EventRepository.kt index 7e41feeded50..53d0fa6cef5e 100644 --- a/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/store/EventRepository.kt +++ b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/store/EventRepository.kt @@ -32,7 +32,7 @@ interface EventRepository { days: Int, ): EventStats - suspend fun deleteOldEvents(olderThanDays: Int) + suspend fun deleteAllEvents() } @ContributesBinding(AppScope::class) @@ -70,10 +70,9 @@ class RealEventRepository @Inject constructor( ) } - override suspend fun deleteOldEvents(olderThanDays: Int) { + override suspend fun deleteAllEvents() { coroutineScope.launch { - val cutoffDay = attributedMetricsDateUtils.getDateMinusDays(olderThanDays) - eventDao.deleteEventsOlderThan(cutoffDay) + eventDao.deleteAllEvents() } } } diff --git a/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/FakeAttributedMetricsDateUtils.kt b/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/FakeAttributedMetricsDateUtils.kt index 70993726dfce..66c9025ae75d 100644 --- a/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/FakeAttributedMetricsDateUtils.kt +++ b/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/FakeAttributedMetricsDateUtils.kt @@ -19,6 +19,7 @@ package com.duckduckgo.app.attributed.metrics import com.duckduckgo.app.attributed.metrics.store.AttributedMetricsDateUtils import java.time.Instant import java.time.LocalDate +import java.time.ZoneId import java.time.format.DateTimeFormatter import java.time.temporal.ChronoUnit @@ -38,9 +39,16 @@ class FakeAttributedMetricsDateUtils(var testDate: LocalDate) : AttributedMetric override fun getDateMinusDays(days: Int): String = getCurrentLocalDate().minusDays(days.toLong()).format(DATE_FORMATTER) + override fun getDateFromTimestamp(timestamp: Long): String { + val instant = Instant.ofEpochMilli(timestamp) + val zonedDateTime = instant.atZone(ET_ZONE) + return zonedDateTime.format(DATE_FORMATTER) + } + private fun getCurrentLocalDate(): LocalDate = testDate companion object { private val DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd") + private val ET_ZONE = ZoneId.of("America/New_York") } } diff --git a/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/RetentionMonthAttributedMetricTest.kt b/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/RetentionMonthAttributedMetricTest.kt deleted file mode 100644 index 75e3f14d23d8..000000000000 --- a/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/RetentionMonthAttributedMetricTest.kt +++ /dev/null @@ -1,160 +0,0 @@ -/* - * 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.attributed.metrics - -import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.duckduckgo.app.attributed.metrics.api.AttributedMetricClient -import com.duckduckgo.app.attributed.metrics.store.AttributedMetricsDateUtils -import com.duckduckgo.browser.api.install.AppInstall -import com.duckduckgo.common.test.CoroutineTestRule -import kotlinx.coroutines.test.runTest -import org.junit.Assert.assertEquals -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.mockito.kotlin.mock -import org.mockito.kotlin.never -import org.mockito.kotlin.verify -import org.mockito.kotlin.whenever - -@RunWith(AndroidJUnit4::class) -class RetentionMonthAttributedMetricTest { - - @get:Rule - val coroutineRule = CoroutineTestRule() - - private val attributedMetricClient: AttributedMetricClient = mock() - private val appInstall: AppInstall = mock() - private val dateUtils: AttributedMetricsDateUtils = mock() - - private lateinit var testee: RetentionMonthAttributedMetric - - @Before - fun setup() { - testee = RetentionMonthAttributedMetric( - appCoroutineScope = coroutineRule.testScope, - dispatcherProvider = coroutineRule.testDispatcherProvider, - appInstall = appInstall, - attributedMetricClient = attributedMetricClient, - dateUtils = dateUtils, - ) - } - - @Test - fun whenPixelNameRequestedThenReturnCorrectName() { - assertEquals("user_retention_month", testee.getPixelName()) - } - - @Test - fun whenAtbNotChangedThenDoNotEmitMetric() = runTest { - testee.onAppRetentionAtbRefreshed("atb", "atb") - - verify(attributedMetricClient, never()).emitMetric(testee) - } - - @Test - fun whenAppOpensAndDaysLessThan29ThenDoNotEmitMetric() = runTest { - givenDaysSinceInstalled(28) - - testee.onAppRetentionAtbRefreshed("old", "new") - - verify(attributedMetricClient, never()).emitMetric(testee) - } - - @Test - fun whenAppOpensAndDaysIs29ThenEmitMetric() = runTest { - givenDaysSinceInstalled(29) - - testee.onAppRetentionAtbRefreshed("old", "new") - - verify(attributedMetricClient).emitMetric(testee) - } - - @Test - fun whenDaysLessThan29ThenReturnEmptyParameters() = runTest { - givenDaysSinceInstalled(28) - - val params = testee.getMetricParameters() - - assertEquals(emptyMap(), params) - } - - @Test - fun whenDaysInstalledThenReturnCorrectPeriod() = runTest { - // Map of days installed to expected period number - val periodRanges = mapOf( - 28 to 0, // Day 28 -> No period (empty map) - 29 to 1, // Day 29 -> Period 1 (first month) - 45 to 1, // Day 45 -> Period 1 (still first month) - 57 to 2, // Day 57 -> Period 2 (second month) - 85 to 3, // Day 85 -> Period 3 (third month) - 113 to 4, // Day 113 -> Period 4 (fourth month) - 141 to 5, // Day 141 -> Period 5 (fifth month) - 169 to 6, // Day 169 -> Period 6 (sixth month) - 197 to 7, // Day 197 -> Period 7 (seventh month) - ) - - periodRanges.forEach { (days, expectedPeriod) -> - givenDaysSinceInstalled(days) - - val params = testee.getMetricParameters() - - val expectedParams = if (expectedPeriod > 0) { - mapOf("count" to expectedPeriod.toString()) - } else { - emptyMap() - } - - assertEquals( - "For $days days installed, should return period $expectedPeriod", - expectedParams, - params, - ) - } - } - - @Test - fun whenDaysInstalledThenReturnCorrectTag() = runTest { - // Test different days and expected period numbers - val testCases = mapOf( - 28 to "0", // Day 28 -> No period - 29 to "1", // Day 29 -> Period 1 - 57 to "2", // Day 57 -> Period 2 - 85 to "3", // Day 85 -> Period 3 - 113 to "4", // Day 113 -> Period 4 - 141 to "5", // Day 141 -> Period 5 - ) - - testCases.forEach { (days, expectedTag) -> - givenDaysSinceInstalled(days) - - val tag = testee.getTag() - - assertEquals( - "For $days days installed, should return tag $expectedTag", - expectedTag, - tag, - ) - } - } - - private fun givenDaysSinceInstalled(days: Int) { - whenever(appInstall.getInstallationTimestamp()).thenReturn(123L) - whenever(dateUtils.daysSince(123L)).thenReturn(days) - } -} diff --git a/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/RetentionWeekAttributedMetricTest.kt b/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/RetentionWeekAttributedMetricTest.kt deleted file mode 100644 index 4a0f0b5e4cf8..000000000000 --- a/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/RetentionWeekAttributedMetricTest.kt +++ /dev/null @@ -1,161 +0,0 @@ -/* - * 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.attributed.metrics - -import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.duckduckgo.app.attributed.metrics.api.AttributedMetricClient -import com.duckduckgo.app.attributed.metrics.store.AttributedMetricsDateUtils -import com.duckduckgo.browser.api.install.AppInstall -import com.duckduckgo.common.test.CoroutineTestRule -import kotlinx.coroutines.test.runTest -import org.junit.Assert.assertEquals -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.mockito.kotlin.mock -import org.mockito.kotlin.never -import org.mockito.kotlin.verify -import org.mockito.kotlin.whenever - -@RunWith(AndroidJUnit4::class) -class RetentionWeekAttributedMetricTest { - - @get:Rule - val coroutineRule = CoroutineTestRule() - - private val attributedMetricClient: AttributedMetricClient = mock() - private val appInstall: AppInstall = mock() - private val dateUtils: AttributedMetricsDateUtils = mock() - - private lateinit var testee: RetentionWeekAttributedMetric - - @Before - fun setup() { - testee = RetentionWeekAttributedMetric( - appCoroutineScope = coroutineRule.testScope, - dispatcherProvider = coroutineRule.testDispatcherProvider, - attributedMetricClient = attributedMetricClient, - appInstall = appInstall, - dateUtils = dateUtils, - ) - } - - @Test - fun whenPixelNameRequestedThenReturnCorrectName() { - assertEquals("user_retention_week", testee.getPixelName()) - } - - @Test - fun whenAtbNotChangedThenDoNotEmitMetric() = runTest { - testee.onAppRetentionAtbRefreshed("atb", "atb") - - verify(attributedMetricClient, never()).emitMetric(testee) - } - - @Test - fun whenDaysInstalledIsZeroThenReturnEmptyParameters() = runTest { - givenDaysSinceInstalled(0) - - val params = testee.getMetricParameters() - - assertEquals(emptyMap(), params) - } - - @Test - fun whenDaysInstalledIs29ThenReturnEmptyParameters() = runTest { - givenDaysSinceInstalled(29) - - val params = testee.getMetricParameters() - - assertEquals(emptyMap(), params) - } - - @Test - fun whenDaysInstalledThenReturnCorrectWeek() = runTest { - // Map of days installed to expected week number - val weekRanges = mapOf( - 1 to 1, // Day 1 -> Week 1 - 3 to 1, // Day 3 -> Week 1 - 8 to 2, // Day 8 -> Week 2 - 10 to 2, // Day 10 -> Week 2 - 15 to 3, // Day 15 -> Week 3 - 17 to 3, // Day 17 -> Week 3 - 22 to 4, // Day 22 -> Week 4 - 24 to 4, // Day 24 -> Week 4 - ) - - weekRanges.forEach { (days, expectedWeek) -> - givenDaysSinceInstalled(days) - - val params = testee.getMetricParameters() - - assertEquals( - "For $days days installed, should return week $expectedWeek", - mapOf("count" to expectedWeek.toString()), - params, - ) - } - } - - @Test - fun whenDaysInstalledThenReturnCorrectTag() = runTest { - // Test different days and expected week numbers - val testCases = mapOf( - 1 to "1", // Week 1 - 8 to "2", // Week 2 - 15 to "3", // Week 3 - 22 to "4", // Week 4 - 29 to "-1", // Outside range - ) - - testCases.forEach { (days, expectedTag) -> - givenDaysSinceInstalled(days) - - val tag = testee.getTag() - - assertEquals( - "For $days days installed, should return tag $expectedTag", - expectedTag, - tag, - ) - } - } - - @Test - fun whenAppOpensAndDaysOutsideRangeThenDoNotEmitMetric() = runTest { - givenDaysSinceInstalled(29) - - testee.onAppRetentionAtbRefreshed("old", "new") - - verify(attributedMetricClient, never()).emitMetric(testee) - } - - @Test - fun whenAppOpensAndDaysInRangeThenEmitMetric() = runTest { - givenDaysSinceInstalled(7) - - testee.onAppRetentionAtbRefreshed("old", "new") - - verify(attributedMetricClient).emitMetric(testee) - } - - private fun givenDaysSinceInstalled(days: Int) { - whenever(appInstall.getInstallationTimestamp()).thenReturn(123L) - whenever(dateUtils.daysSince(123L)).thenReturn(days) - } -} diff --git a/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/impl/AttributeMetricsConfigTest.kt b/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/impl/AttributeMetricsConfigTest.kt new file mode 100644 index 000000000000..ad84a6ade2a6 --- /dev/null +++ b/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/impl/AttributeMetricsConfigTest.kt @@ -0,0 +1,183 @@ +/* + * 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.attributed.metrics.impl + +import android.annotation.SuppressLint +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.duckduckgo.app.attributed.metrics.AttributedMetricsConfigFeature +import com.duckduckgo.app.attributed.metrics.api.MetricBucket +import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory +import com.duckduckgo.feature.toggles.api.FeatureTogglesInventory +import com.duckduckgo.feature.toggles.api.Toggle +import com.duckduckgo.feature.toggles.api.Toggle.State +import com.squareup.moshi.Moshi +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +@SuppressLint("DenyListedApi") +@RunWith(AndroidJUnit4::class) +class AttributeMetricsConfigTest { + + @get:Rule + val coroutineRule = CoroutineTestRule() + + private val attributedMetricsConfigFeature: AttributedMetricsConfigFeature = + FakeFeatureToggleFactory.create(AttributedMetricsConfigFeature::class.java).apply { + self().setRawStoredState(State(true)) + } + private val featureTogglesInventory: FeatureTogglesInventory = mock() + private val moshi = Moshi.Builder().build() + private val attributedMetricToggle = attributedMetricsConfigFeature.self() + + private lateinit var testee: AttributeMetricsConfig + + @Before + fun setup() { + testee = AttributeMetricsConfig( + appCoroutineScope = coroutineRule.testScope, + dispatcherProvider = coroutineRule.testDispatcherProvider, + attributedMetricsConfigFeature = attributedMetricsConfigFeature, + featureTogglesInvestory = featureTogglesInventory, + moshi = moshi, + ) + } + + @Test + fun whenFeatureDisabledThenReturnEmptyToggles() = runTest { + attributedMetricsConfigFeature.self().setRawStoredState(State(false)) + + val toggles = testee.metricsToggles() + + assertEquals(emptyList(), toggles) + verify(featureTogglesInventory, never()).getAllTogglesForParent(any()) + } + + @Test + fun whenFeatureEnabledThenReturnToggles() = runTest { + val expectedToggles = listOf(mock(), mock()) + whenever(featureTogglesInventory.getAllTogglesForParent(any())).thenReturn(expectedToggles) + + val toggles = testee.metricsToggles() + + assertEquals(expectedToggles, toggles) + } + + @Test + fun whenFeatureDisabledThenReturnEmptyBucketConfig() = runTest { + val settings = """ + { + "attributed_metric_active_past_week": { + "buckets": [2, 4], + "version": 0 + }, + "attributed_metric_average_searches_past_week": { + "buckets": [5, 9], + "version": 1 + } + } + """.trimIndent() + attributedMetricToggle.setRawStoredState( + State( + remoteEnableState = false, + settings = settings, + ), + ) + val config = testee.getBucketConfiguration() + + assertEquals(emptyMap(), config) + } + + @Test + fun whenFeatureEnabledButNoSettingsThenReturnEmptyBucketConfig() = runTest { + attributedMetricToggle.setRawStoredState( + State( + remoteEnableState = true, + settings = null, + ), + ) + + val config = testee.getBucketConfiguration() + + assertEquals(emptyMap(), config) + } + + @Test + fun whenFeatureEnabledAndValidSettingsThenReturnBucketConfig() = runTest { + val settings = """ + { + "attributed_metric_active_past_week": { + "buckets": [2, 4], + "version": 0 + }, + "attributed_metric_average_searches_past_week": { + "buckets": [5, 9], + "version": 1 + } + } + """.trimIndent() + attributedMetricToggle.setRawStoredState( + State( + remoteEnableState = true, + settings = settings, + ), + ) + + val config = testee.getBucketConfiguration() + + assertEquals( + mapOf( + "attributed_metric_active_past_week" to MetricBucket( + buckets = listOf(2, 4), + version = 0, + ), + "attributed_metric_average_searches_past_week" to MetricBucket( + buckets = listOf(5, 9), + version = 1, + ), + ), + config, + ) + } + + @Test + fun whenFeatureEnabledAndInvalidSettingsThenReturnEmptyBucketConfig() = runTest { + val invalidSettings = """ + invalid json + """.trimIndent() + + attributedMetricToggle.setRawStoredState( + State( + remoteEnableState = true, + settings = invalidSettings, + ), + ) + + val config = testee.getBucketConfiguration() + + assertEquals(emptyMap(), config) + } +} diff --git a/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/impl/RealAttributedMetricClientTest.kt b/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/impl/RealAttributedMetricClientTest.kt index 621b4ab7c8c2..0e572ad4809c 100644 --- a/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/impl/RealAttributedMetricClientTest.kt +++ b/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/impl/RealAttributedMetricClientTest.kt @@ -19,9 +19,12 @@ package com.duckduckgo.app.attributed.metrics.impl import androidx.test.ext.junit.runners.AndroidJUnit4 import com.duckduckgo.app.attributed.metrics.api.AttributedMetric import com.duckduckgo.app.attributed.metrics.api.EventStats +import com.duckduckgo.app.attributed.metrics.store.AttributedMetricsDateUtils import com.duckduckgo.app.attributed.metrics.store.EventRepository import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Unique +import com.duckduckgo.browser.api.install.AppInstall +import com.duckduckgo.browser.api.referrer.AppReferrer import com.duckduckgo.common.test.CoroutineTestRule import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals @@ -45,6 +48,9 @@ class RealAttributedMetricClientTest { private val mockEventRepository: EventRepository = mock() private val mockPixel: Pixel = mock() private val mockMetricsState: AttributedMetricsState = mock() + private val appReferrer: AppReferrer = mock() + private val appInstall: AppInstall = mock() + private val dateUtils: AttributedMetricsDateUtils = mock() private lateinit var testee: RealAttributedMetricClient @@ -56,6 +62,9 @@ class RealAttributedMetricClientTest { eventRepository = mockEventRepository, pixel = mockPixel, metricsState = mockMetricsState, + appReferrer = appReferrer, + dateUtils = dateUtils, + appInstall = appInstall, ) } @@ -100,13 +109,51 @@ class RealAttributedMetricClientTest { } @Test - fun whenEmitMetricAndClientActiveMetricIsEmitted() = runTest { + fun whenEmitMetricAndClientActiveWithOriginThenMetricIsEmittedWithOrigin() = runTest { val testMetric = TestAttributedMetric() whenever(mockMetricsState.isActive()).thenReturn(true) + whenever(mockMetricsState.canEmitMetrics()).thenReturn(true) + whenever(appReferrer.getOriginAttributeCampaign()).thenReturn("campaign_origin") testee.emitMetric(testMetric) - verify(mockPixel).fire(pixelName = "test_pixel", parameters = mapOf("param" to "value"), type = Unique("test_pixel_test_tag")) + verify(mockPixel).fire( + pixelName = "test_pixel", + parameters = mapOf("param" to "value", "origin" to "campaign_origin"), + type = Unique("test_pixel_test_tag"), + ) + } + + @Test + fun whenEmitMetricAndClientActiveWithoutOriginThenMetricIsEmittedWithInstallDate() = runTest { + val testMetric = TestAttributedMetric() + whenever(mockMetricsState.isActive()).thenReturn(true) + whenever(mockMetricsState.canEmitMetrics()).thenReturn(true) + whenever(appReferrer.getOriginAttributeCampaign()).thenReturn(null) + whenever(dateUtils.getDateFromTimestamp(any())).thenReturn("2025-01-01") + + testee.emitMetric(testMetric) + + verify(mockPixel).fire( + pixelName = "test_pixel", + parameters = mapOf("param" to "value", "install_date" to "2025-01-01"), + type = Unique("test_pixel_test_tag"), + ) + } + + @Test + fun whenEmitMetricClientActiveButCanEmitMetricsFalseThenMetricIsNotEmitted() = runTest { + val testMetric = TestAttributedMetric() + whenever(mockMetricsState.isActive()).thenReturn(true) + whenever(mockMetricsState.canEmitMetrics()).thenReturn(false) + + testee.emitMetric(testMetric) + + verify(mockPixel, never()).fire( + pixelName = "test_pixel", + parameters = mapOf("param" to "value"), + type = Unique("test_pixel_test_tag"), + ) } @Test diff --git a/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/impl/RealAttributedMetricsStateTest.kt b/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/impl/RealAttributedMetricsStateTest.kt index 7cd2486055ae..23218e808282 100644 --- a/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/impl/RealAttributedMetricsStateTest.kt +++ b/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/impl/RealAttributedMetricsStateTest.kt @@ -23,6 +23,7 @@ import com.duckduckgo.app.attributed.metrics.AttributedMetricsConfigFeature import com.duckduckgo.app.attributed.metrics.FakeAttributedMetricsDateUtils import com.duckduckgo.app.attributed.metrics.store.AttributedMetricsDataStore import com.duckduckgo.app.attributed.metrics.store.AttributedMetricsDateUtils +import com.duckduckgo.app.attributed.metrics.store.EventRepository import com.duckduckgo.appbuildconfig.api.AppBuildConfig import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory @@ -51,6 +52,7 @@ class RealAttributedMetricsStateTest { private val mockConfigFeature: AttributedMetricsConfigFeature = FakeFeatureToggleFactory.create(AttributedMetricsConfigFeature::class.java) private val mockAppBuildConfig: AppBuildConfig = mock() private val mockLifecycleOwner: LifecycleOwner = mock() + private val mockEventRepository: EventRepository = mock() private lateinit var testDateUtils: AttributedMetricsDateUtils private lateinit var testee: RealAttributedMetricsState @@ -64,6 +66,7 @@ class RealAttributedMetricsStateTest { attributedMetricsConfigFeature = mockConfigFeature, appBuildConfig = mockAppBuildConfig, attributedMetricsDateUtils = testDateUtils, + eventRepository = mockEventRepository, ) } @@ -108,25 +111,17 @@ class RealAttributedMetricsStateTest { verify(mockDataStore, never()).setActive(any()) } - @Test fun whenOnPrivacyConfigDownloadedThenUpdateEnabledState() = runTest { - givenAttributedClientFeatureEnabled(true) - - testee.onPrivacyConfigDownloaded() - - verify(mockDataStore).setEnabled(true) - } - @Test fun whenIsActiveAndAllConditionsMetThenReturnTrue() = runTest { whenever(mockDataStore.isActive()).thenReturn(true) - whenever(mockDataStore.isEnabled()).thenReturn(true) whenever(mockDataStore.getInitializationDate()).thenReturn("2025-10-03") + mockConfigFeature.self().setRawStoredState(State(true)) assertTrue(testee.isActive()) } @Test fun whenIsActiveAndClientNotActiveThenReturnFalse() = runTest { whenever(mockDataStore.isActive()).thenReturn(false) - whenever(mockDataStore.isEnabled()).thenReturn(true) + mockConfigFeature.self().setRawStoredState(State(true)) whenever(mockDataStore.getInitializationDate()).thenReturn("2025-10-03") assertFalse(testee.isActive()) @@ -134,7 +129,7 @@ class RealAttributedMetricsStateTest { @Test fun whenIsActiveAndNotEnabledThenReturnFalse() = runTest { whenever(mockDataStore.isActive()).thenReturn(true) - whenever(mockDataStore.isEnabled()).thenReturn(false) + mockConfigFeature.self().setRawStoredState(State(false)) whenever(mockDataStore.getInitializationDate()).thenReturn("2025-10-03") assertFalse(testee.isActive()) @@ -142,7 +137,7 @@ class RealAttributedMetricsStateTest { @Test fun whenIsActiveAndNoInitDateThenReturnFalse() = runTest { whenever(mockDataStore.isActive()).thenReturn(true) - whenever(mockDataStore.isEnabled()).thenReturn(true) + mockConfigFeature.self().setRawStoredState(State(true)) whenever(mockDataStore.getInitializationDate()).thenReturn(null) assertFalse(testee.isActive()) @@ -154,6 +149,7 @@ class RealAttributedMetricsStateTest { testee.onCreate(mockLifecycleOwner) verify(mockDataStore, never()).setActive(any()) + verify(mockEventRepository, never()).deleteAllEvents() } @Test fun whenCheckCollectionPeriodAndWithinPeriodAndActiveThenKeepActive() = runTest { @@ -163,6 +159,7 @@ class RealAttributedMetricsStateTest { testee.onCreate(mockLifecycleOwner) verify(mockDataStore).setActive(true) + verify(mockEventRepository, never()).deleteAllEvents() } @Test fun whenCheckCollectionPeriodAndWithinPeriodAndNotActiveThenKeepInactive() = runTest { @@ -171,16 +168,18 @@ class RealAttributedMetricsStateTest { testee.onCreate(mockLifecycleOwner) - verify(mockDataStore).setActive(false) + verify(mockDataStore, never()).setActive(any()) + verify(mockEventRepository, never()).deleteAllEvents() } - @Test fun whenCheckCollectionPeriodAndOutsidePeriodThenSetInactive() = runTest { + @Test fun whenCheckCollectionPeriodAndOutsidePeriodThenSetInactiveAndDeleteAllData() = runTest { whenever(mockDataStore.getInitializationDate()).thenReturn(testDateUtils.getDateMinusDays(169)) // 6months + 1 whenever(mockDataStore.isActive()).thenReturn(true) testee.onCreate(mockLifecycleOwner) verify(mockDataStore).setActive(false) + verify(mockEventRepository).deleteAllEvents() } private fun givenAttributedClientFeatureEnabled(isEnabled: Boolean) { diff --git a/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/retention/RetentionMonthAttributedMetricTest.kt b/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/retention/RetentionMonthAttributedMetricTest.kt new file mode 100644 index 000000000000..4e9c7997a97d --- /dev/null +++ b/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/retention/RetentionMonthAttributedMetricTest.kt @@ -0,0 +1,226 @@ +/* + * 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.attributed.metrics.retention + +import android.annotation.SuppressLint +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.duckduckgo.app.attributed.metrics.AttributedMetricsConfigFeature +import com.duckduckgo.app.attributed.metrics.api.AttributedMetricClient +import com.duckduckgo.app.attributed.metrics.api.AttributedMetricConfig +import com.duckduckgo.app.attributed.metrics.api.MetricBucket +import com.duckduckgo.app.attributed.metrics.store.AttributedMetricsDateUtils +import com.duckduckgo.browser.api.install.AppInstall +import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory +import com.duckduckgo.feature.toggles.api.Toggle.State +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +@SuppressLint("DenyListedApi") +@RunWith(AndroidJUnit4::class) +class RetentionMonthAttributedMetricTest { + + @get:Rule + val coroutineRule = CoroutineTestRule() + + private val attributedMetricClient: AttributedMetricClient = mock() + private val attributedMetricConfig: AttributedMetricConfig = mock() + private val appInstall: AppInstall = mock() + private val dateUtils: AttributedMetricsDateUtils = mock() + private val retentionToggle = FakeFeatureToggleFactory.create(AttributedMetricsConfigFeature::class.java) + + private lateinit var testee: RetentionMonthAttributedMetric + + @Before + fun setup() = runTest { + retentionToggle.retention().setRawStoredState(State(true)) + retentionToggle.canEmitRetention().setRawStoredState(State(true)) + whenever(attributedMetricConfig.metricsToggles()).thenReturn( + listOf(retentionToggle.retention(), retentionToggle.canEmitRetention()), + ) + whenever(attributedMetricConfig.getBucketConfiguration()).thenReturn( + mapOf( + "attributed_metric_retention_month" to MetricBucket( + buckets = listOf(2, 3, 4, 5), + version = 0, + ), + ), + ) + testee = RetentionMonthAttributedMetric( + appCoroutineScope = coroutineRule.testScope, + dispatcherProvider = coroutineRule.testDispatcherProvider, + appInstall = appInstall, + attributedMetricClient = attributedMetricClient, + dateUtils = dateUtils, + attributedMetricConfig = attributedMetricConfig, + ) + } + + @Test + fun whenPixelNameRequestedThenReturnCorrectName() { + assertEquals("attributed_metric_retention_month", testee.getPixelName()) + } + + @Test + fun whenAtbNotChangedThenDoNotEmitMetric() = runTest { + testee.onAppRetentionAtbRefreshed("atb", "atb") + + verify(attributedMetricClient, never()).emitMetric(testee) + } + + @Test + fun whenAppOpensAndDaysLessThan29ThenDoNotEmitMetric() = runTest { + givenDaysSinceInstalled(28) + + testee.onAppRetentionAtbRefreshed("old", "new") + + verify(attributedMetricClient, never()).emitMetric(testee) + } + + @Test + fun whenAppOpensAndDaysIs29ThenEmitMetric() = runTest { + givenDaysSinceInstalled(29) + + testee.onAppRetentionAtbRefreshed("old", "new") + + verify(attributedMetricClient).emitMetric(testee) + } + + @Test + fun whenAppOpensAndDaysIs29ButFFDisabledThenDoNotEmitMetric() = runTest { + retentionToggle.retention().setRawStoredState(State(false)) + whenever(attributedMetricConfig.metricsToggles()).thenReturn( + listOf(retentionToggle.retention(), retentionToggle.canEmitRetention()), + ) + givenDaysSinceInstalled(29) + + testee.onAppRetentionAtbRefreshed("old", "new") + + verify(attributedMetricClient, never()).emitMetric(testee) + } + + @Test + fun whenAppOpensAndDaysIs29AndEmitDisabledThenDoNotEmitMetric() = runTest { + retentionToggle.retention().setRawStoredState(State(true)) + retentionToggle.canEmitRetention().setRawStoredState(State(false)) + whenever(attributedMetricConfig.metricsToggles()).thenReturn( + listOf(retentionToggle.retention(), retentionToggle.canEmitRetention()), + ) + givenDaysSinceInstalled(29) + + testee.onAppRetentionAtbRefreshed("old", "new") + + verify(attributedMetricClient, never()).emitMetric(testee) + } + + @Test + fun whenDaysLessThan29ThenReturnEmptyParameters() = runTest { + givenDaysSinceInstalled(28) + + val params = testee.getMetricParameters() + + assertEquals(emptyMap(), params) + } + + @Test + fun whenDaysInstalledThenReturnCorrectPeriod() = runTest { + // Map of days installed to expected period number + val periodRanges = mapOf( + 10 to -1, // Day 10 -> month 1, not captured by this metric + 28 to -1, // Day 28 -> month 1, not captured by this metric + 29 to 0, // Day 29 -> month 2, Bucket 0 + 45 to 0, // Day 45 -> month 2, Bucket 0 + 56 to 0, // Day 57 -> month 2, Bucket 0 + 57 to 1, // Day 57 -> month 3, Bucket 1 + 85 to 2, // Day 85 -> month 4, Bucket 2 + 113 to 3, // Day 113 -> month 5, Bucket 3 + 141 to 4, // Day 141 -> month 6, Bucket 4 + 169 to 4, // Day 169 -> month 7, Bucket 4 + 197 to 4, // Day 197 -> month 8, Bucket 4 + ) + + periodRanges.forEach { (days, expectedPeriod) -> + givenDaysSinceInstalled(days) + + val params = testee.getMetricParameters()["count"] + + val expectedCount = if (expectedPeriod > -1) { + expectedPeriod.toString() + } else { + null + } + + assertEquals( + "For $days days installed, should return period $expectedPeriod", + expectedCount, + params, + ) + } + } + + @Test + fun whenDaysInstalledThenReturnCorrectTag() = runTest { + // Test different days and expected period numbers + val testCases = mapOf( + 10 to "-1", // Day 10 -> month 1, not captured by this metric + 28 to "-1", // Day 28 -> month 1, not captured by this metric + 29 to "0", // Day 29 -> month 2, Bucket 0 + 45 to "0", // Day 45 -> month 2, Bucket 0 + 56 to "0", // Day 57 -> month 2, Bucket 0 + 57 to "1", // Day 57 -> month 3, Bucket 1 + 85 to "2", // Day 85 -> month 4, Bucket 2 + 113 to "3", // Day 113 -> month 5, Bucket 3 + 141 to "4", // Day 141 -> month 6, Bucket 4 + 169 to "4", // Day 169 -> month 7, Bucket 4 + 197 to "4", // Day 197 -> month 8, Bucket 4 + ) + + testCases.forEach { (days, expectedTag) -> + givenDaysSinceInstalled(days) + + val tag = testee.getTag() + + assertEquals( + "For $days days installed, should return tag $expectedTag", + expectedTag, + tag, + ) + } + } + + @Test + fun whenGetMetricParametersThenReturnVersion() = runTest { + givenDaysSinceInstalled(29) + + val version = testee.getMetricParameters()["version"] + + assertEquals("0", version) + } + + private fun givenDaysSinceInstalled(days: Int) { + whenever(appInstall.getInstallationTimestamp()).thenReturn(123L) + whenever(dateUtils.daysSince(123L)).thenReturn(days) + } +} diff --git a/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/retention/RetentionWeekAttributedMetricTest.kt b/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/retention/RetentionWeekAttributedMetricTest.kt new file mode 100644 index 000000000000..c6c002b130ab --- /dev/null +++ b/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/retention/RetentionWeekAttributedMetricTest.kt @@ -0,0 +1,234 @@ +/* + * 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.attributed.metrics.retention + +import android.annotation.SuppressLint +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.duckduckgo.app.attributed.metrics.AttributedMetricsConfigFeature +import com.duckduckgo.app.attributed.metrics.api.AttributedMetricClient +import com.duckduckgo.app.attributed.metrics.api.AttributedMetricConfig +import com.duckduckgo.app.attributed.metrics.api.MetricBucket +import com.duckduckgo.app.attributed.metrics.store.AttributedMetricsDateUtils +import com.duckduckgo.browser.api.install.AppInstall +import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory +import com.duckduckgo.feature.toggles.api.Toggle.State +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +@SuppressLint("DenyListedApi") +@RunWith(AndroidJUnit4::class) +class RetentionWeekAttributedMetricTest { + + @get:Rule + val coroutineRule = CoroutineTestRule() + + private val attributedMetricClient: AttributedMetricClient = mock() + private val attributedMetricConfig: AttributedMetricConfig = mock() + private val appInstall: AppInstall = mock() + private val dateUtils: AttributedMetricsDateUtils = mock() + private val retentionToggle = FakeFeatureToggleFactory.create(AttributedMetricsConfigFeature::class.java) + + private lateinit var testee: RetentionWeekAttributedMetric + + @Before + fun setup() = runTest { + retentionToggle.retention().setRawStoredState(State(true)) + retentionToggle.canEmitRetention().setRawStoredState(State(true)) + whenever(attributedMetricConfig.metricsToggles()).thenReturn( + listOf(retentionToggle.retention(), retentionToggle.canEmitRetention()), + ) + whenever(attributedMetricConfig.getBucketConfiguration()).thenReturn( + mapOf( + "attributed_metric_retention_week" to MetricBucket( + buckets = listOf(1, 2, 3, 4), + version = 0, + ), + ), + ) + testee = RetentionWeekAttributedMetric( + appCoroutineScope = coroutineRule.testScope, + dispatcherProvider = coroutineRule.testDispatcherProvider, + attributedMetricClient = attributedMetricClient, + appInstall = appInstall, + dateUtils = dateUtils, + attributedMetricConfig = attributedMetricConfig, + ) + } + + @Test + fun whenPixelNameRequestedThenReturnCorrectName() { + assertEquals("attributed_metric_retention_week", testee.getPixelName()) + } + + @Test + fun whenAtbNotChangedThenDoNotEmitMetric() = runTest { + testee.onAppRetentionAtbRefreshed("atb", "atb") + + verify(attributedMetricClient, never()).emitMetric(testee) + } + + @Test + fun whenAppOpensAndDaysLessThan1ThenDoNotEmitMetric() = runTest { + givenDaysSinceInstalled(0) + + testee.onAppRetentionAtbRefreshed("old", "new") + + verify(attributedMetricClient, never()).emitMetric(testee) + } + + @Test + fun whenAppOpensAndDaysIs1ThenEmitMetric() = runTest { + givenDaysSinceInstalled(1) + + testee.onAppRetentionAtbRefreshed("old", "new") + + verify(attributedMetricClient).emitMetric(testee) + } + + @Test + fun whenAppOpensAndDaysIs1ButFFDisabledThenDoNotEmitMetric() = runTest { + retentionToggle.retention().setRawStoredState(State(false)) + whenever(attributedMetricConfig.metricsToggles()).thenReturn( + listOf(retentionToggle.retention(), retentionToggle.canEmitRetention()), + ) + givenDaysSinceInstalled(1) + + testee.onAppRetentionAtbRefreshed("old", "new") + + verify(attributedMetricClient, never()).emitMetric(testee) + } + + @Test + fun whenAppOpensAndDaysIs1AndEmitDisabledThenDoNotEmitMetric() = runTest { + retentionToggle.retention().setRawStoredState(State(true)) + retentionToggle.canEmitRetention().setRawStoredState(State(false)) + whenever(attributedMetricConfig.metricsToggles()).thenReturn( + listOf(retentionToggle.retention(), retentionToggle.canEmitRetention()), + ) + givenDaysSinceInstalled(1) + + testee.onAppRetentionAtbRefreshed("old", "new") + + verify(attributedMetricClient, never()).emitMetric(testee) + } + + @Test + fun whenDaysLessThan1ThenReturnEmptyParameters() = runTest { + givenDaysSinceInstalled(0) + + val params = testee.getMetricParameters() + + assertEquals(emptyMap(), params) + } + + @Test + fun whenDaysInstalledThenReturnCorrectPeriod() = runTest { + // Map of days installed to expected period number + val periodRanges = mapOf( + 0 to -1, // Day 0 -> not captured + 1 to 0, // Day 1 -> week 1, Bucket 0 + 4 to 0, // Day 4 -> week 1, Bucket 0 + 7 to 0, // Day 7 -> week 1, Bucket 0 + 8 to 1, // Day 8 -> week 2, Bucket 1 + 11 to 1, // Day 11 -> week 2, Bucket 1 + 14 to 1, // Day 14 -> week 2, Bucket 1 + 15 to 2, // Day 15 -> week 3, Bucket 2 + 18 to 2, // Day 18 -> week 3, Bucket 2 + 21 to 2, // Day 21 -> week 3, Bucket 2 + 22 to 3, // Day 22 -> week 4, Bucket 3 + 25 to 3, // Day 25 -> week 4, Bucket 3 + 28 to 3, // Day 28 -> week 4, Bucket 3 + 29 to -1, // Day 29 -> outside first month + 35 to -1, // Day 35 -> outside first month + ) + + periodRanges.forEach { (days, expectedPeriod) -> + givenDaysSinceInstalled(days) + + val params = testee.getMetricParameters()["count"] + + val expectedCount = if (expectedPeriod > -1) { + expectedPeriod.toString() + } else { + null + } + + assertEquals( + "For $days days installed, should return period $expectedPeriod", + expectedCount, + params, + ) + } + } + + @Test + fun whenDaysInstalledThenReturnCorrectTag() = runTest { + // Test different days and expected period numbers + val testCases = mapOf( + 0 to "-1", // Day 0 -> not captured + 1 to "0", // Day 1 -> week 1, Bucket 0 + 4 to "0", // Day 4 -> week 1, Bucket 0 + 7 to "0", // Day 7 -> week 1, Bucket 0 + 8 to "1", // Day 8 -> week 2, Bucket 1 + 11 to "1", // Day 11 -> week 2, Bucket 1 + 14 to "1", // Day 14 -> week 2, Bucket 1 + 15 to "2", // Day 15 -> week 3, Bucket 2 + 18 to "2", // Day 18 -> week 3, Bucket 2 + 21 to "2", // Day 21 -> week 3, Bucket 2 + 22 to "3", // Day 22 -> week 4, Bucket 3 + 25 to "3", // Day 25 -> week 4, Bucket 3 + 28 to "3", // Day 28 -> week 4, Bucket 3 + 29 to "-1", // Day 29 -> outside first month + 35 to "-1", // Day 35 -> outside first month + ) + + testCases.forEach { (days, expectedTag) -> + givenDaysSinceInstalled(days) + + val tag = testee.getTag() + + assertEquals( + "For $days days installed, should return tag $expectedTag", + expectedTag, + tag, + ) + } + } + + @Test + fun whenGetMetricParametersThenReturnVersion() = runTest { + givenDaysSinceInstalled(7) + + val version = testee.getMetricParameters()["version"] + + assertEquals("0", version) + } + + private fun givenDaysSinceInstalled(days: Int) { + whenever(appInstall.getInstallationTimestamp()).thenReturn(123L) + whenever(dateUtils.daysSince(123L)).thenReturn(days) + } +} diff --git a/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/RealSearchAttributedMetricTest.kt b/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/search/SearchAttributedMetricTest.kt similarity index 57% rename from attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/RealSearchAttributedMetricTest.kt rename to attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/search/SearchAttributedMetricTest.kt index c1da43838230..d04b9c963176 100644 --- a/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/RealSearchAttributedMetricTest.kt +++ b/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/search/SearchAttributedMetricTest.kt @@ -14,15 +14,21 @@ * limitations under the License. */ -package com.duckduckgo.app.attributed.metrics +package com.duckduckgo.app.attributed.metrics.search +import android.annotation.SuppressLint import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.duckduckgo.app.attributed.metrics.AttributedMetricsConfigFeature import com.duckduckgo.app.attributed.metrics.api.AttributedMetricClient +import com.duckduckgo.app.attributed.metrics.api.AttributedMetricConfig import com.duckduckgo.app.attributed.metrics.api.EventStats +import com.duckduckgo.app.attributed.metrics.api.MetricBucket import com.duckduckgo.app.attributed.metrics.store.AttributedMetricsDateUtils import com.duckduckgo.app.statistics.store.StatisticsDataStore import com.duckduckgo.browser.api.install.AppInstall import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory +import com.duckduckgo.feature.toggles.api.Toggle.State import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertNull @@ -37,8 +43,9 @@ import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever +@SuppressLint("DenyListedApi") @RunWith(AndroidJUnit4::class) -class RealSearchAttributedMetricTest { +class SearchAttributedMetricTest { @get:Rule val coroutineRule = CoroutineTestRule() @@ -47,18 +54,38 @@ class RealSearchAttributedMetricTest { private val appInstall: AppInstall = mock() private val statisticsDataStore: StatisticsDataStore = mock() private val dateUtils: AttributedMetricsDateUtils = mock() + private val attributedMetricConfig: AttributedMetricConfig = mock() + private val searchToggle = FakeFeatureToggleFactory.create(AttributedMetricsConfigFeature::class.java) - private lateinit var testee: RealSearchAttributedMetric + private lateinit var testee: SearchAttributedMetric @Before - fun setup() { - testee = RealSearchAttributedMetric( + fun setup() = runTest { + searchToggle.searchCountAvg().setRawStoredState(State(true)) + searchToggle.canEmitSearchCountAvg().setRawStoredState(State(true)) + whenever(attributedMetricConfig.metricsToggles()).thenReturn( + listOf(searchToggle.searchCountAvg(), searchToggle.canEmitSearchCountAvg()), + ) + whenever(attributedMetricConfig.getBucketConfiguration()).thenReturn( + mapOf( + "attributed_metric_average_searches_past_week_first_month" to MetricBucket( + buckets = listOf(5, 9), + version = 0, + ), + "attributed_metric_average_searches_past_week" to MetricBucket( + buckets = listOf(5, 9), + version = 0, + ), + ), + ) + testee = SearchAttributedMetric( appCoroutineScope = coroutineRule.testScope, dispatcherProvider = coroutineRule.testDispatcherProvider, attributedMetricClient = attributedMetricClient, appInstall = appInstall, statisticsDataStore = statisticsDataStore, dateUtils = dateUtils, + attributedMetricConfig = attributedMetricConfig, ) } @@ -73,6 +100,7 @@ class RealSearchAttributedMetricTest { fun whenOnSearchAndAtbNotChangedThenDoNotEmitMetric() = runTest { testee.onSearchRetentionAtbRefreshed("same", "same") + verify(attributedMetricClient).collectEvent("ddg_search") verify(attributedMetricClient, never()).emitMetric(testee) } @@ -87,21 +115,21 @@ class RealSearchAttributedMetricTest { fun whenDaysSinceInstalledLessThan4WThenReturnFirstMonthPixelName() { givenDaysSinceInstalled(15) - assertEquals("user_average_searches_past_week_first_month", testee.getPixelName()) + assertEquals("attributed_metric_average_searches_past_week_first_month", testee.getPixelName()) } @Test fun whenDaysSinceInstalledMoreThan4WThenReturnRegularPixelName() { givenDaysSinceInstalled(45) - assertEquals("user_average_searches_past_week", testee.getPixelName()) + assertEquals("attributed_metric_average_searches_past_week", testee.getPixelName()) } @Test fun whenDaysSinceInstalledIsEndOf4WThenReturnFirstMonthPixelName() { givenDaysSinceInstalled(28) - assertEquals("user_average_searches_past_week_first_month", testee.getPixelName()) + assertEquals("attributed_metric_average_searches_past_week_first_month", testee.getPixelName()) } @Test @@ -145,18 +173,77 @@ class RealSearchAttributedMetricTest { verify(attributedMetricClient).emitMetric(testee) } + @Test + fun whenSearchedButFFDisabledThenDoNotCollectAndDoNotEmitMetric() = runTest { + searchToggle.searchCountAvg().setRawStoredState(State(false)) + whenever(attributedMetricConfig.metricsToggles()).thenReturn( + listOf(searchToggle.searchCountAvg(), searchToggle.canEmitSearchCountAvg()), + ) + givenDaysSinceInstalled(7) + whenever(attributedMetricClient.getEventStats(any(), any())).thenReturn( + EventStats( + totalEvents = 16, + daysWithEvents = 3, + rollingAverage = 5.3, + ), + ) + + testee.onSearchRetentionAtbRefreshed("old", "new") + + verify(attributedMetricClient, never()).collectEvent("ddg_search") + verify(attributedMetricClient, never()).emitMetric(testee) + } + + @Test + fun whenSearchedButEmitDisabledThenCollectButDoNotEmitMetric() = runTest { + searchToggle.searchCountAvg().setRawStoredState(State(true)) + searchToggle.canEmitSearchCountAvg().setRawStoredState(State(false)) + whenever(attributedMetricConfig.metricsToggles()).thenReturn( + listOf(searchToggle.searchCountAvg(), searchToggle.canEmitSearchCountAvg()), + ) + givenDaysSinceInstalled(7) + whenever(attributedMetricClient.getEventStats(any(), any())).thenReturn( + EventStats( + totalEvents = 16, + daysWithEvents = 3, + rollingAverage = 5.3, + ), + ) + + testee.onSearchRetentionAtbRefreshed("old", "new") + + verify(attributedMetricClient).collectEvent("ddg_search") + verify(attributedMetricClient, never()).emitMetric(testee) + } + @Test fun given7dAverageThenReturnCorrectAverageBucketInParams() = runTest { + whenever(attributedMetricConfig.getBucketConfiguration()).thenReturn( + mapOf( + "attributed_metric_average_searches_past_week_first_month" to MetricBucket( + buckets = listOf(5, 9), + version = 0, + ), + "attributed_metric_average_searches_past_week" to MetricBucket( + buckets = listOf(5, 9), + version = 0, + ), + ), + ) givenDaysSinceInstalled(7) // Map of 7d average to expected bucket val searches7dAvgExpectedBuckets = mapOf( - 2.2 to 0, - 4.4 to 0, - 6.6 to 1, - 9.9 to 1, - 10.0 to 2, - 14.1 to 2, + 2.2 to 0, // ≤5 -> bucket 0 + 4.4 to 0, // ≤5 -> bucket 0 + 5.0 to 0, // ≤5 -> bucket 0 + 5.1 to 0, // rounds to 5, ≤5 -> bucket 0 + 5.8 to 1, // rounds to 6, >5 and ≤9 -> bucket 1 + 6.6 to 1, // >5 and ≤9 -> bucket 1 + 9.0 to 1, // >5 and ≤9 -> bucket 1 + 9.3 to 1, // rounds to 9, >5 and ≤9 -> bucket 1 + 10.0 to 2, // >9 -> bucket 2 + 14.1 to 2, // >9 -> bucket 2 ) searches7dAvgExpectedBuckets.forEach { (avg, bucket) -> @@ -168,11 +255,12 @@ class RealSearchAttributedMetricTest { ), ) - val params = testee.getMetricParameters() + val realBucket = testee.getMetricParameters()["count"] assertEquals( - mapOf("count" to bucket.toString()), - params, + "For $avg searches, should return bucket $bucket", + bucket.toString(), + realBucket, ) } } @@ -190,7 +278,6 @@ class RealSearchAttributedMetricTest { val params = testee.getMetricParameters() - assertEquals("0", params["count"]) assertEquals("5", params["dayAverage"]) } @@ -208,7 +295,6 @@ class RealSearchAttributedMetricTest { val params = testee.getMetricParameters() - assertEquals("0", params["count"]) assertNull(params["dayAverage"]) } @@ -246,6 +332,22 @@ class RealSearchAttributedMetricTest { verify(attributedMetricClient).getEventStats(eq("ddg_search"), eq(7)) } + @Test + fun whenGetMetricParametersThenReturnVersion() = runTest { + givenDaysSinceInstalled(7) + whenever(attributedMetricClient.getEventStats(any(), any())).thenReturn( + EventStats( + totalEvents = 16, + daysWithEvents = 3, + rollingAverage = 5.3, + ), + ) + + val version = testee.getMetricParameters()["version"] + + assertEquals("0", version) + } + private fun givenDaysSinceInstalled(days: Int) { whenever(appInstall.getInstallationTimestamp()).thenReturn(123L) whenever(dateUtils.daysSince(123L)).thenReturn(days) diff --git a/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/RealSearchDaysAttributedMetricTest.kt b/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/search/SearchDaysAttributedMetricTest.kt similarity index 54% rename from attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/RealSearchDaysAttributedMetricTest.kt rename to attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/search/SearchDaysAttributedMetricTest.kt index f694ed6fa079..fe7ebbaa3159 100644 --- a/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/RealSearchDaysAttributedMetricTest.kt +++ b/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/search/SearchDaysAttributedMetricTest.kt @@ -14,17 +14,24 @@ * limitations under the License. */ -package com.duckduckgo.app.attributed.metrics +package com.duckduckgo.app.attributed.metrics.search +import android.annotation.SuppressLint import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.duckduckgo.app.attributed.metrics.AttributedMetricsConfigFeature import com.duckduckgo.app.attributed.metrics.api.AttributedMetricClient +import com.duckduckgo.app.attributed.metrics.api.AttributedMetricConfig import com.duckduckgo.app.attributed.metrics.api.EventStats +import com.duckduckgo.app.attributed.metrics.api.MetricBucket import com.duckduckgo.app.attributed.metrics.store.AttributedMetricsDateUtils import com.duckduckgo.app.statistics.store.StatisticsDataStore import com.duckduckgo.browser.api.install.AppInstall import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory +import com.duckduckgo.feature.toggles.api.Toggle.State import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Before import org.junit.Rule import org.junit.Test @@ -35,8 +42,9 @@ import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever +@SuppressLint("DenyListedApi") @RunWith(AndroidJUnit4::class) -class RealSearchDaysAttributedMetricTest { +class SearchDaysAttributedMetricTest { @get:Rule val coroutineRule = CoroutineTestRule() @@ -45,41 +53,78 @@ class RealSearchDaysAttributedMetricTest { private val appInstall: AppInstall = mock() private val statisticsDataStore: StatisticsDataStore = mock() private val dateUtils: AttributedMetricsDateUtils = mock() - private lateinit var testee: RealSearchDaysAttributedMetric + private val attributedMetricConfig: AttributedMetricConfig = mock() + private val searchDaysToggle = FakeFeatureToggleFactory.create(AttributedMetricsConfigFeature::class.java) + + private lateinit var testee: SearchDaysAttributedMetric @Before - fun setup() { - testee = RealSearchDaysAttributedMetric( + fun setup() = runTest { + searchDaysToggle.searchDaysAvg().setRawStoredState(State(true)) + searchDaysToggle.canEmitSearchDaysAvg().setRawStoredState(State(true)) + whenever(attributedMetricConfig.metricsToggles()).thenReturn( + listOf(searchDaysToggle.searchDaysAvg(), searchDaysToggle.canEmitSearchDaysAvg()), + ) + whenever(attributedMetricConfig.getBucketConfiguration()).thenReturn( + mapOf( + "attributed_metric_active_past_week" to MetricBucket( + buckets = listOf(2, 4), + version = 0, + ), + ), + ) + testee = SearchDaysAttributedMetric( appCoroutineScope = coroutineRule.testScope, dispatcherProvider = coroutineRule.testDispatcherProvider, attributedMetricClient = attributedMetricClient, appInstall = appInstall, statisticsDataStore = statisticsDataStore, dateUtils = dateUtils, + attributedMetricConfig = attributedMetricConfig, ) } @Test - fun whenOnFirstSearchThenCollectEventCalled() { + fun whenOnFirstSearchThenCollectEventCalled() = runTest { testee.onSearchRetentionAtbRefreshed("old", "new") verify(attributedMetricClient).collectEvent("ddg_search_days") } @Test - fun whenOnEachSearchThenCollectEventCalled() { + fun whenOnEachSearchThenCollectEventCalled() = runTest { testee.onSearchRetentionAtbRefreshed("same", "same") verify(attributedMetricClient).collectEvent("ddg_search_days") } + @Test + fun whenSearchedButFFDisabledThenDoNotCollectMetric() = runTest { + searchDaysToggle.searchDaysAvg().setRawStoredState(State(false)) + whenever(attributedMetricConfig.metricsToggles()).thenReturn( + listOf(searchDaysToggle.searchDaysAvg(), searchDaysToggle.canEmitSearchDaysAvg()), + ) + givenDaysSinceInstalled(7) + whenever(attributedMetricClient.getEventStats(any(), any())).thenReturn( + EventStats( + totalEvents = 16, + daysWithEvents = 3, + rollingAverage = 5.3, + ), + ) + + testee.onSearchRetentionAtbRefreshed("old", "new") + + verify(attributedMetricClient, never()).collectEvent("ddg_search_days") + } + @Test fun whenPixelNameRequestedThenReturnCorrectName() { - assertEquals("user_active_past_week", testee.getPixelName()) + assertEquals("attributed_metric_active_past_week", testee.getPixelName()) } @Test - fun whenFirstSearchOfDayIfInstallationDayThenDoNotEmitMetric() = runTest { + fun whenAtbRefreshedIfInstallationDayThenDoNotEmitMetric() = runTest { givenDaysSinceInstalled(0) testee.onAppRetentionAtbRefreshed("old", "new") @@ -88,7 +133,7 @@ class RealSearchDaysAttributedMetricTest { } @Test - fun whenFirstSearchOfDayIfNoDaysWithEventsThenDoNotEmitMetric() = runTest { + fun whenAtbRefreshedIfNoDaysWithEventsThenDoNotEmitMetric() = runTest { givenDaysSinceInstalled(3) whenever(attributedMetricClient.getEventStats(any(), any())).thenReturn( EventStats( @@ -104,7 +149,7 @@ class RealSearchDaysAttributedMetricTest { } @Test - fun whenFirstSearchOfDayIfHasDaysWithEventsThenEmitMetric() = runTest { + fun whenAtbRefreshedIfHasDaysWithEventsThenEmitMetric() = runTest { givenDaysSinceInstalled(3) whenever(attributedMetricClient.getEventStats(any(), any())).thenReturn( EventStats( @@ -135,6 +180,27 @@ class RealSearchDaysAttributedMetricTest { verify(attributedMetricClient, never()).emitMetric(testee) } + @Test + fun whenAtbRefreshedButEmitDisabledThenDoNotEmitMetric() = runTest { + searchDaysToggle.searchDaysAvg().setRawStoredState(State(true)) + searchDaysToggle.canEmitSearchDaysAvg().setRawStoredState(State(false)) + whenever(attributedMetricConfig.metricsToggles()).thenReturn( + listOf(searchDaysToggle.searchDaysAvg(), searchDaysToggle.canEmitSearchDaysAvg()), + ) + givenDaysSinceInstalled(7) + whenever(attributedMetricClient.getEventStats(any(), any())).thenReturn( + EventStats( + totalEvents = 16, + daysWithEvents = 3, + rollingAverage = 5.3, + ), + ) + + testee.onAppRetentionAtbRefreshed("old", "new") + + verify(attributedMetricClient, never()).emitMetric(testee) + } + @Test fun whenGetTagThenReturnAppRetentionAtb() = runTest { whenever(statisticsDataStore.appRetentionAtb).thenReturn("v123-1") @@ -148,14 +214,14 @@ class RealSearchDaysAttributedMetricTest { // Map of days with events to expected bucket val daysWithEventsExpectedBuckets = mapOf( - 0 to 0, - 1 to 0, - 2 to 0, - 3 to 1, - 4 to 1, - 5 to 2, - 6 to 2, - 7 to 2, + 0 to 0, // 0 days ≤2 -> bucket 0 + 1 to 0, // 1 day ≤2 -> bucket 0 + 2 to 0, // 2 days ≤2 -> bucket 0 + 3 to 1, // 3 days >2 and ≤4 -> bucket 1 + 4 to 1, // 4 days >2 and ≤4 -> bucket 1 + 5 to 2, // 5 days >4 -> bucket 2 + 6 to 2, // 6 days >4 -> bucket 2 + 7 to 2, // 7 days >4 -> bucket 2 ) daysWithEventsExpectedBuckets.forEach { (days, bucket) -> @@ -167,11 +233,12 @@ class RealSearchDaysAttributedMetricTest { ), ) - val params = testee.getMetricParameters() + val realBucket = testee.getMetricParameters()["days"] assertEquals( - mapOf("days" to bucket.toString()), - params, + "For $days days with events, should return bucket $bucket", + bucket.toString(), + realBucket, ) } } @@ -187,19 +254,29 @@ class RealSearchDaysAttributedMetricTest { ), ) - val params = testee.getMetricParameters() + val daysWindow = testee.getMetricParameters()["daysSinceInstalled"] - assertEquals( - mapOf( - "days" to "2", - "daysSinceInstalled" to "5", + assertEquals("5", daysWindow) + } + + @Test + fun whenDaysSinceInstalledIs7ThenDoNotIncludeDaysSinceInstalled() = runTest { + givenDaysSinceInstalled(7) + whenever(attributedMetricClient.getEventStats(any(), any())).thenReturn( + EventStats( + totalEvents = 25, + daysWithEvents = 5, + rollingAverage = 5.0, ), - params, ) + + val daysSince = testee.getMetricParameters()["daysSinceInstalled"] + + assertNull(daysSince) } @Test - fun whenDaysSinceInstalledIs8ThenDoNotIncludeDaysSinceInstalled() = runTest { + fun whenGetMetricParametersThenReturnVersion() = runTest { givenDaysSinceInstalled(7) whenever(attributedMetricClient.getEventStats(any(), any())).thenReturn( EventStats( @@ -209,12 +286,9 @@ class RealSearchDaysAttributedMetricTest { ), ) - val params = testee.getMetricParameters() + val version = testee.getMetricParameters()["version"] - assertEquals( - mapOf("days" to "2"), - params, - ) + assertEquals("0", version) } private fun givenDaysSinceInstalled(days: Int) { diff --git a/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/store/RealAttributedMetricsDateUtilsTest.kt b/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/store/RealAttributedMetricsDateUtilsTest.kt new file mode 100644 index 000000000000..b9834107eefb --- /dev/null +++ b/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/store/RealAttributedMetricsDateUtilsTest.kt @@ -0,0 +1,146 @@ +/* + * 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.attributed.metrics.store + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.time.LocalDate +import java.time.format.DateTimeFormatter + +@RunWith(AndroidJUnit4::class) +class RealAttributedMetricsDateUtilsTest { + + private lateinit var testee: RealAttributedMetricsDateUtils + + @Before + fun setup() { + testee = RealAttributedMetricsDateUtils() + } + + @Test + fun whenGetCurrentDateThenReturnsFormattedDateInET() { + val result = testee.getCurrentDate() + + // Verify it matches the expected format yyyy-MM-dd + val dateRegex = Regex("\\d{4}-\\d{2}-\\d{2}") + assertTrue("Date should match yyyy-MM-dd format", dateRegex.matches(result)) + + // Verify it's parseable + val parsedDate = LocalDate.parse(result, DateTimeFormatter.ofPattern("yyyy-MM-dd")) + assertTrue("Date should be parseable", parsedDate != null) + } + + @Test + fun whenGetDateFromTimestampThenReturnsDateInET() { + // 2024-01-01 00:00:00 UTC (which is 2023-12-31 19:00:00 EST) + val timestamp = 1704067200000L + + val result = testee.getDateFromTimestamp(timestamp) + + assertEquals("2023-12-31", result) + } + + @Test + fun whenGetDateFromTimestampForMiddayUTCThenReturnsCorrectDateInET() { + // 2024-06-15 12:00:00 UTC (which is 2024-06-15 08:00:00 EDT) + val timestamp = 1718452800000L + + val result = testee.getDateFromTimestamp(timestamp) + + assertEquals("2024-06-15", result) + } + + @Test + fun whenGetDateFromTimestampForMidnightETThenReturnsCorrectDate() { + // 2024-07-04 04:00:00 UTC (which is 2024-07-04 00:00:00 EDT) + val timestamp = 1720065600000L + + val result = testee.getDateFromTimestamp(timestamp) + + assertEquals("2024-07-04", result) + } + + @Test + fun whenGetDateMinusDaysThenReturnsDateInPast() { + val currentDate = testee.getCurrentDate() + val sevenDaysAgo = testee.getDateMinusDays(7) + + val current = LocalDate.parse(currentDate, DateTimeFormatter.ofPattern("yyyy-MM-dd")) + val past = LocalDate.parse(sevenDaysAgo, DateTimeFormatter.ofPattern("yyyy-MM-dd")) + + assertEquals(7, current.toEpochDay() - past.toEpochDay()) + } + + @Test + fun whenGetDateMinusDaysWithZeroThenReturnsCurrentDate() { + val currentDate = testee.getCurrentDate() + val result = testee.getDateMinusDays(0) + + assertEquals(currentDate, result) + } + + @Test + fun whenGetDateMinusDaysWithOneThenReturnsYesterday() { + val currentDate = testee.getCurrentDate() + val yesterday = testee.getDateMinusDays(1) + + val current = LocalDate.parse(currentDate, DateTimeFormatter.ofPattern("yyyy-MM-dd")) + val past = LocalDate.parse(yesterday, DateTimeFormatter.ofPattern("yyyy-MM-dd")) + + assertEquals(1, current.toEpochDay() - past.toEpochDay()) + } + + @Test + fun whenDaysSinceWithDateStringThenReturnsPositiveForPastDate() { + val sevenDaysAgo = testee.getDateMinusDays(7) + + val result = testee.daysSince(sevenDaysAgo) + + assertEquals(7, result) + } + + @Test + fun whenDaysSinceWithDateStringForTodayThenReturnsZero() { + val result = testee.daysSince(testee.getCurrentDate()) + + assertEquals(0, result) + } + + @Test + fun whenGetDateMinusDaysWithLargeNumberThenReturnsCorrectDate() { + val currentDate = testee.getCurrentDate() + val thirtyDaysAgo = testee.getDateMinusDays(30) + + val current = LocalDate.parse(currentDate, DateTimeFormatter.ofPattern("yyyy-MM-dd")) + val past = LocalDate.parse(thirtyDaysAgo, DateTimeFormatter.ofPattern("yyyy-MM-dd")) + + assertEquals(30, current.toEpochDay() - past.toEpochDay()) + } + + @Test + fun whenDaysSinceWithDateStringForPastMonthThenReturnsCorrectDays() { + val thirtyDaysAgo = testee.getDateMinusDays(30) + + val result = testee.daysSince(thirtyDaysAgo) + + assertEquals(30, result) + } +} diff --git a/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/store/RealEventRepositoryTest.kt b/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/store/RealEventRepositoryTest.kt index e0b02c1aec86..f755b1dac081 100644 --- a/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/store/RealEventRepositoryTest.kt +++ b/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/store/RealEventRepositoryTest.kt @@ -144,18 +144,16 @@ class RealEventRepositoryTest { } @Test - fun whenDeleteOldEventsThenRemoveOnlyOlderThanSpecified() = + fun whenDeleteAllEventsThenRemoveAllEvents() = runTest { // Setup data eventDao.insertEvent(EventEntity("test_event", count = 1, day = "2025-10-03")) eventDao.insertEvent(EventEntity("test_event", count = 1, day = "2025-10-02")) eventDao.insertEvent(EventEntity("test_event", count = 1, day = "2025-09-03")) - testDateProvider.testDate = LocalDate.of(2025, 10, 3) - repository.deleteOldEvents(olderThanDays = 5) + repository.deleteAllEvents() val remainingEvents = eventDao.getEventsByNameAndTimeframe("test_event", "2025-09-03", "2025-10-03") - assert(remainingEvents.size == 2) - assert(remainingEvents.none { it.day == "2025-09-03" }) + assert(remainingEvents.isEmpty()) } } diff --git a/attributed-metrics/attributed-metrics-internal/src/main/java/com/duckduckgo/app/attributed/metrics/internal/ui/AttributedMetricsDevSettingsActivity.kt b/attributed-metrics/attributed-metrics-internal/src/main/java/com/duckduckgo/app/attributed/metrics/internal/ui/AttributedMetricsDevSettingsActivity.kt index 90cdcfd48ff6..1871d93e0e28 100644 --- a/attributed-metrics/attributed-metrics-internal/src/main/java/com/duckduckgo/app/attributed/metrics/internal/ui/AttributedMetricsDevSettingsActivity.kt +++ b/attributed-metrics/attributed-metrics-internal/src/main/java/com/duckduckgo/app/attributed/metrics/internal/ui/AttributedMetricsDevSettingsActivity.kt @@ -76,6 +76,12 @@ class AttributedMetricsDevSettingsActivity : DuckDuckGoActivity() { binding.addTestEventsButton.setOnClickListener { addTestEvents() } + binding.addAdClickTestEventsButton.setOnClickListener { + addAdClickTestEvents() + } + binding.addAiPromptsTestEventsButton.setOnClickListener { + addAiUsageTestEvents() + } lifecycleScope.launch { binding.clientActive.setSecondaryText(if (attributedMetricsState.isActive()) "Yes" else "No") binding.returningUser.setSecondaryText(if (appBuildConfig.isAppReinstall()) "Yes" else "No") @@ -92,6 +98,26 @@ class AttributedMetricsDevSettingsActivity : DuckDuckGoActivity() { Toast.makeText(this@AttributedMetricsDevSettingsActivity, "Test events added", Toast.LENGTH_SHORT).show() } } + + private fun addAdClickTestEvents() { + lifecycleScope.launch { + repeat(10) { daysAgo -> + val date = dateUtils.getDateMinusDays(daysAgo) + eventDao.insertEvent(EventEntity(eventName = "ad_click", count = 1, day = date)) + } + Toast.makeText(this@AttributedMetricsDevSettingsActivity, "Test events added", Toast.LENGTH_SHORT).show() + } + } + + private fun addAiUsageTestEvents() { + lifecycleScope.launch { + repeat(10) { daysAgo -> + val date = dateUtils.getDateMinusDays(daysAgo) + eventDao.insertEvent(EventEntity(eventName = "submit_prompt", count = 1, day = date)) + } + Toast.makeText(this@AttributedMetricsDevSettingsActivity, "Test events added", Toast.LENGTH_SHORT).show() + } + } } data object MainAttributedMetricsSettings : GlobalActivityStarter.ActivityParams { diff --git a/attributed-metrics/attributed-metrics-internal/src/main/res/layout/activity_attributed_metrics_dev_settings.xml b/attributed-metrics/attributed-metrics-internal/src/main/res/layout/activity_attributed_metrics_dev_settings.xml index 5506a1b3ab27..cf36130c02f0 100644 --- a/attributed-metrics/attributed-metrics-internal/src/main/res/layout/activity_attributed_metrics_dev_settings.xml +++ b/attributed-metrics/attributed-metrics-internal/src/main/res/layout/activity_attributed_metrics_dev_settings.xml @@ -42,6 +42,22 @@ app:primaryText="Add Search Events" app:secondaryText="Adds 1 search events for last 10days" /> + + + + { ReportMetric .fromValue(data?.optString("metricName")) - ?.let { reportMetric -> duckChatPixels.sendReportMetricPixel(reportMetric) } + ?.let { reportMetric -> + duckChatPixels.sendReportMetricPixel(reportMetric) + if (reportMetric == ReportMetric.USER_DID_SUBMIT_PROMPT || reportMetric == ReportMetric.USER_DID_SUBMIT_FIRST_PROMPT) { + duckAiMetricCollector.onMessageSent() + } + } null } diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/metric/DuckAiAttributedMetric.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/metric/DuckAiAttributedMetric.kt new file mode 100644 index 000000000000..0f2f2f67a997 --- /dev/null +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/metric/DuckAiAttributedMetric.kt @@ -0,0 +1,169 @@ +/* + * 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.duckchat.impl.metric + +import com.duckduckgo.app.attributed.metrics.api.AttributedMetric +import com.duckduckgo.app.attributed.metrics.api.AttributedMetricClient +import com.duckduckgo.app.attributed.metrics.api.AttributedMetricConfig +import com.duckduckgo.app.attributed.metrics.api.EventStats +import com.duckduckgo.app.attributed.metrics.api.MetricBucket +import com.duckduckgo.app.di.AppCoroutineScope +import com.duckduckgo.browser.api.install.AppInstall +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import com.squareup.anvil.annotations.ContributesMultibinding +import dagger.SingleInstanceIn +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart.LAZY +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import logcat.logcat +import java.time.Instant +import java.time.ZoneId +import java.time.temporal.ChronoUnit +import javax.inject.Inject +import kotlin.math.roundToInt + +interface DuckAiMetricCollector { + fun onMessageSent() +} + +@ContributesMultibinding(AppScope::class, AttributedMetric::class) +@ContributesBinding(AppScope::class, DuckAiMetricCollector::class) +@SingleInstanceIn(AppScope::class) +class DuckAiAttributedMetric @Inject constructor( + @AppCoroutineScope private val appCoroutineScope: CoroutineScope, + private val dispatcherProvider: DispatcherProvider, + private val attributedMetricClient: AttributedMetricClient, + private val appInstall: AppInstall, + private val attributedMetricConfig: AttributedMetricConfig, +) : AttributedMetric, DuckAiMetricCollector { + + companion object { + private const val EVENT_NAME = "submit_prompt" + private const val PIXEL_NAME = "attributed_metric_average_duck_ai_usage_past_week" + private const val FEATURE_TOGGLE_NAME = "aiUsageAvg" + private const val FEATURE_EMIT_TOGGLE_NAME = "canEmitAIUsageAvg" + private const val DAYS_WINDOW = 7 + } + + private val isEnabled: Deferred = appCoroutineScope.async(start = LAZY) { + getToggle(FEATURE_TOGGLE_NAME)?.isEnabled() ?: false + } + + private val canEmit: Deferred = appCoroutineScope.async(start = LAZY) { + getToggle(FEATURE_EMIT_TOGGLE_NAME)?.isEnabled() ?: false + } + + private val bucketConfig: Deferred = appCoroutineScope.async(start = LAZY) { + attributedMetricConfig.getBucketConfiguration()[PIXEL_NAME] ?: MetricBucket( + buckets = listOf(5, 9), + version = 0, + ) + } + + override fun getPixelName(): String = PIXEL_NAME + + override suspend fun getMetricParameters(): Map { + val stats = getEventStats() + val params = mutableMapOf( + "count" to getBucketValue(stats.rollingAverage.roundToInt()).toString(), + "version" to bucketConfig.await().version.toString(), + ) + if (!hasCompleteDataWindow()) { + params["dayAverage"] = daysSinceInstalled().toString() + } + return params + } + + override suspend fun getTag(): String { + return daysSinceInstalled().toString() + } + + override fun onMessageSent() { + appCoroutineScope.launch(dispatcherProvider.io()) { + if (!isEnabled.await()) return@launch + attributedMetricClient.collectEvent(EVENT_NAME) + if (shouldSendPixel().not()) { + logcat(tag = "AttributedMetrics") { + "DuckAiUsage: Skip emitting, not enough data or no events" + } + return@launch + } + + if (canEmit.await()) { + attributedMetricClient.emitMetric(this@DuckAiAttributedMetric) + } + } + } + + private suspend fun getBucketValue(avg: Int): Int { + val buckets = bucketConfig.await().buckets + return buckets.indexOfFirst { bucket -> avg <= bucket }.let { index -> + if (index == -1) buckets.size else index + } + } + + private suspend fun shouldSendPixel(): Boolean { + if (daysSinceInstalled() <= 0) { + // installation day, we don't emit + return false + } + + val eventStats = getEventStats() + if (eventStats.daysWithEvents == 0 || eventStats.rollingAverage == 0.0) { + // no events, nothing to emit + return false + } + + return true + } + + private fun hasCompleteDataWindow(): Boolean { + val daysSinceInstalled = daysSinceInstalled() + return daysSinceInstalled >= DAYS_WINDOW + } + + private suspend fun getEventStats(): EventStats { + val daysSinceInstall = daysSinceInstalled() + val stats = if (daysSinceInstall >= DAYS_WINDOW) { + attributedMetricClient.getEventStats(EVENT_NAME, DAYS_WINDOW) + } else { + attributedMetricClient.getEventStats(EVENT_NAME, daysSinceInstall) + } + + return stats + } + + private fun daysSinceInstalled(): Int { + val etZone = ZoneId.of("America/New_York") + val installInstant = Instant.ofEpochMilli(appInstall.getInstallationTimestamp()) + val nowInstant = Instant.now() + + val installInEt = installInstant.atZone(etZone) + val nowInEt = nowInstant.atZone(etZone) + + return ChronoUnit.DAYS.between(installInEt.toLocalDate(), nowInEt.toLocalDate()).toInt() + } + + private suspend fun getToggle(toggleName: String) = + attributedMetricConfig.metricsToggles().firstOrNull { toggle -> + toggle.featureName().name == toggleName + } +} diff --git a/duckchat/duckchat-impl/src/test/java/com/duckduckgo/duckchat/impl/metric/DuckAiAttributedMetricTest.kt b/duckchat/duckchat-impl/src/test/java/com/duckduckgo/duckchat/impl/metric/DuckAiAttributedMetricTest.kt new file mode 100644 index 000000000000..7c937567d199 --- /dev/null +++ b/duckchat/duckchat-impl/src/test/java/com/duckduckgo/duckchat/impl/metric/DuckAiAttributedMetricTest.kt @@ -0,0 +1,290 @@ +/* + * 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.duckchat.impl.metric + +import android.annotation.SuppressLint +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.duckduckgo.app.attributed.metrics.api.AttributedMetricClient +import com.duckduckgo.app.attributed.metrics.api.AttributedMetricConfig +import com.duckduckgo.app.attributed.metrics.api.EventStats +import com.duckduckgo.app.attributed.metrics.api.MetricBucket +import com.duckduckgo.browser.api.install.AppInstall +import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory +import com.duckduckgo.feature.toggles.api.Toggle +import com.duckduckgo.feature.toggles.api.Toggle.DefaultFeatureValue +import com.duckduckgo.feature.toggles.api.Toggle.State +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import java.time.Instant +import java.time.ZoneId + +@SuppressLint("DenyListedApi") +@RunWith(AndroidJUnit4::class) +class DuckAiAttributedMetricTest { + + @get:Rule + val coroutineRule = CoroutineTestRule() + + private val attributedMetricClient: AttributedMetricClient = mock() + private val appInstall: AppInstall = mock() + private val attributedMetricConfig: AttributedMetricConfig = mock() + private val aiToggle = FakeFeatureToggleFactory.create(FakeDuckAiMetricsConfigFeature::class.java) + + private lateinit var testee: DuckAiAttributedMetric + + @Before + fun setup() = runTest { + aiToggle.aiUsageAvg().setRawStoredState(State(true)) + aiToggle.canEmitAIUsageAvg().setRawStoredState(State(true)) + whenever(attributedMetricConfig.metricsToggles()).thenReturn( + listOf(aiToggle.aiUsageAvg(), aiToggle.canEmitAIUsageAvg()), + ) + whenever(attributedMetricConfig.getBucketConfiguration()).thenReturn( + mapOf( + "attributed_metric_average_duck_ai_usage_past_week" to MetricBucket( + buckets = listOf(5, 9), + version = 0, + ), + ), + ) + + testee = DuckAiAttributedMetric( + appCoroutineScope = coroutineRule.testScope, + dispatcherProvider = coroutineRule.testDispatcherProvider, + attributedMetricClient = attributedMetricClient, + appInstall = appInstall, + attributedMetricConfig = attributedMetricConfig, + ) + } + + @Test + fun whenPixelNameRequestedThenReturnCorrectName() { + assertEquals("attributed_metric_average_duck_ai_usage_past_week", testee.getPixelName()) + } + + @Test + fun whenMessageSentButFFDisabledThenDoNotCollectMetric() = runTest { + aiToggle.aiUsageAvg().setRawStoredState(State(false)) + whenever(attributedMetricConfig.metricsToggles()).thenReturn( + listOf(aiToggle.aiUsageAvg(), aiToggle.canEmitAIUsageAvg()), + ) + givenDaysSinceInstalled(7) + + testee.onMessageSent() + + verify(attributedMetricClient, never()).collectEvent("submit_prompt") + } + + @Test + fun whenMessageSentAndFFEnabledThenCollectMetric() = runTest { + givenDaysSinceInstalled(7) + + testee.onMessageSent() + + verify(attributedMetricClient).collectEvent("submit_prompt") + } + + @Test + fun whenMessageSentAndInstallationDayThenDoNotEmitMetric() = runTest { + givenDaysSinceInstalled(0) + + testee.onMessageSent() + + verify(attributedMetricClient).collectEvent("submit_prompt") + verify(attributedMetricClient, never()).emitMetric(testee) + } + + @Test + fun whenMessageSentAndNoEventsThenDoNotEmitMetric() = runTest { + givenDaysSinceInstalled(3) + whenever(attributedMetricClient.getEventStats(any(), any())).thenReturn( + EventStats( + totalEvents = 0, + daysWithEvents = 0, + rollingAverage = 0.0, + ), + ) + + testee.onMessageSent() + + verify(attributedMetricClient).collectEvent("submit_prompt") + verify(attributedMetricClient, never()).emitMetric(testee) + } + + @Test + fun whenMessageSentAndHasEventsThenEmitMetric() = runTest { + givenDaysSinceInstalled(3) + whenever(attributedMetricClient.getEventStats(any(), any())).thenReturn( + EventStats( + totalEvents = 16, + daysWithEvents = 3, + rollingAverage = 5.3, + ), + ) + + testee.onMessageSent() + + verify(attributedMetricClient).collectEvent("submit_prompt") + verify(attributedMetricClient).emitMetric(testee) + } + + @Test + fun whenMessageSentButEmitDisabledThenCollectButDoNotEmitMetric() = runTest { + aiToggle.aiUsageAvg().setRawStoredState(State(true)) + aiToggle.canEmitAIUsageAvg().setRawStoredState(State(false)) + whenever(attributedMetricConfig.metricsToggles()).thenReturn( + listOf(aiToggle.aiUsageAvg(), aiToggle.canEmitAIUsageAvg()), + ) + givenDaysSinceInstalled(7) + whenever(attributedMetricClient.getEventStats(any(), any())).thenReturn( + EventStats( + totalEvents = 16, + daysWithEvents = 3, + rollingAverage = 5.3, + ), + ) + + testee.onMessageSent() + + verify(attributedMetricClient).collectEvent("submit_prompt") + verify(attributedMetricClient, never()).emitMetric(testee) + } + + @Test + fun whenGetMetricParametersThenReturnCorrectBucketValue() = runTest { + givenDaysSinceInstalled(7) + + // Map of average usage to expected bucket + val usageAvgExpectedBuckets = mapOf( + 2.2 to 0, // ≤5 -> bucket 0 + 4.4 to 0, // ≤5 -> bucket 0 + 5.0 to 0, // ≤5 -> bucket 0 + 5.1 to 0, // rounds to 5, ≤5 -> bucket 0 + 5.8 to 1, // rounds to 6, >5 and ≤9 -> bucket 1 + 6.6 to 1, // >5 and ≤9 -> bucket 1 + 9.0 to 1, // >5 and ≤9 -> bucket 1 + 9.3 to 1, // rounds to 9, >5 and ≤9 -> bucket 1 + 10.0 to 2, // >9 -> bucket 2 + 14.1 to 2, // >9 -> bucket 2 + ) + + usageAvgExpectedBuckets.forEach { (avg, bucket) -> + whenever(attributedMetricClient.getEventStats(any(), any())).thenReturn( + EventStats( + totalEvents = 16, + daysWithEvents = 3, + rollingAverage = avg, + ), + ) + + val realBucket = testee.getMetricParameters()["count"] + + assertEquals( + "For $avg average usage, should return bucket $bucket", + bucket.toString(), + realBucket, + ) + } + } + + @Test + fun whenDaysSinceInstalledLessThan7ThenIncludeDayAverage() = runTest { + givenDaysSinceInstalled(5) + whenever(attributedMetricClient.getEventStats(any(), any())).thenReturn( + EventStats( + totalEvents = 16, + daysWithEvents = 3, + rollingAverage = 5.3, + ), + ) + + val dayAverage = testee.getMetricParameters()["dayAverage"] + + assertEquals("5", dayAverage) + } + + @Test + fun whenDaysSinceInstalledIs7ThenDoNotIncludeDayAverage() = runTest { + givenDaysSinceInstalled(7) + whenever(attributedMetricClient.getEventStats(any(), any())).thenReturn( + EventStats( + totalEvents = 16, + daysWithEvents = 3, + rollingAverage = 5.3, + ), + ) + + val dayAverage = testee.getMetricParameters()["dayAverage"] + + assertNull(dayAverage) + } + + @Test + fun whenGetTagThenReturnDaysSinceInstalled() = runTest { + givenDaysSinceInstalled(7) + + val tag = testee.getTag() + + assertEquals("7", tag) + } + + @Test + fun whenGetMetricParametersThenReturnVersion() = runTest { + givenDaysSinceInstalled(7) + whenever(attributedMetricClient.getEventStats(any(), any())).thenReturn( + EventStats( + totalEvents = 16, + daysWithEvents = 3, + rollingAverage = 5.3, + ), + ) + + val version = testee.getMetricParameters()["version"] + + assertEquals("0", version) + } + + private fun givenDaysSinceInstalled(days: Int) { + val etZone = ZoneId.of("America/New_York") + val now = Instant.now() + val nowInEt = now.atZone(etZone) + val installInEt = nowInEt.minusDays(days.toLong()) + whenever(appInstall.getInstallationTimestamp()).thenReturn(installInEt.toInstant().toEpochMilli()) + } +} + +interface FakeDuckAiMetricsConfigFeature { + @Toggle.DefaultValue(DefaultFeatureValue.INTERNAL) + fun self(): Toggle + + @Toggle.DefaultValue(DefaultFeatureValue.INTERNAL) + fun aiUsageAvg(): Toggle + + @Toggle.DefaultValue(DefaultFeatureValue.INTERNAL) + fun canEmitAIUsageAvg(): Toggle +} diff --git a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/helper/RealDuckChatJSHelperTest.kt b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/helper/RealDuckChatJSHelperTest.kt index 997ee636b674..a743b227e226 100644 --- a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/helper/RealDuckChatJSHelperTest.kt +++ b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/helper/RealDuckChatJSHelperTest.kt @@ -26,6 +26,7 @@ import com.duckduckgo.duckchat.impl.ReportMetric.USER_DID_SELECT_FIRST_HISTORY_I import com.duckduckgo.duckchat.impl.ReportMetric.USER_DID_SUBMIT_FIRST_PROMPT import com.duckduckgo.duckchat.impl.ReportMetric.USER_DID_SUBMIT_PROMPT import com.duckduckgo.duckchat.impl.ReportMetric.USER_DID_TAP_KEYBOARD_RETURN_KEY +import com.duckduckgo.duckchat.impl.metric.DuckAiMetricCollector import com.duckduckgo.duckchat.impl.pixel.DuckChatPixels import com.duckduckgo.duckchat.impl.store.DuckChatDataStore import com.duckduckgo.js.messaging.api.JsCallbackData @@ -50,11 +51,13 @@ class RealDuckChatJSHelperTest { private val mockDuckChat: DuckChatInternal = mock() private val mockDataStore: DuckChatDataStore = mock() private val mockDuckChatPixels: DuckChatPixels = mock() + private val mockDuckAiMetricCollector: DuckAiMetricCollector = mock() private val testee = RealDuckChatJSHelper( duckChat = mockDuckChat, dataStore = mockDataStore, duckChatPixels = mockDuckChatPixels, + duckAiMetricCollector = mockDuckAiMetricCollector, ) @Test @@ -384,7 +387,7 @@ class RealDuckChatJSHelperTest { } @Test - fun whenReportMetricWithDataThenPixelSent() = runTest { + fun whenReportMetricWithDataThenPixelSentAndCollectMetric() = runTest { val featureName = "aiChat" val method = "reportMetric" val id = "123" @@ -393,10 +396,11 @@ class RealDuckChatJSHelperTest { assertNull(testee.processJsCallbackMessage(featureName, method, id, data)) verify(mockDuckChatPixels).sendReportMetricPixel(USER_DID_SUBMIT_PROMPT) + verify(mockDuckAiMetricCollector).onMessageSent() } @Test - fun whenReportMetricWithFirstPromptThenPixelSent() = runTest { + fun whenReportMetricWithFirstPromptThenPixelSentAndCollectMetric() = runTest { val featureName = "aiChat" val method = "reportMetric" val id = "123" @@ -405,6 +409,7 @@ class RealDuckChatJSHelperTest { assertNull(testee.processJsCallbackMessage(featureName, method, id, data)) verify(mockDuckChatPixels).sendReportMetricPixel(USER_DID_SUBMIT_FIRST_PROMPT) + verify(mockDuckAiMetricCollector).onMessageSent() } @Test diff --git a/subscriptions/subscriptions-impl/build.gradle b/subscriptions/subscriptions-impl/build.gradle index a0097a84a628..588c4e2e183a 100644 --- a/subscriptions/subscriptions-impl/build.gradle +++ b/subscriptions/subscriptions-impl/build.gradle @@ -62,6 +62,7 @@ dependencies { implementation project(':content-scope-scripts-api') implementation project(':duckchat-api') implementation project(':pir-api') + implementation project(':attributed-metrics-api') implementation AndroidX.appCompat implementation KotlinX.coroutines.core diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsManager.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsManager.kt index 88b170cf7443..ed2a52f628a8 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsManager.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsManager.kt @@ -577,6 +577,7 @@ class RealSubscriptionsManager @Inject constructor( authRepository.setAccount(null) authRepository.setSubscription(null) authRepository.setEntitlements(emptyList()) + authRepository.removeLocalPurchasedAt() _isSignedIn.emit(false) _subscriptionStatus.emit(UNKNOWN) _entitlements.emit(emptyList()) @@ -659,6 +660,7 @@ class RealSubscriptionsManager @Inject constructor( pixelSender.reportSubscriptionActivated() emitEntitlementsValues() _currentPurchaseState.emit(CurrentPurchase.Success) + authRepository.registerLocalPurchasedAt() subscriptionPurchaseWideEvent.onPurchaseConfirmationSuccess() } else { handlePurchaseFailed() diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/metrics/SubscriptionStatusAttributedMetric.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/metrics/SubscriptionStatusAttributedMetric.kt new file mode 100644 index 000000000000..e291c53e5103 --- /dev/null +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/metrics/SubscriptionStatusAttributedMetric.kt @@ -0,0 +1,176 @@ +/* + * 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.subscriptions.impl.metrics + +import androidx.lifecycle.LifecycleOwner +import com.duckduckgo.app.attributed.metrics.api.AttributedMetric +import com.duckduckgo.app.attributed.metrics.api.AttributedMetricClient +import com.duckduckgo.app.attributed.metrics.api.AttributedMetricConfig +import com.duckduckgo.app.attributed.metrics.api.MetricBucket +import com.duckduckgo.app.di.AppCoroutineScope +import com.duckduckgo.app.lifecycle.MainProcessLifecycleObserver +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.subscriptions.api.SubscriptionStatus +import com.duckduckgo.subscriptions.impl.SubscriptionsManager +import com.duckduckgo.subscriptions.impl.repository.AuthRepository +import com.squareup.anvil.annotations.ContributesMultibinding +import dagger.SingleInstanceIn +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart.LAZY +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch +import logcat.logcat +import java.time.Instant +import java.time.ZoneId +import java.time.temporal.ChronoUnit +import javax.inject.Inject + +@ContributesMultibinding(AppScope::class, AttributedMetric::class) +@ContributesMultibinding(AppScope::class, MainProcessLifecycleObserver::class) +@SingleInstanceIn(AppScope::class) +class SubscriptionStatusAttributedMetric @Inject constructor( + @AppCoroutineScope private val appCoroutineScope: CoroutineScope, + private val dispatcherProvider: DispatcherProvider, + private val attributedMetricClient: AttributedMetricClient, + private val authRepository: AuthRepository, + private val attributedMetricConfig: AttributedMetricConfig, + private val subscriptionsManager: SubscriptionsManager, +) : AttributedMetric, MainProcessLifecycleObserver { + + companion object { + private const val PIXEL_NAME = "attributed_metric_subscribed" + private const val FEATURE_TOGGLE_NAME = "subscriptionRetention" + private const val FEATURE_EMIT_TOGGLE_NAME = "canEmitSubscriptionRetention" + } + + private val isEnabled: Deferred = appCoroutineScope.async(start = LAZY) { + getToggle(FEATURE_TOGGLE_NAME)?.isEnabled() ?: false + } + + private val canEmit: Deferred = appCoroutineScope.async(start = LAZY) { + getToggle(FEATURE_EMIT_TOGGLE_NAME)?.isEnabled() ?: false + } + + private val bucketConfig: Deferred = appCoroutineScope.async(start = LAZY) { + attributedMetricConfig.getBucketConfiguration()[PIXEL_NAME] ?: MetricBucket( + buckets = listOf(0, 1), + version = 0, + ) + } + + override fun onCreate(owner: LifecycleOwner) { + appCoroutineScope.launch(dispatcherProvider.io()) { + if (!isEnabled.await() || !canEmit.await()) { + logcat(tag = "AttributedMetrics") { + "SubscriptionStatusAttributedMetric disabled" + } + return@launch + } + subscriptionsManager.subscriptionStatus.distinctUntilChanged().collect { status -> + logcat(tag = "AttributedMetrics") { + "SubscriptionStatusAttributedMetric subscription status changed: $status" + } + if (shouldSendPixel()) { + logcat(tag = "AttributedMetrics") { + "SubscriptionStatusAttributedMetric emitting metric on status change" + } + attributedMetricClient.emitMetric( + this@SubscriptionStatusAttributedMetric, + ) + } + } + } + } + + override fun getPixelName(): String = PIXEL_NAME + + override suspend fun getMetricParameters(): Map { + val daysSinceSubscribed = daysSinceSubscribed() + if (daysSinceSubscribed == -1) { + return emptyMap() // Should not happen as we check enrollment before sending the pixel + } + val isOnTrial = authRepository.isFreeTrialActive() + val params = mutableMapOf( + "month" to getBucketValue(daysSinceSubscribed, isOnTrial).toString(), + "version" to bucketConfig.await().version.toString(), + ) + return params + } + + override suspend fun getTag(): String { + val daysSinceSubscribed = daysSinceSubscribed() + val isOnTrial = authRepository.isFreeTrialActive() + return getBucketValue(daysSinceSubscribed, isOnTrial).toString() + } + + private suspend fun shouldSendPixel(): Boolean { + val isActive = isSubscriptionActive() + logcat(tag = "AttributedMetrics") { + "SubscriptionStatusAttributedMetric shouldSendPixel isActive: $isActive" + } + val enrolled = daysSinceSubscribed() != -1 + logcat(tag = "AttributedMetrics") { + "SubscriptionStatusAttributedMetric shouldSendPixel enrolled: $enrolled daysSinceSubscribed() = ${daysSinceSubscribed()}" + } + return isActive && enrolled + } + + private suspend fun isSubscriptionActive(): Boolean { + return authRepository.getStatus() == SubscriptionStatus.AUTO_RENEWABLE || + authRepository.getStatus() == SubscriptionStatus.NOT_AUTO_RENEWABLE + } + + private suspend fun daysSinceSubscribed(): Int { + return authRepository.getLocalPurchasedAt()?.let { nonNullStartedAt -> + val etZone = ZoneId.of("America/New_York") + val installInstant = Instant.ofEpochMilli(nonNullStartedAt) + val nowInstant = Instant.now() + + val installInEt = installInstant.atZone(etZone) + val nowInEt = nowInstant.atZone(etZone) + + return ChronoUnit.DAYS.between(installInEt.toLocalDate(), nowInEt.toLocalDate()).toInt() + } ?: -1 + } + + private suspend fun getBucketValue( + days: Int, + isOnTrial: Boolean, + ): Int { + if (isOnTrial) { + return 0 + } + + // Calculate which month the user is in (1-based) + // Each 28 days is a new month + val monthNumber = days / 28 + 1 + + // Get the bucket configuration + val buckets = bucketConfig.await().buckets + return buckets.indexOfFirst { bucket -> monthNumber <= bucket }.let { index -> + if (index == -1) buckets.size else index + } + } + + private suspend fun getToggle(toggleName: String) = + attributedMetricConfig.metricsToggles().firstOrNull { toggle -> + toggle.featureName().name == toggleName + } +} diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/repository/AuthRepository.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/repository/AuthRepository.kt index 95859425b98f..2dc22db7424c 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/repository/AuthRepository.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/repository/AuthRepository.kt @@ -64,6 +64,10 @@ interface AuthRepository { suspend fun canSupportEncryption(): Boolean suspend fun setFeatures(basePlanId: String, features: Set) suspend fun getFeatures(basePlanId: String): Set + suspend fun isFreeTrialActive(): Boolean + suspend fun registerLocalPurchasedAt() + suspend fun getLocalPurchasedAt(): Long? + suspend fun removeLocalPurchasedAt() } @Module @@ -233,6 +237,22 @@ internal class RealAuthRepository constructor( val accessToken = subscriptionsDataStore.run { accessTokenV2 ?: accessToken } serpPromo.injectCookie(accessToken) } + + override suspend fun isFreeTrialActive(): Boolean { + return subscriptionsDataStore.freeTrialActive + } + + override suspend fun registerLocalPurchasedAt() { + subscriptionsDataStore.localPurchasedAt = System.currentTimeMillis() + } + + override suspend fun getLocalPurchasedAt(): Long? { + return subscriptionsDataStore.localPurchasedAt + } + + override suspend fun removeLocalPurchasedAt() { + subscriptionsDataStore.localPurchasedAt = null + } } data class AccessToken( diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/store/SubscriptionsDataStore.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/store/SubscriptionsDataStore.kt index 02e1c5dd8d46..2cd9a370e3af 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/store/SubscriptionsDataStore.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/store/SubscriptionsDataStore.kt @@ -38,6 +38,9 @@ interface SubscriptionsDataStore { var expiresOrRenewsAt: Long? var billingPeriod: String? var startedAt: Long? + + // Local purchased at time, not from server or other devices + var localPurchasedAt: Long? var platform: String? var status: String? var entitlements: String? @@ -197,6 +200,18 @@ internal class SubscriptionsEncryptedDataStore( } } + override var localPurchasedAt: Long? + get() = encryptedPreferences?.getLong(KEY_LOCAL_PURCHASED_AT, 0L).takeIf { it != 0L } + set(value) { + encryptedPreferences?.edit(commit = true) { + if (value == null) { + remove(KEY_LOCAL_PURCHASED_AT) + } else { + putLong(KEY_LOCAL_PURCHASED_AT, value) + } + } + } + override var billingPeriod: String? get() = encryptedPreferences?.getString(KEY_BILLING_PERIOD, null) set(value) { @@ -231,6 +246,7 @@ internal class SubscriptionsEncryptedDataStore( const val KEY_EXTERNAL_ID = "KEY_EXTERNAL_ID" const val KEY_EXPIRES_OR_RENEWS_AT = "KEY_EXPIRES_OR_RENEWS_AT" const val KEY_STARTED_AT = "KEY_STARTED_AT" + const val KEY_LOCAL_PURCHASED_AT = "KEY_LOCAL_PURCHASED_AT" const val KEY_BILLING_PERIOD = "KEY_BILLING_PERIOD" const val KEY_ENTITLEMENTS = "KEY_ENTITLEMENTS" const val KEY_STATUS = "KEY_STATUS" diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/metrics/SubscriptionStatusAttributedMetricTest.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/metrics/SubscriptionStatusAttributedMetricTest.kt new file mode 100644 index 000000000000..b506844048d0 --- /dev/null +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/metrics/SubscriptionStatusAttributedMetricTest.kt @@ -0,0 +1,287 @@ +/* + * 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.subscriptions.impl.metrics + +import android.annotation.SuppressLint +import androidx.lifecycle.LifecycleOwner +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.duckduckgo.app.attributed.metrics.api.AttributedMetricClient +import com.duckduckgo.app.attributed.metrics.api.AttributedMetricConfig +import com.duckduckgo.app.attributed.metrics.api.MetricBucket +import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory +import com.duckduckgo.feature.toggles.api.Toggle +import com.duckduckgo.feature.toggles.api.Toggle.DefaultFeatureValue +import com.duckduckgo.feature.toggles.api.Toggle.State +import com.duckduckgo.subscriptions.api.SubscriptionStatus +import com.duckduckgo.subscriptions.impl.SubscriptionsManager +import com.duckduckgo.subscriptions.impl.repository.AuthRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import java.time.Instant +import java.time.ZoneId + +@SuppressLint("DenyListedApi") +@RunWith(AndroidJUnit4::class) +class SubscriptionStatusAttributedMetricTest { + + @get:Rule + val coroutineRule = CoroutineTestRule() + + private val attributedMetricClient: AttributedMetricClient = mock() + private val authRepository: AuthRepository = mock() + private val attributedMetricConfig: AttributedMetricConfig = mock() + private val subscriptionsManager: SubscriptionsManager = mock() + private val subscriptionToggle = FakeFeatureToggleFactory.create( + FakeSubscriptionMetricsConfigFeature::class.java, + ) + private val lifecycleOwner: LifecycleOwner = mock() + private val subscriptionStatusFlow = MutableStateFlow(SubscriptionStatus.UNKNOWN) + + private lateinit var testee: SubscriptionStatusAttributedMetric + + @Before + fun setup() = runTest { + givenFFStatus(metricEnabled = true, canEmitMetric = true) + whenever(attributedMetricConfig.getBucketConfiguration()).thenReturn( + mapOf( + "attributed_metric_subscribed" to MetricBucket( + buckets = listOf(0, 1), + version = 0, + ), + ), + ) + whenever(subscriptionsManager.subscriptionStatus).thenReturn(subscriptionStatusFlow) + + testee = SubscriptionStatusAttributedMetric( + appCoroutineScope = coroutineRule.testScope, + dispatcherProvider = coroutineRule.testDispatcherProvider, + attributedMetricClient = attributedMetricClient, + authRepository = authRepository, + attributedMetricConfig = attributedMetricConfig, + subscriptionsManager = subscriptionsManager, + ) + } + + @Test + fun whenPixelNameRequestedThenReturnCorrectName() { + assertEquals("attributed_metric_subscribed", testee.getPixelName()) + } + + @Test + fun whenOnCreateAndFFDisabledThenDoNotEmitMetric() = runTest { + givenFFStatus(metricEnabled = false) + givenDaysSinceSubscribed(0) + + testee.onCreate(lifecycleOwner) + subscriptionStatusFlow.emit(SubscriptionStatus.AUTO_RENEWABLE) + + verify(attributedMetricClient, never()).emitMetric(testee) + } + + @Test + fun whenOnCreateAndEmitDisabledThenDoNotEmitMetric() = runTest { + givenFFStatus(metricEnabled = true, canEmitMetric = false) + givenDaysSinceSubscribed(0) + + testee.onCreate(lifecycleOwner) + subscriptionStatusFlow.emit(SubscriptionStatus.AUTO_RENEWABLE) + + verify(attributedMetricClient, never()).emitMetric(testee) + } + + @Test + fun whenOnCreateAndSubscriptionNotActiveThenDoNotEmitMetric() = runTest { + whenever(authRepository.getStatus()).thenReturn(SubscriptionStatus.INACTIVE) + + testee.onCreate(lifecycleOwner) + subscriptionStatusFlow.emit(SubscriptionStatus.INACTIVE) + + verify(attributedMetricClient, never()).emitMetric(testee) + } + + @Test + fun whenOnCreateAndSubscriptionAutoRenewableThenEmitMetric() = runTest { + testee.onCreate(lifecycleOwner) + + givenDaysSinceSubscribed(0) + whenever(authRepository.getStatus()).thenReturn(SubscriptionStatus.AUTO_RENEWABLE) + subscriptionStatusFlow.emit(SubscriptionStatus.AUTO_RENEWABLE) + + verify(attributedMetricClient).emitMetric(testee) + } + + @Test + fun whenOnCreateAndNonAutoRenewableSubscriptionThenEmitMetric() = runTest { + testee.onCreate(lifecycleOwner) + + givenDaysSinceSubscribed(0) + whenever(authRepository.getStatus()).thenReturn(SubscriptionStatus.AUTO_RENEWABLE) + subscriptionStatusFlow.emit(SubscriptionStatus.NOT_AUTO_RENEWABLE) + + verify(attributedMetricClient).emitMetric(testee) + } + + @Test + fun whenOnCreateAndNotEnrolledThenDoNotEmitMetric() = runTest { + whenever(authRepository.getStatus()).thenReturn(SubscriptionStatus.AUTO_RENEWABLE) + whenever(authRepository.getLocalPurchasedAt()).thenReturn(null) + + testee.onCreate(lifecycleOwner) + subscriptionStatusFlow.emit(SubscriptionStatus.AUTO_RENEWABLE) + + verify(attributedMetricClient, never()).emitMetric(testee) + } + + @Test + fun whenGetMetricParametersAndOnTrialThenReturnBucketZero() = runTest { + givenDaysSinceSubscribed(7) + whenever(authRepository.isFreeTrialActive()).thenReturn(true) + + val monthBucket = testee.getMetricParameters()["month"] + + assertEquals("0", monthBucket) + } + + @Test + fun whenGetMetricParametersThenReturnVersion() = runTest { + givenDaysSinceSubscribed(7) + whenever(authRepository.isFreeTrialActive()).thenReturn(false) + + val version = testee.getMetricParameters()["version"] + + assertEquals("0", version) + } + + @Test + fun whenGetMetricParametersThenReturnCorrectBucketValue() = runTest { + whenever(authRepository.isFreeTrialActive()).thenReturn(false) + + // Map of days subscribed to expected bucket + val daysSubscribedExpectedBuckets = mapOf( + 0 to "1", // 0-27 days -> month 1 -> bucket 1 + 13 to "1", // middle of month 1 -> bucket 1 + 27 to "1", // end of month 1 -> bucket 1 + 28 to "2", // 28-55 days -> month 2 -> bucket 2 + 41 to "2", // middle of month 2 -> bucket 2 + 55 to "2", // end of month 2 -> bucket 2 + 56 to "2", // 56-83 days -> month 3 -> bucket 2 + 69 to "2", // middle of month 3 -> bucket 2 + 83 to "2", // end of month 3 -> bucket 2 + 84 to "2", // 84-111 days -> month 4 -> bucket 2 + 97 to "2", // middle of month 4 -> bucket 2 + 111 to "2", // end of month 4 -> bucket 2 + ) + + daysSubscribedExpectedBuckets.forEach { (days, expectedBucket) -> + givenDaysSinceSubscribed(days) + + val realMonthBucket = testee.getMetricParameters()["month"] + + assertEquals( + "For $days days subscribed, should return bucket $expectedBucket", + expectedBucket, + realMonthBucket, + ) + } + } + + @Test + fun whenGetTagAndOnTrialThenReturnBucketZero() = runTest { + givenDaysSinceSubscribed(7) + whenever(authRepository.isFreeTrialActive()).thenReturn(true) + + val tag = testee.getTag() + + assertEquals("0", tag) + } + + @Test + fun whenGetTagThenReturnCorrectBucketValue() = runTest { + whenever(authRepository.isFreeTrialActive()).thenReturn(false) + + // Map of days subscribed to expected bucket + val daysSubscribedExpectedBuckets = mapOf( + 0 to "1", // 0-27 days -> month 1 -> bucket 1 + 13 to "1", // middle of month 1 -> bucket 1 + 27 to "1", // end of month 1 -> bucket 1 + 28 to "2", // 28-55 days -> month 2 -> bucket 2 + 41 to "2", // middle of month 2 -> bucket 2 + 55 to "2", // end of month 2 -> bucket 2 + 56 to "2", // 56-83 days -> month 3 -> bucket 2 + 69 to "2", // middle of month 3 -> bucket 2 + 83 to "2", // end of month 3 -> bucket 2 + 84 to "2", // 84-111 days -> month 4 -> bucket 2 + 97 to "2", // middle of month 4 -> bucket 2 + 111 to "2", // end of month 4 -> bucket 2 + ) + + daysSubscribedExpectedBuckets.forEach { (days, bucket) -> + givenDaysSinceSubscribed(days) + + val tag = testee.getTag() + + assertEquals( + "For $days days subscribed, should return bucket $bucket", + bucket, + tag, + ) + } + } + + private suspend fun givenDaysSinceSubscribed(days: Int) { + val etZone = ZoneId.of("America/New_York") + val now = Instant.now() + val nowInEt = now.atZone(etZone) + val purchasedAt = nowInEt.minusDays(days.toLong()) + whenever(authRepository.getLocalPurchasedAt()).thenReturn( + purchasedAt.toInstant().toEpochMilli(), + ) + whenever(authRepository.getStatus()).thenReturn(SubscriptionStatus.AUTO_RENEWABLE) + } + + private suspend fun givenFFStatus(metricEnabled: Boolean = true, canEmitMetric: Boolean = true) { + subscriptionToggle.subscriptionRetention().setRawStoredState(State(metricEnabled)) + subscriptionToggle.canEmitSubscriptionRetention().setRawStoredState(State(canEmitMetric)) + whenever(attributedMetricConfig.metricsToggles()).thenReturn( + listOf( + subscriptionToggle.subscriptionRetention(), + subscriptionToggle.canEmitSubscriptionRetention(), + ), + ) + } +} + +interface FakeSubscriptionMetricsConfigFeature { + @Toggle.DefaultValue(DefaultFeatureValue.INTERNAL) + fun self(): Toggle + + @Toggle.DefaultValue(DefaultFeatureValue.INTERNAL) + fun subscriptionRetention(): Toggle + + @Toggle.DefaultValue(DefaultFeatureValue.INTERNAL) + fun canEmitSubscriptionRetention(): Toggle +} diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/repository/FakeSubscriptionsDataStore.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/repository/FakeSubscriptionsDataStore.kt index ee811adcca6a..cdb42f147cd3 100644 --- a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/repository/FakeSubscriptionsDataStore.kt +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/repository/FakeSubscriptionsDataStore.kt @@ -45,6 +45,7 @@ class FakeSubscriptionsDataStore( override var platform: String? = null override var billingPeriod: String? = null override var startedAt: Long? = 0L + override var localPurchasedAt: Long? = 0L override var status: String? = null override var entitlements: String? = null override var productId: String? = null diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/repository/RealAuthRepositoryTest.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/repository/RealAuthRepositoryTest.kt index a6e12a711f50..75dca8b45c06 100644 --- a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/repository/RealAuthRepositoryTest.kt +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/repository/RealAuthRepositoryTest.kt @@ -179,4 +179,40 @@ class RealAuthRepositoryTest { assertEquals(emptySet(), result) } + + @Test + fun whenRegisterLocalPurchasedAtThenStoreTimestamp() = runTest { + authRepository.registerLocalPurchasedAt() + + assertNotNull(authStore.localPurchasedAt) + assertTrue(authStore.localPurchasedAt!! > 0) + } + + @Test + fun whenGetLocalPurchasedAtThenReturnStoredValue() = runTest { + val expectedTimestamp = 1699000000000L + authStore.localPurchasedAt = expectedTimestamp + + val result = authRepository.getLocalPurchasedAt() + + assertEquals(expectedTimestamp, result) + } + + @Test + fun whenGetLocalPurchasedAtAndNotSetThenReturnNull() = runTest { + authStore.localPurchasedAt = null + + val result = authRepository.getLocalPurchasedAt() + + assertNull(result) + } + + @Test + fun whenRemoveLocalPurchasedAtThenClearValue() = runTest { + authStore.localPurchasedAt = 1699000000000L + + authRepository.removeLocalPurchasedAt() + + assertNull(authStore.localPurchasedAt) + } } diff --git a/subscriptions/subscriptions-internal/src/main/java/com/duckduckgo/subscriptions/internal/settings/OverrideSubscriptionLocalPurchasedAtView.kt b/subscriptions/subscriptions-internal/src/main/java/com/duckduckgo/subscriptions/internal/settings/OverrideSubscriptionLocalPurchasedAtView.kt new file mode 100644 index 000000000000..def280fb4e53 --- /dev/null +++ b/subscriptions/subscriptions-internal/src/main/java/com/duckduckgo/subscriptions/internal/settings/OverrideSubscriptionLocalPurchasedAtView.kt @@ -0,0 +1,136 @@ +/* + * 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.subscriptions.internal.settings + +import android.content.Context +import android.content.SharedPreferences +import android.util.AttributeSet +import android.view.View +import android.widget.FrameLayout +import android.widget.Toast +import androidx.core.content.edit +import androidx.lifecycle.LifecycleCoroutineScope +import androidx.lifecycle.findViewTreeLifecycleOwner +import androidx.lifecycle.lifecycleScope +import com.duckduckgo.anvil.annotations.InjectWith +import com.duckduckgo.app.di.AppCoroutineScope +import com.duckduckgo.common.ui.viewbinding.viewBinding +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.data.store.api.SharedPreferencesProvider +import com.duckduckgo.di.scopes.ActivityScope +import com.duckduckgo.di.scopes.ViewScope +import com.duckduckgo.subscriptions.internal.SubsSettingPlugin +import com.duckduckgo.subscriptions.internal.databinding.SubsOverrideLocalPurchasedAtViewBinding +import com.squareup.anvil.annotations.ContributesMultibinding +import dagger.android.support.AndroidSupportInjection +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone +import javax.inject.Inject + +@InjectWith(ViewScope::class) +class OverrideSubscriptionLocalPurchasedAtView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0, +) : FrameLayout(context, attrs, defStyle) { + + @Inject + lateinit var sharedPreferencesProvider: SharedPreferencesProvider + + @Inject + @AppCoroutineScope + lateinit var appCoroutineScope: CoroutineScope + + @Inject + lateinit var dispatcherProvider: DispatcherProvider + + private val binding: SubsOverrideLocalPurchasedAtViewBinding by viewBinding() + + private val localPurchaseAtStore by lazy { + LocalPurchaseAtStore(sharedPreferencesProvider) + } + + private val dateETFormat = SimpleDateFormat("yyyy-MM-dd", Locale.US).apply { + timeZone = TimeZone.getTimeZone("US/Eastern") + } + + private val viewCoroutineScope: LifecycleCoroutineScope? + get() = findViewTreeLifecycleOwner()?.lifecycleScope + + override fun onAttachedToWindow() { + AndroidSupportInjection.inject(this) + super.onAttachedToWindow() + + binding.also { base -> + viewCoroutineScope?.launch(dispatcherProvider.main()) { + val timestamp = localPurchaseAtStore.localPurchasedAt ?: return@launch + base.subscriptionEnroll.text = dateETFormat.format(Date(timestamp)) + } + + base.subscriptionEnrollSave.setOnClickListener { + val date = dateETFormat.parse(binding.subscriptionEnroll.text) + if (date != null) { + localPurchaseAtStore.localPurchasedAt = date.time + Toast.makeText(this.context, "Subscription date updated", Toast.LENGTH_SHORT).show() + } else { + Toast.makeText(this.context, "Invalid date format. Use yyyy-MM-dd", Toast.LENGTH_SHORT).show() + } + } + } + } +} + +@ContributesMultibinding(ActivityScope::class) +class OverrideSubscriptionLocalPurchasedAtViewPlugin @Inject constructor() : SubsSettingPlugin { + override fun getView(context: Context): View { + return OverrideSubscriptionLocalPurchasedAtView(context) + } +} + +/** + * Real SubscriptionsDataStore cannot be used directly without going through AuthRepository. + * This class is intended only for manual testing purposes. It allows overriding the local "purchased at" time. + */ +private class LocalPurchaseAtStore( + private val sharedPreferencesProvider: SharedPreferencesProvider, +) { + private val encryptedPreferences: SharedPreferences? by lazy { encryptedPreferences() } + private fun encryptedPreferences(): SharedPreferences? { + return sharedPreferencesProvider.getEncryptedSharedPreferences(FILENAME, multiprocess = true) + } + + var localPurchasedAt: Long? + get() = encryptedPreferences?.getLong(KEY_LOCAL_PURCHASED_AT, 0L).takeIf { it != 0L } + set(value) { + encryptedPreferences?.edit(commit = true) { + if (value == null) { + remove(KEY_LOCAL_PURCHASED_AT) + } else { + putLong(KEY_LOCAL_PURCHASED_AT, value) + } + } + } + + companion object { + const val FILENAME = "com.duckduckgo.subscriptions.store" + const val KEY_LOCAL_PURCHASED_AT = "KEY_LOCAL_PURCHASED_AT" + } +} diff --git a/subscriptions/subscriptions-internal/src/main/res/layout/subs_override_local_purchased_at_view.xml b/subscriptions/subscriptions-internal/src/main/res/layout/subs_override_local_purchased_at_view.xml new file mode 100644 index 000000000000..7bb48a90e774 --- /dev/null +++ b/subscriptions/subscriptions-internal/src/main/res/layout/subs_override_local_purchased_at_view.xml @@ -0,0 +1,41 @@ + + + + + + + + + + \ No newline at end of file diff --git a/sync/sync-impl/build.gradle b/sync/sync-impl/build.gradle index cc1d0d55fa2c..48475310ca4f 100644 --- a/sync/sync-impl/build.gradle +++ b/sync/sync-impl/build.gradle @@ -45,6 +45,7 @@ dependencies { implementation project(':remote-messaging-api') implementation project(path: ':autofill-api') implementation project(path: ':settings-api') // temporary until we release new settings + implementation project(path: ':attributed-metrics-api') implementation project(path: ':app-build-config-api') implementation project(path: ':privacy-config-api') diff --git a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/SyncAccountRepository.kt b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/SyncAccountRepository.kt index aca82a96d691..b5ad3dedf5d3 100644 --- a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/SyncAccountRepository.kt +++ b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/SyncAccountRepository.kt @@ -41,6 +41,7 @@ import com.duckduckgo.sync.impl.SyncAuthCode.Connect import com.duckduckgo.sync.impl.SyncAuthCode.Exchange import com.duckduckgo.sync.impl.SyncAuthCode.Recovery import com.duckduckgo.sync.impl.SyncAuthCode.Unknown +import com.duckduckgo.sync.impl.metrics.ConnectedDevicesObserver import com.duckduckgo.sync.impl.pixels.* import com.duckduckgo.sync.impl.ui.qrcode.SyncBarcodeUrl import com.duckduckgo.sync.impl.ui.qrcode.SyncBarcodeUrlWrapper @@ -98,6 +99,7 @@ interface SyncAccountRepository { @SingleInstanceIn(AppScope::class) @WorkerThread class AppSyncAccountRepository @Inject constructor( + private val connectedDevicesObserver: ConnectedDevicesObserver, private val syncDeviceIds: SyncDeviceIds, private val nativeLib: SyncLib, private val syncApi: SyncApi, @@ -632,11 +634,12 @@ class AppSyncAccountRepository @Inject constructor( } }.sortedWith { a, b -> if (a.thisDevice) -1 else 1 - }.also { + }.also { devices -> connectedDevicesCached.apply { clear() - addAll(it) + addAll(devices) } + connectedDevicesObserver.onDevicesUpdated(devices) }, ) } diff --git a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/metrics/SyncConnectedDevicesObserver.kt b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/metrics/SyncConnectedDevicesObserver.kt new file mode 100644 index 000000000000..cd1cd1d30c53 --- /dev/null +++ b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/metrics/SyncConnectedDevicesObserver.kt @@ -0,0 +1,50 @@ +/* + * 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.sync.impl.metrics + +import com.duckduckgo.app.di.AppCoroutineScope +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.sync.impl.ConnectedDevice +import com.squareup.anvil.annotations.ContributesBinding +import dagger.SingleInstanceIn +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +interface ConnectedDevicesObserver { + fun onDevicesUpdated(devices: List) + fun observeConnectedDevicesCount(): StateFlow +} + +@ContributesBinding(AppScope::class) +@SingleInstanceIn(AppScope::class) +class SyncConnectedDevicesObserver @Inject constructor( + @AppCoroutineScope private val appCoroutineScope: CoroutineScope, +) : ConnectedDevicesObserver { + + private val _connectedDevicesCount = MutableStateFlow(0) + override fun observeConnectedDevicesCount(): StateFlow = _connectedDevicesCount.asStateFlow() + + override fun onDevicesUpdated(devices: List) { + appCoroutineScope.launch { + _connectedDevicesCount.emit(devices.size) + } + } +} diff --git a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/metrics/SyncDevicesAttributeMetric.kt b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/metrics/SyncDevicesAttributeMetric.kt new file mode 100644 index 000000000000..1a00a4cc2580 --- /dev/null +++ b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/metrics/SyncDevicesAttributeMetric.kt @@ -0,0 +1,103 @@ +/* + * 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.sync.impl.metrics + +import androidx.lifecycle.LifecycleOwner +import com.duckduckgo.app.attributed.metrics.api.AttributedMetric +import com.duckduckgo.app.attributed.metrics.api.AttributedMetricClient +import com.duckduckgo.app.attributed.metrics.api.AttributedMetricConfig +import com.duckduckgo.app.attributed.metrics.api.MetricBucket +import com.duckduckgo.app.di.AppCoroutineScope +import com.duckduckgo.app.lifecycle.MainProcessLifecycleObserver +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesMultibinding +import dagger.SingleInstanceIn +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart.LAZY +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import javax.inject.Inject + +@ContributesMultibinding(AppScope::class, AttributedMetric::class) +@ContributesMultibinding(AppScope::class, MainProcessLifecycleObserver::class) +@SingleInstanceIn(AppScope::class) +class SyncDevicesAttributeMetric @Inject constructor( + @AppCoroutineScope private val appCoroutineScope: CoroutineScope, + private val dispatcherProvider: DispatcherProvider, + private val attributedMetricClient: AttributedMetricClient, + private val attributedMetricConfig: AttributedMetricConfig, + private val connectedDevicesObserver: ConnectedDevicesObserver, +) : AttributedMetric, MainProcessLifecycleObserver { + + companion object { + private const val PIXEL_NAME = "attributed_metric_synced_device" + private const val FEATURE_TOGGLE_NAME = "syncDevices" + } + + private val isEnabled: Deferred = appCoroutineScope.async(start = LAZY) { + getToggle(FEATURE_TOGGLE_NAME)?.isEnabled() ?: false + } + + private val bucketConfig: Deferred = appCoroutineScope.async(start = LAZY) { + attributedMetricConfig.getBucketConfiguration()[PIXEL_NAME] ?: MetricBucket( + buckets = listOf(1), + version = 0, + ) + } + + override fun onCreate(owner: LifecycleOwner) { + appCoroutineScope.launch(dispatcherProvider.io()) { + if (isEnabled.await()) { + connectedDevicesObserver.observeConnectedDevicesCount().collect { deviceCount -> + if (deviceCount > 0) { + attributedMetricClient.emitMetric(this@SyncDevicesAttributeMetric) + } + } + } + } + } + + override fun getPixelName(): String = PIXEL_NAME + + override suspend fun getMetricParameters(): Map { + val connectedDevices = connectedDevicesObserver.observeConnectedDevicesCount().value + val params = mutableMapOf( + "device_count" to getBucketValue(connectedDevices).toString(), + "version" to bucketConfig.await().version.toString(), + ) + return params + } + + override suspend fun getTag(): String { + val connectedDevices = connectedDevicesObserver.observeConnectedDevicesCount().value + return getBucketValue(connectedDevices).toString() + } + + private suspend fun getBucketValue(number: Int): Int { + val buckets = bucketConfig.await().buckets + return buckets.indexOfFirst { bucket -> number <= bucket }.let { index -> + if (index == -1) buckets.size else index + } + } + + private suspend fun getToggle(toggleName: String) = + attributedMetricConfig.metricsToggles().firstOrNull { toggle -> + toggle.featureName().name == toggleName + } +} diff --git a/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/AppSyncAccountRepositoryTest.kt b/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/AppSyncAccountRepositoryTest.kt index bc9ac6ba2766..9f587669d3a9 100644 --- a/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/AppSyncAccountRepositoryTest.kt +++ b/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/AppSyncAccountRepositoryTest.kt @@ -74,6 +74,7 @@ import com.duckduckgo.sync.impl.AccountErrorCodes.LOGIN_FAILED import com.duckduckgo.sync.impl.Result.Error import com.duckduckgo.sync.impl.Result.Success import com.duckduckgo.sync.impl.SyncAccountRepository.AuthCode +import com.duckduckgo.sync.impl.metrics.ConnectedDevicesObserver import com.duckduckgo.sync.impl.pixels.SyncPixels import com.duckduckgo.sync.impl.ui.qrcode.SyncBarcodeUrl import com.duckduckgo.sync.impl.ui.qrcode.SyncBarcodeUrlWrapper @@ -108,6 +109,7 @@ class AppSyncAccountRepositoryTest { private var syncEngine: SyncEngine = mock() private var syncPixels: SyncPixels = mock() private val deviceKeyGenerator: DeviceKeyGenerator = mock() + private val connectedDevicesObserver: ConnectedDevicesObserver = mock() private val moshi = Moshi.Builder().build() private val invitationCodeWrapperAdapter = moshi.adapter(InvitationCodeWrapper::class.java) private val invitedDeviceDetailsAdapter = moshi.adapter(InvitedDeviceDetails::class.java) @@ -123,6 +125,7 @@ class AppSyncAccountRepositoryTest { @Before fun before() { syncRepo = AppSyncAccountRepository( + connectedDevicesObserver, syncDeviceIds, nativeLib, syncApi, @@ -587,6 +590,19 @@ class AppSyncAccountRepositoryTest { assertEquals(listOfConnectedDevices, result.data) } + @Test + fun getConnectedDevicesSucceedsThenNotifyDevicesObserver() { + whenever(syncStore.token).thenReturn(token) + whenever(syncStore.primaryKey).thenReturn(primaryKey) + whenever(syncStore.deviceId).thenReturn(deviceId) + prepareForEncryption() + whenever(syncApi.getDevices(anyString())).thenReturn(getDevicesSuccess) + + val result = syncRepo.getConnectedDevices() as Success + + verify(connectedDevicesObserver).onDevicesUpdated(any()) + } + @Test fun getConnectedDevicesReturnsListWithLocalDeviceInFirstPosition() { givenAuthenticatedDevice() diff --git a/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/metrics/SyncConnectedDevicesObserverTest.kt b/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/metrics/SyncConnectedDevicesObserverTest.kt new file mode 100644 index 000000000000..68cb0a9fefbc --- /dev/null +++ b/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/metrics/SyncConnectedDevicesObserverTest.kt @@ -0,0 +1,90 @@ +/* + * 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.sync.impl.metrics + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import app.cash.turbine.test +import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.sync.TestSyncFixtures.connectedDevice +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@ExperimentalCoroutinesApi +@RunWith(AndroidJUnit4::class) +class SyncConnectedDevicesObserverTest { + + @get:Rule + val coroutineRule = CoroutineTestRule() + + private lateinit var observer: SyncConnectedDevicesObserver + + @Before + fun setup() { + observer = SyncConnectedDevicesObserver(coroutineRule.testScope) + } + + @Test + fun whenNoDevicesUpdatedThenEmitsZero() = runTest { + observer.observeConnectedDevicesCount().test { + assertEquals(0, awaitItem()) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun whenDevicesUpdatedThenEmitsCorrectCount() = runTest { + val devices = listOf( + connectedDevice.copy(deviceId = "device1", thisDevice = true), + connectedDevice.copy(deviceId = "device2"), + ) + + observer.observeConnectedDevicesCount().test { + assertEquals(0, awaitItem()) + observer.onDevicesUpdated(devices) + assertEquals(2, awaitItem()) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun whenDevicesUpdatedMultipleTimesThenEmitsLatestCount() = runTest { + val devices1 = listOf(connectedDevice) + + val devices2 = listOf( + connectedDevice.copy(deviceId = "device1", thisDevice = true), + connectedDevice.copy(deviceId = "device2"), + connectedDevice.copy(deviceId = "device3"), + ) + + observer.observeConnectedDevicesCount().test { + assertEquals(0, awaitItem()) + + observer.onDevicesUpdated(devices1) + assertEquals(1, awaitItem()) + + observer.onDevicesUpdated(devices2) + assertEquals(3, awaitItem()) + + cancelAndIgnoreRemainingEvents() + } + } +} diff --git a/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/metrics/SyncDevicesAttributeMetricTest.kt b/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/metrics/SyncDevicesAttributeMetricTest.kt new file mode 100644 index 000000000000..0238ffc312b1 --- /dev/null +++ b/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/metrics/SyncDevicesAttributeMetricTest.kt @@ -0,0 +1,177 @@ +/* + * 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.sync.impl.metrics + +import android.annotation.SuppressLint +import androidx.lifecycle.LifecycleOwner +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.duckduckgo.app.attributed.metrics.api.AttributedMetricClient +import com.duckduckgo.app.attributed.metrics.api.AttributedMetricConfig +import com.duckduckgo.app.attributed.metrics.api.MetricBucket +import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory +import com.duckduckgo.feature.toggles.api.Toggle +import com.duckduckgo.feature.toggles.api.Toggle.DefaultFeatureValue +import com.duckduckgo.feature.toggles.api.Toggle.State +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +@SuppressLint("DenyListedApi") +@RunWith(AndroidJUnit4::class) +class SyncDevicesAttributeMetricTest { + + @get:Rule + val coroutineRule = CoroutineTestRule() + + private val attributedMetricClient: AttributedMetricClient = mock() + private val attributedMetricConfig: AttributedMetricConfig = mock() + private val connectedDevicesObserver: ConnectedDevicesObserver = mock() + private val syncToggle = FakeFeatureToggleFactory.create(FakeSyncMetricsConfigFeature::class.java) + private val lifecycleOwner: LifecycleOwner = mock() + private val connectedDevicesFlow = MutableStateFlow(0) + + private lateinit var testee: SyncDevicesAttributeMetric + + @Before + fun setup() = runTest { + syncToggle.syncDevices().setRawStoredState(State(true)) + whenever(attributedMetricConfig.metricsToggles()).thenReturn(listOf(syncToggle.syncDevices())) + whenever(attributedMetricConfig.getBucketConfiguration()).thenReturn( + mapOf( + "attributed_metric_synced_device" to MetricBucket( + buckets = listOf(1), + version = 0, + ), + ), + ) + whenever(connectedDevicesObserver.observeConnectedDevicesCount()).thenReturn(connectedDevicesFlow) + + testee = SyncDevicesAttributeMetric( + appCoroutineScope = coroutineRule.testScope, + dispatcherProvider = coroutineRule.testDispatcherProvider, + attributedMetricClient = attributedMetricClient, + attributedMetricConfig = attributedMetricConfig, + connectedDevicesObserver = connectedDevicesObserver, + ) + } + + @Test + fun whenPixelNameRequestedThenReturnCorrectName() { + assertEquals("attributed_metric_synced_device", testee.getPixelName()) + } + + @Test + fun whenOnCreateAndFFDisabledThenDoNotEmitMetric() = runTest { + syncToggle.syncDevices().setRawStoredState(State(false)) + whenever(attributedMetricConfig.metricsToggles()).thenReturn(listOf(syncToggle.syncDevices())) + connectedDevicesFlow.emit(1) + + testee.onCreate(lifecycleOwner) + + verify(attributedMetricClient, never()).emitMetric(testee) + } + + @Test + fun whenOnCreateAndNoDevicesThenDoNotEmitMetric() = runTest { + connectedDevicesFlow.emit(0) + + testee.onCreate(lifecycleOwner) + + verify(attributedMetricClient, never()).emitMetric(testee) + } + + @Test + fun whenOnCreateAndHasDevicesThenEmitMetric() = runTest { + connectedDevicesFlow.emit(1) + + testee.onCreate(lifecycleOwner) + + verify(attributedMetricClient).emitMetric(testee) + } + + @Test + fun whenGetMetricParametersThenReturnCorrectBucketValue() = runTest { + // Map of device count to expected bucket + val deviceCountExpectedBuckets = mapOf( + 1 to 0, // 1 device -> bucket 0 + 2 to 1, // 2 devices -> bucket 1 + 3 to 1, // 3 devices -> bucket 1 + 5 to 1, // 5 devices -> bucket 1 + ) + + deviceCountExpectedBuckets.forEach { (devices, bucket) -> + connectedDevicesFlow.emit(devices) + + val realbucket = testee.getMetricParameters()["device_count"] + + assertEquals( + "For $devices devices, should return bucket $bucket", + bucket.toString(), + realbucket, + ) + } + } + + @Test + fun whenGetTagThenReturnCorrectBucketValue() = runTest { + // Map of device count to expected bucket + val deviceCountExpectedBuckets = mapOf( + 1 to "0", // 1 device -> bucket 0 + 2 to "1", // 2 devices -> bucket 1 + 3 to "1", // 3 devices -> bucket 1 + 5 to "1", // 5 devices -> bucket 1 + ) + + deviceCountExpectedBuckets.forEach { (devices, bucket) -> + connectedDevicesFlow.emit(devices) + + val tag = testee.getTag() + + assertEquals( + "For $devices devices, should return bucket $bucket", + bucket, + tag, + ) + } + } + + @Test + fun whenGetMetricParametersThenReturnVersion() = runTest { + connectedDevicesFlow.emit(1) + + val version = testee.getMetricParameters()["version"] + + assertEquals("0", version) + } +} + +interface FakeSyncMetricsConfigFeature { + @Toggle.DefaultValue(DefaultFeatureValue.INTERNAL) + fun self(): Toggle + + @Toggle.DefaultValue(DefaultFeatureValue.INTERNAL) + fun syncDevices(): Toggle +}