Skip to content

Commit b853bf6

Browse files
authored
Switch plan: Migrate purchaseHistory to queryPurchasesAsync() (#6900)
Task/Issue URL: https://app.asana.com/1/137249556945/task/1211320822502370?focus=true ### Description Add queryPurchasesAsync() API to replace deprecated purchaseHistory Migrate switch logic to use the new API ### Steps to test this PR _Switch plan_ - [x] Apply patch on https://app.asana.com/1/137249556945/project/1209991789468715/task/1210448620621729?focus=true - [x] Install from branch - [x] Make a test purchase - [x] Go to Settings > Subscriptions Dev Settings - [x] Scroll down to the new option "Switch Subscription" - [x] Select another plan to switch, different from the one you purchased - [x] Select "Without Proration" Replacement Mode - [x] Tap "OK" - [x] Check Google Play bottom sheet appear - [x] Tap on "Subscribe" - [x] Check you see the Toast of the successful purchase - [x] Go to Settings > Subscription Settings - [x] Check the subscription is changed ### No UI changes
1 parent 010155a commit b853bf6

File tree

5 files changed

+115
-7
lines changed

5 files changed

+115
-7
lines changed

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

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -405,13 +405,7 @@ class RealSubscriptionsManager @Inject constructor(
405405
return@withContext false
406406
}
407407

408-
// fixme: Replace purchaseHistory with queryPurchasesAsync() to fetch the current purchase token.
409-
// This aligns with Billing v8 and avoids using the deprecated Purchase History API.
410-
val currentPurchaseToken =
411-
playBillingManager.purchaseHistory
412-
.filter { it.products.contains(BASIC_SUBSCRIPTION) }
413-
.maxByOrNull { it.purchaseTime }
414-
?.purchaseToken
408+
val currentPurchaseToken = playBillingManager.getLatestPurchaseToken()
415409

416410
if (currentPurchaseToken == null) {
417411
logcat { "Subs: Cannot switch plan - no current purchase token found" }

subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/billing/BillingClientAdapter.kt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ package com.duckduckgo.subscriptions.impl.billing
1818

1919
import android.app.Activity
2020
import com.android.billingclient.api.ProductDetails
21+
import com.android.billingclient.api.Purchase
2122
import com.android.billingclient.api.PurchaseHistoryRecord
2223

2324
interface BillingClientAdapter {
@@ -30,8 +31,14 @@ interface BillingClientAdapter {
3031

3132
suspend fun getSubscriptions(productIds: List<String>): SubscriptionsResult
3233

34+
@Deprecated(
35+
message = "purchaseHistory API is deprecated and removed in the Billing Library v8",
36+
replaceWith = ReplaceWith("queryPurchases"),
37+
)
3338
suspend fun getSubscriptionsPurchaseHistory(): SubscriptionsPurchaseHistoryResult
3439

40+
suspend fun queryPurchases(): QueryPurchasesResult
41+
3542
suspend fun launchBillingFlow(
3643
activity: Activity,
3744
productDetails: ProductDetails,
@@ -68,6 +75,14 @@ sealed class SubscriptionsPurchaseHistoryResult {
6875
data object Failure : SubscriptionsPurchaseHistoryResult()
6976
}
7077

78+
sealed class QueryPurchasesResult {
79+
data class Success(val purchases: List<Purchase>) : QueryPurchasesResult()
80+
data class Failure(
81+
val billingError: BillingError? = null,
82+
val debugMessage: String? = null,
83+
) : QueryPurchasesResult()
84+
}
85+
7186
sealed class LaunchBillingFlowResult {
7287
data object Success : LaunchBillingFlowResult()
7388
data class Failure(val error: BillingError) : LaunchBillingFlowResult()

subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/billing/PlayBillingManager.kt

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import android.app.Activity
2020
import androidx.lifecycle.LifecycleOwner
2121
import androidx.lifecycle.lifecycleScope
2222
import com.android.billingclient.api.ProductDetails
23+
import com.android.billingclient.api.Purchase
2324
import com.android.billingclient.api.PurchaseHistoryRecord
2425
import com.duckduckgo.app.di.AppCoroutineScope
2526
import com.duckduckgo.app.lifecycle.MainProcessLifecycleObserver
@@ -65,6 +66,7 @@ interface PlayBillingManager {
6566
val products: List<ProductDetails>
6667
val productsFlow: Flow<List<ProductDetails>>
6768
val purchaseHistory: List<PurchaseHistoryRecord>
69+
val purchases: List<Purchase>
6870
val purchaseState: Flow<PurchaseState>
6971

7072
/**
@@ -92,6 +94,12 @@ interface PlayBillingManager {
9294
oldPurchaseToken: String,
9395
replacementMode: SubscriptionReplacementMode,
9496
)
97+
98+
/**
99+
* Gets the current purchase token from active purchases (queryPurchasesAsync)
100+
* This is the preferred method over purchase history for getting current tokens
101+
*/
102+
fun getLatestPurchaseToken(): String?
95103
}
96104

97105
@SingleInstanceIn(AppScope::class)
@@ -123,8 +131,15 @@ class RealPlayBillingManager @Inject constructor(
123131
get() = _products.asStateFlow()
124132

125133
// Purchase History
134+
@Deprecated(
135+
message = "purchaseHistory is deprecated",
136+
replaceWith = ReplaceWith("purchases"),
137+
)
126138
override var purchaseHistory = emptyList<PurchaseHistoryRecord>()
127139

140+
// Active Purchases
141+
override var purchases = emptyList<Purchase>()
142+
128143
override fun onCreate(owner: LifecycleOwner) {
129144
connectAsyncWithRetry()
130145
}
@@ -136,6 +151,7 @@ class RealPlayBillingManager @Inject constructor(
136151
owner.lifecycleScope.launch(dispatcherProvider.io()) {
137152
loadProducts()
138153
loadPurchaseHistory()
154+
loadPurchases()
139155
}
140156
}
141157
}
@@ -348,6 +364,36 @@ class RealPlayBillingManager @Inject constructor(
348364
}
349365
}
350366
}
367+
368+
private suspend fun loadPurchases() {
369+
when (val result = billingClient.queryPurchases()) {
370+
is QueryPurchasesResult.Success -> {
371+
purchases = result.purchases
372+
logcat { "Billing: Loaded ${result.purchases.size} active purchases" }
373+
}
374+
is QueryPurchasesResult.Failure -> {
375+
logcat { "Billing: Failed to load purchases: ${result.billingError} - ${result.debugMessage}" }
376+
}
377+
}
378+
}
379+
380+
override fun getLatestPurchaseToken(): String? {
381+
val activePurchases = purchases.filter { purchase: Purchase ->
382+
purchase.products.contains(BASIC_SUBSCRIPTION) &&
383+
purchase.purchaseState == Purchase.PurchaseState.PURCHASED
384+
}
385+
386+
val latestPurchase = activePurchases.maxByOrNull { it.purchaseTime }
387+
388+
return if (latestPurchase != null) {
389+
val tokenPreview = latestPurchase.purchaseToken.take(10) + "..."
390+
logcat { "Billing: Latest active purchase token preview: $tokenPreview" }
391+
latestPurchase.purchaseToken
392+
} else {
393+
logcat { "Billing: No active purchase token found" }
394+
null
395+
}
396+
}
351397
}
352398

353399
sealed class PurchaseState {

subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/billing/RealBillingClientAdapter.kt

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,10 @@ import com.android.billingclient.api.Purchase.PurchaseState
3030
import com.android.billingclient.api.QueryProductDetailsParams
3131
import com.android.billingclient.api.QueryProductDetailsParams.Product
3232
import com.android.billingclient.api.QueryPurchaseHistoryParams
33+
import com.android.billingclient.api.QueryPurchasesParams
3334
import com.android.billingclient.api.queryProductDetails
3435
import com.android.billingclient.api.queryPurchaseHistory
36+
import com.android.billingclient.api.queryPurchasesAsync
3537
import com.duckduckgo.common.utils.DispatcherProvider
3638
import com.duckduckgo.di.scopes.AppScope
3739
import com.duckduckgo.subscriptions.impl.billing.BillingError.BILLING_CRASH_ERROR
@@ -130,6 +132,7 @@ class RealBillingClientAdapter @Inject constructor(
130132
}
131133
}
132134

135+
@Deprecated(message = "check interface")
133136
override suspend fun getSubscriptionsPurchaseHistory(): SubscriptionsPurchaseHistoryResult {
134137
val client = billingClient
135138
if (client == null || !client.isReady) return SubscriptionsPurchaseHistoryResult.Failure
@@ -146,6 +149,43 @@ class RealBillingClientAdapter @Inject constructor(
146149
}
147150
}
148151

152+
override suspend fun queryPurchases(): QueryPurchasesResult {
153+
val client = billingClient
154+
if (client == null || !client.isReady) {
155+
return QueryPurchasesResult.Failure(
156+
billingError = BillingError.SERVICE_DISCONNECTED,
157+
debugMessage = "BillingClient is not ready",
158+
)
159+
}
160+
161+
return try {
162+
val queryParams = QueryPurchasesParams.newBuilder()
163+
.setProductType(ProductType.SUBS)
164+
.build()
165+
166+
val (billingResult, purchases) = client.queryPurchasesAsync(queryParams)
167+
168+
when (billingResult.responseCode) {
169+
BillingResponseCode.OK -> {
170+
QueryPurchasesResult.Success(purchases = purchases)
171+
}
172+
else -> {
173+
val billingError = billingResult.responseCode.toBillingError()
174+
QueryPurchasesResult.Failure(
175+
billingError = billingError,
176+
debugMessage = billingResult.debugMessage,
177+
)
178+
}
179+
}
180+
} catch (e: Exception) {
181+
logcat(WARN) { "Error querying purchases: ${e.asLog()}" }
182+
QueryPurchasesResult.Failure(
183+
billingError = BILLING_CRASH_ERROR,
184+
debugMessage = e.message,
185+
)
186+
}
187+
}
188+
149189
override suspend fun launchBillingFlow(
150190
activity: Activity,
151191
productDetails: ProductDetails,

subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/billing/RealPlayBillingManagerTest.kt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import androidx.lifecycle.Lifecycle.State.RESUMED
77
import androidx.lifecycle.testing.TestLifecycleOwner
88
import app.cash.turbine.test
99
import com.android.billingclient.api.ProductDetails
10+
import com.android.billingclient.api.Purchase
1011
import com.android.billingclient.api.PurchaseHistoryRecord
1112
import com.duckduckgo.common.test.CoroutineTestRule
1213
import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.BASIC_SUBSCRIPTION
@@ -21,6 +22,7 @@ import com.duckduckgo.subscriptions.impl.billing.FakeBillingClientAdapter.FakeMe
2122
import com.duckduckgo.subscriptions.impl.billing.FakeBillingClientAdapter.FakeMethodInvocation.GetSubscriptionsPurchaseHistory
2223
import com.duckduckgo.subscriptions.impl.billing.FakeBillingClientAdapter.FakeMethodInvocation.LaunchBillingFlow
2324
import com.duckduckgo.subscriptions.impl.billing.FakeBillingClientAdapter.FakeMethodInvocation.LaunchSubscriptionUpdate
25+
import com.duckduckgo.subscriptions.impl.billing.FakeBillingClientAdapter.FakeMethodInvocation.QueryPurchases
2426
import com.duckduckgo.subscriptions.impl.billing.PurchaseState.Canceled
2527
import com.duckduckgo.subscriptions.impl.billing.PurchaseState.InProgress
2628
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -384,6 +386,7 @@ class FakeBillingClientAdapter : BillingClientAdapter {
384386
)
385387

386388
var subscriptionsPurchaseHistory: List<PurchaseHistoryRecord> = emptyList()
389+
var activePurchases: List<Purchase> = emptyList()
387390
var launchBillingFlowResult: LaunchBillingFlowResult = LaunchBillingFlowResult.Failure(error = SERVICE_UNAVAILABLE)
388391
var billingInitResult: BillingInitResult = BillingInitResult.Success
389392

@@ -433,6 +436,15 @@ class FakeBillingClientAdapter : BillingClientAdapter {
433436
}
434437
}
435438

439+
override suspend fun queryPurchases(): QueryPurchasesResult {
440+
methodInvocations.add(QueryPurchases)
441+
return if (ready) {
442+
QueryPurchasesResult.Success(activePurchases)
443+
} else {
444+
QueryPurchasesResult.Failure(BillingError.SERVICE_DISCONNECTED, "Service not connected")
445+
}
446+
}
447+
436448
override suspend fun launchBillingFlow(
437449
activity: Activity,
438450
productDetails: ProductDetails,
@@ -520,6 +532,7 @@ class FakeBillingClientAdapter : BillingClientAdapter {
520532
data object Connect : FakeMethodInvocation()
521533
data class GetSubscriptions(val productIds: List<String>) : FakeMethodInvocation()
522534
data object GetSubscriptionsPurchaseHistory : FakeMethodInvocation()
535+
data object QueryPurchases : FakeMethodInvocation()
523536

524537
data class LaunchBillingFlow(
525538
val productDetails: ProductDetails,

0 commit comments

Comments
 (0)