@mpgd/capacitor-play-billing 0.1.0
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 +44 -0
- package/android/build.gradle +22 -0
- package/android/src/main/AndroidManifest.xml +3 -0
- package/android/src/main/java/dev/mpgd/capacitor/playbilling/CapacitorPlayBillingPlugin.java +433 -0
- package/dist/definitions.d.ts +41 -0
- package/dist/definitions.js +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/plugin.d.ts +2 -0
- package/dist/plugin.js +2 -0
- package/dist/provider.d.ts +79 -0
- package/dist/provider.js +299 -0
- package/package.json +52 -0
package/README.md
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# @mpgd/capacitor-play-billing
|
|
2
|
+
|
|
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
|
|
8
|
+
purchase via Google Play Publisher, commit the mpgd ledger grant, and perform
|
|
9
|
+
acknowledge/consume after the grant. A device callback alone never grants a
|
|
10
|
+
purchase.
|
|
11
|
+
|
|
12
|
+
The published package has passed mocked contract checks, an Android compile,
|
|
13
|
+
and external tarball consumer checks. It has not yet passed a live Play purchase
|
|
14
|
+
on a signed game; validate that path before a production rollout.
|
|
15
|
+
|
|
16
|
+
The provider maps game logical product IDs to Play product IDs and requires a
|
|
17
|
+
stable obfuscated account ID that agrees with the backend account resolver.
|
|
18
|
+
When Play returns multiple eligible one-time offers, map an explicit
|
|
19
|
+
`offerToken`; otherwise product lookup reports `PLAY_BILLING_OFFER_REQUIRED`
|
|
20
|
+
instead of displaying a price for the wrong offer.
|
|
21
|
+
`getOwnedPurchases()` returns provisional evidence for recovery after a missed
|
|
22
|
+
callback or process restart. `recoverOwnedPlayPurchases({ provider, backend:
|
|
23
|
+
backend.purchases, playerId })` submits completed owned purchases to the same
|
|
24
|
+
authenticated backend using stable token-derived idempotency keys. Backend
|
|
25
|
+
uncertainty stays pending for retry; `commerce.restore` deliberately returns
|
|
26
|
+
no local entitlements. Pending Play purchases remain pending until Google
|
|
27
|
+
reports purchased. The caller must not grant based on a native callback,
|
|
28
|
+
`getOwnedPurchases()` result, or `commerce.restore` alone.
|
|
29
|
+
If a launched purchase times out, is interrupted, or reports an already-owned
|
|
30
|
+
item without a matching callback, the provider returns `pending`. Requery and
|
|
31
|
+
recover owned purchases before offering another billing flow.
|
|
32
|
+
|
|
33
|
+
The Kit reference app includes an opt-in Gradle compile target, without
|
|
34
|
+
bundling Billing into the base app:
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
cd apps/mobile-capacitor/android
|
|
38
|
+
./gradlew -PmpgdPlayBillingPluginTest=true \
|
|
39
|
+
:mpgd-capacitor-play-billing:compileDebugJavaWithJavac
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
This compile and the TypeScript fixture do not prove a live Play purchase,
|
|
43
|
+
license-test configuration, or device behavior. Validate those in a game-owned
|
|
44
|
+
Play Console project before enabling commerce for players.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
apply plugin: 'com.android.library'
|
|
2
|
+
|
|
3
|
+
android {
|
|
4
|
+
namespace 'dev.mpgd.capacitor.playbilling'
|
|
5
|
+
compileSdk project.hasProperty('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 36
|
|
6
|
+
|
|
7
|
+
defaultConfig {
|
|
8
|
+
minSdkVersion project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 24
|
|
9
|
+
targetSdkVersion project.hasProperty('targetSdkVersion') ? rootProject.ext.targetSdkVersion : 36
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
repositories {
|
|
14
|
+
google()
|
|
15
|
+
mavenCentral()
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
dependencies {
|
|
19
|
+
implementation project(':capacitor-android')
|
|
20
|
+
implementation 'com.android.billingclient:billing:9.1.0'
|
|
21
|
+
compileOnly 'androidx.appcompat:appcompat:1.7.1'
|
|
22
|
+
}
|
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
package dev.mpgd.capacitor.playbilling;
|
|
2
|
+
|
|
3
|
+
import android.os.Handler;
|
|
4
|
+
import android.os.Looper;
|
|
5
|
+
import com.android.billingclient.api.BillingClient;
|
|
6
|
+
import com.android.billingclient.api.BillingClientStateListener;
|
|
7
|
+
import com.android.billingclient.api.BillingFlowParams;
|
|
8
|
+
import com.android.billingclient.api.BillingResult;
|
|
9
|
+
import com.android.billingclient.api.PendingPurchasesParams;
|
|
10
|
+
import com.android.billingclient.api.ProductDetails;
|
|
11
|
+
import com.android.billingclient.api.Purchase;
|
|
12
|
+
import com.android.billingclient.api.QueryProductDetailsParams;
|
|
13
|
+
import com.android.billingclient.api.QueryPurchasesParams;
|
|
14
|
+
import com.getcapacitor.JSArray;
|
|
15
|
+
import com.getcapacitor.JSObject;
|
|
16
|
+
import com.getcapacitor.Plugin;
|
|
17
|
+
import com.getcapacitor.PluginCall;
|
|
18
|
+
import com.getcapacitor.PluginMethod;
|
|
19
|
+
import com.getcapacitor.annotation.CapacitorPlugin;
|
|
20
|
+
import java.util.ArrayList;
|
|
21
|
+
import java.util.Collections;
|
|
22
|
+
import java.util.List;
|
|
23
|
+
import org.json.JSONException;
|
|
24
|
+
|
|
25
|
+
/** Purchase collection only. Finalization is server-owned after the mpgd ledger grant. */
|
|
26
|
+
@CapacitorPlugin(name = "CapacitorPlayBilling")
|
|
27
|
+
public class CapacitorPlayBillingPlugin extends Plugin {
|
|
28
|
+
private BillingClient billingClient;
|
|
29
|
+
private final List<PendingOperation> afterConnection = new ArrayList<>();
|
|
30
|
+
private final Handler mainHandler = new Handler(Looper.getMainLooper());
|
|
31
|
+
private boolean connecting;
|
|
32
|
+
private PluginCall activePurchase;
|
|
33
|
+
private String activeProductId;
|
|
34
|
+
private Runnable purchaseTimeout;
|
|
35
|
+
|
|
36
|
+
@Override
|
|
37
|
+
public void load() {
|
|
38
|
+
billingClient = BillingClient.newBuilder(getContext())
|
|
39
|
+
.setListener((result, purchases) ->
|
|
40
|
+
mainHandler.post(() -> onPurchasesUpdated(result, purchases)))
|
|
41
|
+
.enablePendingPurchases(PendingPurchasesParams.newBuilder().enableOneTimeProducts().build())
|
|
42
|
+
.enableAutoServiceReconnection()
|
|
43
|
+
.build();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
@Override
|
|
47
|
+
protected void handleOnDestroy() {
|
|
48
|
+
if (activePurchase != null) {
|
|
49
|
+
activePurchase.reject("Purchase flow was interrupted.", "PLAY_BILLING_INTERRUPTED");
|
|
50
|
+
clearActivePurchase(activePurchase);
|
|
51
|
+
}
|
|
52
|
+
for (PendingOperation operation : afterConnection) {
|
|
53
|
+
operation.call.reject("Billing connection was interrupted.", "PLAY_BILLING_INTERRUPTED");
|
|
54
|
+
}
|
|
55
|
+
afterConnection.clear();
|
|
56
|
+
if (billingClient != null) {
|
|
57
|
+
billingClient.endConnection();
|
|
58
|
+
}
|
|
59
|
+
super.handleOnDestroy();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
@PluginMethod
|
|
63
|
+
public void getProducts(PluginCall call) {
|
|
64
|
+
mainHandler.post(() -> getProductsOnMain(call));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
private void getProductsOnMain(PluginCall call) {
|
|
68
|
+
JSArray productIds = call.getArray("productIds");
|
|
69
|
+
if (productIds == null || productIds.length() == 0 || productIds.length() > 100) {
|
|
70
|
+
call.reject("Provide 1-100 product IDs.", "PLAY_BILLING_INVALID_PRODUCTS");
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
List<QueryProductDetailsParams.Product> products = new ArrayList<>();
|
|
74
|
+
try {
|
|
75
|
+
for (int index = 0; index < productIds.length(); index++) {
|
|
76
|
+
String productId = productIds.getString(index);
|
|
77
|
+
if (productId == null || productId.trim().isEmpty()) {
|
|
78
|
+
call.reject("Product ID is invalid.", "PLAY_BILLING_INVALID_PRODUCT");
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
products.add(QueryProductDetailsParams.Product.newBuilder()
|
|
82
|
+
.setProductId(productId)
|
|
83
|
+
.setProductType(BillingClient.ProductType.INAPP)
|
|
84
|
+
.build());
|
|
85
|
+
}
|
|
86
|
+
} catch (JSONException error) {
|
|
87
|
+
call.reject("Product IDs are invalid.", "PLAY_BILLING_INVALID_PRODUCTS");
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
connected(call, () -> billingClient.queryProductDetailsAsync(
|
|
91
|
+
QueryProductDetailsParams.newBuilder().setProductList(products).build(),
|
|
92
|
+
(result, detailsResult) -> {
|
|
93
|
+
if (!isOk(result)) {
|
|
94
|
+
rejectBilling(call, result);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
JSArray found = new JSArray();
|
|
98
|
+
for (ProductDetails details : detailsResult.getProductDetailsList()) {
|
|
99
|
+
List<ProductDetails.OneTimePurchaseOfferDetails> offers =
|
|
100
|
+
details.getOneTimePurchaseOfferDetailsList();
|
|
101
|
+
if (offers == null || offers.isEmpty()) {
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
JSArray eligibleOffers = new JSArray();
|
|
105
|
+
for (ProductDetails.OneTimePurchaseOfferDetails offer : offers) {
|
|
106
|
+
if (offer.getRentalDetails() == null && offer.getPreorderDetails() == null) {
|
|
107
|
+
eligibleOffers.put(new JSObject()
|
|
108
|
+
.put("offerToken", offer.getOfferToken())
|
|
109
|
+
.put("formattedPrice", offer.getFormattedPrice())
|
|
110
|
+
.put("currencyCode", offer.getPriceCurrencyCode()));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (eligibleOffers.length() == 0) {
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
found.put(new JSObject()
|
|
117
|
+
.put("productId", details.getProductId())
|
|
118
|
+
.put("title", details.getTitle())
|
|
119
|
+
.put("description", details.getDescription())
|
|
120
|
+
.put("offers", eligibleOffers));
|
|
121
|
+
}
|
|
122
|
+
call.resolve(new JSObject().put("products", found));
|
|
123
|
+
}
|
|
124
|
+
));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
@PluginMethod
|
|
128
|
+
public void purchase(PluginCall call) {
|
|
129
|
+
mainHandler.post(() -> purchaseOnMain(call));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private void purchaseOnMain(PluginCall call) {
|
|
133
|
+
String productId = call.getString("productId");
|
|
134
|
+
String accountId = call.getString("obfuscatedAccountId");
|
|
135
|
+
String selectedOfferToken = call.getString("offerToken");
|
|
136
|
+
if (productId == null || productId.trim().isEmpty() || accountId == null
|
|
137
|
+
|| accountId.trim().isEmpty() || accountId.length() > 64) {
|
|
138
|
+
call.reject("Product and account identifiers are required.", "PLAY_BILLING_INVALID_REQUEST");
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (activePurchase != null) {
|
|
142
|
+
call.reject("Another purchase is already in progress.", "PLAY_BILLING_BUSY");
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
activePurchase = call;
|
|
146
|
+
activeProductId = productId;
|
|
147
|
+
purchaseTimeout = () -> {
|
|
148
|
+
if (activePurchase == call) {
|
|
149
|
+
clearActivePurchase(call);
|
|
150
|
+
call.reject("Purchase confirmation timed out; requery owned purchases.",
|
|
151
|
+
"PLAY_BILLING_TIMEOUT");
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
mainHandler.postDelayed(purchaseTimeout, 180_000);
|
|
155
|
+
connected(call, () -> {
|
|
156
|
+
if (activePurchase != call) {
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
billingClient.queryProductDetailsAsync(
|
|
160
|
+
QueryProductDetailsParams.newBuilder().setProductList(Collections.singletonList(
|
|
161
|
+
QueryProductDetailsParams.Product.newBuilder()
|
|
162
|
+
.setProductId(productId)
|
|
163
|
+
.setProductType(BillingClient.ProductType.INAPP)
|
|
164
|
+
.build()
|
|
165
|
+
)).build(),
|
|
166
|
+
(result, detailsResult) -> {
|
|
167
|
+
if (activePurchase != call) {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (!isOk(result)) {
|
|
171
|
+
clearActivePurchase(call);
|
|
172
|
+
rejectBilling(call, result);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
if (detailsResult.getProductDetailsList().size() != 1) {
|
|
176
|
+
clearActivePurchase(call);
|
|
177
|
+
call.reject("Product is unavailable.", "PLAY_BILLING_PRODUCT_UNAVAILABLE");
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
ProductDetails details = detailsResult.getProductDetailsList().get(0);
|
|
181
|
+
List<ProductDetails.OneTimePurchaseOfferDetails> offers =
|
|
182
|
+
details.getOneTimePurchaseOfferDetailsList();
|
|
183
|
+
ProductDetails.OneTimePurchaseOfferDetails selected = selectOffer(
|
|
184
|
+
offers, selectedOfferToken);
|
|
185
|
+
if (selected == null) {
|
|
186
|
+
clearActivePurchase(call);
|
|
187
|
+
call.reject("Select an eligible one-time offer.", "PLAY_BILLING_OFFER_REQUIRED");
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
BillingFlowParams.ProductDetailsParams productParams =
|
|
191
|
+
BillingFlowParams.ProductDetailsParams.newBuilder()
|
|
192
|
+
.setProductDetails(details)
|
|
193
|
+
.setOfferToken(selected.getOfferToken())
|
|
194
|
+
.build();
|
|
195
|
+
BillingFlowParams flow = BillingFlowParams.newBuilder()
|
|
196
|
+
.setProductDetailsParamsList(Collections.singletonList(productParams))
|
|
197
|
+
.setObfuscatedAccountId(accountId)
|
|
198
|
+
.build();
|
|
199
|
+
if (getActivity() == null) {
|
|
200
|
+
clearActivePurchase(call);
|
|
201
|
+
call.reject("Billing requires a foreground activity.",
|
|
202
|
+
"PLAY_BILLING_ACTIVITY_REQUIRED");
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
BillingResult launched = billingClient.launchBillingFlow(getActivity(), flow);
|
|
206
|
+
if (!isOk(launched)) {
|
|
207
|
+
clearActivePurchase(call);
|
|
208
|
+
if (launched.getResponseCode() == BillingClient.BillingResponseCode.USER_CANCELED) {
|
|
209
|
+
call.resolve(new JSObject().put("status", "cancelled"));
|
|
210
|
+
} else {
|
|
211
|
+
rejectBilling(call, launched);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
);
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
@PluginMethod
|
|
220
|
+
public void getPurchases(PluginCall call) {
|
|
221
|
+
mainHandler.post(() -> getPurchasesOnMain(call));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
private void getPurchasesOnMain(PluginCall call) {
|
|
225
|
+
connected(call, () -> billingClient.queryPurchasesAsync(
|
|
226
|
+
QueryPurchasesParams.newBuilder().setProductType(BillingClient.ProductType.INAPP).build(),
|
|
227
|
+
(result, purchases) -> {
|
|
228
|
+
if (!isOk(result)) {
|
|
229
|
+
rejectBilling(call, result);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
JSArray found = new JSArray();
|
|
233
|
+
for (Purchase purchase : purchases) {
|
|
234
|
+
JSObject encoded = encodePurchase(purchase);
|
|
235
|
+
if (encoded != null) {
|
|
236
|
+
found.put(encoded);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
call.resolve(new JSObject().put("purchases", found));
|
|
240
|
+
}
|
|
241
|
+
));
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
private void onPurchasesUpdated(BillingResult result, List<Purchase> purchases) {
|
|
245
|
+
PluginCall pending = activePurchase;
|
|
246
|
+
String expectedProductId = activeProductId;
|
|
247
|
+
if (result.getResponseCode() == BillingClient.BillingResponseCode.USER_CANCELED) {
|
|
248
|
+
if (pending != null) {
|
|
249
|
+
clearActivePurchase(pending);
|
|
250
|
+
pending.resolve(new JSObject().put("status", "cancelled"));
|
|
251
|
+
}
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
if (!isOk(result)) {
|
|
255
|
+
if (pending != null) {
|
|
256
|
+
clearActivePurchase(pending);
|
|
257
|
+
rejectBilling(pending, result);
|
|
258
|
+
}
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
if (purchases == null || purchases.isEmpty()) {
|
|
262
|
+
if (pending != null) {
|
|
263
|
+
clearActivePurchase(pending);
|
|
264
|
+
pending.reject("Purchase callback was empty; requery owned purchases.",
|
|
265
|
+
"PLAY_BILLING_EMPTY_PURCHASE");
|
|
266
|
+
}
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
for (Purchase purchase : purchases) {
|
|
270
|
+
JSObject encoded = encodePurchase(purchase);
|
|
271
|
+
if (encoded == null) {
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
JSObject outcome = new JSObject()
|
|
275
|
+
.put("status", encoded.optString("state"))
|
|
276
|
+
.put("purchase", encoded);
|
|
277
|
+
notifyListeners("purchaseUpdated", outcome);
|
|
278
|
+
if (pending != null && purchase.getProducts().contains(expectedProductId)) {
|
|
279
|
+
clearActivePurchase(pending);
|
|
280
|
+
pending.resolve(outcome);
|
|
281
|
+
pending = null;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
private JSObject encodePurchase(Purchase purchase) {
|
|
287
|
+
int state = purchase.getPurchaseState();
|
|
288
|
+
if (state != Purchase.PurchaseState.PURCHASED
|
|
289
|
+
&& state != Purchase.PurchaseState.PENDING) {
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
String token = purchase.getPurchaseToken();
|
|
293
|
+
if (token == null || token.trim().isEmpty() || purchase.getProducts().isEmpty()) {
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
JSArray productIds = new JSArray();
|
|
297
|
+
for (String productId : purchase.getProducts()) {
|
|
298
|
+
productIds.put(productId);
|
|
299
|
+
}
|
|
300
|
+
JSObject encoded = new JSObject()
|
|
301
|
+
.put("productIds", productIds)
|
|
302
|
+
.put("purchaseToken", token)
|
|
303
|
+
.put("state", state == Purchase.PurchaseState.PURCHASED ? "purchased" : "pending");
|
|
304
|
+
if (purchase.getPurchaseTime() > 0) {
|
|
305
|
+
encoded.put("purchaseTimeMillis", purchase.getPurchaseTime());
|
|
306
|
+
}
|
|
307
|
+
if (purchase.getOrderId() != null) {
|
|
308
|
+
encoded.put("orderId", purchase.getOrderId());
|
|
309
|
+
}
|
|
310
|
+
return encoded;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
private ProductDetails.OneTimePurchaseOfferDetails selectOffer(
|
|
314
|
+
List<ProductDetails.OneTimePurchaseOfferDetails> offers,
|
|
315
|
+
String offerToken
|
|
316
|
+
) {
|
|
317
|
+
if (offers == null) {
|
|
318
|
+
return null;
|
|
319
|
+
}
|
|
320
|
+
ProductDetails.OneTimePurchaseOfferDetails only = null;
|
|
321
|
+
int eligibleCount = 0;
|
|
322
|
+
for (ProductDetails.OneTimePurchaseOfferDetails offer : offers) {
|
|
323
|
+
if (offer.getRentalDetails() != null || offer.getPreorderDetails() != null) {
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
if (offerToken != null && offerToken.equals(offer.getOfferToken())) {
|
|
327
|
+
return offer;
|
|
328
|
+
}
|
|
329
|
+
eligibleCount++;
|
|
330
|
+
only = offer;
|
|
331
|
+
}
|
|
332
|
+
return offerToken == null && eligibleCount == 1 ? only : null;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
private void connected(PluginCall call, Runnable task) {
|
|
336
|
+
if (billingClient.isReady()) {
|
|
337
|
+
task.run();
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
afterConnection.add(new PendingOperation(call, task));
|
|
341
|
+
if (connecting) {
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
connecting = true;
|
|
345
|
+
billingClient.startConnection(new BillingClientStateListener() {
|
|
346
|
+
@Override
|
|
347
|
+
public void onBillingSetupFinished(BillingResult result) {
|
|
348
|
+
connecting = false;
|
|
349
|
+
List<PendingOperation> queued = new ArrayList<>(afterConnection);
|
|
350
|
+
afterConnection.clear();
|
|
351
|
+
if (!isOk(result)) {
|
|
352
|
+
for (PendingOperation operation : queued) {
|
|
353
|
+
clearActivePurchase(operation.call);
|
|
354
|
+
rejectBilling(operation.call, result);
|
|
355
|
+
}
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
for (PendingOperation operation : queued) {
|
|
359
|
+
operation.task.run();
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
@Override
|
|
364
|
+
public void onBillingServiceDisconnected() {
|
|
365
|
+
connecting = false;
|
|
366
|
+
List<PendingOperation> queued = new ArrayList<>(afterConnection);
|
|
367
|
+
afterConnection.clear();
|
|
368
|
+
for (PendingOperation operation : queued) {
|
|
369
|
+
clearActivePurchase(operation.call);
|
|
370
|
+
operation.call.reject("Billing service disconnected.", "PLAY_BILLING_DISCONNECTED");
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
private boolean isOk(BillingResult result) {
|
|
377
|
+
return result.getResponseCode() == BillingClient.BillingResponseCode.OK;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
private void rejectBilling(PluginCall call, BillingResult result) {
|
|
381
|
+
int responseCode = result.getResponseCode();
|
|
382
|
+
String code;
|
|
383
|
+
switch (responseCode) {
|
|
384
|
+
case BillingClient.BillingResponseCode.SERVICE_DISCONNECTED:
|
|
385
|
+
code = "PLAY_BILLING_DISCONNECTED";
|
|
386
|
+
break;
|
|
387
|
+
case BillingClient.BillingResponseCode.SERVICE_UNAVAILABLE:
|
|
388
|
+
case BillingClient.BillingResponseCode.SERVICE_TIMEOUT:
|
|
389
|
+
code = "PLAY_BILLING_SERVICE_UNAVAILABLE";
|
|
390
|
+
break;
|
|
391
|
+
case BillingClient.BillingResponseCode.NETWORK_ERROR:
|
|
392
|
+
code = "PLAY_BILLING_NETWORK_ERROR";
|
|
393
|
+
break;
|
|
394
|
+
case BillingClient.BillingResponseCode.ERROR:
|
|
395
|
+
code = "PLAY_BILLING_TRANSIENT_ERROR";
|
|
396
|
+
break;
|
|
397
|
+
case BillingClient.BillingResponseCode.ITEM_ALREADY_OWNED:
|
|
398
|
+
code = "PLAY_BILLING_ALREADY_OWNED";
|
|
399
|
+
break;
|
|
400
|
+
case BillingClient.BillingResponseCode.ITEM_UNAVAILABLE:
|
|
401
|
+
code = "PLAY_BILLING_PRODUCT_UNAVAILABLE";
|
|
402
|
+
break;
|
|
403
|
+
case BillingClient.BillingResponseCode.DEVELOPER_ERROR:
|
|
404
|
+
code = "PLAY_BILLING_CONFIGURATION_ERROR";
|
|
405
|
+
break;
|
|
406
|
+
default:
|
|
407
|
+
code = "PLAY_BILLING_ERROR";
|
|
408
|
+
}
|
|
409
|
+
call.reject("Google Play Billing is unavailable (" + result.getResponseCode() + ").",
|
|
410
|
+
code);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
private void clearActivePurchase(PluginCall call) {
|
|
414
|
+
if (activePurchase == call) {
|
|
415
|
+
activePurchase = null;
|
|
416
|
+
activeProductId = null;
|
|
417
|
+
if (purchaseTimeout != null) {
|
|
418
|
+
mainHandler.removeCallbacks(purchaseTimeout);
|
|
419
|
+
purchaseTimeout = null;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
private static final class PendingOperation {
|
|
425
|
+
final PluginCall call;
|
|
426
|
+
final Runnable task;
|
|
427
|
+
|
|
428
|
+
PendingOperation(PluginCall call, Runnable task) {
|
|
429
|
+
this.call = call;
|
|
430
|
+
this.task = task;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { PluginListenerHandle } from '@capacitor/core';
|
|
2
|
+
export type PlayPurchaseState = 'purchased' | 'pending';
|
|
3
|
+
export interface PlayProduct {
|
|
4
|
+
readonly productId: string;
|
|
5
|
+
readonly title: string;
|
|
6
|
+
readonly description: string;
|
|
7
|
+
/** Explicit offer selection is required when Google returns multiple offers. */
|
|
8
|
+
readonly offers: readonly {
|
|
9
|
+
readonly offerToken: string;
|
|
10
|
+
readonly formattedPrice: string;
|
|
11
|
+
readonly currencyCode: string;
|
|
12
|
+
}[];
|
|
13
|
+
}
|
|
14
|
+
export interface PlayPurchase {
|
|
15
|
+
readonly productIds: readonly string[];
|
|
16
|
+
readonly purchaseToken: string;
|
|
17
|
+
readonly orderId?: string;
|
|
18
|
+
readonly purchaseTimeMillis?: number;
|
|
19
|
+
readonly state: PlayPurchaseState;
|
|
20
|
+
}
|
|
21
|
+
export interface PlayPurchaseOutcome {
|
|
22
|
+
readonly status: 'purchased' | 'pending' | 'cancelled';
|
|
23
|
+
readonly purchase?: PlayPurchase;
|
|
24
|
+
}
|
|
25
|
+
export interface CapacitorPlayBillingPlugin {
|
|
26
|
+
getProducts(input: {
|
|
27
|
+
readonly productIds: readonly string[];
|
|
28
|
+
}): Promise<{
|
|
29
|
+
readonly products: readonly PlayProduct[];
|
|
30
|
+
}>;
|
|
31
|
+
purchase(input: {
|
|
32
|
+
readonly productId: string;
|
|
33
|
+
readonly offerToken?: string;
|
|
34
|
+
readonly obfuscatedAccountId: string;
|
|
35
|
+
}): Promise<PlayPurchaseOutcome>;
|
|
36
|
+
/** Requeries owned, unconsumed purchases after restart or missed callbacks. */
|
|
37
|
+
getPurchases(): Promise<{
|
|
38
|
+
readonly purchases: readonly PlayPurchase[];
|
|
39
|
+
}>;
|
|
40
|
+
addListener(eventName: 'purchaseUpdated', listener: (event: PlayPurchaseOutcome) => void): Promise<PluginListenerHandle>;
|
|
41
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
package/dist/plugin.d.ts
ADDED
package/dist/plugin.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { BridgeRequest, BridgeResponse } from '@mpgd/bridge';
|
|
2
|
+
import type { PurchaseResult } from '@mpgd/platform';
|
|
3
|
+
import type { CapacitorPlayBillingPlugin } from './definitions.js';
|
|
4
|
+
export interface PlayBillingProductMapping {
|
|
5
|
+
readonly id: string;
|
|
6
|
+
readonly type: 'consumable' | 'non_consumable';
|
|
7
|
+
readonly storeId: string;
|
|
8
|
+
/** Required when Play lists multiple eligible purchase options or offers. */
|
|
9
|
+
readonly offerToken?: string;
|
|
10
|
+
}
|
|
11
|
+
export interface PlayBillingProviderOptions {
|
|
12
|
+
readonly products: readonly PlayBillingProductMapping[];
|
|
13
|
+
/** Must agree with the server's resolveObfuscatedAccountId mapping. */
|
|
14
|
+
readonly getObfuscatedAccountId: () => Promise<string> | string;
|
|
15
|
+
readonly sdk?: Pick<CapacitorPlayBillingPlugin, 'getProducts' | 'purchase' | 'getPurchases'>;
|
|
16
|
+
readonly isAndroid?: () => boolean;
|
|
17
|
+
}
|
|
18
|
+
export interface OwnedPlayPurchase {
|
|
19
|
+
readonly productId: string;
|
|
20
|
+
readonly result: PurchaseResult;
|
|
21
|
+
readonly purchasedAt?: string;
|
|
22
|
+
}
|
|
23
|
+
export type PlayBillingAvailability = 'available' | 'configuration-required' | 'temporarily-unavailable' | 'unsupported';
|
|
24
|
+
export interface CapacitorPlayBillingProvider {
|
|
25
|
+
readonly id: 'google-play-billing';
|
|
26
|
+
readonly features: readonly ['nativeIap'];
|
|
27
|
+
readonly methods: readonly [
|
|
28
|
+
'commerce.getProducts',
|
|
29
|
+
'commerce.purchase',
|
|
30
|
+
'commerce.restore',
|
|
31
|
+
'commerce.getEntitlements'
|
|
32
|
+
];
|
|
33
|
+
readonly bridge: {
|
|
34
|
+
request(input: BridgeRequest): Promise<BridgeResponse>;
|
|
35
|
+
};
|
|
36
|
+
getAvailability(): Promise<Readonly<{
|
|
37
|
+
nativeIap: PlayBillingAvailability;
|
|
38
|
+
}>>;
|
|
39
|
+
/** Requery owned, non-consumed transactions for game-owned backend recovery. */
|
|
40
|
+
getOwnedPurchases(): Promise<readonly OwnedPlayPurchase[]>;
|
|
41
|
+
}
|
|
42
|
+
export interface RecoveredPlayPurchase {
|
|
43
|
+
readonly productId: string;
|
|
44
|
+
readonly status: 'granted' | 'pending' | 'rejected';
|
|
45
|
+
readonly verification?: PlayPurchaseVerification;
|
|
46
|
+
}
|
|
47
|
+
export interface PlayPurchaseVerification {
|
|
48
|
+
readonly verified: boolean;
|
|
49
|
+
readonly disposition?: 'pending' | 'rejected';
|
|
50
|
+
readonly alreadyProcessed: boolean;
|
|
51
|
+
readonly ledgerEntryId?: string;
|
|
52
|
+
readonly reason?: string;
|
|
53
|
+
}
|
|
54
|
+
export interface PlayPurchaseRecoveryBackend {
|
|
55
|
+
verifyPurchase(input: {
|
|
56
|
+
readonly target: 'android';
|
|
57
|
+
readonly deploymentTarget?: string;
|
|
58
|
+
readonly playerId: string;
|
|
59
|
+
readonly productId: string;
|
|
60
|
+
readonly platformTransactionId: string;
|
|
61
|
+
readonly idempotencyKey: string;
|
|
62
|
+
readonly purchasedAt: string;
|
|
63
|
+
readonly evidence: NonNullable<PurchaseResult['evidence']>;
|
|
64
|
+
}): Promise<PlayPurchaseVerification>;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Resubmit owned purchases after a callback was missed or the app restarted.
|
|
68
|
+
* A native purchase is never credited locally; only the backend response grants it.
|
|
69
|
+
*/
|
|
70
|
+
export declare function recoverOwnedPlayPurchases(input: {
|
|
71
|
+
readonly provider: CapacitorPlayBillingProvider;
|
|
72
|
+
readonly backend: PlayPurchaseRecoveryBackend;
|
|
73
|
+
readonly playerId: string;
|
|
74
|
+
readonly deploymentTarget?: string;
|
|
75
|
+
readonly now?: () => string;
|
|
76
|
+
}): Promise<readonly RecoveredPlayPurchase[]>;
|
|
77
|
+
export declare function createCapacitorPlayBillingProvider(options: PlayBillingProviderOptions): CapacitorPlayBillingProvider;
|
|
78
|
+
/** Must remain byte-for-byte compatible with the server's token transaction ID. */
|
|
79
|
+
export declare function createGooglePlayTokenTransactionId(purchaseToken: string): Promise<string>;
|
package/dist/provider.js
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import { Capacitor } from '@capacitor/core';
|
|
2
|
+
import { CapacitorPlayBilling } from './plugin.js';
|
|
3
|
+
const googlePlayProductPurchaseEvidenceSchema = 'google-play.product-purchase.v2';
|
|
4
|
+
class PlayPurchaseIntegrityError extends Error {
|
|
5
|
+
}
|
|
6
|
+
function isValidPurchaseToken(value) {
|
|
7
|
+
return typeof value === 'string'
|
|
8
|
+
&& value.length > 0
|
|
9
|
+
&& value.length <= 4096
|
|
10
|
+
&& value.trim() === value
|
|
11
|
+
&& !/[\u0000-\u001f\u007f]/u.test(value);
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Resubmit owned purchases after a callback was missed or the app restarted.
|
|
15
|
+
* A native purchase is never credited locally; only the backend response grants it.
|
|
16
|
+
*/
|
|
17
|
+
export async function recoverOwnedPlayPurchases(input) {
|
|
18
|
+
if (input.playerId.trim() === '') {
|
|
19
|
+
throw new TypeError('Recovery requires an authenticated player ID.');
|
|
20
|
+
}
|
|
21
|
+
const owned = await input.provider.getOwnedPurchases();
|
|
22
|
+
return Promise.all(owned.map(async ({ productId, result, purchasedAt }) => {
|
|
23
|
+
if (result.status === 'pending') {
|
|
24
|
+
return { productId, status: 'pending' };
|
|
25
|
+
}
|
|
26
|
+
if (result.status !== 'completed' || result.transactionId === undefined
|
|
27
|
+
|| result.evidence === undefined) {
|
|
28
|
+
return { productId, status: 'rejected' };
|
|
29
|
+
}
|
|
30
|
+
const token = result.evidence.payload.purchaseToken;
|
|
31
|
+
if (typeof token !== 'string') {
|
|
32
|
+
return { productId, status: 'rejected' };
|
|
33
|
+
}
|
|
34
|
+
try {
|
|
35
|
+
const verification = await input.backend.verifyPurchase({
|
|
36
|
+
target: 'android',
|
|
37
|
+
...(input.deploymentTarget === undefined ? {} : { deploymentTarget: input.deploymentTarget }),
|
|
38
|
+
playerId: input.playerId,
|
|
39
|
+
productId,
|
|
40
|
+
platformTransactionId: result.transactionId,
|
|
41
|
+
idempotencyKey: await createGooglePlayTokenTransactionId(token),
|
|
42
|
+
purchasedAt: purchasedAt ?? input.now?.() ?? new Date().toISOString(),
|
|
43
|
+
evidence: result.evidence,
|
|
44
|
+
});
|
|
45
|
+
return {
|
|
46
|
+
productId,
|
|
47
|
+
status: verification.verified ? 'granted' : (verification.disposition ?? 'pending'),
|
|
48
|
+
verification,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
// Network or backend uncertainty must remain retryable, never rejected.
|
|
53
|
+
return { productId, status: 'pending' };
|
|
54
|
+
}
|
|
55
|
+
}));
|
|
56
|
+
}
|
|
57
|
+
export function createCapacitorPlayBillingProvider(options) {
|
|
58
|
+
const sdk = options.sdk ?? CapacitorPlayBilling;
|
|
59
|
+
const isAndroid = options.isAndroid ?? (() => Capacitor.getPlatform() === 'android');
|
|
60
|
+
const byLogicalId = new Map();
|
|
61
|
+
const byStoreId = new Map();
|
|
62
|
+
for (const product of options.products) {
|
|
63
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u.test(product.id)
|
|
64
|
+
|| !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u.test(product.storeId)
|
|
65
|
+
|| byLogicalId.has(product.id)
|
|
66
|
+
|| byStoreId.has(product.storeId)) {
|
|
67
|
+
throw new TypeError('Play Billing product mappings must be unique and valid.');
|
|
68
|
+
}
|
|
69
|
+
byLogicalId.set(product.id, product);
|
|
70
|
+
byStoreId.set(product.storeId, product);
|
|
71
|
+
}
|
|
72
|
+
if (byLogicalId.size === 0) {
|
|
73
|
+
throw new TypeError('Play Billing requires at least one one-time product.');
|
|
74
|
+
}
|
|
75
|
+
if (byLogicalId.size > 100) {
|
|
76
|
+
throw new TypeError('Play Billing supports at most 100 products per provider.');
|
|
77
|
+
}
|
|
78
|
+
async function getOwnedPurchases() {
|
|
79
|
+
if (!isAndroid()) {
|
|
80
|
+
throw new Error('Play Billing is only available on Android.');
|
|
81
|
+
}
|
|
82
|
+
const response = await sdk.getPurchases();
|
|
83
|
+
const owned = [];
|
|
84
|
+
for (const purchase of response.purchases) {
|
|
85
|
+
if (purchase.productIds.length !== 1) {
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
const mapping = byStoreId.get(purchase.productIds[0] ?? '');
|
|
89
|
+
if (mapping !== undefined) {
|
|
90
|
+
const timestamp = purchase.purchaseTimeMillis;
|
|
91
|
+
const purchasedAt = typeof timestamp === 'number'
|
|
92
|
+
&& Number.isSafeInteger(timestamp)
|
|
93
|
+
&& timestamp > 0
|
|
94
|
+
&& timestamp <= 8_640_000_000_000_000
|
|
95
|
+
? new Date(timestamp).toISOString()
|
|
96
|
+
: undefined;
|
|
97
|
+
owned.push({
|
|
98
|
+
productId: mapping.id,
|
|
99
|
+
result: await convertPurchase(purchase),
|
|
100
|
+
...(purchasedAt === undefined ? {} : { purchasedAt }),
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return owned;
|
|
105
|
+
}
|
|
106
|
+
function selectOffer(mapping, found) {
|
|
107
|
+
if (mapping.offerToken === undefined) {
|
|
108
|
+
return found.offers.length === 1 ? found.offers[0] : undefined;
|
|
109
|
+
}
|
|
110
|
+
return found.offers.find((item) => item.offerToken === mapping.offerToken);
|
|
111
|
+
}
|
|
112
|
+
async function request(input) {
|
|
113
|
+
try {
|
|
114
|
+
if (!isAndroid()) {
|
|
115
|
+
return failure(input.id, 'PLAY_BILLING_ANDROID_REQUIRED');
|
|
116
|
+
}
|
|
117
|
+
switch (input.method) {
|
|
118
|
+
case 'commerce.getProducts': {
|
|
119
|
+
const response = await sdk.getProducts({
|
|
120
|
+
productIds: [...byStoreId.keys()],
|
|
121
|
+
});
|
|
122
|
+
const products = [];
|
|
123
|
+
for (const found of response.products) {
|
|
124
|
+
const mapping = byStoreId.get(found.productId);
|
|
125
|
+
if (mapping === undefined) {
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const offer = selectOffer(mapping, found);
|
|
129
|
+
if (offer === undefined) {
|
|
130
|
+
return failure(input.id, 'PLAY_BILLING_OFFER_REQUIRED');
|
|
131
|
+
}
|
|
132
|
+
products.push({
|
|
133
|
+
id: mapping.id,
|
|
134
|
+
type: mapping.type,
|
|
135
|
+
title: found.title,
|
|
136
|
+
description: found.description,
|
|
137
|
+
price: {
|
|
138
|
+
formatted: offer.formattedPrice,
|
|
139
|
+
currencyCode: offer.currencyCode,
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
return success(input.id, products);
|
|
144
|
+
}
|
|
145
|
+
case 'commerce.purchase': {
|
|
146
|
+
const payload = input.payload;
|
|
147
|
+
const productId = isRecord(payload) ? payload.productId : undefined;
|
|
148
|
+
const mapping = typeof productId === 'string' ? byLogicalId.get(productId) : undefined;
|
|
149
|
+
if (mapping === undefined) {
|
|
150
|
+
return failure(input.id, 'PLAY_BILLING_PRODUCT_UNKNOWN');
|
|
151
|
+
}
|
|
152
|
+
let obfuscatedAccountId;
|
|
153
|
+
try {
|
|
154
|
+
obfuscatedAccountId = await options.getObfuscatedAccountId();
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
return failure(input.id, 'PLAY_BILLING_ACCOUNT_LOOKUP_FAILED');
|
|
158
|
+
}
|
|
159
|
+
if (!/^[A-Za-z0-9._-]{1,64}$/u.test(obfuscatedAccountId)) {
|
|
160
|
+
return failure(input.id, 'PLAY_BILLING_ACCOUNT_REQUIRED');
|
|
161
|
+
}
|
|
162
|
+
const outcome = await sdk.purchase({
|
|
163
|
+
productId: mapping.storeId,
|
|
164
|
+
obfuscatedAccountId,
|
|
165
|
+
...(mapping.offerToken === undefined ? {} : { offerToken: mapping.offerToken }),
|
|
166
|
+
});
|
|
167
|
+
return success(input.id, await convertOutcome(outcome, mapping.storeId));
|
|
168
|
+
}
|
|
169
|
+
case 'commerce.restore':
|
|
170
|
+
// Native ownership is not an authoritative entitlement. The game
|
|
171
|
+
// must submit getOwnedPurchases() results to its backend separately.
|
|
172
|
+
return success(input.id, { restoredEntitlements: [] });
|
|
173
|
+
case 'commerce.getEntitlements':
|
|
174
|
+
return success(input.id, []);
|
|
175
|
+
default:
|
|
176
|
+
return failure(input.id, 'PLAY_BILLING_METHOD_UNSUPPORTED');
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
if (error instanceof PlayPurchaseIntegrityError) {
|
|
181
|
+
return failure(input.id, 'PLAY_BILLING_EVIDENCE_INVALID');
|
|
182
|
+
}
|
|
183
|
+
const code = isRecord(error) ? error.code : undefined;
|
|
184
|
+
if (typeof code === 'string' && /^PLAY_BILLING_[A-Z_]+$/u.test(code)) {
|
|
185
|
+
if (input.method === 'commerce.purchase'
|
|
186
|
+
&& (code === 'PLAY_BILLING_TIMEOUT'
|
|
187
|
+
|| code === 'PLAY_BILLING_INTERRUPTED'
|
|
188
|
+
|| code === 'PLAY_BILLING_EMPTY_PURCHASE'
|
|
189
|
+
|| code === 'PLAY_BILLING_ALREADY_OWNED')) {
|
|
190
|
+
// The purchase may have completed without an observed callback.
|
|
191
|
+
// Requery owned purchases; never launch another flow blindly.
|
|
192
|
+
return success(input.id, { status: 'pending', entitlementIds: [] });
|
|
193
|
+
}
|
|
194
|
+
const retryable = code === 'PLAY_BILLING_DISCONNECTED'
|
|
195
|
+
|| code === 'PLAY_BILLING_SERVICE_UNAVAILABLE'
|
|
196
|
+
|| code === 'PLAY_BILLING_NETWORK_ERROR'
|
|
197
|
+
|| code === 'PLAY_BILLING_TRANSIENT_ERROR';
|
|
198
|
+
return failure(input.id, code, retryable);
|
|
199
|
+
}
|
|
200
|
+
return failure(input.id, 'PLAY_BILLING_UNAVAILABLE', true);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return {
|
|
204
|
+
id: 'google-play-billing',
|
|
205
|
+
features: ['nativeIap'],
|
|
206
|
+
methods: [
|
|
207
|
+
'commerce.getProducts',
|
|
208
|
+
'commerce.purchase',
|
|
209
|
+
'commerce.restore',
|
|
210
|
+
'commerce.getEntitlements',
|
|
211
|
+
],
|
|
212
|
+
bridge: { request },
|
|
213
|
+
async getAvailability() {
|
|
214
|
+
if (!isAndroid()) {
|
|
215
|
+
return { nativeIap: 'unsupported' };
|
|
216
|
+
}
|
|
217
|
+
try {
|
|
218
|
+
const response = await sdk.getProducts({ productIds: [...byStoreId.keys()] });
|
|
219
|
+
const hasPurchasableProduct = response.products.some((found) => {
|
|
220
|
+
const mapping = byStoreId.get(found.productId);
|
|
221
|
+
return mapping !== undefined && selectOffer(mapping, found) !== undefined;
|
|
222
|
+
});
|
|
223
|
+
return { nativeIap: hasPurchasableProduct ? 'available' : 'configuration-required' };
|
|
224
|
+
}
|
|
225
|
+
catch (error) {
|
|
226
|
+
const code = isRecord(error) ? error.code : undefined;
|
|
227
|
+
const configurationError = code === 'PLAY_BILLING_CONFIGURATION_ERROR'
|
|
228
|
+
|| code === 'PLAY_BILLING_PRODUCT_UNAVAILABLE'
|
|
229
|
+
|| code === 'PLAY_BILLING_OFFER_REQUIRED'
|
|
230
|
+
|| code === 'PLAY_BILLING_INVALID_PRODUCTS'
|
|
231
|
+
|| code === 'PLAY_BILLING_INVALID_PRODUCT';
|
|
232
|
+
return { nativeIap: configurationError
|
|
233
|
+
? 'configuration-required'
|
|
234
|
+
: 'temporarily-unavailable' };
|
|
235
|
+
}
|
|
236
|
+
},
|
|
237
|
+
getOwnedPurchases,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
async function convertOutcome(outcome, expectedStoreId) {
|
|
241
|
+
if (outcome.status === 'cancelled') {
|
|
242
|
+
return { status: 'cancelled', entitlementIds: [] };
|
|
243
|
+
}
|
|
244
|
+
const purchase = outcome.purchase;
|
|
245
|
+
if (purchase === undefined
|
|
246
|
+
|| purchase.productIds.length !== 1
|
|
247
|
+
|| purchase.productIds[0] !== expectedStoreId) {
|
|
248
|
+
throw new PlayPurchaseIntegrityError('Play purchase did not match the requested product.');
|
|
249
|
+
}
|
|
250
|
+
if ((outcome.status === 'purchased' && purchase.state !== 'purchased')
|
|
251
|
+
|| (outcome.status === 'pending' && purchase.state !== 'pending')) {
|
|
252
|
+
throw new PlayPurchaseIntegrityError('Play purchase state is inconsistent.');
|
|
253
|
+
}
|
|
254
|
+
return convertPurchase(purchase);
|
|
255
|
+
}
|
|
256
|
+
async function convertPurchase(purchase) {
|
|
257
|
+
if (purchase.state !== 'pending' && purchase.state !== 'purchased') {
|
|
258
|
+
throw new PlayPurchaseIntegrityError('Play purchase state is invalid.');
|
|
259
|
+
}
|
|
260
|
+
if (!isValidPurchaseToken(purchase.purchaseToken)) {
|
|
261
|
+
throw new PlayPurchaseIntegrityError('Play purchase token is invalid.');
|
|
262
|
+
}
|
|
263
|
+
const evidence = {
|
|
264
|
+
schema: googlePlayProductPurchaseEvidenceSchema,
|
|
265
|
+
payload: { purchaseToken: purchase.purchaseToken },
|
|
266
|
+
};
|
|
267
|
+
if (purchase.state === 'pending') {
|
|
268
|
+
return { status: 'pending', entitlementIds: [], evidence };
|
|
269
|
+
}
|
|
270
|
+
const transactionId = purchase.orderId?.trim()
|
|
271
|
+
|| await createGooglePlayTokenTransactionId(purchase.purchaseToken);
|
|
272
|
+
return {
|
|
273
|
+
status: 'completed',
|
|
274
|
+
transactionId,
|
|
275
|
+
entitlementIds: [],
|
|
276
|
+
evidence,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
/** Must remain byte-for-byte compatible with the server's token transaction ID. */
|
|
280
|
+
export async function createGooglePlayTokenTransactionId(purchaseToken) {
|
|
281
|
+
if (!isValidPurchaseToken(purchaseToken)) {
|
|
282
|
+
throw new TypeError('Google Play purchase token is invalid.');
|
|
283
|
+
}
|
|
284
|
+
const subtle = globalThis.crypto?.subtle;
|
|
285
|
+
if (subtle === undefined) {
|
|
286
|
+
throw new Error('Web Crypto is required to hash Google Play purchase tokens.');
|
|
287
|
+
}
|
|
288
|
+
const digest = new Uint8Array(await subtle.digest('SHA-256', new TextEncoder().encode(purchaseToken)));
|
|
289
|
+
return `play-token-sha256:${[...digest].map((value) => value.toString(16).padStart(2, '0')).join('')}`;
|
|
290
|
+
}
|
|
291
|
+
function success(id, data) {
|
|
292
|
+
return { id, ok: true, data };
|
|
293
|
+
}
|
|
294
|
+
function failure(id, code, retryable = false) {
|
|
295
|
+
return { id, ok: false, error: { code, message: code, retryable } };
|
|
296
|
+
}
|
|
297
|
+
function isRecord(value) {
|
|
298
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
299
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mpgd/capacitor-play-billing",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Opt-in Android Google Play Billing purchase collector for mpgd Capacitor games.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/imjlk/mpgd-kit.git",
|
|
9
|
+
"directory": "native-plugins/capacitor-play-billing"
|
|
10
|
+
},
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/imjlk/mpgd-kit/issues"
|
|
13
|
+
},
|
|
14
|
+
"homepage": "https://github.com/imjlk/mpgd-kit#readme",
|
|
15
|
+
"type": "module",
|
|
16
|
+
"sideEffects": false,
|
|
17
|
+
"exports": {
|
|
18
|
+
".": {
|
|
19
|
+
"types": "./dist/index.d.ts",
|
|
20
|
+
"default": "./dist/index.js"
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"capacitor": {
|
|
24
|
+
"android": {
|
|
25
|
+
"src": "android"
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"files": ["dist", "android/build.gradle", "android/src"],
|
|
29
|
+
"scripts": {
|
|
30
|
+
"check": "ttsc --noEmit",
|
|
31
|
+
"lint": "ttsc --noEmit",
|
|
32
|
+
"test": "cd ../.. && node tools/run-ttsx.mjs native-plugins/capacitor-play-billing/src/provider.test.ts"
|
|
33
|
+
},
|
|
34
|
+
"peerDependencies": {
|
|
35
|
+
"@capacitor/core": "^8.5.1",
|
|
36
|
+
"@mpgd/bridge": "^0.10.0",
|
|
37
|
+
"@mpgd/platform": "^0.13.0 || ^0.14.0"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@capacitor/core": "8.5.2",
|
|
41
|
+
"@mpgd/bridge": "workspace:*",
|
|
42
|
+
"@mpgd/game-services": "workspace:*",
|
|
43
|
+
"@mpgd/platform": "workspace:*",
|
|
44
|
+
"ttsc": "0.30.4",
|
|
45
|
+
"typescript": "7.0.2"
|
|
46
|
+
},
|
|
47
|
+
"main": "./dist/index.js",
|
|
48
|
+
"types": "./dist/index.d.ts",
|
|
49
|
+
"publishConfig": {
|
|
50
|
+
"access": "public"
|
|
51
|
+
}
|
|
52
|
+
}
|