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.
- package/README.md +61 -0
- package/android/build.gradle +42 -0
- package/android/src/main/AndroidManifest.xml +4 -0
- package/android/src/main/java/cc/fovea/iap/PurchasePlugin.java +1206 -0
- package/dist/definitions.d.ts +82 -0
- package/dist/esm/definitions.d.ts +82 -0
- package/dist/esm/definitions.js +1 -0
- package/dist/esm/index.d.ts +4 -0
- package/dist/esm/index.js +7 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +8438 -0
- package/dist/index.js.map +1 -0
- package/ios/CapacitorPluginPurchase.podspec +18 -0
- package/ios/Sources/PurchasePlugin/PurchasePlugin.swift +434 -0
- package/package.json +56 -0
- package/types/index.d.ts +27 -0
- package/www/capacitor-plugin.js +3 -0
- package/www/store.d.ts +6370 -0
- package/www/store.js +8660 -0
|
@@ -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
|
+
}
|