@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.
@@ -0,0 +1,153 @@
1
+ package dev.mpgd.capacitor.playbilling
2
+
3
+ import com.android.billingclient.api.BillingClient.BillingResponseCode
4
+ import dev.hyo.openiap.DiscountOffer
5
+ import dev.hyo.openiap.OpenIapError
6
+ import dev.hyo.openiap.ProductAndroid
7
+ import dev.hyo.openiap.ProductStatusAndroid
8
+ import dev.hyo.openiap.ProductType
9
+ import dev.hyo.openiap.PurchaseAndroid
10
+ import dev.hyo.openiap.PurchaseState
11
+ import kotlin.math.roundToLong
12
+
13
+ /**
14
+ * mpgd policy over OpenIAP's canonical Play purchase and product: which
15
+ * entries the plugin reports, the 0.3 payload shapes it reports them in, which
16
+ * one-time offer a purchase uses, and how OpenIAP errors become the plugin's
17
+ * existing `PLAY_BILLING_*` codes. Nothing here talks to Play; the plugin and
18
+ * the JVM unit tests drive it directly.
19
+ *
20
+ * The payloads keep the 0.3 field names exactly (`products`, `purchaseToken`,
21
+ * `orderId`, `purchaseTime`, `purchaseState`, `isAcknowledged`, `quantity`,
22
+ * `obfuscatedAccountId`), so the TypeScript SDK, existing journals, and the
23
+ * `play-token-sha256:<hex>` fallback keep working unchanged.
24
+ */
25
+ internal object PlayBillingPolicy {
26
+ /** OpenIAP's store id for the Google Play provider; mpgd calls it `google-play`. */
27
+ const val PLAY_STORE_ID = "play"
28
+
29
+ /**
30
+ * The 0.3 `PlayPurchase` payload, or null for an entry the plugin never
31
+ * reported: another provider, an unspecified state, or no token or product.
32
+ * `orderId` is OpenIAP's `transactionId`, which stays null until Play
33
+ * assigns an order id. OpenIAP's `id` falls back to the token and is never
34
+ * used as the order id; the TypeScript SDK derives the token key instead.
35
+ */
36
+ fun purchasePayload(purchase: PurchaseAndroid): Map<String, Any>? {
37
+ if (purchase.storeId != PLAY_STORE_ID) return null
38
+ val state = when (purchase.purchaseState) {
39
+ PurchaseState.Purchased -> "purchased"
40
+ PurchaseState.Pending -> "pending"
41
+ else -> return null
42
+ }
43
+ val token = purchase.purchaseToken
44
+ if (token == null || token.trim().isEmpty()) return null
45
+ val products = purchase.ids.orEmpty()
46
+ if (products.isEmpty()) return null
47
+ val payload = linkedMapOf<String, Any>(
48
+ "products" to products.toList(),
49
+ "purchaseToken" to token,
50
+ "purchaseState" to state,
51
+ "purchaseTime" to purchase.transactionDate.toLong(),
52
+ "quantity" to purchase.quantity,
53
+ "isAcknowledged" to (purchase.isAcknowledgedAndroid == true),
54
+ )
55
+ purchase.transactionId?.let { payload["orderId"] = it }
56
+ purchase.obfuscatedAccountIdAndroid?.let { payload["obfuscatedAccountId"] = it }
57
+ return payload
58
+ }
59
+
60
+ /** The `purchaseUpdated` outcome and `purchase` resolution for one purchase. */
61
+ fun outcomePayload(purchase: Map<String, Any>): Map<String, Any> =
62
+ linkedMapOf("status" to (purchase["purchaseState"] as String), "purchase" to purchase)
63
+
64
+ /** Whether Play returned details for the product (not an unfetched placeholder). */
65
+ fun isAvailable(product: ProductAndroid): Boolean =
66
+ product.type == ProductType.InApp &&
67
+ (product.productStatusAndroid == null || product.productStatusAndroid == ProductStatusAndroid.Ok)
68
+
69
+ /** One-time offers the plugin sells: no rental or pre-order offers, as in 0.3. */
70
+ fun eligibleOffers(product: ProductAndroid): List<DiscountOffer> =
71
+ product.discountOffers.orEmpty().filter { offer ->
72
+ offer.rentalDetailsAndroid == null &&
73
+ offer.preorderDetailsAndroid == null &&
74
+ !offer.offerTokenAndroid.isNullOrEmpty()
75
+ }
76
+
77
+ /** The 0.3 `PlayProduct` payload, or null when Play lists no eligible offer. */
78
+ fun productPayload(product: ProductAndroid): Map<String, Any>? {
79
+ if (!isAvailable(product)) return null
80
+ val offers = eligibleOffers(product)
81
+ if (offers.isEmpty()) return null
82
+ return linkedMapOf(
83
+ "productId" to product.id,
84
+ "title" to product.title,
85
+ "description" to product.description,
86
+ "offers" to offers.map(::offerPayload),
87
+ )
88
+ }
89
+
90
+ fun offerPayload(offer: DiscountOffer): Map<String, Any> = linkedMapOf(
91
+ "offerToken" to offer.offerTokenAndroid.orEmpty(),
92
+ "formattedPrice" to offer.displayPrice,
93
+ "currencyCode" to offer.currency,
94
+ // OpenIAP reports `priceAmountMicros / 1e6`; micros round-trip exactly
95
+ // for any real price.
96
+ "priceAmountMicros" to (offer.price * 1_000_000.0).roundToLong(),
97
+ )
98
+
99
+ /**
100
+ * The offer a purchase uses: the named one, or the only eligible one when
101
+ * none is named. Several eligible offers and no choice is an error, never
102
+ * a silent pick.
103
+ */
104
+ fun selectOffer(offers: List<DiscountOffer>, offerToken: String?): DiscountOffer? {
105
+ if (offerToken != null) return offers.firstOrNull { it.offerTokenAndroid == offerToken }
106
+ return offers.singleOrNull()
107
+ }
108
+
109
+ fun isCancellation(error: OpenIapError): Boolean =
110
+ error is OpenIapError.UserCancelled || error === OpenIapError.PurchaseCancelled
111
+
112
+ /** The 0.3 plugin code for an OpenIAP error (cancellation is not an error). */
113
+ fun errorCode(error: OpenIapError): String = when (error) {
114
+ is OpenIapError.ServiceDisconnected -> "PLAY_BILLING_DISCONNECTED"
115
+ is OpenIapError.ServiceUnavailable,
116
+ is OpenIapError.ServiceTimeout,
117
+ is OpenIapError.StoreConnectionFailure,
118
+ OpenIapError.NotPrepared,
119
+ OpenIapError.InitConnection -> "PLAY_BILLING_SERVICE_UNAVAILABLE"
120
+ is OpenIapError.NetworkFailure,
121
+ OpenIapError.NetworkError -> "PLAY_BILLING_NETWORK_ERROR"
122
+ is OpenIapError.BillingError,
123
+ is OpenIapError.PurchaseFailed -> "PLAY_BILLING_TRANSIENT_ERROR"
124
+ is OpenIapError.ItemAlreadyOwned -> "PLAY_BILLING_ALREADY_OWNED"
125
+ is OpenIapError.ItemUnavailable,
126
+ is OpenIapError.SkuNotFound,
127
+ is OpenIapError.ProductNotFound -> "PLAY_BILLING_PRODUCT_UNAVAILABLE"
128
+ is OpenIapError.DeveloperError,
129
+ is OpenIapError.ProviderConfiguration -> "PLAY_BILLING_CONFIGURATION_ERROR"
130
+ is OpenIapError.SkuOfferMismatchFailure,
131
+ OpenIapError.SkuOfferMismatch -> "PLAY_BILLING_OFFER_REQUIRED"
132
+ OpenIapError.MissingCurrentActivity -> "PLAY_BILLING_ACTIVITY_REQUIRED"
133
+ // A deferred result has no purchase yet: requery, as for an empty callback.
134
+ is OpenIapError.DeferredPurchase,
135
+ OpenIapError.PurchaseDeferred -> "PLAY_BILLING_EMPTY_PURCHASE"
136
+ is OpenIapError.QueryProduct -> error.responseCode?.let(::billingResponseCode) ?: "PLAY_BILLING_ERROR"
137
+ else -> "PLAY_BILLING_ERROR"
138
+ }
139
+
140
+ /** The 0.3 table for a raw `BillingResponseCode` (product-query failures carry one). */
141
+ @Suppress("DEPRECATION")
142
+ fun billingResponseCode(responseCode: Int): String = when (responseCode) {
143
+ BillingResponseCode.SERVICE_DISCONNECTED -> "PLAY_BILLING_DISCONNECTED"
144
+ BillingResponseCode.SERVICE_UNAVAILABLE,
145
+ BillingResponseCode.SERVICE_TIMEOUT -> "PLAY_BILLING_SERVICE_UNAVAILABLE"
146
+ BillingResponseCode.NETWORK_ERROR -> "PLAY_BILLING_NETWORK_ERROR"
147
+ BillingResponseCode.ERROR -> "PLAY_BILLING_TRANSIENT_ERROR"
148
+ BillingResponseCode.ITEM_ALREADY_OWNED -> "PLAY_BILLING_ALREADY_OWNED"
149
+ BillingResponseCode.ITEM_UNAVAILABLE -> "PLAY_BILLING_PRODUCT_UNAVAILABLE"
150
+ BillingResponseCode.DEVELOPER_ERROR -> "PLAY_BILLING_CONFIGURATION_ERROR"
151
+ else -> "PLAY_BILLING_ERROR"
152
+ }
153
+ }
@@ -59,7 +59,9 @@ export declare class PlayBillingStoreSdkError extends Error implements StoreSdkE
59
59
  * the obfuscated account id from the request's account binding and the offer
