react-native-revnix 1.5.0 → 1.6.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.
@@ -15,7 +15,18 @@ const PENDING_PURCHASES_KEY = "revnix.pendingPurchases";
15
15
  const PLACEMENTS_CACHE_KEY = "revnix.placements";
16
16
  const LAST_ATTRIBUTION_KEY = "revnix.lastAttribution";
17
17
  const LAST_PUSH_TOKEN_KEY = "revnix.lastPushToken";
18
+ const EVENT_QUEUE_KEY = "revnix.eventQueue";
19
+ const PLACEMENTS_INVALIDATED_AT_KEY = "revnix.placementsInvalidatedAt";
18
20
  const DEFAULT_TIMEOUT_MS = 10000;
21
+ const EVENT_QUEUE_CAP = 1000;
22
+ const EVENT_BATCH_SIZE = 100;
23
+ const EVENT_FLUSH_THRESHOLD = 20;
24
+ const EVENT_FLUSH_DELAY_MS = 10000;
25
+ const EVENT_RETRY_HOLD_MS = 30000;
26
+ const PLACEMENT_FRESH_MS = 24 * 60 * 60 * 1000;
27
+ const CACHED_ENTITLEMENTS_TIMEOUT_MS = 3000;
28
+ const PURCHASE_QUEUE_CAP = 100;
29
+ const PURCHASE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
19
30
  // REV-204 (L10): how many customers keep a cached entitlements snapshot.
20
31
  const ENTITLEMENTS_CACHE_MAX = 4;
21
32
  // REV-204 (L10): http:// is tolerated only for development hosts.
@@ -41,6 +52,38 @@ const DEFAULT_OFFLINE_MAX_CACHE_AGE_MS = 14 * 24 * 60 * 60 * 1000;
41
52
  // — a rolled-back clock. Small negative skew is real (NTP corrections), a
42
53
  // large one is not.
43
54
  const CLOCK_ROLLBACK_TOLERANCE_MS = 5 * 60 * 1000;
55
+ function batchResults(response, path) {
56
+ const results = response?.results;
57
+ if (Array.isArray(results) &&
58
+ results.every((r) => typeof r === "object" && r !== null && typeof r.status === "number")) {
59
+ return results;
60
+ }
61
+ throw new RevnixError(0, `Revnix: server returned an ill-shaped batch response for ${path}`);
62
+ }
63
+ export function paywallImageUrls(value) {
64
+ const found = new Set();
65
+ const visit = (node) => {
66
+ if (Array.isArray(node)) {
67
+ node.forEach(visit);
68
+ return;
69
+ }
70
+ if (typeof node !== "object" || node === null)
71
+ return;
72
+ const o = node;
73
+ const background = o.background;
74
+ for (const url of [
75
+ o.heroImageUrl,
76
+ o.type === "image" ? o.url : undefined,
77
+ background?.image?.url,
78
+ ]) {
79
+ if (typeof url === "string" && url.length > 0)
80
+ found.add(url);
81
+ }
82
+ Object.values(o).forEach(visit);
83
+ };
84
+ visit(value);
85
+ return [...found];
86
+ }
44
87
  // One queue entry per store charge — repeated failures collapse onto it, and
45
88
  // the retry pass removes by this key rather than by array position.
46
89
  const pendingKey = (p) => `${p.source}:${p.token}:${p.transactionId}`;
