@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 +33 -11
- package/android/build.gradle +42 -1
- package/android/src/main/java/dev/mpgd/capacitor/playbilling/CapacitorPlayBillingPlugin.kt +378 -0
- package/android/src/main/java/dev/mpgd/capacitor/playbilling/OpenIapPlayConnection.kt +146 -0
- package/android/src/main/java/dev/mpgd/capacitor/playbilling/PlayBillingPolicy.kt +153 -0
- package/dist/store-sdk.d.ts +3 -1
- package/dist/store-sdk.js +19 -2
- package/package.json +8 -8
- package/android/src/main/java/dev/mpgd/capacitor/playbilling/CapacitorPlayBillingPlugin.java +0 -485
|
@@ -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
|
+
}
|
package/dist/store-sdk.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
+
"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.
|
|
41
|
+
"@mpgd/adapter-capacitor": "^0.16.0",
|
|
42
42
|
"@mpgd/bridge": "^0.16.0",
|
|
43
|
-
"@mpgd/platform": "^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/
|
|
52
|
-
"@mpgd/platform": "0.
|
|
53
|
-
"@mpgd/
|
|
54
|
-
"@mpgd/
|
|
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",
|