Skip to content

Commit

Permalink
Add support for RoW subscriptions (#5327)
Browse files Browse the repository at this point in the history
Task/Issue URL:
https://app.asana.com/0/1205648422731273/1208858958261534/f

### Description

See task.

### Steps to test this PR

See task.

### UI changes
| Before  | After |
| ------ | ----- |
!(Upload before screenshot)|(Upload after screenshot)|


---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1208857997419261
  • Loading branch information
lmac012 authored Dec 2, 2024
1 parent 8254e8e commit 8ebd7d8
Show file tree
Hide file tree
Showing 32 changed files with 632 additions and 111 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ interface Subscriptions {
enum class Product(val value: String) {
NetP("Network Protection"),
ITR("Identity Theft Restoration"),
ROW_ITR("Global Identity Theft Restoration"),
PIR("Data Broker Protection"),
}

Expand Down
10 changes: 5 additions & 5 deletions subscriptions/subscriptions-impl/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -20,20 +20,20 @@
<activity
android:name=".ui.SubscriptionsWebViewActivity"
android:exported="false"
android:label="Subscriptions"
android:label="@string/activitySubscriptions"
android:parentActivityName="com.duckduckgo.app.settings.SettingsActivity"
android:screenOrientation="portrait" />
<activity
android:name=".ui.SubscriptionSettingsActivity"
android:exported="false"
android:label="Subscription Settings"
android:label="@string/activitySubscriptionSettings"
android:parentActivityName="com.duckduckgo.app.settings.SettingsActivity"
android:screenOrientation="portrait" />

<activity
android:name=".ui.RestoreSubscriptionActivity"
android:exported="false"
android:label="Activate Subscription"
android:label="@string/activityActivateSubscription"
android:parentActivityName="com.duckduckgo.app.settings.SettingsActivity"
android:screenOrientation="portrait" />

Expand All @@ -47,13 +47,13 @@
<activity
android:name=".pir.PirActivity"
android:exported="false"
android:label="Privacy Pro"
android:label="@string/activityPir"
android:parentActivityName="com.duckduckgo.app.settings.SettingsActivity"
android:screenOrientation="portrait" />

<activity android:name=".feedback.SubscriptionFeedbackActivity"
android:exported="false"
android:label="Send Feedback"
android:label="@string/activitySendFeedback"
android:parentActivityName=".ui.SubscriptionSettingsActivity"
android:configChanges="orientation|screenSize"
android:windowSoftInputMode="adjustResize"/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,13 @@ interface PrivacyProFeature {

@Toggle.DefaultValue(false)
fun authApiV2(): Toggle

@Toggle.DefaultValue(false)
fun isLaunchedROW(): Toggle

// Kill switch
@Toggle.DefaultValue(true)
fun featuresApi(): Toggle
}

@ContributesBinding(AppScope::class)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/*
* Copyright (c) 2024 DuckDuckGo
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.duckduckgo.subscriptions.impl

import androidx.lifecycle.LifecycleOwner
import com.duckduckgo.app.di.AppCoroutineScope
import com.duckduckgo.app.lifecycle.MainProcessLifecycleObserver
import com.duckduckgo.common.utils.DispatcherProvider
import com.duckduckgo.di.scopes.AppScope
import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.BASIC_SUBSCRIPTION
import com.duckduckgo.subscriptions.impl.billing.PlayBillingManager
import com.duckduckgo.subscriptions.impl.repository.AuthRepository
import com.duckduckgo.subscriptions.impl.services.SubscriptionsService
import com.squareup.anvil.annotations.ContributesMultibinding
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import logcat.logcat

@ContributesMultibinding(
scope = AppScope::class,
boundType = MainProcessLifecycleObserver::class,
)
class SubscriptionFeaturesFetcher @Inject constructor(
@AppCoroutineScope private val appCoroutineScope: CoroutineScope,
private val playBillingManager: PlayBillingManager,
private val subscriptionsService: SubscriptionsService,
private val authRepository: AuthRepository,
private val privacyProFeature: PrivacyProFeature,
private val dispatcherProvider: DispatcherProvider,
) : MainProcessLifecycleObserver {

override fun onCreate(owner: LifecycleOwner) {
super.onCreate(owner)
appCoroutineScope.launch {
try {
if (isFeaturesApiEnabled()) {
fetchSubscriptionFeatures()
}
} catch (e: Exception) {
logcat { "Failed to fetch subscription features" }
}
}
}

private suspend fun isFeaturesApiEnabled(): Boolean = withContext(dispatcherProvider.io()) {
privacyProFeature.featuresApi().isEnabled()
}

private suspend fun fetchSubscriptionFeatures() {
playBillingManager.productsFlow
.first { it.isNotEmpty() }
.find { it.productId == BASIC_SUBSCRIPTION }
?.subscriptionOfferDetails
?.map { it.basePlanId }
?.filter { authRepository.getFeatures(it).isEmpty() }
?.forEach { basePlanId ->
val features = subscriptionsService.features(basePlanId).features
logcat { "Subscription features for base plan $basePlanId fetched: $features" }
if (features.isNotEmpty()) {
authRepository.setFeatures(basePlanId, features.toSet())
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,20 +16,32 @@

package com.duckduckgo.subscriptions.impl

import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.MONTHLY_PLAN_ROW
import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.MONTHLY_PLAN_US
import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.YEARLY_PLAN_ROW
import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.YEARLY_PLAN_US

object SubscriptionsConstants {

// List of subscriptions
const val BASIC_SUBSCRIPTION = "ddg_privacy_pro"
val LIST_OF_PRODUCTS = listOf(BASIC_SUBSCRIPTION)

// List of plans
const val YEARLY_PLAN = "ddg-privacy-pro-yearly-renews-us"
const val MONTHLY_PLAN = "ddg-privacy-pro-monthly-renews-us"
const val YEARLY_PLAN_US = "ddg-privacy-pro-yearly-renews-us"
const val MONTHLY_PLAN_US = "ddg-privacy-pro-monthly-renews-us"
const val YEARLY_PLAN_ROW = "ddg-privacy-pro-yearly-renews-row"
const val MONTHLY_PLAN_ROW = "ddg-privacy-pro-monthly-renews-row"

// List of features
const val NETP = "vpn"
const val ITR = "identity-theft-restoration"
const val PIR = "personal-information-removal"
const val LEGACY_FE_NETP = "vpn"
const val LEGACY_FE_ITR = "identity-theft-restoration"
const val LEGACY_FE_PIR = "personal-information-removal"

const val NETP = "Network Protection"
const val ITR = "Identity Theft Restoration"
const val ROW_ITR = "Global Identity Theft Restoration"
const val PIR = "Data Broker Protection"

// Platform
const val PLATFORM = "android"
Expand All @@ -48,11 +60,9 @@ object SubscriptionsConstants {
}

internal fun String.productIdToBillingPeriod(): String? {
return if (this == SubscriptionsConstants.MONTHLY_PLAN) {
"monthly"
} else if (this == SubscriptionsConstants.YEARLY_PLAN) {
"annual"
} else {
null
return when (this) {
MONTHLY_PLAN_US, MONTHLY_PLAN_ROW -> "monthly"
YEARLY_PLAN_US, YEARLY_PLAN_ROW -> "annual"
else -> null
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,15 @@ import com.duckduckgo.subscriptions.api.SubscriptionStatus.UNKNOWN
import com.duckduckgo.subscriptions.api.SubscriptionStatus.WAITING
import com.duckduckgo.subscriptions.impl.RealSubscriptionsManager.RecoverSubscriptionResult
import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.BASIC_SUBSCRIPTION
import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.MONTHLY_PLAN
import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.YEARLY_PLAN
import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.LEGACY_FE_ITR
import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.LEGACY_FE_NETP
import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.LEGACY_FE_PIR
import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.MONTHLY_PLAN_ROW
import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.MONTHLY_PLAN_US
import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.NETP
import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.ROW_ITR
import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.YEARLY_PLAN_ROW
import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.YEARLY_PLAN_US
import com.duckduckgo.subscriptions.impl.auth2.AccessTokenClaims
import com.duckduckgo.subscriptions.impl.auth2.AuthClient
import com.duckduckgo.subscriptions.impl.auth2.AuthJwtValidator
Expand Down Expand Up @@ -83,6 +90,7 @@ import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.onSubscription
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import logcat.LogPriority
import logcat.logcat
import retrofit2.HttpException
Expand Down Expand Up @@ -259,8 +267,8 @@ class RealSubscriptionsManager @Inject constructor(
return authRepository.getRefreshTokenV2() != null
}

private suspend fun shouldUseAuthV2(): Boolean {
return privacyProFeature.get().authApiV2().isEnabled() || isSignedInV2()
private suspend fun shouldUseAuthV2(): Boolean = withContext(dispatcherProvider.io()) {
privacyProFeature.get().authApiV2().isEnabled() || isSignedInV2()
}

private fun emitEntitlementsValues() {
Expand Down Expand Up @@ -648,15 +656,39 @@ class RealSubscriptionsManager @Inject constructor(
override suspend fun getSubscriptionOffer(): SubscriptionOffer? =
playBillingManager.products
.find { it.productId == BASIC_SUBSCRIPTION }
?.run {
val monthlyOffer = subscriptionOfferDetails?.find { it.basePlanId == MONTHLY_PLAN } ?: return@run null
val yearlyOffer = subscriptionOfferDetails?.find { it.basePlanId == YEARLY_PLAN } ?: return@run null
?.subscriptionOfferDetails
.orEmpty()
.associateBy { it.basePlanId }
.let { availablePlans ->
when {
availablePlans.keys.containsAll(listOf(MONTHLY_PLAN_US, YEARLY_PLAN_US)) -> {
availablePlans.getValue(MONTHLY_PLAN_US) to availablePlans.getValue(YEARLY_PLAN_US)
}
availablePlans.keys.containsAll(listOf(MONTHLY_PLAN_ROW, YEARLY_PLAN_ROW)) && isLaunchedRow() -> {
availablePlans.getValue(MONTHLY_PLAN_ROW) to availablePlans.getValue(YEARLY_PLAN_ROW)
}
else -> null
}
}
?.let { (monthlyOffer, yearlyOffer) ->
val features = if (privacyProFeature.get().featuresApi().isEnabled()) {
authRepository.getFeatures(monthlyOffer.basePlanId)
} else {
when (monthlyOffer.basePlanId) {
MONTHLY_PLAN_US -> setOf(LEGACY_FE_NETP, LEGACY_FE_PIR, LEGACY_FE_ITR)
MONTHLY_PLAN_ROW -> setOf(NETP, ROW_ITR)
else -> throw IllegalStateException()
}
}

if (features.isEmpty()) return@let null

SubscriptionOffer(
monthlyPlanId = monthlyOffer.basePlanId,
monthlyFormattedPrice = monthlyOffer.pricingPhases.pricingPhaseList.first().formattedPrice,
yearlyPlanId = yearlyOffer.basePlanId,
yearlyFormattedPrice = yearlyOffer.pricingPhases.pricingPhaseList.first().formattedPrice,
features = features,
)
}

Expand Down Expand Up @@ -839,6 +871,10 @@ class RealSubscriptionsManager @Inject constructor(
}
}

private suspend fun isLaunchedRow(): Boolean = withContext(dispatcherProvider.io()) {
privacyProFeature.get().isLaunchedROW().isEnabled()
}

private fun parseError(e: HttpException): ResponseError? {
return try {
val error = adapter.fromJson(e.response()?.errorBody()?.string().orEmpty())
Expand Down Expand Up @@ -904,6 +940,7 @@ data class SubscriptionOffer(
val monthlyFormattedPrice: String,
val yearlyPlanId: String,
val yearlyFormattedPrice: String,
val features: Set<String>,
)

data class ValidatedTokenPair(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,9 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
Expand All @@ -60,6 +62,7 @@ import logcat.logcat

interface PlayBillingManager {
val products: List<ProductDetails>
val productsFlow: Flow<List<ProductDetails>>
val purchaseHistory: List<PurchaseHistoryRecord>
val purchaseState: Flow<PurchaseState>

Expand Down Expand Up @@ -94,7 +97,13 @@ class RealPlayBillingManager @Inject constructor(
override val purchaseState = _purchaseState.asSharedFlow()

// New Subscription ProductDetails
override var products = emptyList<ProductDetails>()
private var _products = MutableStateFlow(emptyList<ProductDetails>())

override val products: List<ProductDetails>
get() = _products.value

override val productsFlow: Flow<List<ProductDetails>>
get() = _products.asStateFlow()

// Purchase History
override var purchaseHistory = emptyList<PurchaseHistoryRecord>()
Expand Down Expand Up @@ -240,7 +249,7 @@ class RealPlayBillingManager @Inject constructor(
if (result.products.isEmpty()) {
logcat { "No products found" }
}
this.products = result.products
_products.value = result.products
}

is SubscriptionsResult.Failure -> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,20 +18,31 @@ package com.duckduckgo.subscriptions.impl.feedback

import android.os.Bundle
import android.view.View
import androidx.core.view.isVisible
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.withStarted
import com.duckduckgo.anvil.annotations.InjectWith
import com.duckduckgo.common.ui.viewbinding.viewBinding
import com.duckduckgo.di.scopes.FragmentScope
import com.duckduckgo.subscriptions.impl.R
import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.MONTHLY_PLAN_US
import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.YEARLY_PLAN_US
import com.duckduckgo.subscriptions.impl.databinding.ContentFeedbackCategoryBinding
import com.duckduckgo.subscriptions.impl.feedback.SubscriptionFeedbackCategory.ITR
import com.duckduckgo.subscriptions.impl.feedback.SubscriptionFeedbackCategory.PIR
import com.duckduckgo.subscriptions.impl.feedback.SubscriptionFeedbackCategory.SUBS_AND_PAYMENTS
import com.duckduckgo.subscriptions.impl.feedback.SubscriptionFeedbackCategory.VPN
import com.duckduckgo.subscriptions.impl.repository.AuthRepository
import javax.inject.Inject
import kotlinx.coroutines.launch

@InjectWith(FragmentScope::class)
class SubscriptionFeedbackCategoryFragment : SubscriptionFeedbackFragment(R.layout.content_feedback_category) {
private val binding: ContentFeedbackCategoryBinding by viewBinding()

@Inject
lateinit var authRepository: AuthRepository

override fun onViewCreated(
view: View,
savedInstanceState: Bundle?,
Expand All @@ -51,6 +62,18 @@ class SubscriptionFeedbackCategoryFragment : SubscriptionFeedbackFragment(R.layo
binding.categoryPir.setOnClickListener {
listener.onUserClickedCategory(PIR)
}

lifecycleScope.launch {
val pirAvailable = isPirCategoryAvailable()
withStarted {
binding.categoryPir.isVisible = pirAvailable
}
}
}

private suspend fun isPirCategoryAvailable(): Boolean {
val subscription = authRepository.getSubscription() ?: return false
return subscription.productId in listOf(MONTHLY_PLAN_US, YEARLY_PLAN_US)
}

interface Listener {
Expand Down
Loading

0 comments on commit 8ebd7d8

Please sign in to comment.