Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -243,9 +243,6 @@ interface PrivacyProFeature {
@Toggle.DefaultValue(DefaultFeatureValue.TRUE)
fun refreshSubscriptionPlanFeatures(): Toggle

@Toggle.DefaultValue(DefaultFeatureValue.TRUE)
fun useClientWithCacheForFeatures(): Toggle

@Toggle.DefaultValue(DefaultFeatureValue.TRUE)
fun supportsAlternateStripePaymentFlow(): Toggle

Expand All @@ -272,6 +269,14 @@ interface PrivacyProFeature {

@Toggle.DefaultValue(defaultValue = DefaultFeatureValue.TRUE)
fun sendFreeTrialConversionWideEvent(): Toggle

/**
* When enabled, the native app will respond to the getSubscriptionTierOptions message
* with the new tier-based payload structure supporting Plus/Pro tiers.
* The flag is exposed to FE via getFeatureConfig.
*/
@Toggle.DefaultValue(DefaultFeatureValue.TRUE)
fun tierMessagingEnabled(): Toggle
}

@ContributesBinding(AppScope::class)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,9 @@ import com.duckduckgo.common.utils.DispatcherProvider
import com.duckduckgo.di.scopes.AppScope
import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.BASIC_SUBSCRIPTION
import com.duckduckgo.subscriptions.impl.billing.PlayBillingManager
import com.duckduckgo.subscriptions.impl.model.Entitlement
import com.duckduckgo.subscriptions.impl.repository.AuthRepository
import com.duckduckgo.subscriptions.impl.services.SubscriptionsCachedService
import com.duckduckgo.subscriptions.impl.services.SubscriptionsService
import com.squareup.anvil.annotations.ContributesMultibinding
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.firstOrNull
Expand All @@ -41,7 +41,6 @@ import javax.inject.Inject
class SubscriptionFeaturesFetcher @Inject constructor(
@AppCoroutineScope private val appCoroutineScope: CoroutineScope,
private val playBillingManager: PlayBillingManager,
private val subscriptionsService: SubscriptionsService,
private val subscriptionsCachedService: SubscriptionsCachedService,
private val authRepository: AuthRepository,
private val privacyProFeature: PrivacyProFeature,
Expand Down Expand Up @@ -82,14 +81,19 @@ class SubscriptionFeaturesFetcher @Inject constructor(
}
}
?.forEach { basePlanId ->
val features = if (privacyProFeature.useClientWithCacheForFeatures().isEnabled()) {
subscriptionsCachedService.features(basePlanId).features
if (privacyProFeature.tierMessagingEnabled().isEnabled()) {
val features = subscriptionsCachedService.featuresV2(basePlanId).features[basePlanId] ?: emptyList()
logcat { "Subscription features for base plan $basePlanId fetched: $features" }
if (features.isNotEmpty()) {
val entitlements = features.map { Entitlement(name = it.name, product = it.product) }.toSet()
authRepository.setFeaturesV2(basePlanId, entitlements)
}
} else {
subscriptionsService.features(basePlanId).features
}
logcat { "Subscription features for base plan $basePlanId fetched: $features" }
if (features.isNotEmpty()) {
authRepository.setFeatures(basePlanId, features.toSet())
val features = subscriptionsCachedService.features(basePlanId).features
logcat { "Subscription features for base plan $basePlanId fetched: $features" }
if (features.isNotEmpty()) {
authRepository.setFeatures(basePlanId, features.toSet())
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import com.duckduckgo.subscriptions.impl.billing.PurchaseState
import com.duckduckgo.subscriptions.impl.billing.RetryPolicy
import com.duckduckgo.subscriptions.impl.billing.SubscriptionReplacementMode
import com.duckduckgo.subscriptions.impl.billing.retry
import com.duckduckgo.subscriptions.impl.model.Entitlement
import com.duckduckgo.subscriptions.impl.pixels.SubscriptionFailureErrorType
import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixelSender
import com.duckduckgo.subscriptions.impl.repository.AccessToken
Expand Down Expand Up @@ -115,7 +116,10 @@ import kotlin.time.Duration.Companion.milliseconds

interface SubscriptionsManager {
/**
* Returns available purchase options retrieved from Play Store
* Returns available purchase options retrieved from Play Store.
* Works seamlessly regardless of whether tierMessagingEnabled flag is on or off.
* When flag is off, entitlements are constructed from legacy feature strings with default tier "plus".
* When flag is on, actual entitlements and tier information from the API are used.
*/
suspend fun getSubscriptionOffer(): List<SubscriptionOffer>

Expand Down Expand Up @@ -763,7 +767,7 @@ class RealSubscriptionsManager @Inject constructor(
val subscription = authRepository.getSubscription()

return if (subscription != null) {
getFeaturesInternal(subscription.productId)
getEntitlementsForPlan(subscription.productId).map { it.product }.toSet()
} else {
emptySet()
}
Expand Down Expand Up @@ -1102,20 +1106,39 @@ class RealSubscriptionsManager @Inject constructor(
)
}

val features = getFeaturesInternal(offer.basePlanId)
val entitlements = getEntitlementsForPlan(offer.basePlanId)

if (features.isEmpty()) return@let emptyList()
if (entitlements.isEmpty()) return@let emptyList()

SubscriptionOffer(
planId = offer.basePlanId,
tier = "plus", // Temporary placeholder until we have support multiple tiers
pricingPhases = pricingPhases,
offerId = offer.offerId,
features = features,
entitlements = entitlements,
)
}
}

private suspend fun getFeaturesInternal(planId: String): Set<String> {
/**
* Returns entitlements for a plan, working seamlessly regardless of flag state.
* When tierMessagingEnabled is ON: Uses actual entitlements from V2 API, with fallback to legacy.
* When tierMessagingEnabled is OFF: Converts legacy features to entitlements with default tier "plus".
*/
private suspend fun getEntitlementsForPlan(planId: String): Set<Entitlement> {
if (privacyProFeature.get().tierMessagingEnabled().isEnabled()) {
val v2Entitlements = authRepository.getFeaturesV2(planId)
if (v2Entitlements.isNotEmpty()) {
return v2Entitlements
}
// Fallback to legacy features for smooth runtime flag transitions
}
return getLegacyFeatures(planId).map { feature ->
Entitlement(name = "plus", product = feature) // Temporary name placeholder until we have support multiple tiers
}.toSet()
}

private suspend fun getLegacyFeatures(planId: String): Set<String> {
return if (privacyProFeature.get().featuresApi().isEnabled()) {
authRepository.getFeatures(planId)
} else {
Expand Down Expand Up @@ -1442,9 +1465,16 @@ sealed class CurrentPurchase {
data class SubscriptionOffer(
val planId: String,
val offerId: String?,
val tier: String,
val pricingPhases: List<PricingPhase>,
val features: Set<String>,
)
val entitlements: Set<Entitlement>,
) {
/**
* Returns the set of feature/product names from entitlements.
* Provided for backward compatibility with code that used the legacy features set.
*/
val features: Set<String> get() = entitlements.map { it.product }.toSet()
}

data class PricingPhase(
val priceAmount: BigDecimal,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ class SubscriptionMessagingInterface @Inject constructor(
override val methods: List<String> = listOf(
"subscriptionSelected",
"getSubscriptionOptions",
"getSubscriptionTierOptions",
"backToSettings",
"activateSubscription",
"featureSelected",
Expand Down Expand Up @@ -409,10 +410,12 @@ class SubscriptionMessagingInterface @Inject constructor(
val authV2Enabled = privacyProFeature.enableSubscriptionFlowsV2().isEnabled()
val duckAiSubscriberModelsEnabled = privacyProFeature.duckAiPlus().isEnabled()
val supportsAlternateStripePaymentFlow = privacyProFeature.supportsAlternateStripePaymentFlow().isEnabled()
val useGetSubscriptionTierOptions = privacyProFeature.tierMessagingEnabled().isEnabled()
val resultJson = JSONObject().apply {
put("useSubscriptionsAuthV2", authV2Enabled)
put("usePaidDuckAi", duckAiSubscriberModelsEnabled)
put("useAlternateStripePaymentFlow", supportsAlternateStripePaymentFlow)
put("useGetSubscriptionTierOptions", useGetSubscriptionTierOptions)
}

val response = JsRequestResponse.Success(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import com.duckduckgo.subscriptions.api.SubscriptionStatus.INACTIVE
import com.duckduckgo.subscriptions.api.SubscriptionStatus.NOT_AUTO_RENEWABLE
import com.duckduckgo.subscriptions.api.SubscriptionStatus.UNKNOWN
import com.duckduckgo.subscriptions.api.SubscriptionStatus.WAITING
import com.duckduckgo.subscriptions.impl.PrivacyProFeature
import com.duckduckgo.subscriptions.impl.model.Entitlement
import com.duckduckgo.subscriptions.impl.serp_promo.SerpPromo
import com.duckduckgo.subscriptions.impl.store.SubscriptionsDataStore
Expand Down Expand Up @@ -64,6 +65,8 @@ interface AuthRepository {
suspend fun canSupportEncryption(): Boolean
suspend fun setFeatures(basePlanId: String, features: Set<String>)
suspend fun getFeatures(basePlanId: String): Set<String>
suspend fun setFeaturesV2(basePlanId: String, features: Set<Entitlement>)
suspend fun getFeaturesV2(basePlanId: String): Set<Entitlement>
suspend fun isFreeTrialActive(): Boolean
suspend fun registerLocalPurchasedAt()
suspend fun getLocalPurchasedAt(): Long?
Expand All @@ -79,15 +82,17 @@ object AuthRepositoryModule {
dispatcherProvider: DispatcherProvider,
sharedPreferencesProvider: SharedPreferencesProvider,
serpPromo: SerpPromo,
privacyProFeature: dagger.Lazy<PrivacyProFeature>,
): AuthRepository {
return RealAuthRepository(SubscriptionsEncryptedDataStore(sharedPreferencesProvider), dispatcherProvider, serpPromo)
return RealAuthRepository(SubscriptionsEncryptedDataStore(sharedPreferencesProvider), dispatcherProvider, serpPromo, privacyProFeature)
}
}

internal class RealAuthRepository constructor(
private val subscriptionsDataStore: SubscriptionsDataStore,
private val dispatcherProvider: DispatcherProvider,
private val serpPromo: SerpPromo,
private val privacyProFeature: dagger.Lazy<PrivacyProFeature>,
) : AuthRepository {

private val moshi = Builder().build()
Expand All @@ -101,6 +106,12 @@ internal class RealAuthRepository constructor(
moshi.adapter<Map<String, Set<String>>>(type)
}

private val featuresV2Adapter by lazy {
val entitlementSetType = Types.newParameterizedType(Set::class.java, Entitlement::class.java)
val mapType = Types.newParameterizedType(Map::class.java, String::class.java, entitlementSetType)
moshi.adapter<Map<String, Set<Entitlement>>>(mapType)
}

private inline fun <reified T> Moshi.listToJson(list: List<T>): String {
return adapter<List<T>>(Types.newParameterizedType(List::class.java, T::class.java)).toJson(list)
}
Expand Down Expand Up @@ -228,11 +239,37 @@ internal class RealAuthRepository constructor(
}

override suspend fun getFeatures(basePlanId: String): Set<String> = withContext(dispatcherProvider.io()) {
subscriptionsDataStore.subscriptionFeatures
if (privacyProFeature.get().tierMessagingEnabled().isEnabled()) {
// When flag is ON, try v2 first
val v2Features = getFeaturesV2(basePlanId)
if (v2Features.isNotEmpty()) {
return@withContext v2Features.map { it.product }.toSet()
}
// Fallback to v1 for smooth runtime flag transitions (until fetcher runs on next app start)
}
// Use v1 storage
return@withContext subscriptionsDataStore.subscriptionFeatures
?.let(featuresAdapter::fromJson)
?.get(basePlanId) ?: emptySet()
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Missing V2 fallback when tier flag toggled OFF

The flag transition logic has an asymmetry that could cause issues during rollback. When tierMessagingEnabled is ON, getFeatures falls back from V2 to V1 if V2 is empty. However, when the flag is OFF, it only reads from V1 with no fallback to V2. Combined with the fetcher which exclusively populates V2 when flag is ON (and V1 when flag is OFF), this means: if a user's app ran with the flag ON (V2 populated, V1 empty), and the flag is later toggled OFF remotely, getFeatures returns an empty set until the app restarts and the fetcher repopulates V1. This could temporarily hide subscription offers from affected users during emergency flag rollbacks.


Please tell me if this was useful or not with a 👍 or 👎.

Additional Locations (1)

Fix in Cursor Fix in Web

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Expected scenario: Users are in legacy version, we will enable FF for v2.

  • Transition should work
  • We offer a fallback to cover FF change during runtime

Moving from v2 -> v1, not expected. If happens, restarting the app will fix. I think tradeoff is acceptable


override suspend fun setFeaturesV2(
basePlanId: String,
features: Set<Entitlement>,
) = withContext(dispatcherProvider.io()) {
val featuresMap = subscriptionsDataStore.subscriptionEntitlements
?.let(featuresV2Adapter::fromJson)
?.toMutableMap() ?: mutableMapOf()
featuresMap[basePlanId] = features
subscriptionsDataStore.subscriptionEntitlements = featuresV2Adapter.toJson(featuresMap)
}

override suspend fun getFeaturesV2(basePlanId: String): Set<Entitlement> = withContext(dispatcherProvider.io()) {
subscriptionsDataStore.subscriptionEntitlements
?.let(featuresV2Adapter::fromJson)
?.get(basePlanId) ?: emptySet()
}

private suspend fun updateSerpPromoCookie() = withContext(dispatcherProvider.io()) {
val accessToken = subscriptionsDataStore.run { accessTokenV2 ?: accessToken }
serpPromo.injectCookie(accessToken)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,22 @@ package com.duckduckgo.subscriptions.impl.services

import retrofit2.http.GET
import retrofit2.http.Path
import retrofit2.http.Query

interface SubscriptionsCachedService {
@Deprecated("Use featuresV2 instead")
@GET("https://subscriptions.duckduckgo.com/api/products/{sku}/features")
suspend fun features(@Path("sku") sku: String): FeaturesResponse

@GET("https://subscriptions.duckduckgo.com/api/v2/features")
suspend fun featuresV2(@Query("sku") sku: String): FeaturesV2Response
}

data class FeaturesV2Response(
val features: Map<String, List<TierFeatureResponse>>,
)

data class TierFeatureResponse(
val product: String,
val name: String, // e.g. "Plus", "Pro" (Tier)
)
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ interface SubscriptionsDataStore {
var freeTrialActive: Boolean

var subscriptionFeatures: String?

var subscriptionEntitlements: String?
fun canUseEncryption(): Boolean
}

Expand Down Expand Up @@ -228,6 +228,14 @@ internal class SubscriptionsEncryptedDataStore(
}
}

override var subscriptionEntitlements: String?
get() = encryptedPreferences?.getString(KEY_SUBSCRIPTION_ENTITLEMENTS, null)
set(value) {
encryptedPreferences?.edit(commit = true) {
putString(KEY_SUBSCRIPTION_ENTITLEMENTS, value)
}
}

override fun canUseEncryption(): Boolean {
encryptedPreferences?.edit(commit = true) { putBoolean("test", true) }
return encryptedPreferences?.getBoolean("test", false) == true
Expand All @@ -252,6 +260,7 @@ internal class SubscriptionsEncryptedDataStore(
const val KEY_STATUS = "KEY_STATUS"
const val KEY_PRODUCT_ID = "KEY_PRODUCT_ID"
const val KEY_SUBSCRIPTION_FEATURES = "KEY_SUBSCRIPTION_FEATURES"
const val KEY_SUBSCRIPTION_ENTITLEMENTS = "KEY_SUBSCRIPTION_ENTITLEMENTS"
const val KEY_FREE_TRIAL_ACTIVE = "KEY_FREE_TRIAL_ACTIVE"
}
}
Loading
Loading