@livx.cc/appwrap 0.53.0 → 0.55.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/package.json +4 -2
- package/runtime/app/main-page.ts +0 -2
- package/runtime/app/shell/capabilities.manifest.ts +27 -92
- package/runtime/app/shell/config.ts +4 -0
- package/runtime/app/shell/env.ts +8 -1
- package/runtime/app/shell/handlers.ts +2 -2
- package/runtime/tests/env-scheme.test.ts +44 -0
- package/src/cli.ts +398 -65
- package/src/config.ts +8 -1
- package/src/packs.ts +174 -0
- package/src/testing.ts +173 -0
- package/templates/module-pack/README.md +44 -0
- package/templates/module-pack/handler.ts +9 -0
- package/templates/module-pack/manifest.ts +27 -0
- package/templates/module-pack/native-src/__MODULE_NAME__/.gitkeep +0 -0
- package/runtime/app/shell/billing-offer.ts +0 -54
- package/runtime/app/shell/handlers-billing.ts +0 -629
- package/runtime/app/shell/handlers-health.ts +0 -178
- package/runtime/app/shell/handlers-widget.ts +0 -97
- package/runtime/modules-native/billing/App_Resources/iOS/src/AppwrapManageSubscriptions.swift +0 -44
- package/runtime/modules-native/health/App_Resources/Android/src/main/java/cc/livx/appwrap/HealthConnectBridge.kt +0 -74
- package/runtime/modules-native/widget/App_Resources/Android/src/main/java/cc/livx/appwrap/AppwrapWidgetProvider.kt +0 -118
- package/runtime/modules-native/widget/App_Resources/Android/src/main/res/drawable/appwrap_badge_bg.xml +0 -9
- package/runtime/modules-native/widget/App_Resources/Android/src/main/res/layout/appwrap_widget.xml +0 -123
- package/runtime/modules-native/widget/App_Resources/Android/src/main/res/values/appwrap_widget_colors.xml +0 -10
- package/runtime/modules-native/widget/App_Resources/Android/src/main/res/values/appwrap_widget_styles.xml +0 -67
- package/runtime/modules-native/widget/App_Resources/Android/src/main/res/values-night/appwrap_widget_colors.xml +0 -9
- package/runtime/modules-native/widget/App_Resources/Android/src/main/res/xml/appwrap_widget_info.xml +0 -17
- package/runtime/modules-native/widget/App_Resources/iOS/extensions/AppwrapWidget/AppwrapWidget.entitlements +0 -10
- package/runtime/modules-native/widget/App_Resources/iOS/extensions/AppwrapWidget/AppwrapWidget.swift +0 -275
- package/runtime/modules-native/widget/App_Resources/iOS/extensions/AppwrapWidget/Info.plist +0 -25
- package/runtime/modules-native/widget/App_Resources/iOS/extensions/AppwrapWidget/extension.json +0 -9
- package/runtime/modules-native/widget/App_Resources/iOS/src/AppwrapWidgetReload.swift +0 -18
- package/runtime/tests/billing-offer.test.ts +0 -61
|
@@ -1,629 +0,0 @@
|
|
|
1
|
-
import { Application, Utils, isAndroid, isIOS } from '@nativescript/core';
|
|
2
|
-
import { bridge } from './bridge';
|
|
3
|
-
import { appwrapNativeLog } from './native-log';
|
|
4
|
-
import { SHELL_CONFIG } from './config';
|
|
5
|
-
import { selectSubscriptionOffer, type OfferCandidate } from './billing-offer';
|
|
6
|
-
|
|
7
|
-
// @objc Swift shim (AppwrapManageSubscriptions.swift, billing nativeSrc) — no NS types; bridges
|
|
8
|
-
// StoreKit 2's Swift-async showManageSubscriptions(in:) to a completion the ObjC bridge can call.
|
|
9
|
-
declare const AppwrapManageSubscriptions: { present(completion: (message: string | null) => void): void };
|
|
10
|
-
|
|
11
|
-
// no NS types for the Play Billing Library (com.android.billingclient.*) — referenced via FFI,
|
|
12
|
-
// lazily, so a non-billing Android build never touches the class (mirrors handlers-reviews.ts).
|
|
13
|
-
declare const com: any;
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* In-app purchases — ONE bridge contract, two native stores.
|
|
17
|
-
*
|
|
18
|
-
* - iOS: StoreKit 1 (SKPaymentQueue). StoreKit 2 (Product/Transaction) is Swift-only and unreachable
|
|
19
|
-
* via the ObjC bridge, so we use the delegate-based SK1 API every NativeScript/Cordova IAP plugin
|
|
20
|
-
* uses. Returns a base64 app receipt.
|
|
21
|
-
* - Android: Play Billing Library v7 (BillingClient / queryProductDetailsAsync / launchBillingFlow /
|
|
22
|
-
* queryPurchasesAsync). Returns a PurchaseReceipt carrying the Play `purchaseToken` (+ productId +
|
|
23
|
-
* packageName) — the exact shape AGF's server-side PlayStoreTokenValidator consumes.
|
|
24
|
-
*
|
|
25
|
-
* The web layer's validator (server-of-record) turns the returned receipt into entitlements; both
|
|
26
|
-
* platforms register the SAME `billing.*` methods with the SAME return shapes.
|
|
27
|
-
*/
|
|
28
|
-
|
|
29
|
-
const PERIOD_UNIT = ['D', 'W', 'M', 'Y']; // SKProductPeriodUnit: Day/Week/Month/Year
|
|
30
|
-
const STATE = { Purchasing: 0, Purchased: 1, Failed: 2, Restored: 3, Deferred: 4 };
|
|
31
|
-
const SK_ERR_CANCELLED = 2; // SKErrorPaymentCancelled
|
|
32
|
-
|
|
33
|
-
function iosOnly(): Error {
|
|
34
|
-
return Object.assign(new Error('iOS only — Android billing not wired'), { code: 'UNSUPPORTED' });
|
|
35
|
-
}
|
|
36
|
-
function err(code: string, message: string): Error {
|
|
37
|
-
return Object.assign(new Error(message), { code });
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/** Base64 of the bundle's App Store receipt — the proof a validator sends to Apple. */
|
|
41
|
-
function appReceipt(): string {
|
|
42
|
-
const url = NSBundle.mainBundle.appStoreReceiptURL;
|
|
43
|
-
if (!url) return '';
|
|
44
|
-
const data = NSData.dataWithContentsOfURL(url);
|
|
45
|
-
// 0 = NSDataBase64Encoding default (no options); enum-cast for the typed selector.
|
|
46
|
-
return data ? data.base64EncodedStringWithOptions(0 as unknown as NSDataBase64EncodingOptions) : '';
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function mapProduct(p: SKProduct) {
|
|
50
|
-
const f = NSNumberFormatter.new();
|
|
51
|
-
f.numberStyle = 2; // CurrencyStyle
|
|
52
|
-
f.locale = p.priceLocale;
|
|
53
|
-
const sp = p.subscriptionPeriod;
|
|
54
|
-
return {
|
|
55
|
-
id: String(p.productIdentifier),
|
|
56
|
-
title: String(p.localizedTitle ?? ''),
|
|
57
|
-
description: String(p.localizedDescription ?? ''),
|
|
58
|
-
price: p.price ? p.price.doubleValue : 0,
|
|
59
|
-
// stringFromNumber takes an NSNumber* at runtime; the .d.ts types it as number, so cast the NSDecimalNumber.
|
|
60
|
-
displayPrice: String(f.stringFromNumber(p.price as unknown as number) ?? ''),
|
|
61
|
-
currency: String(p.priceLocale?.currencyCode ?? ''),
|
|
62
|
-
type: sp ? 'autoRenewable' : 'unknown',
|
|
63
|
-
subscriptionPeriod: sp ? `P${sp.numberOfUnits}${PERIOD_UNIT[sp.unit] ?? 'D'}` : undefined,
|
|
64
|
-
};
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
function receiptFor(productId: string, transactionId?: string) {
|
|
68
|
-
return { platform: 'ios', productId, transactionId, appReceipt: appReceipt(), raw: {} };
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
// In-flight resolvers, keyed so the single queue observer can route callbacks.
|
|
72
|
-
const purchaseWaiters = new Map<string, { resolve: (r: any) => void; reject: (e: any) => void }>();
|
|
73
|
-
let restoreWaiter: { receipts: any[]; resolve: (r: any[]) => void; reject: (e: any) => void } | null = null;
|
|
74
|
-
const productRequests = new Set<{ request: SKProductsRequest; delegate: any }>(); // keep strong refs to in-flight requests + delegates
|
|
75
|
-
let observer: any = null;
|
|
76
|
-
|
|
77
|
-
// iOS-only StoreKit delegates. Defined lazily inside an isIOS-gated factory (mirrors banner.ts) so the
|
|
78
|
-
// shared module can instantiate on Android — NSObject/SK* are iOS globals, and a top-level
|
|
79
|
-
// `extends NSObject` would evaluate at ES-module load and crash the Android shell.
|
|
80
|
-
// any: module-level holders for runtime-built ObjC subclasses; each class BODY stays fully typed.
|
|
81
|
-
let PaymentObserver: any, ProductsDelegate: any;
|
|
82
|
-
function ensureIosDelegates(): void {
|
|
83
|
-
if (!isIOS || PaymentObserver) return;
|
|
84
|
-
|
|
85
|
-
// SKPaymentQueue transaction observer. State (purchaseWaiters/restoreWaiter) lives at module
|
|
86
|
-
// scope, so the delegate needs no instance fields.
|
|
87
|
-
@NativeClass()
|
|
88
|
-
class PaymentObserverImpl extends NSObject implements SKPaymentTransactionObserver {
|
|
89
|
-
static ObjCProtocols = [SKPaymentTransactionObserver];
|
|
90
|
-
static new(): PaymentObserverImpl {
|
|
91
|
-
return <PaymentObserverImpl>super.new();
|
|
92
|
-
}
|
|
93
|
-
paymentQueueUpdatedTransactions(queue: SKPaymentQueue, transactions: NSArray<SKPaymentTransaction>): void {
|
|
94
|
-
const n = transactions.count;
|
|
95
|
-
for (let i = 0; i < n; i++) {
|
|
96
|
-
const t = transactions.objectAtIndex(i);
|
|
97
|
-
const pid = String(t.payment.productIdentifier);
|
|
98
|
-
switch (t.transactionState) {
|
|
99
|
-
case STATE.Purchased: {
|
|
100
|
-
queue.finishTransaction(t);
|
|
101
|
-
const r = receiptFor(pid, String(t.transactionIdentifier ?? ''));
|
|
102
|
-
bridge.emit('billing.transaction', r);
|
|
103
|
-
purchaseWaiters.get(pid)?.resolve(r);
|
|
104
|
-
purchaseWaiters.delete(pid);
|
|
105
|
-
break;
|
|
106
|
-
}
|
|
107
|
-
case STATE.Restored: {
|
|
108
|
-
queue.finishTransaction(t);
|
|
109
|
-
const r = receiptFor(pid, String(t.transactionIdentifier ?? ''));
|
|
110
|
-
if (restoreWaiter) restoreWaiter.receipts.push(r);
|
|
111
|
-
bridge.emit('billing.transaction', r);
|
|
112
|
-
break;
|
|
113
|
-
}
|
|
114
|
-
case STATE.Failed: {
|
|
115
|
-
queue.finishTransaction(t);
|
|
116
|
-
// 'DENIED' here means exactly ONE thing: the user dismissed the sheet. Anything the
|
|
117
|
-
// device refuses outright (payments restricted) is 'UNSUPPORTED' — see billing.purchase.
|
|
118
|
-
const cancelled = t.error && t.error.code === SK_ERR_CANCELLED;
|
|
119
|
-
const e = err(cancelled ? 'DENIED' : 'NATIVE_ERROR', t.error?.localizedDescription ?? 'Purchase failed');
|
|
120
|
-
purchaseWaiters.get(pid)?.reject(e);
|
|
121
|
-
purchaseWaiters.delete(pid);
|
|
122
|
-
break;
|
|
123
|
-
}
|
|
124
|
-
// Purchasing / Deferred: nothing to do.
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
paymentQueueRestoreCompletedTransactionsFinished(_queue: SKPaymentQueue): void {
|
|
129
|
-
restoreWaiter?.resolve(restoreWaiter.receipts);
|
|
130
|
-
restoreWaiter = null;
|
|
131
|
-
}
|
|
132
|
-
paymentQueueRestoreCompletedTransactionsFailedWithError(_queue: SKPaymentQueue, error: NSError): void {
|
|
133
|
-
restoreWaiter?.reject(err('NATIVE_ERROR', error?.localizedDescription ?? 'Restore failed'));
|
|
134
|
-
restoreWaiter = null;
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
PaymentObserver = PaymentObserverImpl;
|
|
138
|
-
|
|
139
|
-
// SKProductsRequest delegate. Closure-captured state (resolve/reject + the strong-ref holder)
|
|
140
|
-
// is held as instance fields, assigned after new().
|
|
141
|
-
@NativeClass()
|
|
142
|
-
class ProductsDelegateImpl extends NSObject implements SKProductsRequestDelegate {
|
|
143
|
-
static ObjCProtocols = [SKProductsRequestDelegate];
|
|
144
|
-
onResolve?: (products: SKProduct[]) => void;
|
|
145
|
-
onReject?: (e: Error) => void;
|
|
146
|
-
holder?: { request: SKProductsRequest; delegate: any };
|
|
147
|
-
static new(): ProductsDelegateImpl {
|
|
148
|
-
return <ProductsDelegateImpl>super.new();
|
|
149
|
-
}
|
|
150
|
-
productsRequestDidReceiveResponse(_request: SKProductsRequest, response: SKProductsResponse): void {
|
|
151
|
-
const out: SKProduct[] = [];
|
|
152
|
-
const products = response.products;
|
|
153
|
-
for (let i = 0; i < products.count; i++) out.push(products.objectAtIndex(i));
|
|
154
|
-
if (this.holder) productRequests.delete(this.holder);
|
|
155
|
-
this.onResolve?.(out);
|
|
156
|
-
}
|
|
157
|
-
requestDidFailWithError(_request: SKRequest, error: NSError): void {
|
|
158
|
-
if (this.holder) productRequests.delete(this.holder);
|
|
159
|
-
this.onReject?.(err('NATIVE_ERROR', error?.localizedDescription ?? 'Products request failed'));
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
ProductsDelegate = ProductsDelegateImpl;
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
function buildObserver(): any {
|
|
166
|
-
return PaymentObserver.new();
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
/** One SKProductsRequest → resolves the raw SKProduct objects (used by both products + purchase). */
|
|
170
|
-
function requestSKProducts(ids: string[]): Promise<SKProduct[]> {
|
|
171
|
-
return new Promise((resolve, reject) => {
|
|
172
|
-
const set = NSSet.setWithArray<string>(ids);
|
|
173
|
-
const request = SKProductsRequest.alloc().initWithProductIdentifiers(set);
|
|
174
|
-
const delegate = ProductsDelegate.new();
|
|
175
|
-
const holder = { request, delegate };
|
|
176
|
-
delegate.onResolve = resolve;
|
|
177
|
-
delegate.onReject = reject;
|
|
178
|
-
delegate.holder = holder;
|
|
179
|
-
productRequests.add(holder); // strong ref until the callback fires
|
|
180
|
-
request.delegate = delegate;
|
|
181
|
-
request.start();
|
|
182
|
-
});
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
export function registerBillingHandlers(): void {
|
|
186
|
-
if (isIOS) registerIosBilling();
|
|
187
|
-
else if (isAndroid) registerAndroidBilling();
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
function registerIosBilling(): void {
|
|
191
|
-
ensureIosDelegates();
|
|
192
|
-
observer = buildObserver();
|
|
193
|
-
SKPaymentQueue.defaultQueue().addTransactionObserver(observer);
|
|
194
|
-
|
|
195
|
-
bridge.register('billing.products', async ({ ids = [] }: { ids: string[] }) => {
|
|
196
|
-
if (!ids.length) return [];
|
|
197
|
-
return (await requestSKProducts(ids)).map(mapProduct);
|
|
198
|
-
});
|
|
199
|
-
|
|
200
|
-
bridge.register('billing.purchase', async ({ productId }: { productId: string }) => {
|
|
201
|
-
// 'UNSUPPORTED', not 'DENIED': this device CANNOT pay at all (Screen Time / parental controls
|
|
202
|
-
// / MDM restriction) — a standing condition retrying will never clear. 'DENIED' is reserved for
|
|
203
|
-
// the user dismissing the StoreKit sheet (SKErrorPaymentCancelled, below), which is a per-attempt
|
|
204
|
-
// choice. Conflating them made consumers silently swallow this as a cancel and strand the user.
|
|
205
|
-
if (!SKPaymentQueue.canMakePayments()) throw err('UNSUPPORTED', 'Purchases are disabled on this device');
|
|
206
|
-
const sk = (await requestSKProducts([productId]))[0];
|
|
207
|
-
if (!sk) throw err('NATIVE_ERROR', `Unknown product: ${productId}`);
|
|
208
|
-
return new Promise((resolve, reject) => {
|
|
209
|
-
purchaseWaiters.set(productId, { resolve, reject });
|
|
210
|
-
SKPaymentQueue.defaultQueue().addPayment(SKPayment.paymentWithProduct(sk));
|
|
211
|
-
});
|
|
212
|
-
});
|
|
213
|
-
|
|
214
|
-
bridge.register('billing.restore', () => restoreTransactions());
|
|
215
|
-
|
|
216
|
-
// The bundle's App Store receipt, read straight from disk — NO Apple-ID prompt and no
|
|
217
|
-
// store round-trip (unlike restore). For an in-place app update it already holds the
|
|
218
|
-
// user's active/grandfathered subscriptions, so a server validator can confirm
|
|
219
|
-
// entitlements silently. This is the zero-user-action path for migrating existing
|
|
220
|
-
// subscribers into a new build of the SAME bundle id. Empty string if absent (e.g. a
|
|
221
|
-
// dev build with no receipt) — the caller falls back to restore().
|
|
222
|
-
bridge.register('billing.appReceipt', async () => ({ platform: 'ios', appReceipt: appReceipt() }));
|
|
223
|
-
|
|
224
|
-
// Client-trusted entitlements: StoreKit 1 has no on-device entitlement list, so we
|
|
225
|
-
// derive non-consumed/active products from a restore. Real apps plug in a server
|
|
226
|
-
// validator (see kit.billing.configure) instead of trusting this.
|
|
227
|
-
bridge.register('billing.entitlements', async () => {
|
|
228
|
-
const receipts = await restoreTransactions();
|
|
229
|
-
const seen = new Set<string>();
|
|
230
|
-
const ents: any[] = [];
|
|
231
|
-
for (const r of receipts) {
|
|
232
|
-
if (seen.has(r.productId)) continue;
|
|
233
|
-
seen.add(r.productId);
|
|
234
|
-
ents.push({ productId: r.productId, active: true });
|
|
235
|
-
}
|
|
236
|
-
return ents;
|
|
237
|
-
});
|
|
238
|
-
|
|
239
|
-
bridge.register('billing.manageSubscriptions', () => {
|
|
240
|
-
// DEFAULT: StoreKit 1 deep-link — correct for production, but leaves the app and doesn't show
|
|
241
|
-
// sandbox/TestFlight subs. The in-app sheet is the opt-in `billing.manageSubscriptionsSheet` below.
|
|
242
|
-
Utils.openUrl('itms-apps://apps.apple.com/account/subscriptions');
|
|
243
|
-
});
|
|
244
|
-
|
|
245
|
-
// OPT-IN (iOS 15+): StoreKit 2's in-app management sheet via the AppwrapManageSubscriptions Swift
|
|
246
|
-
// shim — shows the user's subs (incl. sandbox/TestFlight) and lets them cancel without leaving the
|
|
247
|
-
// app. Reached only when the app calls kit.billing.showManageSubscriptionsSheet(), so the default
|
|
248
|
-
// deep-link path above is untouched. Rejects on iOS <15, no scene, or a StoreKit error.
|
|
249
|
-
bridge.register('billing.manageSubscriptionsSheet', () => {
|
|
250
|
-
return new Promise<void>((resolve, reject) => {
|
|
251
|
-
AppwrapManageSubscriptions.present((message: string | null) => {
|
|
252
|
-
if (SHELL_CONFIG.debug) appwrapNativeLog(`[native:lifecycle] manageSubscriptionsSheet done err=${message ?? 'none'}`);
|
|
253
|
-
// The sheet dismisses WITHOUT the app scene leaving foregroundActive → no resumeEvent, and it
|
|
254
|
-
// orphans a touch-stealing window above ours. Recover from this completion (the only callback
|
|
255
|
-
// that fires). Shared across all native surfaces; see CustomWebView.recoverAfterNativeSurface.
|
|
256
|
-
// NOTE: redundant with the global UIWindowDidBecomeHidden observer (armNativeSurfaceRecovery in
|
|
257
|
-
// main-page.ts) — kept as the proven baseline; remove the per-handler calls once that observer
|
|
258
|
-
// is device-verified to fire on this dismiss. Double-recovery is safe (coalesced + no-op clean).
|
|
259
|
-
bridge.getWebView()?.recoverAfterNativeSurface();
|
|
260
|
-
if (message) reject(err('NATIVE_ERROR', message));
|
|
261
|
-
else resolve();
|
|
262
|
-
});
|
|
263
|
-
});
|
|
264
|
-
});
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
function restoreTransactions(): Promise<any[]> {
|
|
268
|
-
if (restoreWaiter) return Promise.reject(err('NOT_READY', 'A restore is already in progress'));
|
|
269
|
-
return new Promise((resolve, reject) => {
|
|
270
|
-
restoreWaiter = { receipts: [], resolve, reject };
|
|
271
|
-
SKPaymentQueue.defaultQueue().restoreCompletedTransactions();
|
|
272
|
-
});
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
// ─────────────────────────── Android — Google Play Billing ───────────────────────────
|
|
276
|
-
// Play Billing Library v7. All com.android.billingclient.* access is lazy (inside handler bodies)
|
|
277
|
-
// so the shared module loads on iOS and a non-billing Android build never links the class.
|
|
278
|
-
//
|
|
279
|
-
// ACKNOWLEDGEMENT DECISION (money-critical): Play auto-refunds any purchase not acknowledged within
|
|
280
|
-
// 3 days. We acknowledge CLIENT-SIDE here (BillingClient.acknowledgePurchase) immediately after a
|
|
281
|
-
// PURCHASED update and on restore — the simplest correct choice, since this handler owns the
|
|
282
|
-
// BillingClient. The AGF server validator (PlayStoreTokenValidator) MAY also acknowledge via the
|
|
283
|
-
// Play Developer API; a second acknowledge is a harmless no-op on Google's side (idempotent), so
|
|
284
|
-
// the two can coexist. Tradeoff: we ack before the server confirms the grant, so a failed server
|
|
285
|
-
// validation still consumes the purchase (no auto-refund) — acceptable because the server is the
|
|
286
|
-
// source-of-record and re-validates the token on demand. If the server takes over acknowledgement
|
|
287
|
-
// exclusively, delete acknowledgeAndroidPurchase() calls here.
|
|
288
|
-
|
|
289
|
-
// BillingResponseCode (com.android.billingclient.api.BillingClient.BillingResponseCode).
|
|
290
|
-
const PLAY_RESP = {
|
|
291
|
-
OK: 0, USER_CANCELED: 1, SERVICE_UNAVAILABLE: 2, BILLING_UNAVAILABLE: 3, ITEM_UNAVAILABLE: 4,
|
|
292
|
-
DEVELOPER_ERROR: 5, ERROR: 6, ITEM_ALREADY_OWNED: 7, FEATURE_NOT_SUPPORTED: -2, SERVICE_DISCONNECTED: -1,
|
|
293
|
-
};
|
|
294
|
-
const PURCHASE_STATE_PURCHASED = 1; // com.android.billingclient.api.Purchase.PurchaseState.PURCHASED
|
|
295
|
-
|
|
296
|
-
let billingClient: any = null; // com.android.billingclient.api.BillingClient
|
|
297
|
-
let clientConnecting: Promise<any> | null = null;
|
|
298
|
-
// Purchases arrive asynchronously via the single PurchasesUpdatedListener; route them to the
|
|
299
|
-
// in-flight purchase() caller by product id (module scope, like the iOS purchaseWaiters).
|
|
300
|
-
const androidPurchaseWaiters = new Map<string, { resolve: (r: any) => void; reject: (e: any) => void }>();
|
|
301
|
-
// Single-purchase gate: with only one in-flight purchase, "reject all on error" (which lacks product
|
|
302
|
-
// context) can never reject a DIFFERENT caller's promise. Set synchronously in billing.purchase.
|
|
303
|
-
let androidPurchaseInFlight = false;
|
|
304
|
-
|
|
305
|
-
function billingApi(): any {
|
|
306
|
-
return com.android.billingclient.api;
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
function packageName(): string {
|
|
310
|
-
return String(Utils.android.getApplicationContext().getPackageName());
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
/** Java List<T> from a JS array (Play Billing builders take java.util.List, not a marshaled JS array). */
|
|
314
|
-
function toJavaList(items: any[]): any {
|
|
315
|
-
const list = new java.util.ArrayList();
|
|
316
|
-
for (const it of items) list.add(it);
|
|
317
|
-
return list;
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
/** Map a Play BillingResult to a bridge error, mirroring the iOS cancel/unsupported semantics. */
|
|
321
|
-
function mapBillingErr(result: any): Error {
|
|
322
|
-
const code = result?.getResponseCode?.();
|
|
323
|
-
const msg = String(result?.getDebugMessage?.() ?? 'Play Billing error');
|
|
324
|
-
if (code === PLAY_RESP.USER_CANCELED) return err('DENIED', 'Purchase cancelled');
|
|
325
|
-
if (code === PLAY_RESP.BILLING_UNAVAILABLE || code === PLAY_RESP.FEATURE_NOT_SUPPORTED)
|
|
326
|
-
return err('UNSUPPORTED', msg || 'Billing unavailable on this device');
|
|
327
|
-
return err('NATIVE_ERROR', `${msg} (code ${code})`);
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
/** Lazily build + connect the BillingClient. Resolves when the service is READY. */
|
|
331
|
-
function ensureAndroidClient(): Promise<any> {
|
|
332
|
-
if (billingClient && billingClient.isReady()) return Promise.resolve(billingClient);
|
|
333
|
-
if (clientConnecting) return clientConnecting;
|
|
334
|
-
|
|
335
|
-
clientConnecting = new Promise((resolve, reject) => {
|
|
336
|
-
const api = billingApi();
|
|
337
|
-
const ctx = Utils.android.getApplicationContext();
|
|
338
|
-
// v7 requires PendingPurchasesParams; enableOneTimeProducts() is harmless for a subs-only app
|
|
339
|
-
// (pending purchases are always enabled for subscriptions).
|
|
340
|
-
const pending = api.PendingPurchasesParams.newBuilder().enableOneTimeProducts().build();
|
|
341
|
-
const listener = new api.PurchasesUpdatedListener({
|
|
342
|
-
onPurchasesUpdated(result: any, purchases: any) {
|
|
343
|
-
onAndroidPurchasesUpdated(result, purchases);
|
|
344
|
-
},
|
|
345
|
-
});
|
|
346
|
-
billingClient = api.BillingClient.newBuilder(ctx)
|
|
347
|
-
.setListener(listener)
|
|
348
|
-
.enablePendingPurchases(pending)
|
|
349
|
-
.build();
|
|
350
|
-
billingClient.startConnection(new api.BillingClientStateListener({
|
|
351
|
-
onBillingSetupFinished(result: any) {
|
|
352
|
-
if (result.getResponseCode() === PLAY_RESP.OK) resolve(billingClient);
|
|
353
|
-
else reject(mapBillingErr(result));
|
|
354
|
-
},
|
|
355
|
-
onBillingServiceDisconnected() {
|
|
356
|
-
// Force a fresh connection on the next call (Google recommends reconnect-on-demand).
|
|
357
|
-
clientConnecting = null;
|
|
358
|
-
},
|
|
359
|
-
}));
|
|
360
|
-
}).catch((e) => {
|
|
361
|
-
clientConnecting = null;
|
|
362
|
-
throw e;
|
|
363
|
-
});
|
|
364
|
-
return clientConnecting;
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
/** Query one product's ProductDetails (SUBS). */
|
|
368
|
-
function queryAndroidProductDetails(ids: string[]): Promise<any[]> {
|
|
369
|
-
return ensureAndroidClient().then((client) => new Promise<any[]>((resolve, reject) => {
|
|
370
|
-
const api = billingApi();
|
|
371
|
-
const products = ids.map((id) =>
|
|
372
|
-
api.QueryProductDetailsParams.Product.newBuilder()
|
|
373
|
-
.setProductId(id)
|
|
374
|
-
.setProductType(api.BillingClient.ProductType.SUBS)
|
|
375
|
-
.build());
|
|
376
|
-
const params = api.QueryProductDetailsParams.newBuilder().setProductList(toJavaList(products)).build();
|
|
377
|
-
client.queryProductDetailsAsync(params, new api.ProductDetailsResponseListener({
|
|
378
|
-
onProductDetailsResponse(result: any, list: any) {
|
|
379
|
-
if (result.getResponseCode() !== PLAY_RESP.OK) return reject(mapBillingErr(result));
|
|
380
|
-
const out: any[] = [];
|
|
381
|
-
const n = list ? list.size() : 0;
|
|
382
|
-
for (let i = 0; i < n; i++) out.push(list.get(i));
|
|
383
|
-
resolve(out);
|
|
384
|
-
},
|
|
385
|
-
}));
|
|
386
|
-
}));
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
/** Flatten a ProductDetails' SubscriptionOfferDetails into pure {offerId, phases} + a ref to the
|
|
390
|
-
* native offer (so the caller can read its token / native phases after selection). */
|
|
391
|
-
function androidOfferCandidates(pd: any): Array<OfferCandidate & { native: any }> {
|
|
392
|
-
const offers = pd.getSubscriptionOfferDetails();
|
|
393
|
-
const out: Array<OfferCandidate & { native: any }> = [];
|
|
394
|
-
const n = offers ? offers.size() : 0;
|
|
395
|
-
for (let i = 0; i < n; i++) {
|
|
396
|
-
const o = offers.get(i);
|
|
397
|
-
const list = o.getPricingPhases().getPricingPhaseList();
|
|
398
|
-
const phases = [];
|
|
399
|
-
for (let j = 0; j < list.size(); j++) {
|
|
400
|
-
const ph = list.get(j);
|
|
401
|
-
phases.push({
|
|
402
|
-
priceAmountMicros: Number(ph.getPriceAmountMicros()),
|
|
403
|
-
billingPeriod: String(ph.getBillingPeriod() ?? ''),
|
|
404
|
-
});
|
|
405
|
-
}
|
|
406
|
-
const id = o.getOfferId ? o.getOfferId() : null;
|
|
407
|
-
out.push({ offerId: id != null ? String(id) : null, phases, native: o });
|
|
408
|
-
}
|
|
409
|
-
return out;
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
/** First native pricing phase whose micros satisfy `pred`. */
|
|
413
|
-
function pickNativePhase(list: any, pred: (micros: number) => boolean): any {
|
|
414
|
-
for (let i = 0; i < list.size(); i++) {
|
|
415
|
-
const p = list.get(i);
|
|
416
|
-
if (pred(Number(p.getPriceAmountMicros()))) return p;
|
|
417
|
-
}
|
|
418
|
-
return null;
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
/** ProductDetails → the platform-neutral Product shape, from the SELECTED offer (trial-aware). */
|
|
422
|
-
function mapAndroidProduct(pd: any): any {
|
|
423
|
-
const selected = selectSubscriptionOffer(androidOfferCandidates(pd));
|
|
424
|
-
if (!selected) return null; // not a subscription / no offer
|
|
425
|
-
const list = selected.native.getPricingPhases().getPricingPhaseList();
|
|
426
|
-
// Recurring (paid) phase for the display price; fall back to the last phase.
|
|
427
|
-
const phase = pickNativePhase(list, (m) => m > 0) ?? list.get(list.size() - 1);
|
|
428
|
-
const period = String(phase.getBillingPeriod() ?? '');
|
|
429
|
-
const product: any = {
|
|
430
|
-
id: String(pd.getProductId()),
|
|
431
|
-
title: String(pd.getTitle() ?? pd.getName() ?? ''),
|
|
432
|
-
description: String(pd.getDescription() ?? ''),
|
|
433
|
-
price: Number(phase.getPriceAmountMicros()) / 1_000_000,
|
|
434
|
-
displayPrice: String(phase.getFormattedPrice() ?? ''),
|
|
435
|
-
currency: String(phase.getPriceCurrencyCode() ?? ''),
|
|
436
|
-
type: 'autoRenewable',
|
|
437
|
-
subscriptionPeriod: period || undefined,
|
|
438
|
-
};
|
|
439
|
-
// Surface the free-trial / intro phase so the UI can show trial terms (Play also shows them in its
|
|
440
|
-
// own sheet). Populated only when the selected offer carries a zero-price phase (trial-eligible).
|
|
441
|
-
const intro = pickNativePhase(list, (m) => m === 0);
|
|
442
|
-
if (intro) {
|
|
443
|
-
const introPeriod = String(intro.getBillingPeriod() ?? '');
|
|
444
|
-
product.introOffer = {
|
|
445
|
-
price: Number(intro.getPriceAmountMicros()) / 1_000_000,
|
|
446
|
-
displayPrice: String(intro.getFormattedPrice() ?? ''),
|
|
447
|
-
period: introPeriod || undefined,
|
|
448
|
-
isFreeTrial: Number(intro.getPriceAmountMicros()) === 0,
|
|
449
|
-
};
|
|
450
|
-
}
|
|
451
|
-
return product;
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
/** Play Purchase → PurchaseReceipt (the token is the critical field the server validator consumes). */
|
|
455
|
-
function androidReceiptFor(purchase: any, productId: string): any {
|
|
456
|
-
return {
|
|
457
|
-
platform: 'android',
|
|
458
|
-
productId,
|
|
459
|
-
transactionId: String(purchase.getOrderId?.() ?? ''),
|
|
460
|
-
purchaseToken: String(purchase.getPurchaseToken()),
|
|
461
|
-
packageName: String(purchase.getPackageName?.() ?? packageName()),
|
|
462
|
-
raw: {
|
|
463
|
-
originalJson: String(purchase.getOriginalJson?.() ?? ''),
|
|
464
|
-
signature: String(purchase.getSignature?.() ?? ''),
|
|
465
|
-
},
|
|
466
|
-
};
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
/** Client-side acknowledge (see ACKNOWLEDGEMENT DECISION above). Fire-and-forget; logs on failure. */
|
|
470
|
-
function acknowledgeAndroidPurchase(purchase: any): void {
|
|
471
|
-
try {
|
|
472
|
-
if (purchase.isAcknowledged()) return;
|
|
473
|
-
const api = billingApi();
|
|
474
|
-
const params = api.AcknowledgePurchaseParams.newBuilder()
|
|
475
|
-
.setPurchaseToken(purchase.getPurchaseToken())
|
|
476
|
-
.build();
|
|
477
|
-
billingClient.acknowledgePurchase(params, new api.AcknowledgePurchaseResponseListener({
|
|
478
|
-
onAcknowledgePurchaseResponse(result: any) {
|
|
479
|
-
if (result.getResponseCode() !== PLAY_RESP.OK) {
|
|
480
|
-
console.warn('AppWrap: billing acknowledge failed', result.getResponseCode(), String(result.getDebugMessage?.() ?? ''));
|
|
481
|
-
}
|
|
482
|
-
},
|
|
483
|
-
}));
|
|
484
|
-
} catch (e) {
|
|
485
|
-
console.warn('AppWrap: billing acknowledge threw', e);
|
|
486
|
-
}
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
/** The single PurchasesUpdatedListener callback — routes results to the purchase() waiter(s). */
|
|
490
|
-
function onAndroidPurchasesUpdated(result: any, purchases: any): void {
|
|
491
|
-
const code = result.getResponseCode();
|
|
492
|
-
if (code !== PLAY_RESP.OK || !purchases) {
|
|
493
|
-
// No product context here (Play doesn't say which flow) → reject all in-flight purchases with the
|
|
494
|
-
// mapped error (USER_CANCELED → DENIED, mirroring iOS' cancel semantics).
|
|
495
|
-
const e = mapBillingErr(result);
|
|
496
|
-
for (const w of androidPurchaseWaiters.values()) w.reject(e);
|
|
497
|
-
androidPurchaseWaiters.clear();
|
|
498
|
-
return;
|
|
499
|
-
}
|
|
500
|
-
const n = purchases.size();
|
|
501
|
-
for (let i = 0; i < n; i++) {
|
|
502
|
-
const purchase = purchases.get(i);
|
|
503
|
-
// PENDING (cash/bank/parental-approval) → don't resolve/ack/grant now. This NATIVE waiter has NO
|
|
504
|
-
// timeout; the JS `invoke` rejects at 120s, so by the time Play flips PENDING→PURCHASED (hours/
|
|
505
|
-
// days later) the purchase() promise is long gone. The completion is recoverable ONLY via the
|
|
506
|
-
// billing.transaction event emitted below when this listener fires again with PURCHASED — NOT via
|
|
507
|
-
// the original purchase() promise. Do NOT ack or grant a PENDING purchase.
|
|
508
|
-
if (purchase.getPurchaseState() !== PURCHASE_STATE_PURCHASED) continue;
|
|
509
|
-
acknowledgeAndroidPurchase(purchase);
|
|
510
|
-
const prods = purchase.getProducts();
|
|
511
|
-
const pid = prods && prods.size() > 0 ? String(prods.get(0)) : '';
|
|
512
|
-
const receipt = androidReceiptFor(purchase, pid);
|
|
513
|
-
bridge.emit('billing.transaction', receipt);
|
|
514
|
-
const w = androidPurchaseWaiters.get(pid);
|
|
515
|
-
if (w) { w.resolve(receipt); androidPurchaseWaiters.delete(pid); }
|
|
516
|
-
}
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
/** queryPurchasesAsync(SUBS) → active PurchaseReceipts (used by restore / appReceipt / entitlements). */
|
|
520
|
-
function androidActivePurchases(): Promise<any[]> {
|
|
521
|
-
return ensureAndroidClient().then((client) => new Promise<any[]>((resolve, reject) => {
|
|
522
|
-
const api = billingApi();
|
|
523
|
-
const params = api.QueryPurchasesParams.newBuilder().setProductType(api.BillingClient.ProductType.SUBS).build();
|
|
524
|
-
client.queryPurchasesAsync(params, new api.PurchasesResponseListener({
|
|
525
|
-
onQueryPurchasesResponse(result: any, list: any) {
|
|
526
|
-
if (result.getResponseCode() !== PLAY_RESP.OK) return reject(mapBillingErr(result));
|
|
527
|
-
const receipts: any[] = [];
|
|
528
|
-
const n = list ? list.size() : 0;
|
|
529
|
-
for (let i = 0; i < n; i++) {
|
|
530
|
-
const p = list.get(i);
|
|
531
|
-
if (p.getPurchaseState() !== PURCHASE_STATE_PURCHASED) continue;
|
|
532
|
-
acknowledgeAndroidPurchase(p); // ack any not-yet-acked active sub (in-place migration safety)
|
|
533
|
-
const prods = p.getProducts();
|
|
534
|
-
const pid = prods && prods.size() > 0 ? String(prods.get(0)) : '';
|
|
535
|
-
receipts.push(androidReceiptFor(p, pid));
|
|
536
|
-
}
|
|
537
|
-
resolve(receipts);
|
|
538
|
-
},
|
|
539
|
-
}));
|
|
540
|
-
}));
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
function registerAndroidBilling(): void {
|
|
544
|
-
bridge.register('billing.products', async ({ ids = [] }: { ids: string[] }) => {
|
|
545
|
-
if (!ids.length) return [];
|
|
546
|
-
return (await queryAndroidProductDetails(ids)).map(mapAndroidProduct).filter(Boolean);
|
|
547
|
-
});
|
|
548
|
-
|
|
549
|
-
bridge.register('billing.purchase', async ({ productId }: { productId: string }) => {
|
|
550
|
-
// SERIALIZE: Play's PurchasesUpdatedListener gives NO product context on error, so a single
|
|
551
|
-
// in-flight purchase is the only way "reject all on error" can't cross-talk between callers.
|
|
552
|
-
// The app is single-product; a concurrent second purchase() is rejected outright, guaranteeing
|
|
553
|
-
// there is only ever ONE entry in androidPurchaseWaiters. Guard is set SYNCHRONOUSLY (before any
|
|
554
|
-
// await) so two near-simultaneous calls can't both pass it.
|
|
555
|
-
if (androidPurchaseInFlight) throw err('NATIVE_ERROR', 'A purchase is already in progress');
|
|
556
|
-
androidPurchaseInFlight = true;
|
|
557
|
-
try {
|
|
558
|
-
const client = await ensureAndroidClient();
|
|
559
|
-
const pd = (await queryAndroidProductDetails([productId]))[0];
|
|
560
|
-
if (!pd) throw err('NATIVE_ERROR', `Unknown product: ${productId}`);
|
|
561
|
-
const selected = selectSubscriptionOffer(androidOfferCandidates(pd));
|
|
562
|
-
if (!selected) throw err('NATIVE_ERROR', `No subscription offer for ${productId}`);
|
|
563
|
-
// Trial-aware: the SELECTED offer's token carries the free trial when the user is eligible.
|
|
564
|
-
const offerToken = selected.native.getOfferToken();
|
|
565
|
-
const api = billingApi();
|
|
566
|
-
const pdParams = api.BillingFlowParams.ProductDetailsParams.newBuilder()
|
|
567
|
-
.setProductDetails(pd)
|
|
568
|
-
.setOfferToken(offerToken)
|
|
569
|
-
.build();
|
|
570
|
-
const flowParams = api.BillingFlowParams.newBuilder()
|
|
571
|
-
.setProductDetailsParamsList(toJavaList([pdParams]))
|
|
572
|
-
.build();
|
|
573
|
-
return await new Promise((resolve, reject) => {
|
|
574
|
-
androidPurchaseWaiters.set(productId, { resolve, reject });
|
|
575
|
-
Utils.dispatchToMainThread(() => {
|
|
576
|
-
const activity = Application.android.foregroundActivity ?? Application.android.startActivity;
|
|
577
|
-
if (!activity) {
|
|
578
|
-
androidPurchaseWaiters.delete(productId);
|
|
579
|
-
reject(err('NOT_READY', 'no foreground activity'));
|
|
580
|
-
return;
|
|
581
|
-
}
|
|
582
|
-
// launchBillingFlow's synchronous result reports only launch failures; the purchase itself
|
|
583
|
-
// resolves later via onAndroidPurchasesUpdated → the waiter above.
|
|
584
|
-
const launch = client.launchBillingFlow(activity, flowParams);
|
|
585
|
-
if (launch.getResponseCode() !== PLAY_RESP.OK) {
|
|
586
|
-
androidPurchaseWaiters.delete(productId);
|
|
587
|
-
reject(mapBillingErr(launch));
|
|
588
|
-
}
|
|
589
|
-
});
|
|
590
|
-
});
|
|
591
|
-
} finally {
|
|
592
|
-
// Runs when the purchase settles (resolve/reject) or on any setup throw — re-opening the gate.
|
|
593
|
-
androidPurchaseInFlight = false;
|
|
594
|
-
}
|
|
595
|
-
});
|
|
596
|
-
|
|
597
|
-
bridge.register('billing.restore', () => androidActivePurchases());
|
|
598
|
-
|
|
599
|
-
// Android has no StoreKit-1-style on-disk app receipt; the analog is the most recent active sub's
|
|
600
|
-
// purchaseToken (empty when none). Mirrors iOS' { platform:'ios', appReceipt }.
|
|
601
|
-
bridge.register('billing.appReceipt', async () => {
|
|
602
|
-
const receipts = await androidActivePurchases();
|
|
603
|
-
return { platform: 'android', purchaseToken: receipts[0]?.purchaseToken };
|
|
604
|
-
});
|
|
605
|
-
|
|
606
|
-
// Thin, device-derived entitlements (server is the source-of-record — same posture as iOS).
|
|
607
|
-
bridge.register('billing.entitlements', async () => {
|
|
608
|
-
const receipts = await androidActivePurchases();
|
|
609
|
-
const seen = new Set<string>();
|
|
610
|
-
const ents: any[] = [];
|
|
611
|
-
for (const r of receipts) {
|
|
612
|
-
if (!r.productId || seen.has(r.productId)) continue;
|
|
613
|
-
seen.add(r.productId);
|
|
614
|
-
ents.push({ productId: r.productId, active: true });
|
|
615
|
-
}
|
|
616
|
-
return ents;
|
|
617
|
-
});
|
|
618
|
-
|
|
619
|
-
// Android has no in-app management sheet; the Play subscriptions deep link is the analog for BOTH
|
|
620
|
-
// manageSubscriptions() and showManageSubscriptionsSheet() (the JS layer dispatches the sheet call
|
|
621
|
-
// here now that the capability is 'native' on Android).
|
|
622
|
-
const openManage = ({ productId }: { productId?: string } = {}) => {
|
|
623
|
-
const base = 'https://play.google.com/store/account/subscriptions';
|
|
624
|
-
const url = productId ? `${base}?sku=${encodeURIComponent(productId)}&package=${packageName()}` : base;
|
|
625
|
-
Utils.openUrl(url);
|
|
626
|
-
};
|
|
627
|
-
bridge.register('billing.manageSubscriptions', (p: { productId?: string } = {}) => openManage(p));
|
|
628
|
-
bridge.register('billing.manageSubscriptionsSheet', (p: { productId?: string } = {}) => openManage(p));
|
|
629
|
-
}
|