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/build.gradle b/app/build.gradle index 563ad0350af4..94cad9a98d3f 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -413,6 +413,10 @@ dependencies { implementation project(':survey-api') implementation project(':survey-impl') + implementation project(':attributed-metrics-api') + implementation project(':attributed-metrics-impl') + internalImplementation project(':attributed-metrics-internal') + implementation project(':breakage-reporting-impl') implementation project(':dax-prompts-api') diff --git a/app/src/internal/java/com/duckduckgo/app/statistics/StatisticsAttributedMetricsPlugin.kt b/app/src/internal/java/com/duckduckgo/app/statistics/StatisticsAttributedMetricsPlugin.kt new file mode 100644 index 000000000000..694140818283 --- /dev/null +++ b/app/src/internal/java/com/duckduckgo/app/statistics/StatisticsAttributedMetricsPlugin.kt @@ -0,0 +1,32 @@ +/* + * 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.statistics + +import android.content.Context +import android.view.View +import com.duckduckgo.app.attributed.metrics.internal.ui.AttributedMetricsSettingPlugin +import com.duckduckgo.di.scopes.ActivityScope +import com.squareup.anvil.annotations.ContributesMultibinding +import javax.inject.Inject + +@ContributesMultibinding(ActivityScope::class) +class StatisticsAttributedMetricsPlugin @Inject constructor() : AttributedMetricsSettingPlugin { + + override fun getView(context: Context): View { + return StatisticsInternalInfoView(context) + } +} diff --git a/app/src/internal/java/com/duckduckgo/app/statistics/StatisticsInternalInfoView.kt b/app/src/internal/java/com/duckduckgo/app/statistics/StatisticsInternalInfoView.kt new file mode 100644 index 000000000000..a7f1dc5e808d --- /dev/null +++ b/app/src/internal/java/com/duckduckgo/app/statistics/StatisticsInternalInfoView.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.app.statistics + +import android.content.Context +import android.util.AttributeSet +import android.widget.LinearLayout +import android.widget.Toast +import com.duckduckgo.anvil.annotations.InjectWith +import com.duckduckgo.app.browser.databinding.ViewStatisticsAttributedMetricsBinding +import com.duckduckgo.app.global.install.AppInstallStore +import com.duckduckgo.app.referral.AppReferrerDataStore +import com.duckduckgo.app.statistics.store.StatisticsDataStore +import com.duckduckgo.common.ui.viewbinding.viewBinding +import com.duckduckgo.di.scopes.ViewScope +import dagger.android.support.AndroidSupportInjection +import java.text.SimpleDateFormat +import java.util.* +import javax.inject.Inject + +@InjectWith(ViewScope::class) +class StatisticsInternalInfoView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0, +) : LinearLayout(context, attrs, defStyle) { + + @Inject lateinit var store: StatisticsDataStore + + @Inject lateinit var referrerDataStore: AppReferrerDataStore + + @Inject lateinit var appInstallStore: AppInstallStore + + private val binding: ViewStatisticsAttributedMetricsBinding by viewBinding() + + private val dateETFormat = SimpleDateFormat("yyyy-MM-dd", Locale.US).apply { + timeZone = TimeZone.getTimeZone("US/Eastern") + } + override fun onAttachedToWindow() { + AndroidSupportInjection.inject(this) + super.onAttachedToWindow() + + binding.retentionAtb.apply { + text = store.appRetentionAtb ?: "unknown" + } + + binding.retentionAtbSave.setOnClickListener { + store.appRetentionAtb = binding.retentionAtb.text + Toast.makeText(this.context, "App Retention Atb updated", Toast.LENGTH_SHORT).show() + } + + binding.searchAtb.apply { + text = store.searchRetentionAtb ?: "unknown" + } + + binding.searchAtbSave.setOnClickListener { + store.searchRetentionAtb = binding.searchAtb.text + Toast.makeText(this.context, "Search Atb updated", Toast.LENGTH_SHORT).show() + } + + binding.originInput.apply { + text = referrerDataStore.utmOriginAttributeCampaign ?: "unknown" + } + + binding.originInputSave.setOnClickListener { + referrerDataStore.utmOriginAttributeCampaign = binding.originInput.text.toString() + Toast.makeText(this.context, "Origin updated", Toast.LENGTH_SHORT).show() + } + + binding.installDateInput.apply { + val timestamp = appInstallStore.installTimestamp + text = dateETFormat.format(Date(timestamp)) + } + + binding.installDateSave.setOnClickListener { + try { + val date = dateETFormat.parse(binding.installDateInput.text) + if (date != null) { + appInstallStore.installTimestamp = date.time + Toast.makeText(this.context, "Install date updated", Toast.LENGTH_SHORT).show() + } else { + Toast.makeText(this.context, "Invalid date format. Use yyyy-MM-dd", Toast.LENGTH_SHORT).show() + } + } catch (e: Exception) { + Toast.makeText(this.context, "Invalid date format. Use yyyy-MM-dd", Toast.LENGTH_SHORT).show() + } + } + } +} diff --git a/app/src/internal/res/layout/view_statistics_attributed_metrics.xml b/app/src/internal/res/layout/view_statistics_attributed_metrics.xml new file mode 100644 index 000000000000..4217d51f9568 --- /dev/null +++ b/app/src/internal/res/layout/view_statistics_attributed_metrics.xml @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/duckduckgo/app/global/install/AppInstallRepository.kt b/app/src/main/java/com/duckduckgo/app/global/install/AppInstallRepository.kt index 1772bd13d1af..eb902158483e 100644 --- a/app/src/main/java/com/duckduckgo/app/global/install/AppInstallRepository.kt +++ b/app/src/main/java/com/duckduckgo/app/global/install/AppInstallRepository.kt @@ -18,9 +18,11 @@ package com.duckduckgo.app.global.install import com.duckduckgo.browser.api.install.AppInstall import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding import dagger.SingleInstanceIn import javax.inject.Inject +@ContributesBinding(AppScope::class) @SingleInstanceIn(AppScope::class) class AppInstallRepository @Inject constructor( private val appInstallStore: AppInstallStore, 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 new file mode 100644 index 000000000000..43e681891350 --- /dev/null +++ b/attributed-metrics/attributed-metrics-api/build.gradle @@ -0,0 +1,38 @@ +/* + * 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. + */ + +plugins { + id 'java-library' + id 'kotlin' +} + +apply from: "$rootProject.projectDir/code-formatting.gradle" + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +kotlin { + jvmToolchain(17) +} + +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 new file mode 100644 index 000000000000..05238b7da283 --- /dev/null +++ b/attributed-metrics/attributed-metrics-api/src/main/java/com/duckduckgo/app/attributed/metrics/api/AttributedMetricsClient.kt @@ -0,0 +1,107 @@ +/* + * 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.api + +import com.duckduckgo.feature.toggles.api.Toggle + +/** + * Client for collecting and emitting attributed metrics. + */ +interface AttributedMetricClient { + /** + * Stores an event occurrence for later analysis. + * Does nothing if the client is not active. + * + * @param eventName Name of the event to collect + */ + fun collectEvent(eventName: String) + + /** + * Calculates statistics for a specific event over a time period. + * Returns zero stats if the client is not active. + * + * @param eventName Name of the event to analyze + * @param days Number of days to look back + * @return Statistics about the event's occurrences + */ + suspend fun getEventStats( + eventName: String, + days: Int, + ): EventStats + + /** + * Emits a metric with its parameters if the client is active. + * Does nothing if the client is not active. + * + * @param metric The metric to emit + */ + fun emitMetric(metric: AttributedMetric) +} + +/** + * Statistics about collected events over a time period. + * + * @property daysWithEvents Number of days that had at least one event + * @property rollingAverage Average number of events per day over the period + * @property totalEvents Total number of events in the period + */ +data class EventStats( + val daysWithEvents: Int, + val rollingAverage: Double, + val totalEvents: Int, +) + +/** + * Interface for defining an attributed metric. + * Each metric implementation should provide its name and parameters. + */ +interface AttributedMetric { + /** + * @return The name used to identify this metric + */ + fun getPixelName(): String + + /** + * @return Parameters to be included with this metric + */ + suspend fun getMetricParameters(): Map + + /** + * @return Identifier used to deduplicate metric emissions. The same combination of metric + * and tag will only be emitted once. + */ + 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/build.gradle b/attributed-metrics/attributed-metrics-impl/build.gradle new file mode 100644 index 000000000000..a27947bafc53 --- /dev/null +++ b/attributed-metrics/attributed-metrics-impl/build.gradle @@ -0,0 +1,92 @@ +/* + * 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. + */ + +plugins { + id 'com.android.library' + id 'kotlin-android' + id 'com.google.devtools.ksp' + id 'com.squareup.anvil' +} + +apply from: "$rootProject.projectDir/gradle/android-library.gradle" + +dependencies { + anvil project(path: ':anvil-compiler') + + implementation project(path: ':anvil-annotations') + implementation project(path: ':attributed-metrics-api') + implementation project(path: ':common-utils') + implementation project(path: ':di') + implementation project(path: ':app-build-config-api') + implementation project(path: ':statistics-api') + implementation project(path: ':browser-api') + + implementation KotlinX.coroutines.core + implementation KotlinX.coroutines.android + + implementation Google.dagger + + // DataStore + api AndroidX.dataStore.preferences + + // Room + implementation AndroidX.room.ktx + ksp AndroidX.room.compiler + + implementation "com.squareup.logcat:logcat:_" + + implementation AndroidX.core.ktx + + testImplementation Testing.junit4 + testImplementation "org.mockito.kotlin:mockito-kotlin:_" + testImplementation "androidx.lifecycle:lifecycle-runtime-testing:_" + testImplementation project(path: ':common-test') + testImplementation project(':data-store-test') + testImplementation project(':feature-toggles-test') + testImplementation CashApp.turbine + testImplementation Testing.robolectric + testImplementation(KotlinX.coroutines.test) { + // https://github.com/Kotlin/kotlinx.coroutines/issues/2023 + // conflicts with mockito due to direct inclusion of byte buddy + exclude group: "org.jetbrains.kotlinx", module: "kotlinx-coroutines-debug" + } + testImplementation AndroidX.test.ext.junit + testImplementation AndroidX.archCore.testing + testImplementation AndroidX.room.testing + testImplementation AndroidX.room.rxJava2 + + androidTestImplementation AndroidX.test.runner + androidTestImplementation AndroidX.test.rules + + coreLibraryDesugaring Android.tools.desugarJdkLibs +} + +android { + anvil { + generateDaggerFactories = true // default is false + } + lintOptions { + baseline file("lint-baseline.xml") + abortOnError = !project.hasProperty("abortOnError") || project.property("abortOnError") != "false" + } + namespace 'com.duckduckgo.app.attributed.metrics' + compileOptions { + coreLibraryDesugaringEnabled = true + } + buildFeatures { + buildConfig = true + } +} \ No newline at end of file diff --git a/attributed-metrics/attributed-metrics-impl/lint-baseline.xml b/attributed-metrics/attributed-metrics-impl/lint-baseline.xml new file mode 100644 index 000000000000..1526a743bda6 --- /dev/null +++ b/attributed-metrics/attributed-metrics-impl/lint-baseline.xml @@ -0,0 +1,4 @@ + + + + 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 new file mode 100644 index 000000000000..b16979c63129 --- /dev/null +++ b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/AttributedMetricsConfigFeature.kt @@ -0,0 +1,73 @@ +/* + * 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 com.duckduckgo.anvil.annotations.ContributesRemoteFeature +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.feature.toggles.api.Toggle +import com.duckduckgo.feature.toggles.api.Toggle.DefaultFeatureValue + +@ContributesRemoteFeature( + scope = AppScope::class, + featureName = "attributedMetrics", +) +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/di/AttributedMetricsDataStoreModule.kt b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/di/AttributedMetricsDataStoreModule.kt new file mode 100644 index 000000000000..f9acef38992c --- /dev/null +++ b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/di/AttributedMetricsDataStoreModule.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.attributed.metrics.di + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.preferencesDataStoreFile +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesTo +import dagger.Module +import dagger.Provides +import dagger.SingleInstanceIn +import javax.inject.Qualifier + +@Module +@ContributesTo(AppScope::class) +object AttributedMetricsDataStoreModule { + @Provides + @SingleInstanceIn(AppScope::class) + @AttributedMetrics + fun provideDataStore(context: Context): DataStore = + PreferenceDataStoreFactory.create( + produceFile = { context.preferencesDataStoreFile("attributed_metrics_v1") }, + ) +} + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class AttributedMetrics diff --git a/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/di/AttributedMetricsDatabaseModule.kt b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/di/AttributedMetricsDatabaseModule.kt new file mode 100644 index 000000000000..99002d5aac3c --- /dev/null +++ b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/di/AttributedMetricsDatabaseModule.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.attributed.metrics.di + +import android.content.Context +import androidx.room.Room +import com.duckduckgo.app.attributed.metrics.store.AttributedMetricsDatabase +import com.duckduckgo.app.attributed.metrics.store.EventDao +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesTo +import dagger.Module +import dagger.Provides +import dagger.SingleInstanceIn + +@Module +@ContributesTo(AppScope::class) +class AttributedMetricsDatabaseModule { + @Provides + @SingleInstanceIn(AppScope::class) + fun provideAttributedMetricsDatabase(context: Context): AttributedMetricsDatabase = + Room + .databaseBuilder( + context = context, + klass = AttributedMetricsDatabase::class.java, + name = "attributed_metrics.db", + ).fallbackToDestructiveMigration() + .build() + + @Provides + @SingleInstanceIn(AppScope::class) + fun provideEventDao(db: AttributedMetricsDatabase): EventDao = db.eventDao() +} 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 new file mode 100644 index 000000000000..5cd64cbe3397 --- /dev/null +++ b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/impl/AttributedMetricsState.kt @@ -0,0 +1,160 @@ +/* + * 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 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.squareup.anvil.annotations.ContributesBinding +import com.squareup.anvil.annotations.ContributesMultibinding +import dagger.SingleInstanceIn +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import logcat.logcat +import javax.inject.Inject + +/** + * Interface for checking if attributed metrics are active. + * The active state is determined by: + * 1. Having an initialization date + * 2. Being within the collection period (6 months = 24 weeks) + * 3. Being enabled in remote config + */ +interface AttributedMetricsState { + suspend fun isActive(): Boolean + suspend fun canEmitMetrics(): Boolean +} + +@ContributesBinding( + scope = AppScope::class, + boundType = AttributedMetricsState::class, +) +@ContributesMultibinding( + scope = AppScope::class, + boundType = MainProcessLifecycleObserver::class, +) +@ContributesMultibinding( + scope = AppScope::class, + boundType = AtbLifecyclePlugin::class, +) +@SingleInstanceIn(AppScope::class) +class RealAttributedMetricsState @Inject constructor( + @AppCoroutineScope private val appCoroutineScope: CoroutineScope, + private val dispatcherProvider: DispatcherProvider, + private val dataStore: AttributedMetricsDataStore, + private val attributedMetricsConfigFeature: AttributedMetricsConfigFeature, + private val appBuildConfig: AppBuildConfig, + private val attributedMetricsDateUtils: AttributedMetricsDateUtils, + private val eventRepository: EventRepository, +) : AttributedMetricsState, MainProcessLifecycleObserver, AtbLifecyclePlugin { + + override fun onCreate(owner: LifecycleOwner) { + appCoroutineScope.launch(dispatcherProvider.io()) { + if (!isEnabled()) return@launch + checkCollectionPeriodAndUpdateState() + } + } + + // this is called when the ATB is initialized after privacy config is downloaded, only once after app is installed + override fun onAppAtbInitialized() { + appCoroutineScope.launch(dispatcherProvider.io()) { + logcat(tag = "AttributedMetrics") { + "Detected New Install, try to initialize Attributed Metrics" + } + if (!isEnabled()) { + logcat(tag = "AttributedMetrics") { + "Client disabled from remote config, skipping initialization" + } + return@launch + } + + val initDate = dataStore.getInitializationDate() + if (initDate == null) { + logcat(tag = "AttributedMetrics") { + "Setting initialization date for Attributed Metrics" + } + val currentDate = attributedMetricsDateUtils.getCurrentDate() + dataStore.setInitializationDate(currentDate) + if (appBuildConfig.isAppReinstall()) { + logcat(tag = "AttributedMetrics") { + "App reinstall detected, attributed metrics will not be active" + } + // Do not start metrics for returning users + dataStore.setActive(false) + } else { + logcat(tag = "AttributedMetrics") { + "New install detected, attributed metrics active" + } + dataStore.setActive(true) + } + } + logClientStatus() + } + } + + override suspend fun isActive(): Boolean = isEnabled() && dataStore.isActive() && dataStore.getInitializationDate() != null + + override suspend fun canEmitMetrics(): Boolean = isActive() && emitMetricsEnabled() + + private suspend fun checkCollectionPeriodAndUpdateState() { + val initDate = dataStore.getInitializationDate() + + if (initDate == null) { + logcat(tag = "AttributedMetrics") { + "Client not initialized, skipping state check" + } + 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() + + logcat(tag = "AttributedMetrics") { + "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: ${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 new file mode 100644 index 000000000000..a9710823fcf5 --- /dev/null +++ b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/impl/RealAttributedMetricClient.kt @@ -0,0 +1,118 @@ +/* + * 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.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 +import dagger.SingleInstanceIn +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import logcat.logcat +import javax.inject.Inject + +@ContributesBinding(AppScope::class, AttributedMetricClient::class) +@SingleInstanceIn(AppScope::class) +class RealAttributedMetricClient @Inject constructor( + @AppCoroutineScope private val appCoroutineScope: CoroutineScope, + private val dispatcherProvider: DispatcherProvider, + 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) { + appCoroutineScope.launch(dispatcherProvider.io()) { + if (!metricsState.isActive()) { + logcat(tag = "AttributedMetrics") { + "Discard collect event $eventName, client not active" + } + return@launch + } + eventRepository.collectEvent(eventName).also { + logcat(tag = "AttributedMetrics") { + "Collected event $eventName" + } + } + } + } + + override suspend fun getEventStats( + eventName: String, + days: Int, + ): EventStats = + withContext(dispatcherProvider.io()) { + if (!metricsState.isActive()) { + logcat(tag = "AttributedMetrics") { + "Discard get stats for event $eventName, client not active" + } + return@withContext EventStats(daysWithEvents = 0, rollingAverage = 0.0, totalEvents = 0) + } + eventRepository.getEventStats(eventName, days).also { + logcat(tag = "AttributedMetrics") { + "Returning Stats for Event $eventName($days days): $it" + } + } + } + + override fun emitMetric(metric: AttributedMetric) { + appCoroutineScope.launch(dispatcherProvider.io()) { + 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" + + 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 $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/retention/RetentionMonthAttributedMetric.kt b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/retention/RetentionMonthAttributedMetric.kt new file mode 100644 index 000000000000..b7a568ff6165 --- /dev/null +++ b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/retention/RetentionMonthAttributedMetric.kt @@ -0,0 +1,150 @@ +/* + * 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 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 +import com.duckduckgo.browser.api.install.AppInstall +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 logcat.logcat +import javax.inject.Inject + +@ContributesMultibinding(AppScope::class, AtbLifecyclePlugin::class) +@ContributesMultibinding(AppScope::class, AttributedMetric::class) +@SingleInstanceIn(AppScope::class) +class RetentionMonthAttributedMetric @Inject constructor( + @AppCoroutineScope private val appCoroutineScope: CoroutineScope, + private val dispatcherProvider: DispatcherProvider, + 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 = "attributed_metric_retention_month" + private const val DAYS_IN_4_WEEKS = 28 // we consider 1 month after 4 weeks + 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( + oldAtb: String, + newAtb: String, + ) { + appCoroutineScope.launch(dispatcherProvider.io()) { + if (!isEnabled.await()) return@launch + + if (oldAtb == newAtb) { + logcat(tag = "AttributedMetrics") { + "RetentionMonth: Skip emitting atb not changed" + } + return@launch + } + if (shouldSendPixel().not()) { + logcat(tag = "AttributedMetrics") { + "RetentionMonth: Skip emitting, outside window" + } + return@launch + } + if (canEmit.await()) { + attributedMetricClient.emitMetric(this@RetentionMonthAttributedMetric) + } + } + } + + override fun getPixelName(): String = PIXEL_NAME_FIRST_MONTH + + override suspend fun getMetricParameters(): Map { + val month = getMonthSinceInstall() + if (month < START_MONTH_THRESHOLD) return emptyMap() + + val params = mutableMapOf( + "count" to bucketMonth(month).toString(), + "version" to bucketConfig.await().version.toString(), + ) + return params + } + + override suspend fun getTag(): String { + val month = getMonthSinceInstall() + return bucketMonth(month).toString() + } + + private fun shouldSendPixel(): Boolean { + val month = getMonthSinceInstall() + if (month < START_MONTH_THRESHOLD) return false + + return true + } + + 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/retention/RetentionWeekAttributedMetric.kt b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/retention/RetentionWeekAttributedMetric.kt new file mode 100644 index 000000000000..a83de642ecf4 --- /dev/null +++ b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/retention/RetentionWeekAttributedMetric.kt @@ -0,0 +1,147 @@ +/* + * 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 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 +import com.duckduckgo.browser.api.install.AppInstall +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 logcat.logcat +import javax.inject.Inject + +@ContributesMultibinding(AppScope::class, AtbLifecyclePlugin::class) +@ContributesMultibinding(AppScope::class, AttributedMetric::class) +@SingleInstanceIn(AppScope::class) +class RetentionWeekAttributedMetric @Inject constructor( + @AppCoroutineScope private val appCoroutineScope: CoroutineScope, + private val dispatcherProvider: DispatcherProvider, + 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_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( + oldAtb: String, + newAtb: String, + ) { + appCoroutineScope.launch(dispatcherProvider.io()) { + if (!isEnabled.await()) return@launch + if (oldAtb == newAtb) { + logcat(tag = "AttributedMetrics") { + "RetentionFirstMonth: Skip emitting atb not changed" + } + return@launch + } + if (shouldSendPixel().not()) { + logcat(tag = "AttributedMetrics") { + "RetentionFirstMonth: Skip emitting, outside window" + } + return@launch + } + if (canEmit.await()) { + attributedMetricClient.emitMetric(this@RetentionWeekAttributedMetric) + } + } + } + + override fun getPixelName(): String = PIXEL_NAME_FIRST_WEEK + + override suspend fun getMetricParameters(): Map { + 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 { + return bucketValue(getWeekSinceInstall()).toString() + } + + private fun shouldSendPixel(): Boolean { + val week = getWeekSinceInstall() + if (week == -1) return false + + return true + } + + 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 // 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/search/SearchAttributedMetric.kt b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/search/SearchAttributedMetric.kt new file mode 100644 index 000000000000..39695d0ea458 --- /dev/null +++ b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/search/SearchAttributedMetric.kt @@ -0,0 +1,196 @@ +/* + * 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.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 +import com.duckduckgo.app.statistics.store.StatisticsDataStore +import com.duckduckgo.browser.api.install.AppInstall +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 logcat.logcat +import javax.inject.Inject +import kotlin.math.roundToInt + +/** + * Search Count 7d avg Attributed Metric + * Trigger: on first search of day + * Type: Daily pixel + * Report: 7d rolling average of searches (bucketed value). Not sent if count is 0. + * Specs: https://app.asana.com/1/137249556945/project/1206716555947156/task/1211313432282643?focus=true + */ +@ContributesMultibinding(AppScope::class, AtbLifecyclePlugin::class) +@ContributesMultibinding(AppScope::class, AttributedMetric::class) +@SingleInstanceIn(AppScope::class) +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 = "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 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( + oldAtb: String, + newAtb: String, + ) { + appCoroutineScope.launch(dispatcherProvider.io()) { + if (!isEnabled.await()) return@launch + attributedMetricClient.collectEvent(EVENT_NAME) + + if (oldAtb == newAtb) { + logcat(tag = "AttributedMetrics") { + "SearchCount7d: Skip emitting, atb not changed" + } + return@launch + } + if (shouldSendPixel().not()) { + logcat(tag = "AttributedMetrics") { + "SearchCount7d: Skip emitting, not enough data or no events" + } + return@launch + } + if (canEmit.await()) { + attributedMetricClient.emitMetric(this@SearchAttributedMetric) + } + } + } + + override fun getPixelName(): String = when (daysSinceInstalled()) { + in 0..FIRST_MONTH_DAY_THRESHOLD -> FIRST_MONTH_PIXEL + else -> PAST_WEEK_PIXEL_NAME + } + + override suspend fun getMetricParameters(): Map { + val stats = getEventStats() + val params = mutableMapOf( + "count" to getBucketValue(stats.rollingAverage.roundToInt()).toString(), + "version" to getBucketConfig().version.toString(), + ) + if (!hasCompleteDataWindow()) { + params["dayAverage"] = daysSinceInstalled().toString() + } + return params + } + + override suspend fun getTag(): String { + // Daily metric, on first search of day + // rely on searchRetentionAtb as mirrors the metric trigger event + return statisticsDataStore.searchRetentionAtb + ?: "no-atb" // should not happen, but just in case + } + + 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 + } + } + + 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 suspend fun getEventStats(): EventStats { + val stats = if (hasCompleteDataWindow()) { + attributedMetricClient.getEventStats(EVENT_NAME, DAYS_WINDOW) + } else { + attributedMetricClient.getEventStats( + EVENT_NAME, + 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 + } + + 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/search/SearchDaysAttributedMetric.kt b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/search/SearchDaysAttributedMetric.kt new file mode 100644 index 000000000000..ec482f74f24e --- /dev/null +++ b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/search/SearchDaysAttributedMetric.kt @@ -0,0 +1,171 @@ +/* + * 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.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 +import com.duckduckgo.app.statistics.store.StatisticsDataStore +import com.duckduckgo.browser.api.install.AppInstall +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 logcat.logcat +import javax.inject.Inject + +/** + * Search Days Attributed Metric + * Trigger: on app start + * Type: Daily pixel + * Report: Bucketed value, how many days user searched last 7d. Not sent if count is 0. + * Specs: https://app.asana.com/1/137249556945/project/1206716555947156/task/1211301604929609?focus=true + */ +@ContributesMultibinding(AppScope::class, AtbLifecyclePlugin::class) +@ContributesMultibinding(AppScope::class, AttributedMetric::class) +@SingleInstanceIn(AppScope::class) +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 = "attributed_metric_active_past_week" + private const val DAYS_WINDOW = 7 + 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( + oldAtb: String, + newAtb: String, + ) { + appCoroutineScope.launch(dispatcherProvider.io()) { + if (!isEnabled.await()) return@launch + + if (oldAtb == newAtb) { + logcat(tag = "AttributedMetrics") { + "SearchDays: Skip emitting atb not changed" + } + return@launch + } + if (shouldSendPixel().not()) { + logcat(tag = "AttributedMetrics") { + "SearchDays: Skip emitting, not enough data or no events" + } + return@launch + } + + if (canEmit.await()) { + attributedMetricClient.emitMetric(this@SearchDaysAttributedMetric) + } + } + } + + override fun onSearchRetentionAtbRefreshed( + oldAtb: String, + newAtb: String, + ) { + appCoroutineScope.launch(dispatcherProvider.io()) { + if (!isEnabled.await()) return@launch + attributedMetricClient.collectEvent(EVENT_NAME) + } + } + + override fun getPixelName(): String = PIXEL_NAME + + override suspend fun getMetricParameters(): Map { + val daysSinceInstalled = daysSinceInstalled() + val hasCompleteDataWindow = daysSinceInstalled >= DAYS_WINDOW + 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() + } + return params + } + + override suspend fun getTag(): String { + // Daily metric, on App start + // rely on appRetentionAtb as mirrors the metric trigger event + return statisticsDataStore.appRetentionAtb ?: "no-atb" // should not happen, but just in case + } + + 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 + } + } + + private suspend fun shouldSendPixel(): Boolean { + if (daysSinceInstalled() <= 0) { + // installation day, we don't emit + return false + } + + val eventStats = attributedMetricClient.getEventStats(EVENT_NAME, DAYS_WINDOW) + if (eventStats.daysWithEvents == 0) { + // no events, nothing to emit + return false + } + + return true + } + + 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 new file mode 100644 index 000000000000..bc5c7c7c9cdc --- /dev/null +++ b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/store/AttributedMetricsDataStore.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.attributed.metrics.store + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import com.duckduckgo.app.attributed.metrics.di.AttributedMetrics +import com.duckduckgo.app.di.AppCoroutineScope +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import dagger.SingleInstanceIn +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.* +import javax.inject.Inject + +interface AttributedMetricsDataStore { + suspend fun isActive(): Boolean + + suspend fun setActive(active: Boolean) + + suspend fun getInitializationDate(): String? + + suspend fun setInitializationDate(date: String?) +} + +@ContributesBinding(AppScope::class) +@SingleInstanceIn(AppScope::class) +class RealAttributedMetricsDataStore @Inject constructor( + @AttributedMetrics private val store: DataStore, + @AppCoroutineScope private val appCoroutineScope: CoroutineScope, +) : AttributedMetricsDataStore { + private object Keys { + val IS_ACTIVE = booleanPreferencesKey("is_active") + val INIT_DATE = stringPreferencesKey("client_init_date") + } + + override suspend fun getInitializationDate(): String? = store.data.firstOrNull()?.get(Keys.INIT_DATE) + + override suspend fun setInitializationDate(date: String?) { + store.edit { preferences -> + if (date != null) { + preferences[Keys.INIT_DATE] = date + } else { + preferences.remove(Keys.INIT_DATE) + } + } + } + + override suspend fun isActive(): Boolean = store.data.firstOrNull()?.get(Keys.IS_ACTIVE) ?: false + + override suspend fun setActive(active: Boolean) { + store.edit { preferences -> + preferences[Keys.IS_ACTIVE] = active + } + } +} diff --git a/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/store/AttributedMetricsDatabase.kt b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/store/AttributedMetricsDatabase.kt new file mode 100644 index 000000000000..c0e98dc63799 --- /dev/null +++ b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/store/AttributedMetricsDatabase.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.attributed.metrics.store + +import androidx.room.Database +import androidx.room.RoomDatabase + +@Database( + version = 1, + entities = [ + EventEntity::class, + ], + exportSchema = true, +) +abstract class AttributedMetricsDatabase : RoomDatabase() { + abstract fun eventDao(): EventDao +} 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 new file mode 100644 index 000000000000..f932f1816609 --- /dev/null +++ b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/store/AttributedMetricsDateUtils.kt @@ -0,0 +1,149 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.attributed.metrics.store + +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import java.time.Instant +import java.time.LocalDate +import java.time.LocalTime +import java.time.ZoneId +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter +import java.time.temporal.ChronoUnit +import javax.inject.Inject + +/** + * Utility interface for handling date operations in the Attributed Metrics feature. + * + * This interface provides methods for: + * - Getting the current date in a standardized format + * - Calculating days between dates + * - Generating dates relative to the current date + * + * All dates are handled in Eastern Time (ET) and formatted as "yyyy-MM-dd" for consistency. + * This format is used for both storage and calculations. The timezone ensures that day + * boundaries align with business operations in ET. + * + * Example usage: + * ``` + * // Get today's date in ET + * val today = dateUtils.getCurrentDate() // returns "2025-10-03" (if it's Oct 3rd in ET) + * + * // Get a date 7 days ago in ET + * val lastWeek = dateUtils.getDateMinusDays(7) // returns "2025-09-26" + * + * // Calculate days since a specific date in ET + * // Note: The calculation uses ET midnight as the boundary for day changes + * val daysSince = dateUtils.daysSince("2025-09-01") // returns number of days + * ``` + * + * Note: All date operations use Eastern Time (ET) timezone. This means: + * - Day changes occur at midnight ET + * - Date comparisons and calculations are based on ET dates + * - The returned date strings represent dates in ET + */ +interface AttributedMetricsDateUtils { + /** + * Gets the current date in Eastern Time formatted as "yyyy-MM-dd". + * + * @return The current date in ET as a string in the format "yyyy-MM-dd" + */ + fun getCurrentDate(): String + + /** + * Calculates the number of days between a given date and the current date in Eastern Time. + * Day boundaries are determined using midnight ET. + * + * @param date The reference date in "yyyy-MM-dd" format (interpreted in ET) + * @return The number of days between the reference date and current date. + * Positive if the reference date is in the past, + * negative if it's in the future, + * zero if it's today. + */ + fun daysSince(date: String): Int + + /** + * Calculates the number of days between a given timestamp and the current date in Eastern Time. + * Day boundaries are determined using midnight ET. + * + * @param timestamp The reference timestamp in milliseconds since epoch (Unix timestamp) + * @return The number of days between the reference timestamp and current date. + * Positive if the reference timestamp is in the past, + * negative if it's in the future, + * zero if it's today. + */ + fun daysSince(timestamp: Long): Int + + /** + * Gets a date that is a specified number of days before the current date in Eastern Time. + * Day boundaries are determined using midnight ET. + * + * @param days The number of days to subtract from the current date + * @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) +class RealAttributedMetricsDateUtils @Inject constructor() : AttributedMetricsDateUtils { + override fun getCurrentDate(): String = getCurrentZonedDateTime().format(DATE_FORMATTER) + + override fun daysSince(date: String): Int { + // Parse the input date and set it to start of day (midnight) in ET + val initDate = ZonedDateTime.of( + LocalDate.parse(date, DATE_FORMATTER), + LocalTime.MIDNIGHT, + ET_ZONE, + ) + return ChronoUnit.DAYS.between(initDate, getCurrentZonedDateTime()).toInt() + } + + override fun daysSince(timestamp: Long): Int { + val etZone = ZoneId.of("America/New_York") + val installInstant = Instant.ofEpochMilli(timestamp) + val nowInstant = Instant.now() + + val installInEt = installInstant.atZone(etZone) + val nowInEt = nowInstant.atZone(etZone) + + return ChronoUnit.DAYS.between(installInEt.toLocalDate(), nowInEt.toLocalDate()).toInt() + } + + 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 { + 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/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 new file mode 100644 index 000000000000..a6c6d940730a --- /dev/null +++ b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/store/EventDao.kt @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.attributed.metrics.store + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query + +@Dao +interface EventDao { + @Query("SELECT * FROM event_metrics WHERE eventName = :eventName AND day >= :startDay AND day <= :endDay ORDER BY day DESC") + suspend fun getEventsByNameAndTimeframe( + eventName: String, + startDay: String, + endDay: String, + ): List + + @Query("SELECT COUNT(DISTINCT day) FROM event_metrics WHERE eventName = :eventName AND day >= :startDay AND day <= :endDay") + suspend fun getDaysWithEvents( + eventName: String, + startDay: String, + endDay: String, + ): Int + + @Query("SELECT SUM(count) FROM event_metrics WHERE eventName = :eventName AND day >= :startDay AND day <= :endDay") + suspend fun getTotalEvents( + eventName: String, + startDay: String, + endDay: String, + ): Int + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertEvent(event: EventEntity) + + @Query( + """ + UPDATE event_metrics + SET count = count + 1 + WHERE eventName = :eventName AND day = :day + """, + ) + suspend fun incrementEventCount( + eventName: String, + day: String, + ) + + @Query("SELECT count FROM event_metrics WHERE eventName = :eventName AND day = :day") + suspend fun getEventCount( + eventName: String, + day: String, + ): Int? + + @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/EventEntity.kt b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/store/EventEntity.kt new file mode 100644 index 000000000000..bcb6d3ac2b7a --- /dev/null +++ b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/store/EventEntity.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.attributed.metrics.store + +import androidx.room.Entity +import androidx.room.Index + +@Entity( + tableName = "event_metrics", + primaryKeys = ["eventName", "day"], + indices = [Index("eventName"), Index("day")], +) +data class EventEntity( + val eventName: String, + val count: Int, + // Format: YYYY-MM-DD + val day: String, +) 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 new file mode 100644 index 000000000000..d2d8bc4016dc --- /dev/null +++ b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/store/EventRepository.kt @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.attributed.metrics.store + +import com.duckduckgo.app.attributed.metrics.api.EventStats +import com.duckduckgo.app.di.AppCoroutineScope +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import javax.inject.Inject + +interface EventRepository { + suspend fun collectEvent(eventName: String) + + suspend fun getEventStats( + eventName: String, + days: Int, + ): EventStats + + suspend fun deleteAllEvents() +} + +@ContributesBinding(AppScope::class) +class RealEventRepository @Inject constructor( + private val eventDao: EventDao, + private val attributedMetricsDateUtils: AttributedMetricsDateUtils, + @AppCoroutineScope private val appCoroutineScope: CoroutineScope, + private val dispatcherProvider: DispatcherProvider, +) : EventRepository { + override suspend fun collectEvent(eventName: String) { + val today = attributedMetricsDateUtils.getCurrentDate() + val currentCount = eventDao.getEventCount(eventName, today) + + if (currentCount == null) { + eventDao.insertEvent(EventEntity(eventName = eventName, count = 1, day = today)) + } else { + eventDao.incrementEventCount(eventName, today) + } + } + + override suspend fun getEventStats( + eventName: String, + days: Int, + ): EventStats { + val startDay = attributedMetricsDateUtils.getDateMinusDays(days) + val yesterday = attributedMetricsDateUtils.getDateMinusDays(1) + + val daysWithEvents = eventDao.getDaysWithEvents(eventName, startDay, yesterday) + val totalEvents = eventDao.getTotalEvents(eventName, startDay, yesterday) ?: 0 + val rollingAverage = if (days > 0) totalEvents.toDouble() / days else 0.0 + + return EventStats( + daysWithEvents = daysWithEvents, + rollingAverage = rollingAverage, + totalEvents = totalEvents, + ) + } + + override suspend fun deleteAllEvents() { + appCoroutineScope.launch(dispatcherProvider.io()) { + 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 new file mode 100644 index 000000000000..66c9025ae75d --- /dev/null +++ b/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/FakeAttributedMetricsDateUtils.kt @@ -0,0 +1,54 @@ +/* + * 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 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 + +class FakeAttributedMetricsDateUtils(var testDate: LocalDate) : AttributedMetricsDateUtils { + + override fun getCurrentDate(): String = getCurrentLocalDate().format(DATE_FORMATTER) + + override fun daysSince(date: String): Int { + val initDate = LocalDate.parse(date, DATE_FORMATTER) + return ChronoUnit.DAYS.between(initDate, getCurrentLocalDate()).toInt() + } + + override fun daysSince(timestamp: Long): Int { + val installDate = Instant.ofEpochMilli(timestamp) + return ChronoUnit.DAYS.between(installDate, getCurrentLocalDate()).toInt() + } + + 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/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 new file mode 100644 index 000000000000..0e572ad4809c --- /dev/null +++ b/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/impl/RealAttributedMetricClientTest.kt @@ -0,0 +1,174 @@ +/* + * 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 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 +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.verifyNoInteractions +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 + +@RunWith(AndroidJUnit4::class) +class RealAttributedMetricClientTest { + + @get:Rule + val coroutineTestRule = CoroutineTestRule() + + 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 + + @Before + fun setup() { + testee = RealAttributedMetricClient( + appCoroutineScope = coroutineTestRule.testScope, + dispatcherProvider = coroutineTestRule.testDispatcherProvider, + eventRepository = mockEventRepository, + pixel = mockPixel, + metricsState = mockMetricsState, + appReferrer = appReferrer, + dateUtils = dateUtils, + appInstall = appInstall, + ) + } + + @Test + fun whenCollectEventAndClientActiveEventIsCollected() = runTest { + whenever(mockMetricsState.isActive()).thenReturn(true) + + testee.collectEvent("test_event") + + verify(mockEventRepository).collectEvent("test_event") + } + + @Test + fun whenCollectEventAndClientNotActiveEventIsNotCollected() = runTest { + whenever(mockMetricsState.isActive()).thenReturn(false) + + testee.collectEvent("test_event") + + verify(mockEventRepository, never()).collectEvent(any()) + } + + @Test + fun whenGetEventStatsAndClientActiveStatsAreReturned() = runTest { + val expectedStats = EventStats(daysWithEvents = 5, rollingAverage = 2.5, totalEvents = 10) + whenever(mockMetricsState.isActive()).thenReturn(true) + whenever(mockEventRepository.getEventStats("test_event", 7)).thenReturn(expectedStats) + + val result = testee.getEventStats("test_event", 7) + + assertEquals(expectedStats, result) + verify(mockEventRepository).getEventStats("test_event", 7) + } + + @Test + fun whenGetEventStatsAndClientNotActiveEmptyStatsAreReturned() = runTest { + whenever(mockMetricsState.isActive()).thenReturn(false) + + val result = testee.getEventStats("test_event", 7) + + assertEquals(EventStats(daysWithEvents = 0, rollingAverage = 0.0, totalEvents = 0), result) + verify(mockEventRepository, never()).getEventStats(any(), any()) + } + + @Test + 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", "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 + fun whenEmitMetricAndClientNotActiveMetricIsNotEmitted() = runTest { + val testMetric = TestAttributedMetric() + whenever(mockMetricsState.isActive()).thenReturn(false) + + testee.emitMetric(testMetric) + + verifyNoInteractions(mockPixel) + } + + private class TestAttributedMetric : AttributedMetric { + override fun getPixelName(): String = "test_pixel" + override suspend fun getMetricParameters(): Map = mapOf("param" to "value") + override suspend fun getTag(): String = "test_tag" + } +} 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 new file mode 100644 index 000000000000..2c8bab75b85d --- /dev/null +++ b/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/impl/RealAttributedMetricsStateTest.kt @@ -0,0 +1,193 @@ +/* + * 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.lifecycle.LifecycleOwner +import androidx.test.ext.junit.runners.AndroidJUnit4 +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 +import com.duckduckgo.feature.toggles.api.Toggle.State +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +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.LocalDate + +@SuppressLint("DenyListedApi") +@RunWith(AndroidJUnit4::class) +class RealAttributedMetricsStateTest { + + @get:Rule val coroutineTestRule = CoroutineTestRule() + + private val mockDataStore: AttributedMetricsDataStore = mock() + 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 + + @Before fun setup() { + givenAttributedClientFeatureEnabled(true) + initDateUtilsWith(LocalDate.of(2025, 10, 3)) + testee = RealAttributedMetricsState( + appCoroutineScope = coroutineTestRule.testScope, + dispatcherProvider = coroutineTestRule.testDispatcherProvider, + dataStore = mockDataStore, + attributedMetricsConfigFeature = mockConfigFeature, + appBuildConfig = mockAppBuildConfig, + attributedMetricsDateUtils = testDateUtils, + eventRepository = mockEventRepository, + ) + } + + @Test fun whenOnAppAtbInitializedAndFeatureDisabledThenDoNothing() = runTest { + givenAttributedClientFeatureEnabled(false) + + testee.onAppAtbInitialized() + + verify(mockDataStore, never()).setInitializationDate(any()) + verify(mockDataStore, never()).setActive(any()) + } + + @Test fun whenOnAppAtbInitializedAndFeatureEnabledAndReinstallThenSetInactiveState() = runTest { + givenAttributedClientFeatureEnabled(true) + whenever(mockDataStore.getInitializationDate()).thenReturn(null) + whenever(mockAppBuildConfig.isAppReinstall()).thenReturn(true) + + testee.onAppAtbInitialized() + + verify(mockDataStore).setInitializationDate("2025-10-03") + verify(mockDataStore).setActive(false) + } + + @Test fun whenOnAppAtbInitializedAndFeatureEnabledAndNewInstallThenSetActiveState() = runTest { + givenAttributedClientFeatureEnabled(true) + whenever(mockDataStore.getInitializationDate()).thenReturn(null) + whenever(mockAppBuildConfig.isAppReinstall()).thenReturn(false) + + testee.onAppAtbInitialized() + + verify(mockDataStore).setInitializationDate("2025-10-03") + verify(mockDataStore).setActive(true) + } + + @Test fun whenOnAppAtbInitializedAndFeatureEnabledAndAlreadyInitializedThenDoNothing() = runTest { + givenAttributedClientFeatureEnabled(true) + whenever(mockDataStore.getInitializationDate()).thenReturn("2025-10-03") + + testee.onAppAtbInitialized() + + verify(mockDataStore, never()).setInitializationDate(any()) + verify(mockDataStore, never()).setActive(any()) + } + + @Test fun whenIsActiveAndAllConditionsMetThenReturnTrue() = runTest { + whenever(mockDataStore.isActive()).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) + mockConfigFeature.self().setRawStoredState(State(true)) + whenever(mockDataStore.getInitializationDate()).thenReturn("2025-10-03") + + assertFalse(testee.isActive()) + } + + @Test fun whenIsActiveAndNotEnabledThenReturnFalse() = runTest { + whenever(mockDataStore.isActive()).thenReturn(true) + mockConfigFeature.self().setRawStoredState(State(false)) + whenever(mockDataStore.getInitializationDate()).thenReturn("2025-10-03") + + assertFalse(testee.isActive()) + } + + @Test fun whenIsActiveAndNoInitDateThenReturnFalse() = runTest { + whenever(mockDataStore.isActive()).thenReturn(true) + mockConfigFeature.self().setRawStoredState(State(true)) + whenever(mockDataStore.getInitializationDate()).thenReturn(null) + + assertFalse(testee.isActive()) + } + + @Test fun whenCheckCollectionPeriodAndNoInitDateThenDoNothing() = runTest { + whenever(mockDataStore.getInitializationDate()).thenReturn(null) + + testee.onCreate(mockLifecycleOwner) + + verify(mockDataStore, never()).setActive(any()) + verify(mockEventRepository, never()).deleteAllEvents() + } + + @Test fun whenCheckCollectionPeriodAndWithinPeriodAndActiveThenKeepActive() = runTest { + whenever(mockDataStore.getInitializationDate()).thenReturn(testDateUtils.getDateMinusDays(100)) + whenever(mockDataStore.isActive()).thenReturn(true) + + testee.onCreate(mockLifecycleOwner) + + verify(mockDataStore).setActive(true) + verify(mockEventRepository, never()).deleteAllEvents() + } + + @Test fun whenCheckCollectionPeriodAndWithinPeriodAndNotActiveThenKeepInactive() = runTest { + whenever(mockDataStore.getInitializationDate()).thenReturn(testDateUtils.getDateMinusDays(100)) + whenever(mockDataStore.isActive()).thenReturn(false) + + testee.onCreate(mockLifecycleOwner) + + verify(mockDataStore, never()).setActive(any()) + verify(mockEventRepository, never()).deleteAllEvents() + } + + @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) { + mockConfigFeature.self().setRawStoredState(State(isEnabled)) + } + + private fun initDateUtilsWith(date: LocalDate) { + testDateUtils = FakeAttributedMetricsDateUtils(date) + } +} 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/search/SearchAttributedMetricTest.kt b/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/search/SearchAttributedMetricTest.kt new file mode 100644 index 000000000000..d04b9c963176 --- /dev/null +++ b/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/search/SearchAttributedMetricTest.kt @@ -0,0 +1,355 @@ +/* + * 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.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 +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +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 SearchAttributedMetricTest { + + @get:Rule + val coroutineRule = CoroutineTestRule() + + private val attributedMetricClient: AttributedMetricClient = mock() + 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: SearchAttributedMetric + + @Before + 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, + ) + } + + @Test + fun whenOnSearchThenCollectEventCalled() = runTest { + testee.onSearchRetentionAtbRefreshed("old", "new") + + verify(attributedMetricClient).collectEvent("ddg_search") + } + + @Test + fun whenOnSearchAndAtbNotChangedThenDoNotEmitMetric() = runTest { + testee.onSearchRetentionAtbRefreshed("same", "same") + + verify(attributedMetricClient).collectEvent("ddg_search") + verify(attributedMetricClient, never()).emitMetric(testee) + } + + @Test + fun whenGetTagThenReturnSearchRetentionAtb() = runTest { + whenever(statisticsDataStore.searchRetentionAtb).thenReturn("v123-1") + + assertEquals("v123-1", testee.getTag()) + } + + @Test + fun whenDaysSinceInstalledLessThan4WThenReturnFirstMonthPixelName() { + givenDaysSinceInstalled(15) + + assertEquals("attributed_metric_average_searches_past_week_first_month", testee.getPixelName()) + } + + @Test + fun whenDaysSinceInstalledMoreThan4WThenReturnRegularPixelName() { + givenDaysSinceInstalled(45) + + assertEquals("attributed_metric_average_searches_past_week", testee.getPixelName()) + } + + @Test + fun whenDaysSinceInstalledIsEndOf4WThenReturnFirstMonthPixelName() { + givenDaysSinceInstalled(28) + + assertEquals("attributed_metric_average_searches_past_week_first_month", testee.getPixelName()) + } + + @Test + fun whenFirstSearchOfDayIfInstallationDayThenDoNotEmitMetric() = runTest { + givenDaysSinceInstalled(0) + + testee.onSearchRetentionAtbRefreshed("old", "new") + + verify(attributedMetricClient, never()).emitMetric(testee) + } + + @Test + fun whenFirstSearchOfDayIfRollingAverageIsZeroThenDoNotEmitMetric() = runTest { + givenDaysSinceInstalled(3) + whenever(attributedMetricClient.getEventStats(any(), any())).thenReturn( + EventStats( + totalEvents = 0, + daysWithEvents = 0, + rollingAverage = 0.0, + ), + ) + + testee.onSearchRetentionAtbRefreshed("old", "new") + + verify(attributedMetricClient, never()).emitMetric(testee) + } + + @Test + fun whenFirstSearchOfDayIfRollingAverageIsNotZeroThenEmitMetric() = runTest { + givenDaysSinceInstalled(3) + whenever(attributedMetricClient.getEventStats(any(), any())).thenReturn( + EventStats( + totalEvents = 16, + daysWithEvents = 3, + rollingAverage = 5.3, + ), + ) + + testee.onSearchRetentionAtbRefreshed("old", "new") + + 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, // ≤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) -> + whenever(attributedMetricClient.getEventStats(any(), any())).thenReturn( + EventStats( + totalEvents = 16, + daysWithEvents = 3, + rollingAverage = avg, + ), + ) + + val realBucket = testee.getMetricParameters()["count"] + + assertEquals( + "For $avg searches, should return bucket $bucket", + bucket.toString(), + realBucket, + ) + } + } + + @Test + fun getMetricParametersAndDaysSinceInstalledLessThan7ThenIncludeDayAverage() = runTest { + givenDaysSinceInstalled(5) + whenever(attributedMetricClient.getEventStats(any(), any())).thenReturn( + EventStats( + totalEvents = 16, + daysWithEvents = 3, + rollingAverage = 5.3, + ), + ) + + val params = testee.getMetricParameters() + + assertEquals("5", params["dayAverage"]) + } + + @Test + fun getMetricParametersAndDaysSinceInstalledMoreThan7ThenDoNotIncludeDaysSinceInstall() = + runTest { + givenDaysSinceInstalled(10) + whenever(attributedMetricClient.getEventStats(any(), any())).thenReturn( + EventStats( + totalEvents = 16, + daysWithEvents = 3, + rollingAverage = 5.3, + ), + ) + + val params = testee.getMetricParameters() + + assertNull(params["dayAverage"]) + } + + @Test + fun getMetricParametersAndDaysSinceInstalledLessThan7ThenCalculateStatsWithExistingWindow() = + runTest { + givenDaysSinceInstalled(3) + whenever(attributedMetricClient.getEventStats(any(), any())).thenReturn( + EventStats( + totalEvents = 16, + daysWithEvents = 3, + rollingAverage = 5.3, + ), + ) + + testee.getMetricParameters() + + verify(attributedMetricClient).getEventStats(eq("ddg_search"), eq(3)) + } + + @Test + fun getMetricParametersAndDaysSinceInstalledIsCompleteDataWindowThenCalculateStats7d() = + runTest { + givenDaysSinceInstalled(7) + whenever(attributedMetricClient.getEventStats(any(), any())).thenReturn( + EventStats( + totalEvents = 16, + daysWithEvents = 3, + rollingAverage = 5.3, + ), + ) + + testee.getMetricParameters() + + 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/search/SearchDaysAttributedMetricTest.kt b/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/search/SearchDaysAttributedMetricTest.kt new file mode 100644 index 000000000000..fe7ebbaa3159 --- /dev/null +++ b/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/search/SearchDaysAttributedMetricTest.kt @@ -0,0 +1,298 @@ +/* + * 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.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 +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 SearchDaysAttributedMetricTest { + + @get:Rule + val coroutineRule = CoroutineTestRule() + + private val attributedMetricClient: AttributedMetricClient = mock() + private val appInstall: AppInstall = mock() + private val statisticsDataStore: StatisticsDataStore = mock() + private val dateUtils: AttributedMetricsDateUtils = mock() + private val attributedMetricConfig: AttributedMetricConfig = mock() + private val searchDaysToggle = FakeFeatureToggleFactory.create(AttributedMetricsConfigFeature::class.java) + + private lateinit var testee: SearchDaysAttributedMetric + + @Before + 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() = runTest { + testee.onSearchRetentionAtbRefreshed("old", "new") + + verify(attributedMetricClient).collectEvent("ddg_search_days") + } + + @Test + 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("attributed_metric_active_past_week", testee.getPixelName()) + } + + @Test + fun whenAtbRefreshedIfInstallationDayThenDoNotEmitMetric() = runTest { + givenDaysSinceInstalled(0) + + testee.onAppRetentionAtbRefreshed("old", "new") + + verify(attributedMetricClient, never()).emitMetric(testee) + } + + @Test + fun whenAtbRefreshedIfNoDaysWithEventsThenDoNotEmitMetric() = runTest { + givenDaysSinceInstalled(3) + whenever(attributedMetricClient.getEventStats(any(), any())).thenReturn( + EventStats( + totalEvents = 0, + daysWithEvents = 0, + rollingAverage = 0.0, + ), + ) + + testee.onAppRetentionAtbRefreshed("old", "new") + + verify(attributedMetricClient, never()).emitMetric(testee) + } + + @Test + fun whenAtbRefreshedIfHasDaysWithEventsThenEmitMetric() = runTest { + givenDaysSinceInstalled(3) + whenever(attributedMetricClient.getEventStats(any(), any())).thenReturn( + EventStats( + totalEvents = 16, + daysWithEvents = 3, + rollingAverage = 5.3, + ), + ) + + testee.onAppRetentionAtbRefreshed("old", "new") + + verify(attributedMetricClient).emitMetric(testee) + } + + @Test + fun whenAtbNotChangedThenDoNotEmitMetric() = runTest { + givenDaysSinceInstalled(3) + whenever(attributedMetricClient.getEventStats(any(), any())).thenReturn( + EventStats( + totalEvents = 16, + daysWithEvents = 3, + rollingAverage = 5.3, + ), + ) + + testee.onAppRetentionAtbRefreshed("same", "same") + + 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") + + assertEquals("v123-1", testee.getTag()) + } + + @Test + fun givenCompleteDataWindowThenReturnCorrectDaysBucketInParams() = runTest { + givenDaysSinceInstalled(7) + + // Map of days with events to expected bucket + val daysWithEventsExpectedBuckets = mapOf( + 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) -> + whenever(attributedMetricClient.getEventStats(any(), any())).thenReturn( + EventStats( + totalEvents = days * 5, // Not relevant for this test + daysWithEvents = days, + rollingAverage = days.toDouble(), // Not relevant for this test + ), + ) + + val realBucket = testee.getMetricParameters()["days"] + + assertEquals( + "For $days days with events, should return bucket $bucket", + bucket.toString(), + realBucket, + ) + } + } + + @Test + fun whenDaysSinceInstalledLessThan7ThenIncludeDaysSinceInstalled() = runTest { + givenDaysSinceInstalled(5) + whenever(attributedMetricClient.getEventStats(any(), any())).thenReturn( + EventStats( + totalEvents = 25, + daysWithEvents = 5, + rollingAverage = 5.0, + ), + ) + + val daysWindow = testee.getMetricParameters()["daysSinceInstalled"] + + assertEquals("5", daysWindow) + } + + @Test + fun whenDaysSinceInstalledIs7ThenDoNotIncludeDaysSinceInstalled() = runTest { + givenDaysSinceInstalled(7) + whenever(attributedMetricClient.getEventStats(any(), any())).thenReturn( + EventStats( + totalEvents = 25, + daysWithEvents = 5, + rollingAverage = 5.0, + ), + ) + + val daysSince = testee.getMetricParameters()["daysSinceInstalled"] + + assertNull(daysSince) + } + + @Test + fun whenGetMetricParametersThenReturnVersion() = runTest { + givenDaysSinceInstalled(7) + whenever(attributedMetricClient.getEventStats(any(), any())).thenReturn( + EventStats( + totalEvents = 25, + daysWithEvents = 5, + rollingAverage = 5.0, + ), + ) + + 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/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 new file mode 100644 index 000000000000..8170ec123fa0 --- /dev/null +++ b/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/store/RealEventRepositoryTest.kt @@ -0,0 +1,159 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.attributed.metrics.store + +import androidx.room.Room +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.duckduckgo.app.attributed.metrics.FakeAttributedMetricsDateUtils +import com.duckduckgo.common.test.CoroutineTestRule +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import java.time.LocalDate + +@RunWith(AndroidJUnit4::class) +class RealEventRepositoryTest { + @get:Rule + var coroutineTestRule = CoroutineTestRule() + + private lateinit var db: AttributedMetricsDatabase + private lateinit var eventDao: EventDao + private lateinit var testDateProvider: FakeAttributedMetricsDateUtils + private lateinit var repository: RealEventRepository + + @Before + fun setup() { + db = + Room + .inMemoryDatabaseBuilder( + InstrumentationRegistry.getInstrumentation().targetContext, + AttributedMetricsDatabase::class.java, + ).build() + eventDao = db.eventDao() + testDateProvider = FakeAttributedMetricsDateUtils(LocalDate.of(2025, 10, 3)) + repository = + RealEventRepository( + eventDao = eventDao, + attributedMetricsDateUtils = testDateProvider, + appCoroutineScope = coroutineTestRule.testScope, + dispatcherProvider = coroutineTestRule.testDispatcherProvider, + ) + } + + @After + fun tearDown() { + db.close() + } + + @Test + fun whenCollectEventFirstTimeForTodayThenInsertNewRecord() = + runTest { + testDateProvider.testDate = LocalDate.of(2025, 10, 3) + + repository.collectEvent("test_event") + + val events = eventDao.getEventsByNameAndTimeframe("test_event", "2025-10-03", "2025-10-03") + assert(events.size == 1) + assert(events[0].count == 1) + assert(events[0].eventName == "test_event") + assert(events[0].day == "2025-10-03") + } + + @Test + fun whenCollectEventMultipleTimesForTodayThenIncrementCount() = + runTest { + testDateProvider.testDate = LocalDate.of(2025, 10, 3) + + repository.collectEvent("test_event") + repository.collectEvent("test_event") + repository.collectEvent("test_event") + + val events = eventDao.getEventsByNameAndTimeframe("test_event", "2025-10-03", "2025-10-03") + assert(events.size == 1) + assert(events[0].count == 3) + } + + @Test + fun whenGetEventStatsWithNoEventsThenReturnZeros() = + runTest { + testDateProvider.testDate = LocalDate.of(2025, 10, 3) + + val stats = repository.getEventStats("test_event", days = 7) + + assert(stats.daysWithEvents == 0) + assert(stats.totalEvents == 0) + assert(stats.rollingAverage == 0.0) + } + + @Test + fun whenGetEventStatsWithDataOnEveryDayThenCalculateCorrectlyUsingPreviousDaysWindow() = + runTest { + // Setup data for 3 days + testDateProvider.testDate = LocalDate.of(2025, 10, 8) + eventDao.insertEvent(EventEntity("test_event", count = 3, day = "2025-10-08")) + eventDao.insertEvent(EventEntity("test_event", count = 1, day = "2025-10-07")) + eventDao.insertEvent(EventEntity("test_event", count = 2, day = "2025-10-06")) + eventDao.insertEvent(EventEntity("test_event", count = 3, day = "2025-10-05")) + eventDao.insertEvent(EventEntity("test_event", count = 1, day = "2025-10-04")) + eventDao.insertEvent(EventEntity("test_event", count = 2, day = "2025-10-03")) + eventDao.insertEvent(EventEntity("test_event", count = 3, day = "2025-10-02")) + eventDao.insertEvent(EventEntity("test_event", count = 1, day = "2025-10-01")) + + val stats = repository.getEventStats("test_event", days = 7) + + assert(stats.daysWithEvents == 7) + assert(stats.totalEvents == 13) + assert(stats.rollingAverage == 13.0 / 7.0) + } + + @Test + fun whenGetEventStatsWithMissingDaysDataThenCalculateCorrectlyUsingPreviousDaysWindow() = + runTest { + // Setup data for 3 days + testDateProvider.testDate = LocalDate.of(2025, 10, 8) + eventDao.insertEvent(EventEntity("test_event", count = 3, day = "2025-10-08")) + eventDao.insertEvent(EventEntity("test_event", count = 1, day = "2025-10-07")) + eventDao.insertEvent(EventEntity("test_event", count = 2, day = "2025-10-06")) + eventDao.insertEvent(EventEntity("test_event", count = 1, day = "2025-10-04")) + eventDao.insertEvent(EventEntity("test_event", count = 2, day = "2025-10-03")) + eventDao.insertEvent(EventEntity("test_event", count = 1, day = "2025-10-01")) + + val stats = repository.getEventStats("test_event", days = 7) + + assert(stats.daysWithEvents == 5) + assert(stats.totalEvents == 7) + assert(stats.rollingAverage == 7.0 / 7.0) + } + + @Test + 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")) + + repository.deleteAllEvents() + + val remainingEvents = eventDao.getEventsByNameAndTimeframe("test_event", "2025-09-03", "2025-10-03") + assert(remainingEvents.isEmpty()) + } +} diff --git a/attributed-metrics/attributed-metrics-internal/build.gradle b/attributed-metrics/attributed-metrics-internal/build.gradle new file mode 100644 index 000000000000..6707ee5e481c --- /dev/null +++ b/attributed-metrics/attributed-metrics-internal/build.gradle @@ -0,0 +1,41 @@ +plugins { + id 'com.android.library' + id 'kotlin-android' + id 'com.squareup.anvil' + id 'com.google.devtools.ksp' +} + +apply from: "$rootProject.projectDir/gradle/android-library.gradle" + +android { + anvil { + generateDaggerFactories = true // default is false + } + namespace 'com.duckduckgo.attributed.metrics.internal' + testOptions { + unitTests { + includeAndroidResources = true + } + } +} + +dependencies { + anvil project(':anvil-compiler') + implementation project(':anvil-annotations') + implementation project(':attributed-metrics-api') + implementation project(':attributed-metrics-impl') + implementation project(':di') + implementation project(':internal-features-api') + implementation project(':navigation-api') + implementation project(':common-utils') + implementation project(':design-system') + + implementation AndroidX.appCompat + implementation AndroidX.constraintLayout + implementation Google.android.material + implementation Google.dagger + + testImplementation project(path: ':common-test') + testImplementation "org.mockito.kotlin:mockito-kotlin:_" + testImplementation Testing.junit4 +} diff --git a/attributed-metrics/attributed-metrics-internal/lint-baseline.xml b/attributed-metrics/attributed-metrics-internal/lint-baseline.xml new file mode 100644 index 000000000000..1526a743bda6 --- /dev/null +++ b/attributed-metrics/attributed-metrics-internal/lint-baseline.xml @@ -0,0 +1,4 @@ + + + + diff --git a/attributed-metrics/attributed-metrics-internal/src/main/AndroidManifest.xml b/attributed-metrics/attributed-metrics-internal/src/main/AndroidManifest.xml new file mode 100644 index 000000000000..ade4ff78dfe9 --- /dev/null +++ b/attributed-metrics/attributed-metrics-internal/src/main/AndroidManifest.xml @@ -0,0 +1,11 @@ + + + + + + + + diff --git a/attributed-metrics/attributed-metrics-internal/src/main/java/com/duckduckgo/app/attributed/metrics/internal/AttributedMetricsDevSettingsFeatures.kt b/attributed-metrics/attributed-metrics-internal/src/main/java/com/duckduckgo/app/attributed/metrics/internal/AttributedMetricsDevSettingsFeatures.kt new file mode 100644 index 000000000000..aaaeb9886959 --- /dev/null +++ b/attributed-metrics/attributed-metrics-internal/src/main/java/com/duckduckgo/app/attributed/metrics/internal/AttributedMetricsDevSettingsFeatures.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.attributed.metrics.internal + +import android.content.Context +import com.duckduckgo.anvil.annotations.PriorityKey +import com.duckduckgo.app.attributed.metrics.internal.ui.MainAttributedMetricsSettings +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.internal.features.api.InternalFeaturePlugin +import com.duckduckgo.navigation.api.GlobalActivityStarter +import com.squareup.anvil.annotations.ContributesMultibinding +import javax.inject.Inject + +@ContributesMultibinding(AppScope::class) +@PriorityKey(InternalFeaturePlugin.ATTRIBUTED_METRICS_SETTINGS_PRIO_KEY) +class AttributedMetricsDevSettingsFeatures @Inject constructor( + private val globalActivityStarter: GlobalActivityStarter, +) : InternalFeaturePlugin { + override fun internalFeatureTitle(): String { + return "Attributed Metrics Settings" + } + + override fun internalFeatureSubtitle(): String { + return "Attributed Metrics Dev Settings for internal users" + } + + override fun onInternalFeatureClicked(activityContext: Context) { + globalActivityStarter.start(activityContext, MainAttributedMetricsSettings) + } +} 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 new file mode 100644 index 000000000000..1871d93e0e28 --- /dev/null +++ b/attributed-metrics/attributed-metrics-internal/src/main/java/com/duckduckgo/app/attributed/metrics/internal/ui/AttributedMetricsDevSettingsActivity.kt @@ -0,0 +1,125 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.attributed.metrics.internal.ui + +import android.os.Bundle +import android.widget.Toast +import androidx.lifecycle.lifecycleScope +import com.duckduckgo.anvil.annotations.ContributeToActivityStarter +import com.duckduckgo.anvil.annotations.InjectWith +import com.duckduckgo.app.attributed.metrics.impl.AttributedMetricsState +import com.duckduckgo.app.attributed.metrics.store.AttributedMetricsDateUtils +import com.duckduckgo.app.attributed.metrics.store.EventDao +import com.duckduckgo.app.attributed.metrics.store.EventEntity +import com.duckduckgo.appbuildconfig.api.AppBuildConfig +import com.duckduckgo.attributed.metrics.internal.databinding.ActivityAttributedMetricsDevSettingsBinding +import com.duckduckgo.common.ui.DuckDuckGoActivity +import com.duckduckgo.common.ui.viewbinding.viewBinding +import com.duckduckgo.common.utils.plugins.PluginPoint +import com.duckduckgo.di.scopes.ActivityScope +import com.duckduckgo.navigation.api.GlobalActivityStarter +import kotlinx.coroutines.launch +import javax.inject.Inject + +@InjectWith(ActivityScope::class) +@ContributeToActivityStarter(MainAttributedMetricsSettings::class) +class AttributedMetricsDevSettingsActivity : DuckDuckGoActivity() { + + private val binding: ActivityAttributedMetricsDevSettingsBinding by viewBinding() + + @Inject + lateinit var eventDao: EventDao + + @Inject + lateinit var dateUtils: AttributedMetricsDateUtils + + @Inject + lateinit var appBuildConfig: AppBuildConfig + + @Inject + lateinit var attributedMetricsState: AttributedMetricsState + + @Inject + lateinit var settingsPlugins: PluginPoint + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(binding.root) + setupToolbar(binding.includeToolbar.toolbar) + setupViews() + setupPlugins() + } + + private fun setupPlugins() { + settingsPlugins.getPlugins() + .mapNotNull { it.getView(this) } + .forEach { view -> + binding.settingsContainer.addView(view) + } + } + + private fun setupViews() { + 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") + } + } + + private fun addTestEvents() { + lifecycleScope.launch { + repeat(10) { daysAgo -> + val date = dateUtils.getDateMinusDays(daysAgo) + eventDao.insertEvent(EventEntity(eventName = "ddg_search_days", count = 1, day = date)) + eventDao.insertEvent(EventEntity(eventName = "ddg_search", count = 1, day = date)) + } + 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 { + private fun readResolve(): Any = MainAttributedMetricsSettings +} diff --git a/attributed-metrics/attributed-metrics-internal/src/main/java/com/duckduckgo/app/attributed/metrics/internal/ui/AttributedMetricsSettingPlugin.kt b/attributed-metrics/attributed-metrics-internal/src/main/java/com/duckduckgo/app/attributed/metrics/internal/ui/AttributedMetricsSettingPlugin.kt new file mode 100644 index 000000000000..b3c6e8ff7a1d --- /dev/null +++ b/attributed-metrics/attributed-metrics-internal/src/main/java/com/duckduckgo/app/attributed/metrics/internal/ui/AttributedMetricsSettingPlugin.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.attributed.metrics.internal.ui + +import android.content.Context +import android.view.View +import com.duckduckgo.anvil.annotations.ContributesPluginPoint +import com.duckduckgo.di.scopes.ActivityScope + +@ContributesPluginPoint(ActivityScope::class) +interface AttributedMetricsSettingPlugin { + fun getView(context: Context): View +} 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 new file mode 100644 index 000000000000..cf36130c02f0 --- /dev/null +++ b/attributed-metrics/attributed-metrics-internal/src/main/res/layout/activity_attributed_metrics_dev_settings.xml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/attributed-metrics/readme.md b/attributed-metrics/readme.md new file mode 100644 index 000000000000..b66b7333c6a6 --- /dev/null +++ b/attributed-metrics/readme.md @@ -0,0 +1,8 @@ +# Attributed Metrics +This module contains code for collecting attributed metrics. + +## Who can help you better understand this feature? +- Cristian Monforte + +## More information +N/A diff --git a/browser-api/src/main/java/com/duckduckgo/browser/api/referrer/AppReferrer.kt b/browser-api/src/main/java/com/duckduckgo/browser/api/referrer/AppReferrer.kt index 5af1d10b24fb..4dee15cc25ae 100644 --- a/browser-api/src/main/java/com/duckduckgo/browser/api/referrer/AppReferrer.kt +++ b/browser-api/src/main/java/com/duckduckgo/browser/api/referrer/AppReferrer.kt @@ -23,4 +23,9 @@ interface AppReferrer { * Sets the attribute campaign origin. */ fun setOriginAttributeCampaign(origin: String?) + + /** + * Returns campaign origin if it exists. + */ + fun getOriginAttributeCampaign(): String? } diff --git a/duckchat/duckchat-impl/build.gradle b/duckchat/duckchat-impl/build.gradle index 016db1581842..72134f5047b4 100644 --- a/duckchat/duckchat-impl/build.gradle +++ b/duckchat/duckchat-impl/build.gradle @@ -41,6 +41,7 @@ dependencies { implementation project(':history-api') implementation project(':saved-sites-api') implementation project(':remote-messaging-api') + implementation project(':attributed-metrics-api') anvil project(path: ':anvil-compiler') implementation project(path: ':anvil-annotations') diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/helper/DuckChatJSHelper.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/helper/DuckChatJSHelper.kt index d35ce0c3318f..ee327690edc3 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/helper/DuckChatJSHelper.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/helper/DuckChatJSHelper.kt @@ -22,6 +22,7 @@ import com.duckduckgo.duckchat.impl.ChatState.HIDE import com.duckduckgo.duckchat.impl.ChatState.SHOW import com.duckduckgo.duckchat.impl.DuckChatInternal import com.duckduckgo.duckchat.impl.ReportMetric +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 @@ -45,6 +46,7 @@ class RealDuckChatJSHelper @Inject constructor( private val duckChat: DuckChatInternal, private val duckChatPixels: DuckChatPixels, private val dataStore: DuckChatDataStore, + private val duckAiMetricCollector: DuckAiMetricCollector, ) : DuckChatJSHelper { override suspend fun processJsCallbackMessage( featureName: String, @@ -106,7 +108,12 @@ class RealDuckChatJSHelper @Inject constructor( REPORT_METRIC -> { 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/internal-features/internal-features-api/src/main/java/com/duckduckgo/internal/features/api/InternalFeaturePlugin.kt b/internal-features/internal-features-api/src/main/java/com/duckduckgo/internal/features/api/InternalFeaturePlugin.kt index 2de6cf527e42..b016023c43a6 100644 --- a/internal-features/internal-features-api/src/main/java/com/duckduckgo/internal/features/api/InternalFeaturePlugin.kt +++ b/internal-features/internal-features-api/src/main/java/com/duckduckgo/internal/features/api/InternalFeaturePlugin.kt @@ -48,5 +48,6 @@ interface InternalFeaturePlugin { const val CRASH_ANR_SETTINGS_PRIO_KEY = 900 const val WEB_VIEW_DEV_SETTINGS_PRIO_KEY = 1_000 const val SAVED_SITES_SETTINGS_PRIO_KEY = 1_100 + const val ATTRIBUTED_METRICS_SETTINGS_PRIO_KEY = 1_200 } } 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..5150bb02e19c --- /dev/null +++ b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/metrics/SyncConnectedDevicesObserver.kt @@ -0,0 +1,52 @@ +/* + * 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.common.utils.DispatcherProvider +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, + private val dispatcherProvider: DispatcherProvider, +) : ConnectedDevicesObserver { + + private val _connectedDevicesCount = MutableStateFlow(0) + override fun observeConnectedDevicesCount(): StateFlow = _connectedDevicesCount.asStateFlow() + + override fun onDevicesUpdated(devices: List) { + appCoroutineScope.launch(dispatcherProvider.io()) { + _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..f15df754a616 --- /dev/null +++ b/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/metrics/SyncConnectedDevicesObserverTest.kt @@ -0,0 +1,93 @@ +/* + * 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( + appCoroutineScope = coroutineRule.testScope, + dispatcherProvider = coroutineRule.testDispatcherProvider, + ) + } + + @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 +}