Skip to content

Commit 1a3728c

Browse files
committed
improving state management inside client
1 parent d2faadf commit 1a3728c

File tree

6 files changed

+196
-64
lines changed

6 files changed

+196
-64
lines changed

app/build.gradle

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -409,6 +409,9 @@ dependencies {
409409
implementation project(':survey-api')
410410
implementation project(':survey-impl')
411411

412+
implementation project(':attributed-metrics-api')
413+
implementation project(':attributed-metrics-impl')
414+
412415
implementation project(':breakage-reporting-impl')
413416

414417
implementation project(':dax-prompts-api')

attributed-metrics/attributed-metrics-impl/build.gradle

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -36,14 +36,6 @@ dependencies {
3636
implementation KotlinX.coroutines.core
3737
implementation KotlinX.coroutines.android
3838

39-
implementation "io.reactivex.rxjava2:rxjava:_"
40-
implementation "io.reactivex.rxjava2:rxandroid:_"
41-
42-
implementation Square.retrofit2.retrofit
43-
implementation Square.retrofit2.converter.moshi
44-
implementation Square.retrofit2.adapter.rxJava2
45-
implementation Square.retrofit2.converter.scalars
46-
4739
implementation Google.dagger
4840

4941
// DataStore
@@ -53,11 +45,6 @@ dependencies {
5345
implementation AndroidX.room.ktx
5446
ksp AndroidX.room.compiler
5547

56-
// WorkManager
57-
implementation AndroidX.work.runtimeKtx
58-
androidTestImplementation AndroidX.work.testing
59-
implementation AndroidX.work.rxJava2
60-
6148
implementation "com.squareup.logcat:logcat:_"
6249

6350
implementation AndroidX.core.ktx
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
/*
2+
* Copyright (c) 2025 DuckDuckGo
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.duckduckgo.app.attributed.metrics.impl
18+
19+
import androidx.lifecycle.LifecycleOwner
20+
import com.duckduckgo.app.attributed.metrics.AttributedMetricsConfigFeature
21+
import com.duckduckgo.app.attributed.metrics.store.AttributedMetricsDataStore
22+
import com.duckduckgo.app.attributed.metrics.store.DateProvider
23+
import com.duckduckgo.app.di.AppCoroutineScope
24+
import com.duckduckgo.app.lifecycle.MainProcessLifecycleObserver
25+
import com.duckduckgo.app.statistics.api.AtbLifecyclePlugin
26+
import com.duckduckgo.appbuildconfig.api.AppBuildConfig
27+
import com.duckduckgo.common.utils.DispatcherProvider
28+
import com.duckduckgo.di.scopes.AppScope
29+
import com.duckduckgo.privacy.config.api.PrivacyConfigCallbackPlugin
30+
import com.squareup.anvil.annotations.ContributesBinding
31+
import com.squareup.anvil.annotations.ContributesMultibinding
32+
import dagger.SingleInstanceIn
33+
import kotlinx.coroutines.CoroutineScope
34+
import kotlinx.coroutines.launch
35+
import logcat.logcat
36+
import java.time.LocalDate
37+
import java.time.format.DateTimeFormatter
38+
import java.time.temporal.ChronoUnit
39+
import javax.inject.Inject
40+
41+
/**
42+
* Interface for checking if attributed metrics are active.
43+
* The active state is determined by:
44+
* 1. Having an initialization date
45+
* 2. Being within the collection period (6 months = 24 weeks)
46+
* 3. Being enabled in remote config
47+
*/
48+
interface AttributedMetricsState {
49+
suspend fun isActive(): Boolean
50+
}
51+
52+
@ContributesBinding(
53+
scope = AppScope::class,
54+
boundType = AttributedMetricsState::class,
55+
)
56+
@ContributesMultibinding(
57+
scope = AppScope::class,
58+
boundType = MainProcessLifecycleObserver::class,
59+
)
60+
@ContributesMultibinding(
61+
scope = AppScope::class,
62+
boundType = AtbLifecyclePlugin::class,
63+
)
64+
@ContributesMultibinding(
65+
scope = AppScope::class,
66+
boundType = PrivacyConfigCallbackPlugin::class,
67+
)
68+
@SingleInstanceIn(AppScope::class)
69+
class RealAttributedMetricsState @Inject constructor(
70+
@AppCoroutineScope private val appCoroutineScope: CoroutineScope,
71+
private val dispatcherProvider: DispatcherProvider,
72+
private val dataStore: AttributedMetricsDataStore,
73+
private val attributedMetricsConfigFeature: AttributedMetricsConfigFeature,
74+
private val appBuildConfig: AppBuildConfig,
75+
private val dateProvider: DateProvider,
76+
) : AttributedMetricsState, MainProcessLifecycleObserver, AtbLifecyclePlugin, PrivacyConfigCallbackPlugin {
77+
78+
override fun onCreate(owner: LifecycleOwner) {
79+
appCoroutineScope.launch(dispatcherProvider.io()) {
80+
checkCollectionPeriodAndUpdateState()
81+
}
82+
}
83+
84+
// this is called when the ATB is initialized after privacy config is downloaded, only once after app is installed
85+
override fun onAppAtbInitialized() {
86+
appCoroutineScope.launch(dispatcherProvider.io()) {
87+
logcat(tag = "AttributedMetrics") {
88+
"Detected New Install, try to initialize Attributed Metrics"
89+
}
90+
if (attributedMetricsConfigFeature.self().isEnabled().not()) {
91+
logcat(tag = "AttributedMetrics") {
92+
"Client disabled from remote config, skipping initialization"
93+
}
94+
return@launch
95+
}
96+
97+
val initDate = dataStore.getInitializationDate()
98+
if (initDate == null) {
99+
logcat(tag = "AttributedMetrics") {
100+
"Setting initialization date for Attributed Metrics"
101+
}
102+
val currentDate = dateProvider.getCurrentDate()
103+
dataStore.setInitializationDate(currentDate)
104+
if (appBuildConfig.isAppReinstall()) {
105+
logcat(tag = "AttributedMetrics") {
106+
"App reinstall detected, attributed metrics will not be active"
107+
}
108+
// Do not start metrics for returning users
109+
dataStore.setActive(false)
110+
} else {
111+
logcat(tag = "AttributedMetrics") {
112+
"New install detected, attributed metrics active"
113+
}
114+
dataStore.setActive(true)
115+
}
116+
}
117+
}
118+
}
119+
120+
override fun onPrivacyConfigDownloaded() {
121+
appCoroutineScope.launch(dispatcherProvider.io()) {
122+
val toggleEnabledState = attributedMetricsConfigFeature.self().isEnabled()
123+
logcat(tag = "AttributedMetrics") {
124+
"Privacy config downloaded, attributed metrics enabled: $toggleEnabledState," +
125+
" client state: ${dataStore.isActive()}-${dataStore.getInitializationDate()}"
126+
}
127+
dataStore.setEnabled(toggleEnabledState)
128+
}
129+
}
130+
131+
override suspend fun isActive(): Boolean = dataStore.isActive() && dataStore.isEnabled() && dataStore.getInitializationDate() != null
132+
133+
private suspend fun checkCollectionPeriodAndUpdateState() {
134+
val initDate = dataStore.getInitializationDate()
135+
136+
if (initDate == null) {
137+
logcat(tag = "AttributedMetrics") {
138+
"Client not initialized, skipping state check"
139+
}
140+
return
141+
}
142+
143+
val initLocalDate = LocalDate.parse(initDate, DATE_FORMATTER)
144+
val currentDate = LocalDate.now()
145+
146+
val daysSinceInit = ChronoUnit.DAYS.between(initLocalDate, currentDate)
147+
val isWithinPeriod = daysSinceInit <= COLLECTION_PERIOD_DAYS
148+
val isClientActive = isWithinPeriod && dataStore.isActive()
149+
150+
logcat(tag = "AttributedMetrics") {
151+
"Updating client state to $isClientActive, within period? $isWithinPeriod, is client active? ${dataStore.isActive()}"
152+
}
153+
dataStore.setActive(isClientActive)
154+
}
155+
156+
companion object {
157+
private val DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd")
158+
private const val COLLECTION_PERIOD_DAYS = 168 // 24 weeks * 7 days (6 months in weeks)
159+
}
160+
}

attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/impl/RealAttributedMetricClient.kt

Lines changed: 19 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -16,66 +16,38 @@
1616

1717
package com.duckduckgo.app.attributed.metrics.impl
1818

19-
import com.duckduckgo.app.attributed.metrics.AttributedMetricsConfigFeature
2019
import com.duckduckgo.app.attributed.metrics.api.AttributedMetric
2120
import com.duckduckgo.app.attributed.metrics.api.AttributedMetricClient
2221
import com.duckduckgo.app.attributed.metrics.api.EventStats
23-
import com.duckduckgo.app.attributed.metrics.store.AttributedMetricsDataStore
24-
import com.duckduckgo.app.attributed.metrics.store.DateProvider
2522
import com.duckduckgo.app.attributed.metrics.store.EventRepository
2623
import com.duckduckgo.app.di.AppCoroutineScope
27-
import com.duckduckgo.app.statistics.api.AtbLifecyclePlugin
28-
import com.duckduckgo.appbuildconfig.api.AppBuildConfig
24+
import com.duckduckgo.app.statistics.pixels.Pixel
2925
import com.duckduckgo.common.utils.DispatcherProvider
3026
import com.duckduckgo.di.scopes.AppScope
31-
import com.duckduckgo.privacy.config.api.PrivacyConfigCallbackPlugin
3227
import com.squareup.anvil.annotations.ContributesBinding
33-
import com.squareup.anvil.annotations.ContributesMultibinding
3428
import dagger.SingleInstanceIn
3529
import kotlinx.coroutines.CoroutineScope
36-
import kotlinx.coroutines.Job
3730
import kotlinx.coroutines.launch
3831
import kotlinx.coroutines.withContext
32+
import logcat.logcat
3933
import javax.inject.Inject
4034

41-
@ContributesMultibinding(AppScope::class, AtbLifecyclePlugin::class)
4235
@ContributesBinding(AppScope::class, AttributedMetricClient::class)
4336
@SingleInstanceIn(AppScope::class)
4437
class RealAttributedMetricClient @Inject constructor(
4538
@AppCoroutineScope private val appCoroutineScope: CoroutineScope,
4639
private val dispatcherProvider: DispatcherProvider,
47-
private val appBuildConfig: AppBuildConfig,
4840
private val eventRepository: EventRepository,
49-
private val dataStore: AttributedMetricsDataStore,
50-
private val dateProvider: DateProvider,
51-
private val attributedMetricsConfigFeature: AttributedMetricsConfigFeature,
52-
) : AttributedMetricClient,
53-
AtbLifecyclePlugin,
54-
PrivacyConfigCallbackPlugin {
55-
56-
// We only want to enable Attributed Metrics for new installations
57-
override fun onAppAtbInitialized() {
58-
appCoroutineScope.launch(dispatcherProvider.io()) {
59-
// atb happens after remote config is downloaded, here we should have the latest value
60-
if (attributedMetricsConfigFeature.self().isEnabled().not()) return@launch
61-
62-
val initDate = dataStore.getInitializationDate()
63-
if (initDate == null) {
64-
val currentDate = dateProvider.getCurrentDate()
65-
dataStore.setInitializationDate(currentDate)
66-
if (appBuildConfig.isAppReinstall()) {
67-
// Do not start metrics for returning users
68-
return@launch
69-
} else {
70-
dataStore.setEnabled(true)
71-
}
72-
}
73-
}
74-
}
41+
private val pixel: Pixel,
42+
private val metricsState: AttributedMetricsState,
43+
) : AttributedMetricClient {
7544

7645
override fun collectEvent(eventName: String) {
7746
appCoroutineScope.launch(dispatcherProvider.io()) {
78-
if (!isEnabled()) return@launch
47+
if (!metricsState.isActive()) return@launch
48+
logcat(tag = "AttributedMetrics") {
49+
"Collecting event $eventName"
50+
}
7951
eventRepository.collectEvent(eventName)
8052
}
8153
}
@@ -85,26 +57,23 @@ class RealAttributedMetricClient @Inject constructor(
8557
days: Int,
8658
): EventStats =
8759
withContext(dispatcherProvider.io()) {
88-
if (!isEnabled()) {
60+
if (!metricsState.isActive()) {
8961
return@withContext EventStats(daysWithEvents = 0, rollingAverage = 0.0, totalEvents = 0)
9062
}
63+
logcat(tag = "AttributedMetrics") {
64+
"Calculating stats for event $eventName over $days days"
65+
}
9166
eventRepository.getEventStats(eventName, days)
9267
}
9368

9469
override fun emitMetric(metric: AttributedMetric) {
9570
appCoroutineScope.launch(dispatcherProvider.io()) {
96-
if (!isEnabled()) return@launch
97-
val parameters = metric.getMetricParameters()
98-
// Implement metric emission logic
99-
}
100-
}
101-
102-
// Check if Attributed Metrics is enabled (RemoteConfig) and initialization date is set
103-
private suspend fun isEnabled(): Boolean = dataStore.isEnabled() && dataStore.getInitializationDate() != null
104-
105-
override fun onPrivacyConfigDownloaded() {
106-
appCoroutineScope.launch(dispatcherProvider.io()) {
107-
dataStore.setEnabled(attributedMetricsConfigFeature.self().isEnabled())
71+
if (!metricsState.isActive()) return@launch
72+
val pixelName = metric.getPixelName()
73+
logcat(tag = "AttributedMetrics") {
74+
"Firing pixel for $pixelName"
75+
}
76+
pixel.fire(pixelName = pixelName, parameters = metric.getMetricParameters())
10877
}
10978
}
11079
}

attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/store/AttributedMetricsDataStore.kt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ interface AttributedMetricsDataStore {
3535

3636
suspend fun setEnabled(enabled: Boolean)
3737

38+
suspend fun isActive(): Boolean
39+
40+
suspend fun setActive(active: Boolean)
41+
3842
suspend fun getInitializationDate(): String?
3943

4044
suspend fun setInitializationDate(date: String?)
@@ -50,6 +54,7 @@ class RealAttributedMetricsDataStore @Inject constructor(
5054
) : AttributedMetricsDataStore {
5155
private object Keys {
5256
val IS_ENABLED = booleanPreferencesKey("is_enabled")
57+
val IS_ACTIVE = booleanPreferencesKey("is_active")
5358
val INIT_DATE = stringPreferencesKey("client_init_date")
5459
}
5560

@@ -79,5 +84,13 @@ class RealAttributedMetricsDataStore @Inject constructor(
7984
}
8085
}
8186

87+
override suspend fun isActive(): Boolean = store.data.firstOrNull()?.get(Keys.IS_ACTIVE) ?: false
88+
89+
override suspend fun setActive(active: Boolean) {
90+
store.edit { preferences ->
91+
preferences[Keys.IS_ACTIVE] = active
92+
}
93+
}
94+
8295
override fun observeEnabled(): Flow<Boolean> = enabledState
8396
}

attributed-metrics/readme.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# Attributed Metrics
2-
This module contains utility code for collecting attributed metrics.
2+
This module contains code for collecting attributed metrics.
33

44
## Who can help you better understand this feature?
55
- Cristian Monforte

0 commit comments

Comments
 (0)