Skip to content

Commit a083093

Browse files
committed
fix(google): recover already-owned purchases
When Play Billing reports ITEM_ALREADY_OWNED during a purchase flow, query the current owned purchases for the requested SKU and publish matching purchases to update listeners instead of only surfacing an already-owned error. Closes #166
1 parent 1259ac2 commit a083093

3 files changed

Lines changed: 142 additions & 6 deletions

File tree

packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ import dev.hyo.openiap.helpers.AndroidPurchaseArgs
5151
import dev.hyo.openiap.helpers.onPurchaseError
5252
import dev.hyo.openiap.helpers.onPurchaseUpdated
5353
import dev.hyo.openiap.helpers.onSubscriptionBillingIssue
54+
import dev.hyo.openiap.helpers.queryAlreadyOwnedPurchases
5455
import dev.hyo.openiap.helpers.queryProductDetails
5556
import dev.hyo.openiap.helpers.queryPurchases
5657
import dev.hyo.openiap.helpers.resumeGuard
@@ -107,8 +108,8 @@ class OpenIapModule(
107108
private val gson = Gson()
108109
private val fallbackActivity: Activity? = if (context is Activity) context else null
109110

110-
private val purchaseUpdateListeners = mutableSetOf<OpenIapPurchaseUpdateListener>()
111-
private val purchaseErrorListeners = mutableSetOf<OpenIapPurchaseErrorListener>()
111+
private val purchaseUpdateListeners = java.util.concurrent.CopyOnWriteArraySet<OpenIapPurchaseUpdateListener>()
112+
private val purchaseErrorListeners = java.util.concurrent.CopyOnWriteArraySet<OpenIapPurchaseErrorListener>()
112113
private val userChoiceBillingListeners = mutableSetOf<OpenIapUserChoiceBillingListener>()
113114
private val developerProvidedBillingListeners = mutableSetOf<OpenIapDeveloperProvidedBillingListener>()
114115
// Thread-safe: listeners can be added/removed on the main thread while
@@ -901,6 +902,8 @@ class OpenIapModule(
901902
return@withContext emptyList()
902903
}
903904

905+
val desiredType = if (androidArgs.type == ProductQueryType.Subs) BillingClient.ProductType.SUBS else BillingClient.ProductType.INAPP
906+
904907
suspendCancellableCoroutine<List<Purchase>> { continuation ->
905908
var callbackRef: ((Result<List<Purchase>>) -> Unit)? = null
906909
val resumer = continuation.resumeGuard {
@@ -922,8 +925,6 @@ class OpenIapModule(
922925
return@suspendCancellableCoroutine
923926
}
924927

925-
val desiredType = if (androidArgs.type == ProductQueryType.Subs) BillingClient.ProductType.SUBS else BillingClient.ProductType.INAPP
926-
927928
val detailsBySku = mutableMapOf<String, ProductDetails>()
928929
for (sku in androidArgs.skus) {
929930
productManager.get(sku)?.takeIf { it.productType == desiredType }?.let { detailsBySku[sku] = it }
@@ -1093,6 +1094,30 @@ class OpenIapModule(
10931094
val result = client.launchBillingFlow(activity, flowBuilder.build())
10941095
OpenIapLog.d("launchBillingFlow result: ${result.responseCode} - ${result.debugMessage}", TAG)
10951096
if (result.responseCode != BillingClient.BillingResponseCode.OK) {
1097+
if (result.responseCode == BillingClient.BillingResponseCode.ITEM_ALREADY_OWNED) {
1098+
val err = OpenIapError.fromBillingResponseCode(
1099+
result.responseCode,
1100+
result.debugMessage
1101+
)
1102+
OpenIapLog.d("ITEM_ALREADY_OWNED received; querying owned purchases for ${androidArgs.skus}", TAG)
1103+
queryAlreadyOwnedPurchases(client, desiredType, androidArgs.skus) { recovered ->
1104+
if (recovered.isNotEmpty()) {
1105+
OpenIapLog.d("Recovered ${recovered.size} already-owned purchase(s)", TAG)
1106+
consumePurchaseCallback(Result.success(recovered))
1107+
notifySuspendedSubscriptions(recovered)
1108+
for (purchase in recovered) {
1109+
for (listener in purchaseUpdateListeners) {
1110+
runCatching { listener.onPurchaseUpdated(purchase) }
1111+
}
1112+
}
1113+
} else {
1114+
OpenIapLog.w("ITEM_ALREADY_OWNED recovery found no matching owned purchases", TAG)
1115+
for (listener in purchaseErrorListeners) { runCatching { listener.onPurchaseError(err) } }
1116+
consumePurchaseCallback(Result.success(emptyList()))
1117+
}
1118+
}
1119+
return
1120+
}
10961121
val err = when (result.responseCode) {
10971122
BillingClient.BillingResponseCode.DEVELOPER_ERROR -> {
10981123
OpenIapLog.w("DEVELOPER_ERROR: Invalid arguments. Check if subscriptions are in the same group.", TAG)

packages/google/openiap/src/play/java/dev/hyo/openiap/helpers/Helpers.kt

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import dev.hyo.openiap.OpenIapError
77
import dev.hyo.openiap.Purchase
88
import dev.hyo.openiap.utils.BillingConverters.toPurchase
99
import kotlinx.coroutines.suspendCancellableCoroutine
10+
import java.util.concurrent.atomic.AtomicBoolean
1011

1112
// Common helpers (onPurchaseUpdated, onPurchaseError, AndroidPurchaseArgs,
1213
// toAndroidPurchaseArgs, toPurchaseError) are in main/helpers/CommonHelpers.kt
@@ -69,6 +70,45 @@ internal suspend fun queryPurchases(
6970
}
7071
}
7172

73+
/**
74+
* Queries Play Billing directly after ITEM_ALREADY_OWNED and returns only
75+
* currently owned purchases that match the in-flight request SKUs.
76+
*/
77+
internal fun queryAlreadyOwnedPurchases(
78+
client: BillingClient?,
79+
productType: String,
80+
skus: List<String>,
81+
onResult: (List<Purchase>) -> Unit
82+
) {
83+
val requestedSkus = skus.toSet()
84+
if (client == null || requestedSkus.isEmpty()) {
85+
onResult(emptyList())
86+
return
87+
}
88+
89+
val didHandleResult = AtomicBoolean(false)
90+
val params = QueryPurchasesParams.newBuilder()
91+
.setProductType(productType)
92+
.build()
93+
94+
client.queryPurchasesAsync(params) { result, purchaseList ->
95+
if (!didHandleResult.compareAndSet(false, true)) return@queryPurchasesAsync
96+
97+
if (result.responseCode != BillingClient.BillingResponseCode.OK) {
98+
onResult(emptyList())
99+
return@queryPurchasesAsync
100+
}
101+
102+
val recovered = purchaseList.orEmpty()
103+
.map { billingPurchase -> billingPurchase.toPurchase(productType, null) }
104+
.filter { purchase ->
105+
purchase.productId in requestedSkus ||
106+
purchase.ids.orEmpty().any { id -> id in requestedSkus }
107+
}
108+
onResult(recovered)
109+
}
110+
}
111+
72112
internal suspend fun queryProductDetails(
73113
client: BillingClient?,
74114
productManager: ProductManager,

packages/google/openiap/src/testPlay/java/dev/hyo/openiap/QueryPurchasesRaceTest.kt

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,15 +33,21 @@ import com.android.billingclient.api.QueryProductDetailsParams
3333
import com.android.billingclient.api.QueryProductDetailsResult
3434
import com.android.billingclient.api.QueryPurchasesParams
3535
import dev.hyo.openiap.helpers.ProductManager
36+
import dev.hyo.openiap.helpers.queryAlreadyOwnedPurchases
3637
import dev.hyo.openiap.helpers.queryPurchases
3738
import java.util.Collections
3839
import java.util.concurrent.CountDownLatch
3940
import java.util.concurrent.TimeUnit
41+
import java.util.concurrent.atomic.AtomicInteger
4042
import kotlin.concurrent.thread
4143
import kotlinx.coroutines.test.runTest
44+
import org.junit.Assert.assertEquals
4245
import org.junit.Assert.assertTrue
4346
import org.junit.Test
47+
import org.junit.runner.RunWith
48+
import org.robolectric.RobolectricTestRunner
4449

50+
@RunWith(RobolectricTestRunner::class)
4551
class QueryPurchasesRaceTest {
4652

4753
@Test
@@ -57,6 +63,51 @@ class QueryPurchasesRaceTest {
5763
)
5864
}
5965

66+
@Test
67+
fun `queryAlreadyOwnedPurchases tolerates duplicate concurrent callbacks`() {
68+
val client = DuplicateBillingClient()
69+
val completions = AtomicInteger(0)
70+
71+
queryAlreadyOwnedPurchases(
72+
client,
73+
BillingClient.ProductType.INAPP,
74+
listOf("product-id")
75+
) {
76+
completions.incrementAndGet()
77+
}
78+
79+
assertEquals(1, completions.get())
80+
assertTrue(
81+
"queryAlreadyOwnedPurchases must ignore duplicate concurrent callbacks: " +
82+
client.callbackFailures.joinToString { it::class.java.simpleName },
83+
client.callbackFailures.isEmpty()
84+
)
85+
}
86+
87+
@Test
88+
fun `queryAlreadyOwnedPurchases filters purchases by requested sku`() {
89+
val requested = billingPurchase("requested-product", "requested-token")
90+
assertEquals(listOf("requested-product"), requested.products)
91+
92+
val client = DuplicateBillingClient(
93+
purchases = listOf(
94+
requested,
95+
billingPurchase("other-product", "other-token")
96+
)
97+
)
98+
val recoveredProductIds = mutableListOf<String>()
99+
100+
queryAlreadyOwnedPurchases(
101+
client,
102+
BillingClient.ProductType.SUBS,
103+
listOf("requested-product")
104+
) { purchases ->
105+
recoveredProductIds += purchases.map { it.productId }
106+
}
107+
108+
assertEquals(listOf("requested-product"), recoveredProductIds)
109+
}
110+
60111
@Test
61112
fun `ProductManager getOrQuery tolerates duplicate concurrent callbacks`() = runTest {
62113
val client = DuplicateBillingClient()
@@ -71,7 +122,25 @@ class QueryPurchasesRaceTest {
71122
)
72123
}
73124

74-
private class DuplicateBillingClient : BillingClient() {
125+
private fun billingPurchase(productId: String, token: String): Purchase = Purchase(
126+
"""
127+
{
128+
"orderId": "order-$productId",
129+
"packageName": "dev.hyo.openiap.test",
130+
"productId": "$productId",
131+
"purchaseTime": 1,
132+
"purchaseState": 0,
133+
"purchaseToken": "$token",
134+
"quantity": 1,
135+
"acknowledged": false
136+
}
137+
""".trimIndent(),
138+
"signature"
139+
)
140+
141+
private class DuplicateBillingClient(
142+
private val purchases: List<Purchase> = emptyList()
143+
) : BillingClient() {
75144
val callbackFailures = Collections.synchronizedList(mutableListOf<Throwable>())
76145

77146
override fun queryPurchasesAsync(
@@ -81,7 +150,9 @@ class QueryPurchasesRaceTest {
81150
val result = BillingResult.newBuilder()
82151
.setResponseCode(BillingResponseCode.OK)
83152
.build()
84-
val purchaseList = CallbackBarrierList<Purchase>(callbackCount = 2)
153+
val purchaseList = purchases.ifEmpty {
154+
CallbackBarrierList(callbackCount = 2)
155+
}
85156
runDuplicateCallbacks {
86157
listener.onQueryPurchasesResponse(result, purchaseList)
87158
}

0 commit comments

Comments
 (0)