60
60
  * token from `offerId`, `getAvailablePurchases` is `getPurchases` filtered to
61
61
  * unfinished entries (purchased and not acknowledged, plus pending on
62
- * request), and `purchaseUpdated` events are the plugin's. A purchased plugin
62
+ * request; the player's Restore Purchases read, `restore: true`, adds every
63
+ * owned configured durable, acknowledged or not), and `purchaseUpdated`
64
+ * events are the plugin's. A purchased plugin
63
65
  * outcome is `purchased`; a pending one is `launched`, because Play resolves
64
66
  * it later through `purchaseUpdated` or `getPurchases`; a cancelled one is
65
67
  * `cancelled`; a plugin rejection is `failed` with the plugin code, which the
package/dist/store-sdk.js CHANGED
@@ -61,7 +61,9 @@ export class PlayBillingStoreSdkError extends Error {
61
61
  * the obfuscated account id from the request's account binding and the offer
62
62
  * token from `offerId`, `getAvailablePurchases` is `getPurchases` filtered to
63
63
  * unfinished entries (purchased and not acknowledged, plus pending on
64
- * request), and `purchaseUpdated` events are the plugin's. A purchased plugin
64
+ * request; the player's Restore Purchases read, `restore: true`, adds every
65
+ * owned configured durable, acknowledged or not), and `purchaseUpdated`
66
+ * events are the plugin's. A purchased plugin
65
67
  * outcome is `purchased`; a pending one is `launched`, because Play resolves
