Skip to content

Commit 607f8bf

Browse files
authored
Use http client with cache support on subscriptions feature endpoint (#6545)
Task/Issue URL: https://app.asana.com/1/137249556945/project/488551667048375/task/1210997377644545?focus=true ### Description Adds cache support to subscription feature endpoints. It uses custom one to avoid being affected by other network requests and have a better cache hit rate. Also adds the file as fire button exception, same we do for the other api requests ### Steps to test this PR _Feature 1_ - [x] Add the following to your logcat filter `message~:"Subscription features for base plan|Subscriptions response came from"` - [x] Checkout this branch - [x] Apply patch attached in https://app.asana.com/1/137249556945/project/488551667048375/task/1210997377644545?focus=true - [x] Fresh install - [x] Check in the logs you hit the Network and you receive the features - [x] Fire button - [x] Check in the logs you hit the Cache - [x] Kill the app manually, open it again - [x] Check in the logs you hit the Cache ### UI changes | Before | After | | ------ | ----- | !(Upload before screenshot)|(Upload after screenshot)|
1 parent 07ea263 commit 607f8bf

File tree

6 files changed

+142
-1
lines changed

6 files changed

+142
-1
lines changed

app/src/main/java/com/duckduckgo/app/fire/AppCacheClearer.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import android.content.Context
2020
import com.duckduckgo.app.browser.favicon.FileBasedFaviconPersister.Companion.FAVICON_PERSISTED_DIR
2121
import com.duckduckgo.app.global.api.NetworkApiCache
2222
import com.duckduckgo.app.global.file.FileDeleter
23+
import com.duckduckgo.subscriptions.impl.services.SubscriptionNetworkModule.Companion.SUBSCRIPTION_CACHE_FILE
2324

2425
interface AppCacheClearer {
2526

@@ -52,7 +53,10 @@ class AndroidAppCacheClearer(
5253
*/
5354
private const val NETWORK_CACHE_DIR = NetworkApiCache.FILE_NAME
5455

56+
// TODO: We need to allow other modules to contribute this list
57+
// https://app.asana.com/1/137249556945/project/1149059203486286/task/1210997578538480?focus=true
5558
private val FILENAMES_EXCLUDED_FROM_DELETION = listOf(
59+
SUBSCRIPTION_CACHE_FILE,
5660
WEBVIEW_CACHE_DIR,
5761
WEBVIEW_CACHE_DIR_LEGACY,
5862
NETWORK_CACHE_DIR,

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,9 @@ interface PrivacyProFeature {
252252

253253
@Toggle.DefaultValue(DefaultFeatureValue.INTERNAL)
254254
fun subscriptionAIFeaturesRebranding(): Toggle
255+
256+
@Toggle.DefaultValue(DefaultFeatureValue.TRUE)
257+
fun useClientWithCacheForFeatures(): Toggle
255258
}
256259

257260
@ContributesBinding(AppScope::class)

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import com.duckduckgo.di.scopes.AppScope
2424
import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.BASIC_SUBSCRIPTION
2525
import com.duckduckgo.subscriptions.impl.billing.PlayBillingManager
2626
import com.duckduckgo.subscriptions.impl.repository.AuthRepository
27+
import com.duckduckgo.subscriptions.impl.services.SubscriptionsCachedService
2728
import com.duckduckgo.subscriptions.impl.services.SubscriptionsService
2829
import com.squareup.anvil.annotations.ContributesMultibinding
2930
import javax.inject.Inject
@@ -41,6 +42,7 @@ class SubscriptionFeaturesFetcher @Inject constructor(
4142
@AppCoroutineScope private val appCoroutineScope: CoroutineScope,
4243
private val playBillingManager: PlayBillingManager,
4344
private val subscriptionsService: SubscriptionsService,
45+
private val subscriptionsCachedService: SubscriptionsCachedService,
4446
private val authRepository: AuthRepository,
4547
private val privacyProFeature: PrivacyProFeature,
4648
private val dispatcherProvider: DispatcherProvider,
@@ -80,7 +82,11 @@ class SubscriptionFeaturesFetcher @Inject constructor(
8082
}
8183
}
8284
?.forEach { basePlanId ->
83-
val features = subscriptionsService.features(basePlanId).features
85+
val features = if (privacyProFeature.useClientWithCacheForFeatures().isEnabled()) {
86+
subscriptionsCachedService.features(basePlanId).features
87+
} else {
88+
subscriptionsService.features(basePlanId).features
89+
}
8490
logcat { "Subscription features for base plan $basePlanId fetched: $features" }
8591
if (features.isNotEmpty()) {
8692
authRepository.setFeatures(basePlanId, features.toSet())
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
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.services
18+
19+
import android.annotation.SuppressLint
20+
import android.content.Context
21+
import com.duckduckgo.di.scopes.AppScope
22+
import com.squareup.anvil.annotations.ContributesTo
23+
import dagger.Lazy
24+
import dagger.Module
25+
import dagger.Provides
26+
import dagger.SingleInstanceIn
27+
import java.io.File
28+
import javax.inject.Named
29+
import javax.inject.Qualifier
30+
import okhttp3.Cache
31+
import okhttp3.OkHttpClient
32+
import retrofit2.Retrofit
33+
34+
@Module
35+
@ContributesTo(AppScope::class)
36+
class SubscriptionNetworkModule {
37+
38+
@Retention(AnnotationRetention.BINARY)
39+
@Qualifier
40+
private annotation class SubscriptionCachedClient
41+
42+
@Provides
43+
@SubscriptionCachedClient
44+
@SingleInstanceIn(AppScope::class)
45+
fun provideSubscriptionsCustomCacheHttpClient(
46+
context: Context,
47+
@Named("api") okHttpClient: OkHttpClient,
48+
): OkHttpClient {
49+
val cacheLocation = File(context.cacheDir, SUBSCRIPTION_CACHE_FILE)
50+
val cacheSize: Long = 128 * 1024 // 128KB, responses are 1kb so this is more than enough
51+
val cache = Cache(cacheLocation, cacheSize)
52+
return okHttpClient.newBuilder()
53+
.cache(cache)
54+
.build()
55+
}
56+
57+
@Provides
58+
@SingleInstanceIn(AppScope::class)
59+
@SuppressLint("NoRetrofitCreateMethodCallDetector")
60+
fun providesSubscriptionsCachedService(
61+
@Named(value = "api") retrofit: Retrofit,
62+
@SubscriptionCachedClient customClient: Lazy<OkHttpClient>,
63+
): SubscriptionsCachedService {
64+
val customRetrofit = retrofit.newBuilder()
65+
.callFactory { customClient.get().newCall(it) }
66+
.build()
67+
68+
return customRetrofit.create(SubscriptionsCachedService::class.java)
69+
}
70+
71+
companion object {
72+
const val SUBSCRIPTION_CACHE_FILE = "subscriptionsCache"
73+
}
74+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
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.services
18+
19+
import retrofit2.http.GET
20+
import retrofit2.http.Path
21+
22+
interface SubscriptionsCachedService {
23+
@GET("https://subscriptions.duckduckgo.com/api/products/{sku}/features")
24+
suspend fun features(@Path("sku") sku: String): FeaturesResponse
25+
}

subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/SubscriptionFeaturesFetcherTest.kt

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.YEARLY_PLAN_US
2020
import com.duckduckgo.subscriptions.impl.billing.PlayBillingManager
2121
import com.duckduckgo.subscriptions.impl.repository.AuthRepository
2222
import com.duckduckgo.subscriptions.impl.services.FeaturesResponse
23+
import com.duckduckgo.subscriptions.impl.services.SubscriptionsCachedService
2324
import com.duckduckgo.subscriptions.impl.services.SubscriptionsService
2425
import kotlinx.coroutines.flow.flowOf
2526
import kotlinx.coroutines.test.runTest
@@ -43,13 +44,15 @@ class SubscriptionFeaturesFetcherTest {
4344
private val processLifecycleOwner = TestLifecycleOwner(initialState = INITIALIZED)
4445
private val playBillingManager: PlayBillingManager = mock()
4546
private val subscriptionsService: SubscriptionsService = mock()
47+
private val subscriptionsCachedService: SubscriptionsCachedService = mock()
4648
private val authRepository: AuthRepository = mock()
4749
private val privacyProFeature: PrivacyProFeature = FakeFeatureToggleFactory.create(PrivacyProFeature::class.java)
4850

4951
private val subscriptionFeaturesFetcher = SubscriptionFeaturesFetcher(
5052
appCoroutineScope = coroutineRule.testScope,
5153
playBillingManager = playBillingManager,
5254
subscriptionsService = subscriptionsService,
55+
subscriptionsCachedService = subscriptionsCachedService,
5356
authRepository = authRepository,
5457
privacyProFeature = privacyProFeature,
5558
dispatcherProvider = coroutineRule.testDispatcherProvider,
@@ -71,9 +74,29 @@ class SubscriptionFeaturesFetcherTest {
7174
verifyNoInteractions(subscriptionsService)
7275
}
7376

77+
@Test
78+
fun `when products loaded And Use Client with Cache Enabled then fetches and stores features from Cached Service`() = runTest {
79+
givenIsFeaturesApiEnabled(true)
80+
givenUseClientWithCacheForFeaturesEnabled(true)
81+
val productDetails = mockProductDetails()
82+
whenever(playBillingManager.productsFlow).thenReturn(flowOf(productDetails))
83+
whenever(authRepository.getFeatures(any())).thenReturn(emptySet())
84+
whenever(subscriptionsCachedService.features(any())).thenReturn(FeaturesResponse(listOf(NETP, ITR, DUCK_AI)))
85+
86+
processLifecycleOwner.currentState = CREATED
87+
88+
verify(playBillingManager).productsFlow
89+
verifyNoInteractions(subscriptionsService)
90+
verify(subscriptionsCachedService).features(MONTHLY_PLAN_US)
91+
verify(subscriptionsCachedService).features(YEARLY_PLAN_US)
92+
verify(authRepository).setFeatures(MONTHLY_PLAN_US, setOf(NETP, ITR, DUCK_AI))
93+
verify(authRepository).setFeatures(YEARLY_PLAN_US, setOf(NETP, ITR, DUCK_AI))
94+
}
95+
7496
@Test
7597
fun `when products loaded then fetches and stores features`() = runTest {
7698
givenIsFeaturesApiEnabled(true)
99+
givenUseClientWithCacheForFeaturesEnabled(false)
77100
val productDetails = mockProductDetails()
78101
whenever(playBillingManager.productsFlow).thenReturn(flowOf(productDetails))
79102
whenever(authRepository.getFeatures(any())).thenReturn(emptySet())
@@ -121,6 +144,7 @@ class SubscriptionFeaturesFetcherTest {
121144
@Test
122145
fun `when features already stored and refresh features FF enabled then does fetch again`() = runTest {
123146
givenRefreshSubscriptionPlanFeaturesEnabled(true)
147+
givenUseClientWithCacheForFeaturesEnabled(false)
124148
givenIsFeaturesApiEnabled(true)
125149
val productDetails = mockProductDetails()
126150
whenever(playBillingManager.productsFlow).thenReturn(flowOf(productDetails))
@@ -146,6 +170,11 @@ class SubscriptionFeaturesFetcherTest {
146170
privacyProFeature.refreshSubscriptionPlanFeatures().setRawStoredState(State(value))
147171
}
148172

173+
@SuppressLint("DenyListedApi")
174+
private fun givenUseClientWithCacheForFeaturesEnabled(value: Boolean) {
175+
privacyProFeature.useClientWithCacheForFeatures().setRawStoredState(State(value))
176+
}
177+
149178
private fun mockProductDetails(): List<ProductDetails> {
150179
val productDetails: ProductDetails = mock { productDetails ->
151180
whenever(productDetails.productId).thenReturn(SubscriptionsConstants.BASIC_SUBSCRIPTION)

0 commit comments

Comments
 (0)