react-native-revnix 1.6.0 → 1.7.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.
Files changed (41) hide show
  1. package/README.md +16 -0
  2. package/android/build.gradle +3 -0
  3. package/android/consumer-rules.pro +1 -0
  4. package/android/src/main/kotlin/com/revnix/reactnative/RevnixStoreModule.kt +43 -0
  5. package/dist/blocks/locale.js +7 -0
  6. package/dist/blocks/selection.d.ts +7 -1
  7. package/dist/blocks/selection.js +9 -0
  8. package/dist/blocks/tags.d.ts +3 -0
  9. package/dist/blocks/tags.js +33 -0
  10. package/dist/blocks/types.d.ts +45 -3
  11. package/dist/blocks/types.js +1 -0
  12. package/dist/client.d.ts +4 -0
  13. package/dist/client.js +73 -5
  14. package/dist/esm/blocks/locale.js +7 -0
  15. package/dist/esm/blocks/selection.js +8 -0
  16. package/dist/esm/blocks/tags.js +31 -0
  17. package/dist/esm/blocks/types.js +1 -0
  18. package/dist/esm/client.js +73 -5
  19. package/dist/esm/store/index.js +7 -0
  20. package/dist/esm/ui/RevnixBlockPaywall.js +103 -22
  21. package/dist/esm/version.js +1 -1
  22. package/dist/index.d.ts +1 -1
  23. package/dist/store/index.d.ts +8 -0
  24. package/dist/store/index.js +10 -1
  25. package/dist/types.d.ts +32 -0
  26. package/dist/ui/RevnixBlockPaywall.js +100 -19
  27. package/dist/version.d.ts +1 -1
  28. package/dist/version.js +1 -1
  29. package/ios/RevnixStore.m +4 -0
  30. package/ios/RevnixStore.swift +29 -0
  31. package/package.json +1 -1
  32. package/src/blocks/locale.ts +7 -0
  33. package/src/blocks/selection.ts +13 -0
  34. package/src/blocks/tags.ts +38 -0
  35. package/src/blocks/types.ts +51 -2
  36. package/src/client.ts +90 -8
  37. package/src/index.ts +1 -0
  38. package/src/store/index.ts +15 -0
  39. package/src/types.ts +30 -0
  40. package/src/ui/RevnixBlockPaywall.tsx +186 -20
  41. package/src/version.ts +1 -1
