capacitor-plugin-cdv-purchase 13.14.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.
@@ -0,0 +1,1206 @@
1
+ /**
2
+ * The Capacitor Purchase Plugin for Google Play.
3
+ *
4
+ * Wraps Google Play BillingClient 8.3.0 and communicates with the TypeScript
5
+ * CapacitorBridge via Capacitor's plugin API.
6
+ *
7
+ * JSON formats for purchases and products match the existing Cordova plugin
8
+ * format so the same TypeScript adapter code can parse them.
9
+ *
10
+ * @author Jean-Christophe Hoelt - Fovea.cc
11
+ */
12
+
13
+ package cc.fovea.iap;
14
+
15
+ import android.app.Activity;
16
+ import android.content.Intent;
17
+ import android.net.Uri;
18
+ import android.util.Log;
19
+
20
+ import com.android.billingclient.api.AcknowledgePurchaseParams;
21
+ import com.android.billingclient.api.AcknowledgePurchaseResponseListener;
22
+ import com.android.billingclient.api.BillingClient;
23
+ import com.android.billingclient.api.BillingClient.BillingResponseCode;
24
+ import com.android.billingclient.api.BillingClient.FeatureType;
25
+ import com.android.billingclient.api.BillingClient.ProductType;
26
+ import com.android.billingclient.api.BillingClientStateListener;
27
+ import com.android.billingclient.api.BillingFlowParams;
28
+ import com.android.billingclient.api.BillingFlowParams.ProductDetailsParams;
29
+ import com.android.billingclient.api.BillingResult;
30
+ import com.android.billingclient.api.ConsumeParams;
31
+ import com.android.billingclient.api.ConsumeResponseListener;
32
+ import com.android.billingclient.api.PendingPurchasesParams;
33
+ import com.android.billingclient.api.ProductDetails;
34
+ import com.android.billingclient.api.ProductDetails.OneTimePurchaseOfferDetails;
35
+ import com.android.billingclient.api.ProductDetails.PricingPhase;
36
+ import com.android.billingclient.api.ProductDetails.SubscriptionOfferDetails;
37
+ import com.android.billingclient.api.ProductDetailsResponseListener;
38
+ import com.android.billingclient.api.Purchase;
39
+ import com.android.billingclient.api.PurchasesResponseListener;
40
+ import com.android.billingclient.api.PurchasesUpdatedListener;
41
+ import com.android.billingclient.api.QueryProductDetailsParams;
42
+ import com.android.billingclient.api.QueryProductDetailsParams.Product;
43
+ import com.android.billingclient.api.QueryProductDetailsResult;
44
+ import com.android.billingclient.api.QueryPurchasesParams;
45
+
46
+ import com.getcapacitor.JSObject;
47
+ import com.getcapacitor.Plugin;
48
+ import com.getcapacitor.PluginCall;
49
+ import com.getcapacitor.PluginMethod;
50
+ import com.getcapacitor.annotation.CapacitorPlugin;
51
+
52
+ import org.json.JSONArray;
53
+ import org.json.JSONException;
54
+ import org.json.JSONObject;
55
+
56
+ import java.util.ArrayList;
57
+ import java.util.Calendar;
58
+ import java.util.HashMap;
59
+ import java.util.HashSet;
60
+ import java.util.List;
61
+ import java.util.Set;
62
+
63
+ @CapacitorPlugin(name = "PurchasePlugin")
64
+ public class PurchasePlugin extends Plugin implements
65
+ PurchasesUpdatedListener {
66
+
67
+ private static final String TAG = "CdvPurchase";
68
+
69
+ // -------------------------------------------------------------------------
70
+ // State
71
+ // -------------------------------------------------------------------------
72
+
73
+ /** A reference to BillingClient. */
74
+ private BillingClient mBillingClient;
75
+
76
+ /** List of registered IN_APP product identifiers. */
77
+ private final List<String> mInAppProductIds = new ArrayList<>();
78
+
79
+ /** List of registered SUBS product identifiers. */
80
+ private final List<String> mSubsProductIds = new ArrayList<>();
81
+
82
+ /** List of purchases reported by the Billing library. */
83
+ private final List<Purchase> mPurchases = new ArrayList<>();
84
+
85
+ /** True if billing service is connected now. */
86
+ private boolean mIsServiceConnected;
87
+
88
+ /** Value for mBillingClientResult until BillingClient is connected. */
89
+ private static final int BILLING_CLIENT_NOT_CONNECTED = -1;
90
+
91
+ /** Last response from the billing client. */
92
+ private BillingResult mBillingClientResult = BillingResult.newBuilder()
93
+ .setResponseCode(BILLING_CLIENT_NOT_CONNECTED).build();
94
+
95
+ /** Product details loaded from GooglePlay, indexed by product identifier. */
96
+ private final HashMap<String, ProductDetails> mProductDetails = new HashMap<>();
97
+
98
+ /** List of purchase tokens being consumed. */
99
+ private final Set<String> mTokensToBeConsumed = new HashSet<>();
100
+
101
+ /** Purchases indexed by token, for lookup when consuming. (CRITICAL 5) */
102
+ private final HashMap<String, Purchase> mPurchasesByToken = new HashMap<>();
103
+
104
+ /** Pending call for buy/subscribe (resolved in onPurchasesUpdated). */
105
+ private PluginCall mPendingBuyCall;
106
+
107
+ /** Pending call for consumePurchase (async callback, CRITICAL 6). */
108
+ private PluginCall mPendingConsumeCall;
109
+
110
+ /** Pending call for acknowledgePurchase (async callback, CRITICAL 6). */
111
+ private PluginCall mPendingAcknowledgeCall;
112
+
113
+ /** Last time onStart refreshed purchases. */
114
+ private long mLastQueryOnStart = 0;
115
+
116
+ /** Intermediate results for parallel purchase queries. */
117
+ private BillingResult mInAppResult;
118
+ private BillingResult mSubsResult;
119
+ private int nProductDetailsQuerySuccessful = 0;
120
+
121
+ // -------------------------------------------------------------------------
122
+ // Internal callback interface for product details queries
123
+ // -------------------------------------------------------------------------
124
+
125
+ private interface InternalProductDetailsResponseListener {
126
+ void onProductDetailsResponse(BillingResult result,
127
+ List<ProductDetails> productDetailsList);
128
+ }
129
+
130
+ // -------------------------------------------------------------------------
131
+ // Helpers
132
+ // -------------------------------------------------------------------------
133
+
134
+ private BillingResult getLastResult() {
135
+ return mBillingClientResult;
136
+ }
137
+
138
+ private int getLastResponseCode() {
139
+ return mBillingClientResult.getResponseCode();
140
+ }
141
+
142
+ private void resetLastResult(final int responseCode) {
143
+ mBillingClientResult = BillingResult.newBuilder()
144
+ .setResponseCode(responseCode)
145
+ .setDebugMessage("")
146
+ .build();
147
+ }
148
+
149
+ private void addInAppProductIds(final List<String> list) {
150
+ for (int i = 0; i < list.size(); ++i) {
151
+ if (!mInAppProductIds.contains(list.get(i))) {
152
+ mInAppProductIds.add(list.get(i));
153
+ }
154
+ }
155
+ }
156
+
157
+ private void addSubsProductIds(final List<String> list) {
158
+ for (int i = 0; i < list.size(); ++i) {
159
+ if (!mSubsProductIds.contains(list.get(i))) {
160
+ mSubsProductIds.add(list.get(i));
161
+ }
162
+ }
163
+ }
164
+
165
+ /** Store a purchase in the lookup maps. */
166
+ private void storePurchase(Purchase p) {
167
+ mPurchases.add(0, p);
168
+ mPurchasesByToken.put(p.getPurchaseToken(), p);
169
+ }
170
+
171
+ private Purchase findPurchaseByToken(final String purchaseToken) {
172
+ Purchase p = mPurchasesByToken.get(purchaseToken);
173
+ if (p != null) return p;
174
+ // Fallback: linear scan
175
+ for (Purchase purchase : mPurchases) {
176
+ if (purchase.getPurchaseToken().equals(purchaseToken))
177
+ return purchase;
178
+ }
179
+ return null;
180
+ }
181
+
182
+ // -------------------------------------------------------------------------
183
+ // JSON conversion — MUST match Cordova plugin format
184
+ // -------------------------------------------------------------------------
185
+
186
+ /**
187
+ * Convert a Purchase to JSON.
188
+ * CRITICAL 1: starts from getOriginalJson() and overlays fields.
189
+ */
190
+ private JSONObject purchaseToJson(final Purchase p) throws JSONException {
191
+ return new JSONObject(p.getOriginalJson())
192
+ .put("productIds", new JSONArray(p.getProducts()))
193
+ .put("orderId", p.getOrderId())
194
+ .put("getPurchaseState", p.getPurchaseState())
195
+ .put("developerPayload", p.getDeveloperPayload())
196
+ .put("acknowledged", p.isAcknowledged())
197
+ .put("autoRenewing", p.isAutoRenewing())
198
+ .put("accountId", p.getAccountIdentifiers() != null
199
+ ? p.getAccountIdentifiers().getObfuscatedAccountId() : null)
200
+ .put("profileId", p.getAccountIdentifiers() != null
201
+ ? p.getAccountIdentifiers().getObfuscatedProfileId() : null)
202
+ .put("signature", p.getSignature())
203
+ .put("receipt", p.getOriginalJson().toString())
204
+ .put("quantity", p.getQuantity());
205
+ }
206
+
207
+ /** Convert a list of purchases to a JSONArray. */
208
+ private JSONArray purchasesToJson(final List<Purchase> purchaseList)
209
+ throws JSONException {
210
+ JSONArray arr = new JSONArray();
211
+ for (Purchase p : purchaseList) {
212
+ arr.put(purchaseToJson(p));
213
+ }
214
+ return arr;
215
+ }
216
+
217
+ /**
218
+ * Convert ProductDetails to JSON.
219
+ * CRITICAL 3: handles BillingClient 8.x multi-offer INAPP format.
220
+ * CRITICAL 4: recurrence_mode is a string enum.
221
+ */
222
+ private JSONObject productDetailsToJson(ProductDetails product)
223
+ throws JSONException {
224
+ JSONObject ret = new JSONObject()
225
+ .put("productId", product.getProductId())
226
+ .put("title", product.getTitle())
227
+ .put("name", product.getName())
228
+ .put("description", product.getDescription());
229
+
230
+ if (product.getProductType().equals(ProductType.INAPP)) {
231
+ // Check for multiple offers (new in Billing Library 8.0.0)
232
+ List<OneTimePurchaseOfferDetails> offerList =
233
+ product.getOneTimePurchaseOfferDetailsList();
234
+
235
+ if (offerList != null && offerList.size() > 1) {
236
+ // Multiple offers — v12.0 format with offers array
237
+ ret.put("product_format", "v12.0")
238
+ .put("product_type", "inapp");
239
+ JSONArray offers = new JSONArray();
240
+ for (OneTimePurchaseOfferDetails offer : offerList) {
241
+ JSONObject offerJson = new JSONObject()
242
+ .put("offer_id", offer.getOfferId())
243
+ .put("offer_token", offer.getOfferToken())
244
+ .put("formatted_price", offer.getFormattedPrice())
245
+ .put("price_amount_micros", offer.getPriceAmountMicros())
246
+ .put("price_currency_code", offer.getPriceCurrencyCode());
247
+ offers.put(offerJson);
248
+ }
249
+ ret.put("offers", offers);
250
+ } else {
251
+ // Single offer — legacy v11.0 format
252
+ final OneTimePurchaseOfferDetails details =
253
+ product.getOneTimePurchaseOfferDetails();
254
+ ret.put("product_type", "inapp")
255
+ .put("product_format", "v11.0")
256
+ .put("formatted_price", details.getFormattedPrice())
257
+ .put("price_amount_micros", details.getPriceAmountMicros())
258
+ .put("price_currency_code", details.getPriceCurrencyCode());
259
+ }
260
+ } else if (product.getProductType().equals(ProductType.SUBS)) {
261
+ // Subscriptions — v12.0 format
262
+ ret.put("product_format", "v12.0")
263
+ .put("product_type", "subs");
264
+ JSONArray offers = new JSONArray();
265
+ List<SubscriptionOfferDetails> subscriptionOfferDetailsList =
266
+ product.getSubscriptionOfferDetails();
267
+ for (SubscriptionOfferDetails details : subscriptionOfferDetailsList) {
268
+ JSONObject offer = new JSONObject()
269
+ .put("base_plan_id", details.getBasePlanId())
270
+ .put("offer_id", details.getOfferId())
271
+ .put("token", details.getOfferToken())
272
+ .put("tags", new JSONArray(details.getOfferTags()));
273
+ JSONArray pricingPhases = new JSONArray();
274
+ if (details.getPricingPhases() != null) {
275
+ for (PricingPhase pricing :
276
+ details.getPricingPhases().getPricingPhaseList()) {
277
+ JSONObject pricingPhase = new JSONObject()
278
+ .put("billing_cycle_count",
279
+ pricing.getBillingCycleCount())
280
+ .put("billing_period",
281
+ pricing.getBillingPeriod())
282
+ .put("formatted_price",
283
+ pricing.getFormattedPrice())
284
+ .put("price_amount_micros",
285
+ pricing.getPriceAmountMicros())
286
+ .put("price_currency_code",
287
+ pricing.getPriceCurrencyCode());
288
+ // CRITICAL 4: string enum, not integer
289
+ if (pricing.getRecurrenceMode()
290
+ == ProductDetails.RecurrenceMode.FINITE_RECURRING) {
291
+ pricingPhase.put("recurrence_mode",
292
+ "FINITE_RECURRING");
293
+ } else if (pricing.getRecurrenceMode()
294
+ == ProductDetails.RecurrenceMode.INFINITE_RECURRING) {
295
+ pricingPhase.put("recurrence_mode",
296
+ "INFINITE_RECURRING");
297
+ } else if (pricing.getRecurrenceMode()
298
+ == ProductDetails.RecurrenceMode.NON_RECURRING) {
299
+ pricingPhase.put("recurrence_mode",
300
+ "NON_RECURRING");
301
+ }
302
+ pricingPhases.put(pricingPhase);
303
+ }
304
+ } else {
305
+ ret.put("product_format", "unknown");
306
+ }
307
+ offer.put("pricing_phases", pricingPhases);
308
+ offers.put(offer);
309
+ }
310
+ ret.put("offers", offers);
311
+ }
312
+ return ret;
313
+ }
314
+
315
+ // -------------------------------------------------------------------------
316
+ // Billing response code helpers (from Cordova plugin)
317
+ // -------------------------------------------------------------------------
318
+
319
+ private String codeToString(int code) {
320
+ switch (code) {
321
+ case BillingResponseCode.BILLING_UNAVAILABLE:
322
+ return "BILLING_UNAVAILABLE";
323
+ case BillingResponseCode.DEVELOPER_ERROR:
324
+ return "DEVELOPER_ERROR";
325
+ case BillingResponseCode.ERROR:
326
+ return "ERROR";
327
+ case BillingResponseCode.FEATURE_NOT_SUPPORTED:
328
+ return "FEATURE_NOT_SUPPORTED";
329
+ case BillingResponseCode.ITEM_ALREADY_OWNED:
330
+ return "ITEM_ALREADY_OWNED";
331
+ case BillingResponseCode.ITEM_NOT_OWNED:
332
+ return "ITEM_NOT_OWNED";
333
+ case BillingResponseCode.ITEM_UNAVAILABLE:
334
+ return "ITEM_UNAVAILABLE";
335
+ case BillingResponseCode.OK:
336
+ return "OK";
337
+ case BillingResponseCode.SERVICE_DISCONNECTED:
338
+ return "SERVICE_DISCONNECTED";
339
+ case BillingResponseCode.SERVICE_TIMEOUT:
340
+ return "SERVICE_TIMEOUT";
341
+ case BillingResponseCode.SERVICE_UNAVAILABLE:
342
+ return "SERVICE_UNAVAILABLE";
343
+ case BillingResponseCode.USER_CANCELED:
344
+ return "USER_CANCELED";
345
+ default:
346
+ return "CODE_" + code;
347
+ }
348
+ }
349
+
350
+ private String codeToMessage(int code) {
351
+ switch (code) {
352
+ case BillingResponseCode.BILLING_UNAVAILABLE:
353
+ return "Billing API version is not supported for the type requested";
354
+ case BillingResponseCode.DEVELOPER_ERROR:
355
+ return "Invalid arguments provided to the API";
356
+ case BillingResponseCode.ERROR:
357
+ return "Fatal error during the API action";
358
+ case BillingResponseCode.FEATURE_NOT_SUPPORTED:
359
+ return "Requested feature is not supported by Play Store on the current device";
360
+ case BillingResponseCode.ITEM_ALREADY_OWNED:
361
+ return "Failure to purchase since item is already owned";
362
+ case BillingResponseCode.ITEM_NOT_OWNED:
363
+ return "Failure to consume since item is not owned";
364
+ case BillingResponseCode.ITEM_UNAVAILABLE:
365
+ return "Requested product is not available for purchase";
366
+ case BillingResponseCode.OK:
367
+ return "Success";
368
+ case BillingResponseCode.SERVICE_DISCONNECTED:
369
+ return "Play Store service is not connected now - potentially transient state";
370
+ case BillingResponseCode.SERVICE_TIMEOUT:
371
+ return "The request has reached the maximum timeout before Google Play responds";
372
+ case BillingResponseCode.SERVICE_UNAVAILABLE:
373
+ return "Network connection is down";
374
+ case BillingResponseCode.USER_CANCELED:
375
+ return "User pressed back or canceled a dialog";
376
+ default:
377
+ return "Unknown error code";
378
+ }
379
+ }
380
+
381
+ private String format(final BillingResult result) {
382
+ final int code = result.getResponseCode();
383
+ final String message =
384
+ !result.getDebugMessage().isEmpty()
385
+ ? result.getDebugMessage()
386
+ : codeToMessage(code);
387
+ return codeToString(code) + ": " + message;
388
+ }
389
+
390
+ // -------------------------------------------------------------------------
391
+ // BillingClient connection management
392
+ // -------------------------------------------------------------------------
393
+
394
+ private void startServiceConnection(final Runnable executeOnSuccess,
395
+ final Runnable executeOnFailure) {
396
+ Log.d(TAG, "startServiceConnection()");
397
+ mBillingClient.startConnection(new BillingClientStateListener() {
398
+ @Override
399
+ public void onBillingSetupFinished(final BillingResult result) {
400
+ mBillingClientResult = result;
401
+ if (result.getResponseCode() == BillingResponseCode.OK) {
402
+ Log.d(TAG, "startServiceConnection() -> Success");
403
+ mIsServiceConnected = true;
404
+ if (executeOnSuccess != null) {
405
+ executeOnSuccess.run();
406
+ }
407
+ } else {
408
+ Log.d(TAG, "startServiceConnection() -> Failed: "
409
+ + format(getLastResult()));
410
+ mIsServiceConnected = false;
411
+ if (executeOnFailure != null) {
412
+ executeOnFailure.run();
413
+ }
414
+ }
415
+ }
416
+
417
+ @Override
418
+ public void onBillingServiceDisconnected() {
419
+ Log.d(TAG, "startServiceConnection() -> Disconnected");
420
+ mIsServiceConnected = false;
421
+ }
422
+ });
423
+ }
424
+
425
+ private void executeServiceRequest(final Runnable runnable) {
426
+ if (mIsServiceConnected) {
427
+ Log.d(TAG, "executeServiceRequest() -> OK");
428
+ resetLastResult(BillingResponseCode.OK);
429
+ runnable.run();
430
+ } else {
431
+ Log.d(TAG, "executeServiceRequest() -> Failed (try again).");
432
+ startServiceConnection(runnable, () -> {
433
+ Log.d(TAG, "executeServiceRequest() -> "
434
+ + "Failed to reconnect to billing server...");
435
+ });
436
+ }
437
+ }
438
+
439
+ public boolean areSubscriptionsSupported() {
440
+ BillingResult result =
441
+ mBillingClient.isFeatureSupported(FeatureType.SUBSCRIPTIONS);
442
+ if (result.getResponseCode() != BillingResponseCode.OK) {
443
+ Log.w(TAG, "areSubscriptionsSupported() -> Failed: "
444
+ + format(result));
445
+ return false;
446
+ }
447
+ return true;
448
+ }
449
+
450
+ // -------------------------------------------------------------------------
451
+ // Capacitor lifecycle
452
+ // -------------------------------------------------------------------------
453
+
454
+ @Override
455
+ public void load() {
456
+ super.load();
457
+ }
458
+
459
+ @Override
460
+ protected void handleOnStart() {
461
+ super.handleOnStart();
462
+ Log.d(TAG, "handleOnStart()");
463
+ if (mBillingClient != null) {
464
+ long now = Calendar.getInstance().getTimeInMillis();
465
+ if (now - mLastQueryOnStart > 24 * 60 * 60 * 1000) {
466
+ mLastQueryOnStart = Calendar.getInstance().getTimeInMillis();
467
+ queryPurchases();
468
+ }
469
+ }
470
+ }
471
+
472
+ @Override
473
+ protected void handleOnDestroy() {
474
+ if (mBillingClient != null && mBillingClient.isReady()) {
475
+ mBillingClient.endConnection();
476
+ }
477
+ super.handleOnDestroy();
478
+ }
479
+
480
+ // -------------------------------------------------------------------------
481
+ // Plugin methods — called from TypeScript via Capacitor
482
+ // -------------------------------------------------------------------------
483
+
484
+ /**
485
+ * Initialize the Google Play BillingClient.
486
+ * CRITICAL 2: enablePendingPurchases with new API.
487
+ */
488
+ @PluginMethod
489
+ public void init(PluginCall call) {
490
+ Log.d(TAG, "init()");
491
+
492
+ mBillingClient = BillingClient
493
+ .newBuilder(getContext())
494
+ .enablePendingPurchases(
495
+ PendingPurchasesParams.newBuilder()
496
+ .enableOneTimeProducts()
497
+ .enablePrepaidPlans()
498
+ .build())
499
+ .setListener(this)
500
+ .build();
501
+
502
+ resetLastResult(BILLING_CLIENT_NOT_CONNECTED);
503
+ startServiceConnection(() -> {
504
+ if (getLastResponseCode() == BillingResponseCode.OK) {
505
+ Log.d(TAG, "init() -> Success");
506
+ call.resolve();
507
+ } else {
508
+ Log.d(TAG, "init() -> Failed: " + format(getLastResult()));
509
+ call.reject("Setup failed. " + format(getLastResult()));
510
+ }
511
+ }, () -> {
512
+ Log.d(TAG, "init() -> Failure: " + format(getLastResult()));
513
+ call.reject("Setup failure. " + format(getLastResult()));
514
+ });
515
+ }
516
+
517
+ /**
518
+ * Query available products (INAPP + SUBS).
519
+ * Returns { products: [...] } matching the CapacitorBridge expectation.
520
+ */
521
+ @PluginMethod
522
+ public void getAvailableProducts(PluginCall call) {
523
+ Log.d(TAG, "getAvailableProducts()");
524
+ try {
525
+ final JSObject args = call.getData();
526
+ final JSONArray inAppArr = args.getJSONArray("inAppSkus");
527
+ final JSONArray subsArr = args.getJSONArray("subsSkus");
528
+ List<String> inAppSkus = jsonArrayToStringList(inAppArr);
529
+ List<String> subsSkus = jsonArrayToStringList(subsArr);
530
+ addInAppProductIds(inAppSkus);
531
+ addSubsProductIds(subsSkus);
532
+
533
+ queryAllProductDetails(mInAppProductIds, mSubsProductIds,
534
+ (result, productDetailsList) -> {
535
+ if (result.getResponseCode() != BillingResponseCode.OK) {
536
+ Log.d(TAG, "getAvailableProducts() -> Failed: "
537
+ + format(result));
538
+ call.reject("Failed to load Products, code: "
539
+ + result.getResponseCode());
540
+ return;
541
+ }
542
+ try {
543
+ JSONArray jsonProductList = new JSONArray();
544
+ for (ProductDetails product : productDetailsList) {
545
+ Log.d(TAG, "getAvailableProducts() -> productDetails: "
546
+ + product.toString());
547
+ jsonProductList.put(productDetailsToJson(product));
548
+ }
549
+ Log.d(TAG, "getAvailableProducts() -> Success");
550
+ JSObject ret = new JSObject();
551
+ ret.put("products", jsonProductList);
552
+ call.resolve(ret);
553
+ } catch (JSONException e) {
554
+ Log.d(TAG, "getAvailableProducts() -> Failed: "
555
+ + e.getMessage());
556
+ call.reject(e.getMessage());
557
+ }
558
+ });
559
+ } catch (JSONException e) {
560
+ call.reject("Invalid parameters: " + e.getMessage());
561
+ }
562
+ }
563
+
564
+ /**
565
+ * Query existing purchases. Results arrive via the "setPurchases" event.
566
+ */
567
+ @PluginMethod
568
+ public void getPurchases(PluginCall call) {
569
+ Log.d(TAG, "getPurchases()");
570
+ queryPurchases();
571
+ call.resolve();
572
+ }
573
+
574
+ /**
575
+ * Initiate a purchase flow (INAPP).
576
+ */
577
+ @PluginMethod
578
+ public void buy(PluginCall call) {
579
+ Log.d(TAG, "buy()");
580
+ try {
581
+ final String productId = call.getString("productId");
582
+ final JSONObject additionalData = call.getData().has("additionalData")
583
+ ? new JSONObject(call.getObject("additionalData").toString())
584
+ : new JSONObject();
585
+ BillingFlowParams params =
586
+ parseBillingFlowParams(productId, additionalData, call);
587
+ if (params != null) {
588
+ initiatePurchaseFlow(params, call);
589
+ }
590
+ // If params == null, parseBillingFlowParams already called
591
+ // call.reject().
592
+ } catch (JSONException e) {
593
+ call.reject(e.getMessage());
594
+ }
595
+ }
596
+
597
+ /**
598
+ * Initiate a subscription flow.
599
+ */
600
+ @PluginMethod
601
+ public void subscribe(PluginCall call) {
602
+ Log.d(TAG, "subscribe()");
603
+ if (!areSubscriptionsSupported()) {
604
+ call.reject("FEATURE_NOT_SUPPORTED");
605
+ return;
606
+ }
607
+ try {
608
+ final String productId = call.getString("productId");
609
+ final JSONObject additionalData = call.getData().has("additionalData")
610
+ ? new JSONObject(call.getObject("additionalData").toString())
611
+ : new JSONObject();
612
+ BillingFlowParams params =
613
+ parseBillingFlowParams(productId, additionalData, call);
614
+ if (params != null) {
615
+ initiatePurchaseFlow(params, call);
616
+ }
617
+ } catch (JSONException e) {
618
+ call.reject(e.getMessage());
619
+ }
620
+ }
621
+
622
+ /**
623
+ * Consume a purchase.
624
+ * CRITICAL 6: store PluginCall and resolve in async callback.
625
+ */
626
+ @PluginMethod
627
+ public void consumePurchase(PluginCall call) {
628
+ final String purchaseToken = call.getString("purchaseToken");
629
+ Log.d(TAG, "consumePurchase(" + purchaseToken + ")");
630
+
631
+ if (mTokensToBeConsumed.contains(purchaseToken)) {
632
+ Log.i(TAG, "consumePurchase() -> Consume already in progress.");
633
+ call.reject("ITEM_ALREADY_CONSUMED");
634
+ return;
635
+ }
636
+ mTokensToBeConsumed.add(purchaseToken);
637
+ mPendingConsumeCall = call;
638
+
639
+ executeServiceRequest(() -> {
640
+ final ConsumeParams params = ConsumeParams.newBuilder()
641
+ .setPurchaseToken(purchaseToken)
642
+ .build();
643
+ mBillingClient.consumeAsync(params, (result, token) -> {
644
+ onConsumeResponse(result, token);
645
+ });
646
+ });
647
+ }
648
+
649
+ /**
650
+ * Acknowledge a purchase.
651
+ * CRITICAL 6: store PluginCall and resolve in async callback.
652
+ */
653
+ @PluginMethod
654
+ public void acknowledgePurchase(PluginCall call) {
655
+ final String purchaseToken = call.getString("purchaseToken");
656
+ Log.d(TAG, "acknowledgePurchase(" + purchaseToken + ")");
657
+
658
+ mPendingAcknowledgeCall = call;
659
+
660
+ executeServiceRequest(() -> {
661
+ final AcknowledgePurchaseParams params =
662
+ AcknowledgePurchaseParams.newBuilder()
663
+ .setPurchaseToken(purchaseToken)
664
+ .build();
665
+ mBillingClient.acknowledgePurchase(params, result -> {
666
+ onAcknowledgePurchaseResponse(result);
667
+ });
668
+ });
669
+ }
670
+
671
+ /**
672
+ * Finish/acknowledge a transaction (generic form used by the bridge).
673
+ * For Android this is an alias for acknowledgePurchase using
674
+ * transactionId as the purchase token.
675
+ */
676
+ @PluginMethod
677
+ public void finish(PluginCall call) {
678
+ final String transactionId = call.getString("transactionId");
679
+ Log.d(TAG, "finish(" + transactionId + ")");
680
+
681
+ mPendingAcknowledgeCall = call;
682
+
683
+ executeServiceRequest(() -> {
684
+ final AcknowledgePurchaseParams params =
685
+ AcknowledgePurchaseParams.newBuilder()
686
+ .setPurchaseToken(transactionId)
687
+ .build();
688
+ mBillingClient.acknowledgePurchase(params, result -> {
689
+ onAcknowledgePurchaseResponse(result);
690
+ });
691
+ });
692
+ }
693
+
694
+ /**
695
+ * CRITICAL 7: open subscription management via Intent deep link.
696
+ */
697
+ @PluginMethod
698
+ public void manageSubscriptions(PluginCall call) {
699
+ Intent browserIntent = new Intent(Intent.ACTION_VIEW,
700
+ Uri.parse("http://play.google.com/store/account/subscriptions"));
701
+ getActivity().startActivity(browserIntent);
702
+ call.resolve();
703
+ }
704
+
705
+ /**
706
+ * CRITICAL 7: open billing management via Intent deep link.
707
+ */
708
+ @PluginMethod
709
+ public void manageBilling(PluginCall call) {
710
+ Intent browserIntent = new Intent(Intent.ACTION_VIEW,
711
+ Uri.parse("http://play.google.com/store/paymentmethods"));
712
+ getActivity().startActivity(browserIntent);
713
+ call.resolve();
714
+ }
715
+
716
+ /**
717
+ * CRITICAL 7: launch price change confirmation flow via deep link.
718
+ */
719
+ @PluginMethod
720
+ public void launchPriceChangeConfirmationFlow(PluginCall call) {
721
+ final String sku = call.getString("productId");
722
+ Log.d(TAG, "launchPriceChangeConfirmationFlow(" + sku + ")");
723
+ Intent browserIntent = new Intent(Intent.ACTION_VIEW,
724
+ Uri.parse("http://play.google.com/store/account/subscriptions"));
725
+ getActivity().startActivity(browserIntent);
726
+ call.resolve();
727
+ }
728
+
729
+ // -------------------------------------------------------------------------
730
+ // PurchasesUpdatedListener — called by BillingClient after purchase flow
731
+ // -------------------------------------------------------------------------
732
+
733
+ @Override
734
+ public void onPurchasesUpdated(final BillingResult result,
735
+ final List<Purchase> purchases) {
736
+ try {
737
+ final int code = result.getResponseCode();
738
+ if (code == BillingResponseCode.OK
739
+ && purchases != null && purchases.size() > 0) {
740
+ Log.d(TAG, "onPurchasesUpdated() -> Success");
741
+ for (Purchase p : purchases) {
742
+ storePurchase(p);
743
+ }
744
+ JSObject data = new JSObject();
745
+ data.put("purchases", purchasesToJson(purchases));
746
+ notifyListeners("purchasesUpdated", data);
747
+ if (mPendingBuyCall != null) {
748
+ mPendingBuyCall.resolve();
749
+ mPendingBuyCall = null;
750
+ }
751
+ } else if (code == BillingResponseCode.USER_CANCELED) {
752
+ Log.w(TAG, "onPurchasesUpdated() -> Cancelled: "
753
+ + format(result));
754
+ if (mPendingBuyCall != null) {
755
+ mPendingBuyCall.reject("USER_CANCELED", String.valueOf(code));
756
+ mPendingBuyCall = null;
757
+ }
758
+ } else {
759
+ Log.w(TAG, "onPurchasesUpdated() -> Failed: "
760
+ + format(result));
761
+ if (mPendingBuyCall != null) {
762
+ mPendingBuyCall.reject(format(result), String.valueOf(code));
763
+ mPendingBuyCall = null;
764
+ }
765
+ }
766
+ } catch (JSONException e) {
767
+ Log.w(TAG, "onPurchasesUpdated() -> JSONException "
768
+ + e.getMessage());
769
+ }
770
+ }
771
+
772
+ // -------------------------------------------------------------------------
773
+ // ConsumeResponseListener — CRITICAL 5
774
+ // -------------------------------------------------------------------------
775
+
776
+ private void onConsumeResponse(BillingResult result,
777
+ String purchaseToken) {
778
+ try {
779
+ Log.d(TAG, "onConsumeResponse()");
780
+ if (result.getResponseCode() == BillingResponseCode.OK) {
781
+ mTokensToBeConsumed.remove(purchaseToken);
782
+ final Purchase purchase = findPurchaseByToken(purchaseToken);
783
+ Log.d(TAG, "onConsumeResponse() -> Success");
784
+ // CRITICAL 5: send full Purchase JSON under "purchase" key
785
+ if (purchase != null) {
786
+ JSObject eventData = new JSObject();
787
+ eventData.put("purchase", purchaseToJson(purchase));
788
+ notifyListeners("purchaseConsumed", eventData);
789
+ }
790
+ // Resolve the pending call
791
+ if (mPendingConsumeCall != null) {
792
+ mPendingConsumeCall.resolve();
793
+ mPendingConsumeCall = null;
794
+ }
795
+ } else {
796
+ Log.d(TAG, "onConsumeResponse() -> Failed: "
797
+ + result.getDebugMessage());
798
+ if (mPendingConsumeCall != null) {
799
+ mPendingConsumeCall.reject(result.getDebugMessage());
800
+ mPendingConsumeCall = null;
801
+ }
802
+ }
803
+ } catch (JSONException e) {
804
+ Log.d(TAG, "onConsumeResponse() -> Failed: " + e.getMessage());
805
+ if (mPendingConsumeCall != null) {
806
+ mPendingConsumeCall.reject(e.getMessage());
807
+ mPendingConsumeCall = null;
808
+ }
809
+ }
810
+ }
811
+
812
+ // -------------------------------------------------------------------------
813
+ // AcknowledgePurchaseResponseListener
814
+ // -------------------------------------------------------------------------
815
+
816
+ private void onAcknowledgePurchaseResponse(BillingResult result) {
817
+ if (result.getResponseCode() == BillingResponseCode.OK) {
818
+ Log.d(TAG, "onAcknowledgePurchaseResponse() -> Success");
819
+ if (mPendingAcknowledgeCall != null) {
820
+ mPendingAcknowledgeCall.resolve();
821
+ mPendingAcknowledgeCall = null;
822
+ }
823
+ } else {
824
+ Log.d(TAG, "onAcknowledgePurchaseResponse() -> Failed: "
825
+ + format(result));
826
+ if (mPendingAcknowledgeCall != null) {
827
+ mPendingAcknowledgeCall.reject(format(result));
828
+ mPendingAcknowledgeCall = null;
829
+ }
830
+ }
831
+ }
832
+
833
+ // -------------------------------------------------------------------------
834
+ // Purchase queries
835
+ // -------------------------------------------------------------------------
836
+
837
+ private void queryPurchases() {
838
+ Log.d(TAG, "queryPurchases()");
839
+ executeServiceRequest(() -> {
840
+ long time = System.currentTimeMillis();
841
+
842
+ final List<Purchase> allPurchases = new ArrayList<>();
843
+ mInAppResult = null;
844
+ mSubsResult = null;
845
+
846
+ mBillingClient.queryPurchasesAsync(
847
+ QueryPurchasesParams.newBuilder()
848
+ .setProductType(ProductType.INAPP).build(),
849
+ (billingResult, purchases) -> {
850
+ mInAppResult = billingResult;
851
+ Log.i(TAG, "queryPurchases(INAPP) -> Elapsed time: "
852
+ + (System.currentTimeMillis() - time) + "ms");
853
+ if (billingResult.getResponseCode()
854
+ == BillingResponseCode.OK) {
855
+ allPurchases.addAll(purchases);
856
+ }
857
+ if (mInAppResult != null
858
+ && (mSubsResult != null
859
+ || !areSubscriptionsSupported())) {
860
+ BillingResult best =
861
+ mInAppResult.getResponseCode()
862
+ == BillingResponseCode.OK
863
+ ? mInAppResult : mSubsResult;
864
+ onQueryPurchasesFinished(best, allPurchases);
865
+ }
866
+ });
867
+
868
+ if (areSubscriptionsSupported()) {
869
+ mBillingClient.queryPurchasesAsync(
870
+ QueryPurchasesParams.newBuilder()
871
+ .setProductType(ProductType.SUBS).build(),
872
+ (billingResult, purchases) -> {
873
+ mSubsResult = billingResult;
874
+ Log.i(TAG,
875
+ "queryPurchases(SUBS) -> Elapsed time: "
876
+ + (System.currentTimeMillis()
877
+ - time) + "ms");
878
+ if (billingResult.getResponseCode()
879
+ == BillingResponseCode.OK) {
880
+ allPurchases.addAll(purchases);
881
+ }
882
+ if (mInAppResult != null
883
+ && (mSubsResult != null
884
+ || !areSubscriptionsSupported())) {
885
+ BillingResult best =
886
+ mInAppResult.getResponseCode()
887
+ == BillingResponseCode.OK
888
+ ? mInAppResult : mSubsResult;
889
+ onQueryPurchasesFinished(best, allPurchases);
890
+ }
891
+ });
892
+ } else {
893
+ Log.i(TAG, "queryPurchases() -> "
894
+ + "Subscriptions are not supported, skipped.");
895
+ }
896
+ });
897
+ }
898
+
899
+ private void onQueryPurchasesFinished(final BillingResult result,
900
+ final List<Purchase> purchases) {
901
+ try {
902
+ if (result.getResponseCode() == BillingResponseCode.OK) {
903
+ for (Purchase p : purchases) {
904
+ storePurchase(p);
905
+ }
906
+ JSObject data = new JSObject();
907
+ data.put("purchases", purchasesToJson(purchases));
908
+ notifyListeners("setPurchases", data);
909
+ } else {
910
+ Log.d(TAG, result.getDebugMessage());
911
+ }
912
+ } catch (Exception e) {
913
+ Log.e(TAG, "onQueryPurchasesFinished() -> Failed: "
914
+ + e.getMessage());
915
+ }
916
+ }
917
+
918
+ // -------------------------------------------------------------------------
919
+ // Product details queries
920
+ // -------------------------------------------------------------------------
921
+
922
+ private void queryAllProductDetails(
923
+ List<String> inAppProductIds,
924
+ List<String> subsProductIds,
925
+ final InternalProductDetailsResponseListener listener) {
926
+ Log.d(TAG, "queryAllProductDetails()");
927
+ final ArrayList<ProductDetails> allProducts = new ArrayList<>();
928
+
929
+ final int nRequests =
930
+ (subsProductIds.size() > 0 ? 1 : 0)
931
+ + (inAppProductIds.size() > 0 ? 1 : 0);
932
+ nProductDetailsQuerySuccessful = 0;
933
+
934
+ final InternalProductDetailsResponseListener queryListener =
935
+ (result, productDetailsList) -> {
936
+ mBillingClientResult = result;
937
+ if (result.getResponseCode() != BillingResponseCode.OK) {
938
+ Log.w(TAG, "queryAllProductDetails() -> Failed: "
939
+ + "Unsuccessful query. " + format(result));
940
+ // Don't call listener on failure — let the other query finish
941
+ } else {
942
+ if (productDetailsList != null
943
+ && productDetailsList.size() > 0) {
944
+ for (ProductDetails product : productDetailsList) {
945
+ Log.d(TAG,
946
+ "queryAllProductDetails() -> ProductDetails: "
947
+ + "Title: " + product.getTitle());
948
+ mProductDetails.put(product.getProductId(), product);
949
+ allProducts.add(product);
950
+ }
951
+ } else {
952
+ Log.w(TAG,
953
+ "queryAllProductDetails() -> Query returned nothing.");
954
+ }
955
+ nProductDetailsQuerySuccessful++;
956
+ if (nProductDetailsQuerySuccessful == nRequests
957
+ && listener != null) {
958
+ Log.d(TAG,
959
+ "queryAllProductDetails() -> Calling listener.");
960
+ listener.onProductDetailsResponse(result, allProducts);
961
+ }
962
+ }
963
+ };
964
+
965
+ List<Product> subsList = new ArrayList<>();
966
+ for (int i = 0; i < subsProductIds.size(); ++i) {
967
+ subsList.add(Product.newBuilder()
968
+ .setProductId(subsProductIds.get(i))
969
+ .setProductType(ProductType.SUBS)
970
+ .build());
971
+ }
972
+
973
+ List<Product> inAppList = new ArrayList<>();
974
+ for (int i = 0; i < inAppProductIds.size(); ++i) {
975
+ inAppList.add(Product.newBuilder()
976
+ .setProductId(inAppProductIds.get(i))
977
+ .setProductType(ProductType.INAPP)
978
+ .build());
979
+ }
980
+
981
+ if (subsList.size() > 0) {
982
+ Log.d(TAG, "queryAllProductDetails() -> Query SUBS.");
983
+ queryProductDetailsAsync(subsList, queryListener);
984
+ }
985
+
986
+ if (inAppList.size() > 0) {
987
+ Log.d(TAG, "queryAllProductDetails() -> Query INAPP.");
988
+ queryProductDetailsAsync(inAppList, queryListener);
989
+ }
990
+
991
+ if (inAppList.size() == 0 && subsList.size() == 0) {
992
+ Log.d(TAG,
993
+ "queryAllProductDetails() -> Calling listener (0 requests).");
994
+ if (listener != null) {
995
+ listener.onProductDetailsResponse(getLastResult(), allProducts);
996
+ }
997
+ }
998
+ }
999
+
1000
+ private void queryProductDetailsAsync(
1001
+ final List<Product> productList,
1002
+ final InternalProductDetailsResponseListener listener) {
1003
+ Log.d(TAG, "queryProductDetailsAsync()");
1004
+ executeServiceRequest(() -> {
1005
+ if (getLastResponseCode() != BillingResponseCode.OK) {
1006
+ Log.d(TAG, "queryProductDetailsAsync() -> Failed: "
1007
+ + format(getLastResult()));
1008
+ listener.onProductDetailsResponse(getLastResult(), null);
1009
+ } else {
1010
+ Log.d(TAG, "queryProductDetailsAsync() -> Success");
1011
+ QueryProductDetailsParams.Builder params =
1012
+ QueryProductDetailsParams.newBuilder();
1013
+ params.setProductList(productList);
1014
+ mBillingClient.queryProductDetailsAsync(params.build(),
1015
+ (billingResult, queryResult) -> {
1016
+ List<ProductDetails> productDetailsList =
1017
+ queryResult != null
1018
+ ? queryResult.getProductDetailsList()
1019
+ : null;
1020
+ listener.onProductDetailsResponse(
1021
+ billingResult, productDetailsList);
1022
+ });
1023
+ }
1024
+ });
1025
+ }
1026
+
1027
+ // -------------------------------------------------------------------------
1028
+ // Purchase flow
1029
+ // -------------------------------------------------------------------------
1030
+
1031
+ private BillingFlowParams parseBillingFlowParams(
1032
+ String productId,
1033
+ JSONObject additionalData,
1034
+ PluginCall call) throws JSONException {
1035
+
1036
+ // Handle "productId@offerToken" syntax
1037
+ final String[] parts = productId.split("@", 2);
1038
+ String offerToken = null;
1039
+ if (parts.length == 2) {
1040
+ productId = parts[0];
1041
+ offerToken = parts[1];
1042
+ }
1043
+
1044
+ if (offerToken == null && additionalData.has("offerToken")) {
1045
+ offerToken = additionalData.getString("offerToken");
1046
+ }
1047
+
1048
+ String oldPurchaseToken = null;
1049
+ if (additionalData.has("oldPurchaseToken")) {
1050
+ oldPurchaseToken = additionalData.getString("oldPurchaseToken");
1051
+ }
1052
+
1053
+ final String accountId = additionalData.has("accountId")
1054
+ ? additionalData.getString("accountId") : null;
1055
+
1056
+ final String profileId = additionalData.has("profileId")
1057
+ ? additionalData.getString("profileId") : null;
1058
+
1059
+ final ProductDetails productDetails = mProductDetails.get(productId);
1060
+ if (productDetails == null) {
1061
+ Log.d(TAG, "buy() -> Failed: Product not registered: "
1062
+ + productId);
1063
+ call.reject("Product not registered: " + productId);
1064
+ return null;
1065
+ }
1066
+
1067
+ BillingFlowParams.Builder params = BillingFlowParams.newBuilder();
1068
+
1069
+ // If not passed in additionalData, use the offer token based on the
1070
+ // selected offer index.
1071
+ final List<SubscriptionOfferDetails> subscriptionOfferDetails =
1072
+ productDetails.getSubscriptionOfferDetails();
1073
+ if (offerToken == null && subscriptionOfferDetails != null) {
1074
+ offerToken = subscriptionOfferDetails.get(0).getOfferToken();
1075
+ }
1076
+
1077
+ List<ProductDetailsParams> productDetailsParamsList = new ArrayList<>();
1078
+ if (offerToken != null) {
1079
+ productDetailsParamsList.add(ProductDetailsParams.newBuilder()
1080
+ .setProductDetails(productDetails)
1081
+ .setOfferToken(offerToken)
1082
+ .build());
1083
+ } else {
1084
+ productDetailsParamsList.add(ProductDetailsParams.newBuilder()
1085
+ .setProductDetails(productDetails)
1086
+ .build());
1087
+ }
1088
+
1089
+ BillingFlowParams.SubscriptionUpdateParams.Builder
1090
+ subscriptionUpdateParams =
1091
+ BillingFlowParams.SubscriptionUpdateParams.newBuilder();
1092
+ boolean hasSubscriptionUpdateParams = false;
1093
+
1094
+ params.setProductDetailsParamsList(productDetailsParamsList);
1095
+
1096
+ if (oldPurchaseToken != null) {
1097
+ Log.d(TAG, "buy() -> setOldSkuPurchaseToken");
1098
+ subscriptionUpdateParams.setOldPurchaseToken(oldPurchaseToken);
1099
+ hasSubscriptionUpdateParams = true;
1100
+ }
1101
+
1102
+ if (accountId != null) {
1103
+ Log.d(TAG, "buy() -> setObfuscatedAccountId");
1104
+ params.setObfuscatedAccountId(accountId);
1105
+ }
1106
+
1107
+ if (profileId != null) {
1108
+ Log.d(TAG, "buy() -> setObfuscatedProfileId");
1109
+ params.setObfuscatedProfileId(profileId);
1110
+ }
1111
+
1112
+ // Subscription replacement mode
1113
+ final String replacementMode = additionalData.has("prorationMode")
1114
+ ? additionalData.getString("prorationMode")
1115
+ : additionalData.has("replacementMode")
1116
+ ? additionalData.getString("replacementMode")
1117
+ : null;
1118
+ if (replacementMode != null) {
1119
+ if ("IMMEDIATE_WITH_TIME_PRORATION".equals(replacementMode))
1120
+ subscriptionUpdateParams.setSubscriptionReplacementMode(
1121
+ BillingFlowParams.SubscriptionUpdateParams
1122
+ .ReplacementMode.WITH_TIME_PRORATION);
1123
+ else if ("IMMEDIATE_AND_CHARGE_PRORATED_PRICE".equals(
1124
+ replacementMode))
1125
+ subscriptionUpdateParams.setSubscriptionReplacementMode(
1126
+ BillingFlowParams.SubscriptionUpdateParams
1127
+ .ReplacementMode.CHARGE_PRORATED_PRICE);
1128
+ else if ("IMMEDIATE_WITHOUT_PRORATION".equals(replacementMode))
1129
+ subscriptionUpdateParams.setSubscriptionReplacementMode(
1130
+ BillingFlowParams.SubscriptionUpdateParams
1131
+ .ReplacementMode.WITHOUT_PRORATION);
1132
+ else if ("DEFERRED".equals(replacementMode))
1133
+ subscriptionUpdateParams.setSubscriptionReplacementMode(
1134
+ BillingFlowParams.SubscriptionUpdateParams
1135
+ .ReplacementMode.DEFERRED);
1136
+ else if ("IMMEDIATE_AND_CHARGE_FULL_PRICE".equals(replacementMode))
1137
+ subscriptionUpdateParams.setSubscriptionReplacementMode(
1138
+ BillingFlowParams.SubscriptionUpdateParams
1139
+ .ReplacementMode.CHARGE_FULL_PRICE);
1140
+ }
1141
+
1142
+ if (hasSubscriptionUpdateParams) {
1143
+ params.setSubscriptionUpdateParams(
1144
+ subscriptionUpdateParams.build());
1145
+ }
1146
+
1147
+ return params.build();
1148
+ }
1149
+
1150
+ private void initiatePurchaseFlow(final BillingFlowParams params,
1151
+ final PluginCall call) {
1152
+ Log.d(TAG, "initiatePurchaseFlow()");
1153
+ if (params == null) {
1154
+ return;
1155
+ }
1156
+ final Activity activity = getActivity();
1157
+ if (activity == null || activity.isFinishing()) {
1158
+ Log.e(TAG, "Activity is null or finishing, "
1159
+ + "cannot launch billing flow.");
1160
+ call.reject("Activity not available to launch billing flow.");
1161
+ return;
1162
+ }
1163
+ executeServiceRequest(() -> {
1164
+ if (getLastResponseCode() != BillingResponseCode.OK) {
1165
+ Log.d(TAG, "initiatePurchaseFlow() -> Failed: "
1166
+ + "Failed to execute service request. "
1167
+ + format(getLastResult()));
1168
+ call.reject("Failed to execute service request. "
1169
+ + format(getLastResult()));
1170
+ return;
1171
+ }
1172
+ Log.d(TAG, "Attempting to launch billing flow on UI thread.");
1173
+ activity.runOnUiThread(() -> {
1174
+ Log.d(TAG, "launchBillingFlow happening now.");
1175
+ BillingResult billingResult =
1176
+ mBillingClient.launchBillingFlow(activity, params);
1177
+ Log.d(TAG, "launchBillingFlow immediate result: "
1178
+ + format(billingResult));
1179
+ if (billingResult.getResponseCode() == BillingResponseCode.OK) {
1180
+ mPendingBuyCall = call;
1181
+ } else {
1182
+ Log.e(TAG,
1183
+ "launchBillingFlow failed immediately with code: "
1184
+ + format(billingResult));
1185
+ call.reject("launchBillingFlow failed: "
1186
+ + format(billingResult));
1187
+ }
1188
+ });
1189
+ });
1190
+ }
1191
+
1192
+ // -------------------------------------------------------------------------
1193
+ // Utility
1194
+ // -------------------------------------------------------------------------
1195
+
1196
+ private List<String> jsonArrayToStringList(JSONArray arr)
1197
+ throws JSONException {
1198
+ List<String> list = new ArrayList<>();
1199
+ if (arr != null) {
1200
+ for (int i = 0; i < arr.length(); i++) {
1201
+ list.add(arr.getString(i));
1202
+ }
1203
+ }
1204
+ return list;
1205
+ }
1206
+ }