@mpgd/capacitor-play-billing 0.3.10 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,10 +1,12 @@
1
1
  # @mpgd/capacitor-play-billing
2
2
 
3
3
  Android-only, opt-in Google Play one-time purchase collection for Capacitor 8.
4
- The native module uses Google Play Billing Library 9.1.0. It queries product
5
- details, launches a purchase, listens for purchase updates, and requeries
6
- currently owned, unconsumed purchases. It intentionally does **not** call
7
- BillingClient acknowledge or consume. The game-owned backend must verify the
4
+ The Kotlin native module runs on the [OpenIAP](https://github.com/hyodotdev/openiap)
5
+ Google core (`io.github.hyochan.openiap:openiap-google` 4.0.6, which brings
6
+ Google Play Billing Library 9.1.0). It queries product details, launches a
7
+ purchase, listens for purchase updates, and requeries currently owned,
8
+ unconsumed purchases. It intentionally does **not** acknowledge, consume, or
9
+ call OpenIAP's `finishTransaction`. The game-owned backend must verify the
8
10
  purchase via Google Play Publisher, commit the mpgd ledger grant, and perform
9
11
  acknowledge/consume after the grant. A device callback alone never grants a
10
12
  purchase.
@@ -69,7 +71,13 @@ when `includePending` is set; each carries `token = purchaseToken` and
69
71
  `transactionId = orderId`, or the `play-token-sha256:<hex>` token key until
70
72
  Play assigns an order id, so the same purchase deduplicates in the ledger. A
71
73
  purchased entry whose `isAcknowledged` flag is absent counts as unfinished so a
72
- paid purchase is never dropped. A purchase for a SKU that is not in `products`
74
+ paid purchase is never dropped. The player's Restore Purchases read
75
+ (`restore: true`, sent only by the reconciler's `restorePurchases`) also lists
76
+ every `purchased` entry of exactly one configured durable, acknowledged or
77
+ not: after a reinstall or on a new device the backend acknowledged it long
78
+ ago under an earlier guest, and only this read hands it to the backend's
79
+ restore sharing. Acknowledged consumables, unmapped SKUs, and multi-line
80
+ purchases are not added. A purchase for a SKU that is not in `products`
73
81
  (a retired product) is still listed and emitted, with that product id as
74
82
  `sku` and the purchase token intact, so the reconciler can submit it when a
75
83
  logical id is still known. A multi-line purchase is listed the same way,
@@ -175,17 +183,31 @@ owned purchases over the fake plugin: a purchased token is verified once, a
175
183
  pending purchase is never sent, a rejection is terminal, and an outage stays
176
184
  retryable.
177
185
 
178
- ## Native compile check
186
+ ## Native engine
179
187
 
180
- The Kit reference app includes an opt-in Gradle compile target, without
181
- bundling Billing into the base app:
188
+ The plugin keeps its 0.3 Capacitor contract (methods, the `purchaseUpdated`
189
+ event, payload fields, `PLAY_BILLING_*` codes) over OpenIAP; the
190
+ [migration record](../../docs/specs/commerce/openiap-migration.md) lists the
191
+ contract, the call mapping, and every behavior difference. A shell needs the
192
+ Kotlin Gradle plugin (2.2.20 unless the root project sets `kotlin_version`;
193
+ OpenIAP needs 2.1.20 or newer), and the merged manifest gains OpenIAP's
194
+ `dev.hyo.openiap.PROVIDER` entry and `INTERNET`. The plugin refuses to run
195
+ unless OpenIAP's provider is `play`.
196
+
197
+ ## Native build check
198
+
199
+ The Kit reference app includes an opt-in Gradle target, without bundling
200
+ Billing into the base app. It compiles the Kotlin plugin, runs its JVM
201
+ policy tests, and assembles the release AAR:
182
202
 
183
203
  ```sh
184
204
  cd apps/mobile-capacitor/android
185
205
  ./gradlew -PmpgdPlayBillingPluginTest=true \
186
- :mpgd-capacitor-play-billing:compileDebugJavaWithJavac
206
+ :mpgd-capacitor-play-billing:testDebugUnitTest \
207
+ :mpgd-capacitor-play-billing:assembleRelease
187
208
  ```
188
209
 
189
- This compile and the TypeScript fixture do not prove a live Play purchase,
190
- license-test configuration, or device behavior. Validate those in a game-owned
210
+ `pnpm smoke:native-packed-store-plugins android` assembles the packed npm
211
+ copy the same way. These builds and the TypeScript fixture do not prove a
212
+ live Play purchase, license-test configuration, or device behavior. Validate those in a game-owned
191
213
  Play Console project before enabling commerce for players.
@@ -1,4 +1,23 @@
1
+ buildscript {
2
+ // Same Kotlin line as the Capacitor 8 community plugins; a shell that pins
3
+ // `kotlin_version` in its root project wins. OpenIAP Google 4.0.6 needs a
4
+ // 2.1.20+ Kotlin compiler.
5
+ ext.kotlin_version = project.hasProperty('kotlin_version') ? rootProject.ext.kotlin_version : '2.2.20'
6
+ repositories {
7
+ google()
8
+ mavenCentral()
9
+ }
10
+ dependencies {
11
+ classpath 'com.android.tools.build:gradle:8.13.0'
12
+ classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
13
+ }
14
+ }
15
+
1
16
  apply plugin: 'com.android.library'
17
+ // AGP 9 ships built-in Kotlin; only older AGP needs the Kotlin Android plugin.
18
+ if (project.extensions.findByName('kotlin') == null) {
19
+ apply plugin: 'kotlin-android'
20
+ }
2
21
 
3
22
  android {
4
23
  namespace 'dev.mpgd.capacitor.playbilling'
@@ -8,6 +27,21 @@ android {
8
27
  minSdkVersion project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 24
9
28
  targetSdkVersion project.hasProperty('targetSdkVersion') ? rootProject.ext.targetSdkVersion : 36
10
29
  }
30
+
31
+ compileOptions {
32
+ sourceCompatibility JavaVersion.VERSION_21
33
+ targetCompatibility JavaVersion.VERSION_21
34
+ }
35
+
36
+ testOptions {
37
+ unitTests.returnDefaultValues = true
38
+ }
39
+ }
40
+
41
+ kotlin {
42
+ compilerOptions {
43
+ jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_21
44
+ }
11
45
  }
12
46
 
13
47
  repositories {
@@ -17,6 +51,13 @@ repositories {
17
51
 
18
52
  dependencies {
19
53
  implementation project(':capacitor-android')
20
- implementation 'com.android.billingclient:billing:9.1.0'
54
+ // OpenIAP Google core, Play flavor, pinned exactly. It brings Play Billing
55
+ // 9.1.0 as an API dependency, so the plugin no longer declares Billing
56
+ // itself. See docs/specs/commerce/openiap-migration.md before changing it.
57
+ implementation 'io.github.hyochan.openiap:openiap-google:4.0.6'
58
+ // OpenIAP publishes coroutines as a runtime dependency; the plugin calls
59
+ // its suspend API directly, so it needs them at compile time too.
60
+ implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0'
21
61
  compileOnly 'androidx.appcompat:appcompat:1.7.1'
62
+ testImplementation 'junit:junit:4.13.2'
22
63
  }
@@ -0,0 +1,378 @@
1
+ package dev.mpgd.capacitor.playbilling
2
+
3
+ import android.os.Handler
4
+ import android.os.Looper
5
+ import android.util.Log
6
+ import com.getcapacitor.JSArray
7
+ import com.getcapacitor.JSObject
8
+ import com.getcapacitor.Plugin
9
+ import com.getcapacitor.PluginCall
10
+ import com.getcapacitor.PluginMethod
11
+ import com.getcapacitor.annotation.CapacitorPlugin
12
+ import dev.hyo.openiap.OpenIapError
13
+ import dev.hyo.openiap.PurchaseAndroid
14
+ import kotlinx.coroutines.CancellationException
15
+ import kotlinx.coroutines.CoroutineExceptionHandler
16
+ import kotlinx.coroutines.CoroutineScope
17
+ import kotlinx.coroutines.Dispatchers
18
+ import kotlinx.coroutines.Job
19
+ import kotlinx.coroutines.SupervisorJob
20
+ import kotlinx.coroutines.cancel
21
+ import kotlinx.coroutines.delay
22
+ import kotlinx.coroutines.launch
23
+ import org.json.JSONException
24
+
25
+ /**
26
+ * Purchase collection only, over the OpenIAP Google core. Finalization is
27
+ * server-owned after the mpgd ledger grant: this plugin never finishes,
28
+ * acknowledges, or consumes a purchase, and the encoded `isAcknowledged` flag
29
+ * lets the TypeScript provider list unfinished purchases for the backend
30
+ * finalizer. All plugin state is touched on the main thread only.
31
+ */
32
+ @CapacitorPlugin(name = "CapacitorPlayBilling")
33
+ class CapacitorPlayBillingPlugin : Plugin() {
34
+ private val mainHandler = Handler(Looper.getMainLooper())
35
+ // Every coroutine below answers its own call; the handler only keeps a
36
+ // billing failure that escapes them (an Error) from crashing the game.
37
+ private val scope = CoroutineScope(
38
+ SupervisorJob() + Dispatchers.Main.immediate + CoroutineExceptionHandler { _, error ->
39
+ Log.e(TAG, "Unhandled Google Play Billing failure: ${error.javaClass.name}")
40
+ },
41
+ )
42
+ private var connection: OpenIapPlayConnection? = null
43
+ private var providerError: String? = null
44
+ private var activePurchase: PluginCall? = null
45
+ private var activeProductId: String? = null
46
+ private var purchaseTimeout: Job? = null
47
+
48
+ /** True from the start of an OpenIAP purchase flow until OpenIAP returns. */
49
+ private var flowInProgress = false
50
+
51
+ /**
52
+ * Product of a purchase call that timed out while the Play UI was still
53
+ * up. When OpenIAP later reports that flow's cancel or failure, it is
54
+ * delivered as a `purchaseUpdated` event so the JavaScript attempt the
55
+ * timeout left open can close.
56
+ */
57
+ private var timedOutProductId: String? = null
58
+
59
+ override fun load() {
60
+ try {
61
+ connection = OpenIapPlayConnection.open(context) { purchase ->
62
+ onMain { onPurchaseUpdated(purchase) }
63
+ }
64
+ } catch (error: OpenIapError) {
65
+ providerError = error.message
66
+ } catch (error: Exception) {
67
+ logUnexpected("provider load", error)
68
+ providerError = "OpenIAP could not load the Google Play provider."
69
+ }
70
+ }
71
+
72
+ override fun handleOnDestroy() {
73
+ activePurchase?.let { call ->
74
+ clearActivePurchase(call)
75
+ call.reject("Purchase flow was interrupted.", "PLAY_BILLING_INTERRUPTED")
76
+ }
77
+ scope.cancel()
78
+ connection?.close()
79
+ connection = null
80
+ super.handleOnDestroy()
81
+ }
82
+
83
+ @PluginMethod
84
+ fun getProducts(call: PluginCall) {
85
+ onMain { getProductsOnMain(call) }
86
+ }
87
+
88
+ private fun getProductsOnMain(call: PluginCall) {
89
+ val ids = call.getArray("productIds")
90
+ if (ids == null || ids.length() == 0 || ids.length() > 100) {
91
+ call.reject("Provide 1-100 product IDs.", "PLAY_BILLING_INVALID_PRODUCTS")
92
+ return
93
+ }
94
+ val productIds = mutableListOf<String>()
95
+ try {
96
+ for (index in 0 until ids.length()) {
97
+ val productId = ids.getString(index)
98
+ if (productId == null || productId.trim().isEmpty()) {
99
+ call.reject("Product ID is invalid.", "PLAY_BILLING_INVALID_PRODUCT")
100
+ return
101
+ }
102
+ productIds += productId
103
+ }
104
+ } catch (error: JSONException) {
105
+ call.reject("Product IDs are invalid.", "PLAY_BILLING_INVALID_PRODUCTS")
106
+ return
107
+ }
108
+ val active = connectionOrReject(call) ?: return
109
+ launchCall(call) {
110
+ if (!active.connect()) {
111
+ rejectUnavailable(call)
112
+ return@launchCall
113
+ }
114
+ val products = JSArray()
115
+ for (product in active.fetchInAppProducts(productIds)) {
116
+ PlayBillingPolicy.productPayload(product)?.let { products.put(toJs(it)) }
117
+ }
118
+ call.resolve(JSObject().put("products", products))
119
+ }
120
+ }
121
+
122
+ @PluginMethod
123
+ fun purchase(call: PluginCall) {
124
+ onMain { purchaseOnMain(call) }
125
+ }
126
+
127
+ private fun purchaseOnMain(call: PluginCall) {
128
+ val productId = call.getString("productId")
129
+ val accountId = call.getString("obfuscatedAccountId")
130
+ val selectedOfferToken = call.getString("offerToken")
131
+ if (productId == null || productId.trim().isEmpty() || accountId == null ||
132
+ accountId.trim().isEmpty() || accountId.length > 64
133
+ ) {
134
+ call.reject("Product and account identifiers are required.", "PLAY_BILLING_INVALID_REQUEST")
135
+ return
136
+ }
137
+ val active = connectionOrReject(call) ?: return
138
+ // OpenIAP holds one flow at a time, including one whose call already
139
+ // timed out here; a second flow is busy until OpenIAP releases it.
140
+ if (activePurchase != null || flowInProgress) {
141
+ call.reject("Another purchase is already in progress.", "PLAY_BILLING_BUSY")
142
+ return
143
+ }
144
+ activePurchase = call
145
+ activeProductId = productId
146
+ timedOutProductId = null
147
+ flowInProgress = true
148
+ purchaseTimeout = scope.launch {
149
+ delay(PURCHASE_TIMEOUT_MILLIS)
150
+ if (activePurchase === call) {
151
+ timedOutProductId = activeProductId
152
+ clearActivePurchase(call)
153
+ call.reject("Purchase confirmation timed out; requery owned purchases.", "PLAY_BILLING_TIMEOUT")
154
+ }
155
+ }
156
+ scope.launch {
157
+ val resolution = try {
158
+ runPurchase(active, productId, accountId, selectedOfferToken)
159
+ } catch (error: CancellationException) {
160
+ throw error
161
+ } catch (error: OpenIapError) {
162
+ if (PlayBillingPolicy.isCancellation(error)) {
163
+ PurchaseResolution.Cancelled
164
+ } else {
165
+ PurchaseResolution.Failed(PlayBillingPolicy.errorCode(error), billingMessage(error))
166
+ }
167
+ } catch (error: Exception) {
168
+ logUnexpected("purchase", error)
169
+ PurchaseResolution.Failed("PLAY_BILLING_ERROR", "Google Play Billing failed.")
170
+ } finally {
171
+ // Released on every path, so one failure never leaves later
172
+ // purchases answering PLAY_BILLING_BUSY until a restart.
173
+ flowInProgress = false
174
+ }
175
+ settlePurchase(call, productId, resolution)
176
+ }
177
+ }
178
+
179
+ private suspend fun runPurchase(
180
+ active: OpenIapPlayConnection,
181
+ productId: String,
182
+ accountId: String,
183
+ selectedOfferToken: String?,
184
+ ): PurchaseResolution {
185
+ if (!active.connect()) {
186
+ return PurchaseResolution.Failed("PLAY_BILLING_SERVICE_UNAVAILABLE", UNAVAILABLE_MESSAGE)
187
+ }
188
+ val product = active.fetchInAppProducts(listOf(productId))
189
+ .filter { it.id == productId && PlayBillingPolicy.isAvailable(it) }
190
+ .singleOrNull()
191
+ ?: return PurchaseResolution.Failed("PLAY_BILLING_PRODUCT_UNAVAILABLE", "Product is unavailable.")
192
+ val offer = PlayBillingPolicy.selectOffer(PlayBillingPolicy.eligibleOffers(product), selectedOfferToken)
193
+ ?: return PurchaseResolution.Failed("PLAY_BILLING_OFFER_REQUIRED", "Select an eligible one-time offer.")
194
+ val activity = activity
195
+ ?: return PurchaseResolution.Failed("PLAY_BILLING_ACTIVITY_REQUIRED", "Billing requires a foreground activity.")
196
+ // Tokens Play already reports. OpenIAP answers ITEM_ALREADY_OWNED with
197
+ // the owned purchase; the 0.3 contract rejects instead, and the owned
198
+ // purchase still reaches JavaScript through `purchaseUpdated`.
199
+ val ownedBefore = active.inAppPurchases().mapNotNullTo(HashSet()) { it.purchaseToken }
200
+ val (purchases, error) = active.purchase(activity, productId, offer.offerTokenAndroid.orEmpty(), accountId)
201
+ val matched = purchases.firstOrNull { it.ids.orEmpty().contains(productId) }
202
+ if (matched != null) {
203
+ if (matched.purchaseToken in ownedBefore) {
204
+ return PurchaseResolution.Failed("PLAY_BILLING_ALREADY_OWNED", "The product is already owned.")
205
+ }
206
+ val payload = PlayBillingPolicy.purchasePayload(matched)
207
+ ?: return PurchaseResolution.Failed("PLAY_BILLING_EMPTY_PURCHASE", EMPTY_MESSAGE)
208
+ return PurchaseResolution.Reported(PlayBillingPolicy.outcomePayload(payload))
209
+ }
210
+ return when {
211
+ error == null -> PurchaseResolution.Failed("PLAY_BILLING_EMPTY_PURCHASE", EMPTY_MESSAGE)
212
+ PlayBillingPolicy.isCancellation(error) -> PurchaseResolution.Cancelled
213
+ else -> PurchaseResolution.Failed(PlayBillingPolicy.errorCode(error), billingMessage(error))
214
+ }
215
+ }
216
+
217
+ private fun settlePurchase(call: PluginCall, productId: String, resolution: PurchaseResolution) {
218
+ if (activePurchase === call) {
219
+ clearActivePurchase(call)
220
+ when (resolution) {
221
+ is PurchaseResolution.Reported -> call.resolve(toJs(resolution.outcome))
222
+ PurchaseResolution.Cancelled -> call.resolve(JSObject().put("status", "cancelled"))
223
+ is PurchaseResolution.Failed -> call.reject(resolution.message, resolution.code)
224
+ }
225
+ return
226
+ }
227
+ // The call already timed out. A purchase reached JavaScript through
228
+ // `purchaseUpdated`; a cancel or failure closes the open attempt.
229
+ if (timedOutProductId != productId) return
230
+ when (resolution) {
231
+ is PurchaseResolution.Reported -> timedOutProductId = null
232
+ PurchaseResolution.Cancelled -> notifyLateTerminalResult("cancelled", null)
233
+ is PurchaseResolution.Failed ->
234
+ if (resolution.code != "PLAY_BILLING_EMPTY_PURCHASE") {
235
+ notifyLateTerminalResult("failed", resolution.code)
236
+ }
237
+ }
238
+ }
239
+
240
+ @PluginMethod
241
+ fun getPurchases(call: PluginCall) {
242
+ onMain {
243
+ val active = connectionOrReject(call) ?: return@onMain
244
+ launchCall(call) {
245
+ if (!active.connect()) {
246
+ rejectUnavailable(call)
247
+ return@launchCall
248
+ }
249
+ val purchases = JSArray()
250
+ for (purchase in active.inAppPurchases()) {
251
+ PlayBillingPolicy.purchasePayload(purchase)?.let { purchases.put(toJs(it)) }
252
+ }
253
+ call.resolve(JSObject().put("purchases", purchases))
254
+ }
255
+ }
256
+ }
257
+
258
+ private fun onPurchaseUpdated(purchase: PurchaseAndroid) {
259
+ val payload = PlayBillingPolicy.purchasePayload(purchase) ?: return
260
+ // Retained until a listener consumes it: a purchase that completes
261
+ // after the purchase call timed out, or before the web view attached
262
+ // its listener, must still reach the provider's subscription.
263
+ notifyListeners("purchaseUpdated", toJs(PlayBillingPolicy.outcomePayload(payload)), true)
264
+ val timedOut = timedOutProductId
265
+ if (timedOut != null && purchase.ids.orEmpty().contains(timedOut)) {
266
+ timedOutProductId = null
267
+ }
268
+ }
269
+
270
+ /**
271
+ * A cancel or failure that arrives after the purchase call already timed
272
+ * out has no call to answer; without this event the JavaScript attempt
273
+ * would stay open and block another purchase of the product.
274
+ */
275
+ private fun notifyLateTerminalResult(status: String, code: String?) {
276
+ val productId = timedOutProductId ?: return
277
+ timedOutProductId = null
278
+ val outcome = JSObject().put("status", status).put("productId", productId)
279
+ if (code != null) {
280
+ outcome.put("code", code)
281
+ }
282
+ notifyListeners("purchaseUpdated", outcome, true)
283
+ }
284
+
285
+ private fun connectionOrReject(call: PluginCall): OpenIapPlayConnection? {
286
+ connection?.let { return it }
287
+ call.reject(providerError ?: "Google Play Billing is not configured.", "PLAY_BILLING_CONFIGURATION_ERROR")
288
+ return null
289
+ }
290
+
291
+ /** Runs a query on the main thread and answers every outcome, interruption included. */
292
+ private fun launchCall(call: PluginCall, block: suspend () -> Unit) {
293
+ scope.launch {
294
+ try {
295
+ block()
296
+ } catch (error: CancellationException) {
297
+ call.reject("Billing connection was interrupted.", "PLAY_BILLING_INTERRUPTED")
298
+ throw error
299
+ } catch (error: OpenIapError) {
300
+ call.reject(billingMessage(error), PlayBillingPolicy.errorCode(error))
301
+ } catch (error: Exception) {
302
+ logUnexpected(call.methodName ?: "query", error)
303
+ call.reject("Google Play Billing failed.", "PLAY_BILLING_ERROR")
304
+ }
305
+ }
306
+ }
307
+
308
+ /**
309
+ * The exception class only: a message or stack trace can carry a
310
+ * purchase token or account id, so neither is logged.
311
+ */
312
+ private fun logUnexpected(operation: String, error: Exception) {
313
+ Log.w(TAG, "Unexpected Google Play Billing failure in $operation: ${error.javaClass.name}")
314
+ }
315
+
316
+ private fun rejectUnavailable(call: PluginCall) {
317
+ call.reject(UNAVAILABLE_MESSAGE, "PLAY_BILLING_SERVICE_UNAVAILABLE")
318
+ }
319
+
320
+ private fun clearActivePurchase(call: PluginCall) {
321
+ if (activePurchase === call) {
322
+ activePurchase = null
323
+ activeProductId = null
324
+ purchaseTimeout?.cancel()
325
+ purchaseTimeout = null
326
+ }
327
+ }
328
+
329
+ private fun onMain(task: () -> Unit) {
330
+ if (Looper.myLooper() == Looper.getMainLooper()) {
331
+ task()
332
+ } else {
333
+ mainHandler.post(task)
334
+ }
335
+ }
336
+
337
+ private sealed class PurchaseResolution {
338
+ data class Reported(val outcome: Map<String, Any>) : PurchaseResolution()
339
+ object Cancelled : PurchaseResolution()
340
+ data class Failed(val code: String, val message: String) : PurchaseResolution()
341
+ }
342
+
343
+ private companion object {
344
+ const val TAG = "CapacitorPlayBilling"
345
+ const val PURCHASE_TIMEOUT_MILLIS = 180_000L
346
+ const val UNAVAILABLE_MESSAGE = "Google Play Billing is unavailable."
347
+ const val EMPTY_MESSAGE = "Purchase callback was empty; requery owned purchases."
348
+
349
+ fun billingMessage(error: OpenIapError): String =
350
+ "Google Play Billing is unavailable (${error.code})."
351
+
352
+ @Suppress("UNCHECKED_CAST")
353
+ fun toJs(value: Map<String, Any>): JSObject {
354
+ val result = JSObject()
355
+ for ((key, item) in value) {
356
+ when (item) {
357
+ is Map<*, *> -> result.put(key, toJs(item as Map<String, Any>))
358
+ is List<*> -> result.put(key, toJsArray(item))
359
+ else -> result.put(key, item)
360
+ }
361
+ }
362
+ return result
363
+ }
364
+
365
+ @Suppress("UNCHECKED_CAST")
366
+ fun toJsArray(values: List<*>): JSArray {
367
+ val result = JSArray()
368
+ for (item in values) {
369
+ when (item) {
370
+ is Map<*, *> -> result.put(toJs(item as Map<String, Any>))
371
+ is List<*> -> result.put(toJsArray(item))
372
+ else -> result.put(item)
373
+ }
374
+ }
375
+ return result
376
+ }
377
+ }
378
+ }
@@ -0,0 +1,146 @@
1
+ package dev.mpgd.capacitor.playbilling
2
+
3
+ import android.app.Activity
4
+ import android.content.Context
5
+ import dev.hyo.openiap.FetchProductsResultProducts
6
+ import dev.hyo.openiap.OpenIapError
7
+ import dev.hyo.openiap.OpenIapProtocol
8
+ import dev.hyo.openiap.OpenIapProvider
9
+ import dev.hyo.openiap.ProductAndroid
10
+ import dev.hyo.openiap.ProductQueryType
11
+ import dev.hyo.openiap.ProductRequest
12
+ import dev.hyo.openiap.PurchaseAndroid
13
+ import dev.hyo.openiap.RequestPurchaseAndroidProps
14
+ import dev.hyo.openiap.RequestPurchaseProps
15
+ import dev.hyo.openiap.RequestPurchasePropsByPlatforms
16
+ import dev.hyo.openiap.RequestPurchaseResultPurchase
17
+ import dev.hyo.openiap.RequestPurchaseResultPurchases
18
+ import dev.hyo.openiap.listener.OpenIapPurchaseErrorListener
19
+ import dev.hyo.openiap.listener.OpenIapPurchaseUpdateListener
20
+ import dev.hyo.openiap.store.OpenIapStore
21
+ import java.util.concurrent.atomic.AtomicReference
22
+ import kotlinx.coroutines.CoroutineScope
23
+ import kotlinx.coroutines.Dispatchers
24
+ import kotlinx.coroutines.SupervisorJob
25
+ import kotlinx.coroutines.launch
26
+
27
+ /**
28
+ * The OpenIAP Google connection one Capacitor plugin instance owns: the
29
+ * store-neutral half of the bridge.
30
+ *
31
+ * The provider comes from OpenIAP's own discovery (the merged
32
+ * `dev.hyo.openiap.PROVIDER` manifest entry), not from a hard-coded Play
33
+ * class, and the plugin refuses to run unless that provider is `play`: a
34
+ * receipt from another store must never reach the Google Play verifier.
35
+ * `OpenIapStore` is the public facade for connection, products, and purchase
36
+ * flows; the provider module is kept only for the in-app-only ownership
37
+ * query (`getAvailableItems(InApp)`), which the facade does not expose.
38
+ *
39
+ * The connection never finishes a purchase. `OpenIapStore.finishTransaction`,
40
+ * `acknowledgePurchaseAndroid`, and `consumePurchaseAndroid` are not called
41
+ * anywhere in this plugin: the mpgd backend finalizer consumes or
42
+ * acknowledges after its ledger grant.
43
+ */
44
+ internal class OpenIapPlayConnection private constructor(
45
+ private val module: OpenIapProtocol,
46
+ private val store: OpenIapStore,
47
+ onPurchase: (PurchaseAndroid) -> Unit,
48
+ ) {
49
+ private val errorSink = AtomicReference<((OpenIapError) -> Unit)?>(null)
50
+ private val updateListener = OpenIapPurchaseUpdateListener { purchase ->
51
+ (purchase as? PurchaseAndroid)?.let(onPurchase)
52
+ }
53
+ private val errorListener = OpenIapPurchaseErrorListener { error ->
54
+ errorSink.get()?.invoke(error)
55
+ }
56
+
57
+ init {
58
+ store.addPurchaseUpdateListener(updateListener)
59
+ store.addPurchaseErrorListener(errorListener)
60
+ }
61
+
62
+ /** Connects, or confirms the live connection. False when Play Billing is unavailable. */
63
+ suspend fun connect(): Boolean = store.initConnection(null)
64
+
65
+ suspend fun fetchInAppProducts(productIds: List<String>): List<ProductAndroid> {
66
+ val result = store.fetchProducts(ProductRequest(skus = productIds, type = ProductQueryType.InApp))
67
+ return (result as? FetchProductsResultProducts)?.value.orEmpty().filterIsInstance<ProductAndroid>()
68
+ }
69
+
70
+ /** Every in-app purchase Play reports (`queryPurchasesAsync(INAPP)`), never subscriptions. */
71
+ suspend fun inAppPurchases(): List<PurchaseAndroid> =
72
+ module.getAvailableItems(ProductQueryType.InApp).filterIsInstance<PurchaseAndroid>()
73
+
74
+ /**
75
+ * Runs one purchase flow and returns the purchases OpenIAP matched to it,
76
+ * with the error OpenIAP published for it when it matched none. OpenIAP
77
+ * reports most flow failures (cancel included) through its error listener
78
+ * and an empty result rather than by throwing.
79
+ */
80
+ suspend fun purchase(
81
+ activity: Activity,
82
+ productId: String,
83
+ offerToken: String,
84
+ obfuscatedAccountId: String,
85
+ ): Pair<List<PurchaseAndroid>, OpenIapError?> {
86
+ // OpenIAP publishes one error per purchase request, tagged with its
87
+ // product; an untagged error counts only until a tagged one arrives.
88
+ val captured = AtomicReference<OpenIapError?>(null)
89
+ errorSink.set { error ->
90
+ when (error.requestProductId) {
91
+ productId -> captured.set(error)
92
+ null -> captured.compareAndSet(null, error)
93
+ }
94
+ }
95
+ try {
96
+ store.setActivity(activity)
97
+ val result = store.requestPurchase(
98
+ RequestPurchaseProps(
99
+ request = RequestPurchaseProps.Request.Purchase(
100
+ RequestPurchasePropsByPlatforms(
101
+ google = RequestPurchaseAndroidProps(
102
+ obfuscatedAccountId = obfuscatedAccountId,
103
+ offerToken = offerToken,
104
+ skus = listOf(productId),
105
+ ),
106
+ ),
107
+ ),
108
+ type = ProductQueryType.InApp,
109
+ ),
110
+ )
111
+ val purchases = when (result) {
112
+ is RequestPurchaseResultPurchases -> result.value.orEmpty()
113
+ is RequestPurchaseResultPurchase -> listOfNotNull(result.value)
114
+ else -> emptyList()
115
+ }.filterIsInstance<PurchaseAndroid>()
116
+ return purchases to captured.get()
117
+ } finally {
118
+ errorSink.set(null)
119
+ }
120
+ }
121
+
122
+ fun close() {
123
+ store.removePurchaseUpdateListener(updateListener)
124
+ store.removePurchaseErrorListener(errorListener)
125
+ errorSink.set(null)
126
+ store.clear()
127
+ // Ending the connection never touches a purchase; it only releases Play.
128
+ CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
129
+ runCatching { store.endConnection() }
130
+ }
131
+ }
132
+
133
+ companion object {
134
+ /** Throws `OpenIapError.ProviderConfiguration` unless OpenIAP links the Play provider. */
135
+ fun open(context: Context, onPurchase: (PurchaseAndroid) -> Unit): OpenIapPlayConnection {
136
+ val factory = OpenIapProvider.factory(context)
137
+ if (factory.storeId != PlayBillingPolicy.PLAY_STORE_ID) {
138
+ throw OpenIapError.ProviderConfiguration(
139
+ "OpenIAP links the ${factory.storeId} provider; this plugin requires play.",
140
+ )
141
+ }
142
+ val module = OpenIapProvider.create(context, factory)
143
+ return OpenIapPlayConnection(module, OpenIapStore(module), onPurchase)
144
+ }
145
+ }
146
+ }