@mpgd/capacitor-play-billing 0.3.9 → 0.4.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.
@@ -1,485 +0,0 @@
1
- package dev.mpgd.capacitor.playbilling;
2
-
3
- import android.os.Handler;
4
- import android.os.Looper;
5
- import com.android.billingclient.api.AccountIdentifiers;
6
- import com.android.billingclient.api.BillingClient;
7
- import com.android.billingclient.api.BillingClientStateListener;
8
- import com.android.billingclient.api.BillingFlowParams;
9
- import com.android.billingclient.api.BillingResult;
10
- import com.android.billingclient.api.PendingPurchasesParams;
11
- import com.android.billingclient.api.ProductDetails;
12
- import com.android.billingclient.api.Purchase;
13
- import com.android.billingclient.api.QueryProductDetailsParams;
14
- import com.android.billingclient.api.QueryPurchasesParams;
15
- import com.getcapacitor.JSArray;
16
- import com.getcapacitor.JSObject;
17
- import com.getcapacitor.Plugin;
18
- import com.getcapacitor.PluginCall;
19
- import com.getcapacitor.PluginMethod;
20
- import com.getcapacitor.annotation.CapacitorPlugin;
21
- import java.util.ArrayList;
22
- import java.util.Collections;
23
- import java.util.List;
24
- import org.json.JSONException;
25
-
26
- /**
27
- * Purchase collection only. Finalization is server-owned after the mpgd ledger grant: this
28
- * plugin never calls acknowledgePurchase or consumeAsync, and the encoded isAcknowledged flag
29
- * lets the TypeScript provider list unfinished purchases for the backend finalizer.
30
- */
31
- @CapacitorPlugin(name = "CapacitorPlayBilling")
32
- public class CapacitorPlayBillingPlugin extends Plugin {
33
- private BillingClient billingClient;
34
- private final List<PendingOperation> afterConnection = new ArrayList<>();
35
- private final Handler mainHandler = new Handler(Looper.getMainLooper());
36
- private boolean connecting;
37
- private PluginCall activePurchase;
38
- private String activeProductId;
39
- private Runnable purchaseTimeout;
40
- /**
41
- * Product of a purchase call that timed out while the Play UI was still up. A later
42
- * terminal callback (cancel or non-OK result) is delivered for it as a purchaseUpdated
43
- * event, so the JavaScript attempt the timeout left open can close.
44
- */
45
- private String timedOutProductId;
46
-
47
- @Override
48
- public void load() {
49
- billingClient = BillingClient.newBuilder(getContext())
50
- .setListener((result, purchases) ->
51
- mainHandler.post(() -> onPurchasesUpdated(result, purchases)))
52
- .enablePendingPurchases(PendingPurchasesParams.newBuilder().enableOneTimeProducts().build())
53
- .enableAutoServiceReconnection()
54
- .build();
55
- }
56
-
57
- @Override
58
- protected void handleOnDestroy() {
59
- if (activePurchase != null) {
60
- activePurchase.reject("Purchase flow was interrupted.", "PLAY_BILLING_INTERRUPTED");
61
- clearActivePurchase(activePurchase);
62
- }
63
- for (PendingOperation operation : afterConnection) {
64
- operation.call.reject("Billing connection was interrupted.", "PLAY_BILLING_INTERRUPTED");
65
- }
66
- afterConnection.clear();
67
- if (billingClient != null) {
68
- billingClient.endConnection();
69
- }
70
- super.handleOnDestroy();
71
- }
72
-
73
- @PluginMethod
74
- public void getProducts(PluginCall call) {
75
- mainHandler.post(() -> getProductsOnMain(call));
76
- }
77
-
78
- private void getProductsOnMain(PluginCall call) {
79
- JSArray productIds = call.getArray("productIds");
80
- if (productIds == null || productIds.length() == 0 || productIds.length() > 100) {
81
- call.reject("Provide 1-100 product IDs.", "PLAY_BILLING_INVALID_PRODUCTS");
82
- return;
83
- }
84
- List<QueryProductDetailsParams.Product> products = new ArrayList<>();
85
- try {
86
- for (int index = 0; index < productIds.length(); index++) {
87
- String productId = productIds.getString(index);
88
- if (productId == null || productId.trim().isEmpty()) {
89
- call.reject("Product ID is invalid.", "PLAY_BILLING_INVALID_PRODUCT");
90
- return;
91
- }
92
- products.add(QueryProductDetailsParams.Product.newBuilder()
93
- .setProductId(productId)
94
- .setProductType(BillingClient.ProductType.INAPP)
95
- .build());
96
- }
97
- } catch (JSONException error) {
98
- call.reject("Product IDs are invalid.", "PLAY_BILLING_INVALID_PRODUCTS");
99
- return;
100
- }
101
- connected(call, () -> billingClient.queryProductDetailsAsync(
102
- QueryProductDetailsParams.newBuilder().setProductList(products).build(),
103
- (result, detailsResult) -> {
104
- if (!isOk(result)) {
105
- rejectBilling(call, result);
106
- return;
107
- }
108
- JSArray found = new JSArray();
109
- for (ProductDetails details : detailsResult.getProductDetailsList()) {
110
- List<ProductDetails.OneTimePurchaseOfferDetails> offers =
111
- details.getOneTimePurchaseOfferDetailsList();
112
- if (offers == null || offers.isEmpty()) {
113
- continue;
114
- }
115
- JSArray eligibleOffers = new JSArray();
116
- for (ProductDetails.OneTimePurchaseOfferDetails offer : offers) {
117
- if (offer.getRentalDetails() == null && offer.getPreorderDetails() == null) {
118
- eligibleOffers.put(new JSObject()
119
- .put("offerToken", offer.getOfferToken())
120
- .put("formattedPrice", offer.getFormattedPrice())
121
- .put("currencyCode", offer.getPriceCurrencyCode())
122
- .put("priceAmountMicros", offer.getPriceAmountMicros()));
123
- }
124
- }
125
- if (eligibleOffers.length() == 0) {
126
- continue;
127
- }
128
- found.put(new JSObject()
129
- .put("productId", details.getProductId())
130
- .put("title", details.getTitle())
131
- .put("description", details.getDescription())
132
- .put("offers", eligibleOffers));
133
- }
134
- call.resolve(new JSObject().put("products", found));
135
- }
136
- ));
137
- }
138
-
139
- @PluginMethod
140
- public void purchase(PluginCall call) {
141
- mainHandler.post(() -> purchaseOnMain(call));
142
- }
143
-
144
- private void purchaseOnMain(PluginCall call) {
145
- String productId = call.getString("productId");
146
- String accountId = call.getString("obfuscatedAccountId");
147
- String selectedOfferToken = call.getString("offerToken");
148
- if (productId == null || productId.trim().isEmpty() || accountId == null
149
- || accountId.trim().isEmpty() || accountId.length() > 64) {
150
- call.reject("Product and account identifiers are required.", "PLAY_BILLING_INVALID_REQUEST");
151
- return;
152
- }
153
- if (activePurchase != null) {
154
- call.reject("Another purchase is already in progress.", "PLAY_BILLING_BUSY");
155
- return;
156
- }
157
- activePurchase = call;
158
- activeProductId = productId;
159
- timedOutProductId = null;
160
- purchaseTimeout = () -> {
161
- if (activePurchase == call) {
162
- timedOutProductId = activeProductId;
163
- clearActivePurchase(call);
164
- call.reject("Purchase confirmation timed out; requery owned purchases.",
165
- "PLAY_BILLING_TIMEOUT");
166
- }
167
- };
168
- mainHandler.postDelayed(purchaseTimeout, 180_000);
169
- connected(call, () -> {
170
- if (activePurchase != call) {
171
- return;
172
- }
173
- billingClient.queryProductDetailsAsync(
174
- QueryProductDetailsParams.newBuilder().setProductList(Collections.singletonList(
175
- QueryProductDetailsParams.Product.newBuilder()
176
- .setProductId(productId)
177
- .setProductType(BillingClient.ProductType.INAPP)
178
- .build()
179
- )).build(),
180
- (result, detailsResult) -> {
181
- if (activePurchase != call) {
182
- return;
183
- }
184
- if (!isOk(result)) {
185
- clearActivePurchase(call);
186
- rejectBilling(call, result);
187
- return;
188
- }
189
- if (detailsResult.getProductDetailsList().size() != 1) {
190
- clearActivePurchase(call);
191
- call.reject("Product is unavailable.", "PLAY_BILLING_PRODUCT_UNAVAILABLE");
192
- return;
193
- }
194
- ProductDetails details = detailsResult.getProductDetailsList().get(0);
195
- List<ProductDetails.OneTimePurchaseOfferDetails> offers =
196
- details.getOneTimePurchaseOfferDetailsList();
197
- ProductDetails.OneTimePurchaseOfferDetails selected = selectOffer(
198
- offers, selectedOfferToken);
199
- if (selected == null) {
200
- clearActivePurchase(call);
201
- call.reject("Select an eligible one-time offer.", "PLAY_BILLING_OFFER_REQUIRED");
202
- return;
203
- }
204
- BillingFlowParams.ProductDetailsParams productParams =
205
- BillingFlowParams.ProductDetailsParams.newBuilder()
206
- .setProductDetails(details)
207
- .setOfferToken(selected.getOfferToken())
208
- .build();
209
- BillingFlowParams flow = BillingFlowParams.newBuilder()
210
- .setProductDetailsParamsList(Collections.singletonList(productParams))
211
- .setObfuscatedAccountId(accountId)
212
- .build();
213
- if (getActivity() == null) {
214
- clearActivePurchase(call);
215
- call.reject("Billing requires a foreground activity.",
216
- "PLAY_BILLING_ACTIVITY_REQUIRED");
217
- return;
218
- }
219
- BillingResult launched = billingClient.launchBillingFlow(getActivity(), flow);
220
- if (!isOk(launched)) {
221
- clearActivePurchase(call);
222
- if (launched.getResponseCode() == BillingClient.BillingResponseCode.USER_CANCELED) {
223
- call.resolve(new JSObject().put("status", "cancelled"));
224
- } else {
225
- rejectBilling(call, launched);
226
- }
227
- }
228
- }
229
- );
230
- });
231
- }
232
-
233
- @PluginMethod
234
- public void getPurchases(PluginCall call) {
235
- mainHandler.post(() -> getPurchasesOnMain(call));
236
- }
237
-
238
- private void getPurchasesOnMain(PluginCall call) {
239
- connected(call, () -> billingClient.queryPurchasesAsync(
240
- QueryPurchasesParams.newBuilder().setProductType(BillingClient.ProductType.INAPP).build(),
241
- (result, purchases) -> {
242
- if (!isOk(result)) {
243
- rejectBilling(call, result);
244
- return;
245
- }
246
- JSArray found = new JSArray();
247
- for (Purchase purchase : purchases) {
248
- JSObject encoded = encodePurchase(purchase);
249
- if (encoded != null) {
250
- found.put(encoded);
251
- }
252
- }
253
- call.resolve(new JSObject().put("purchases", found));
254
- }
255
- ));
256
- }
257
-
258
- private void onPurchasesUpdated(BillingResult result, List<Purchase> purchases) {
259
- PluginCall pending = activePurchase;
260
- String expectedProductId = activeProductId;
261
- if (result.getResponseCode() == BillingClient.BillingResponseCode.USER_CANCELED) {
262
- if (pending != null) {
263
- clearActivePurchase(pending);
264
- pending.resolve(new JSObject().put("status", "cancelled"));
265
- } else {
266
- notifyLateTerminalResult("cancelled", null);
267
- }
268
- return;
269
- }
270
- if (!isOk(result)) {
271
- if (pending != null) {
272
- clearActivePurchase(pending);
273
- rejectBilling(pending, result);
274
- } else {
275
- notifyLateTerminalResult("failed", billingErrorCode(result));
276
- }
277
- return;
278
- }
279
- if (purchases == null || purchases.isEmpty()) {
280
- if (pending != null) {
281
- clearActivePurchase(pending);
282
- pending.reject("Purchase callback was empty; requery owned purchases.",
283
- "PLAY_BILLING_EMPTY_PURCHASE");
284
- }
285
- return;
286
- }
287
- for (Purchase purchase : purchases) {
288
- JSObject encoded = encodePurchase(purchase);
289
- if (encoded == null) {
290
- continue;
291
- }
292
- JSObject outcome = new JSObject()
293
- .put("status", encoded.optString("purchaseState"))
294
- .put("purchase", encoded);
295
- // Retained until a listener consumes it: a purchase that completes after the
296
- // purchase call timed out, or before the web view attached its listener, must
297
- // still reach the provider's purchaseUpdated subscription.
298
- notifyListeners("purchaseUpdated", outcome, true);
299
- if (timedOutProductId != null && purchase.getProducts().contains(timedOutProductId)) {
300
- timedOutProductId = null;
301
- }
302
- if (pending != null && purchase.getProducts().contains(expectedProductId)) {
303
- clearActivePurchase(pending);
304
- pending.resolve(outcome);
305
- pending = null;
306
- }
307
- }
308
- }
309
-
310
- /**
311
- * A cancel or non-OK result that arrives after the purchase call already timed out has no
312
- * call to answer; without this event the JavaScript attempt would stay open forever and
313
- * block another purchase of the product. Only the timed-out product is correlated.
314
- */
315
- private void notifyLateTerminalResult(String status, String code) {
316
- String productId = timedOutProductId;
317
- if (productId == null) {
318
- return;
319
- }
320
- timedOutProductId = null;
321
- JSObject outcome = new JSObject().put("status", status).put("productId", productId);
322
- if (code != null) {
323
- outcome.put("code", code);
324
- }
325
- notifyListeners("purchaseUpdated", outcome, true);
326
- }
327
-
328
- private JSObject encodePurchase(Purchase purchase) {
329
- int state = purchase.getPurchaseState();
330
- if (state != Purchase.PurchaseState.PURCHASED
331
- && state != Purchase.PurchaseState.PENDING) {
332
- return null;
333
- }
334
- String token = purchase.getPurchaseToken();
335
- if (token == null || token.trim().isEmpty() || purchase.getProducts().isEmpty()) {
336
- return null;
337
- }
338
- JSArray products = new JSArray();
339
- for (String productId : purchase.getProducts()) {
340
- products.put(productId);
341
- }
342
- String purchaseState = state == Purchase.PurchaseState.PURCHASED ? "purchased" : "pending";
343
- // Billing 9.1.0 Purchase getters under their own names.
344
- JSObject encoded = new JSObject()
345
- .put("products", products)
346
- .put("purchaseToken", token)
347
- .put("purchaseState", purchaseState)
348
- .put("purchaseTime", purchase.getPurchaseTime())
349
- .put("quantity", purchase.getQuantity())
350
- .put("isAcknowledged", purchase.isAcknowledged());
351
- if (purchase.getOrderId() != null) {
352
- encoded.put("orderId", purchase.getOrderId());
353
- }
354
- AccountIdentifiers accountIdentifiers = purchase.getAccountIdentifiers();
355
- if (accountIdentifiers != null && accountIdentifiers.getObfuscatedAccountId() != null) {
356
- encoded.put("obfuscatedAccountId", accountIdentifiers.getObfuscatedAccountId());
357
- }
358
- return encoded;
359
- }
360
-
361
- private ProductDetails.OneTimePurchaseOfferDetails selectOffer(
362
- List<ProductDetails.OneTimePurchaseOfferDetails> offers,
363
- String offerToken
364
- ) {
365
- if (offers == null) {
366
- return null;
367
- }
368
- ProductDetails.OneTimePurchaseOfferDetails only = null;
369
- int eligibleCount = 0;
370
- for (ProductDetails.OneTimePurchaseOfferDetails offer : offers) {
371
- if (offer.getRentalDetails() != null || offer.getPreorderDetails() != null) {
372
- continue;
373
- }
374
- if (offerToken != null && offerToken.equals(offer.getOfferToken())) {
375
- return offer;
376
- }
377
- eligibleCount++;
378
- only = offer;
379
- }
380
- return offerToken == null && eligibleCount == 1 ? only : null;
381
- }
382
-
383
- private void connected(PluginCall call, Runnable task) {
384
- if (billingClient.isReady()) {
385
- task.run();
386
- return;
387
- }
388
- afterConnection.add(new PendingOperation(call, task));
389
- if (connecting) {
390
- return;
391
- }
392
- connecting = true;
393
- billingClient.startConnection(new BillingClientStateListener() {
394
- @Override
395
- public void onBillingSetupFinished(BillingResult result) {
396
- connecting = false;
397
- List<PendingOperation> queued = new ArrayList<>(afterConnection);
398
- afterConnection.clear();
399
- if (!isOk(result)) {
400
- for (PendingOperation operation : queued) {
401
- clearActivePurchase(operation.call);
402
- rejectBilling(operation.call, result);
403
- }
404
- return;
405
- }
406
- for (PendingOperation operation : queued) {
407
- operation.task.run();
408
- }
409
- }
410
-
411
- @Override
412
- public void onBillingServiceDisconnected() {
413
- connecting = false;
414
- List<PendingOperation> queued = new ArrayList<>(afterConnection);
415
- afterConnection.clear();
416
- for (PendingOperation operation : queued) {
417
- clearActivePurchase(operation.call);
418
- operation.call.reject("Billing service disconnected.", "PLAY_BILLING_DISCONNECTED");
419
- }
420
- }
421
- });
422
- }
423
-
424
- private boolean isOk(BillingResult result) {
425
- return result.getResponseCode() == BillingClient.BillingResponseCode.OK;
426
- }
427
-
428
- private void rejectBilling(PluginCall call, BillingResult result) {
429
- call.reject("Google Play Billing is unavailable (" + result.getResponseCode() + ").",
430
- billingErrorCode(result));
431
- }
432
-
433
- private String billingErrorCode(BillingResult result) {
434
- int responseCode = result.getResponseCode();
435
- String code;
436
- switch (responseCode) {
437
- case BillingClient.BillingResponseCode.SERVICE_DISCONNECTED:
438
- code = "PLAY_BILLING_DISCONNECTED";
439
- break;
440
- case BillingClient.BillingResponseCode.SERVICE_UNAVAILABLE:
441
- case BillingClient.BillingResponseCode.SERVICE_TIMEOUT:
442
- code = "PLAY_BILLING_SERVICE_UNAVAILABLE";
443
- break;
444
- case BillingClient.BillingResponseCode.NETWORK_ERROR:
445
- code = "PLAY_BILLING_NETWORK_ERROR";
446
- break;
447
- case BillingClient.BillingResponseCode.ERROR:
448
- code = "PLAY_BILLING_TRANSIENT_ERROR";
449
- break;
450
- case BillingClient.BillingResponseCode.ITEM_ALREADY_OWNED:
451
- code = "PLAY_BILLING_ALREADY_OWNED";
452
- break;
453
- case BillingClient.BillingResponseCode.ITEM_UNAVAILABLE:
454
- code = "PLAY_BILLING_PRODUCT_UNAVAILABLE";
455
- break;
456
- case BillingClient.BillingResponseCode.DEVELOPER_ERROR:
457
- code = "PLAY_BILLING_CONFIGURATION_ERROR";
458
- break;
459
- default:
460
- code = "PLAY_BILLING_ERROR";
461
- }
462
- return code;
463
- }
464
-
465
- private void clearActivePurchase(PluginCall call) {
466
- if (activePurchase == call) {
467
- activePurchase = null;
468
- activeProductId = null;
469
- if (purchaseTimeout != null) {
470
- mainHandler.removeCallbacks(purchaseTimeout);
471
- purchaseTimeout = null;
472
- }
473
- }
474
- }
475
-
476
- private static final class PendingOperation {
477
- final PluginCall call;
478
- final Runnable task;
479
-
480
- PendingOperation(PluginCall call, Runnable task) {
481
- this.call = call;
482
- this.task = task;
483
- }
484
- }
485
- }