Skip to content

Commit b3c04fc

Browse files
committed
Subscription status attributed metric implementation
1 parent 5e1bfbd commit b3c04fc

File tree

10 files changed

+705
-0
lines changed

10 files changed

+705
-0
lines changed

subscriptions/subscriptions-impl/build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ dependencies {
6262
implementation project(':content-scope-scripts-api')
6363
implementation project(':duckchat-api')
6464
implementation project(':pir-api')
65+
implementation project(':attributed-metrics-api')
6566

6667
implementation AndroidX.appCompat
6768
implementation KotlinX.coroutines.core

subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsManager.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -512,6 +512,7 @@ class RealSubscriptionsManager @Inject constructor(
512512
authRepository.setAccount(null)
513513
authRepository.setSubscription(null)
514514
authRepository.setEntitlements(emptyList())
515+
authRepository.removeLocalPurchasedAt()
515516
_isSignedIn.emit(false)
516517
_subscriptionStatus.emit(UNKNOWN)
517518
_entitlements.emit(emptyList())
@@ -594,6 +595,7 @@ class RealSubscriptionsManager @Inject constructor(
594595
pixelSender.reportSubscriptionActivated()
595596
emitEntitlementsValues()
596597
_currentPurchaseState.emit(CurrentPurchase.Success)
598+
authRepository.registerLocalPurchasedAt()
597599
subscriptionPurchaseWideEvent.onPurchaseConfirmationSuccess()
598600
} else {
599601
handlePurchaseFailed()
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
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.subscriptions.impl.metrics
18+
19+
import androidx.lifecycle.LifecycleOwner
20+
import com.duckduckgo.app.attributed.metrics.api.AttributedMetric
21+
import com.duckduckgo.app.attributed.metrics.api.AttributedMetricClient
22+
import com.duckduckgo.app.attributed.metrics.api.AttributedMetricConfig
23+
import com.duckduckgo.app.attributed.metrics.api.MetricBucket
24+
import com.duckduckgo.app.di.AppCoroutineScope
25+
import com.duckduckgo.app.lifecycle.MainProcessLifecycleObserver
26+
import com.duckduckgo.common.utils.DispatcherProvider
27+
import com.duckduckgo.di.scopes.AppScope
28+
import com.duckduckgo.subscriptions.api.SubscriptionStatus
29+
import com.duckduckgo.subscriptions.impl.SubscriptionsManager
30+
import com.duckduckgo.subscriptions.impl.repository.AuthRepository
31+
import com.squareup.anvil.annotations.ContributesMultibinding
32+
import dagger.SingleInstanceIn
33+
import kotlinx.coroutines.CoroutineScope
34+
import kotlinx.coroutines.CoroutineStart.LAZY
35+
import kotlinx.coroutines.Deferred
36+
import kotlinx.coroutines.async
37+
import kotlinx.coroutines.flow.distinctUntilChanged
38+
import kotlinx.coroutines.launch
39+
import logcat.logcat
40+
import java.time.Instant
41+
import java.time.ZoneId
42+
import java.time.temporal.ChronoUnit
43+
import javax.inject.Inject
44+
45+
@ContributesMultibinding(AppScope::class, AttributedMetric::class)
46+
@ContributesMultibinding(AppScope::class, MainProcessLifecycleObserver::class)
47+
@SingleInstanceIn(AppScope::class)
48+
class SubscriptionStatusAttributedMetric @Inject constructor(
49+
@AppCoroutineScope private val appCoroutineScope: CoroutineScope,
50+
private val dispatcherProvider: DispatcherProvider,
51+
private val attributedMetricClient: AttributedMetricClient,
52+
private val authRepository: AuthRepository,
53+
private val attributedMetricConfig: AttributedMetricConfig,
54+
private val subscriptionsManager: SubscriptionsManager,
55+
) : AttributedMetric, MainProcessLifecycleObserver {
56+
57+
companion object {
58+
private const val PIXEL_NAME = "user_subscribed"
59+
private const val FEATURE_TOGGLE_NAME = "subscriptionRetention"
60+
private const val FEATURE_EMIT_TOGGLE_NAME = "canEmitSubscriptionRetention"
61+
}
62+
63+
private val isEnabled: Deferred<Boolean> = appCoroutineScope.async(start = LAZY) {
64+
getToggle(FEATURE_TOGGLE_NAME)?.isEnabled() ?: false
65+
}
66+
67+
private val canEmit: Deferred<Boolean> = appCoroutineScope.async(start = LAZY) {
68+
getToggle(FEATURE_EMIT_TOGGLE_NAME)?.isEnabled() ?: false
69+
}
70+
71+
private val bucketConfig: Deferred<MetricBucket> = appCoroutineScope.async(start = LAZY) {
72+
attributedMetricConfig.getBucketConfiguration()[PIXEL_NAME] ?: MetricBucket(
73+
buckets = listOf(0, 1),
74+
version = 0,
75+
)
76+
}
77+
78+
override fun onCreate(owner: LifecycleOwner) {
79+
appCoroutineScope.launch(dispatcherProvider.io()) {
80+
if (!isEnabled.await() || !canEmit.await()) {
81+
logcat(tag = "AttributedMetrics") {
82+
"SubscriptionStatusAttributedMetric disabled"
83+
}
84+
return@launch
85+
}
86+
subscriptionsManager.subscriptionStatus.distinctUntilChanged().collect { status ->
87+
logcat(tag = "AttributedMetrics") {
88+
"SubscriptionStatusAttributedMetric subscription status changed: $status"
89+
}
90+
if (shouldSendPixel()) {
91+
logcat(tag = "AttributedMetrics") {
92+
"SubscriptionStatusAttributedMetric emitting metric on status change"
93+
}
94+
attributedMetricClient.emitMetric(
95+
this@SubscriptionStatusAttributedMetric,
96+
)
97+
}
98+
}
99+
}
100+
}
101+
102+
override fun getPixelName(): String = PIXEL_NAME
103+
104+
override suspend fun getMetricParameters(): Map<String, String> {
105+
val daysSinceSubscribed = daysSinceSubscribed()
106+
if (daysSinceSubscribed == -1) {
107+
return emptyMap() // Should not happen as we check enrollment before sending the pixel
108+
}
109+
val isOnTrial = authRepository.isFreeTrialActive()
110+
val params = mutableMapOf(
111+
"month" to getBucketValue(daysSinceSubscribed, isOnTrial).toString(),
112+
)
113+
return params
114+
}
115+
116+
override suspend fun getTag(): String {
117+
val daysSinceSubscribed = daysSinceSubscribed()
118+
val isOnTrial = authRepository.isFreeTrialActive()
119+
return getBucketValue(daysSinceSubscribed, isOnTrial).toString()
120+
}
121+
122+
private suspend fun shouldSendPixel(): Boolean {
123+
val isActive = isSubscriptionActive()
124+
logcat(tag = "AttributedMetrics") {
125+
"SubscriptionStatusAttributedMetric shouldSendPixel isActive: $isActive"
126+
}
127+
val enrolled = daysSinceSubscribed() != -1
128+
logcat(tag = "AttributedMetrics") {
129+
"SubscriptionStatusAttributedMetric shouldSendPixel enrolled: $enrolled daysSinceSubscribed() = ${daysSinceSubscribed()}"
130+
}
131+
return isActive && enrolled
132+
}
133+
134+
private suspend fun isSubscriptionActive(): Boolean {
135+
return authRepository.getStatus() == SubscriptionStatus.AUTO_RENEWABLE ||
136+
authRepository.getStatus() == SubscriptionStatus.NOT_AUTO_RENEWABLE
137+
}
138+
139+
private suspend fun daysSinceSubscribed(): Int {
140+
return authRepository.getLocalPurchasedAt()?.let { nonNullStartedAt ->
141+
val etZone = ZoneId.of("America/New_York")
142+
val installInstant = Instant.ofEpochMilli(nonNullStartedAt)
143+
val nowInstant = Instant.now()
144+
145+
val installInEt = installInstant.atZone(etZone)
146+
val nowInEt = nowInstant.atZone(etZone)
147+
148+
return ChronoUnit.DAYS.between(installInEt.toLocalDate(), nowInEt.toLocalDate()).toInt()
149+
} ?: -1
150+
}
151+
152+
private suspend fun getBucketValue(
153+
days: Int,
154+
isOnTrial: Boolean,
155+
): Int {
156+
if (isOnTrial) {
157+
return 0
158+
}
159+
160+
// Calculate which month the user is in (1-based)
161+
// Each 28 days is a new month
162+
val monthNumber = days / 28 + 1
163+
164+
// Get the bucket configuration
165+
val buckets = bucketConfig.await().buckets
166+
return buckets.indexOfFirst { bucket -> monthNumber <= bucket }.let { index ->
167+
if (index == -1) buckets.size else index
168+
}
169+
}
170+
171+
private suspend fun getToggle(toggleName: String) =
172+
attributedMetricConfig.metricsToggles().firstOrNull { toggle ->
173+
toggle.featureName().name == toggleName
174+
}
175+
}

subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/repository/AuthRepository.kt

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,10 @@ interface AuthRepository {
6464
suspend fun canSupportEncryption(): Boolean
6565
suspend fun setFeatures(basePlanId: String, features: Set<String>)
6666
suspend fun getFeatures(basePlanId: String): Set<String>
67+
suspend fun isFreeTrialActive(): Boolean
68+
suspend fun registerLocalPurchasedAt()
69+
suspend fun getLocalPurchasedAt(): Long?
70+
suspend fun removeLocalPurchasedAt()
6771
}
6872

6973
@Module
@@ -233,6 +237,22 @@ internal class RealAuthRepository constructor(
233237
val accessToken = subscriptionsDataStore.run { accessTokenV2 ?: accessToken }
234238
serpPromo.injectCookie(accessToken)
235239
}
240+
241+
override suspend fun isFreeTrialActive(): Boolean {
242+
return subscriptionsDataStore.freeTrialActive
243+
}
244+
245+
override suspend fun registerLocalPurchasedAt() {
246+
subscriptionsDataStore.localPurchasedAt = System.currentTimeMillis()
247+
}
248+
249+
override suspend fun getLocalPurchasedAt(): Long? {
250+
return subscriptionsDataStore.localPurchasedAt
251+
}
252+
253+
override suspend fun removeLocalPurchasedAt() {
254+
subscriptionsDataStore.localPurchasedAt = null
255+
}
236256
}
237257

238258
data class AccessToken(

subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/store/SubscriptionsDataStore.kt

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ interface SubscriptionsDataStore {
3838
var expiresOrRenewsAt: Long?
3939
var billingPeriod: String?
4040
var startedAt: Long?
41+
42+
// Local purchased at time, not from server or other devices
43+
var localPurchasedAt: Long?
4144
var platform: String?
4245
var status: String?
4346
var entitlements: String?
@@ -197,6 +200,18 @@ internal class SubscriptionsEncryptedDataStore(
197200
}
198201
}
199202

203+
override var localPurchasedAt: Long?
204+
get() = encryptedPreferences?.getLong(KEY_LOCAL_PURCHASED_AT, 0L).takeIf { it != 0L }
205+
set(value) {
206+
encryptedPreferences?.edit(commit = true) {
207+
if (value == null) {
208+
remove(KEY_LOCAL_PURCHASED_AT)
209+
} else {
210+
putLong(KEY_LOCAL_PURCHASED_AT, value)
211+
}
212+
}
213+
}
214+
200215
override var billingPeriod: String?
201216
get() = encryptedPreferences?.getString(KEY_BILLING_PERIOD, null)
202217
set(value) {
@@ -231,6 +246,7 @@ internal class SubscriptionsEncryptedDataStore(
231246
const val KEY_EXTERNAL_ID = "KEY_EXTERNAL_ID"
232247
const val KEY_EXPIRES_OR_RENEWS_AT = "KEY_EXPIRES_OR_RENEWS_AT"
233248
const val KEY_STARTED_AT = "KEY_STARTED_AT"
249+
const val KEY_LOCAL_PURCHASED_AT = "KEY_LOCAL_PURCHASED_AT"
234250
const val KEY_BILLING_PERIOD = "KEY_BILLING_PERIOD"
235251
const val KEY_ENTITLEMENTS = "KEY_ENTITLEMENTS"
236252
const val KEY_STATUS = "KEY_STATUS"

0 commit comments

Comments
 (0)