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 index 0e52f6da6e2b..0d165d17bca9 100644 --- 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 @@ -64,7 +64,7 @@ class RealAdClickAttributedMetric @Inject constructor( companion object { private const val EVENT_NAME = "ad_click" - private const val PIXEL_NAME = "user_average_ad_clicks_past_week" + 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 @@ -108,6 +108,7 @@ class RealAdClickAttributedMetric @Inject constructor( 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() 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 index 23ef39c678c9..e82dfbff24c8 100644 --- 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 @@ -63,7 +63,7 @@ class RealAdClickAttributedMetricTest { ) whenever(attributedMetricConfig.getBucketConfiguration()).thenReturn( mapOf( - "user_average_ad_clicks_past_week" to MetricBucket( + "attributed_metric_average_ad_clicks_past_week" to MetricBucket( buckets = listOf(2, 5), version = 0, ), @@ -79,7 +79,7 @@ class RealAdClickAttributedMetricTest { } @Test fun whenPixelNameRequestedThenReturnCorrectName() { - assertEquals("user_average_ad_clicks_past_week", testee.getPixelName()) + assertEquals("attributed_metric_average_ad_clicks_past_week", testee.getPixelName()) } @Test fun whenAdClickAndDaysInstalledIsZeroThenDoNotEmitMetric() = runTest { @@ -222,12 +222,12 @@ class RealAdClickAttributedMetricTest { ), ) - val params = testee.getMetricParameters() + val count = testee.getMetricParameters()["count"] assertEquals( "For $clicksAvg clicks, should return bucket $expectedBucket", - mapOf("count" to expectedBucket.toString()), - params, + expectedBucket.toString(), + count, ) } } @@ -255,6 +255,21 @@ class RealAdClickAttributedMetricTest { } } + @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() 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-impl/src/main/java/com/duckduckgo/app/attributed/metrics/AttributedMetricsConfigFeature.kt b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/AttributedMetricsConfigFeature.kt index 551b136bb7d6..b16979c63129 100644 --- a/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/AttributedMetricsConfigFeature.kt +++ b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/AttributedMetricsConfigFeature.kt @@ -70,7 +70,4 @@ interface AttributedMetricsConfigFeature { @Toggle.DefaultValue(DefaultFeatureValue.INTERNAL) fun syncDevices(): Toggle - - @Toggle.DefaultValue(DefaultFeatureValue.INTERNAL) - fun canEmitSyncDevices(): Toggle } diff --git a/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/impl/AttributedMetricsState.kt b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/impl/AttributedMetricsState.kt index fa6b19b33cff..df81b870c6d9 100644 --- a/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/impl/AttributedMetricsState.kt +++ b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/impl/AttributedMetricsState.kt @@ -20,6 +20,7 @@ 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 @@ -66,6 +67,7 @@ class RealAttributedMetricsState @Inject constructor( private val attributedMetricsConfigFeature: AttributedMetricsConfigFeature, private val appBuildConfig: AppBuildConfig, private val attributedMetricsDateUtils: AttributedMetricsDateUtils, + private val eventRepository: EventRepository, ) : AttributedMetricsState, MainProcessLifecycleObserver, AtbLifecyclePlugin { override fun onCreate(owner: LifecycleOwner) { @@ -125,6 +127,8 @@ class RealAttributedMetricsState @Inject constructor( return } + if (dataStore.isActive().not()) return // if already inactive, no need to check further + val daysSinceInit = attributedMetricsDateUtils.daysSince(initDate) val isWithinPeriod = daysSinceInit <= COLLECTION_PERIOD_DAYS val newClientActiveState = isWithinPeriod && dataStore.isActive() @@ -133,6 +137,10 @@ class RealAttributedMetricsState @Inject constructor( "Updating client state to $newClientActiveState result of -> within period? $isWithinPeriod, client active? ${dataStore.isActive()}" } dataStore.setActive(newClientActiveState) + if (!isWithinPeriod) { + eventRepository.deleteAllEvents() + } + logClientStatus() } diff --git a/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/impl/RealAttributedMetricClient.kt b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/impl/RealAttributedMetricClient.kt index 6f65536a6669..a9710823fcf5 100644 --- a/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/impl/RealAttributedMetricClient.kt +++ b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/impl/RealAttributedMetricClient.kt @@ -19,10 +19,13 @@ package com.duckduckgo.app.attributed.metrics.impl import com.duckduckgo.app.attributed.metrics.api.AttributedMetric import com.duckduckgo.app.attributed.metrics.api.AttributedMetricClient import com.duckduckgo.app.attributed.metrics.api.EventStats +import com.duckduckgo.app.attributed.metrics.store.AttributedMetricsDateUtils import com.duckduckgo.app.attributed.metrics.store.EventRepository import com.duckduckgo.app.di.AppCoroutineScope import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Unique +import com.duckduckgo.browser.api.install.AppInstall +import com.duckduckgo.browser.api.referrer.AppReferrer import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.AppScope import com.squareup.anvil.annotations.ContributesBinding @@ -41,6 +44,9 @@ class RealAttributedMetricClient @Inject constructor( private val eventRepository: EventRepository, private val pixel: Pixel, private val metricsState: AttributedMetricsState, + private val appReferrer: AppReferrer, + private val dateUtils: AttributedMetricsDateUtils, + private val appInstall: AppInstall, ) : AttributedMetricClient { override fun collectEvent(eventName: String) { @@ -77,7 +83,6 @@ class RealAttributedMetricClient @Inject constructor( } } - // TODO: Pending adding default attributed metrics and removing default prefix from pixel names override fun emitMetric(metric: AttributedMetric) { appCoroutineScope.launch(dispatcherProvider.io()) { if (!metricsState.isActive() || !metricsState.canEmitMetrics()) { @@ -91,11 +96,23 @@ class RealAttributedMetricClient @Inject constructor( val params = metric.getMetricParameters() val tag = metric.getTag() val pixelTag = "${pixelName}_$tag" - pixel.fire(pixelName = pixelName, parameters = params, type = Unique(pixelTag)).also { + + val origin = appReferrer.getOriginAttributeCampaign() + val paramsMutableMap = params.toMutableMap() + if (!origin.isNullOrBlank()) { + paramsMutableMap["origin"] = origin + } else { + paramsMutableMap["install_date"] = getInstallDate() + } + pixel.fire(pixelName = pixelName, parameters = paramsMutableMap, type = Unique(pixelTag)).also { logcat(tag = "AttributedMetrics") { - "Fired pixel $pixelName with params $params" + "Fired pixel $pixelName with params $paramsMutableMap" } } } } + + private fun getInstallDate(): String { + return dateUtils.getDateFromTimestamp(appInstall.getInstallationTimestamp()) + } } diff --git a/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/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 index d46a681a3290..b7a568ff6165 100644 --- 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 @@ -49,7 +49,7 @@ class RetentionMonthAttributedMetric @Inject constructor( ) : AttributedMetric, AtbLifecyclePlugin { companion object { - private const val PIXEL_NAME_FIRST_MONTH = "user_retention_month" + private const val PIXEL_NAME_FIRST_MONTH = "attributed_metric_retention_month" private const val DAYS_IN_4_WEEKS = 28 // we consider 1 month after 4 weeks private const val MONTH_DAY_THRESHOLD = DAYS_IN_4_WEEKS + 1 private const val START_MONTH_THRESHOLD = 2 @@ -103,7 +103,11 @@ class RetentionMonthAttributedMetric @Inject constructor( val month = getMonthSinceInstall() if (month < START_MONTH_THRESHOLD) return emptyMap() - return mutableMapOf("count" to bucketMonth(month).toString()) + val params = mutableMapOf( + "count" to bucketMonth(month).toString(), + "version" to bucketConfig.await().version.toString(), + ) + return params } override suspend fun getTag(): String { 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 index 2a6dfb6641e7..a83de642ecf4 100644 --- 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 @@ -49,7 +49,7 @@ class RetentionWeekAttributedMetric @Inject constructor( ) : AttributedMetric, AtbLifecyclePlugin { companion object { - private const val PIXEL_NAME_FIRST_WEEK = "user_retention_week" + private const val PIXEL_NAME_FIRST_WEEK = "attributed_metric_retention_week" private const val FEATURE_TOGGLE_NAME = "retention" private const val FEATURE_EMIT_TOGGLE_NAME = "canEmitRetention" } @@ -98,7 +98,12 @@ class RetentionWeekAttributedMetric @Inject constructor( override suspend fun getMetricParameters(): Map { val week = getWeekSinceInstall() if (week == -1) return emptyMap() - return mutableMapOf("count" to bucketValue(getWeekSinceInstall()).toString()) + + val params = mutableMapOf( + "count" to bucketValue(getWeekSinceInstall()).toString(), + "version" to bucketConfig.await().version.toString(), + ) + return params } override suspend fun getTag(): String { 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 index f9b88838e893..39695d0ea458 100644 --- 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 @@ -61,8 +61,8 @@ class SearchAttributedMetric @Inject constructor( companion object { private const val EVENT_NAME = "ddg_search" - private const val FIRST_MONTH_PIXEL = "user_average_searches_past_week_first_month" - private const val PAST_WEEK_PIXEL_NAME = "user_average_searches_past_week" + private const val FIRST_MONTH_PIXEL = "attributed_metric_average_searches_past_week_first_month" + private const val PAST_WEEK_PIXEL_NAME = "attributed_metric_average_searches_past_week" private const val DAYS_WINDOW = 7 private const val FIRST_MONTH_DAY_THRESHOLD = 28 // we consider 1 month after 4 weeks private const val FEATURE_TOGGLE_NAME = "searchCountAvg" @@ -126,6 +126,7 @@ class SearchAttributedMetric @Inject constructor( val stats = getEventStats() val params = mutableMapOf( "count" to getBucketValue(stats.rollingAverage.roundToInt()).toString(), + "version" to getBucketConfig().version.toString(), ) if (!hasCompleteDataWindow()) { params["dayAverage"] = daysSinceInstalled().toString() @@ -141,10 +142,7 @@ class SearchAttributedMetric @Inject constructor( } private suspend fun getBucketValue(searches: Int): Int { - val buckets = when (daysSinceInstalled()) { - in 0..FIRST_MONTH_DAY_THRESHOLD -> bucketConfigFirstMonth.await().buckets - else -> bucketConfigPastWeek.await().buckets - } + val buckets = getBucketConfig().buckets return buckets.indexOfFirst { bucket -> searches <= bucket }.let { index -> if (index == -1) buckets.size else index } @@ -177,6 +175,11 @@ class SearchAttributedMetric @Inject constructor( 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 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 index a48b9d6c337e..ec482f74f24e 100644 --- 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 @@ -59,7 +59,7 @@ class SearchDaysAttributedMetric @Inject constructor( companion object { private const val EVENT_NAME = "ddg_search_days" - private const val PIXEL_NAME = "user_active_past_week" + private const val PIXEL_NAME = "attributed_metric_active_past_week" private const val DAYS_WINDOW = 7 private const val FEATURE_TOGGLE_NAME = "searchDaysAvg" private const val FEATURE_EMIT_TOGGLE_NAME = "canEmitSearchDaysAvg" @@ -124,6 +124,7 @@ class SearchDaysAttributedMetric @Inject constructor( val stats = attributedMetricClient.getEventStats(EVENT_NAME, DAYS_WINDOW) val params = mutableMapOf( "days" to getBucketValue(stats.daysWithEvents).toString(), + "version" to bucketConfiguration.await().version.toString(), ) if (!hasCompleteDataWindow) { params["daysSinceInstalled"] = daysSinceInstalled.toString() diff --git a/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/store/AttributedMetricsDateUtils.kt b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/store/AttributedMetricsDateUtils.kt index 8fdae5efbafd..f932f1816609 100644 --- a/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/store/AttributedMetricsDateUtils.kt +++ b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/store/AttributedMetricsDateUtils.kt @@ -97,6 +97,14 @@ interface AttributedMetricsDateUtils { * @return The calculated date as a string in "yyyy-MM-dd" format (in ET) */ fun getDateMinusDays(days: Int): String + + /** + * Converts a timestamp to a formatted date string in Eastern Time. + * + * @param timestamp The timestamp in milliseconds since epoch (Unix timestamp) + * @return The date as a string in "yyyy-MM-dd" format (in ET) + */ + fun getDateFromTimestamp(timestamp: Long): String } @ContributesBinding(AppScope::class) @@ -126,6 +134,12 @@ class RealAttributedMetricsDateUtils @Inject constructor() : AttributedMetricsDa override fun getDateMinusDays(days: Int): String = getCurrentZonedDateTime().minusDays(days.toLong()).format(DATE_FORMATTER) + override fun getDateFromTimestamp(timestamp: Long): String { + val instant = Instant.ofEpochMilli(timestamp) + val zonedDateTime = instant.atZone(ET_ZONE) + return zonedDateTime.format(DATE_FORMATTER) + } + private fun getCurrentZonedDateTime(): ZonedDateTime = ZonedDateTime.now(ET_ZONE) companion object { diff --git a/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/store/EventDao.kt b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/store/EventDao.kt index a38349ba16e0..a6c6d940730a 100644 --- a/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/store/EventDao.kt +++ b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/store/EventDao.kt @@ -65,6 +65,6 @@ interface EventDao { day: String, ): Int? - @Query("DELETE FROM event_metrics WHERE day < :day") - suspend fun deleteEventsOlderThan(day: String) + @Query("delete from event_metrics") + suspend fun deleteAllEvents() } diff --git a/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/store/EventRepository.kt b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/store/EventRepository.kt index 7e41feeded50..53d0fa6cef5e 100644 --- a/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/store/EventRepository.kt +++ b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/store/EventRepository.kt @@ -32,7 +32,7 @@ interface EventRepository { days: Int, ): EventStats - suspend fun deleteOldEvents(olderThanDays: Int) + suspend fun deleteAllEvents() } @ContributesBinding(AppScope::class) @@ -70,10 +70,9 @@ class RealEventRepository @Inject constructor( ) } - override suspend fun deleteOldEvents(olderThanDays: Int) { + override suspend fun deleteAllEvents() { coroutineScope.launch { - val cutoffDay = attributedMetricsDateUtils.getDateMinusDays(olderThanDays) - eventDao.deleteEventsOlderThan(cutoffDay) + eventDao.deleteAllEvents() } } } diff --git a/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/FakeAttributedMetricsDateUtils.kt b/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/FakeAttributedMetricsDateUtils.kt index 70993726dfce..66c9025ae75d 100644 --- a/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/FakeAttributedMetricsDateUtils.kt +++ b/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/FakeAttributedMetricsDateUtils.kt @@ -19,6 +19,7 @@ package com.duckduckgo.app.attributed.metrics import com.duckduckgo.app.attributed.metrics.store.AttributedMetricsDateUtils import java.time.Instant import java.time.LocalDate +import java.time.ZoneId import java.time.format.DateTimeFormatter import java.time.temporal.ChronoUnit @@ -38,9 +39,16 @@ class FakeAttributedMetricsDateUtils(var testDate: LocalDate) : AttributedMetric override fun getDateMinusDays(days: Int): String = getCurrentLocalDate().minusDays(days.toLong()).format(DATE_FORMATTER) + override fun getDateFromTimestamp(timestamp: Long): String { + val instant = Instant.ofEpochMilli(timestamp) + val zonedDateTime = instant.atZone(ET_ZONE) + return zonedDateTime.format(DATE_FORMATTER) + } + private fun getCurrentLocalDate(): LocalDate = testDate companion object { private val DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd") + private val ET_ZONE = ZoneId.of("America/New_York") } } diff --git a/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/impl/AttributeMetricsConfigTest.kt b/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/impl/AttributeMetricsConfigTest.kt index abd125f0c572..ad84a6ade2a6 100644 --- 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 @@ -90,11 +90,11 @@ class AttributeMetricsConfigTest { fun whenFeatureDisabledThenReturnEmptyBucketConfig() = runTest { val settings = """ { - "user_active_past_week": { + "attributed_metric_active_past_week": { "buckets": [2, 4], "version": 0 }, - "user_average_searches_past_week": { + "attributed_metric_average_searches_past_week": { "buckets": [5, 9], "version": 1 } @@ -129,11 +129,11 @@ class AttributeMetricsConfigTest { fun whenFeatureEnabledAndValidSettingsThenReturnBucketConfig() = runTest { val settings = """ { - "user_active_past_week": { + "attributed_metric_active_past_week": { "buckets": [2, 4], "version": 0 }, - "user_average_searches_past_week": { + "attributed_metric_average_searches_past_week": { "buckets": [5, 9], "version": 1 } @@ -150,11 +150,11 @@ class AttributeMetricsConfigTest { assertEquals( mapOf( - "user_active_past_week" to MetricBucket( + "attributed_metric_active_past_week" to MetricBucket( buckets = listOf(2, 4), version = 0, ), - "user_average_searches_past_week" to MetricBucket( + "attributed_metric_average_searches_past_week" to MetricBucket( buckets = listOf(5, 9), version = 1, ), diff --git a/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/impl/RealAttributedMetricClientTest.kt b/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/impl/RealAttributedMetricClientTest.kt index 38de8388597d..0e572ad4809c 100644 --- a/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/impl/RealAttributedMetricClientTest.kt +++ b/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/impl/RealAttributedMetricClientTest.kt @@ -19,9 +19,12 @@ package com.duckduckgo.app.attributed.metrics.impl import androidx.test.ext.junit.runners.AndroidJUnit4 import com.duckduckgo.app.attributed.metrics.api.AttributedMetric import com.duckduckgo.app.attributed.metrics.api.EventStats +import com.duckduckgo.app.attributed.metrics.store.AttributedMetricsDateUtils import com.duckduckgo.app.attributed.metrics.store.EventRepository import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Unique +import com.duckduckgo.browser.api.install.AppInstall +import com.duckduckgo.browser.api.referrer.AppReferrer import com.duckduckgo.common.test.CoroutineTestRule import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals @@ -45,6 +48,9 @@ class RealAttributedMetricClientTest { private val mockEventRepository: EventRepository = mock() private val mockPixel: Pixel = mock() private val mockMetricsState: AttributedMetricsState = mock() + private val appReferrer: AppReferrer = mock() + private val appInstall: AppInstall = mock() + private val dateUtils: AttributedMetricsDateUtils = mock() private lateinit var testee: RealAttributedMetricClient @@ -56,6 +62,9 @@ class RealAttributedMetricClientTest { eventRepository = mockEventRepository, pixel = mockPixel, metricsState = mockMetricsState, + appReferrer = appReferrer, + dateUtils = dateUtils, + appInstall = appInstall, ) } @@ -100,14 +109,36 @@ class RealAttributedMetricClientTest { } @Test - fun whenEmitMetricAndClientActiveMetricIsEmitted() = runTest { + fun whenEmitMetricAndClientActiveWithOriginThenMetricIsEmittedWithOrigin() = runTest { val testMetric = TestAttributedMetric() whenever(mockMetricsState.isActive()).thenReturn(true) whenever(mockMetricsState.canEmitMetrics()).thenReturn(true) + whenever(appReferrer.getOriginAttributeCampaign()).thenReturn("campaign_origin") testee.emitMetric(testMetric) - verify(mockPixel).fire(pixelName = "test_pixel", parameters = mapOf("param" to "value"), type = Unique("test_pixel_test_tag")) + verify(mockPixel).fire( + pixelName = "test_pixel", + parameters = mapOf("param" to "value", "origin" to "campaign_origin"), + type = Unique("test_pixel_test_tag"), + ) + } + + @Test + fun whenEmitMetricAndClientActiveWithoutOriginThenMetricIsEmittedWithInstallDate() = runTest { + val testMetric = TestAttributedMetric() + whenever(mockMetricsState.isActive()).thenReturn(true) + whenever(mockMetricsState.canEmitMetrics()).thenReturn(true) + whenever(appReferrer.getOriginAttributeCampaign()).thenReturn(null) + whenever(dateUtils.getDateFromTimestamp(any())).thenReturn("2025-01-01") + + testee.emitMetric(testMetric) + + verify(mockPixel).fire( + pixelName = "test_pixel", + parameters = mapOf("param" to "value", "install_date" to "2025-01-01"), + type = Unique("test_pixel_test_tag"), + ) } @Test @@ -118,7 +149,11 @@ class RealAttributedMetricClientTest { testee.emitMetric(testMetric) - verify(mockPixel, never()).fire(pixelName = "test_pixel", parameters = mapOf("param" to "value"), type = Unique("test_pixel_test_tag")) + verify(mockPixel, never()).fire( + pixelName = "test_pixel", + parameters = mapOf("param" to "value"), + type = Unique("test_pixel_test_tag"), + ) } @Test diff --git a/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/impl/RealAttributedMetricsStateTest.kt b/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/impl/RealAttributedMetricsStateTest.kt index b3b02a911f24..23218e808282 100644 --- a/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/impl/RealAttributedMetricsStateTest.kt +++ b/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/impl/RealAttributedMetricsStateTest.kt @@ -23,6 +23,7 @@ import com.duckduckgo.app.attributed.metrics.AttributedMetricsConfigFeature import com.duckduckgo.app.attributed.metrics.FakeAttributedMetricsDateUtils import com.duckduckgo.app.attributed.metrics.store.AttributedMetricsDataStore import com.duckduckgo.app.attributed.metrics.store.AttributedMetricsDateUtils +import com.duckduckgo.app.attributed.metrics.store.EventRepository import com.duckduckgo.appbuildconfig.api.AppBuildConfig import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory @@ -51,6 +52,7 @@ class RealAttributedMetricsStateTest { private val mockConfigFeature: AttributedMetricsConfigFeature = FakeFeatureToggleFactory.create(AttributedMetricsConfigFeature::class.java) private val mockAppBuildConfig: AppBuildConfig = mock() private val mockLifecycleOwner: LifecycleOwner = mock() + private val mockEventRepository: EventRepository = mock() private lateinit var testDateUtils: AttributedMetricsDateUtils private lateinit var testee: RealAttributedMetricsState @@ -64,6 +66,7 @@ class RealAttributedMetricsStateTest { attributedMetricsConfigFeature = mockConfigFeature, appBuildConfig = mockAppBuildConfig, attributedMetricsDateUtils = testDateUtils, + eventRepository = mockEventRepository, ) } @@ -146,6 +149,7 @@ class RealAttributedMetricsStateTest { testee.onCreate(mockLifecycleOwner) verify(mockDataStore, never()).setActive(any()) + verify(mockEventRepository, never()).deleteAllEvents() } @Test fun whenCheckCollectionPeriodAndWithinPeriodAndActiveThenKeepActive() = runTest { @@ -155,6 +159,7 @@ class RealAttributedMetricsStateTest { testee.onCreate(mockLifecycleOwner) verify(mockDataStore).setActive(true) + verify(mockEventRepository, never()).deleteAllEvents() } @Test fun whenCheckCollectionPeriodAndWithinPeriodAndNotActiveThenKeepInactive() = runTest { @@ -163,16 +168,18 @@ class RealAttributedMetricsStateTest { testee.onCreate(mockLifecycleOwner) - verify(mockDataStore).setActive(false) + verify(mockDataStore, never()).setActive(any()) + verify(mockEventRepository, never()).deleteAllEvents() } - @Test fun whenCheckCollectionPeriodAndOutsidePeriodThenSetInactive() = runTest { + @Test fun whenCheckCollectionPeriodAndOutsidePeriodThenSetInactiveAndDeleteAllData() = runTest { whenever(mockDataStore.getInitializationDate()).thenReturn(testDateUtils.getDateMinusDays(169)) // 6months + 1 whenever(mockDataStore.isActive()).thenReturn(true) testee.onCreate(mockLifecycleOwner) verify(mockDataStore).setActive(false) + verify(mockEventRepository).deleteAllEvents() } private fun givenAttributedClientFeatureEnabled(isEnabled: Boolean) { diff --git a/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/retention/RetentionMonthAttributedMetricTest.kt b/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/retention/RetentionMonthAttributedMetricTest.kt index 0898e26f5668..4e9c7997a97d 100644 --- 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 @@ -60,6 +60,14 @@ class RetentionMonthAttributedMetricTest { 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, @@ -72,7 +80,7 @@ class RetentionMonthAttributedMetricTest { @Test fun whenPixelNameRequestedThenReturnCorrectName() { - assertEquals("user_retention_month", testee.getPixelName()) + assertEquals("attributed_metric_retention_month", testee.getPixelName()) } @Test @@ -138,14 +146,6 @@ class RetentionMonthAttributedMetricTest { @Test fun whenDaysInstalledThenReturnCorrectPeriod() = runTest { - whenever(attributedMetricConfig.getBucketConfiguration()).thenReturn( - mapOf( - "user_retention_month" to MetricBucket( - buckets = listOf(2, 3, 4, 5), - version = 0, - ), - ), - ) // Map of days installed to expected period number val periodRanges = mapOf( 10 to -1, // Day 10 -> month 1, not captured by this metric @@ -164,17 +164,17 @@ class RetentionMonthAttributedMetricTest { periodRanges.forEach { (days, expectedPeriod) -> givenDaysSinceInstalled(days) - val params = testee.getMetricParameters() + val params = testee.getMetricParameters()["count"] - val expectedParams = if (expectedPeriod > -1) { - mapOf("count" to expectedPeriod.toString()) + val expectedCount = if (expectedPeriod > -1) { + expectedPeriod.toString() } else { - emptyMap() + null } assertEquals( "For $days days installed, should return period $expectedPeriod", - expectedParams, + expectedCount, params, ) } @@ -182,15 +182,6 @@ class RetentionMonthAttributedMetricTest { @Test fun whenDaysInstalledThenReturnCorrectTag() = runTest { - whenever(attributedMetricConfig.getBucketConfiguration()).thenReturn( - mapOf( - "user_retention_month" to MetricBucket( - buckets = listOf(2, 3, 4, 5), - version = 0, - ), - ), - ) - // Test different days and expected period numbers val testCases = mapOf( 10 to "-1", // Day 10 -> month 1, not captured by this metric @@ -219,6 +210,15 @@ class RetentionMonthAttributedMetricTest { } } + @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 index 8ad61dbfd0f4..c6c002b130ab 100644 --- 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 @@ -60,6 +60,14 @@ class RetentionWeekAttributedMetricTest { 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, @@ -72,7 +80,7 @@ class RetentionWeekAttributedMetricTest { @Test fun whenPixelNameRequestedThenReturnCorrectName() { - assertEquals("user_retention_week", testee.getPixelName()) + assertEquals("attributed_metric_retention_week", testee.getPixelName()) } @Test @@ -138,14 +146,6 @@ class RetentionWeekAttributedMetricTest { @Test fun whenDaysInstalledThenReturnCorrectPeriod() = runTest { - whenever(attributedMetricConfig.getBucketConfiguration()).thenReturn( - mapOf( - "user_retention_week" to MetricBucket( - buckets = listOf(1, 2, 3, 4), - version = 0, - ), - ), - ) // Map of days installed to expected period number val periodRanges = mapOf( 0 to -1, // Day 0 -> not captured @@ -168,17 +168,17 @@ class RetentionWeekAttributedMetricTest { periodRanges.forEach { (days, expectedPeriod) -> givenDaysSinceInstalled(days) - val params = testee.getMetricParameters() + val params = testee.getMetricParameters()["count"] - val expectedParams = if (expectedPeriod > -1) { - mapOf("count" to expectedPeriod.toString()) + val expectedCount = if (expectedPeriod > -1) { + expectedPeriod.toString() } else { - emptyMap() + null } assertEquals( "For $days days installed, should return period $expectedPeriod", - expectedParams, + expectedCount, params, ) } @@ -186,15 +186,6 @@ class RetentionWeekAttributedMetricTest { @Test fun whenDaysInstalledThenReturnCorrectTag() = runTest { - whenever(attributedMetricConfig.getBucketConfiguration()).thenReturn( - mapOf( - "user_retention_week" to MetricBucket( - buckets = listOf(1, 2, 3, 4), - version = 0, - ), - ), - ) - // Test different days and expected period numbers val testCases = mapOf( 0 to "-1", // Day 0 -> not captured @@ -227,6 +218,15 @@ class RetentionWeekAttributedMetricTest { } } + @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 index bd395a62161b..d04b9c963176 100644 --- 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 @@ -68,11 +68,11 @@ class SearchAttributedMetricTest { ) whenever(attributedMetricConfig.getBucketConfiguration()).thenReturn( mapOf( - "user_average_searches_past_week_first_month" to MetricBucket( + "attributed_metric_average_searches_past_week_first_month" to MetricBucket( buckets = listOf(5, 9), version = 0, ), - "user_average_searches_past_week" to MetricBucket( + "attributed_metric_average_searches_past_week" to MetricBucket( buckets = listOf(5, 9), version = 0, ), @@ -115,21 +115,21 @@ class SearchAttributedMetricTest { fun whenDaysSinceInstalledLessThan4WThenReturnFirstMonthPixelName() { givenDaysSinceInstalled(15) - assertEquals("user_average_searches_past_week_first_month", testee.getPixelName()) + assertEquals("attributed_metric_average_searches_past_week_first_month", testee.getPixelName()) } @Test fun whenDaysSinceInstalledMoreThan4WThenReturnRegularPixelName() { givenDaysSinceInstalled(45) - assertEquals("user_average_searches_past_week", testee.getPixelName()) + assertEquals("attributed_metric_average_searches_past_week", testee.getPixelName()) } @Test fun whenDaysSinceInstalledIsEndOf4WThenReturnFirstMonthPixelName() { givenDaysSinceInstalled(28) - assertEquals("user_average_searches_past_week_first_month", testee.getPixelName()) + assertEquals("attributed_metric_average_searches_past_week_first_month", testee.getPixelName()) } @Test @@ -220,11 +220,11 @@ class SearchAttributedMetricTest { fun given7dAverageThenReturnCorrectAverageBucketInParams() = runTest { whenever(attributedMetricConfig.getBucketConfiguration()).thenReturn( mapOf( - "user_average_searches_past_week_first_month" to MetricBucket( + "attributed_metric_average_searches_past_week_first_month" to MetricBucket( buckets = listOf(5, 9), version = 0, ), - "user_average_searches_past_week" to MetricBucket( + "attributed_metric_average_searches_past_week" to MetricBucket( buckets = listOf(5, 9), version = 0, ), @@ -255,12 +255,12 @@ class SearchAttributedMetricTest { ), ) - val params = testee.getMetricParameters() + val realBucket = testee.getMetricParameters()["count"] assertEquals( "For $avg searches, should return bucket $bucket", - mapOf("count" to bucket.toString()), - params, + bucket.toString(), + realBucket, ) } } @@ -332,6 +332,22 @@ class SearchAttributedMetricTest { 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 index 5dc821723950..fe7ebbaa3159 100644 --- 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 @@ -31,6 +31,7 @@ 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 @@ -66,7 +67,7 @@ class SearchDaysAttributedMetricTest { ) whenever(attributedMetricConfig.getBucketConfiguration()).thenReturn( mapOf( - "user_active_past_week" to MetricBucket( + "attributed_metric_active_past_week" to MetricBucket( buckets = listOf(2, 4), version = 0, ), @@ -119,7 +120,7 @@ class SearchDaysAttributedMetricTest { @Test fun whenPixelNameRequestedThenReturnCorrectName() { - assertEquals("user_active_past_week", testee.getPixelName()) + assertEquals("attributed_metric_active_past_week", testee.getPixelName()) } @Test @@ -232,12 +233,12 @@ class SearchDaysAttributedMetricTest { ), ) - val params = testee.getMetricParameters() + val realBucket = testee.getMetricParameters()["days"] assertEquals( "For $days days with events, should return bucket $bucket", - mapOf("days" to bucket.toString()), - params, + bucket.toString(), + realBucket, ) } } @@ -253,15 +254,9 @@ class SearchDaysAttributedMetricTest { ), ) - val params = testee.getMetricParameters() + val daysWindow = testee.getMetricParameters()["daysSinceInstalled"] - assertEquals( - mapOf( - "days" to "2", // 5 days >4 -> bucket 2 - "daysSinceInstalled" to "5", - ), - params, - ) + assertEquals("5", daysWindow) } @Test @@ -275,12 +270,25 @@ class SearchDaysAttributedMetricTest { ), ) - val params = testee.getMetricParameters() + val daysSince = testee.getMetricParameters()["daysSinceInstalled"] + + assertNull(daysSince) + } - assertEquals( - mapOf("days" to "2"), // 5 days >4 -> bucket 2 - params, + @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) { diff --git a/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/store/RealAttributedMetricsDateUtilsTest.kt b/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/store/RealAttributedMetricsDateUtilsTest.kt new file mode 100644 index 000000000000..b9834107eefb --- /dev/null +++ b/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/store/RealAttributedMetricsDateUtilsTest.kt @@ -0,0 +1,146 @@ +/* + * Copyright (c) 2025 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.attributed.metrics.store + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.time.LocalDate +import java.time.format.DateTimeFormatter + +@RunWith(AndroidJUnit4::class) +class RealAttributedMetricsDateUtilsTest { + + private lateinit var testee: RealAttributedMetricsDateUtils + + @Before + fun setup() { + testee = RealAttributedMetricsDateUtils() + } + + @Test + fun whenGetCurrentDateThenReturnsFormattedDateInET() { + val result = testee.getCurrentDate() + + // Verify it matches the expected format yyyy-MM-dd + val dateRegex = Regex("\\d{4}-\\d{2}-\\d{2}") + assertTrue("Date should match yyyy-MM-dd format", dateRegex.matches(result)) + + // Verify it's parseable + val parsedDate = LocalDate.parse(result, DateTimeFormatter.ofPattern("yyyy-MM-dd")) + assertTrue("Date should be parseable", parsedDate != null) + } + + @Test + fun whenGetDateFromTimestampThenReturnsDateInET() { + // 2024-01-01 00:00:00 UTC (which is 2023-12-31 19:00:00 EST) + val timestamp = 1704067200000L + + val result = testee.getDateFromTimestamp(timestamp) + + assertEquals("2023-12-31", result) + } + + @Test + fun whenGetDateFromTimestampForMiddayUTCThenReturnsCorrectDateInET() { + // 2024-06-15 12:00:00 UTC (which is 2024-06-15 08:00:00 EDT) + val timestamp = 1718452800000L + + val result = testee.getDateFromTimestamp(timestamp) + + assertEquals("2024-06-15", result) + } + + @Test + fun whenGetDateFromTimestampForMidnightETThenReturnsCorrectDate() { + // 2024-07-04 04:00:00 UTC (which is 2024-07-04 00:00:00 EDT) + val timestamp = 1720065600000L + + val result = testee.getDateFromTimestamp(timestamp) + + assertEquals("2024-07-04", result) + } + + @Test + fun whenGetDateMinusDaysThenReturnsDateInPast() { + val currentDate = testee.getCurrentDate() + val sevenDaysAgo = testee.getDateMinusDays(7) + + val current = LocalDate.parse(currentDate, DateTimeFormatter.ofPattern("yyyy-MM-dd")) + val past = LocalDate.parse(sevenDaysAgo, DateTimeFormatter.ofPattern("yyyy-MM-dd")) + + assertEquals(7, current.toEpochDay() - past.toEpochDay()) + } + + @Test + fun whenGetDateMinusDaysWithZeroThenReturnsCurrentDate() { + val currentDate = testee.getCurrentDate() + val result = testee.getDateMinusDays(0) + + assertEquals(currentDate, result) + } + + @Test + fun whenGetDateMinusDaysWithOneThenReturnsYesterday() { + val currentDate = testee.getCurrentDate() + val yesterday = testee.getDateMinusDays(1) + + val current = LocalDate.parse(currentDate, DateTimeFormatter.ofPattern("yyyy-MM-dd")) + val past = LocalDate.parse(yesterday, DateTimeFormatter.ofPattern("yyyy-MM-dd")) + + assertEquals(1, current.toEpochDay() - past.toEpochDay()) + } + + @Test + fun whenDaysSinceWithDateStringThenReturnsPositiveForPastDate() { + val sevenDaysAgo = testee.getDateMinusDays(7) + + val result = testee.daysSince(sevenDaysAgo) + + assertEquals(7, result) + } + + @Test + fun whenDaysSinceWithDateStringForTodayThenReturnsZero() { + val result = testee.daysSince(testee.getCurrentDate()) + + assertEquals(0, result) + } + + @Test + fun whenGetDateMinusDaysWithLargeNumberThenReturnsCorrectDate() { + val currentDate = testee.getCurrentDate() + val thirtyDaysAgo = testee.getDateMinusDays(30) + + val current = LocalDate.parse(currentDate, DateTimeFormatter.ofPattern("yyyy-MM-dd")) + val past = LocalDate.parse(thirtyDaysAgo, DateTimeFormatter.ofPattern("yyyy-MM-dd")) + + assertEquals(30, current.toEpochDay() - past.toEpochDay()) + } + + @Test + fun whenDaysSinceWithDateStringForPastMonthThenReturnsCorrectDays() { + val thirtyDaysAgo = testee.getDateMinusDays(30) + + val result = testee.daysSince(thirtyDaysAgo) + + assertEquals(30, result) + } +} diff --git a/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/store/RealEventRepositoryTest.kt b/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/store/RealEventRepositoryTest.kt index e0b02c1aec86..f755b1dac081 100644 --- a/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/store/RealEventRepositoryTest.kt +++ b/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/store/RealEventRepositoryTest.kt @@ -144,18 +144,16 @@ class RealEventRepositoryTest { } @Test - fun whenDeleteOldEventsThenRemoveOnlyOlderThanSpecified() = + fun whenDeleteAllEventsThenRemoveAllEvents() = runTest { // Setup data eventDao.insertEvent(EventEntity("test_event", count = 1, day = "2025-10-03")) eventDao.insertEvent(EventEntity("test_event", count = 1, day = "2025-10-02")) eventDao.insertEvent(EventEntity("test_event", count = 1, day = "2025-09-03")) - testDateProvider.testDate = LocalDate.of(2025, 10, 3) - repository.deleteOldEvents(olderThanDays = 5) + repository.deleteAllEvents() val remainingEvents = eventDao.getEventsByNameAndTimeframe("test_event", "2025-09-03", "2025-10-03") - assert(remainingEvents.size == 2) - assert(remainingEvents.none { it.day == "2025-09-03" }) + assert(remainingEvents.isEmpty()) } } diff --git a/attributed-metrics/attributed-metrics-internal/src/main/java/com/duckduckgo/app/attributed/metrics/internal/ui/AttributedMetricsDevSettingsActivity.kt b/attributed-metrics/attributed-metrics-internal/src/main/java/com/duckduckgo/app/attributed/metrics/internal/ui/AttributedMetricsDevSettingsActivity.kt index b33dafefcf31..1871d93e0e28 100644 --- a/attributed-metrics/attributed-metrics-internal/src/main/java/com/duckduckgo/app/attributed/metrics/internal/ui/AttributedMetricsDevSettingsActivity.kt +++ b/attributed-metrics/attributed-metrics-internal/src/main/java/com/duckduckgo/app/attributed/metrics/internal/ui/AttributedMetricsDevSettingsActivity.kt @@ -79,6 +79,9 @@ class AttributedMetricsDevSettingsActivity : DuckDuckGoActivity() { 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") @@ -105,6 +108,16 @@ class AttributedMetricsDevSettingsActivity : DuckDuckGoActivity() { Toast.makeText(this@AttributedMetricsDevSettingsActivity, "Test events added", Toast.LENGTH_SHORT).show() } } + + private fun addAiUsageTestEvents() { + lifecycleScope.launch { + repeat(10) { daysAgo -> + val date = dateUtils.getDateMinusDays(daysAgo) + eventDao.insertEvent(EventEntity(eventName = "submit_prompt", count = 1, day = date)) + } + Toast.makeText(this@AttributedMetricsDevSettingsActivity, "Test events added", Toast.LENGTH_SHORT).show() + } + } } data object MainAttributedMetricsSettings : GlobalActivityStarter.ActivityParams { diff --git a/attributed-metrics/attributed-metrics-internal/src/main/res/layout/activity_attributed_metrics_dev_settings.xml b/attributed-metrics/attributed-metrics-internal/src/main/res/layout/activity_attributed_metrics_dev_settings.xml index 2c042fb33e53..cf36130c02f0 100644 --- a/attributed-metrics/attributed-metrics-internal/src/main/res/layout/activity_attributed_metrics_dev_settings.xml +++ b/attributed-metrics/attributed-metrics-internal/src/main/res/layout/activity_attributed_metrics_dev_settings.xml @@ -50,6 +50,14 @@ app:primaryText="Add Ad clicks Events" app:secondaryText="Adds 1 ad click events for last 10days" /> + + { ReportMetric .fromValue(data?.optString("metricName")) - ?.let { reportMetric -> duckChatPixels.sendReportMetricPixel(reportMetric) } + ?.let { reportMetric -> + duckChatPixels.sendReportMetricPixel(reportMetric) + if (reportMetric == ReportMetric.USER_DID_SUBMIT_PROMPT || reportMetric == ReportMetric.USER_DID_SUBMIT_FIRST_PROMPT) { + duckAiMetricCollector.onMessageSent() + } + } null } diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/metric/DuckAiAttributedMetric.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/metric/DuckAiAttributedMetric.kt new file mode 100644 index 000000000000..0f2f2f67a997 --- /dev/null +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/metric/DuckAiAttributedMetric.kt @@ -0,0 +1,169 @@ +/* + * Copyright (c) 2025 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.duckchat.impl.metric + +import com.duckduckgo.app.attributed.metrics.api.AttributedMetric +import com.duckduckgo.app.attributed.metrics.api.AttributedMetricClient +import com.duckduckgo.app.attributed.metrics.api.AttributedMetricConfig +import com.duckduckgo.app.attributed.metrics.api.EventStats +import com.duckduckgo.app.attributed.metrics.api.MetricBucket +import com.duckduckgo.app.di.AppCoroutineScope +import com.duckduckgo.browser.api.install.AppInstall +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import com.squareup.anvil.annotations.ContributesMultibinding +import dagger.SingleInstanceIn +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart.LAZY +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import logcat.logcat +import java.time.Instant +import java.time.ZoneId +import java.time.temporal.ChronoUnit +import javax.inject.Inject +import kotlin.math.roundToInt + +interface DuckAiMetricCollector { + fun onMessageSent() +} + +@ContributesMultibinding(AppScope::class, AttributedMetric::class) +@ContributesBinding(AppScope::class, DuckAiMetricCollector::class) +@SingleInstanceIn(AppScope::class) +class DuckAiAttributedMetric @Inject constructor( + @AppCoroutineScope private val appCoroutineScope: CoroutineScope, + private val dispatcherProvider: DispatcherProvider, + private val attributedMetricClient: AttributedMetricClient, + private val appInstall: AppInstall, + private val attributedMetricConfig: AttributedMetricConfig, +) : AttributedMetric, DuckAiMetricCollector { + + companion object { + private const val EVENT_NAME = "submit_prompt" + private const val PIXEL_NAME = "attributed_metric_average_duck_ai_usage_past_week" + private const val FEATURE_TOGGLE_NAME = "aiUsageAvg" + private const val FEATURE_EMIT_TOGGLE_NAME = "canEmitAIUsageAvg" + private const val DAYS_WINDOW = 7 + } + + private val isEnabled: Deferred = appCoroutineScope.async(start = LAZY) { + getToggle(FEATURE_TOGGLE_NAME)?.isEnabled() ?: false + } + + private val canEmit: Deferred = appCoroutineScope.async(start = LAZY) { + getToggle(FEATURE_EMIT_TOGGLE_NAME)?.isEnabled() ?: false + } + + private val bucketConfig: Deferred = appCoroutineScope.async(start = LAZY) { + attributedMetricConfig.getBucketConfiguration()[PIXEL_NAME] ?: MetricBucket( + buckets = listOf(5, 9), + version = 0, + ) + } + + override fun getPixelName(): String = PIXEL_NAME + + override suspend fun getMetricParameters(): Map { + val stats = getEventStats() + val params = mutableMapOf( + "count" to getBucketValue(stats.rollingAverage.roundToInt()).toString(), + "version" to bucketConfig.await().version.toString(), + ) + if (!hasCompleteDataWindow()) { + params["dayAverage"] = daysSinceInstalled().toString() + } + return params + } + + override suspend fun getTag(): String { + return daysSinceInstalled().toString() + } + + override fun onMessageSent() { + appCoroutineScope.launch(dispatcherProvider.io()) { + if (!isEnabled.await()) return@launch + attributedMetricClient.collectEvent(EVENT_NAME) + if (shouldSendPixel().not()) { + logcat(tag = "AttributedMetrics") { + "DuckAiUsage: Skip emitting, not enough data or no events" + } + return@launch + } + + if (canEmit.await()) { + attributedMetricClient.emitMetric(this@DuckAiAttributedMetric) + } + } + } + + private suspend fun getBucketValue(avg: Int): Int { + val buckets = bucketConfig.await().buckets + return buckets.indexOfFirst { bucket -> avg <= bucket }.let { index -> + if (index == -1) buckets.size else index + } + } + + private suspend fun shouldSendPixel(): Boolean { + if (daysSinceInstalled() <= 0) { + // installation day, we don't emit + return false + } + + val eventStats = getEventStats() + if (eventStats.daysWithEvents == 0 || eventStats.rollingAverage == 0.0) { + // no events, nothing to emit + return false + } + + return true + } + + private fun hasCompleteDataWindow(): Boolean { + val daysSinceInstalled = daysSinceInstalled() + return daysSinceInstalled >= DAYS_WINDOW + } + + private suspend fun getEventStats(): EventStats { + val daysSinceInstall = daysSinceInstalled() + val stats = if (daysSinceInstall >= DAYS_WINDOW) { + attributedMetricClient.getEventStats(EVENT_NAME, DAYS_WINDOW) + } else { + attributedMetricClient.getEventStats(EVENT_NAME, daysSinceInstall) + } + + return stats + } + + private fun daysSinceInstalled(): Int { + val etZone = ZoneId.of("America/New_York") + val installInstant = Instant.ofEpochMilli(appInstall.getInstallationTimestamp()) + val nowInstant = Instant.now() + + val installInEt = installInstant.atZone(etZone) + val nowInEt = nowInstant.atZone(etZone) + + return ChronoUnit.DAYS.between(installInEt.toLocalDate(), nowInEt.toLocalDate()).toInt() + } + + private suspend fun getToggle(toggleName: String) = + attributedMetricConfig.metricsToggles().firstOrNull { toggle -> + toggle.featureName().name == toggleName + } +} diff --git a/duckchat/duckchat-impl/src/test/java/com/duckduckgo/duckchat/impl/metric/DuckAiAttributedMetricTest.kt b/duckchat/duckchat-impl/src/test/java/com/duckduckgo/duckchat/impl/metric/DuckAiAttributedMetricTest.kt new file mode 100644 index 000000000000..7c937567d199 --- /dev/null +++ b/duckchat/duckchat-impl/src/test/java/com/duckduckgo/duckchat/impl/metric/DuckAiAttributedMetricTest.kt @@ -0,0 +1,290 @@ +/* + * Copyright (c) 2025 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.duckchat.impl.metric + +import android.annotation.SuppressLint +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.duckduckgo.app.attributed.metrics.api.AttributedMetricClient +import com.duckduckgo.app.attributed.metrics.api.AttributedMetricConfig +import com.duckduckgo.app.attributed.metrics.api.EventStats +import com.duckduckgo.app.attributed.metrics.api.MetricBucket +import com.duckduckgo.browser.api.install.AppInstall +import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory +import com.duckduckgo.feature.toggles.api.Toggle +import com.duckduckgo.feature.toggles.api.Toggle.DefaultFeatureValue +import com.duckduckgo.feature.toggles.api.Toggle.State +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import java.time.Instant +import java.time.ZoneId + +@SuppressLint("DenyListedApi") +@RunWith(AndroidJUnit4::class) +class DuckAiAttributedMetricTest { + + @get:Rule + val coroutineRule = CoroutineTestRule() + + private val attributedMetricClient: AttributedMetricClient = mock() + private val appInstall: AppInstall = mock() + private val attributedMetricConfig: AttributedMetricConfig = mock() + private val aiToggle = FakeFeatureToggleFactory.create(FakeDuckAiMetricsConfigFeature::class.java) + + private lateinit var testee: DuckAiAttributedMetric + + @Before + fun setup() = runTest { + aiToggle.aiUsageAvg().setRawStoredState(State(true)) + aiToggle.canEmitAIUsageAvg().setRawStoredState(State(true)) + whenever(attributedMetricConfig.metricsToggles()).thenReturn( + listOf(aiToggle.aiUsageAvg(), aiToggle.canEmitAIUsageAvg()), + ) + whenever(attributedMetricConfig.getBucketConfiguration()).thenReturn( + mapOf( + "attributed_metric_average_duck_ai_usage_past_week" to MetricBucket( + buckets = listOf(5, 9), + version = 0, + ), + ), + ) + + testee = DuckAiAttributedMetric( + appCoroutineScope = coroutineRule.testScope, + dispatcherProvider = coroutineRule.testDispatcherProvider, + attributedMetricClient = attributedMetricClient, + appInstall = appInstall, + attributedMetricConfig = attributedMetricConfig, + ) + } + + @Test + fun whenPixelNameRequestedThenReturnCorrectName() { + assertEquals("attributed_metric_average_duck_ai_usage_past_week", testee.getPixelName()) + } + + @Test + fun whenMessageSentButFFDisabledThenDoNotCollectMetric() = runTest { + aiToggle.aiUsageAvg().setRawStoredState(State(false)) + whenever(attributedMetricConfig.metricsToggles()).thenReturn( + listOf(aiToggle.aiUsageAvg(), aiToggle.canEmitAIUsageAvg()), + ) + givenDaysSinceInstalled(7) + + testee.onMessageSent() + + verify(attributedMetricClient, never()).collectEvent("submit_prompt") + } + + @Test + fun whenMessageSentAndFFEnabledThenCollectMetric() = runTest { + givenDaysSinceInstalled(7) + + testee.onMessageSent() + + verify(attributedMetricClient).collectEvent("submit_prompt") + } + + @Test + fun whenMessageSentAndInstallationDayThenDoNotEmitMetric() = runTest { + givenDaysSinceInstalled(0) + + testee.onMessageSent() + + verify(attributedMetricClient).collectEvent("submit_prompt") + verify(attributedMetricClient, never()).emitMetric(testee) + } + + @Test + fun whenMessageSentAndNoEventsThenDoNotEmitMetric() = runTest { + givenDaysSinceInstalled(3) + whenever(attributedMetricClient.getEventStats(any(), any())).thenReturn( + EventStats( + totalEvents = 0, + daysWithEvents = 0, + rollingAverage = 0.0, + ), + ) + + testee.onMessageSent() + + verify(attributedMetricClient).collectEvent("submit_prompt") + verify(attributedMetricClient, never()).emitMetric(testee) + } + + @Test + fun whenMessageSentAndHasEventsThenEmitMetric() = runTest { + givenDaysSinceInstalled(3) + whenever(attributedMetricClient.getEventStats(any(), any())).thenReturn( + EventStats( + totalEvents = 16, + daysWithEvents = 3, + rollingAverage = 5.3, + ), + ) + + testee.onMessageSent() + + verify(attributedMetricClient).collectEvent("submit_prompt") + verify(attributedMetricClient).emitMetric(testee) + } + + @Test + fun whenMessageSentButEmitDisabledThenCollectButDoNotEmitMetric() = runTest { + aiToggle.aiUsageAvg().setRawStoredState(State(true)) + aiToggle.canEmitAIUsageAvg().setRawStoredState(State(false)) + whenever(attributedMetricConfig.metricsToggles()).thenReturn( + listOf(aiToggle.aiUsageAvg(), aiToggle.canEmitAIUsageAvg()), + ) + givenDaysSinceInstalled(7) + whenever(attributedMetricClient.getEventStats(any(), any())).thenReturn( + EventStats( + totalEvents = 16, + daysWithEvents = 3, + rollingAverage = 5.3, + ), + ) + + testee.onMessageSent() + + verify(attributedMetricClient).collectEvent("submit_prompt") + verify(attributedMetricClient, never()).emitMetric(testee) + } + + @Test + fun whenGetMetricParametersThenReturnCorrectBucketValue() = runTest { + givenDaysSinceInstalled(7) + + // Map of average usage to expected bucket + val usageAvgExpectedBuckets = mapOf( + 2.2 to 0, // ≤5 -> bucket 0 + 4.4 to 0, // ≤5 -> bucket 0 + 5.0 to 0, // ≤5 -> bucket 0 + 5.1 to 0, // rounds to 5, ≤5 -> bucket 0 + 5.8 to 1, // rounds to 6, >5 and ≤9 -> bucket 1 + 6.6 to 1, // >5 and ≤9 -> bucket 1 + 9.0 to 1, // >5 and ≤9 -> bucket 1 + 9.3 to 1, // rounds to 9, >5 and ≤9 -> bucket 1 + 10.0 to 2, // >9 -> bucket 2 + 14.1 to 2, // >9 -> bucket 2 + ) + + usageAvgExpectedBuckets.forEach { (avg, bucket) -> + whenever(attributedMetricClient.getEventStats(any(), any())).thenReturn( + EventStats( + totalEvents = 16, + daysWithEvents = 3, + rollingAverage = avg, + ), + ) + + val realBucket = testee.getMetricParameters()["count"] + + assertEquals( + "For $avg average usage, should return bucket $bucket", + bucket.toString(), + realBucket, + ) + } + } + + @Test + fun whenDaysSinceInstalledLessThan7ThenIncludeDayAverage() = runTest { + givenDaysSinceInstalled(5) + whenever(attributedMetricClient.getEventStats(any(), any())).thenReturn( + EventStats( + totalEvents = 16, + daysWithEvents = 3, + rollingAverage = 5.3, + ), + ) + + val dayAverage = testee.getMetricParameters()["dayAverage"] + + assertEquals("5", dayAverage) + } + + @Test + fun whenDaysSinceInstalledIs7ThenDoNotIncludeDayAverage() = runTest { + givenDaysSinceInstalled(7) + whenever(attributedMetricClient.getEventStats(any(), any())).thenReturn( + EventStats( + totalEvents = 16, + daysWithEvents = 3, + rollingAverage = 5.3, + ), + ) + + val dayAverage = testee.getMetricParameters()["dayAverage"] + + assertNull(dayAverage) + } + + @Test + fun whenGetTagThenReturnDaysSinceInstalled() = runTest { + givenDaysSinceInstalled(7) + + val tag = testee.getTag() + + assertEquals("7", tag) + } + + @Test + fun whenGetMetricParametersThenReturnVersion() = runTest { + givenDaysSinceInstalled(7) + whenever(attributedMetricClient.getEventStats(any(), any())).thenReturn( + EventStats( + totalEvents = 16, + daysWithEvents = 3, + rollingAverage = 5.3, + ), + ) + + val version = testee.getMetricParameters()["version"] + + assertEquals("0", version) + } + + private fun givenDaysSinceInstalled(days: Int) { + val etZone = ZoneId.of("America/New_York") + val now = Instant.now() + val nowInEt = now.atZone(etZone) + val installInEt = nowInEt.minusDays(days.toLong()) + whenever(appInstall.getInstallationTimestamp()).thenReturn(installInEt.toInstant().toEpochMilli()) + } +} + +interface FakeDuckAiMetricsConfigFeature { + @Toggle.DefaultValue(DefaultFeatureValue.INTERNAL) + fun self(): Toggle + + @Toggle.DefaultValue(DefaultFeatureValue.INTERNAL) + fun aiUsageAvg(): Toggle + + @Toggle.DefaultValue(DefaultFeatureValue.INTERNAL) + fun canEmitAIUsageAvg(): Toggle +} diff --git a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/helper/RealDuckChatJSHelperTest.kt b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/helper/RealDuckChatJSHelperTest.kt index 997ee636b674..a743b227e226 100644 --- a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/helper/RealDuckChatJSHelperTest.kt +++ b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/helper/RealDuckChatJSHelperTest.kt @@ -26,6 +26,7 @@ import com.duckduckgo.duckchat.impl.ReportMetric.USER_DID_SELECT_FIRST_HISTORY_I import com.duckduckgo.duckchat.impl.ReportMetric.USER_DID_SUBMIT_FIRST_PROMPT import com.duckduckgo.duckchat.impl.ReportMetric.USER_DID_SUBMIT_PROMPT import com.duckduckgo.duckchat.impl.ReportMetric.USER_DID_TAP_KEYBOARD_RETURN_KEY +import com.duckduckgo.duckchat.impl.metric.DuckAiMetricCollector import com.duckduckgo.duckchat.impl.pixel.DuckChatPixels import com.duckduckgo.duckchat.impl.store.DuckChatDataStore import com.duckduckgo.js.messaging.api.JsCallbackData @@ -50,11 +51,13 @@ class RealDuckChatJSHelperTest { private val mockDuckChat: DuckChatInternal = mock() private val mockDataStore: DuckChatDataStore = mock() private val mockDuckChatPixels: DuckChatPixels = mock() + private val mockDuckAiMetricCollector: DuckAiMetricCollector = mock() private val testee = RealDuckChatJSHelper( duckChat = mockDuckChat, dataStore = mockDataStore, duckChatPixels = mockDuckChatPixels, + duckAiMetricCollector = mockDuckAiMetricCollector, ) @Test @@ -384,7 +387,7 @@ class RealDuckChatJSHelperTest { } @Test - fun whenReportMetricWithDataThenPixelSent() = runTest { + fun whenReportMetricWithDataThenPixelSentAndCollectMetric() = runTest { val featureName = "aiChat" val method = "reportMetric" val id = "123" @@ -393,10 +396,11 @@ class RealDuckChatJSHelperTest { assertNull(testee.processJsCallbackMessage(featureName, method, id, data)) verify(mockDuckChatPixels).sendReportMetricPixel(USER_DID_SUBMIT_PROMPT) + verify(mockDuckAiMetricCollector).onMessageSent() } @Test - fun whenReportMetricWithFirstPromptThenPixelSent() = runTest { + fun whenReportMetricWithFirstPromptThenPixelSentAndCollectMetric() = runTest { val featureName = "aiChat" val method = "reportMetric" val id = "123" @@ -405,6 +409,7 @@ class RealDuckChatJSHelperTest { assertNull(testee.processJsCallbackMessage(featureName, method, id, data)) verify(mockDuckChatPixels).sendReportMetricPixel(USER_DID_SUBMIT_FIRST_PROMPT) + verify(mockDuckAiMetricCollector).onMessageSent() } @Test diff --git a/subscriptions/subscriptions-impl/build.gradle b/subscriptions/subscriptions-impl/build.gradle index a0097a84a628..588c4e2e183a 100644 --- a/subscriptions/subscriptions-impl/build.gradle +++ b/subscriptions/subscriptions-impl/build.gradle @@ -62,6 +62,7 @@ dependencies { implementation project(':content-scope-scripts-api') implementation project(':duckchat-api') implementation project(':pir-api') + implementation project(':attributed-metrics-api') implementation AndroidX.appCompat implementation KotlinX.coroutines.core diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsManager.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsManager.kt index 88b170cf7443..ed2a52f628a8 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsManager.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsManager.kt @@ -577,6 +577,7 @@ class RealSubscriptionsManager @Inject constructor( authRepository.setAccount(null) authRepository.setSubscription(null) authRepository.setEntitlements(emptyList()) + authRepository.removeLocalPurchasedAt() _isSignedIn.emit(false) _subscriptionStatus.emit(UNKNOWN) _entitlements.emit(emptyList()) @@ -659,6 +660,7 @@ class RealSubscriptionsManager @Inject constructor( pixelSender.reportSubscriptionActivated() emitEntitlementsValues() _currentPurchaseState.emit(CurrentPurchase.Success) + authRepository.registerLocalPurchasedAt() subscriptionPurchaseWideEvent.onPurchaseConfirmationSuccess() } else { handlePurchaseFailed() diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/metrics/SubscriptionStatusAttributedMetric.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/metrics/SubscriptionStatusAttributedMetric.kt new file mode 100644 index 000000000000..e291c53e5103 --- /dev/null +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/metrics/SubscriptionStatusAttributedMetric.kt @@ -0,0 +1,176 @@ +/* + * Copyright (c) 2025 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.subscriptions.impl.metrics + +import androidx.lifecycle.LifecycleOwner +import com.duckduckgo.app.attributed.metrics.api.AttributedMetric +import com.duckduckgo.app.attributed.metrics.api.AttributedMetricClient +import com.duckduckgo.app.attributed.metrics.api.AttributedMetricConfig +import com.duckduckgo.app.attributed.metrics.api.MetricBucket +import com.duckduckgo.app.di.AppCoroutineScope +import com.duckduckgo.app.lifecycle.MainProcessLifecycleObserver +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.subscriptions.api.SubscriptionStatus +import com.duckduckgo.subscriptions.impl.SubscriptionsManager +import com.duckduckgo.subscriptions.impl.repository.AuthRepository +import com.squareup.anvil.annotations.ContributesMultibinding +import dagger.SingleInstanceIn +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart.LAZY +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch +import logcat.logcat +import java.time.Instant +import java.time.ZoneId +import java.time.temporal.ChronoUnit +import javax.inject.Inject + +@ContributesMultibinding(AppScope::class, AttributedMetric::class) +@ContributesMultibinding(AppScope::class, MainProcessLifecycleObserver::class) +@SingleInstanceIn(AppScope::class) +class SubscriptionStatusAttributedMetric @Inject constructor( + @AppCoroutineScope private val appCoroutineScope: CoroutineScope, + private val dispatcherProvider: DispatcherProvider, + private val attributedMetricClient: AttributedMetricClient, + private val authRepository: AuthRepository, + private val attributedMetricConfig: AttributedMetricConfig, + private val subscriptionsManager: SubscriptionsManager, +) : AttributedMetric, MainProcessLifecycleObserver { + + companion object { + private const val PIXEL_NAME = "attributed_metric_subscribed" + private const val FEATURE_TOGGLE_NAME = "subscriptionRetention" + private const val FEATURE_EMIT_TOGGLE_NAME = "canEmitSubscriptionRetention" + } + + private val isEnabled: Deferred = appCoroutineScope.async(start = LAZY) { + getToggle(FEATURE_TOGGLE_NAME)?.isEnabled() ?: false + } + + private val canEmit: Deferred = appCoroutineScope.async(start = LAZY) { + getToggle(FEATURE_EMIT_TOGGLE_NAME)?.isEnabled() ?: false + } + + private val bucketConfig: Deferred = appCoroutineScope.async(start = LAZY) { + attributedMetricConfig.getBucketConfiguration()[PIXEL_NAME] ?: MetricBucket( + buckets = listOf(0, 1), + version = 0, + ) + } + + override fun onCreate(owner: LifecycleOwner) { + appCoroutineScope.launch(dispatcherProvider.io()) { + if (!isEnabled.await() || !canEmit.await()) { + logcat(tag = "AttributedMetrics") { + "SubscriptionStatusAttributedMetric disabled" + } + return@launch + } + subscriptionsManager.subscriptionStatus.distinctUntilChanged().collect { status -> + logcat(tag = "AttributedMetrics") { + "SubscriptionStatusAttributedMetric subscription status changed: $status" + } + if (shouldSendPixel()) { + logcat(tag = "AttributedMetrics") { + "SubscriptionStatusAttributedMetric emitting metric on status change" + } + attributedMetricClient.emitMetric( + this@SubscriptionStatusAttributedMetric, + ) + } + } + } + } + + override fun getPixelName(): String = PIXEL_NAME + + override suspend fun getMetricParameters(): Map { + val daysSinceSubscribed = daysSinceSubscribed() + if (daysSinceSubscribed == -1) { + return emptyMap() // Should not happen as we check enrollment before sending the pixel + } + val isOnTrial = authRepository.isFreeTrialActive() + val params = mutableMapOf( + "month" to getBucketValue(daysSinceSubscribed, isOnTrial).toString(), + "version" to bucketConfig.await().version.toString(), + ) + return params + } + + override suspend fun getTag(): String { + val daysSinceSubscribed = daysSinceSubscribed() + val isOnTrial = authRepository.isFreeTrialActive() + return getBucketValue(daysSinceSubscribed, isOnTrial).toString() + } + + private suspend fun shouldSendPixel(): Boolean { + val isActive = isSubscriptionActive() + logcat(tag = "AttributedMetrics") { + "SubscriptionStatusAttributedMetric shouldSendPixel isActive: $isActive" + } + val enrolled = daysSinceSubscribed() != -1 + logcat(tag = "AttributedMetrics") { + "SubscriptionStatusAttributedMetric shouldSendPixel enrolled: $enrolled daysSinceSubscribed() = ${daysSinceSubscribed()}" + } + return isActive && enrolled + } + + private suspend fun isSubscriptionActive(): Boolean { + return authRepository.getStatus() == SubscriptionStatus.AUTO_RENEWABLE || + authRepository.getStatus() == SubscriptionStatus.NOT_AUTO_RENEWABLE + } + + private suspend fun daysSinceSubscribed(): Int { + return authRepository.getLocalPurchasedAt()?.let { nonNullStartedAt -> + val etZone = ZoneId.of("America/New_York") + val installInstant = Instant.ofEpochMilli(nonNullStartedAt) + val nowInstant = Instant.now() + + val installInEt = installInstant.atZone(etZone) + val nowInEt = nowInstant.atZone(etZone) + + return ChronoUnit.DAYS.between(installInEt.toLocalDate(), nowInEt.toLocalDate()).toInt() + } ?: -1 + } + + private suspend fun getBucketValue( + days: Int, + isOnTrial: Boolean, + ): Int { + if (isOnTrial) { + return 0 + } + + // Calculate which month the user is in (1-based) + // Each 28 days is a new month + val monthNumber = days / 28 + 1 + + // Get the bucket configuration + val buckets = bucketConfig.await().buckets + return buckets.indexOfFirst { bucket -> monthNumber <= bucket }.let { index -> + if (index == -1) buckets.size else index + } + } + + private suspend fun getToggle(toggleName: String) = + attributedMetricConfig.metricsToggles().firstOrNull { toggle -> + toggle.featureName().name == toggleName + } +} diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/repository/AuthRepository.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/repository/AuthRepository.kt index 95859425b98f..2dc22db7424c 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/repository/AuthRepository.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/repository/AuthRepository.kt @@ -64,6 +64,10 @@ interface AuthRepository { suspend fun canSupportEncryption(): Boolean suspend fun setFeatures(basePlanId: String, features: Set) suspend fun getFeatures(basePlanId: String): Set + suspend fun isFreeTrialActive(): Boolean + suspend fun registerLocalPurchasedAt() + suspend fun getLocalPurchasedAt(): Long? + suspend fun removeLocalPurchasedAt() } @Module @@ -233,6 +237,22 @@ internal class RealAuthRepository constructor( val accessToken = subscriptionsDataStore.run { accessTokenV2 ?: accessToken } serpPromo.injectCookie(accessToken) } + + override suspend fun isFreeTrialActive(): Boolean { + return subscriptionsDataStore.freeTrialActive + } + + override suspend fun registerLocalPurchasedAt() { + subscriptionsDataStore.localPurchasedAt = System.currentTimeMillis() + } + + override suspend fun getLocalPurchasedAt(): Long? { + return subscriptionsDataStore.localPurchasedAt + } + + override suspend fun removeLocalPurchasedAt() { + subscriptionsDataStore.localPurchasedAt = null + } } data class AccessToken( diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/store/SubscriptionsDataStore.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/store/SubscriptionsDataStore.kt index 02e1c5dd8d46..2cd9a370e3af 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/store/SubscriptionsDataStore.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/store/SubscriptionsDataStore.kt @@ -38,6 +38,9 @@ interface SubscriptionsDataStore { var expiresOrRenewsAt: Long? var billingPeriod: String? var startedAt: Long? + + // Local purchased at time, not from server or other devices + var localPurchasedAt: Long? var platform: String? var status: String? var entitlements: String? @@ -197,6 +200,18 @@ internal class SubscriptionsEncryptedDataStore( } } + override var localPurchasedAt: Long? + get() = encryptedPreferences?.getLong(KEY_LOCAL_PURCHASED_AT, 0L).takeIf { it != 0L } + set(value) { + encryptedPreferences?.edit(commit = true) { + if (value == null) { + remove(KEY_LOCAL_PURCHASED_AT) + } else { + putLong(KEY_LOCAL_PURCHASED_AT, value) + } + } + } + override var billingPeriod: String? get() = encryptedPreferences?.getString(KEY_BILLING_PERIOD, null) set(value) { @@ -231,6 +246,7 @@ internal class SubscriptionsEncryptedDataStore( const val KEY_EXTERNAL_ID = "KEY_EXTERNAL_ID" const val KEY_EXPIRES_OR_RENEWS_AT = "KEY_EXPIRES_OR_RENEWS_AT" const val KEY_STARTED_AT = "KEY_STARTED_AT" + const val KEY_LOCAL_PURCHASED_AT = "KEY_LOCAL_PURCHASED_AT" const val KEY_BILLING_PERIOD = "KEY_BILLING_PERIOD" const val KEY_ENTITLEMENTS = "KEY_ENTITLEMENTS" const val KEY_STATUS = "KEY_STATUS" diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/metrics/SubscriptionStatusAttributedMetricTest.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/metrics/SubscriptionStatusAttributedMetricTest.kt new file mode 100644 index 000000000000..b506844048d0 --- /dev/null +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/metrics/SubscriptionStatusAttributedMetricTest.kt @@ -0,0 +1,287 @@ +/* + * Copyright (c) 2025 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.subscriptions.impl.metrics + +import android.annotation.SuppressLint +import androidx.lifecycle.LifecycleOwner +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.duckduckgo.app.attributed.metrics.api.AttributedMetricClient +import com.duckduckgo.app.attributed.metrics.api.AttributedMetricConfig +import com.duckduckgo.app.attributed.metrics.api.MetricBucket +import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory +import com.duckduckgo.feature.toggles.api.Toggle +import com.duckduckgo.feature.toggles.api.Toggle.DefaultFeatureValue +import com.duckduckgo.feature.toggles.api.Toggle.State +import com.duckduckgo.subscriptions.api.SubscriptionStatus +import com.duckduckgo.subscriptions.impl.SubscriptionsManager +import com.duckduckgo.subscriptions.impl.repository.AuthRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import java.time.Instant +import java.time.ZoneId + +@SuppressLint("DenyListedApi") +@RunWith(AndroidJUnit4::class) +class SubscriptionStatusAttributedMetricTest { + + @get:Rule + val coroutineRule = CoroutineTestRule() + + private val attributedMetricClient: AttributedMetricClient = mock() + private val authRepository: AuthRepository = mock() + private val attributedMetricConfig: AttributedMetricConfig = mock() + private val subscriptionsManager: SubscriptionsManager = mock() + private val subscriptionToggle = FakeFeatureToggleFactory.create( + FakeSubscriptionMetricsConfigFeature::class.java, + ) + private val lifecycleOwner: LifecycleOwner = mock() + private val subscriptionStatusFlow = MutableStateFlow(SubscriptionStatus.UNKNOWN) + + private lateinit var testee: SubscriptionStatusAttributedMetric + + @Before + fun setup() = runTest { + givenFFStatus(metricEnabled = true, canEmitMetric = true) + whenever(attributedMetricConfig.getBucketConfiguration()).thenReturn( + mapOf( + "attributed_metric_subscribed" to MetricBucket( + buckets = listOf(0, 1), + version = 0, + ), + ), + ) + whenever(subscriptionsManager.subscriptionStatus).thenReturn(subscriptionStatusFlow) + + testee = SubscriptionStatusAttributedMetric( + appCoroutineScope = coroutineRule.testScope, + dispatcherProvider = coroutineRule.testDispatcherProvider, + attributedMetricClient = attributedMetricClient, + authRepository = authRepository, + attributedMetricConfig = attributedMetricConfig, + subscriptionsManager = subscriptionsManager, + ) + } + + @Test + fun whenPixelNameRequestedThenReturnCorrectName() { + assertEquals("attributed_metric_subscribed", testee.getPixelName()) + } + + @Test + fun whenOnCreateAndFFDisabledThenDoNotEmitMetric() = runTest { + givenFFStatus(metricEnabled = false) + givenDaysSinceSubscribed(0) + + testee.onCreate(lifecycleOwner) + subscriptionStatusFlow.emit(SubscriptionStatus.AUTO_RENEWABLE) + + verify(attributedMetricClient, never()).emitMetric(testee) + } + + @Test + fun whenOnCreateAndEmitDisabledThenDoNotEmitMetric() = runTest { + givenFFStatus(metricEnabled = true, canEmitMetric = false) + givenDaysSinceSubscribed(0) + + testee.onCreate(lifecycleOwner) + subscriptionStatusFlow.emit(SubscriptionStatus.AUTO_RENEWABLE) + + verify(attributedMetricClient, never()).emitMetric(testee) + } + + @Test + fun whenOnCreateAndSubscriptionNotActiveThenDoNotEmitMetric() = runTest { + whenever(authRepository.getStatus()).thenReturn(SubscriptionStatus.INACTIVE) + + testee.onCreate(lifecycleOwner) + subscriptionStatusFlow.emit(SubscriptionStatus.INACTIVE) + + verify(attributedMetricClient, never()).emitMetric(testee) + } + + @Test + fun whenOnCreateAndSubscriptionAutoRenewableThenEmitMetric() = runTest { + testee.onCreate(lifecycleOwner) + + givenDaysSinceSubscribed(0) + whenever(authRepository.getStatus()).thenReturn(SubscriptionStatus.AUTO_RENEWABLE) + subscriptionStatusFlow.emit(SubscriptionStatus.AUTO_RENEWABLE) + + verify(attributedMetricClient).emitMetric(testee) + } + + @Test + fun whenOnCreateAndNonAutoRenewableSubscriptionThenEmitMetric() = runTest { + testee.onCreate(lifecycleOwner) + + givenDaysSinceSubscribed(0) + whenever(authRepository.getStatus()).thenReturn(SubscriptionStatus.AUTO_RENEWABLE) + subscriptionStatusFlow.emit(SubscriptionStatus.NOT_AUTO_RENEWABLE) + + verify(attributedMetricClient).emitMetric(testee) + } + + @Test + fun whenOnCreateAndNotEnrolledThenDoNotEmitMetric() = runTest { + whenever(authRepository.getStatus()).thenReturn(SubscriptionStatus.AUTO_RENEWABLE) + whenever(authRepository.getLocalPurchasedAt()).thenReturn(null) + + testee.onCreate(lifecycleOwner) + subscriptionStatusFlow.emit(SubscriptionStatus.AUTO_RENEWABLE) + + verify(attributedMetricClient, never()).emitMetric(testee) + } + + @Test + fun whenGetMetricParametersAndOnTrialThenReturnBucketZero() = runTest { + givenDaysSinceSubscribed(7) + whenever(authRepository.isFreeTrialActive()).thenReturn(true) + + val monthBucket = testee.getMetricParameters()["month"] + + assertEquals("0", monthBucket) + } + + @Test + fun whenGetMetricParametersThenReturnVersion() = runTest { + givenDaysSinceSubscribed(7) + whenever(authRepository.isFreeTrialActive()).thenReturn(false) + + val version = testee.getMetricParameters()["version"] + + assertEquals("0", version) + } + + @Test + fun whenGetMetricParametersThenReturnCorrectBucketValue() = runTest { + whenever(authRepository.isFreeTrialActive()).thenReturn(false) + + // Map of days subscribed to expected bucket + val daysSubscribedExpectedBuckets = mapOf( + 0 to "1", // 0-27 days -> month 1 -> bucket 1 + 13 to "1", // middle of month 1 -> bucket 1 + 27 to "1", // end of month 1 -> bucket 1 + 28 to "2", // 28-55 days -> month 2 -> bucket 2 + 41 to "2", // middle of month 2 -> bucket 2 + 55 to "2", // end of month 2 -> bucket 2 + 56 to "2", // 56-83 days -> month 3 -> bucket 2 + 69 to "2", // middle of month 3 -> bucket 2 + 83 to "2", // end of month 3 -> bucket 2 + 84 to "2", // 84-111 days -> month 4 -> bucket 2 + 97 to "2", // middle of month 4 -> bucket 2 + 111 to "2", // end of month 4 -> bucket 2 + ) + + daysSubscribedExpectedBuckets.forEach { (days, expectedBucket) -> + givenDaysSinceSubscribed(days) + + val realMonthBucket = testee.getMetricParameters()["month"] + + assertEquals( + "For $days days subscribed, should return bucket $expectedBucket", + expectedBucket, + realMonthBucket, + ) + } + } + + @Test + fun whenGetTagAndOnTrialThenReturnBucketZero() = runTest { + givenDaysSinceSubscribed(7) + whenever(authRepository.isFreeTrialActive()).thenReturn(true) + + val tag = testee.getTag() + + assertEquals("0", tag) + } + + @Test + fun whenGetTagThenReturnCorrectBucketValue() = runTest { + whenever(authRepository.isFreeTrialActive()).thenReturn(false) + + // Map of days subscribed to expected bucket + val daysSubscribedExpectedBuckets = mapOf( + 0 to "1", // 0-27 days -> month 1 -> bucket 1 + 13 to "1", // middle of month 1 -> bucket 1 + 27 to "1", // end of month 1 -> bucket 1 + 28 to "2", // 28-55 days -> month 2 -> bucket 2 + 41 to "2", // middle of month 2 -> bucket 2 + 55 to "2", // end of month 2 -> bucket 2 + 56 to "2", // 56-83 days -> month 3 -> bucket 2 + 69 to "2", // middle of month 3 -> bucket 2 + 83 to "2", // end of month 3 -> bucket 2 + 84 to "2", // 84-111 days -> month 4 -> bucket 2 + 97 to "2", // middle of month 4 -> bucket 2 + 111 to "2", // end of month 4 -> bucket 2 + ) + + daysSubscribedExpectedBuckets.forEach { (days, bucket) -> + givenDaysSinceSubscribed(days) + + val tag = testee.getTag() + + assertEquals( + "For $days days subscribed, should return bucket $bucket", + bucket, + tag, + ) + } + } + + private suspend fun givenDaysSinceSubscribed(days: Int) { + val etZone = ZoneId.of("America/New_York") + val now = Instant.now() + val nowInEt = now.atZone(etZone) + val purchasedAt = nowInEt.minusDays(days.toLong()) + whenever(authRepository.getLocalPurchasedAt()).thenReturn( + purchasedAt.toInstant().toEpochMilli(), + ) + whenever(authRepository.getStatus()).thenReturn(SubscriptionStatus.AUTO_RENEWABLE) + } + + private suspend fun givenFFStatus(metricEnabled: Boolean = true, canEmitMetric: Boolean = true) { + subscriptionToggle.subscriptionRetention().setRawStoredState(State(metricEnabled)) + subscriptionToggle.canEmitSubscriptionRetention().setRawStoredState(State(canEmitMetric)) + whenever(attributedMetricConfig.metricsToggles()).thenReturn( + listOf( + subscriptionToggle.subscriptionRetention(), + subscriptionToggle.canEmitSubscriptionRetention(), + ), + ) + } +} + +interface FakeSubscriptionMetricsConfigFeature { + @Toggle.DefaultValue(DefaultFeatureValue.INTERNAL) + fun self(): Toggle + + @Toggle.DefaultValue(DefaultFeatureValue.INTERNAL) + fun subscriptionRetention(): Toggle + + @Toggle.DefaultValue(DefaultFeatureValue.INTERNAL) + fun canEmitSubscriptionRetention(): Toggle +} diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/repository/FakeSubscriptionsDataStore.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/repository/FakeSubscriptionsDataStore.kt index ee811adcca6a..cdb42f147cd3 100644 --- a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/repository/FakeSubscriptionsDataStore.kt +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/repository/FakeSubscriptionsDataStore.kt @@ -45,6 +45,7 @@ class FakeSubscriptionsDataStore( override var platform: String? = null override var billingPeriod: String? = null override var startedAt: Long? = 0L + override var localPurchasedAt: Long? = 0L override var status: String? = null override var entitlements: String? = null override var productId: String? = null diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/repository/RealAuthRepositoryTest.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/repository/RealAuthRepositoryTest.kt index a6e12a711f50..75dca8b45c06 100644 --- a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/repository/RealAuthRepositoryTest.kt +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/repository/RealAuthRepositoryTest.kt @@ -179,4 +179,40 @@ class RealAuthRepositoryTest { assertEquals(emptySet(), result) } + + @Test + fun whenRegisterLocalPurchasedAtThenStoreTimestamp() = runTest { + authRepository.registerLocalPurchasedAt() + + assertNotNull(authStore.localPurchasedAt) + assertTrue(authStore.localPurchasedAt!! > 0) + } + + @Test + fun whenGetLocalPurchasedAtThenReturnStoredValue() = runTest { + val expectedTimestamp = 1699000000000L + authStore.localPurchasedAt = expectedTimestamp + + val result = authRepository.getLocalPurchasedAt() + + assertEquals(expectedTimestamp, result) + } + + @Test + fun whenGetLocalPurchasedAtAndNotSetThenReturnNull() = runTest { + authStore.localPurchasedAt = null + + val result = authRepository.getLocalPurchasedAt() + + assertNull(result) + } + + @Test + fun whenRemoveLocalPurchasedAtThenClearValue() = runTest { + authStore.localPurchasedAt = 1699000000000L + + authRepository.removeLocalPurchasedAt() + + assertNull(authStore.localPurchasedAt) + } } diff --git a/subscriptions/subscriptions-internal/src/main/java/com/duckduckgo/subscriptions/internal/settings/OverrideSubscriptionLocalPurchasedAtView.kt b/subscriptions/subscriptions-internal/src/main/java/com/duckduckgo/subscriptions/internal/settings/OverrideSubscriptionLocalPurchasedAtView.kt new file mode 100644 index 000000000000..def280fb4e53 --- /dev/null +++ b/subscriptions/subscriptions-internal/src/main/java/com/duckduckgo/subscriptions/internal/settings/OverrideSubscriptionLocalPurchasedAtView.kt @@ -0,0 +1,136 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.subscriptions.internal.settings + +import android.content.Context +import android.content.SharedPreferences +import android.util.AttributeSet +import android.view.View +import android.widget.FrameLayout +import android.widget.Toast +import androidx.core.content.edit +import androidx.lifecycle.LifecycleCoroutineScope +import androidx.lifecycle.findViewTreeLifecycleOwner +import androidx.lifecycle.lifecycleScope +import com.duckduckgo.anvil.annotations.InjectWith +import com.duckduckgo.app.di.AppCoroutineScope +import com.duckduckgo.common.ui.viewbinding.viewBinding +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.data.store.api.SharedPreferencesProvider +import com.duckduckgo.di.scopes.ActivityScope +import com.duckduckgo.di.scopes.ViewScope +import com.duckduckgo.subscriptions.internal.SubsSettingPlugin +import com.duckduckgo.subscriptions.internal.databinding.SubsOverrideLocalPurchasedAtViewBinding +import com.squareup.anvil.annotations.ContributesMultibinding +import dagger.android.support.AndroidSupportInjection +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone +import javax.inject.Inject + +@InjectWith(ViewScope::class) +class OverrideSubscriptionLocalPurchasedAtView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0, +) : FrameLayout(context, attrs, defStyle) { + + @Inject + lateinit var sharedPreferencesProvider: SharedPreferencesProvider + + @Inject + @AppCoroutineScope + lateinit var appCoroutineScope: CoroutineScope + + @Inject + lateinit var dispatcherProvider: DispatcherProvider + + private val binding: SubsOverrideLocalPurchasedAtViewBinding by viewBinding() + + private val localPurchaseAtStore by lazy { + LocalPurchaseAtStore(sharedPreferencesProvider) + } + + private val dateETFormat = SimpleDateFormat("yyyy-MM-dd", Locale.US).apply { + timeZone = TimeZone.getTimeZone("US/Eastern") + } + + private val viewCoroutineScope: LifecycleCoroutineScope? + get() = findViewTreeLifecycleOwner()?.lifecycleScope + + override fun onAttachedToWindow() { + AndroidSupportInjection.inject(this) + super.onAttachedToWindow() + + binding.also { base -> + viewCoroutineScope?.launch(dispatcherProvider.main()) { + val timestamp = localPurchaseAtStore.localPurchasedAt ?: return@launch + base.subscriptionEnroll.text = dateETFormat.format(Date(timestamp)) + } + + base.subscriptionEnrollSave.setOnClickListener { + val date = dateETFormat.parse(binding.subscriptionEnroll.text) + if (date != null) { + localPurchaseAtStore.localPurchasedAt = date.time + Toast.makeText(this.context, "Subscription date updated", Toast.LENGTH_SHORT).show() + } else { + Toast.makeText(this.context, "Invalid date format. Use yyyy-MM-dd", Toast.LENGTH_SHORT).show() + } + } + } + } +} + +@ContributesMultibinding(ActivityScope::class) +class OverrideSubscriptionLocalPurchasedAtViewPlugin @Inject constructor() : SubsSettingPlugin { + override fun getView(context: Context): View { + return OverrideSubscriptionLocalPurchasedAtView(context) + } +} + +/** + * Real SubscriptionsDataStore cannot be used directly without going through AuthRepository. + * This class is intended only for manual testing purposes. It allows overriding the local "purchased at" time. + */ +private class LocalPurchaseAtStore( + private val sharedPreferencesProvider: SharedPreferencesProvider, +) { + private val encryptedPreferences: SharedPreferences? by lazy { encryptedPreferences() } + private fun encryptedPreferences(): SharedPreferences? { + return sharedPreferencesProvider.getEncryptedSharedPreferences(FILENAME, multiprocess = true) + } + + var localPurchasedAt: Long? + get() = encryptedPreferences?.getLong(KEY_LOCAL_PURCHASED_AT, 0L).takeIf { it != 0L } + set(value) { + encryptedPreferences?.edit(commit = true) { + if (value == null) { + remove(KEY_LOCAL_PURCHASED_AT) + } else { + putLong(KEY_LOCAL_PURCHASED_AT, value) + } + } + } + + companion object { + const val FILENAME = "com.duckduckgo.subscriptions.store" + const val KEY_LOCAL_PURCHASED_AT = "KEY_LOCAL_PURCHASED_AT" + } +} diff --git a/subscriptions/subscriptions-internal/src/main/res/layout/subs_override_local_purchased_at_view.xml b/subscriptions/subscriptions-internal/src/main/res/layout/subs_override_local_purchased_at_view.xml new file mode 100644 index 000000000000..7bb48a90e774 --- /dev/null +++ b/subscriptions/subscriptions-internal/src/main/res/layout/subs_override_local_purchased_at_view.xml @@ -0,0 +1,41 @@ + + + + + + + + + + \ No newline at end of file diff --git a/sync/sync-impl/build.gradle b/sync/sync-impl/build.gradle index cc1d0d55fa2c..48475310ca4f 100644 --- a/sync/sync-impl/build.gradle +++ b/sync/sync-impl/build.gradle @@ -45,6 +45,7 @@ dependencies { implementation project(':remote-messaging-api') implementation project(path: ':autofill-api') implementation project(path: ':settings-api') // temporary until we release new settings + implementation project(path: ':attributed-metrics-api') implementation project(path: ':app-build-config-api') implementation project(path: ':privacy-config-api') diff --git a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/SyncAccountRepository.kt b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/SyncAccountRepository.kt index aca82a96d691..b5ad3dedf5d3 100644 --- a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/SyncAccountRepository.kt +++ b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/SyncAccountRepository.kt @@ -41,6 +41,7 @@ import com.duckduckgo.sync.impl.SyncAuthCode.Connect import com.duckduckgo.sync.impl.SyncAuthCode.Exchange import com.duckduckgo.sync.impl.SyncAuthCode.Recovery import com.duckduckgo.sync.impl.SyncAuthCode.Unknown +import com.duckduckgo.sync.impl.metrics.ConnectedDevicesObserver import com.duckduckgo.sync.impl.pixels.* import com.duckduckgo.sync.impl.ui.qrcode.SyncBarcodeUrl import com.duckduckgo.sync.impl.ui.qrcode.SyncBarcodeUrlWrapper @@ -98,6 +99,7 @@ interface SyncAccountRepository { @SingleInstanceIn(AppScope::class) @WorkerThread class AppSyncAccountRepository @Inject constructor( + private val connectedDevicesObserver: ConnectedDevicesObserver, private val syncDeviceIds: SyncDeviceIds, private val nativeLib: SyncLib, private val syncApi: SyncApi, @@ -632,11 +634,12 @@ class AppSyncAccountRepository @Inject constructor( } }.sortedWith { a, b -> if (a.thisDevice) -1 else 1 - }.also { + }.also { devices -> connectedDevicesCached.apply { clear() - addAll(it) + addAll(devices) } + connectedDevicesObserver.onDevicesUpdated(devices) }, ) } diff --git a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/metrics/SyncConnectedDevicesObserver.kt b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/metrics/SyncConnectedDevicesObserver.kt new file mode 100644 index 000000000000..cd1cd1d30c53 --- /dev/null +++ b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/metrics/SyncConnectedDevicesObserver.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2025 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.sync.impl.metrics + +import com.duckduckgo.app.di.AppCoroutineScope +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.sync.impl.ConnectedDevice +import com.squareup.anvil.annotations.ContributesBinding +import dagger.SingleInstanceIn +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +interface ConnectedDevicesObserver { + fun onDevicesUpdated(devices: List) + fun observeConnectedDevicesCount(): StateFlow +} + +@ContributesBinding(AppScope::class) +@SingleInstanceIn(AppScope::class) +class SyncConnectedDevicesObserver @Inject constructor( + @AppCoroutineScope private val appCoroutineScope: CoroutineScope, +) : ConnectedDevicesObserver { + + private val _connectedDevicesCount = MutableStateFlow(0) + override fun observeConnectedDevicesCount(): StateFlow = _connectedDevicesCount.asStateFlow() + + override fun onDevicesUpdated(devices: List) { + appCoroutineScope.launch { + _connectedDevicesCount.emit(devices.size) + } + } +} diff --git a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/metrics/SyncDevicesAttributeMetric.kt b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/metrics/SyncDevicesAttributeMetric.kt new file mode 100644 index 000000000000..1a00a4cc2580 --- /dev/null +++ b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/metrics/SyncDevicesAttributeMetric.kt @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2025 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.sync.impl.metrics + +import androidx.lifecycle.LifecycleOwner +import com.duckduckgo.app.attributed.metrics.api.AttributedMetric +import com.duckduckgo.app.attributed.metrics.api.AttributedMetricClient +import com.duckduckgo.app.attributed.metrics.api.AttributedMetricConfig +import com.duckduckgo.app.attributed.metrics.api.MetricBucket +import com.duckduckgo.app.di.AppCoroutineScope +import com.duckduckgo.app.lifecycle.MainProcessLifecycleObserver +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesMultibinding +import dagger.SingleInstanceIn +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart.LAZY +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import javax.inject.Inject + +@ContributesMultibinding(AppScope::class, AttributedMetric::class) +@ContributesMultibinding(AppScope::class, MainProcessLifecycleObserver::class) +@SingleInstanceIn(AppScope::class) +class SyncDevicesAttributeMetric @Inject constructor( + @AppCoroutineScope private val appCoroutineScope: CoroutineScope, + private val dispatcherProvider: DispatcherProvider, + private val attributedMetricClient: AttributedMetricClient, + private val attributedMetricConfig: AttributedMetricConfig, + private val connectedDevicesObserver: ConnectedDevicesObserver, +) : AttributedMetric, MainProcessLifecycleObserver { + + companion object { + private const val PIXEL_NAME = "attributed_metric_synced_device" + private const val FEATURE_TOGGLE_NAME = "syncDevices" + } + + private val isEnabled: Deferred = appCoroutineScope.async(start = LAZY) { + getToggle(FEATURE_TOGGLE_NAME)?.isEnabled() ?: false + } + + private val bucketConfig: Deferred = appCoroutineScope.async(start = LAZY) { + attributedMetricConfig.getBucketConfiguration()[PIXEL_NAME] ?: MetricBucket( + buckets = listOf(1), + version = 0, + ) + } + + override fun onCreate(owner: LifecycleOwner) { + appCoroutineScope.launch(dispatcherProvider.io()) { + if (isEnabled.await()) { + connectedDevicesObserver.observeConnectedDevicesCount().collect { deviceCount -> + if (deviceCount > 0) { + attributedMetricClient.emitMetric(this@SyncDevicesAttributeMetric) + } + } + } + } + } + + override fun getPixelName(): String = PIXEL_NAME + + override suspend fun getMetricParameters(): Map { + val connectedDevices = connectedDevicesObserver.observeConnectedDevicesCount().value + val params = mutableMapOf( + "device_count" to getBucketValue(connectedDevices).toString(), + "version" to bucketConfig.await().version.toString(), + ) + return params + } + + override suspend fun getTag(): String { + val connectedDevices = connectedDevicesObserver.observeConnectedDevicesCount().value + return getBucketValue(connectedDevices).toString() + } + + private suspend fun getBucketValue(number: Int): Int { + val buckets = bucketConfig.await().buckets + return buckets.indexOfFirst { bucket -> number <= bucket }.let { index -> + if (index == -1) buckets.size else index + } + } + + private suspend fun getToggle(toggleName: String) = + attributedMetricConfig.metricsToggles().firstOrNull { toggle -> + toggle.featureName().name == toggleName + } +} diff --git a/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/AppSyncAccountRepositoryTest.kt b/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/AppSyncAccountRepositoryTest.kt index bc9ac6ba2766..9f587669d3a9 100644 --- a/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/AppSyncAccountRepositoryTest.kt +++ b/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/AppSyncAccountRepositoryTest.kt @@ -74,6 +74,7 @@ import com.duckduckgo.sync.impl.AccountErrorCodes.LOGIN_FAILED import com.duckduckgo.sync.impl.Result.Error import com.duckduckgo.sync.impl.Result.Success import com.duckduckgo.sync.impl.SyncAccountRepository.AuthCode +import com.duckduckgo.sync.impl.metrics.ConnectedDevicesObserver import com.duckduckgo.sync.impl.pixels.SyncPixels import com.duckduckgo.sync.impl.ui.qrcode.SyncBarcodeUrl import com.duckduckgo.sync.impl.ui.qrcode.SyncBarcodeUrlWrapper @@ -108,6 +109,7 @@ class AppSyncAccountRepositoryTest { private var syncEngine: SyncEngine = mock() private var syncPixels: SyncPixels = mock() private val deviceKeyGenerator: DeviceKeyGenerator = mock() + private val connectedDevicesObserver: ConnectedDevicesObserver = mock() private val moshi = Moshi.Builder().build() private val invitationCodeWrapperAdapter = moshi.adapter(InvitationCodeWrapper::class.java) private val invitedDeviceDetailsAdapter = moshi.adapter(InvitedDeviceDetails::class.java) @@ -123,6 +125,7 @@ class AppSyncAccountRepositoryTest { @Before fun before() { syncRepo = AppSyncAccountRepository( + connectedDevicesObserver, syncDeviceIds, nativeLib, syncApi, @@ -587,6 +590,19 @@ class AppSyncAccountRepositoryTest { assertEquals(listOfConnectedDevices, result.data) } + @Test + fun getConnectedDevicesSucceedsThenNotifyDevicesObserver() { + whenever(syncStore.token).thenReturn(token) + whenever(syncStore.primaryKey).thenReturn(primaryKey) + whenever(syncStore.deviceId).thenReturn(deviceId) + prepareForEncryption() + whenever(syncApi.getDevices(anyString())).thenReturn(getDevicesSuccess) + + val result = syncRepo.getConnectedDevices() as Success + + verify(connectedDevicesObserver).onDevicesUpdated(any()) + } + @Test fun getConnectedDevicesReturnsListWithLocalDeviceInFirstPosition() { givenAuthenticatedDevice() diff --git a/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/metrics/SyncConnectedDevicesObserverTest.kt b/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/metrics/SyncConnectedDevicesObserverTest.kt new file mode 100644 index 000000000000..68cb0a9fefbc --- /dev/null +++ b/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/metrics/SyncConnectedDevicesObserverTest.kt @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2025 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.sync.impl.metrics + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import app.cash.turbine.test +import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.sync.TestSyncFixtures.connectedDevice +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@ExperimentalCoroutinesApi +@RunWith(AndroidJUnit4::class) +class SyncConnectedDevicesObserverTest { + + @get:Rule + val coroutineRule = CoroutineTestRule() + + private lateinit var observer: SyncConnectedDevicesObserver + + @Before + fun setup() { + observer = SyncConnectedDevicesObserver(coroutineRule.testScope) + } + + @Test + fun whenNoDevicesUpdatedThenEmitsZero() = runTest { + observer.observeConnectedDevicesCount().test { + assertEquals(0, awaitItem()) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun whenDevicesUpdatedThenEmitsCorrectCount() = runTest { + val devices = listOf( + connectedDevice.copy(deviceId = "device1", thisDevice = true), + connectedDevice.copy(deviceId = "device2"), + ) + + observer.observeConnectedDevicesCount().test { + assertEquals(0, awaitItem()) + observer.onDevicesUpdated(devices) + assertEquals(2, awaitItem()) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun whenDevicesUpdatedMultipleTimesThenEmitsLatestCount() = runTest { + val devices1 = listOf(connectedDevice) + + val devices2 = listOf( + connectedDevice.copy(deviceId = "device1", thisDevice = true), + connectedDevice.copy(deviceId = "device2"), + connectedDevice.copy(deviceId = "device3"), + ) + + observer.observeConnectedDevicesCount().test { + assertEquals(0, awaitItem()) + + observer.onDevicesUpdated(devices1) + assertEquals(1, awaitItem()) + + observer.onDevicesUpdated(devices2) + assertEquals(3, awaitItem()) + + cancelAndIgnoreRemainingEvents() + } + } +} diff --git a/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/metrics/SyncDevicesAttributeMetricTest.kt b/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/metrics/SyncDevicesAttributeMetricTest.kt new file mode 100644 index 000000000000..0238ffc312b1 --- /dev/null +++ b/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/metrics/SyncDevicesAttributeMetricTest.kt @@ -0,0 +1,177 @@ +/* + * Copyright (c) 2025 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.sync.impl.metrics + +import android.annotation.SuppressLint +import androidx.lifecycle.LifecycleOwner +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.duckduckgo.app.attributed.metrics.api.AttributedMetricClient +import com.duckduckgo.app.attributed.metrics.api.AttributedMetricConfig +import com.duckduckgo.app.attributed.metrics.api.MetricBucket +import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory +import com.duckduckgo.feature.toggles.api.Toggle +import com.duckduckgo.feature.toggles.api.Toggle.DefaultFeatureValue +import com.duckduckgo.feature.toggles.api.Toggle.State +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +@SuppressLint("DenyListedApi") +@RunWith(AndroidJUnit4::class) +class SyncDevicesAttributeMetricTest { + + @get:Rule + val coroutineRule = CoroutineTestRule() + + private val attributedMetricClient: AttributedMetricClient = mock() + private val attributedMetricConfig: AttributedMetricConfig = mock() + private val connectedDevicesObserver: ConnectedDevicesObserver = mock() + private val syncToggle = FakeFeatureToggleFactory.create(FakeSyncMetricsConfigFeature::class.java) + private val lifecycleOwner: LifecycleOwner = mock() + private val connectedDevicesFlow = MutableStateFlow(0) + + private lateinit var testee: SyncDevicesAttributeMetric + + @Before + fun setup() = runTest { + syncToggle.syncDevices().setRawStoredState(State(true)) + whenever(attributedMetricConfig.metricsToggles()).thenReturn(listOf(syncToggle.syncDevices())) + whenever(attributedMetricConfig.getBucketConfiguration()).thenReturn( + mapOf( + "attributed_metric_synced_device" to MetricBucket( + buckets = listOf(1), + version = 0, + ), + ), + ) + whenever(connectedDevicesObserver.observeConnectedDevicesCount()).thenReturn(connectedDevicesFlow) + + testee = SyncDevicesAttributeMetric( + appCoroutineScope = coroutineRule.testScope, + dispatcherProvider = coroutineRule.testDispatcherProvider, + attributedMetricClient = attributedMetricClient, + attributedMetricConfig = attributedMetricConfig, + connectedDevicesObserver = connectedDevicesObserver, + ) + } + + @Test + fun whenPixelNameRequestedThenReturnCorrectName() { + assertEquals("attributed_metric_synced_device", testee.getPixelName()) + } + + @Test + fun whenOnCreateAndFFDisabledThenDoNotEmitMetric() = runTest { + syncToggle.syncDevices().setRawStoredState(State(false)) + whenever(attributedMetricConfig.metricsToggles()).thenReturn(listOf(syncToggle.syncDevices())) + connectedDevicesFlow.emit(1) + + testee.onCreate(lifecycleOwner) + + verify(attributedMetricClient, never()).emitMetric(testee) + } + + @Test + fun whenOnCreateAndNoDevicesThenDoNotEmitMetric() = runTest { + connectedDevicesFlow.emit(0) + + testee.onCreate(lifecycleOwner) + + verify(attributedMetricClient, never()).emitMetric(testee) + } + + @Test + fun whenOnCreateAndHasDevicesThenEmitMetric() = runTest { + connectedDevicesFlow.emit(1) + + testee.onCreate(lifecycleOwner) + + verify(attributedMetricClient).emitMetric(testee) + } + + @Test + fun whenGetMetricParametersThenReturnCorrectBucketValue() = runTest { + // Map of device count to expected bucket + val deviceCountExpectedBuckets = mapOf( + 1 to 0, // 1 device -> bucket 0 + 2 to 1, // 2 devices -> bucket 1 + 3 to 1, // 3 devices -> bucket 1 + 5 to 1, // 5 devices -> bucket 1 + ) + + deviceCountExpectedBuckets.forEach { (devices, bucket) -> + connectedDevicesFlow.emit(devices) + + val realbucket = testee.getMetricParameters()["device_count"] + + assertEquals( + "For $devices devices, should return bucket $bucket", + bucket.toString(), + realbucket, + ) + } + } + + @Test + fun whenGetTagThenReturnCorrectBucketValue() = runTest { + // Map of device count to expected bucket + val deviceCountExpectedBuckets = mapOf( + 1 to "0", // 1 device -> bucket 0 + 2 to "1", // 2 devices -> bucket 1 + 3 to "1", // 3 devices -> bucket 1 + 5 to "1", // 5 devices -> bucket 1 + ) + + deviceCountExpectedBuckets.forEach { (devices, bucket) -> + connectedDevicesFlow.emit(devices) + + val tag = testee.getTag() + + assertEquals( + "For $devices devices, should return bucket $bucket", + bucket, + tag, + ) + } + } + + @Test + fun whenGetMetricParametersThenReturnVersion() = runTest { + connectedDevicesFlow.emit(1) + + val version = testee.getMetricParameters()["version"] + + assertEquals("0", version) + } +} + +interface FakeSyncMetricsConfigFeature { + @Toggle.DefaultValue(DefaultFeatureValue.INTERNAL) + fun self(): Toggle + + @Toggle.DefaultValue(DefaultFeatureValue.INTERNAL) + fun syncDevices(): Toggle +}