@@ -96,6 +139,9 @@ export class RevnixClient {
96
139
  * /v1/installs uses, and independent of the device header (which an app
97
140
  * can turn off entirely). */
98
141
  this.freshInstall = false;
142
+ this.eventFlush = null;
143
+ this.eventTimer = null;
144
+ this.eventRetryAt = 0;
99
145
  // REV-199 (CTO-R2 M21): the last fresh network read, held in memory so
100
146
  // reads within the soft TTL answer without a round trip — apps gate many
101
147
  // screens on isEntitled(), and every one of those was a full request for an
@@ -105,11 +151,7 @@ export class RevnixClient {
105
151
  // REV-199 (M21): concurrent getEntitlements() calls share one request.
106
152
  this.inflightEntitlements = null;
107
153
  // ——— C7: persisted purchase retry queue ———
108
- // Serializes every queue read-modify-write. Storage adapters may be async,
109
- // so two interleaved mutations (an enqueue during the retry pass, retry
110
- // passes from constructor + a manual call) would otherwise clobber each
111
- // other's writes and lose purchases.
112
- this.queueLock = Promise.resolve();
154
+ this.storageLock = Promise.resolve();
113
155
  if (!config.apiKey)
114
156
  throw new Error("Revnix: apiKey is required");
115
157
  if (!config.baseUrl)
@@ -168,8 +210,13 @@ export class RevnixClient {
168
210
  config.implicitPlacements ?? config.onImplicitPaywall !== undefined;
169
211
  this.sessionTimeoutMs =
170
212
  config.sessionTimeoutMs ?? DEFAULT_SESSION_TIMEOUT_MS;
213
+ this.subscribeLifecycle(config.lifecycle);
171
214
  if (this.implicitEnabled)
172
- this.startImplicit(config.lifecycle);
215
+ this.startImplicit();
216
+ void this.readEvents().then((events) => {
217
+ if (events.length > 0)
218
+ this.startEventFlush();
219
+ });
173
220
  const { document, location } = globalThis;
174
221
  if (document &&
175
222
  location &&
@@ -255,6 +302,7 @@ export class RevnixClient {
255
302
  const customerId = await this.customerIdPromise;
256
303
  try {
257
304
  const result = await this.postPurchase(customerId, input);
305
+ await this.invalidatePlacements();
258
306
  // Identity resolution may have merged this device's id away (restore
259
307
  // after login), so the server's id is the one to keep using.
260
308
  //
@@ -364,25 +412,30 @@ export class RevnixClient {
364
412
  }
365
413
  /** C7: retry purchases that failed to register and were persisted locally.
366
414
  * Runs automatically at construction; call again when connectivity returns.
367
- * Idempotent and safe to call repeatedly. */
415
+ * Idempotent and safe to call repeatedly. Purchases queued more than 30
416
+ * days ago are dropped with a diagnostic. */
368
417
  async retryPendingPurchases() {
418
+ const now = Date.now();
419
+ await this.mutateQueue((queue) => queue.some((q) => q.queuedAt === undefined)
420
+ ? queue.map((q) => ({ ...q, queuedAt: q.queuedAt ?? now }))
421
+ : queue);
369
422
  const queue = await this.readPurchaseQueue();
370
423
  for (const item of queue) {
371
424
  if (this.disposed)
372
425
  return;
373
- try {
374
- await this.postPurchase(item.customerId, item.input);
375
- // Delivered — remove it.
426
+ if (now - (item.queuedAt ?? now) > PURCHASE_MAX_AGE_MS) {
427
+ this.diag("retryPendingPurchases", new Error(`expired ${pendingKey(item.input)}`));
376
428
  }
377
- catch (err) {
378
- if (worthQueueing(err)) {
379
- // REV-200 (M26): a queued purchase that STILL cannot land is the
380
- // most important background failure there is — report it.
429
+ else {
430
+ try {
431
+ await this.postPurchase(item.customerId, item.input);
432
+ await this.invalidatePlacements();
433
+ }
434
+ catch (err) {
381
435
  this.diag("retryPendingPurchases", err);
382
- continue; // transient — keep for next time
436
+ if (worthQueueing(err))
437
+ continue;
383
438
  }
384
- // Permanent rejection — remove it.
385
- this.diag("retryPendingPurchases", err);
386
439
  }
387
440
  // Remove THIS item from the queue as it stands now, not from the
388
441
  // snapshot iterated above: each postPurchase can spend seconds in a
@@ -424,16 +477,19 @@ export class RevnixClient {
424
477
  }
425
478
  /** The authoritative network read: no TTL, no cache fallback — throws so
426
479
  * the caller sees the real failure (waitForEntitlements needs 429s). */
427
- async fetchFreshEntitlements(customerId) {
428
- const fresh = await this.request("GET", `/v1/customers/${encodeURIComponent(customerId)}/entitlements`);
480
+ async fetchFreshEntitlements(customerId, timeoutMs) {
481
+ const fresh = await this.request("GET", `/v1/customers/${encodeURIComponent(customerId)}/entitlements`, undefined, undefined, timeoutMs);
429
482
  await this.writeEntitlementsCache(customerId, fresh);
430
483
  const result = { ...fresh, stale: false, fetchedAt: Date.now() };
431
484
  this.freshEntitlements = { customerId, data: result, at: Date.now() };
432
485
  return result;
433
486
  }
434
487
  async fetchEntitlementsWithFallback(customerId) {
488
+ const hasCache = (await this.readEntitlementsCache(customerId)) !== null;
435
489
  try {
436
- return await this.fetchFreshEntitlements(customerId);
490
+ return await this.fetchFreshEntitlements(customerId, hasCache
491
+ ? Math.min(CACHED_ENTITLEMENTS_TIMEOUT_MS, this.requestTimeoutMs)
492
+ : undefined);
437
493
  }
438
494
  catch (err) {
439
495
  // REV-198 (CTO-R2 M22): the cache exists to ride out failures that
@@ -544,35 +600,28 @@ export class RevnixClient {
544
600
  return await this.request("GET", `/v1/customers/${encodeURIComponent(customerId)}/identity`);
545
601
  }
546
602
  /** Resolve a named in-app location to the currently published offering —
547
- * the one-line paywall call. REV-157: reads the network first and caches
548
- * the result; if that read FAILS (offline / incident) it falls back to the
549
- * last cached resolution with `stale: true`, so the paywall renders the
550
- * last published version rather than not at all. Throws only when there
551
- * is nothing cached. */
603
+ * the one-line paywall call. Served instantly from a cache under 24 h old
604
+ * that was fetched for this customer since the last `setAttributes` or
605
+ * purchase, refreshed in the background. Otherwise reads the network and
606
+ * caches the result; if that read FAILS (offline / incident) it falls back
607
+ * to the last cached resolution with `stale: true`, so the paywall renders
608
+ * the last published version rather than not at all. Throws only when
609
+ * there is nothing cached. A deliberate rejection seen by the background
610
+ * refresh drops the cache, so the next resolve throws it. */
552
611
  async resolvePlacement(placementKey) {
553
612
  const customerId = await this.customerIdPromise;
613
+ const cached = await this.readPlacementCache(placementKey, customerId);
614
+ const age = Date.now() - (cached?.fetchedAt ?? 0);
615
+ if (cached &&
616
+ cached.customerId === customerId &&
617
+ cached.fetchedAt > (await this.placementsInvalidatedAt()) &&
618
+ age >= 0 &&
619
+ age <= PLACEMENT_FRESH_MS) {
620
+ void this.refreshPlacement(placementKey);
621
+ return { ...cached.data, stale: false, fetchedAt: cached.fetchedAt };
622
+ }
554
623
  try {
555
- // REV-219: identify the caller so a running experiment on this
556
- // placement can assign a (sticky, deterministic) variant. Servers
557
- // without experiments simply ignore the parameter. An EPHEMERAL anon
558
- // id (memory-only storage — new id every launch) is deliberately NOT
559
- // sent: each launch would enroll a phantom customer and flip the same
560
- // user between variants; the server serves the placement default
561
- // when no customer is identified (same guard class as M5 installs).
562
- const path = `/v1/placements/${encodeURIComponent(placementKey)}/offering`;
563
- // REV-268: the device facts ride along so the server can evaluate
564
- // targeting rules against THIS device on THIS request, and store them
565
- // as device.* attributes. Not sent for an ephemeral anon id: the server
566
- // would have no customer to keep them on, and it serves such callers
567
- // the untargeted default anyway.
568
- const deviceHeader = this.ephemeralAnonId
569
- ? null
570
- : await this.deviceHeaderPromise;
571
- const fresh = await this.request("GET", this.ephemeralAnonId
572
- ? path
573
- : `${path}?customer=${encodeURIComponent(customerId)}`, undefined, deviceHeader ? { [DEVICE_HEADER]: deviceHeader } : undefined);
574
- await this.writePlacementCache(placementKey, fresh, customerId);
575
- return { ...fresh, stale: false, fetchedAt: Date.now() };
624
+ return await this.fetchPlacement(placementKey);
576
625
  }
577
626
  catch (err) {
578
627
  // REV-198 (M22): same rule as getEntitlements — an unpublished catalog
@@ -581,13 +630,44 @@ export class RevnixClient {
581
630
  // fleet-wide with no way to pull it.
582
631
  if (err instanceof RevnixError && !err.isRetryable)
583
632
  throw err;
584
- const cached = await this.readPlacementCache(placementKey, customerId);
585
- if (cached) {
586
- return { ...cached.data, stale: true, fetchedAt: cached.fetchedAt };
633
+ const fallback = await this.readPlacementCache(placementKey, customerId);
634
+ if (fallback) {
635
+ return { ...fallback.data, stale: true, fetchedAt: fallback.fetchedAt };
587
636
  }
588
637
  throw err;
589
638
  }
590
639
  }
640
+ /** Fetch these placements now so the next `resolvePlacement` answers from
641
+ * the cache. Never throws; failures report to `onDiagnostic`. Paywall
642
+ * images are warmed by `prefetchPaywallImages` from
643
+ * `react-native-revnix/ui`. */
644
+ async preload(placementKeys) {
645
+ await Promise.all(placementKeys.map((key) => this.refreshPlacement(key)));
646
+ }
647
+ async refreshPlacement(placementKey) {
648
+ try {
649
+ await this.fetchPlacement(placementKey);
650
+ }
651
+ catch (err) {
652
+ if (err instanceof RevnixError && !err.isRetryable) {
653
+ await this.deletePlacementCache(placementKey);
654
+ }
655
+ this.diag("resolvePlacement", err);
656
+ }
657
+ }
658
+ async fetchPlacement(placementKey) {
659
+ const customerId = await this.customerIdPromise;
660
+ const fetchedAt = Date.now();
661
+ const path = `/v1/placements/${encodeURIComponent(placementKey)}/offering`;
662
+ const deviceHeader = this.ephemeralAnonId
663
+ ? null
664
+ : await this.deviceHeaderPromise;
665
+ const fresh = await this.request("GET", this.ephemeralAnonId
666
+ ? path
667
+ : `${path}?customer=${encodeURIComponent(customerId)}`, undefined, deviceHeader ? { [DEVICE_HEADER]: deviceHeader } : undefined);
668
+ await this.writePlacementCache(placementKey, fresh, customerId, fetchedAt);
669
+ return { ...fresh, stale: false, fetchedAt: Date.now() };
670
+ }
591
671
  /** REV-157: the last resolution cached for `placementKey`, with NO network
592
672
  * call — for an instant, offline-safe paywall at launch. Always
593
673
  * `stale: true`; follow with resolvePlacement() to revalidate. Null if
@@ -681,7 +761,8 @@ export class RevnixClient {
681
761
  * - `restore` — restore purchases was tapped
682
762
  * - `error` — the paywall itself failed (config, products, render)
683
763
  *
684
- * Fire-and-forget like the other beacons: never blocks, never throws.
764
+ * Queued and sent in batches (see `flushEvents`): resolves once queued,
765
+ * never throws.
685
766
  *
686
767
  * `viewId` is the id `logPaywallShown` returned for THIS display. Passing
687
768
  * it is what threads the whole life of one impression together and puts
@@ -698,31 +779,29 @@ export class RevnixClient {
698
779
  * which caps the report at one per display per event type. Pass one per
699
780
  * occurrence (and reuse it across your own retries) to record every
700
781
  * occurrence — e.g. a customer who fails, retries and fails again. */
701
- logPaywallEvent(event, viewId, options) {
782
+ async logPaywallEvent(event, viewId, options) {
702
783
  if (options?.placementKey === PREVIEW_PLACEMENT_KEY)
703
784
  return;
704
- void this.customerIdPromise
705
- .then((customerId) => this.request("POST", "/v1/paywalls/events", {
706
- customerId,
707
- viewId,
708
- event,
709
- occurredAt: Date.now(),
710
- sdkVersion: SDK_VERSION,
711
- ...(options?.eventId ? { eventId: options.eventId } : {}),
712
- ...(options?.placementKey ? { placementKey: options.placementKey } : {}),
713
- ...(options?.paywallId ? { paywallId: options.paywallId } : {}),
714
- ...(options?.productId ? { productId: options.productId } : {}),
715
- ...(options?.code ? { code: options.code } : {}),
716
- ...(options?.message
717
- ? { message: options.message.slice(0, 1024) }
718
- : {}),
719
- }))
720
- .catch((err) => this.diag("logPaywallEvent", err));
721
- // REV-272: backing out of the store sheet is the `transaction_abandon`
722
- // moment. Reuses the paywall.purchase_abandoned event just reported.
723
785
  if (event === "purchase_abandoned") {
724
786
  this.fireImplicitFromPaywall("transaction_abandon", viewId, options?.paywallId);
725
787
  }
788
+ const customerId = await this.customerIdPromise;
789
+ await this.enqueueEvent({
790
+ path: "/v1/paywalls/events",
791
+ body: {
792
+ customerId,
793
+ viewId,
794
+ event,
795
+ eventId: options?.eventId ?? viewId,
796
+ occurredAt: Date.now(),
797
+ sdkVersion: SDK_VERSION,
798
+ ...(options?.placementKey ? { placementKey: options.placementKey } : {}),
799
+ ...(options?.paywallId ? { paywallId: options.paywallId } : {}),
800
+ ...(options?.productId ? { productId: options.productId } : {}),
801
+ ...(options?.code ? { code: options.code } : {}),
802
+ ...(options?.message ? { message: options.message.slice(0, 1024) } : {}),
803
+ },
804
+ });
726
805
  }
727
806
  /** Reports one ad impression's payout (POST /v1/ad-revenue).
728
807
  * Fire-and-forget like the other beacons: never blocks, never throws. */
@@ -747,22 +826,124 @@ export class RevnixClient {
747
826
  .catch((err) => this.diag("logAdRevenue", err));
748
827
  }
749
828
  /** Reports one custom in-app event (POST /v1/events), not for purchases.
750
- * Fire-and-forget like the other beacons: never blocks, never throws. */
751
- track(event, properties, options) {
829
+ * Queued and sent in batches (see `flushEvents`): resolves once queued,
830
+ * never throws. */
831
+ async track(event, properties, options) {
752
832
  if (!/^[a-z0-9_]{1,64}$/.test(event)) {
753
833
  this.diag("track", new Error(`invalid event name: ${event}`));
754
834
  return;
755
835
  }
756
836
  const eventId = options?.eventId ?? generateEventId();
757
- void this.customerIdPromise
758
- .then((customerId) => this.request("POST", "/v1/events", {
759
- customerId,
760
- event,
761
- eventId,
762
- occurredAt: Date.now(),
763
- ...(properties ? { properties } : {}),
764
- }))
765
- .catch((err) => this.diag("track", err));
837
+ const customerId = await this.customerIdPromise;
838
+ await this.enqueueEvent({
839
+ path: "/v1/events",
840
+ body: {
841
+ customerId,
842
+ event,
843
+ eventId,
844
+ occurredAt: Date.now(),
845
+ ...(properties ? { properties } : {}),
846
+ },
847
+ });
848
+ }
849
+ /** Send every queued `track` and paywall interaction event now. Events are
850
+ * otherwise sent in batches: at 20 queued, 10 s after the first, when the
851
+ * app goes to the background, and on the next launch. Never throws; a
852
+ * transient failure keeps the events and holds further sends for 30 s. */
853
+ async flushEvents() {
854
+ await this.eventFlush;
855
+ this.startEventFlush();
856
+ await this.eventFlush;
857
+ }
858
+ async readEvents() {
859
+ try {
860
+ const parsed = JSON.parse((await this.storage.getItem(EVENT_QUEUE_KEY)) ?? "[]");
861
+ return Array.isArray(parsed) ? parsed : [];
862
+ }
863
+ catch {
864
+ return [];
865
+ }
866
+ }
867
+ mutateEvents(mutate) {
868
+ return this.withStorageLock(async () => {
869
+ const next = mutate(await this.readEvents());
870
+ try {
871
+ await this.storage.setItem(EVENT_QUEUE_KEY, JSON.stringify(next));
872
+ }
873
+ catch (err) {
874
+ this.diag("storageWrite", err);
875
+ }
876
+ return next.length;
877
+ });
878
+ }
879
+ // ponytail: the whole queue is re-serialized per event; keep it in memory if 1000-item offline queues show up in profiles.
880
+ async enqueueEvent(event) {
881
+ const count = await this.mutateEvents((events) => {
882
+ const kept = events.slice(Math.max(0, events.length - EVENT_QUEUE_CAP + 1));
883
+ if (kept.length < events.length) {
884
+ this.diag("eventQueue", new Error("queue full, dropped the oldest event"));
885
+ }
886
+ return [...kept, event];
887
+ });
888
+ if (count >= EVENT_FLUSH_THRESHOLD)
889
+ this.startEventFlush();
890
+ else
891
+ this.armEventTimer(EVENT_FLUSH_DELAY_MS);
892
+ }
893
+ armEventTimer(ms) {
894
+ if (this.eventTimer !== null || this.disposed)
895
+ return;
896
+ this.eventTimer = setTimeout(() => {
897
+ this.eventTimer = null;
898
+ this.startEventFlush();
899
+ }, ms);
900
+ }
901
+ startEventFlush() {
902
+ if (this.eventFlush !== null || this.disposed)
903
+ return;
904
+ const hold = this.eventRetryAt - Date.now();
905
+ if (hold > 0) {
906
+ this.armEventTimer(hold);
907
+ return;
908
+ }
909
+ this.eventFlush = this.runEventFlush().finally(() => {
910
+ this.eventFlush = null;
911
+ });
912
+ }
913
+ async runEventFlush() {
914
+ const byPath = new Map();
915
+ for (const event of await this.withStorageLock(() => this.readEvents())) {
916
+ byPath.set(event.path, [...(byPath.get(event.path) ?? []), event]);
917
+ }
918
+ for (const [path, events] of byPath) {
919
+ for (let start = 0; start < events.length; start += EVENT_BATCH_SIZE) {
920
+ const chunk = events.slice(start, start + EVENT_BATCH_SIZE);
921
+ try {
922
+ const response = await this.request("POST", path, {
923
+ events: chunk.map((e) => e.body),
924
+ });
925
+ batchResults(response, path).forEach((result, i) => {
926
+ if (result.status < 400)
927
+ return;
928
+ this.diag("eventQueue", new RevnixError(result.status, `dropped ${path} event ${String(chunk[i]?.body.eventId)}: ${result.status} ${result.error ?? ""}`));
929
+ });
930
+ await this.removeEvents(chunk);
931
+ }
932
+ catch (err) {
933
+ if (worthQueueing(err)) {
934
+ this.eventRetryAt = Date.now() + EVENT_RETRY_HOLD_MS;
935
+ this.diag("eventQueue", err);
936
+ return;
937
+ }
938
+ await this.removeEvents(chunk);
939
+ this.diag("eventQueue", err);
940
+ }
941
+ }
942
+ }
943
+ }
944
+ async removeEvents(sent) {
945
+ const sentJson = new Set(sent.map((e) => JSON.stringify(e)));
946
+ await this.mutateEvents((events) => events.filter((e) => !sentJson.has(JSON.stringify(e))));
766
947
  }
767
948
  /** PT11: forward an MMP's attribution callback (Adjust, AppsFlyer, …) so
768
949
  * Revnix credits revenue to the right network/campaign. */
@@ -855,6 +1036,7 @@ export class RevnixClient {
855
1036
  async setAttributes(attributes) {
856
1037
  const customerId = await this.customerIdPromise;
857
1038
  await this.request("POST", `/v1/customers/${encodeURIComponent(customerId)}/attributes`, { attributes });
1039
+ await this.invalidatePlacements();
858
1040
  }
859
1041
  /** REV-272: hand the SDK the URL that opened your app, from wherever you
860
1042
  * already receive it (Linking.getInitialURL / onNewIntent / your router).
@@ -1202,6 +1384,19 @@ export class RevnixClient {
1202
1384
  await this.storage.setItem(SESSION_STARTED_AT_KEY, String(Date.now()));
1203
1385
  return extra;
1204
1386
  }
1387
+ subscribeLifecycle(configured) {
1388
+ if (configured === false)
1389
+ return;
1390
+ const lifecycle = configured ?? browserLifecycle();
1391
+ if (!lifecycle)
1392
+ return;
1393
+ try {
1394
+ this.lifecycleUnsubscribe = lifecycle.onStateChange((state) => this.onAppState(state));
1395
+ }
1396
+ catch (err) {
1397
+ this.diag("lifecycleSubscribe", err);
1398
+ }
1399
+ }
1205
1400
  /** REV-272: the launch-time moments, plus the foreground listener that
1206
1401
  * `session_start` is built on.
1207
1402
  *
@@ -1210,7 +1405,7 @@ export class RevnixClient {
1210
1405
  * install too when this launch generated the anonymous id. All three are
1211
1406
  * independent placements, so an app may have any subset configured and the
1212
1407
  * others cost nothing. */
1213
- startImplicit(configured) {
1408
+ startImplicit() {
1214
1409
  // freshInstall is set inside loadCustomerId, which the constructor kicked
1215
1410
  // off but did not await — so read it after that promise settles rather
1216
1411
  // than now, when it is still false on a genuine first launch.
@@ -1220,22 +1415,6 @@ export class RevnixClient {
1220
1415
  // are still reported — a launch is a launch whether or not a paywall
1221
1416
  // showed — but an app that configured all three must not have three
1222
1417
  // paywalls pushed onto its first frame.
1223
- // Subscribed BEFORE the batch, which can take a full network timeout per
1224
- // moment when offline: a customer who backgrounds the app during that
1225
- // window and comes back an hour later is a session, and missing the
1226
- // background transition would lose it.
1227
- if (configured !== false) {
1228
- const lifecycle = configured ?? browserLifecycle();
1229
- if (lifecycle) {
1230
- try {
1231
- this.lifecycleUnsubscribe = lifecycle.onStateChange((state) => this.onAppState(state));
1232
- }
1233
- catch (err) {
1234
- // A broken adapter costs session detection, not the client.
1235
- this.diag("lifecycleSubscribe", err);
1236
- }
1237
- }
1238
- }
1239
1418
  const sessionExtra = this.consumePreviousSessionMs().catch((err) => {
1240
1419
  this.diag("session", err);
1241
1420
  return undefined;
@@ -1270,6 +1449,10 @@ export class RevnixClient {
1270
1449
  onAppState(state) {
1271
1450
  if (this.disposed)
1272
1451
  return;
1452
+ if (state === "background")
1453
+ this.startEventFlush();
1454
+ if (!this.implicitEnabled)
1455
+ return;
1273
1456
  if (state === "background") {
1274
1457
  // First report wins: a platform that repeats "background" (a paused
1275
1458
  // Activity re-reporting) must not keep resetting the clock forward.
@@ -1320,6 +1503,9 @@ export class RevnixClient {
1320
1503
  this.lifecycleUnsubscribe = null;
1321
1504
  this.stopStoreObserver?.();
1322
1505
  this.stopStoreObserver = null;
1506
+ if (this.eventTimer !== null)
1507
+ clearTimeout(this.eventTimer);
1508
+ this.eventTimer = null;
1323
1509
  }
1324
1510
  /** A block paywall reporting a paint string it could not read. Routed to the
1325
1511
  * same sink as every other swallowed failure, so a host that already wired
@@ -1587,27 +1773,56 @@ export class RevnixClient {
1587
1773
  }
1588
1774
  return entry;
1589
1775
  }
1590
- async writePlacementCache(placementKey, data, customerId) {
1776
+ writePlacementCache(placementKey, data, customerId, fetchedAt) {
1777
+ return this.mutatePlacements((map) => {
1778
+ map[placementKey] = { data, fetchedAt, customerId };
1779
+ });
1780
+ }
1781
+ deletePlacementCache(placementKey) {
1782
+ return this.mutatePlacements((map) => {
1783
+ delete map[placementKey];
1784
+ });
1785
+ }
1786
+ mutatePlacements(mutate) {
1787
+ return this.withStorageLock(async () => {
1788
+ try {
1789
+ const map = await this.readPlacementMap();
1790
+ mutate(map);
1791
+ await this.storage.setItem(PLACEMENTS_CACHE_KEY, JSON.stringify(map));
1792
+ }
1793
+ catch (err) {
1794
+ this.diag("storageWrite", err);
1795
+ }
1796
+ });
1797
+ }
1798
+ async placementsInvalidatedAt() {
1799
+ try {
1800
+ return Number(await this.storage.getItem(PLACEMENTS_INVALIDATED_AT_KEY)) || 0;
1801
+ }
1802
+ catch {
1803
+ return 0;
1804
+ }
1805
+ }
1806
+ async invalidatePlacements() {
1591
1807
  try {
1592
- const map = await this.readPlacementMap();
1593
- map[placementKey] = { data, fetchedAt: Date.now(), customerId };
1594
- await this.storage.setItem(PLACEMENTS_CACHE_KEY, JSON.stringify(map));
1808
+ await this.storage.setItem(PLACEMENTS_INVALIDATED_AT_KEY, String(Date.now()));
1595
1809
  }
1596
1810
  catch (err) {
1597
- // Best-effort, but reported (REV-200 M26).
1598
1811
  this.diag("storageWrite", err);
1599
1812
  }
1600
1813
  }
1814
+ withStorageLock(task) {
1815
+ const run = this.storageLock.then(task);
1816
+ this.storageLock = run.then(() => { }, () => { });
1817
+ return run;
1818
+ }
1601
1819
  mutateQueue(mutate) {
1602
- const run = this.queueLock.then(async () => {
1820
+ return this.withStorageLock(async () => {
1603
1821
  const queue = await this.readPurchaseQueue();
1604
1822
  const next = mutate(queue);
1605
1823
  if (next !== queue)
1606
1824
  await this.writePurchaseQueue(next);
1607
1825
  });
1608
- // The chain must survive a failed mutation or every later one deadlocks.
1609
- this.queueLock = run.catch(() => { });
1610
- return run;
1611
1826
  }
1612
1827
  async readPurchaseQueue() {
1613
1828
  try {
@@ -1632,17 +1847,22 @@ export class RevnixClient {
1632
1847
  }
1633
1848
  }
1634
1849
  enqueuePurchase(customerId, input) {
1635
- // Repeated failures of the same purchase collapse to one queued entry.
1636
- return this.mutateQueue((queue) => queue.some((q) => pendingKey(q.input) === pendingKey(input))
1637
- ? queue
1638
- : [...queue, { customerId, input }]);
1850
+ return this.mutateQueue((queue) => {
1851
+ if (queue.some((q) => pendingKey(q.input) === pendingKey(input)))
1852
+ return queue;
1853
+ const kept = queue.slice(Math.max(0, queue.length - PURCHASE_QUEUE_CAP + 1));
1854
+ for (const dropped of queue.slice(0, queue.length - kept.length)) {
1855
+ this.diag("registerPurchase", new Error(`queue full, dropped ${pendingKey(dropped.input)}`));
1856
+ }
1857
+ return [...kept, { customerId, input, queuedAt: Date.now() }];
1858
+ });
1639
1859
  }
1640
- async request(method, path, body, extraHeaders) {
1860
+ async request(method, path, body, extraHeaders, timeoutMs = this.requestTimeoutMs) {
1641
1861
  // C7: abort the request after requestTimeoutMs so a slow or dead backend
1642
1862
  // can't hang the app. AbortController is guarded for very old runtimes.
1643
1863
  const controller = typeof AbortController !== "undefined" ? new AbortController() : undefined;
1644
1864
  const timer = controller
1645
- ? setTimeout(() => controller.abort(), this.requestTimeoutMs)
1865
+ ? setTimeout(() => controller.abort(), timeoutMs)
1646
1866
  : undefined;
1647
1867
  // REV-200 (M26): piggyback the background-failure counter on this
1648
1868
  // request so field pain reaches the backend's request logs. Cleared only
@@ -1671,7 +1891,7 @@ export class RevnixClient {
1671
1891
  catch (err) {
1672
1892
  if (controller?.signal.aborted ||
1673
1893
  (err instanceof Error && err.name === "AbortError")) {
1674
- throw new RevnixTimeoutError(`Revnix request to ${path} timed out after ${this.requestTimeoutMs}ms`);
1894
+ throw new RevnixTimeoutError(`Revnix request to ${path} timed out after ${timeoutMs}ms`);
1675
1895
  }
1676
1896
  throw new RevnixNetworkError(err instanceof Error ? err.message : "network request failed");
1677
1897
  }
@@ -18,6 +18,7 @@ import * as React from "react";
18
18
  import { ActivityIndicator, Alert, Image, Linking, Pressable, ScrollView, StyleSheet, Text, View, } from "react-native";
19
19
  import { PREVIEW_PLACEMENT_KEY } from "../implicit.js";
20
20
  import { getStoreAdapter } from "../store.js";
21
+ import { paywallImageUrls } from "../client.js";
21
22
  import { withAlpha } from "../blocks/color.js";
22
23
  import { RevnixBlockPaywall, } from "./RevnixBlockPaywall.js";
23
24
  /** `config.blocks` is opaque on the core config (the non-UI entrypoint must
@@ -66,6 +67,12 @@ const CURRENCY_SYMBOL = /[$€£¥₹₩₽₺₫₪฿₴₦₱]/;
66
67
  // part of the public theme — derived from the config's mode, in lockstep
67
68
  // with the dashboard preview's SCREEN_PALETTES.card.
68
69
  const CARD_BG = { dark: "#181b22", light: "#f4f5f7" };
70
+ /** Warms React Native's image cache with every image `resolution`'s paywall
71
+ * loads, so it paints without waiting on the network. Pair it with
72
+ * `client.preload` / `resolvePlacement`. Never throws. */
73
+ export async function prefetchPaywallImages(resolution) {
74
+ await Promise.all(paywallImageUrls(resolution?.paywall).map((url) => Image.prefetch(url).catch(() => false)));
75
+ }
69
76
  export function RevnixPaywall(props) {
70
77
  // One view per mount: remounting (a re-shown paywall) is a genuine new
71
78
  // display; re-renders are not.