@livx.cc/appwrap 0.52.0 → 0.54.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livx.cc/appwrap",
3
- "version": "0.52.0",
3
+ "version": "0.54.0",
4
4
  "description": "Wrap any PWA into a native app with native capabilities (appwrap runtime + @livx.cc/native-kit).",
5
5
  "license": "MIT",
6
6
  "author": "Elya Livshitz",
@@ -0,0 +1,54 @@
1
+ // Pure Play-Billing offer selection — NO NativeScript/native deps, so it is unit-testable in
2
+ // isolation from com.android.billingclient.* objects. handlers-billing.ts adapts native
3
+ // SubscriptionOfferDetails into these plain shapes and delegates the CHOICE here.
4
+ //
5
+ // WHY this exists (money/product-critical): getSubscriptionOfferDetails() returns EVERY offer the
6
+ // user is eligible for, in NO guaranteed order. A trial-eligible (first-time) user's list carries
7
+ // BOTH the base-plan offer (offerId==null, a single paid phase) AND a separate free-trial offer
8
+ // (tag "freetrial", two phases: first priceAmountMicros==0, then the base price). A lapsed /
9
+ // ineligible user gets ONLY the base-plan offer. Blindly taking offers.get(0) can drop the
10
+ // MANDATORY free trial. We must pick the trial offer WHEN PRESENT, else the base plan.
11
+
12
+ /** One pricing phase of an offer (only the fields the selection/pricing logic needs). */
13
+ export interface OfferPhase {
14
+ /** Micros of `currency`; 0 marks a free-trial phase. */
15
+ priceAmountMicros: number;
16
+ /** ISO-8601 billing period, e.g. "P1W", "P1M". */
17
+ billingPeriod?: string;
18
+ }
19
+
20
+ /** A single subscription offer, flattened from Play's SubscriptionOfferDetails. */
21
+ export interface OfferCandidate {
22
+ /** null/"" for the base-plan offer; a non-empty id for a promotional offer (trial). */
23
+ offerId: string | null;
24
+ phases: OfferPhase[];
25
+ }
26
+
27
+ /** True when the offer contains a zero-price (free-trial) phase. */
28
+ export function hasFreeTrialPhase(offer: OfferCandidate): boolean {
29
+ return offer.phases.some((p) => p.priceAmountMicros === 0);
30
+ }
31
+
32
+ /**
33
+ * Select the offer to purchase / price from. Prefers the free-trial offer (a zero-price phase) when
34
+ * present, so a trial-eligible user actually gets the trial; otherwise falls back to the base-plan
35
+ * offer (offerId==null), else the first offer. Returns null for an empty list.
36
+ */
37
+ export function selectSubscriptionOffer<T extends OfferCandidate>(offers: readonly T[]): T | null {
38
+ if (!offers || offers.length === 0) return null;
39
+ const trial = offers.find(hasFreeTrialPhase);
40
+ if (trial) return trial;
41
+ const base = offers.find((o) => o.offerId == null || o.offerId === '');
42
+ return base ?? offers[0];
43
+ }
44
+
45
+ /** The recurring paid phase to display (first phase with a price > 0), or the last phase as fallback. */
46
+ export function recurringPhase(offer: OfferCandidate): OfferPhase | undefined {
47
+ const paid = offer.phases.find((p) => p.priceAmountMicros > 0);
48
+ return paid ?? offer.phases[offer.phases.length - 1];
49
+ }
50
+
51
+ /** The intro/free-trial phase (first zero-price phase), if any. */
52
+ export function introPhase(offer: OfferCandidate): OfferPhase | undefined {
53
+ return offer.phases.find((p) => p.priceAmountMicros === 0);
54
+ }
@@ -150,10 +150,14 @@ export const MODULES: ModuleManifest[] = [
150
150
  },
151
151
  {
152
152
  name: 'billing', group: 'billing',
153
- capabilities: { billing: { ios: true, android: false } },
154
- // iOS-only Swift shim (AppwrapManageSubscriptions.swift) bridging StoreKit 2's Swift-async
153
+ capabilities: { billing: { ios: true, android: true } },
154
+ // iOS: Swift shim (AppwrapManageSubscriptions.swift) bridging StoreKit 2's Swift-async
155
155
  // showManageSubscriptions(in:) to an @objc completion the ObjC bridge can call. Compiled into
156
156
  // the app target only when `billing` is active (NS auto-compiles App_Resources/iOS/src/*.swift).
157
+ // Android: Play Billing Library v7 (handlers-billing.ts registerAndroidBilling). The BILLING
158
+ // permission is auto-merged by the library's own manifest, so no android.permissions entry —
159
+ // only the gradle dep is needed, and it lands ONLY when `billing` is active (strippable weight).
160
+ android: { gradleDeps: ['com.android.billingclient:billing:7.1.1'] },
157
161
  nativeSrc: 'billing',
158
162
  },
159
163
 