66
68
  * it later through `purchaseUpdated` or `getPurchases`; a cancelled one is
67
69
  * `cancelled`; a plugin rejection is `failed` with the plugin code, which the
@@ -356,13 +358,14 @@ export function createCapacitorPlayBillingStoreSdk(input) {
356
358
  async getAvailablePurchases(request) {
357
359
  assertAndroid(isAndroid);
358
360
  const includePending = request.includePending ?? false;
361
+ const restore = request.restore === true;
359
362
  const response = await callPlugin(() => plugin.getPurchases());
360
363
  const available = [];
361
364
  for (const purchase of readArray(response, 'purchases')) {
362
365
  if (!isRecord(purchase)) {
363
366
  throw invalidResponse('Play reported an invalid purchase.');
364
367
  }
365
- if (!isUnfinished(purchase, includePending)) {
368
+ if (!isUnfinished(purchase, includePending) && !(restore && isOwnedDurable(purchase))) {
366
369
  continue;
367
370
  }
368
371
  isMapped(purchase);
@@ -412,6 +415,20 @@ export function createCapacitorPlayBillingStoreSdk(input) {
412
415
  }
413
416
  },
414
417
  };
418
+ /**
419
+ * A purchased entry of exactly one configured durable, acknowledged or not:
420
+ * what the player's Restore Purchases read adds to the unfinished list.
421
+ * The backend acknowledged it at the first purchase, so after a reinstall
422
+ * or on a new device it is never unfinished again, and only a restore read
423
+ * hands it to the backend's restore sharing. A consumable, an unmapped SKU,
424
+ * and a multi-line purchase are not added.
425
+ */
426
+ function isOwnedDurable(purchase) {
427
+ const sku = skuOf(purchase);
428
+ return purchaseStateOf(purchase) === 'purchased'
429
+ && sku !== undefined
430
+ && products.get(sku)?.kind === 'durable';
431
+ }
415
432
  return sdk;
416
433
  }
417
434
  /** Validates a Play purchase token: non-empty, at most 4096 characters, no padding or control characters. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mpgd/capacitor-play-billing",
3
- "version": "0.3.10",
3
+ "version": "0.4.2",
4
4
  "description": "Opt-in Android Google Play Billing purchase collector for mpgd Capacitor games.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -34,13 +34,13 @@
34
34
  "files": [
35
35
  "dist",
36
36
  "android/build.gradle",
37
- "android/src"
37
+ "android/src/main"
38
38
  ],
39
39
  "peerDependencies": {
40
40
  "@capacitor/core": "^8.5.1",
41
- "@mpgd/adapter-capacitor": "^0.13.0",
41
+ "@mpgd/adapter-capacitor": "^0.16.0",
42
42
  "@mpgd/bridge": "^0.16.0",
43
- "@mpgd/platform": "^0.29.0"
43
+ "@mpgd/platform": "^0.32.0"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@capacitor/core": "8.5.2",
@@ -48,10 +48,10 @@
48
48
  "ttsc": "0.32.1",
49
49
  "typescript": "7.0.2",
50
50
  "vitest": "^4.0.0",
51
- "@mpgd/bridge": "0.16.1",
52
- "@mpgd/platform": "0.29.0",
53
- "@mpgd/game-services": "0.29.0",
54
- "@mpgd/adapter-capacitor": "0.13.0"
51
+ "@mpgd/adapter-capacitor": "0.16.0",
52
+ "@mpgd/platform": "0.32.0",
53
+ "@mpgd/bridge": "0.16.4",
54
+ "@mpgd/game-services": "0.30.2"
55
55
  },
56
56
  "main": "./dist/index.js",
57
57
  "types": "./dist/index.d.ts",