package/README.md CHANGED
@@ -145,6 +145,22 @@ Learn more in [A/B tests](https://www.revnix.io/docs/ab-tests).
145
145
  - **MMP forwarding** to AppsFlyer and Adjust, **ad revenue** logging and **uninstall measurement**. See [MMP attribution](https://www.revnix.io/docs/mmp-attribution), [Ad revenue](https://www.revnix.io/docs/ad-revenue) and [Uninstall measurement](https://www.revnix.io/docs/uninstall-measurement).
146
146
  - **Fraud prevention**: anonymous-IP and click-injection checks keep paid channels honest. See [Fraud prevention](https://www.revnix.io/docs/fraud-prevention).
147
147
 
148
+ ### Device integrity
149
+
150
+ Pass `nativeDeviceIntegrity` so install reports carry Apple App Attest or Google Play Integrity evidence. With "Require device integrity" on in the dashboard, installs without it lose their attribution.
151
+
152
+ ```ts
153
+ import { nativeDeviceIntegrity } from "react-native-revnix/store";
154
+
155
+ Revnix.configure({
156
+ apiKey: "rvx_pk_live_…",
157
+ baseUrl: "https://<your-deployment>.convex.site",
158
+ deviceIntegrity: nativeDeviceIntegrity,
159
+ });
160
+ ```
161
+
162
+ It runs once per customer, waits at most 10 seconds and never blocks the report. Needs App Attest on iOS or Google Play services on Android; simulators and emulators send no evidence.
163
+
148
164
  ## Real-time analytics for your React Native app
149
165
 
150
166
  ![Revnix overview: active subscriptions, revenue and MRR over 90 days](https://www.revnix.io/sdk/react-native/analytics.png)
@@ -11,6 +11,7 @@ android {
11
11
 
12
12
  defaultConfig {
13
13
  minSdkVersion safeExtGet("minSdkVersion", 24)
14
+ consumerProguardFiles "consumer-rules.pro"
14
15
  }
15
16
 
16
17
  compileOptions {
@@ -31,4 +32,6 @@ repositories {
31
32
  dependencies {
32
33
  implementation "com.facebook.react:react-native:+"
33
34
  implementation "com.android.billingclient:billing:8.3.0"
35
+ implementation "com.google.android.play:integrity:1.6.0"
36
+ compileOnly "com.google.android.gms:play-services-ads-identifier:18.2.0"
34
37
  }
@@ -0,0 +1 @@
1
+ -dontwarn com.google.android.gms.ads.identifier.**
@@ -1,5 +1,6 @@
1
1
  package com.revnix.reactnative
2
2
 
3
+ import android.util.Base64
3
4
  import android.util.Log
4
5
  import com.android.billingclient.api.AcknowledgePurchaseParams
5
6
  import com.android.billingclient.api.BillingClient
@@ -21,6 +22,10 @@ import com.facebook.react.bridge.ReactContextBaseJavaModule
21
22
  import com.facebook.react.bridge.ReactMethod
22
23
  import com.facebook.react.bridge.WritableMap
23
24
  import com.facebook.react.modules.core.DeviceEventManagerModule
25
+ import com.google.android.gms.ads.identifier.AdvertisingIdClient
26
+ import com.google.android.play.core.integrity.IntegrityManagerFactory
27
+ import com.google.android.play.core.integrity.IntegrityTokenRequest
28
+ import java.security.MessageDigest
24
29
  import java.util.concurrent.ConcurrentHashMap
25
30
  import java.util.concurrent.atomic.AtomicReference
26
31
 
@@ -115,6 +120,44 @@ class RevnixStoreModule(context: ReactApplicationContext) :
115
120
  }
116
121
  }
117
122
 
123
+ @ReactMethod
124
+ fun deviceIntegrity(customerId: String, promise: Promise) {
125
+ val nonce = Base64.encodeToString(
126
+ MessageDigest.getInstance("SHA-256").digest(customerId.toByteArray(Charsets.UTF_8)),
127
+ Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP,
128
+ )
129
+ try {
130
+ IntegrityManagerFactory.create(reactApplicationContext.applicationContext)
131
+ .requestIntegrityToken(IntegrityTokenRequest.builder().setNonce(nonce).build())
132
+ .addOnSuccessListener { response ->
133
+ promise.resolve(
134
+ Arguments.createMap().apply {
135
+ putString("platform", "android")
136
+ putString("token", response.token())
137
+ },
138
+ )
139
+ }
140
+ .addOnFailureListener { promise.resolve(null) }
141
+ .addOnCanceledListener { promise.resolve(null) }
142
+ } catch (err: Exception) {
143
+ promise.resolve(null)
144
+ }
145
+ }
146
+
147
+ @ReactMethod
148
+ fun advertisingId(promise: Promise) {
149
+ Thread {
150
+ try {
151
+ val info = AdvertisingIdClient.getAdvertisingIdInfo(reactApplicationContext.applicationContext)
152
+ promise.resolve(
153
+ info.id.takeUnless { info.isLimitAdTrackingEnabled || it == "00000000-0000-0000-0000-000000000000" },
154
+ )
155
+ } catch (err: Throwable) {
156
+ promise.reject("advertising_id_unavailable", err.message, err)
157
+ }
158
+ }.start()
159
+ }
160
+
118
161
  override fun onPurchasesUpdated(result: BillingResult, purchases: List<Purchase>?) {
119
162
  if (result.responseCode != BillingResponseCode.OK) {
120
163
  settle(pending.getAndSet(null) ?: return, result)
@@ -123,7 +123,14 @@ function localizeBlock(block, lookup) {
123
123
  case "text":
124
124
  return { ...block, text: at("text", block.text) };
125
125
  case "button":
126
+ case "toggle":
126
127
  return { ...block, label: at("label", block.label) };
128
+ case "timer":
129
+ return {
130
+ ...block,
131
+ format: opt("format", block.format),
132
+ expiredText: opt("expiredText", block.expiredText),
133
+ };
127
134
  case "list":
128
135
  return {
129
136
  ...block,
@@ -1,4 +1,4 @@
1
- import type { BlockStyle, PaywallBlock, PaywallBlockDoc } from "./types.js";
1
+ import type { BlockStyle, PaywallBlock, PaywallBlockDoc, ToggleBlock } from "./types.js";
2
2
  import type { RevnixPaywallPackage } from "../ui/RevnixPaywall.js";
3
3
  /** Where a block sits: inside the selected plan card, inside another plan
4
4
  * card, or outside any plan card (undefined). */
@@ -9,6 +9,12 @@ export type SelectionContext = "selected" | "unselected" | undefined;
9
9
  export declare function selectedPackageId(packages: ReadonlyArray<{
10
10
  packageId: string;
11
11
  }>, ...candidates: Array<string | undefined>): string | undefined;
12
+ export declare function toggleSelection(block: ToggleBlock, packages: ReadonlyArray<{
13
+ packageId: string;
14
+ }>, selectedId: string | undefined): {
15
+ on: boolean;
16
+ target: string;
17
+ } | null;
12
18
  /** The context a package-bearing card opens for itself and its subtree. */
13
19
  export declare function packageContext(pkg: {
14
20
  packageId: string;
@@ -9,6 +9,7 @@
9
9
  Object.defineProperty(exports, "__esModule", { value: true });
10
10
  exports.CANVAS_MAX_WIDTH = void 0;
11
11
  exports.selectedPackageId = selectedPackageId;
12
+ exports.toggleSelection = toggleSelection;
12
13
  exports.packageContext = packageContext;
13
14
  exports.effectiveStyle = effectiveStyle;
14
15
  exports.isVisible = isVisible;
@@ -26,6 +27,14 @@ function selectedPackageId(packages, ...candidates) {
26
27
  }
27
28
  return packages[0]?.packageId;
28
29
  }
30
+ function toggleSelection(block, packages, selectedId) {
31
+ const on = packages[block.onPackageIndex];
32
+ const off = packages[block.offPackageIndex];
33
+ if (on === undefined || off === undefined)
34
+ return null;
35
+ const isOn = on.packageId === selectedId;
36
+ return { on: isOn, target: (isOn ? off : on).packageId };
37
+ }
29
38
  /** The context a package-bearing card opens for itself and its subtree. */
30
39
  function packageContext(pkg, selectedId) {
31
40
  if (pkg === undefined)
@@ -1,4 +1,5 @@
1
1
  import type { RevnixPaywallPackage } from "../ui/RevnixPaywall.js";
2
+ import type { TimerBlock } from "./types.js";
2
3
  /** Minor units per major unit. Not every currency is a hundredth: JPY and KRW
3
4
  * have no minor unit at all, so dividing by 100 would understate a price by
4
5
  * 100×. Intl knows each currency's exponent — ask it rather than assume.
@@ -10,3 +11,5 @@ export declare function minorUnitsForCurrency(currency: string): number;
10
11
  /** Fills a copy template from one package. `all` is the rest of the offering,
11
12
  * which {save_percent} needs to have something to compare against. */
12
13
  export declare function resolveTags(text: string, pkg: RevnixPaywallPackage | undefined, all?: RevnixPaywallPackage[]): string;
14
+ export declare function formatCountdown(format: string, remaining: number): string;
15
+ export declare function timerText(block: TimerBlock, shownAt: number, now: number): string | null;
@@ -13,6 +13,8 @@
13
13
  Object.defineProperty(exports, "__esModule", { value: true });
14
14
  exports.minorUnitsForCurrency = minorUnitsForCurrency;
15
15
  exports.resolveTags = resolveTags;
16
+ exports.formatCountdown = formatCountdown;
17
+ exports.timerText = timerText;
16
18
  /** Renewal cycles, in months. Lifetime and one-time products have no cycle. */
17
19
  const MONTHS = {
18
20
  weekly: 1 / 4.345,
@@ -119,3 +121,34 @@ function resolveTags(text, pkg, all = []) {
119
121
  }
120
122
  });
121
123
  }
124
+ function formatCountdown(format, remaining) {
125
+ const pad = (n) => String(n).padStart(2, "0");
126
+ let span = Infinity;
127
+ const part = (token, unit) => {
128
+ const value = Math.floor((remaining % span) / unit);
129
+ if (format.includes(token))
130
+ span = unit;
131
+ return value;
132
+ };
133
+ const days = part("{d}", 86400);
134
+ const hours = part("{hh}", 3600);
135
+ const minutes = part("{mm}", 60);
136
+ const seconds = part("{ss}", 1);
137
+ return format
138
+ .replace(/\{d\}/g, String(days))
139
+ .replace(/\{hh\}/g, pad(hours))
140
+ .replace(/\{mm\}/g, pad(minutes))
141
+ .replace(/\{ss\}/g, pad(seconds));
142
+ }
143
+ function timerText(block, shownAt, now) {
144
+ const deadline = block.endsAt !== undefined
145
+ ? Date.parse(block.endsAt)
146
+ : shownAt + (block.duration ?? 0) * 1000;
147
+ const remaining = Number.isNaN(deadline)
148
+ ? 0
149
+ : Math.max(0, Math.floor((deadline - now) / 1000));
150
+ if (remaining > 0) {
151
+ return formatCountdown(block.format ?? "{hh}:{mm}:{ss}", remaining);
152
+ }
153
+ return block.expiredText ?? null;
154
+ }
@@ -229,13 +229,55 @@ export interface SpacerBlock {
229
229
  selectedStyle?: BlockStyle;
230
230
  visibility?: BlockVisibility;
231
231
  }
232
+ /** Countdown text. `endsAt` is one deadline for everyone; `duration` counts
233
+ * from the moment this paywall view opened. At zero it draws `expiredText`,
234
+ * or nothing when that is absent. Tokens in `format`: {d} {hh} {mm} {ss}. */
235
+ export interface TimerBlock {
236
+ id: string;
237
+ type: "timer";
238
+ endsAt?: string;
239
+ duration?: number;
240
+ format?: string;
241
+ expiredText?: string;
242
+ style?: BlockStyle;
243
+ selectedStyle?: BlockStyle;
244
+ visibility?: BlockVisibility;
245
+ }
246
+ /** A muted looping clip. React Native has no built-in player, so this SDK
247
+ * draws `poster` in its place, sized like an image block. */
248
+ export interface VideoBlock {
249
+ id: string;
250
+ type: "video";
251
+ url: string;
252
+ poster?: string;
253
+ fit?: "cover" | "contain";
254
+ style?: BlockStyle;
255
+ selectedStyle?: BlockStyle;
256
+ visibility?: BlockVisibility;
257
+ }
258
+ /** The free-trial switch: ON while package `onPackageIndex` is selected, and
259
+ * tapping it selects the other one. Hidden when the offering lacks either
260
+ * index. */
261
+ export interface ToggleBlock {
262
+ id: string;
263
+ type: "toggle";
264
+ label: string;
265
+ onPackageIndex: number;
266
+ offPackageIndex: number;
267
+ style?: BlockStyle;
268
+ selectedStyle?: BlockStyle;
269
+ visibility?: BlockVisibility;
270
+ }
232
271
  /** The one container block. `layout` picks how children are placed: column /
233
272
  * row are flex lines, `stack` layers them (children position with
234
- * style.inset or the edge offsets), and `grid` is an N-column grid. */
273
+ * style.inset or the edge offsets), `grid` is an N-column grid, and
274
+ * `carousel` pages through its children one swipe at a time. */
235
275
  export interface CardBlock {
236
276
  id: string;
237
277
  type: "card";
238
- layout?: "column" | "row" | "stack" | "grid";
278
+ layout?: "column" | "row" | "stack" | "grid" | "carousel";
279
+ /** carousel only: seconds per page before it moves on by itself. */
280
+ autoAdvance?: number;
239
281
  /** Renders this container once per package in the attached offering. Its
240
282
  * subtree reads the package through tag variables. */
241
283
  repeat?: "packages";
@@ -254,7 +296,7 @@ export interface CardBlock {
254
296
  selectedStyle?: BlockStyle;
255
297
  visibility?: BlockVisibility;
256
298
  }
257
- export type PaywallBlock = TextBlock | ImageBlock | ListBlock | ProductsBlock | ButtonBlock | LinksBlock | LineBlock | SpacerBlock | CardBlock;
299
+ export type PaywallBlock = TextBlock | ImageBlock | ListBlock | ProductsBlock | ButtonBlock | LinksBlock | LineBlock | SpacerBlock | TimerBlock | VideoBlock | ToggleBlock | CardBlock;
258
300
  /** The published document: screen palette plus the block tree. */
259
301
  export interface PaywallBlockDoc {
260
302
  version: number;
@@ -38,6 +38,7 @@ function hasCloseAction(blocks) {
38
38
  : b.type === "card"
39
39
  ? b.repeat === undefined &&
40
40
  b.packageIndex === undefined &&
41
+ b.layout !== "carousel" &&
41
42
  hasCloseAction(b.children)
42
43
  : (b.type === "text" || b.type === "image" || b.type === "button") &&
43
44
  b.action === "close");
package/dist/client.d.ts CHANGED
@@ -26,6 +26,8 @@ export declare class RevnixClient {
26
26
  private readonly onImplicitPaywall?;
27
27
  private readonly onDeferredDeepLink?;
28
28
  private readonly onAttribution?;
29
+ private readonly deviceIntegrity?;
30
+ private integrityRequest;
29
31
  private deferredDeepLinkClaimed;
30
32
  private landingUrl;
31
33
  private lastDeepLinkWrite;
@@ -279,6 +281,7 @@ export declare class RevnixClient {
279
281
  * report the token dead. iOS/Android only (no-op on web),
280
282
  * fire-and-forget. */
281
283
  setPushToken(token: string): void;
284
+ private reportAdvertisingId;
282
285
  /** REV-272: the two moments that happen ON a paywall, with the loop guard
283
286
  * applied. `fromPaywallId` travels so the server can refuse to hand back
284
287
  * the very paywall being dismissed — the backstop for a host that reports
@@ -416,6 +419,7 @@ export declare class RevnixClient {
416
419
  /** Fire-and-forget install report (REV-093). Never blocks startup and
417
420
  * never throws — the backend absorbs retries idempotently per id. */
418
421
  private reportInstall;
422
+ private integrityFor;
419
423
  /** REV-268: assemble and encode the device facts once. Runtime-detected
420
424
  * facts first, the app's `device` over them, then the three the SDK owns.
421
425
  * `installedAt` is the first launch this storage ever saw — written on
package/dist/client.js CHANGED
@@ -19,9 +19,11 @@ const PENDING_PURCHASES_KEY = "revnix.pendingPurchases";
19
19
  const PLACEMENTS_CACHE_KEY = "revnix.placements";
20
20
  const LAST_ATTRIBUTION_KEY = "revnix.lastAttribution";
21
21
  const LAST_PUSH_TOKEN_KEY = "revnix.lastPushToken";
22
+ const LAST_AD_ID_KEY = "revnix.lastAdId";
22
23
  const EVENT_QUEUE_KEY = "revnix.eventQueue";
23
24
  const PLACEMENTS_INVALIDATED_AT_KEY = "revnix.placementsInvalidatedAt";
24
25
  const DEFAULT_TIMEOUT_MS = 10000;
26
+ const INTEGRITY_TIMEOUT_MS = 10000;
25
27
  const EVENT_QUEUE_CAP = 1000;
26
28
  const EVENT_BATCH_SIZE = 100;
27
29
  const EVENT_FLUSH_THRESHOLD = 20;
@@ -102,6 +104,19 @@ const jittered = (ms) => ms * (0.8 + Math.random() * 0.4);
102
104
  // from memory. Short enough that a webhook-driven change is picked up within
103
105
  // half a minute; long enough that gating five screens costs one request.
104
106
  const DEFAULT_ENTITLEMENTS_TTL_MS = 30000;
107
+ const fitsWithin = (value, max) => typeof value === "string" && value.length > 0 && value.length <= max;
108
+ function wellFormedIntegrity(evidence) {
109
+ if (typeof evidence !== "object" || evidence === null)
110
+ return undefined;
111
+ const { platform, keyId, attestation, token } = evidence;
112
+ if (platform === "ios" && fitsWithin(keyId, 128) && fitsWithin(attestation, 16384)) {
113
+ return { platform, keyId, attestation };
114
+ }
115
+ if (platform === "android" && fitsWithin(token, 16384)) {
116
+ return { platform, token };
117
+ }
118
+ return undefined;
119
+ }
105
120
  class RevnixClient {
106
121
  constructor(config) {
107
122
  /** True while the current id is a generated anon id living on
@@ -114,6 +129,7 @@ class RevnixClient {
114
129
  // client (dev fast-refresh re-running configure()) does not keep retrying
115
130
  // and prefetching alongside its successor.
116
131
  this.disposed = false;
132
+ this.integrityRequest = null;
117
133
  this.deferredDeepLinkClaimed = false;
118
134
  this.landingUrl = null;
119
135
  this.lastDeepLinkWrite = Promise.resolve();
@@ -190,12 +206,15 @@ class RevnixClient {
190
206
  this.onDiagnostic = config.onDiagnostic;
191
207
  this.onDeferredDeepLink = config.onDeferredDeepLink;
192
208
  this.onAttribution = config.onAttribution;
209
+ this.deviceIntegrity = config.deviceIntegrity;
193
210
  this.readYourWritesDelaysMs =
194
211
  config.readYourWritesDelaysMs ?? [250, 500, 1000, 2000];
195
212
  this.customerIdPromise = this.loadCustomerId(config.customerId);
196
213
  // C7: retry any purchases that failed to register on a previous launch, so
197
214
  // a paying user never stays locked out because a POST didn't land.
198
215
  void this.retryPendingPurchases().catch((err) => this.diag("retryPendingPurchases", err));
216
+ if (config.advertisingId)
217
+ this.reportAdvertisingId(config.advertisingId);
199
218
  const store = (0, store_js_1.getStoreAdapter)();
200
219
  if (store) {
201
220
  this.stopStoreObserver = store.observe((tx) => void this.registerStoreTransaction(store, tx).catch((err) => this.diag("storeObserver", err)));
@@ -971,6 +990,7 @@ class RevnixClient {
971
990
  ...(attribution.campaign ? { campaign: attribution.campaign } : {}),
972
991
  ...(attribution.adGroup ? { adGroup: attribution.adGroup } : {}),
973
992
  ...(attribution.creative ? { creative: attribution.creative } : {}),
993
+ ...(await this.integrityFor(customerId)),
974
994
  });
975
995
  await this.storage.setItem(LAST_ATTRIBUTION_KEY, payload);
976
996
  })
@@ -1004,6 +1024,21 @@ class RevnixClient {
1004
1024
  })
1005
1025
  .catch((err) => this.diag("setPushToken", err));
1006
1026
  }
1027
+ reportAdvertisingId(source) {
1028
+ void Promise.resolve()
1029
+ .then(source)
1030
+ .then(async (value) => {
1031
+ const id = value || null;
1032
+ const customerId = await this.customerIdPromise;
1033
+ const payload = JSON.stringify({ customerId, id });
1034
+ const last = await this.storage.getItem(LAST_AD_ID_KEY);
1035
+ if (last === payload || (id === null && last === null))
1036
+ return;
1037
+ await this.setAttributes({ gps_adid: id });
1038
+ await this.storage.setItem(LAST_AD_ID_KEY, payload);
1039
+ })
1040
+ .catch((err) => this.diag("advertisingId", err));
1041
+ }
1007
1042
  /** REV-272: the two moments that happen ON a paywall, with the loop guard
1008
1043
  * applied. `fromPaywallId` travels so the server can refuse to hand back
1009
1044
  * the very paywall being dismissed — the backstop for a host that reports
@@ -1226,12 +1261,13 @@ class RevnixClient {
1226
1261
  if (this.ephemeralAnonId)
1227
1262
  return;
1228
1263
  void this.customerIdPromise
1229
- .then((customerId) => this.request("POST", "/v1/installs", {
1264
+ .then(async (customerId) => this.request("POST", "/v1/installs", {
1230
1265
  customerId,
1231
1266
  occurredAt: Date.now(),
1232
1267
  sdkVersion: version_js_1.SDK_VERSION,
1233
1268
  installReferrer: referrer.slice(0, 1024),
1234
1269
  ...this.installMetadata,
1270
+ ...(await this.integrityFor(customerId)),
1235
1271
  }))
1236
1272
  .then((body) => {
1237
1273
  this.refreshAttribution();
@@ -1266,12 +1302,13 @@ class RevnixClient {
1266
1302
  if (this.ephemeralAnonId)
1267
1303
  return Promise.resolve(undefined);
1268
1304
  return this.customerIdPromise
1269
- .then((customerId) => this.request("POST", "/v1/installs", {
1305
+ .then(async (customerId) => this.request("POST", "/v1/installs", {
1270
1306
  customerId,
1271
1307
  occurredAt: Date.now(),
1272
1308
  sdkVersion: version_js_1.SDK_VERSION,
1273
1309
  attributionToken: token.slice(0, 2048),
1274
1310
  ...this.installMetadata,
1311
+ ...(await this.integrityFor(customerId)),
1275
1312
  }))
1276
1313
  .then((body) => {
1277
1314
  this.refreshAttribution();
@@ -1604,12 +1641,15 @@ class RevnixClient {
1604
1641
  /** Fire-and-forget install report (REV-093). Never blocks startup and
1605
1642
  * never throws — the backend absorbs retries idempotently per id. */
1606
1643
  reportInstall(customerId) {
1607
- void this.request("POST", "/v1/installs", {
1644
+ const occurredAt = Date.now();
1645
+ void this.integrityFor(customerId)
1646
+ .then((integrity) => this.request("POST", "/v1/installs", {
1608
1647
  customerId,
1609
- occurredAt: Date.now(),
1648
+ occurredAt,
1610
1649
  sdkVersion: version_js_1.SDK_VERSION,
1611
1650
  ...this.installMetadata,
1612
- })
1651
+ ...integrity,
1652
+ }))
1613
1653
  .then((body) => {
1614
1654
  if (!this.ephemeralAnonId)
1615
1655
  this.refreshAttribution();
@@ -1617,6 +1657,34 @@ class RevnixClient {
1617
1657
  })
1618
1658
  .catch((err) => this.diag("reportInstall", err));
1619
1659
  }
1660
+ integrityFor(customerId) {
1661
+ const source = this.deviceIntegrity;
1662
+ if (!source)
1663
+ return Promise.resolve({});
1664
+ if (this.integrityRequest?.customerId !== customerId) {
1665
+ let timer;
1666
+ const timeout = new Promise((_, reject) => {
1667
+ timer = setTimeout(() => reject(new Error("device integrity timed out")), INTEGRITY_TIMEOUT_MS);
1668
+ });
1669
+ const body = Promise.race([
1670
+ Promise.resolve().then(() => source(customerId)),
1671
+ timeout,
1672
+ ])
1673
+ .then((evidence) => {
1674
+ const integrity = wellFormedIntegrity(evidence);
1675
+ if (!integrity)
1676
+ throw new Error("device integrity unavailable");
1677
+ return { integrity };
1678
+ })
1679
+ .catch((err) => {
1680
+ this.diag("deviceIntegrity", err);
1681
+ return {};
1682
+ })
1683
+ .finally(() => clearTimeout(timer));
1684
+ this.integrityRequest = { customerId, body };
1685
+ }
1686
+ return this.integrityRequest.body;
1687
+ }
1620
1688
  /** REV-268: assemble and encode the device facts once. Runtime-detected
1621
1689
  * facts first, the app's `device` over them, then the three the SDK owns.
1622
1690
  * `installedAt` is the first launch this storage ever saw — written on
@@ -115,7 +115,14 @@ function localizeBlock(block, lookup) {
115
115
  case "text":
116
116
  return { ...block, text: at("text", block.text) };
117
117
  case "button":
118
+ case "toggle":
118
119
  return { ...block, label: at("label", block.label) };
120
+ case "timer":
121
+ return {
122
+ ...block,
123
+ format: opt("format", block.format),
124
+ expiredText: opt("expiredText", block.expiredText),
125
+ };
119
126
  case "list":
120
127
  return {
121
128
  ...block,
@@ -17,6 +17,14 @@ export function selectedPackageId(packages, ...candidates) {
17
17
  }
18
18
  return packages[0]?.packageId;
19
19
  }
20
+ export function toggleSelection(block, packages, selectedId) {
21
+ const on = packages[block.onPackageIndex];
22
+ const off = packages[block.offPackageIndex];
23
+ if (on === undefined || off === undefined)
24
+ return null;
25
+ const isOn = on.packageId === selectedId;
26
+ return { on: isOn, target: (isOn ? off : on).packageId };
27
+ }
20
28
  /** The context a package-bearing card opens for itself and its subtree. */
21
29
  export function packageContext(pkg, selectedId) {
22
30
  if (pkg === undefined)
@@ -115,3 +115,34 @@ export function resolveTags(text, pkg, all = []) {
115
115
  }
116
116
  });
117
117
  }
118
+ export function formatCountdown(format, remaining) {
119
+ const pad = (n) => String(n).padStart(2, "0");
120
+ let span = Infinity;
121
+ const part = (token, unit) => {
122
+ const value = Math.floor((remaining % span) / unit);
123
+ if (format.includes(token))
124
+ span = unit;
125
+ return value;
126
+ };
127
+ const days = part("{d}", 86400);
128
+ const hours = part("{hh}", 3600);
129
+ const minutes = part("{mm}", 60);
130
+ const seconds = part("{ss}", 1);
131
+ return format
132
+ .replace(/\{d\}/g, String(days))
133
+ .replace(/\{hh\}/g, pad(hours))
134
+ .replace(/\{mm\}/g, pad(minutes))
135
+ .replace(/\{ss\}/g, pad(seconds));
136
+ }
137
+ export function timerText(block, shownAt, now) {
138
+ const deadline = block.endsAt !== undefined
139
+ ? Date.parse(block.endsAt)
140
+ : shownAt + (block.duration ?? 0) * 1000;
141
+ const remaining = Number.isNaN(deadline)
142
+ ? 0
143
+ : Math.max(0, Math.floor((deadline - now) / 1000));
144
+ if (remaining > 0) {
145
+ return formatCountdown(block.format ?? "{hh}:{mm}:{ss}", remaining);
146
+ }
147
+ return block.expiredText ?? null;
148
+ }
@@ -34,6 +34,7 @@ export function hasCloseAction(blocks) {
34
34
  : b.type === "card"
35
35
  ? b.repeat === undefined &&
36
36
  b.packageIndex === undefined &&
37
+ b.layout !== "carousel" &&
37
38
  hasCloseAction(b.children)
38
39
  : (b.type === "text" || b.type === "image" || b.type === "button") &&
39
40
  b.action === "close");