diff --git a/app/build.gradle b/app/build.gradle index f99de7ae9e30..db9b9d54c548 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -409,6 +409,9 @@ dependencies { implementation project(':survey-api') implementation project(':survey-impl') + implementation project(':attributed-metrics-api') + implementation project(':attributed-metrics-impl') + implementation project(':breakage-reporting-impl') implementation project(':dax-prompts-api') diff --git a/attributed-metrics/attributed-metrics-api/build.gradle b/attributed-metrics/attributed-metrics-api/build.gradle new file mode 100644 index 000000000000..d3a1defd058d --- /dev/null +++ b/attributed-metrics/attributed-metrics-api/build.gradle @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + id 'java-library' + id 'kotlin' +} + +apply from: "$rootProject.projectDir/code-formatting.gradle" + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +kotlin { + jvmToolchain(17) +} + +dependencies { + implementation Kotlin.stdlib.jdk7 + implementation KotlinX.coroutines.core +} diff --git a/attributed-metrics/attributed-metrics-api/src/main/java/com/duckduckgo/app/attributed/metrics/api/AttributedMetricsClient.kt b/attributed-metrics/attributed-metrics-api/src/main/java/com/duckduckgo/app/attributed/metrics/api/AttributedMetricsClient.kt new file mode 100644 index 000000000000..002485edcd99 --- /dev/null +++ b/attributed-metrics/attributed-metrics-api/src/main/java/com/duckduckgo/app/attributed/metrics/api/AttributedMetricsClient.kt @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2025 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.attributed.metrics.api + +/** + * Client for collecting and emitting attributed metrics. + */ +interface AttributedMetricClient { + /** + * Stores an event occurrence for later analysis. + * Does nothing if the client is not active. + * + * @param eventName Name of the event to collect + */ + fun collectEvent(eventName: String) + + /** + * Calculates statistics for a specific event over a time period. + * Returns zero stats if the client is not active. + * + * @param eventName Name of the event to analyze + * @param days Number of days to look back + * @return Statistics about the event's occurrences + */ + suspend fun getEventStats( + eventName: String, + days: Int, + ): EventStats + + /** + * Emits a metric with its parameters if the client is active. + * Does nothing if the client is not active. + * + * @param metric The metric to emit + */ + fun emitMetric(metric: AttributedMetric) +} + +/** + * Statistics about collected events over a time period. + * + * @property daysWithEvents Number of days that had at least one event + * @property rollingAverage Average number of events per day over the period + * @property totalEvents Total number of events in the period + */ +data class EventStats( + val daysWithEvents: Int, + val rollingAverage: Double, + val totalEvents: Int, +) + +/** + * Interface for defining an attributed metric. + * Each metric implementation should provide its name and parameters. + */ +interface AttributedMetric { + /** + * @return The name used to identify this metric + */ + fun getPixelName(): String + + /** + * @return Parameters to be included with this metric + */ + suspend fun getMetricParameters(): Map +} diff --git a/attributed-metrics/attributed-metrics-impl/build.gradle b/attributed-metrics/attributed-metrics-impl/build.gradle new file mode 100644 index 000000000000..8db72c5ab127 --- /dev/null +++ b/attributed-metrics/attributed-metrics-impl/build.gradle @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + id 'com.android.library' + id 'kotlin-android' + id 'com.google.devtools.ksp' + id 'com.squareup.anvil' +} + +apply from: "$rootProject.projectDir/gradle/android-library.gradle" + +dependencies { + anvil project(path: ':anvil-compiler') + + implementation project(path: ':anvil-annotations') + implementation project(path: ':attributed-metrics-api') + implementation project(path: ':common-utils') + implementation project(path: ':di') + implementation project(path: ':app-build-config-api') + implementation project(path: ':statistics-api') + + implementation KotlinX.coroutines.core + implementation KotlinX.coroutines.android + + implementation Google.dagger + + // DataStore + api AndroidX.dataStore.preferences + + // Room + implementation AndroidX.room.ktx + ksp AndroidX.room.compiler + + implementation "com.squareup.logcat:logcat:_" + + implementation AndroidX.core.ktx + + testImplementation Testing.junit4 + testImplementation "org.mockito.kotlin:mockito-kotlin:_" + testImplementation "androidx.lifecycle:lifecycle-runtime-testing:_" + testImplementation project(path: ':common-test') + testImplementation project(':data-store-test') + testImplementation project(':feature-toggles-test') + testImplementation CashApp.turbine + testImplementation Testing.robolectric + testImplementation(KotlinX.coroutines.test) { + // https://github.com/Kotlin/kotlinx.coroutines/issues/2023 + // conflicts with mockito due to direct inclusion of byte buddy + exclude group: "org.jetbrains.kotlinx", module: "kotlinx-coroutines-debug" + } + testImplementation AndroidX.test.ext.junit + testImplementation AndroidX.archCore.testing + testImplementation AndroidX.room.testing + testImplementation AndroidX.room.rxJava2 + + androidTestImplementation AndroidX.test.runner + androidTestImplementation AndroidX.test.rules + + coreLibraryDesugaring Android.tools.desugarJdkLibs +} + +android { + anvil { + generateDaggerFactories = true // default is false + } + lintOptions { + baseline file("lint-baseline.xml") + abortOnError = !project.hasProperty("abortOnError") || project.property("abortOnError") != "false" + } + namespace 'com.duckduckgo.app.attributed.metrics' + compileOptions { + coreLibraryDesugaringEnabled = true + } + buildFeatures { + buildConfig = true + } +} \ No newline at end of file diff --git a/attributed-metrics/attributed-metrics-impl/lint-baseline.xml b/attributed-metrics/attributed-metrics-impl/lint-baseline.xml new file mode 100644 index 000000000000..1526a743bda6 --- /dev/null +++ b/attributed-metrics/attributed-metrics-impl/lint-baseline.xml @@ -0,0 +1,4 @@ + + + + diff --git a/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/AttributedMetricsConfigFeature.kt b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/AttributedMetricsConfigFeature.kt new file mode 100644 index 000000000000..42306fa90d65 --- /dev/null +++ b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/AttributedMetricsConfigFeature.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2025 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.attributed.metrics + +import com.duckduckgo.anvil.annotations.ContributesRemoteFeature +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.feature.toggles.api.Toggle +import com.duckduckgo.feature.toggles.api.Toggle.DefaultFeatureValue + +@ContributesRemoteFeature( + scope = AppScope::class, + featureName = "attributedMetrics", +) +interface AttributedMetricsConfigFeature { + @Toggle.DefaultValue(DefaultFeatureValue.INTERNAL) + fun self(): Toggle +} diff --git a/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/di/AttributedMetricsDataStoreModule.kt b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/di/AttributedMetricsDataStoreModule.kt new file mode 100644 index 000000000000..f9acef38992c --- /dev/null +++ b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/di/AttributedMetricsDataStoreModule.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.attributed.metrics.di + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.preferencesDataStoreFile +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesTo +import dagger.Module +import dagger.Provides +import dagger.SingleInstanceIn +import javax.inject.Qualifier + +@Module +@ContributesTo(AppScope::class) +object AttributedMetricsDataStoreModule { + @Provides + @SingleInstanceIn(AppScope::class) + @AttributedMetrics + fun provideDataStore(context: Context): DataStore = + PreferenceDataStoreFactory.create( + produceFile = { context.preferencesDataStoreFile("attributed_metrics_v1") }, + ) +} + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class AttributedMetrics diff --git a/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/di/AttributedMetricsDatabaseModule.kt b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/di/AttributedMetricsDatabaseModule.kt new file mode 100644 index 000000000000..99002d5aac3c --- /dev/null +++ b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/di/AttributedMetricsDatabaseModule.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.attributed.metrics.di + +import android.content.Context +import androidx.room.Room +import com.duckduckgo.app.attributed.metrics.store.AttributedMetricsDatabase +import com.duckduckgo.app.attributed.metrics.store.EventDao +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesTo +import dagger.Module +import dagger.Provides +import dagger.SingleInstanceIn + +@Module +@ContributesTo(AppScope::class) +class AttributedMetricsDatabaseModule { + @Provides + @SingleInstanceIn(AppScope::class) + fun provideAttributedMetricsDatabase(context: Context): AttributedMetricsDatabase = + Room + .databaseBuilder( + context = context, + klass = AttributedMetricsDatabase::class.java, + name = "attributed_metrics.db", + ).fallbackToDestructiveMigration() + .build() + + @Provides + @SingleInstanceIn(AppScope::class) + fun provideEventDao(db: AttributedMetricsDatabase): EventDao = db.eventDao() +} diff --git a/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/impl/AttributedMetricsState.kt b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/impl/AttributedMetricsState.kt new file mode 100644 index 000000000000..dffeb53e00d5 --- /dev/null +++ b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/impl/AttributedMetricsState.kt @@ -0,0 +1,160 @@ +/* + * Copyright (c) 2025 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.attributed.metrics.impl + +import androidx.lifecycle.LifecycleOwner +import com.duckduckgo.app.attributed.metrics.AttributedMetricsConfigFeature +import com.duckduckgo.app.attributed.metrics.store.AttributedMetricsDataStore +import com.duckduckgo.app.attributed.metrics.store.AttributedMetricsDateUtils +import com.duckduckgo.app.di.AppCoroutineScope +import com.duckduckgo.app.lifecycle.MainProcessLifecycleObserver +import com.duckduckgo.app.statistics.api.AtbLifecyclePlugin +import com.duckduckgo.appbuildconfig.api.AppBuildConfig +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.privacy.config.api.PrivacyConfigCallbackPlugin +import com.squareup.anvil.annotations.ContributesBinding +import com.squareup.anvil.annotations.ContributesMultibinding +import dagger.SingleInstanceIn +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import logcat.logcat +import javax.inject.Inject + +/** + * Interface for checking if attributed metrics are active. + * The active state is determined by: + * 1. Having an initialization date + * 2. Being within the collection period (6 months = 24 weeks) + * 3. Being enabled in remote config + */ +interface AttributedMetricsState { + suspend fun isActive(): Boolean +} + +@ContributesBinding( + scope = AppScope::class, + boundType = AttributedMetricsState::class, +) +@ContributesMultibinding( + scope = AppScope::class, + boundType = MainProcessLifecycleObserver::class, +) +@ContributesMultibinding( + scope = AppScope::class, + boundType = AtbLifecyclePlugin::class, +) +@ContributesMultibinding( + scope = AppScope::class, + boundType = PrivacyConfigCallbackPlugin::class, +) +@SingleInstanceIn(AppScope::class) +class RealAttributedMetricsState @Inject constructor( + @AppCoroutineScope private val appCoroutineScope: CoroutineScope, + private val dispatcherProvider: DispatcherProvider, + private val dataStore: AttributedMetricsDataStore, + private val attributedMetricsConfigFeature: AttributedMetricsConfigFeature, + private val appBuildConfig: AppBuildConfig, + private val attributedMetricsDateUtils: AttributedMetricsDateUtils, +) : AttributedMetricsState, MainProcessLifecycleObserver, AtbLifecyclePlugin, PrivacyConfigCallbackPlugin { + + override fun onCreate(owner: LifecycleOwner) { + appCoroutineScope.launch(dispatcherProvider.io()) { + checkCollectionPeriodAndUpdateState() + } + } + + // this is called when the ATB is initialized after privacy config is downloaded, only once after app is installed + override fun onAppAtbInitialized() { + appCoroutineScope.launch(dispatcherProvider.io()) { + logcat(tag = "AttributedMetrics") { + "Detected New Install, try to initialize Attributed Metrics" + } + if (attributedMetricsConfigFeature.self().isEnabled().not()) { + logcat(tag = "AttributedMetrics") { + "Client disabled from remote config, skipping initialization" + } + return@launch + } + + val initDate = dataStore.getInitializationDate() + if (initDate == null) { + logcat(tag = "AttributedMetrics") { + "Setting initialization date for Attributed Metrics" + } + val currentDate = attributedMetricsDateUtils.getCurrentDate() + dataStore.setInitializationDate(currentDate) + if (appBuildConfig.isAppReinstall()) { + logcat(tag = "AttributedMetrics") { + "App reinstall detected, attributed metrics will not be active" + } + // Do not start metrics for returning users + dataStore.setActive(false) + } else { + logcat(tag = "AttributedMetrics") { + "New install detected, attributed metrics active" + } + dataStore.setActive(true) + } + } + logClientStatus() + } + } + + override fun onPrivacyConfigDownloaded() { + appCoroutineScope.launch(dispatcherProvider.io()) { + val toggleEnabledState = attributedMetricsConfigFeature.self().isEnabled() + logcat(tag = "AttributedMetrics") { + "Privacy config downloaded, update client toggle state: $toggleEnabledState" + } + dataStore.setEnabled(toggleEnabledState) + logClientStatus() + } + } + + override suspend fun isActive(): Boolean = dataStore.isActive() && dataStore.isEnabled() && dataStore.getInitializationDate() != null + + private suspend fun checkCollectionPeriodAndUpdateState() { + val initDate = dataStore.getInitializationDate() + + if (initDate == null) { + logcat(tag = "AttributedMetrics") { + "Client not initialized, skipping state check" + } + return + } + + val daysSinceInit = attributedMetricsDateUtils.daysSince(initDate) + val isWithinPeriod = daysSinceInit <= COLLECTION_PERIOD_DAYS + val newClientActiveState = isWithinPeriod && dataStore.isActive() + + logcat(tag = "AttributedMetrics") { + "Updating client state to $newClientActiveState result of -> within period? $isWithinPeriod, client active? ${dataStore.isActive()}" + } + dataStore.setActive(newClientActiveState) + logClientStatus() + } + + private suspend fun logClientStatus() = logcat(tag = "AttributedMetrics") { + "Client status running: ${isActive()} -> isActive: ${dataStore.isActive()}, isEnabled: ${dataStore.isEnabled()}," + + " initializationDate: ${dataStore.getInitializationDate()}" + } + + companion object { + private const val COLLECTION_PERIOD_DAYS = 168 // 24 weeks * 7 days (6 months in weeks) + } +} diff --git a/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/impl/RealAttributedMetricClient.kt b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/impl/RealAttributedMetricClient.kt new file mode 100644 index 000000000000..9b9ce426244c --- /dev/null +++ b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/impl/RealAttributedMetricClient.kt @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2025 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.attributed.metrics.impl + +import com.duckduckgo.app.attributed.metrics.api.AttributedMetric +import com.duckduckgo.app.attributed.metrics.api.AttributedMetricClient +import com.duckduckgo.app.attributed.metrics.api.EventStats +import com.duckduckgo.app.attributed.metrics.store.EventRepository +import com.duckduckgo.app.di.AppCoroutineScope +import com.duckduckgo.app.statistics.pixels.Pixel +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import dagger.SingleInstanceIn +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import logcat.logcat +import javax.inject.Inject + +@ContributesBinding(AppScope::class, AttributedMetricClient::class) +@SingleInstanceIn(AppScope::class) +class RealAttributedMetricClient @Inject constructor( + @AppCoroutineScope private val appCoroutineScope: CoroutineScope, + private val dispatcherProvider: DispatcherProvider, + private val eventRepository: EventRepository, + private val pixel: Pixel, + private val metricsState: AttributedMetricsState, +) : AttributedMetricClient { + + override fun collectEvent(eventName: String) { + appCoroutineScope.launch(dispatcherProvider.io()) { + if (!metricsState.isActive()) return@launch + logcat(tag = "AttributedMetrics") { + "Collecting event $eventName" + } + eventRepository.collectEvent(eventName) + } + } + + override suspend fun getEventStats( + eventName: String, + days: Int, + ): EventStats = + withContext(dispatcherProvider.io()) { + if (!metricsState.isActive()) { + return@withContext EventStats(daysWithEvents = 0, rollingAverage = 0.0, totalEvents = 0) + } + logcat(tag = "AttributedMetrics") { + "Calculating stats for event $eventName over $days days" + } + eventRepository.getEventStats(eventName, days) + } + + // 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()) return@launch + val pixelName = metric.getPixelName() + logcat(tag = "AttributedMetrics") { + "Firing pixel for $pixelName" + } + pixel.fire(pixelName = pixelName, parameters = metric.getMetricParameters()) + } + } +} diff --git a/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/store/AttributedMetricsDataStore.kt b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/store/AttributedMetricsDataStore.kt new file mode 100644 index 000000000000..ef27316cbc4f --- /dev/null +++ b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/store/AttributedMetricsDataStore.kt @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.attributed.metrics.store + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import com.duckduckgo.app.attributed.metrics.di.AttributedMetrics +import com.duckduckgo.app.di.AppCoroutineScope +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import dagger.SingleInstanceIn +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.* +import javax.inject.Inject + +interface AttributedMetricsDataStore { + suspend fun isEnabled(): Boolean + + suspend fun setEnabled(enabled: Boolean) + + suspend fun isActive(): Boolean + + suspend fun setActive(active: Boolean) + + suspend fun getInitializationDate(): String? + + suspend fun setInitializationDate(date: String?) + + fun observeEnabled(): Flow +} + +@ContributesBinding(AppScope::class) +@SingleInstanceIn(AppScope::class) +class RealAttributedMetricsDataStore @Inject constructor( + @AttributedMetrics private val store: DataStore, + @AppCoroutineScope private val appCoroutineScope: CoroutineScope, +) : AttributedMetricsDataStore { + private object Keys { + val IS_ENABLED = booleanPreferencesKey("is_enabled") + val IS_ACTIVE = booleanPreferencesKey("is_active") + val INIT_DATE = stringPreferencesKey("client_init_date") + } + + private val enabledState: StateFlow = + store.data + .map { prefs -> prefs[Keys.IS_ENABLED] ?: false } + .distinctUntilChanged() + .stateIn(appCoroutineScope, SharingStarted.Eagerly, false) + + override suspend fun isEnabled(): Boolean = store.data.firstOrNull()?.get(Keys.IS_ENABLED) ?: false + + override suspend fun setEnabled(enabled: Boolean) { + store.edit { preferences -> + preferences[Keys.IS_ENABLED] = enabled + } + } + + override suspend fun getInitializationDate(): String? = store.data.firstOrNull()?.get(Keys.INIT_DATE) + + override suspend fun setInitializationDate(date: String?) { + store.edit { preferences -> + if (date != null) { + preferences[Keys.INIT_DATE] = date + } else { + preferences.remove(Keys.INIT_DATE) + } + } + } + + override suspend fun isActive(): Boolean = store.data.firstOrNull()?.get(Keys.IS_ACTIVE) ?: false + + override suspend fun setActive(active: Boolean) { + store.edit { preferences -> + preferences[Keys.IS_ACTIVE] = active + } + } + + override fun observeEnabled(): Flow = enabledState +} diff --git a/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/store/AttributedMetricsDatabase.kt b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/store/AttributedMetricsDatabase.kt new file mode 100644 index 000000000000..c0e98dc63799 --- /dev/null +++ b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/store/AttributedMetricsDatabase.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.attributed.metrics.store + +import androidx.room.Database +import androidx.room.RoomDatabase + +@Database( + version = 1, + entities = [ + EventEntity::class, + ], + exportSchema = true, +) +abstract class AttributedMetricsDatabase : RoomDatabase() { + abstract fun eventDao(): EventDao +} diff --git a/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/store/AttributedMetricsDateUtils.kt b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/store/AttributedMetricsDateUtils.kt new file mode 100644 index 000000000000..02acdbc66352 --- /dev/null +++ b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/store/AttributedMetricsDateUtils.kt @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.attributed.metrics.store + +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import java.time.* +import java.time.format.DateTimeFormatter +import java.time.temporal.ChronoUnit +import javax.inject.Inject + +/** + * Utility interface for handling date operations in the Attributed Metrics feature. + * + * This interface provides methods for: + * - Getting the current date in a standardized format + * - Calculating days between dates + * - Generating dates relative to the current date + * + * All dates are handled in Eastern Time (ET) and formatted as "yyyy-MM-dd" for consistency. + * This format is used for both storage and calculations. The timezone ensures that day + * boundaries align with business operations in ET. + * + * Example usage: + * ``` + * // Get today's date in ET + * val today = dateUtils.getCurrentDate() // returns "2025-10-03" (if it's Oct 3rd in ET) + * + * // Get a date 7 days ago in ET + * val lastWeek = dateUtils.getDateMinusDays(7) // returns "2025-09-26" + * + * // Calculate days since a specific date in ET + * // Note: The calculation uses ET midnight as the boundary for day changes + * val daysSince = dateUtils.daysSince("2025-09-01") // returns number of days + * ``` + * + * Note: All date operations use Eastern Time (ET) timezone. This means: + * - Day changes occur at midnight ET + * - Date comparisons and calculations are based on ET dates + * - The returned date strings represent dates in ET + */ +interface AttributedMetricsDateUtils { + /** + * Gets the current date in Eastern Time formatted as "yyyy-MM-dd". + * + * @return The current date in ET as a string in the format "yyyy-MM-dd" + */ + fun getCurrentDate(): String + + /** + * Calculates the number of days between a given date and the current date in Eastern Time. + * Day boundaries are determined using midnight ET. + * + * @param date The reference date in "yyyy-MM-dd" format (interpreted in ET) + * @return The number of days between the reference date and current date. + * Positive if the reference date is in the past, + * negative if it's in the future, + * zero if it's today. + */ + fun daysSince(date: String): Int + + /** + * Gets a date that is a specified number of days before the current date in Eastern Time. + * Day boundaries are determined using midnight ET. + * + * @param days The number of days to subtract from the current date + * @return The calculated date as a string in "yyyy-MM-dd" format (in ET) + */ + fun getDateMinusDays(days: Int): String +} + +@ContributesBinding(AppScope::class) +class RealAttributedMetricsDateUtils @Inject constructor() : AttributedMetricsDateUtils { + override fun getCurrentDate(): String = getCurrentZonedDateTime().format(DATE_FORMATTER) + + override fun daysSince(date: String): Int { + // Parse the input date and set it to start of day (midnight) in ET + val initDate = ZonedDateTime.of( + LocalDate.parse(date, DATE_FORMATTER), + LocalTime.MIDNIGHT, + ET_ZONE, + ) + return ChronoUnit.DAYS.between(initDate, getCurrentZonedDateTime()).toInt() + } + + override fun getDateMinusDays(days: Int): String = getCurrentZonedDateTime().minusDays(days.toLong()).format(DATE_FORMATTER) + + private fun getCurrentZonedDateTime(): ZonedDateTime = ZonedDateTime.now(ET_ZONE) + + companion object { + private val DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd") + private val ET_ZONE = ZoneId.of("America/New_York") + } +} diff --git a/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/store/EventDao.kt b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/store/EventDao.kt new file mode 100644 index 000000000000..12692b0fe8a8 --- /dev/null +++ b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/store/EventDao.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.attributed.metrics.store + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query + +@Dao +interface EventDao { + @Query("SELECT * FROM event_metrics WHERE eventName = :eventName AND day >= :startDay ORDER BY day DESC") + suspend fun getEventsByNameAndTimeframe( + eventName: String, + startDay: String, + ): List + + @Query("SELECT COUNT(DISTINCT day) FROM event_metrics WHERE eventName = :eventName AND day >= :startDay") + suspend fun getDaysWithEvents( + eventName: String, + startDay: String, + ): Int + + @Query("SELECT SUM(count) FROM event_metrics WHERE eventName = :eventName AND day >= :startDay") + suspend fun getTotalEvents( + eventName: String, + startDay: String, + ): Int + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertEvent(event: EventEntity) + + @Query( + """ + UPDATE event_metrics + SET count = count + 1 + WHERE eventName = :eventName AND day = :day + """, + ) + suspend fun incrementEventCount( + eventName: String, + day: String, + ) + + @Query("SELECT count FROM event_metrics WHERE eventName = :eventName AND day = :day") + suspend fun getEventCount( + eventName: String, + day: String, + ): Int? + + @Query("DELETE FROM event_metrics WHERE day < :day") + suspend fun deleteEventsOlderThan(day: String) +} diff --git a/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/store/EventEntity.kt b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/store/EventEntity.kt new file mode 100644 index 000000000000..bcb6d3ac2b7a --- /dev/null +++ b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/store/EventEntity.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.attributed.metrics.store + +import androidx.room.Entity +import androidx.room.Index + +@Entity( + tableName = "event_metrics", + primaryKeys = ["eventName", "day"], + indices = [Index("eventName"), Index("day")], +) +data class EventEntity( + val eventName: String, + val count: Int, + // Format: YYYY-MM-DD + val day: String, +) diff --git a/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/store/EventRepository.kt b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/store/EventRepository.kt new file mode 100644 index 000000000000..83dac54c516f --- /dev/null +++ b/attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/store/EventRepository.kt @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.attributed.metrics.store + +import com.duckduckgo.app.attributed.metrics.api.EventStats +import com.duckduckgo.app.di.AppCoroutineScope +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import javax.inject.Inject + +interface EventRepository { + suspend fun collectEvent(eventName: String) + + suspend fun getEventStats( + eventName: String, + days: Int, + ): EventStats + + suspend fun deleteOldEvents(olderThanDays: Int) +} + +@ContributesBinding(AppScope::class) +class RealEventRepository @Inject constructor( + private val eventDao: EventDao, + private val attributedMetricsDateUtils: AttributedMetricsDateUtils, + @AppCoroutineScope private val coroutineScope: CoroutineScope, +) : EventRepository { + override suspend fun collectEvent(eventName: String) { + val today = attributedMetricsDateUtils.getCurrentDate() + val currentCount = eventDao.getEventCount(eventName, today) + + if (currentCount == null) { + eventDao.insertEvent(EventEntity(eventName = eventName, count = 1, day = today)) + } else { + eventDao.incrementEventCount(eventName, today) + } + } + + override suspend fun getEventStats( + eventName: String, + days: Int, + ): EventStats { + val startDay = attributedMetricsDateUtils.getDateMinusDays(days) + + val daysWithEvents = eventDao.getDaysWithEvents(eventName, startDay) + val totalEvents = eventDao.getTotalEvents(eventName, startDay) ?: 0 + val rollingAverage = if (days > 0) totalEvents.toDouble() / days else 0.0 + + return EventStats( + daysWithEvents = daysWithEvents, + rollingAverage = rollingAverage, + totalEvents = totalEvents, + ) + } + + override suspend fun deleteOldEvents(olderThanDays: Int) { + coroutineScope.launch { + val cutoffDay = attributedMetricsDateUtils.getDateMinusDays(olderThanDays) + eventDao.deleteEventsOlderThan(cutoffDay) + } + } +} diff --git a/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/FakeAttributedMetricsDateUtils.kt b/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/FakeAttributedMetricsDateUtils.kt new file mode 100644 index 000000000000..fa8d30b4cc73 --- /dev/null +++ b/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/FakeAttributedMetricsDateUtils.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.attributed.metrics + +import com.duckduckgo.app.attributed.metrics.store.AttributedMetricsDateUtils +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.time.temporal.ChronoUnit + +class FakeAttributedMetricsDateUtils(var testDate: LocalDate) : AttributedMetricsDateUtils { + + override fun getCurrentDate(): String = getCurrentLocalDate().format(DATE_FORMATTER) + + override fun daysSince(date: String): Int { + val initDate = LocalDate.parse(date, DATE_FORMATTER) + return ChronoUnit.DAYS.between(initDate, getCurrentLocalDate()).toInt() + } + + override fun getDateMinusDays(days: Int): String = getCurrentLocalDate().minusDays(days.toLong()).format(DATE_FORMATTER) + + private fun getCurrentLocalDate(): LocalDate = testDate + + companion object { + private val DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd") + } +} diff --git a/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/impl/RealAttributedMetricClientTest.kt b/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/impl/RealAttributedMetricClientTest.kt new file mode 100644 index 000000000000..f46e7098f788 --- /dev/null +++ b/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/impl/RealAttributedMetricClientTest.kt @@ -0,0 +1,125 @@ +/* + * Copyright (c) 2025 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.attributed.metrics.impl + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.duckduckgo.app.attributed.metrics.api.AttributedMetric +import com.duckduckgo.app.attributed.metrics.api.EventStats +import com.duckduckgo.app.attributed.metrics.store.EventRepository +import com.duckduckgo.app.statistics.pixels.Pixel +import com.duckduckgo.common.test.CoroutineTestRule +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.verifyNoInteractions +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +@RunWith(AndroidJUnit4::class) +class RealAttributedMetricClientTest { + + @get:Rule + val coroutineTestRule = CoroutineTestRule() + + private val mockEventRepository: EventRepository = mock() + private val mockPixel: Pixel = mock() + private val mockMetricsState: AttributedMetricsState = mock() + + private lateinit var testee: RealAttributedMetricClient + + @Before + fun setup() { + testee = RealAttributedMetricClient( + appCoroutineScope = coroutineTestRule.testScope, + dispatcherProvider = coroutineTestRule.testDispatcherProvider, + eventRepository = mockEventRepository, + pixel = mockPixel, + metricsState = mockMetricsState, + ) + } + + @Test + fun whenCollectEventAndClientActiveEventIsCollected() = runTest { + whenever(mockMetricsState.isActive()).thenReturn(true) + + testee.collectEvent("test_event") + + verify(mockEventRepository).collectEvent("test_event") + } + + @Test + fun whenCollectEventAndClientNotActiveEventIsNotCollected() = runTest { + whenever(mockMetricsState.isActive()).thenReturn(false) + + testee.collectEvent("test_event") + + verify(mockEventRepository, never()).collectEvent(any()) + } + + @Test + fun whenGetEventStatsAndClientActiveStatsAreReturned() = runTest { + val expectedStats = EventStats(daysWithEvents = 5, rollingAverage = 2.5, totalEvents = 10) + whenever(mockMetricsState.isActive()).thenReturn(true) + whenever(mockEventRepository.getEventStats("test_event", 7)).thenReturn(expectedStats) + + val result = testee.getEventStats("test_event", 7) + + assertEquals(expectedStats, result) + verify(mockEventRepository).getEventStats("test_event", 7) + } + + @Test + fun whenGetEventStatsAndClientNotActiveEmptyStatsAreReturned() = runTest { + whenever(mockMetricsState.isActive()).thenReturn(false) + + val result = testee.getEventStats("test_event", 7) + + assertEquals(EventStats(daysWithEvents = 0, rollingAverage = 0.0, totalEvents = 0), result) + verify(mockEventRepository, never()).getEventStats(any(), any()) + } + + @Test + fun whenEmitMetricAndClientActiveMetricIsEmitted() = runTest { + val testMetric = TestAttributedMetric() + whenever(mockMetricsState.isActive()).thenReturn(true) + + testee.emitMetric(testMetric) + + verify(mockPixel).fire(pixelName = "test_pixel", parameters = mapOf("param" to "value")) + } + + @Test + fun whenEmitMetricAndClientNotActiveMetricIsNotEmitted() = runTest { + val testMetric = TestAttributedMetric() + whenever(mockMetricsState.isActive()).thenReturn(false) + + testee.emitMetric(testMetric) + + verifyNoInteractions(mockPixel) + } + + private class TestAttributedMetric : AttributedMetric { + override fun getPixelName(): String = "test_pixel" + override suspend fun getMetricParameters(): Map = mapOf("param" to "value") + } +} diff --git a/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/impl/RealAttributedMetricsStateTest.kt b/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/impl/RealAttributedMetricsStateTest.kt new file mode 100644 index 000000000000..7cd2486055ae --- /dev/null +++ b/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/impl/RealAttributedMetricsStateTest.kt @@ -0,0 +1,193 @@ +/* + * Copyright (c) 2025 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.attributed.metrics.impl + +import android.annotation.SuppressLint +import androidx.lifecycle.LifecycleOwner +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.duckduckgo.app.attributed.metrics.AttributedMetricsConfigFeature +import com.duckduckgo.app.attributed.metrics.FakeAttributedMetricsDateUtils +import com.duckduckgo.app.attributed.metrics.store.AttributedMetricsDataStore +import com.duckduckgo.app.attributed.metrics.store.AttributedMetricsDateUtils +import com.duckduckgo.appbuildconfig.api.AppBuildConfig +import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory +import com.duckduckgo.feature.toggles.api.Toggle.State +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import java.time.LocalDate + +@SuppressLint("DenyListedApi") +@RunWith(AndroidJUnit4::class) +class RealAttributedMetricsStateTest { + + @get:Rule val coroutineTestRule = CoroutineTestRule() + + private val mockDataStore: AttributedMetricsDataStore = mock() + private val mockConfigFeature: AttributedMetricsConfigFeature = FakeFeatureToggleFactory.create(AttributedMetricsConfigFeature::class.java) + private val mockAppBuildConfig: AppBuildConfig = mock() + private val mockLifecycleOwner: LifecycleOwner = mock() + private lateinit var testDateUtils: AttributedMetricsDateUtils + + private lateinit var testee: RealAttributedMetricsState + + @Before fun setup() { + initDateUtilsWith(LocalDate.of(2025, 10, 3)) + testee = RealAttributedMetricsState( + appCoroutineScope = coroutineTestRule.testScope, + dispatcherProvider = coroutineTestRule.testDispatcherProvider, + dataStore = mockDataStore, + attributedMetricsConfigFeature = mockConfigFeature, + appBuildConfig = mockAppBuildConfig, + attributedMetricsDateUtils = testDateUtils, + ) + } + + @Test fun whenOnAppAtbInitializedAndFeatureDisabledThenDoNothing() = runTest { + givenAttributedClientFeatureEnabled(false) + + testee.onAppAtbInitialized() + + verify(mockDataStore, never()).setInitializationDate(any()) + verify(mockDataStore, never()).setActive(any()) + } + + @Test fun whenOnAppAtbInitializedAndFeatureEnabledAndReinstallThenSetInactiveState() = runTest { + givenAttributedClientFeatureEnabled(true) + whenever(mockDataStore.getInitializationDate()).thenReturn(null) + whenever(mockAppBuildConfig.isAppReinstall()).thenReturn(true) + + testee.onAppAtbInitialized() + + verify(mockDataStore).setInitializationDate("2025-10-03") + verify(mockDataStore).setActive(false) + } + + @Test fun whenOnAppAtbInitializedAndFeatureEnabledAndNewInstallThenSetActiveState() = runTest { + givenAttributedClientFeatureEnabled(true) + whenever(mockDataStore.getInitializationDate()).thenReturn(null) + whenever(mockAppBuildConfig.isAppReinstall()).thenReturn(false) + + testee.onAppAtbInitialized() + + verify(mockDataStore).setInitializationDate("2025-10-03") + verify(mockDataStore).setActive(true) + } + + @Test fun whenOnAppAtbInitializedAndFeatureEnabledAndAlreadyInitializedThenDoNothing() = runTest { + givenAttributedClientFeatureEnabled(true) + whenever(mockDataStore.getInitializationDate()).thenReturn("2025-10-03") + + testee.onAppAtbInitialized() + + verify(mockDataStore, never()).setInitializationDate(any()) + verify(mockDataStore, never()).setActive(any()) + } + + @Test fun whenOnPrivacyConfigDownloadedThenUpdateEnabledState() = runTest { + givenAttributedClientFeatureEnabled(true) + + testee.onPrivacyConfigDownloaded() + + verify(mockDataStore).setEnabled(true) + } + + @Test fun whenIsActiveAndAllConditionsMetThenReturnTrue() = runTest { + whenever(mockDataStore.isActive()).thenReturn(true) + whenever(mockDataStore.isEnabled()).thenReturn(true) + whenever(mockDataStore.getInitializationDate()).thenReturn("2025-10-03") + + assertTrue(testee.isActive()) + } + + @Test fun whenIsActiveAndClientNotActiveThenReturnFalse() = runTest { + whenever(mockDataStore.isActive()).thenReturn(false) + whenever(mockDataStore.isEnabled()).thenReturn(true) + whenever(mockDataStore.getInitializationDate()).thenReturn("2025-10-03") + + assertFalse(testee.isActive()) + } + + @Test fun whenIsActiveAndNotEnabledThenReturnFalse() = runTest { + whenever(mockDataStore.isActive()).thenReturn(true) + whenever(mockDataStore.isEnabled()).thenReturn(false) + whenever(mockDataStore.getInitializationDate()).thenReturn("2025-10-03") + + assertFalse(testee.isActive()) + } + + @Test fun whenIsActiveAndNoInitDateThenReturnFalse() = runTest { + whenever(mockDataStore.isActive()).thenReturn(true) + whenever(mockDataStore.isEnabled()).thenReturn(true) + whenever(mockDataStore.getInitializationDate()).thenReturn(null) + + assertFalse(testee.isActive()) + } + + @Test fun whenCheckCollectionPeriodAndNoInitDateThenDoNothing() = runTest { + whenever(mockDataStore.getInitializationDate()).thenReturn(null) + + testee.onCreate(mockLifecycleOwner) + + verify(mockDataStore, never()).setActive(any()) + } + + @Test fun whenCheckCollectionPeriodAndWithinPeriodAndActiveThenKeepActive() = runTest { + whenever(mockDataStore.getInitializationDate()).thenReturn(testDateUtils.getDateMinusDays(100)) + whenever(mockDataStore.isActive()).thenReturn(true) + + testee.onCreate(mockLifecycleOwner) + + verify(mockDataStore).setActive(true) + } + + @Test fun whenCheckCollectionPeriodAndWithinPeriodAndNotActiveThenKeepInactive() = runTest { + whenever(mockDataStore.getInitializationDate()).thenReturn(testDateUtils.getDateMinusDays(100)) + whenever(mockDataStore.isActive()).thenReturn(false) + + testee.onCreate(mockLifecycleOwner) + + verify(mockDataStore).setActive(false) + } + + @Test fun whenCheckCollectionPeriodAndOutsidePeriodThenSetInactive() = runTest { + whenever(mockDataStore.getInitializationDate()).thenReturn(testDateUtils.getDateMinusDays(169)) // 6months + 1 + whenever(mockDataStore.isActive()).thenReturn(true) + + testee.onCreate(mockLifecycleOwner) + + verify(mockDataStore).setActive(false) + } + + private fun givenAttributedClientFeatureEnabled(isEnabled: Boolean) { + mockConfigFeature.self().setRawStoredState(State(isEnabled)) + } + + private fun initDateUtilsWith(date: LocalDate) { + testDateUtils = FakeAttributedMetricsDateUtils(date) + } +} diff --git a/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/store/RealEventRepositoryTest.kt b/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/store/RealEventRepositoryTest.kt new file mode 100644 index 000000000000..d36aeaaeabbc --- /dev/null +++ b/attributed-metrics/attributed-metrics-impl/src/test/java/com/duckduckgo/app/attributed/metrics/store/RealEventRepositoryTest.kt @@ -0,0 +1,137 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.attributed.metrics.store + +import androidx.room.Room +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.duckduckgo.app.attributed.metrics.FakeAttributedMetricsDateUtils +import com.duckduckgo.common.test.CoroutineTestRule +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import java.time.LocalDate + +@RunWith(AndroidJUnit4::class) +class RealEventRepositoryTest { + @get:Rule + var coroutineTestRule = CoroutineTestRule() + + private lateinit var db: AttributedMetricsDatabase + private lateinit var eventDao: EventDao + private lateinit var testDateProvider: FakeAttributedMetricsDateUtils + private lateinit var repository: RealEventRepository + + @Before + fun setup() { + db = + Room + .inMemoryDatabaseBuilder( + InstrumentationRegistry.getInstrumentation().targetContext, + AttributedMetricsDatabase::class.java, + ).build() + eventDao = db.eventDao() + testDateProvider = FakeAttributedMetricsDateUtils(LocalDate.of(2025, 10, 3)) + repository = + RealEventRepository( + eventDao = eventDao, + attributedMetricsDateUtils = testDateProvider, + coroutineScope = TestScope(coroutineTestRule.testDispatcher), + ) + } + + @After + fun tearDown() { + db.close() + } + + @Test + fun whenCollectEventFirstTimeForTodayThenInsertNewRecord() = + runTest { + testDateProvider.testDate = LocalDate.of(2025, 10, 3) + + repository.collectEvent("test_event") + + val events = eventDao.getEventsByNameAndTimeframe("test_event", "2025-10-03") + assert(events.size == 1) + assert(events[0].count == 1) + assert(events[0].eventName == "test_event") + assert(events[0].day == "2025-10-03") + } + + @Test + fun whenCollectEventMultipleTimesForTodayThenIncrementCount() = + runTest { + testDateProvider.testDate = LocalDate.of(2025, 10, 3) + + repository.collectEvent("test_event") + repository.collectEvent("test_event") + repository.collectEvent("test_event") + + val events = eventDao.getEventsByNameAndTimeframe("test_event", "2025-10-03") + assert(events.size == 1) + assert(events[0].count == 3) + } + + @Test + fun whenGetEventStatsWithNoEventsThenReturnZeros() = + runTest { + testDateProvider.testDate = LocalDate.of(2025, 10, 3) + + val stats = repository.getEventStats("test_event", days = 7) + + assert(stats.daysWithEvents == 0) + assert(stats.totalEvents == 0) + assert(stats.rollingAverage == 0.0) + } + + @Test + fun whenGetEventStatsThenCalculateCorrectly() = + runTest { + // Setup data for 3 days + testDateProvider.testDate = LocalDate.of(2025, 10, 3) + eventDao.insertEvent(EventEntity("test_event", count = 2, day = "2025-10-03")) + eventDao.insertEvent(EventEntity("test_event", count = 3, day = "2025-10-02")) + eventDao.insertEvent(EventEntity("test_event", count = 1, day = "2025-10-01")) + + val stats = repository.getEventStats("test_event", days = 7) + + assert(stats.daysWithEvents == 3) + assert(stats.totalEvents == 6) + assert(stats.rollingAverage == 6.0 / 7.0) + } + + @Test + fun whenDeleteOldEventsThenRemoveOnlyOlderThanSpecified() = + 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) + + val remainingEvents = eventDao.getEventsByNameAndTimeframe("test_event", "2025-09-03") + assert(remainingEvents.size == 2) + assert(remainingEvents.none { it.day == "2025-09-03" }) + } +} diff --git a/attributed-metrics/readme.md b/attributed-metrics/readme.md new file mode 100644 index 000000000000..b66b7333c6a6 --- /dev/null +++ b/attributed-metrics/readme.md @@ -0,0 +1,8 @@ +# Attributed Metrics +This module contains code for collecting attributed metrics. + +## Who can help you better understand this feature? +- Cristian Monforte + +## More information +N/A