@@ -94,6 +94,17 @@ export class CustomWebView extends WebView {
94
94
  /** Set by the bridge before load; receives raw envelope JSON. */
95
95
  onAppwrapMessage: ((json: string) => void) | null = null;
96
96
 
97
+ /**
98
+ * A TLS handshake this view REFUSED (onReceivedSslError → handler.cancel()), pending attribution to the
99
+ * navigation it killed. Cancelling an SSL error does NOT route through onReceivedError — Chromium instead
100
+ * swaps in its own error page and reports it via onPageFinished with the ORIGINAL url, which would read as
101
+ * a SUCCESSFUL load (see `pageFinished`). Recorded here so that onPageFinished can tell the two apart.
102
+ * Host-scoped (a cert is per-host, so a same-host sub-resource can't fail while the main frame succeeds),
103
+ * and cleared on any COMMITTED navigation (`pageStarted`) — a refused one never commits, so the record
104
+ * always outlives the failure it describes and never leaks onto the next page.
105
+ */
106
+ pendingSslError: { host: string; reason: string } | null = null;
107
+
97
108
  // STRONG JS references to the native clients. Chromium holds the Java WebViewClient /
98
109
  // WebChromeClient, but NativeScript's mark-and-sweep does NOT treat that native hold as a
99
110
  // GC root — so without a JS-side reference the JS peer gets collected under memory pressure
@@ -416,6 +427,18 @@ function createAssetServingClient(): android.webkit.WebViewClient {
416
427
  };
417
428
 
418
429
  const pageStarted = (view: android.webkit.WebView): void => {
430
+ // A navigation COMMITTED, so any earlier TLS refusal is answered for — drop it (see pendingSslError).
431
+ // A refused nav never reaches here (device-verified on WebView 138: no onPageStarted on an SSL cancel),
432
+ // so this cannot clear the record before `pageFinished` reads it.
433
+ //
434
+ // ⚠️ That is UNDOCUMENTED Chromium behaviour, not a contract. If a future WebView fires onPageStarted
435
+ // for its own error-page commit (i.e. AFTER onReceivedSslError), this clears the record and the
436
+ // swallowed-as-success bug returns SILENTLY. If that regresses, look here first.
437
+ // Redirects are safe for a stronger reason than the above: on http://x → 302 → https://y(bad),
438
+ // onPageStarted(x) fires BEFORE the handshake, so it clears an OLDER record and the refusal is
439
+ // recorded after — clear-before-write ordering holds regardless.
440
+ const owner = CustomWebView.forNative(view);
441
+ if (owner) owner.pendingSslError = null;
419
442
  // Fallback injection — no-op when the document-start scripts already ran
420
443
  view.evaluateJavascript(buildBootstrapJs(), null as unknown as android.webkit.ValueCallback<string>);
421
444
  };
@@ -449,9 +472,33 @@ function createAssetServingClient(): android.webkit.WebViewClient {
449
472
  // firing on a SUCCESSFUL load. A one-shot `loadFinished` listener (env-switcher's reloadToEffective)
450
473
  // would then never unhook on success and would leak onto the NEXT navigation, blaming a later error on
451
474
  // an earlier switch (and naming the earlier switch's host). Both halves or neither.
475
+ //
476
+ // …EXCEPT this callback is ALSO how Chromium reports its OWN error page after a REFUSED TLS handshake
477
+ // (device-verified sequence: onReceivedSslError → handler.cancel() → onPageFinished with the original
478
+ // url, and NO onReceivedError). Forwarding that as a success is what silently swallowed a cert failure
479
+ // into a blank page — and it unhooked the env-switcher's one-shot listener too, so the failure could
480
+ // never be reported at all. Attribute it via the recorded refusal instead (see pendingSslError).
452
481
  const pageFinished = (view: android.webkit.WebView, url: string): void => {
482
+ const owner = CustomWebView.forNative(view);
483
+ const finishedUrl = String(url ?? '');
484
+ const ssl = owner?.pendingSslError;
485
+ if (ssl && ssl.host === hostOf(finishedUrl)) {
486
+ owner!.pendingSslError = null;
487
+ (owner as any)?._onLoadFinished(finishedUrl, ssl.reason);
488
+ return;
489
+ }
453
490
  // Mirrors NS's own `owner._onLoadFinished(url, undefined)` — absent `error` is what marks success.
454
- (CustomWebView.forNative(view) as any)?._onLoadFinished(String(url ?? ''));
491
+ (owner as any)?._onLoadFinished(finishedUrl);
492
+ };
493
+
494
+ /** Android SslError primary codes → why the cert was rejected (SslError.SSL_* ordinals). */
495
+ const SSL_REASONS: Record<number, string> = {
496
+ 0: 'the certificate is not valid yet',
497
+ 1: 'the certificate has expired',
498
+ 2: "the certificate's hostname does not match",
499
+ 3: 'the certificate issuer is not trusted',
500
+ 4: 'the certificate date is invalid',
501
+ 5: 'the certificate is invalid',
455
502
  };
456
503
 
457
504
  // DEBUG-ONLY dev-server cert trust (Android parity with the iOS WKNavigationDelegate). `appwrap dev`
@@ -460,7 +507,13 @@ function createAssetServingClient(): android.webkit.WebViewClient {
460
507
  // Proceed past it ONLY in a debug build, only in server-loader mode, AND only for the ONE host the
461
508
  // build-time host (`SHELL_CONFIG.serverUrl`, NOT the switchable override) — never blanket-trust every host. Production app:// builds
462
509
  // never reach this (local assets, no TLS). NEVER active in a store build (SHELL_CONFIG.debug false).
510
+ //
511
+ // NOT trusting it is a LOAD FAILURE, and Chromium reports it through no other callback — so the cancel
512
+ // path RECORDS the refusal on the owning view for `pageFinished` to raise as `loadFinished(error)`.
513
+ // Without that the whole cert class is invisible to JS (blank page, no feedback) — the exact incident the
514
+ // env-switcher's reload feedback exists to name.
463
515
  const receivedSslError = (
516
+ view: android.webkit.WebView,
464
517
  handler: android.webkit.SslErrorHandler,
465
518
  error: android.net.http.SslError
466
519
  ): void => {
@@ -469,14 +522,20 @@ function createAssetServingClient(): android.webkit.WebViewClient {
469
522
  // Host WITHOUT port, mirroring the iOS DevCertNavDelegate (a dev server on :3000 and its HMR sub-
470
523
  // resources on an alt port share one self-signed cert) — same `hostOf` normalization + port strip.
471
524
  const stripPort = (h: string) => h.replace(/:\d+$/, '');
525
+ const errUrl = String(error?.getUrl?.() ?? '');
472
526
  const allowedHost = stripPort(hostOf(SHELL_CONFIG.serverUrl));
473
- const errHost = stripPort(hostOf(String(error?.getUrl?.() ?? '')));
527
+ const errHost = stripPort(hostOf(errUrl));
474
528
  if (SHELL_CONFIG.debug && SHELL_CONFIG.loader === 'server' && !!allowedHost && errHost === allowedHost) {
475
529
  console.warn('AppWrap: trusting self-signed dev-server cert (debug + host-scoped):', errHost);
476
530
  handler.proceed();
477
- } else {
478
- handler.cancel();
531
+ return;
479
532
  }
533
+ const why = SSL_REASONS[error?.getPrimaryError?.()] ?? 'the certificate was rejected';
534
+ const owner = CustomWebView.forNative(view);
535
+ // Host WITH port here (unlike the trust match above) — this record is matched against the finished
536
+ // url via the same `hostOf`, so both sides must normalize identically.
537
+ if (owner) owner.pendingSslError = { host: hostOf(errUrl), reason: `TLS certificate rejected — ${why}.` };
538
+ handler.cancel();
480
539
  };
481
540
 
482
541
  // TWO literals, selected by loader — NOT one literal with a conditional spread. Both the omission and
@@ -492,8 +551,12 @@ function createAssetServingClient(): android.webkit.WebViewClient {
492
551
  // declared later in the bundle (this cost us a FATAL `LookedUpClassNotFound:
493
552
  // cc.livx.appwrap.AppwrapMessagingService` boot crash that built green). For the same reason don't
494
553
  // hoist the object into a variable and pass `.extend(methods)`: SBG requires an ObjectExpression at
495
- // the call site and silently skips anything else. `appwrap doctor:bindings` (run by the android
496
- // build) now fails the build if a @JavaProxy ever loses its binding again.
554
+ // the call site and silently skips anything else.
555
+ // ⚠️ NOTHING VERIFIES THIS. An earlier version of this comment claimed `appwrap doctor:bindings`
556
+ // (run by the android build) fails the build on a lost @JavaProxy binding — that command does not
557
+ // exist and no test asserts the shape. The requirement is hand-maintained. A lost binding means the
558
+ // callback silently never fires, which reads as "no TLS errors ever" — a green from something that
559
+ // never ran. Either implement the check or keep this warning honest; do not restore the false claim.
497
560
  assetClientClass = SHELL_CONFIG.loader === 'server'
498
561
  ? (android.webkit.WebViewClient as any).extend({
499
562
  shouldOverrideUrlLoading(view: android.webkit.WebView, request: android.webkit.WebResourceRequest | string): boolean {
@@ -505,8 +568,8 @@ function createAssetServingClient(): android.webkit.WebViewClient {
505
568
  onPageFinished(view: android.webkit.WebView, url: string): void {
506
569
  pageFinished(view, url);
507
570
  },
508
- onReceivedSslError(_view: android.webkit.WebView, handler: android.webkit.SslErrorHandler, error: android.net.http.SslError): void {
509
- receivedSslError(handler, error);
571
+ onReceivedSslError(view: android.webkit.WebView, handler: android.webkit.SslErrorHandler, error: android.net.http.SslError): void {
572
+ receivedSslError(view, handler, error);
510
573
  },
511
574
  onReceivedError(view: android.webkit.WebView, request: android.webkit.WebResourceRequest, error: android.webkit.WebResourceError): void {
512
575
  receivedError(view, request, error);
@@ -525,8 +588,8 @@ function createAssetServingClient(): android.webkit.WebViewClient {
525
588
  onPageFinished(view: android.webkit.WebView, url: string): void {
526
589
  pageFinished(view, url);
527
590
  },
528
- onReceivedSslError(_view: android.webkit.WebView, handler: android.webkit.SslErrorHandler, error: android.net.http.SslError): void {
529
- receivedSslError(handler, error);
591
+ onReceivedSslError(view: android.webkit.WebView, handler: android.webkit.SslErrorHandler, error: android.net.http.SslError): void {
592
+ receivedSslError(view, handler, error);
530
593
  },
531
594
  onReceivedError(view: android.webkit.WebView, request: android.webkit.WebResourceRequest, error: android.webkit.WebResourceError): void {
532
595
  receivedError(view, request, error);
@@ -1,21 +1,33 @@
1
- import { Utils, isIOS } from '@nativescript/core';
1
+ import { Application, Utils, isAndroid, isIOS } from '@nativescript/core';
2
2
  import { bridge } from './bridge';
3
3
  import { appwrapNativeLog } from './native-log';
4
4
  import { SHELL_CONFIG } from './config';
5
+ import { selectSubscriptionOffer, type OfferCandidate } from './billing-offer';
5
6
 
6
7
  // @objc Swift shim (AppwrapManageSubscriptions.swift, billing nativeSrc) — no NS types; bridges
7
8
  // StoreKit 2's Swift-async showManageSubscriptions(in:) to a completion the ObjC bridge can call.
8
9
  declare const AppwrapManageSubscriptions: { present(completion: (message: string | null) => void): void };
9
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
+
10
15
  /**
11
- * In-app purchases — iOS StoreKit 1 (SKPaymentQueue). StoreKit 2 (Product/Transaction)
12
- * is Swift-only and unreachable via the ObjC bridge, so we use the delegate-based
13
- * SK1 API every NativeScript/Cordova IAP plugin uses. The web layer's validator turns
14
- * the returned app receipt into entitlements (server-of-record). Android billing is a
15
- * separate handler (Play Billing) and is currently unimplemented (capability 'none').
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.
16
27
  */
17
28
 
18
29
  const PERIOD_UNIT = ['D', 'W', 'M', 'Y']; // SKProductPeriodUnit: Day/Week/Month/Year
30
+ const DISCOUNT_FREE_TRIAL = 2; // SKProductDiscountPaymentMode.freeTrial (0=PayAsYouGo, 1=PayUpFront, 2=FreeTrial)
19
31
  const STATE = { Purchasing: 0, Purchased: 1, Failed: 2, Restored: 3, Deferred: 4 };
20
32
  const SK_ERR_CANCELLED = 2; // SKErrorPaymentCancelled
21
33
 
@@ -35,12 +47,40 @@ function appReceipt(): string {
35
47
  return data ? data.base64EncodedStringWithOptions(0 as unknown as NSDataBase64EncodingOptions) : '';
36
48
  }
37
49
 
50
+ /**
51
+ * SKProductDiscount → the platform-neutral IntroOffer (parity with the Android mapper's zero-price
52
+ * phase, same `{ price, displayPrice, period, isFreeTrial }` shape native-kit's `IntroOffer` types).
53
+ *
54
+ * ⚠️ ELIGIBILITY CAVEAT: StoreKit 1 exposes `introductoryPrice` on the SKProduct REGARDLESS of whether
55
+ * this user is eligible — SK1 has no eligibility API (SK2's `isEligibleForIntroOffer` is Swift-async,
56
+ * unreachable via the ObjC bridge this handler uses). So a user who already consumed a trial in this
57
+ * group MAY see trial copy they won't actually get; StoreKit's own purchase sheet shows the truth at
58
+ * checkout. Gating on real eligibility is a follow-up if/when this handler moves to StoreKit 2.
59
+ */
60
+ function mapIntroOffer(d: SKProductDiscount, locale: NSLocale): {
61
+ price: number; displayPrice: string; period: string | undefined; isFreeTrial: boolean;
62
+ } {
63
+ const f = NSNumberFormatter.new();
64
+ f.numberStyle = 2; // CurrencyStyle
65
+ f.locale = locale;
66
+ const sp = d.subscriptionPeriod;
67
+ // The discount period is ONE cycle; numberOfPeriods is how many cycles it repeats. Total trial
68
+ // length = numberOfUnits × numberOfPeriods (e.g. 1 week × 1 = P1W).
69
+ const totalUnits = (sp ? sp.numberOfUnits : 0) * (d.numberOfPeriods || 1);
70
+ return {
71
+ price: d.price ? d.price.doubleValue : 0,
72
+ displayPrice: String(f.stringFromNumber(d.price as unknown as number) ?? ''),
73
+ period: sp ? `P${totalUnits}${PERIOD_UNIT[sp.unit] ?? 'D'}` : undefined,
74
+ isFreeTrial: d.paymentMode === DISCOUNT_FREE_TRIAL || (d.price ? d.price.doubleValue : 0) === 0,
75
+ };
76
+ }
77
+
38
78
  function mapProduct(p: SKProduct) {
39
79
  const f = NSNumberFormatter.new();
40
80
  f.numberStyle = 2; // CurrencyStyle
41
81
  f.locale = p.priceLocale;
42
82
  const sp = p.subscriptionPeriod;
43
- return {
83
+ const product: any = {
44
84
  id: String(p.productIdentifier),
45
85
  title: String(p.localizedTitle ?? ''),
46
86
  description: String(p.localizedDescription ?? ''),
@@ -51,6 +91,10 @@ function mapProduct(p: SKProduct) {
51
91
  type: sp ? 'autoRenewable' : 'unknown',
52
92
  subscriptionPeriod: sp ? `P${sp.numberOfUnits}${PERIOD_UNIT[sp.unit] ?? 'D'}` : undefined,
53
93
  };
94
+ // Surface the intro / free-trial phase so the paywall can show trial terms (see eligibility caveat
95
+ // on mapIntroOffer). Populated only when StoreKit reports an introductory price for this product.
96
+ if (p.introductoryPrice) product.introOffer = mapIntroOffer(p.introductoryPrice, p.priceLocale);
97
+ return product;
54
98
  }
55
99
 
56
100
  function receiptFor(productId: string, transactionId?: string) {
@@ -102,6 +146,8 @@ function ensureIosDelegates(): void {
102
146
  }
103
147
  case STATE.Failed: {
104
148
  queue.finishTransaction(t);
149
+ // 'DENIED' here means exactly ONE thing: the user dismissed the sheet. Anything the
150
+ // device refuses outright (payments restricted) is 'UNSUPPORTED' — see billing.purchase.
105
151
  const cancelled = t.error && t.error.code === SK_ERR_CANCELLED;
106
152
  const e = err(cancelled ? 'DENIED' : 'NATIVE_ERROR', t.error?.localizedDescription ?? 'Purchase failed');
107
153
  purchaseWaiters.get(pid)?.reject(e);
@@ -170,8 +216,11 @@ function requestSKProducts(ids: string[]): Promise<SKProduct[]> {
170
216
  }
171
217
 
172
218
  export function registerBillingHandlers(): void {
173
- if (!isIOS) return; // Android billing is a separate, not-yet-wired handler
219
+ if (isIOS) registerIosBilling();
220
+ else if (isAndroid) registerAndroidBilling();
221
+ }
174
222
 
223
+ function registerIosBilling(): void {
175
224
  ensureIosDelegates();
176
225
  observer = buildObserver();
177
226
  SKPaymentQueue.defaultQueue().addTransactionObserver(observer);
@@ -182,7 +231,11 @@ export function registerBillingHandlers(): void {
182
231
  });
183
232
 
184
233
  bridge.register('billing.purchase', async ({ productId }: { productId: string }) => {
185
- if (!SKPaymentQueue.canMakePayments()) throw err('DENIED', 'Purchases disabled on this device');
234
+ // 'UNSUPPORTED', not 'DENIED': this device CANNOT pay at all (Screen Time / parental controls
235
+ // / MDM restriction) — a standing condition retrying will never clear. 'DENIED' is reserved for
236
+ // the user dismissing the StoreKit sheet (SKErrorPaymentCancelled, below), which is a per-attempt
237
+ // choice. Conflating them made consumers silently swallow this as a cancel and strand the user.
238
+ if (!SKPaymentQueue.canMakePayments()) throw err('UNSUPPORTED', 'Purchases are disabled on this device');
186
239
  const sk = (await requestSKProducts([productId]))[0];
187
240
  if (!sk) throw err('NATIVE_ERROR', `Unknown product: ${productId}`);
188
241
  return new Promise((resolve, reject) => {
@@ -251,3 +304,359 @@ function restoreTransactions(): Promise<any[]> {
251
304
  SKPaymentQueue.defaultQueue().restoreCompletedTransactions();
252
305
  });
253
306
  }
307
+
308
+ // ─────────────────────────── Android — Google Play Billing ───────────────────────────
309
+ // Play Billing Library v7. All com.android.billingclient.* access is lazy (inside handler bodies)
310
+ // so the shared module loads on iOS and a non-billing Android build never links the class.
311
+ //
312
+ // ACKNOWLEDGEMENT DECISION (money-critical): Play auto-refunds any purchase not acknowledged within
313
+ // 3 days. We acknowledge CLIENT-SIDE here (BillingClient.acknowledgePurchase) immediately after a
314
+ // PURCHASED update and on restore — the simplest correct choice, since this handler owns the
315
+ // BillingClient. The AGF server validator (PlayStoreTokenValidator) MAY also acknowledge via the
316
+ // Play Developer API; a second acknowledge is a harmless no-op on Google's side (idempotent), so
317
+ // the two can coexist. Tradeoff: we ack before the server confirms the grant, so a failed server
318
+ // validation still consumes the purchase (no auto-refund) — acceptable because the server is the
319
+ // source-of-record and re-validates the token on demand. If the server takes over acknowledgement
320
+ // exclusively, delete acknowledgeAndroidPurchase() calls here.
321
+
322
+ // BillingResponseCode (com.android.billingclient.api.BillingClient.BillingResponseCode).
323
+ const PLAY_RESP = {
324
+ OK: 0, USER_CANCELED: 1, SERVICE_UNAVAILABLE: 2, BILLING_UNAVAILABLE: 3, ITEM_UNAVAILABLE: 4,
325
+ DEVELOPER_ERROR: 5, ERROR: 6, ITEM_ALREADY_OWNED: 7, FEATURE_NOT_SUPPORTED: -2, SERVICE_DISCONNECTED: -1,
326
+ };
327
+ const PURCHASE_STATE_PURCHASED = 1; // com.android.billingclient.api.Purchase.PurchaseState.PURCHASED
328
+
329
+ let billingClient: any = null; // com.android.billingclient.api.BillingClient
330
+ let clientConnecting: Promise<any> | null = null;
331
+ // Purchases arrive asynchronously via the single PurchasesUpdatedListener; route them to the
332
+ // in-flight purchase() caller by product id (module scope, like the iOS purchaseWaiters).
333
+ const androidPurchaseWaiters = new Map<string, { resolve: (r: any) => void; reject: (e: any) => void }>();
334
+ // Single-purchase gate: with only one in-flight purchase, "reject all on error" (which lacks product
335
+ // context) can never reject a DIFFERENT caller's promise. Set synchronously in billing.purchase.
336
+ let androidPurchaseInFlight = false;
337
+
338
+ function billingApi(): any {
339
+ return com.android.billingclient.api;
340
+ }
341
+
342
+ function packageName(): string {
343
+ return String(Utils.android.getApplicationContext().getPackageName());
344
+ }
345
+
346
+ /** Java List<T> from a JS array (Play Billing builders take java.util.List, not a marshaled JS array). */
347
+ function toJavaList(items: any[]): any {
348
+ const list = new java.util.ArrayList();
349
+ for (const it of items) list.add(it);
350
+ return list;
351
+ }
352
+
353
+ /** Map a Play BillingResult to a bridge error, mirroring the iOS cancel/unsupported semantics. */
354
+ function mapBillingErr(result: any): Error {
355
+ const code = result?.getResponseCode?.();
356
+ const msg = String(result?.getDebugMessage?.() ?? 'Play Billing error');
357
+ if (code === PLAY_RESP.USER_CANCELED) return err('DENIED', 'Purchase cancelled');
358
+ if (code === PLAY_RESP.BILLING_UNAVAILABLE || code === PLAY_RESP.FEATURE_NOT_SUPPORTED)
359
+ return err('UNSUPPORTED', msg || 'Billing unavailable on this device');
360
+ return err('NATIVE_ERROR', `${msg} (code ${code})`);
361
+ }
362
+
363
+ /** Lazily build + connect the BillingClient. Resolves when the service is READY. */
364
+ function ensureAndroidClient(): Promise<any> {
365
+ if (billingClient && billingClient.isReady()) return Promise.resolve(billingClient);
366
+ if (clientConnecting) return clientConnecting;
367
+
368
+ clientConnecting = new Promise((resolve, reject) => {
369
+ const api = billingApi();
370
+ const ctx = Utils.android.getApplicationContext();
371
+ // v7 requires PendingPurchasesParams; enableOneTimeProducts() is harmless for a subs-only app
372
+ // (pending purchases are always enabled for subscriptions).
373
+ const pending = api.PendingPurchasesParams.newBuilder().enableOneTimeProducts().build();
374
+ const listener = new api.PurchasesUpdatedListener({
375
+ onPurchasesUpdated(result: any, purchases: any) {
376
+ onAndroidPurchasesUpdated(result, purchases);
377
+ },
378
+ });
379
+ billingClient = api.BillingClient.newBuilder(ctx)
380
+ .setListener(listener)
381
+ .enablePendingPurchases(pending)
382
+ .build();
383
+ billingClient.startConnection(new api.BillingClientStateListener({
384
+ onBillingSetupFinished(result: any) {
385
+ if (result.getResponseCode() === PLAY_RESP.OK) resolve(billingClient);
386
+ else reject(mapBillingErr(result));
387
+ },
388
+ onBillingServiceDisconnected() {
389
+ // Force a fresh connection on the next call (Google recommends reconnect-on-demand).
390
+ clientConnecting = null;
391
+ },
392
+ }));
393
+ }).catch((e) => {
394
+ clientConnecting = null;
395
+ throw e;
396
+ });
397
+ return clientConnecting;
398
+ }
399
+
400
+ /** Query one product's ProductDetails (SUBS). */
401
+ function queryAndroidProductDetails(ids: string[]): Promise<any[]> {
402
+ return ensureAndroidClient().then((client) => new Promise<any[]>((resolve, reject) => {
403
+ const api = billingApi();
404
+ const products = ids.map((id) =>
405
+ api.QueryProductDetailsParams.Product.newBuilder()
406
+ .setProductId(id)
407
+ .setProductType(api.BillingClient.ProductType.SUBS)
408
+ .build());
409
+ const params = api.QueryProductDetailsParams.newBuilder().setProductList(toJavaList(products)).build();
410
+ client.queryProductDetailsAsync(params, new api.ProductDetailsResponseListener({
411
+ onProductDetailsResponse(result: any, list: any) {
412
+ if (result.getResponseCode() !== PLAY_RESP.OK) return reject(mapBillingErr(result));
413
+ const out: any[] = [];
414
+ const n = list ? list.size() : 0;
415
+ for (let i = 0; i < n; i++) out.push(list.get(i));
416
+ resolve(out);
417
+ },
418
+ }));
419
+ }));
420
+ }
421
+
422
+ /** Flatten a ProductDetails' SubscriptionOfferDetails into pure {offerId, phases} + a ref to the
423
+ * native offer (so the caller can read its token / native phases after selection). */
424
+ function androidOfferCandidates(pd: any): Array<OfferCandidate & { native: any }> {
425
+ const offers = pd.getSubscriptionOfferDetails();
426
+ const out: Array<OfferCandidate & { native: any }> = [];
427
+ const n = offers ? offers.size() : 0;
428
+ for (let i = 0; i < n; i++) {
429
+ const o = offers.get(i);
430
+ const list = o.getPricingPhases().getPricingPhaseList();
431
+ const phases = [];
432
+ for (let j = 0; j < list.size(); j++) {
433
+ const ph = list.get(j);
434
+ phases.push({
435
+ priceAmountMicros: Number(ph.getPriceAmountMicros()),
436
+ billingPeriod: String(ph.getBillingPeriod() ?? ''),
437
+ });
438
+ }
439
+ const id = o.getOfferId ? o.getOfferId() : null;
440
+ out.push({ offerId: id != null ? String(id) : null, phases, native: o });
441
+ }
442
+ return out;
443
+ }
444
+
445
+ /** First native pricing phase whose micros satisfy `pred`. */
446
+ function pickNativePhase(list: any, pred: (micros: number) => boolean): any {
447
+ for (let i = 0; i < list.size(); i++) {
448
+ const p = list.get(i);
449
+ if (pred(Number(p.getPriceAmountMicros()))) return p;
450
+ }
451
+ return null;
452
+ }
453
+
454
+ /** ProductDetails → the platform-neutral Product shape, from the SELECTED offer (trial-aware). */
455
+ function mapAndroidProduct(pd: any): any {
456
+ const selected = selectSubscriptionOffer(androidOfferCandidates(pd));
457
+ if (!selected) return null; // not a subscription / no offer
458
+ const list = selected.native.getPricingPhases().getPricingPhaseList();
459
+ // Recurring (paid) phase for the display price; fall back to the last phase.
460
+ const phase = pickNativePhase(list, (m) => m > 0) ?? list.get(list.size() - 1);
461
+ const period = String(phase.getBillingPeriod() ?? '');
462
+ const product: any = {
463
+ id: String(pd.getProductId()),
464
+ title: String(pd.getTitle() ?? pd.getName() ?? ''),
465
+ description: String(pd.getDescription() ?? ''),
466
+ price: Number(phase.getPriceAmountMicros()) / 1_000_000,
467
+ displayPrice: String(phase.getFormattedPrice() ?? ''),
468
+ currency: String(phase.getPriceCurrencyCode() ?? ''),
469
+ type: 'autoRenewable',
470
+ subscriptionPeriod: period || undefined,
471
+ };
472
+ // Surface the free-trial / intro phase so the UI can show trial terms (Play also shows them in its
473
+ // own sheet). Populated only when the selected offer carries a zero-price phase (trial-eligible).
474
+ const intro = pickNativePhase(list, (m) => m === 0);
475
+ if (intro) {
476
+ const introPeriod = String(intro.getBillingPeriod() ?? '');
477
+ product.introOffer = {
478
+ price: Number(intro.getPriceAmountMicros()) / 1_000_000,
479
+ displayPrice: String(intro.getFormattedPrice() ?? ''),
480
+ period: introPeriod || undefined,
481
+ isFreeTrial: Number(intro.getPriceAmountMicros()) === 0,
482
+ };
483
+ }
484
+ return product;
485
+ }
486
+
487
+ /** Play Purchase → PurchaseReceipt (the token is the critical field the server validator consumes). */
488
+ function androidReceiptFor(purchase: any, productId: string): any {
489
+ return {
490
+ platform: 'android',
491
+ productId,
492
+ transactionId: String(purchase.getOrderId?.() ?? ''),
493
+ purchaseToken: String(purchase.getPurchaseToken()),
494
+ packageName: String(purchase.getPackageName?.() ?? packageName()),
495
+ raw: {
496
+ originalJson: String(purchase.getOriginalJson?.() ?? ''),
497
+ signature: String(purchase.getSignature?.() ?? ''),
498
+ },
499
+ };
500
+ }
501
+
502
+ /** Client-side acknowledge (see ACKNOWLEDGEMENT DECISION above). Fire-and-forget; logs on failure. */
503
+ function acknowledgeAndroidPurchase(purchase: any): void {
504
+ try {
505
+ if (purchase.isAcknowledged()) return;
506
+ const api = billingApi();
507
+ const params = api.AcknowledgePurchaseParams.newBuilder()
508
+ .setPurchaseToken(purchase.getPurchaseToken())
509
+ .build();
510
+ billingClient.acknowledgePurchase(params, new api.AcknowledgePurchaseResponseListener({
511
+ onAcknowledgePurchaseResponse(result: any) {
512
+ if (result.getResponseCode() !== PLAY_RESP.OK) {
513
+ console.warn('AppWrap: billing acknowledge failed', result.getResponseCode(), String(result.getDebugMessage?.() ?? ''));
514
+ }
515
+ },
516
+ }));
517
+ } catch (e) {
518
+ console.warn('AppWrap: billing acknowledge threw', e);
519
+ }
520
+ }
521
+
522
+ /** The single PurchasesUpdatedListener callback — routes results to the purchase() waiter(s). */
523
+ function onAndroidPurchasesUpdated(result: any, purchases: any): void {
524
+ const code = result.getResponseCode();
525
+ if (code !== PLAY_RESP.OK || !purchases) {
526
+ // No product context here (Play doesn't say which flow) → reject all in-flight purchases with the
527
+ // mapped error (USER_CANCELED → DENIED, mirroring iOS' cancel semantics).
528
+ const e = mapBillingErr(result);
529
+ for (const w of androidPurchaseWaiters.values()) w.reject(e);
530
+ androidPurchaseWaiters.clear();
531
+ return;
532
+ }
533
+ const n = purchases.size();
534
+ for (let i = 0; i < n; i++) {
535
+ const purchase = purchases.get(i);
536
+ // PENDING (cash/bank/parental-approval) → don't resolve/ack/grant now. This NATIVE waiter has NO
537
+ // timeout; the JS `invoke` rejects at 120s, so by the time Play flips PENDING→PURCHASED (hours/
538
+ // days later) the purchase() promise is long gone. The completion is recoverable ONLY via the
539
+ // billing.transaction event emitted below when this listener fires again with PURCHASED — NOT via
540
+ // the original purchase() promise. Do NOT ack or grant a PENDING purchase.
541
+ if (purchase.getPurchaseState() !== PURCHASE_STATE_PURCHASED) continue;
542
+ acknowledgeAndroidPurchase(purchase);
543
+ const prods = purchase.getProducts();
544
+ const pid = prods && prods.size() > 0 ? String(prods.get(0)) : '';
545
+ const receipt = androidReceiptFor(purchase, pid);
546
+ bridge.emit('billing.transaction', receipt);
547
+ const w = androidPurchaseWaiters.get(pid);
548
+ if (w) { w.resolve(receipt); androidPurchaseWaiters.delete(pid); }
549
+ }
550
+ }
551
+
552
+ /** queryPurchasesAsync(SUBS) → active PurchaseReceipts (used by restore / appReceipt / entitlements). */
553
+ function androidActivePurchases(): Promise<any[]> {
554
+ return ensureAndroidClient().then((client) => new Promise<any[]>((resolve, reject) => {
555
+ const api = billingApi();
556
+ const params = api.QueryPurchasesParams.newBuilder().setProductType(api.BillingClient.ProductType.SUBS).build();
557
+ client.queryPurchasesAsync(params, new api.PurchasesResponseListener({
558
+ onQueryPurchasesResponse(result: any, list: any) {
559
+ if (result.getResponseCode() !== PLAY_RESP.OK) return reject(mapBillingErr(result));
560
+ const receipts: any[] = [];
561
+ const n = list ? list.size() : 0;
562
+ for (let i = 0; i < n; i++) {
563
+ const p = list.get(i);
564
+ if (p.getPurchaseState() !== PURCHASE_STATE_PURCHASED) continue;
565
+ acknowledgeAndroidPurchase(p); // ack any not-yet-acked active sub (in-place migration safety)
566
+ const prods = p.getProducts();
567
+ const pid = prods && prods.size() > 0 ? String(prods.get(0)) : '';
568
+ receipts.push(androidReceiptFor(p, pid));
569
+ }
570
+ resolve(receipts);
571
+ },
572
+ }));
573
+ }));
574
+ }
575
+
576
+ function registerAndroidBilling(): void {
577
+ bridge.register('billing.products', async ({ ids = [] }: { ids: string[] }) => {
578
+ if (!ids.length) return [];
579
+ return (await queryAndroidProductDetails(ids)).map(mapAndroidProduct).filter(Boolean);
580
+ });
581
+
582
+ bridge.register('billing.purchase', async ({ productId }: { productId: string }) => {
583
+ // SERIALIZE: Play's PurchasesUpdatedListener gives NO product context on error, so a single
584
+ // in-flight purchase is the only way "reject all on error" can't cross-talk between callers.
585
+ // The app is single-product; a concurrent second purchase() is rejected outright, guaranteeing
586
+ // there is only ever ONE entry in androidPurchaseWaiters. Guard is set SYNCHRONOUSLY (before any
587
+ // await) so two near-simultaneous calls can't both pass it.
588
+ if (androidPurchaseInFlight) throw err('NATIVE_ERROR', 'A purchase is already in progress');
589
+ androidPurchaseInFlight = true;
590
+ try {
591
+ const client = await ensureAndroidClient();
592
+ const pd = (await queryAndroidProductDetails([productId]))[0];
593
+ if (!pd) throw err('NATIVE_ERROR', `Unknown product: ${productId}`);
594
+ const selected = selectSubscriptionOffer(androidOfferCandidates(pd));
595
+ if (!selected) throw err('NATIVE_ERROR', `No subscription offer for ${productId}`);
596
+ // Trial-aware: the SELECTED offer's token carries the free trial when the user is eligible.
597
+ const offerToken = selected.native.getOfferToken();
598
+ const api = billingApi();
599
+ const pdParams = api.BillingFlowParams.ProductDetailsParams.newBuilder()
600
+ .setProductDetails(pd)
601
+ .setOfferToken(offerToken)
602
+ .build();
603
+ const flowParams = api.BillingFlowParams.newBuilder()
604
+ .setProductDetailsParamsList(toJavaList([pdParams]))
605
+ .build();
606
+ return await new Promise((resolve, reject) => {
607
+ androidPurchaseWaiters.set(productId, { resolve, reject });
608
+ Utils.dispatchToMainThread(() => {
609
+ const activity = Application.android.foregroundActivity ?? Application.android.startActivity;
610
+ if (!activity) {
611
+ androidPurchaseWaiters.delete(productId);
612
+ reject(err('NOT_READY', 'no foreground activity'));
613
+ return;
614
+ }
615
+ // launchBillingFlow's synchronous result reports only launch failures; the purchase itself
616
+ // resolves later via onAndroidPurchasesUpdated → the waiter above.
617
+ const launch = client.launchBillingFlow(activity, flowParams);
618
+ if (launch.getResponseCode() !== PLAY_RESP.OK) {
619
+ androidPurchaseWaiters.delete(productId);
620
+ reject(mapBillingErr(launch));
621
+ }
622
+ });
623
+ });
624
+ } finally {
625
+ // Runs when the purchase settles (resolve/reject) or on any setup throw — re-opening the gate.
626
+ androidPurchaseInFlight = false;
627
+ }
628
+ });
629
+
630
+ bridge.register('billing.restore', () => androidActivePurchases());
631
+
632
+ // Android has no StoreKit-1-style on-disk app receipt; the analog is the most recent active sub's
633
+ // purchaseToken (empty when none). Mirrors iOS' { platform:'ios', appReceipt }.
634
+ bridge.register('billing.appReceipt', async () => {
635
+ const receipts = await androidActivePurchases();
636
+ return { platform: 'android', purchaseToken: receipts[0]?.purchaseToken };
637
+ });
638
+
639
+ // Thin, device-derived entitlements (server is the source-of-record — same posture as iOS).
640
+ bridge.register('billing.entitlements', async () => {
641
+ const receipts = await androidActivePurchases();
642
+ const seen = new Set<string>();
643
+ const ents: any[] = [];
644
+ for (const r of receipts) {
645
+ if (!r.productId || seen.has(r.productId)) continue;
646
+ seen.add(r.productId);
647
+ ents.push({ productId: r.productId, active: true });
648
+ }
649
+ return ents;
650
+ });
651
+
652
+ // Android has no in-app management sheet; the Play subscriptions deep link is the analog for BOTH
653
+ // manageSubscriptions() and showManageSubscriptionsSheet() (the JS layer dispatches the sheet call
654
+ // here now that the capability is 'native' on Android).
655
+ const openManage = ({ productId }: { productId?: string } = {}) => {
656
+ const base = 'https://play.google.com/store/account/subscriptions';
657
+ const url = productId ? `${base}?sku=${encodeURIComponent(productId)}&package=${packageName()}` : base;
658
+ Utils.openUrl(url);
659
+ };
660
+ bridge.register('billing.manageSubscriptions', (p: { productId?: string } = {}) => openManage(p));
661
+ bridge.register('billing.manageSubscriptionsSheet', (p: { productId?: string } = {}) => openManage(p));
662
+ }
@@ -37,29 +37,30 @@ window.__APPWRAP_DEBUG__=${SHELL_CONFIG.debug ? 'true' : 'false'};
37
37
  });
38
38
  window.addEventListener('error', function(e){ post('[uncaught] ' + (e.message || e) + ' @ ' + (e.filename || '') + ':' + (e.lineno || 0)); });
39
39
  window.addEventListener('unhandledrejection', function(e){ var r = e.reason; post('[rejection] ' + ((r && (r.stack || r.message)) || r)); });
40
- // Debug builds only: a loud, non-interactive DEV strip pinned to the top edge showing the server
41
- // this shell points at. A dev-pointing ipa (debug:true) must never be mistaken for a store/prod
42
- // build — the host in the strip makes "which backend am I on?" answerable at a glance.
40
+ // Debug builds only: a small, non-interactive RED DOT parked just under the top safe area. A
41
+ // dev-pointing/dev-signed ipa (debug:true) must never be mistaken for a store/prod build — the dot is
42
+ // the minimum always-on "this is not a store build" tell. It deliberately carries NO text (it replaced
43
+ // a full-width 'DEV · <host>' strip that was too heavy): WHICH backend is answerable on demand via
44
+ // shake → dev menu → App Info (remote host). Sits BELOW safe-area-inset-top so it never overlaps the
45
+ // notch/Dynamic Island, is position:fixed (no layout shift) and pointer-events:none (never steals a tap).
43
46
  // SUPPRESSED when the env-switcher is enabled: its bottom env-banner (shown only on a NON-default
44
- // env) is the better, less-intrusive "which env am I on" indicator and supersedes this strip. Apps
45
- // without envSwitcher keep the strip. (Store builds never reach here — debug is false.)
46
- var __appwrapDevStrip = ${SHELL_CONFIG.envSwitcher?.enabled ? 'false' : 'true'};
47
- var srv = ${JSON.stringify(SHELL_CONFIG.serverUrl || '')};
48
- var host = ''; try { host = srv ? new URL(srv).host : (location && location.host) || ''; } catch (e) { host = srv || ''; }
49
- function mountDevStrip(){
50
- if (document.getElementById('__appwrap_dev_strip__') || !document.body) return;
47
+ // env) is the better, less-intrusive "which env am I on" indicator and supersedes this. Apps
48
+ // without envSwitcher keep the dot. (Store builds never reach here — debug is false.)
49
+ var __appwrapDevDot = ${SHELL_CONFIG.envSwitcher?.enabled ? 'false' : 'true'};
50
+ function mountDevDot(){
51
+ if (document.getElementById('__appwrap_dev_dot__') || !document.body) return;
51
52
  var el = document.createElement('div');
52
- el.id = '__appwrap_dev_strip__';
53
- el.textContent = 'DEV · ' + host;
54
- el.style.cssText = 'position:fixed;top:0;left:0;right:0;z-index:2147483647;pointer-events:none;'
55
- + 'background:#b91c1c;color:#fff;text-align:center;letter-spacing:.5px;'
56
- + 'font:600 10px/1 -apple-system,system-ui,sans-serif;'
57
- + 'padding:calc(env(safe-area-inset-top,0px) + 2px) 6px 2px;';
53
+ el.id = '__appwrap_dev_dot__';
54
+ // White ring + drop shadow so a 9px dot stays legible over BOTH light and dark app backgrounds.
55
+ el.style.cssText = 'position:fixed;z-index:2147483647;pointer-events:none;'
56
+ + 'top:calc(env(safe-area-inset-top,0px) + 6px);right:calc(env(safe-area-inset-right,0px) + 8px);'
57
+ + 'width:9px;height:9px;border-radius:50%;background:#ef4444;'
58
+ + 'box-shadow:0 0 0 1.5px rgba(255,255,255,.9),0 1px 3px rgba(0,0,0,.45);';
58
59
  document.body.appendChild(el);
59
60
  }
60
- if (__appwrapDevStrip) {
61
- if (document.body) mountDevStrip();
62
- else document.addEventListener('DOMContentLoaded', mountDevStrip);
61
+ if (__appwrapDevDot) {
62
+ if (document.body) mountDevDot();
63
+ else document.addEventListener('DOMContentLoaded', mountDevDot);
63
64
  }
64
65
  })();`;
65
66
 
@@ -0,0 +1,61 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import {
3
+ hasFreeTrialPhase,
4
+ introPhase,
5
+ recurringPhase,
6
+ selectSubscriptionOffer,
7
+ type OfferCandidate,
8
+ } from '../app/shell/billing-offer';
9
+
10
+ // The two offers a trial-ELIGIBLE user sees. Order is deliberately trial-LAST to prove index-0 is
11
+ // unsafe by contract (the old offers.get(0) bug picked the base plan and dropped the trial).
12
+ const basePlan: OfferCandidate = {
13
+ offerId: null,
14
+ phases: [{ priceAmountMicros: 4_990_000, billingPeriod: 'P1M' }],
15
+ };
16
+ const trialOffer: OfferCandidate = {
17
+ offerId: 'freetrial',
18
+ phases: [
19
+ { priceAmountMicros: 0, billingPeriod: 'P1W' },
20
+ { priceAmountMicros: 4_990_000, billingPeriod: 'P1M' },
21
+ ],
22
+ };
23
+
24
+ describe('selectSubscriptionOffer', () => {
25
+ test('trial present (base first) → picks the trial offer', () => {
26
+ expect(selectSubscriptionOffer([basePlan, trialOffer])).toBe(trialOffer);
27
+ });
28
+
29
+ test('trial present (trial first) → still picks the trial offer regardless of order', () => {
30
+ expect(selectSubscriptionOffer([trialOffer, basePlan])).toBe(trialOffer);
31
+ });
32
+
33
+ test('trial absent (ineligible user) → falls back to the base-plan offer', () => {
34
+ expect(selectSubscriptionOffer([basePlan])).toBe(basePlan);
35
+ });
36
+
37
+ test('trial absent, offerId not null → returns the first offer as last resort', () => {
38
+ const promoNoTrial: OfferCandidate = { offerId: 'promo', phases: [{ priceAmountMicros: 2_990_000 }] };
39
+ expect(selectSubscriptionOffer([promoNoTrial])).toBe(promoNoTrial);
40
+ });
41
+
42
+ test('empty list → null', () => {
43
+ expect(selectSubscriptionOffer([])).toBeNull();
44
+ });
45
+
46
+ // Falsifiability guard: the trial offer's FIRST phase is free — if the code silently used
47
+ // offers.get(0) again, this would fail because base-first order would pick basePlan.
48
+ test('the selected trial offer exposes a zero-price intro phase and the recurring price', () => {
49
+ const chosen = selectSubscriptionOffer([basePlan, trialOffer])!;
50
+ expect(hasFreeTrialPhase(chosen)).toBe(true);
51
+ expect(introPhase(chosen)?.priceAmountMicros).toBe(0);
52
+ expect(introPhase(chosen)?.billingPeriod).toBe('P1W');
53
+ expect(recurringPhase(chosen)?.priceAmountMicros).toBe(4_990_000);
54
+ });
55
+
56
+ test('base plan has no intro phase; recurring == its only paid phase', () => {
57
+ expect(hasFreeTrialPhase(basePlan)).toBe(false);
58
+ expect(introPhase(basePlan)).toBeUndefined();
59
+ expect(recurringPhase(basePlan)?.priceAmountMicros).toBe(4_990_000);
60
+ });
61
+ });
package/src/cli.ts CHANGED
@@ -644,6 +644,24 @@ export const SHELL_CONFIG = {
644
644
  writeFileSync(join(outDir, 'app/shell/config.ts'), content);
645
645
  }
646
646
 
647
+ /** `deploy` (ios + android) stamps the shell with **`debug: true` FORCED, overriding your config**.
648
+ *
649
+ * This is INTENTIONAL and load-bearing: `deploy` is the local dev-install loop, and debug mode is what
650
+ * enables keep-awake + the WebView inspector for continuous troubleshooting. `sync`, `release` and
651
+ * `submit` all honour `cfg.debug` verbatim — `deploy` is the deliberate exception, not an oversight.
652
+ *
653
+ * ⚠️ THE TRAP: any feature gated on `SHELL_CONFIG.debug` is **unfalsifiable on a deployed build** — the
654
+ * gate is always open, so "it worked when I deployed it" proves nothing about a real (`debug:false`)
655
+ * build, and a debug-gated regression cannot reproduce here. To verify debug-gated behaviour, build via
656
+ * `appwrap release` (which honours the config) — do NOT conclude from a `deploy` build.
657
+ * Hence the printed line: the forcing must never be silent. */
658
+ function stampDeployShellConfig(outDir: string, cfg: AppwrapConfig): void {
659
+ stampShellConfig(outDir, { ...cfg, debug: true });
660
+ console.log(cfg.debug === false
661
+ ? ' ⚠ deploy forces debug:true (keep-awake + WebView inspector) — OVERRIDING debug:false from your config.\n debug-gated behaviour cannot be verified on this build; use `appwrap release` for that.'
662
+ : ' ℹ debug:true — forced by deploy (keep-awake + WebView inspector); debug-gated code is always ON here.');
663
+ }
664
+
647
665
  function stampNativeScriptConfig(outDir: string, cfg: AppwrapConfig): void {
648
666
  const file = join(outDir, 'nativescript.config.ts');
649
667
  const src = readFileSync(file, 'utf8').replace(/id: '[^']*'/, `id: '${cfg.id}'`);
@@ -3253,40 +3271,122 @@ function pinTeamIdToConfig(configPath: string, teamId: string): void {
3253
3271
 
3254
3272
  // ── Build fingerprint for smart resume ───────────────────────────────────────────────────────────
3255
3273
 
3256
- /** Cheap fingerprint of SOURCE build inputs: mtime sum of the PWA dist/ + appwrap config.
3257
- * App_Resources/ is intentionally excluded — sync() rewrites it every run, so its mtime always
3258
- * changes and would make the fingerprint permanently stale.
3274
+ /** Files under TEMPLATE_DIR that are NOT build inputs, for fingerprinting. Deliberately a superset-keeper
3275
+ * vs `templateCopyFilter`: `modules-native/` IS excluded there (copied selectively per active module) but
3276
+ * IS compiled into bundle.js, so editing it MUST invalidate the cache — it stays in the fingerprint here.
3277
+ * `app/www` (PWA staging) is build OUTPUT and `node_modules`/`platforms`/`hooks` are generated, so all four
3278
+ * would make the hash unstable across runs. Matched RELATIVE to TEMPLATE_DIR: when installed from npm
3279
+ * TEMPLATE_DIR itself lives under node_modules/, and testing the absolute path would exclude everything. */
3280
+ const templateFingerprintFilter = (src: string): boolean =>
3281
+ !/(?:^|\/)(node_modules|platforms|hooks|app\/www)(\/|$)/.test(src.slice(TEMPLATE_DIR.length));
3282
+
3283
+ /** Cheap fingerprint of SOURCE build inputs: mtime sum of the PWA dist/ + appwrap config + the appwrap
3284
+ * `runtime/` template tree + this CLI's version.
3285
+ * App_Resources/ (under the app's native/ outDir) is intentionally excluded — sync() rewrites it every
3286
+ * run, so its mtime always changes and would make the fingerprint permanently stale.
3287
+ * runtime/ IS included: it is compiled straight into bundle.js, so omitting it made a runtime edit print
3288
+ * "Skipping build — inputs unchanged" and then install an artifact carrying the PREVIOUS runtime — a
3289
+ * silent stale build that reads as a pass. CLI_VERSION covers codegen changes that touch no source file.
3290
+ * Cost: ~150 stat() calls on a 1MB tree, sub-millisecond — no need for content hashing.
3259
3291
  * Collision risk is acceptable — a false "match" just skips a redundant build, not a correctness bug. */
3260
- export function buildFingerprint(cwd: string, cfg: { pwaDist?: string; overrides?: string }, flags: Record<string, string> = {}): string {
3261
- const mtime = (p: string): number => {
3262
- if (!existsSync(p)) return 0;
3263
- try {
3264
- const s = statSync(p);
3265
- if (s.isDirectory()) {
3266
- let sum = 0;
3267
- for (const e of readdirSync(p, { withFileTypes: true }))
3268
- sum += mtime(join(p, e.name));
3269
- return sum;
3292
+ function mtimeStats(p: string): { sum: number; newest: number } {
3293
+ if (!existsSync(p)) return { sum: 0, newest: 0 };
3294
+ try {
3295
+ const s = statSync(p);
3296
+ if (s.isDirectory()) {
3297
+ let sum = 0, newest = 0;
3298
+ for (const e of readdirSync(p, { withFileTypes: true })) {
3299
+ const c = mtimeStats(join(p, e.name));
3300
+ sum += c.sum;
3301
+ if (c.newest > newest) newest = c.newest;
3270
3302
  }
3271
- return s.mtimeMs;
3272
- } catch { return 0; }
3273
- };
3303
+ return { sum, newest };
3304
+ }
3305
+ return { sum: s.mtimeMs, newest: s.mtimeMs };
3306
+ } catch { return { sum: 0, newest: 0 }; }
3307
+ }
3308
+
3309
+ /** THE build-input set, walked once — the single source of truth for both consumers below:
3310
+ * `parts` (per-input mtime SUMS) feed the fingerprint hash; `newest` (max mtime across every input)
3311
+ * feeds the `--resume` staleness gate. Keeping them on one walk means an input can never be in the
3312
+ * hash but out of the gate (or vice versa) — that divergence is exactly how stale builds ship. */
3313
+ function buildInputStats(cwd: string, cfg: { pwaDist?: string; overrides?: string }, flags: Record<string, string>): { parts: number[]; newest: number } {
3274
3314
  // Fingerprint EVERY app source the build actually consumes — not just dist. Missing the overrides dir
3275
3315
  // (native escape hatch) or a `.js`/`.json`/`--config` config file made edits there produce a stale
3276
3316
  // "inputs unchanged" skip. Resolve the real config path (ts→js→json / --config) instead of hardcoding.
3277
3317
  const distDir = cfg.pwaDist ? resolve(cwd, cfg.pwaDist) : join(cwd, 'dist');
3278
3318
  const overridesDir = resolve(cwd, cfg.overrides ?? 'appwrap.overrides');
3279
- const parts = [mtime(distDir), mtime(overridesDir), mtime(resolveConfigPath(cwd, flags))];
3319
+ const stats = [mtimeStats(distDir), mtimeStats(overridesDir), mtimeStats(resolveConfigPath(cwd, flags))];
3320
+ // The appwrap runtime template — bundled into bundle.js, so it is a first-class build input.
3321
+ let runtime = 0, runtimeNewest = 0;
3322
+ for (const rel of collectRelFiles(TEMPLATE_DIR, templateFingerprintFilter)) {
3323
+ const s = mtimeStats(join(TEMPLATE_DIR, rel));
3324
+ runtime += s.sum;
3325
+ if (s.newest > runtimeNewest) runtimeNewest = s.newest;
3326
+ }
3327
+ stats.push({ sum: runtime, newest: runtimeNewest });
3328
+ return { parts: stats.map((s) => s.sum), newest: Math.max(...stats.map((s) => s.newest)) };
3329
+ }
3330
+
3331
+ /** Newest mtime across every build input — the evidence the `--resume` gate needs when no build cache
3332
+ * exists to compare a fingerprint against. Same input set as `buildFingerprint` by construction. */
3333
+ export function newestBuildInputMtime(cwd: string, cfg: { pwaDist?: string; overrides?: string }, flags: Record<string, string> = {}): number {
3334
+ return buildInputStats(cwd, cfg, flags).newest;
3335
+ }
3336
+
3337
+ export function buildFingerprint(cwd: string, cfg: { pwaDist?: string; overrides?: string }, flags: Record<string, string> = {}): string {
3338
+ const { parts } = buildInputStats(cwd, cfg, flags);
3280
3339
  // Simple djb2-style hash — good enough for a build-skip check (not cryptographic).
3281
3340
  let h = 5381;
3282
3341
  for (const n of parts) h = (((h << 5) + h) ^ (n | 0)) >>> 0;
3342
+ // Mix in the CLI version — a `bunx appwrap` upgrade changes codegen without touching any app source.
3343
+ for (const c of CLI_VERSION) h = (((h << 5) + h) ^ c.charCodeAt(0)) >>> 0;
3283
3344
  return h.toString(36);
3284
3345
  }
3285
3346
 
3347
+ /** The iOS build-skip decision — pure, so it is testable without a device, an Xcode toolchain, or an .ipa.
3348
+ *
3349
+ * INVARIANT (the whole point of this function): a skip REQUIRES positive evidence that the existing .ipa
3350
+ * already contains the current inputs. Two forms of evidence are accepted:
3351
+ * 1. fingerprintMatch — the recorded cache fingerprint equals the current one. Strongest; the default.
3352
+ * 2. --resume with NO cache (manual Xcode build / first run) — nothing recorded a fingerprint, so we
3353
+ * fall back to the .ipa being at least as new as every build input. An input touched after the .ipa
3354
+ * was written provably postdates it, so the .ipa cannot contain it → rebuild.
3355
+ * `--resume` relaxes WHICH evidence is required; it never removes the requirement. Before this, form 2
3356
+ * was `resume && !cache` with NO comparison at all — `--resume` silently shipped stale .ipas, the exact
3357
+ * failure the fingerprint exists to kill. Android has no equivalent branch (strictly form 1).
3358
+ *
3359
+ * Honest limit: mtime proves the artifact POSTDATES its inputs, not that it was BUILT from them (a
3360
+ * hand-`touch`ed .ipa still fools it). That is the trust `--resume` explicitly asks for, and it is
3361
+ * strictly stronger than the unconditional skip it replaces. `--force` overrides everything.
3362
+ *
3363
+ * `adoptCache` → record the fingerprint for a form-2 skip, so every LATER run is gated by form 1. */
3364
+ export function decideIosBuildSkip(o: {
3365
+ force: boolean;
3366
+ resume: boolean;
3367
+ fp: string;
3368
+ ipaPath?: string;
3369
+ ipaMtime: number;
3370
+ cache: BuildCache | null;
3371
+ newestInputMtime: () => number;
3372
+ }): { skip: boolean; reason: string; adoptCache: boolean } {
3373
+ if (o.force) return { skip: false, reason: '', adoptCache: false };
3374
+ if (!o.ipaPath) return { skip: false, reason: '', adoptCache: false };
3375
+ if (o.cache) {
3376
+ return o.cache.fingerprint === o.fp && o.cache.artifactPath === o.ipaPath
3377
+ ? { skip: true, reason: 'inputs unchanged since last build', adoptCache: false }
3378
+ : { skip: false, reason: '', adoptCache: false };
3379
+ }
3380
+ if (!o.resume) return { skip: false, reason: '', adoptCache: false };
3381
+ return o.ipaMtime >= o.newestInputMtime()
3382
+ ? { skip: true, reason: '--resume (no cache; .ipa is newer than every build input)', adoptCache: true }
3383
+ : { skip: false, adoptCache: false, reason: '--resume ignored — the existing .ipa is older than a build input (it cannot contain your latest change); rebuilding.' };
3384
+ }
3385
+
3286
3386
  // Per-platform build cache (iOS .ipa / Android .apk) — separate files so the two don't clobber each
3287
3387
  // other's fingerprint (that's what enables the build-skip on BOTH platforms).
3288
3388
  const buildCacheFile = (platform: string) => `.appwrap-build-cache-${platform}.json`;
3289
- interface BuildCache { fingerprint: string; artifactPath: string; builtAt: string }
3389
+ export interface BuildCache { fingerprint: string; artifactPath: string; builtAt: string }
3290
3390
 
3291
3391
  function readBuildCache(outDir: string, platform: string): BuildCache | null {
3292
3392
  try { return JSON.parse(readFileSync(join(outDir, buildCacheFile(platform)), 'utf8')); } catch { return null; }
@@ -3577,11 +3677,11 @@ async function deployAndroid(cwd: string, flags: Record<string, string>, cfgOver
3577
3677
 
3578
3678
  buildWebIfBundled(cwd, cfg, flags); // bundled → fresh web bundle; server → skip (both printed)
3579
3679
  await sync(cwd, flags, cfgOverride); // re-stamp config + copy latest PWA dist
3580
- stampShellConfig(outDir, { ...cfg, debug: true }); // debug: keep-awake + WebView inspector (parity with deploy ios)
3680
+ stampDeployShellConfig(outDir, cfg); // forces debug:true — see the helper's doc for why + the trap
3581
3681
 
3582
3682
  const apk = join(outDir, 'platforms/android/app/build/outputs/apk/debug/app-debug.apk');
3583
- // Fingerprint build-skip (parity with deploy ios): skip the gradle build when dist + config are
3584
- // unchanged since the last build. --force/-f always rebuilds. (Rebuilding the web above usually
3683
+ // Fingerprint build-skip (parity with deploy ios): skip the gradle build when dist + config + the
3684
+ // appwrap runtime/ are unchanged since the last build. --force/-f always rebuilds. (Rebuilding the web above usually
3585
3685
  // bumps the fingerprint; pair with --no-web-build to actually hit this fast-path.)
3586
3686
  const force = 'force' in flags || 'f' in flags;
3587
3687
  const fp = buildFingerprint(cwd, cfg, flags);
@@ -3651,8 +3751,8 @@ async function deploy(cwd: string, flags: Record<string, string>, positionals: s
3651
3751
 
3652
3752
  buildWebIfBundled(cwd, cfg, flags); // bundled → fresh web bundle (no stale dist); server → skip (printed)
3653
3753
  await sync(cwd, flags, cfgOverride); // re-stamp config + copy latest PWA dist (+ vendor backend assets)
3654
- // Dev deploy → debug mode: keep-awake + WebView inspector for continuous troubleshooting.
3655
- stampShellConfig(outDir, { ...cfg, debug: true });
3754
+ // Dev deploy → debug mode forced: keep-awake + WebView inspector. See the helper's doc for the trap.
3755
+ stampDeployShellConfig(outDir, cfg);
3656
3756
  // Self-heal: drop platforms/ios if a prior `release`/`publish` left App-Store/manual signing there
3657
3757
  // (NS preserves it across prepares + it overrides build.xcconfig) → clean automatic dev signing.
3658
3758
  resetStaleSigningForAutoLane(outDir, cfg);
@@ -3660,24 +3760,29 @@ async function deploy(cwd: string, flags: Record<string, string>, positionals: s
3660
3760
  const ipaDir = join(outDir, 'platforms/ios/build/Debug-iphoneos');
3661
3761
 
3662
3762
  // Smart resume: skip ns build (pod install + xcodebuild) when inputs haven't changed.
3663
- // --resume (-r): opt in to fingerprint-based skip (same logic as auto, but explicit — useful when
3664
- // the auto check has no prior cache yet and you want to force a skip on first run after a manual build).
3763
+ // --resume (-r): additionally allow a skip when there is NO build cache yet (e.g. the .ipa came from a
3764
+ // manual Xcode build, or this is the first run) — gated on the .ipa being newer than every build
3765
+ // input. It relaxes WHICH evidence is required, never whether evidence is required.
3665
3766
  // Auto: always checks fingerprint; never skips if sources/deps changed.
3666
3767
  const resume = 'resume' in flags || 'r' in flags;
3667
3768
  const force = 'force' in flags || 'f' in flags;
3668
3769
  const fp = buildFingerprint(cwd, cfg, flags);
3669
3770
  const cache = readBuildCache(outDir, 'ios');
3670
3771
  const existingIpa = newestIpa(ipaDir);
3671
- const fingerprintMatch = !force && existingIpa && cache?.fingerprint === fp && cache?.artifactPath === join(ipaDir, existingIpa);
3672
- // --resume also accepts a missing cache file (e.g. after a manual Xcode build or first run),
3673
- // but ONLY when the fingerprint matches what's currently on disk — never skips a needed build.
3674
- const noCache = existingIpa && !cache;
3675
- const canSkipBuild = !force && (fingerprintMatch || (resume && noCache));
3772
+ const decision = decideIosBuildSkip({
3773
+ force, resume, fp,
3774
+ ipaPath: existingIpa ? join(ipaDir, existingIpa) : undefined,
3775
+ ipaMtime: existingIpa ? statSync(join(ipaDir, existingIpa)).mtimeMs : 0,
3776
+ cache,
3777
+ newestInputMtime: () => newestBuildInputMtime(cwd, cfg, flags),
3778
+ });
3779
+ const { skip: canSkipBuild, adoptCache } = decision;
3676
3780
 
3677
3781
  if (canSkipBuild) {
3678
- const reason = fingerprintMatch ? 'inputs unchanged since last build' : '--resume (first run, .ipa present)';
3679
- console.log(`⚡ Skipping build — ${reason} (${existingIpa})`);
3782
+ console.log(`⚡ Skipping build — ${decision.reason} (${existingIpa})`);
3680
3783
  } else {
3784
+ // Asked to resume but the .ipa predates a build input → say so instead of quietly rebuilding.
3785
+ if (decision.reason) console.log(`↻ ${decision.reason}`);
3681
3786
  // Manual signing (signing:'manual') → pass the main-app profile as --provision so NS emits a
3682
3787
  // manual exportOptions.plist covering the app + its extensions (see stampManualSigning sidecar).
3683
3788
  const nsArgs = ['build', 'ios', '--for-device'];
@@ -3728,7 +3833,11 @@ async function deploy(cwd: string, flags: Record<string, string>, positionals: s
3728
3833
  const ipa = builtIpa;
3729
3834
  if (!ipa) { console.error(`✖ No .ipa produced in ${ipaDir}`); process.exit(1); }
3730
3835
  const ipaPath = join(ipaDir, ipa);
3731
- if (!canSkipBuild) writeBuildCache(outDir, 'ios', { fingerprint: fp, artifactPath: ipaPath, builtAt: new Date().toISOString() });
3836
+ // Write the cache after a real build, AND after a --resume skip: adopting the fingerprint converts
3837
+ // that one-time mtime-based trust into a recorded baseline, so EVERY later run is strictly
3838
+ // fingerprint-gated. Without this, --resume stayed in the no-cache branch forever — permanently
3839
+ // ungated. (A fingerprintMatch skip already has an identical cache; nothing to write.)
3840
+ if (!canSkipBuild || adoptCache) writeBuildCache(outDir, 'ios', { fingerprint: fp, artifactPath: ipaPath, builtAt: new Date().toISOString() });
3732
3841
 
3733
3842
  console.log(`▶ installing ${ipa} → ${device.name} [${device.transport}]`);
3734
3843
  let installedViaUsbmux = false;