@chipmobilesdk/rn-ads 0.5.1 → 0.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,132 @@
1
1
  # @chipmobilesdk/rn-ads
2
2
 
3
+ ## 0.6.0
4
+
5
+ ### Minor Changes
6
+
7
+ - One diagnostic stream: install a sink with `installSdkSink` from
8
+ `@chipmobilesdk/rn-core` and it receives what every package observes (events,
9
+ failures, warnings) as `SdkDiagnostic`s, redacted. Listeners are notified through
10
+ rn-core, so a listener that throws no longer stops the others nor fails the call that
11
+ notified, and its error is reported rather than swallowed. ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17))
12
+
13
+ - rn-ads: a banner's auto-refresh is a new cycle, so its `loaded` and `impression`
14
+ events are no longer deduplicated away; the dedupe memory is bounded. A throwing
15
+ status, consent or placement listener is reported as an `internal_error` event.
16
+ - rn-i18n: new `onDiagnostic` config receives every diagnostic (`I18nDiagnostic`,
17
+ with a stable `code`); without it, warnings go to `console.warn` in development
18
+ builds only. The warn-once memory is bounded.
19
+ - rn-chart: now depends on rn-core for its diagnostics.
20
+ - rn-payment: status, entitlement and pending listeners report a throw to the logger
21
+ instead of swallowing it.
22
+
23
+ - The package's errors extend `SdkError` from the new `@chipmobilesdk/rn-core`
24
+ dependency: `isSdkError` recognises them alongside every other package's, and each
25
+ carries `pkg`, `code` and `retryable`. Names and codes are unchanged. ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17))
26
+
27
+ - rn-ai and rn-camera: `retryable` is the new name of `recoverable`, which stays as a
28
+ deprecated alias.
29
+ - rn-auth: `retryable` is true for `network-unavailable`. `redact` also removes URLs,
30
+ e-mail addresses and UUIDs.
31
+ - rn-ads: `redact` also removes JWTs and e-mail addresses, and reduces an `AdsError`
32
+ to its code.
33
+ - rn-ai: an error message quoted from the gateway is pattern-redacted.
34
+ - rn-camera: `redact` also removes JWTs, e-mail addresses and UUIDs.
35
+ - rn-theme: `ThemeConfigError` has `code: 'invalid_config'` and `ThemeContrastError`
36
+ `code: 'contrast'` (new `ThemeErrorCode` type).
37
+ - rn-i18n: `I18nConfigError` has `code: 'invalid_config'` and `UnitConversionError`
38
+ `code: 'unit_conversion'` (new `I18nErrorCode` type).
39
+
40
+ - Timers, retries and bounds now come from `@chipmobilesdk/rn-core`, and the drift
41
+ between the copies is fixed. ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17))
42
+
43
+ - rn-ads: a retry policy out of bounds throws `retry_out_of_bounds`, as documented,
44
+ instead of `timeout_out_of_bounds`; a placement's `minRequestIntervalMs` out of
45
+ bounds throws `invalid_config`.
46
+ - rn-payment: backoff validates `initialDelayMs` (100 ms to 60 s, not above
47
+ `maxDelayMs`) and `factor` (1 to 10); `factor: 0` used to retry at once, forever.
48
+ Every timeout, backoff or interval out of bounds now throws the new
49
+ `PAYMENT_INVALID_CONFIG` instead of `PAYMENT_INVALID_CATALOG`, which is about
50
+ products.
51
+ - rn-ai: concurrent calls share one behavior-config request, so a cold start makes
52
+ one instead of one per call. A silent retry after a config reload waits
53
+ `recovery.retryBackoffMs`, which was parsed but never applied.
54
+
55
+ - Native peers load through `lazyPeer` from `@chipmobilesdk/rn-core`, on first use,
56
+ and a missing one is each package's own typed error. ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17))
57
+
58
+ - rn-auth: creating `auth` no longer loads react-native-app-auth or
59
+ react-native-keychain; each loads on first use. New entrypoints
60
+ `@chipmobilesdk/rn-auth/app-auth` and `/keychain`. The default platform comes from
61
+ rn-core.
62
+ - rn-camera: a preview whose VisionCamera feed cannot load fails the session with
63
+ `cameraUnavailable` (new `controller.reportSurfaceUnavailable()`), instead of
64
+ showing black with no error. A missing VisionCamera module is `cameraUnavailable`.
65
+ - rn-notification: `createMmkvStore` reports a fallback to memory as
66
+ `STATE_STORE_DEGRADED` (to the SDK sinks and a new `onDegraded` option) and
67
+ exposes `durable`.
68
+ - rn-payment: an iOS app validates its catalog against the iOS product ids; the
69
+ default platform used to be Android everywhere. A missing react-native-iap is
70
+ `PAYMENT_UNAVAILABLE`.
71
+ - rn-theme: `buildThemes` runs without React Native (new `hairlineWidth` option) and
72
+ importing the package no longer loads Unistyles; `applyThemeMode` loads it on
73
+ first use.
74
+ - rn-ads, rn-local-db: the same loading, with the same error codes as before.
75
+
76
+ - Hooks read through `useSyncExternalStore` (rn-core's `useExternalStore`) and
77
+ re-render only when what they return changes. ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17))
78
+
79
+ - rn-auth: `useProfile` and `useAccessTier` no longer re-render for a status change,
80
+ and the provider's context no longer changes on every transition. New
81
+ `useAuthSelector`. `auth.getState()` returns the same frozen object until the state
82
+ changes, instead of a copy per call.
83
+ - rn-payment: `useEntitlement` re-renders only when its own entitlement changes; the
84
+ engine keeps unchanged entitlement objects across syncs and does not notify for a
85
+ sync that changed nothing.
86
+ - rn-ads: `usePlacementState` and `useAdsStatus` read tear-free; `useAdsEvents` keeps
87
+ one subscription when given an inline listener.
88
+ - rn-i18n: `useTranslation` returns the same `t` until the locale changes, and
89
+ `useLocale`'s `setLocale` is stable.
90
+ - rn-ai: new `useAiBehaviorState`, with `client.subscribe` and a stable
91
+ `client.getBehaviorState()`.
92
+
93
+ - Port conformance suites: one list of checks that a package's fake and its real adapter
94
+ both pass, so a test written against the fake proves something about the device. ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17))
95
+
96
+ - rn-core: `@chipmobilesdk/rn-core/testing` adds `runChecks`, `expectConformance`,
97
+ `ensure` and `createFaultRegistry` (deterministic fault injection by named point).
98
+ - rn-camera: a `./testing` entrypoint with `runPermissionPortConformance`,
99
+ `runCameraEngineConformance` and the test adapter, which stays on the main
100
+ entrypoint too until the next major. `createTestCamera(...).prompts()` counts system
101
+ prompts.
102
+ - rn-payment: `runProviderPortConformance` in `./testing`, passed by the fake store and
103
+ the Play adapter.
104
+ - rn-ads: `runAdsEngineConformance` in `./testing`, passed by the fake engine and the
105
+ AdMob adapter.
106
+ - rn-auth: a `./testing` entrypoint with `createMemorySecureStore`,
107
+ `runSecureTokenStoreConformance` and `runOidcAuthClientConformance`, passed by the
108
+ in-memory doubles and the keychain and app-auth adapters.
109
+
110
+ ### Patch Changes
111
+
112
+ - One app-lifecycle source, and a startup that needs no extra frame. ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17))
113
+
114
+ - rn-core: `appLifecycle()`, a shared, reference-counted `AppState` subscription that
115
+ reports changes only (`active`, `inactive`, `background`), with `onForeground` for a
116
+ return from the background and `createManualLifecycle` in `./testing`.
117
+ - rn-camera, rn-ads and rn-notification hear the lifecycle through it instead of
118
+ subscribing to `AppState` each, so an app holds one native listener. The camera
119
+ controller's core layer no longer imports `react-native`.
120
+ - rn-payment: `syncOnForeground(engine)` and `<PaymentProvider syncOnForeground>` call
121
+ `handleForeground()` on each return from the background. Off by default; calling
122
+ `handleForeground()` yourself works as before.
123
+ - rn-i18n: `i18n.initSync()` resolves the locale before the first render when the
124
+ storage adapter has the new optional `getItemSync`, and returns false otherwise.
125
+ `localeStorageFromKeyValue(port)` builds such an adapter from rn-core's
126
+ `KeyValuePort` (MMKV).
127
+ - Updated dependencies ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17), [#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17), [#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17)):
128
+ - @chipmobilesdk/rn-core@0.1.0
129
+
3
130
  ## 0.5.1
4
131
 
5
132
  ### Patch Changes
package/README.md CHANGED
@@ -268,10 +268,12 @@ ads.subscribeEvents(event => {
268
268
 
269
269
  **Ordering and exactly-once** are documented and tested. Within one `correlationId`, `loaded` / `display_started` / `impression` / `dismissed` / `reward_earned` occur at most once; `click` may repeat. After `dispose()`, zero events are emitted — a late provider callback is dropped.
270
270
 
271
- **Errors** carry a stable `code`, a log-safe `message`, the `operation`, a `retryable` flag, and a redacted `cause`. Codes are public API; renaming one is a breaking change.
271
+ **Errors** carry a stable `code`, a log-safe `message`, the `operation`, a `retryable` flag, and a redacted `cause`. Codes are public API; renaming one is a breaking change. `AdsError` extends `SdkError` from `@chipmobilesdk/rn-core`, so `isSdkError(error)` recognises it alongside every other package's errors, `error.pkg` names this package, and `error.redactedCause` (when present) is the underlying failure reduced to log-safe text.
272
+
273
+ A config value outside its band names its own field and code: a timeout or validity window is `timeout_out_of_bounds`, a retry policy `retry_out_of_bounds`, a placement's `minRequestIntervalMs` `invalid_config`.
272
274
 
273
275
  Retryable: `no_fill` `network_error` `load_timeout` `show_timeout` `ad_expired` `not_ready` `busy` `consumed` `app_inactive` `provider_error`
274
- Not retryable: `invalid_config` `duplicate_placement_key` `missing_ad_unit_id` `format_mismatch` `placement_not_found` `production_in_dev_build` `timeout_out_of_bounds` `unsupported_platform` `provider_missing` `disabled` `consent_required` `consent_undetermined` `requires_reinitialization` `internal_error`
276
+ Not retryable: `invalid_config` `duplicate_placement_key` `missing_ad_unit_id` `format_mismatch` `placement_not_found` `production_in_dev_build` `timeout_out_of_bounds` `retry_out_of_bounds` `unsupported_platform` `provider_missing` `disabled` `consent_required` `consent_undetermined` `requires_reinitialization` `internal_error`
275
277
 
276
278
  Attach any logger or analytics you already use. The package depends on none:
277
279
 
@@ -741,6 +743,11 @@ engine.failConsentInfoRead(); // the re-read after a failure r
741
743
  engine.consentCalls // includes 'showPrivacyOptionsForm'
742
744
  ```
743
745
 
746
+ `runAdsEngineConformance(harness)` from `./testing` holds the checks every `AdsEngine`
747
+ must pass — initialization, `loaded` reaching a subscriber and nothing after it
748
+ unsubscribes, a reward and a dismissal, one handle per ad, and a dispose that is safe
749
+ twice. The package runs it against the fake engine and the AdMob adapter.
750
+
744
751
  On device, verify with Google's official test ads only. `npm run android`, then exercise each `Ads · …` screen in the demo app.
745
752
 
746
753
  ---
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chipmobilesdk/rn-ads",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "main": "src/index.ts",
@@ -31,9 +31,12 @@
31
31
  ],
32
32
  "sideEffects": false,
33
33
  "scripts": {
34
- "typecheck": "tsc -p tsconfig.json --noEmit",
34
+ "typecheck": "tsc -p tsconfig.json && tsc -p tsconfig.test.json",
35
35
  "pack:check": "npm pack --dry-run"
36
36
  },
37
+ "dependencies": {
38
+ "@chipmobilesdk/rn-core": "^0.1.0"
39
+ },
37
40
  "peerDependencies": {
38
41
  "react": ">=19",
39
42
  "react-native": ">=0.85",
@@ -173,8 +173,8 @@ policy; everything else returns a typed result rather than throwing) · `AdsProv
173
173
  `SHOW_TIMEOUT_BOUNDS`, `AD_VALIDITY_BOUNDS`, `APP_OPEN_VALIDITY_BOUNDS`, `RETRY_BOUNDS`,
174
174
  `UNDECIDED_WARNING_BOUNDS`, `DEFAULT_BANNER_MIN_REQUEST_INTERVAL_MS`,
175
175
  `DEFAULT_FULL_SCREEN_MIN_REQUEST_INTERVAL_MS`, `MIN_REQUEST_INTERVAL_BOUNDS`,
176
- `defaultMinRequestIntervalMs` · `createFakeAdsEngine`, `createTestClock` from
177
- `@chipmobilesdk/rn-ads/testing` (test doubles only).
176
+ `defaultMinRequestIntervalMs` · `createFakeAdsEngine`, `createTestClock`,
177
+ `runAdsEngineConformance` from `@chipmobilesdk/rn-ads/testing` (test doubles only).
178
178
 
179
179
  Instance: `initialize`, `updatePrivacy`, `setEnabled`, `setEnablement`, `setAppActive`,
180
180
  `consent`, `getStatus`, `subscribeStatus`, `load`, `show`, `getPlacementState`,
@@ -146,10 +146,12 @@ export function validateConfig(config: AdsConfig, options: ValidateOptions): Res
146
146
  key,
147
147
  placement.minRequestIntervalMs === undefined
148
148
  ? defaultMinRequestIntervalMs(placement.format)
149
- : checkBand(
149
+ : // A request floor, not a timeout: its own code (ADS-16).
150
+ checkBand(
150
151
  `placements["${key}"].minRequestIntervalMs`,
151
152
  placement.minRequestIntervalMs,
152
153
  MIN_REQUEST_INTERVAL_BOUNDS,
154
+ 'invalid_config',
153
155
  ),
154
156
  );
155
157
  }
@@ -16,6 +16,12 @@
16
16
  * the caller injects — the AdMob adapter in production, the fake in tests.
17
17
  */
18
18
 
19
+ import {
20
+ createListenerSet,
21
+ isDevBuild as detectDevBuild,
22
+ type ListenerSet,
23
+ } from '@chipmobilesdk/rn-core';
24
+
19
25
  import {
20
26
  isFullScreenFormat,
21
27
  type AdFormat,
@@ -314,18 +320,23 @@ interface PlacementRuntime {
314
320
  cancelLoadTimer: Unsubscribe | null;
315
321
  cancelShowTimer: Unsubscribe | null;
316
322
  rewarded: RewardedSession | null;
317
- listeners: Set<(state: PlacementState) => void>;
323
+ listeners: ListenerSet<[PlacementState]>;
318
324
  /** Retries already spent in this load cycle. Reset when a new cycle starts. */
319
325
  retryAttempts: number;
320
326
  /** Cancels a scheduled retry. Called by disable, undecided, and dispose. */
321
327
  cancelRetry: Unsubscribe | null;
322
328
  }
323
329
 
324
- function detectDevBuild(): boolean {
325
- return typeof __DEV__ !== 'undefined' ? Boolean(__DEV__) : false;
326
- }
327
-
328
- export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): AdsInstance {
330
+ /**
331
+ * The instance itself. The public `createAds` (src/index.ts) passes the AdMob
332
+ * adapter as `defaultEngine`: core/ never loads an adapter, so it stays testable
333
+ * in Node and the provider stays replaceable (ADS-14).
334
+ */
335
+ export function createAds(
336
+ config: AdsConfig,
337
+ options: CreateAdsOptions = {},
338
+ defaultEngine: () => AdsEngine = noDefaultEngine,
339
+ ): AdsInstance {
329
340
  const isDevBuild = options.isDevBuild ?? detectDevBuild();
330
341
  const now = options.now ?? (() => Date.now());
331
342
 
@@ -333,7 +344,7 @@ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): Ad
333
344
  // before anything else: a misconfigured instance should not exist at all.
334
345
  const resolved: ResolvedConfig = validateConfig(config, { isDevBuild });
335
346
 
336
- const engine: AdsEngine = options.engine ?? createDefaultEngine();
347
+ const engine: AdsEngine = options.engine ?? defaultEngine();
337
348
  const events = createEventEmitter();
338
349
  const correlation = createCorrelationIssuer();
339
350
  // FR-140. Account safety, not product policy — see core/requestThrottle.ts.
@@ -387,7 +398,21 @@ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): Ad
387
398
  let disposed = false;
388
399
  let warnedSubstitution = false;
389
400
 
390
- const statusListeners = new Set<(status: InitStatus) => void>();
401
+ /**
402
+ * Status, consent and placement listeners are consumer code. A failure is
403
+ * isolated from the rest and reported the way an event listener's is: as an
404
+ * `internal_error` event (FR-056).
405
+ */
406
+ function reportListenerFailure(error: unknown): void {
407
+ events.emit({
408
+ type: 'internal_error',
409
+ timestamp: now(),
410
+ error: new AdsError('internal_error', { message: 'consumer listener threw', cause: error }),
411
+ });
412
+ }
413
+ const listenerOptions = { onListenerError: reportListenerFailure };
414
+
415
+ const statusListeners = createListenerSet<[InitStatus]>(listenerOptions);
391
416
  const placements = new Map<string, PlacementRuntime>();
392
417
 
393
418
  for (const key of resolved.placements.keys()) {
@@ -402,23 +427,17 @@ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): Ad
402
427
  cancelLoadTimer: null,
403
428
  cancelShowTimer: null,
404
429
  rewarded: null,
405
- listeners: new Set(),
430
+ listeners: createListenerSet(listenerOptions),
406
431
  retryAttempts: 0,
407
432
  cancelRetry: null,
408
433
  });
409
434
  }
410
435
 
411
- const consentListeners = new Set<(snapshot: ConsentSnapshot) => void>();
436
+ const consentListeners = createListenerSet<[ConsentSnapshot]>(listenerOptions);
412
437
 
413
438
  function setConsent(next: ConsentSnapshot): void {
414
439
  consent = next;
415
- for (const listener of Array.from(consentListeners)) {
416
- try {
417
- listener(next);
418
- } catch {
419
- // Consumer code; isolated exactly as event listeners are (FR-056).
420
- }
421
- }
440
+ consentListeners.notify(next);
422
441
  }
423
442
 
424
443
  /**
@@ -487,14 +506,7 @@ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): Ad
487
506
  status: next.status,
488
507
  retryable: next.error?.retryable,
489
508
  });
490
- for (const listener of Array.from(statusListeners)) {
491
- try {
492
- listener(next.status);
493
- } catch {
494
- // A status listener is consumer code; its failure is isolated the
495
- // same way an event listener's is (FR-056).
496
- }
497
- }
509
+ statusListeners.notify(next.status);
498
510
  }
499
511
 
500
512
  function setPlacement(key: string, next: PlacementSnapshot): void {
@@ -506,13 +518,7 @@ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): Ad
506
518
  return;
507
519
  }
508
520
  runtime.snapshot = next;
509
- for (const listener of Array.from(runtime.listeners)) {
510
- try {
511
- listener(next.state);
512
- } catch {
513
- // Isolated, as above.
514
- }
515
- }
521
+ runtime.listeners.notify(next.state);
516
522
  }
517
523
 
518
524
  function dispatchPlacement(key: string, event: Parameters<typeof placementReducer>[1]): void {
@@ -1331,17 +1337,7 @@ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): Ad
1331
1337
  return init.status;
1332
1338
  },
1333
1339
 
1334
- subscribeStatus(listener) {
1335
- statusListeners.add(listener);
1336
- let active = true;
1337
- return () => {
1338
- if (!active) {
1339
- return;
1340
- }
1341
- active = false;
1342
- statusListeners.delete(listener);
1343
- };
1344
- },
1340
+ subscribeStatus: listener => statusListeners.add(listener),
1345
1341
 
1346
1342
  async load(placementKey) {
1347
1343
  if (disposed) {
@@ -1610,15 +1606,7 @@ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): Ad
1610
1606
  if (!runtime) {
1611
1607
  return () => undefined;
1612
1608
  }
1613
- runtime.listeners.add(listener);
1614
- let active = true;
1615
- return () => {
1616
- if (!active) {
1617
- return;
1618
- }
1619
- active = false;
1620
- runtime.listeners.delete(listener);
1621
- };
1609
+ return runtime.listeners.add(listener);
1622
1610
  },
1623
1611
 
1624
1612
  subscribeEvents(listener) {
@@ -1759,17 +1747,7 @@ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): Ad
1759
1747
  return consent;
1760
1748
  },
1761
1749
 
1762
- subscribe(listener) {
1763
- consentListeners.add(listener);
1764
- let active = true;
1765
- return () => {
1766
- if (!active) {
1767
- return;
1768
- }
1769
- active = false;
1770
- consentListeners.delete(listener);
1771
- };
1772
- },
1750
+ subscribe: listener => consentListeners.add(listener),
1773
1751
 
1774
1752
  async reset() {
1775
1753
  if (disposed) {
@@ -1914,16 +1892,8 @@ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): Ad
1914
1892
  return instance;
1915
1893
  }
1916
1894
 
1917
- /**
1918
- * Resolved lazily so importing the package on a platform with no adapter — or
1919
- * with the bridge absent — costs nothing and crashes nothing (FR-025, SC-004).
1920
- */
1921
- function createDefaultEngine(): AdsEngine {
1922
- // Deferred require keeps the provider off the import graph of every module
1923
- // that merely mentions `createAds`.
1924
- const { createAdMobEngine } =
1925
- require('../engine/admobEngine') as typeof import('../engine/admobEngine');
1926
- return createAdMobEngine();
1895
+ function noDefaultEngine(): AdsEngine {
1896
+ throw new Error(
1897
+ 'core/createAds was called without an engine; production code uses createAds from the package entrypoint.',
1898
+ );
1927
1899
  }
1928
-
1929
- declare const __DEV__: boolean | undefined;
@@ -21,6 +21,8 @@
21
21
  * TimerSet so `dispose()` can prove it released everything.
22
22
  */
23
23
 
24
+ import { computeBackoff } from '@chipmobilesdk/rn-core';
25
+
24
26
  import { AdsConfigError } from '../diagnostics/errors';
25
27
  import { checkBand, DEFAULT_RETRY_POLICY, RETRY_BOUNDS } from './timers';
26
28
  import type { RetryPolicy } from '../config/types';
@@ -47,16 +49,20 @@ export function resolveRetryPolicy(policy: RetryPolicy, label: string): Resolved
47
49
  `${label}.maxRetries`,
48
50
  policy.maxRetries ?? DEFAULT_RETRY_POLICY.maxRetries,
49
51
  RETRY_BOUNDS.maxRetries,
52
+ 'retry_out_of_bounds',
53
+ '',
50
54
  );
51
55
  const initialDelayMs = checkBand(
52
56
  `${label}.initialDelayMs`,
53
57
  policy.initialDelayMs ?? DEFAULT_RETRY_POLICY.initialDelayMs,
54
58
  RETRY_BOUNDS.initialDelayMs,
59
+ 'retry_out_of_bounds',
55
60
  );
56
61
  const maxDelayMs = checkBand(
57
62
  `${label}.maxDelayMs`,
58
63
  policy.maxDelayMs ?? DEFAULT_RETRY_POLICY.maxDelayMs,
59
64
  RETRY_BOUNDS.maxDelayMs,
65
+ 'retry_out_of_bounds',
60
66
  );
61
67
 
62
68
  if (!Number.isInteger(maxRetries)) {
@@ -117,8 +123,12 @@ export function resolveEffectiveRetry(
117
123
  * than a property that does not apply.
118
124
  */
119
125
  export function delayForAttempt(policy: ResolvedRetryPolicy, attempt: number): number {
120
- const raw = policy.initialDelayMs * Math.pow(2, Math.max(0, attempt - 1));
121
- return Math.min(raw, policy.maxDelayMs);
126
+ return computeBackoff(attempt, {
127
+ initialDelayMs: policy.initialDelayMs,
128
+ maxDelayMs: policy.maxDelayMs,
129
+ factor: 2,
130
+ jitter: 'none',
131
+ });
122
132
  }
123
133
 
124
134
  /** Whether another attempt is permitted. `attemptsMade` counts retries only. */
@@ -8,8 +8,10 @@
8
8
  * something it did not (FR-008).
9
9
  */
10
10
 
11
- import { AdsConfigError } from '../diagnostics/errors';
12
- import type { TimeoutConfig, Unsubscribe } from '../config/types';
11
+ import { checkRange, type Range } from '@chipmobilesdk/rn-core';
12
+
13
+ import { AdsConfigError, type AdsErrorCode } from '../diagnostics/errors';
14
+ import type { TimeoutConfig } from '../config/types';
13
15
 
14
16
  /** Comfortably longer than a normal fill on a slow mobile network, still bounded. */
15
17
  export const DEFAULT_LOAD_TIMEOUT_MS = 30_000;
@@ -96,17 +98,19 @@ export interface ResolvedTimeouts {
96
98
  readonly undecidedWarning: number;
97
99
  }
98
100
 
101
+ /**
102
+ * `value` when it is inside `bounds`, else an `AdsConfigError` with `code`: each
103
+ * field reports the code its documentation names, so a retry policy out of bounds
104
+ * is `retry_out_of_bounds`, not a timeout's code (CORE-04, ADS-16).
105
+ */
99
106
  export function checkBand(
100
107
  label: string,
101
108
  value: number,
102
- bounds: { readonly min: number; readonly max: number },
109
+ bounds: Range,
110
+ code: AdsErrorCode = 'timeout_out_of_bounds',
111
+ unit = 'ms',
103
112
  ): number {
104
- if (!Number.isFinite(value) || value < bounds.min || value > bounds.max) {
105
- throw new AdsConfigError('timeout_out_of_bounds', {
106
- message: `${label} must be between ${bounds.min}ms and ${bounds.max}ms; received ${value}`,
107
- });
108
- }
109
- return value;
113
+ return checkRange(label, value, bounds, message => new AdsConfigError(code, { message }), unit);
110
114
  }
111
115
 
112
116
  export interface TimeoutInput {
@@ -148,67 +152,11 @@ export function resolveTimeouts(input: TimeoutInput): ResolvedTimeouts {
148
152
  }
149
153
 
150
154
  /**
151
- * How timers are actually scheduled.
152
- *
153
- * Injectable so a test can drive retry backoff, app-open expiry, and the
154
- * undecided warning by moving a clock rather than by waiting. The production
155
- * default is `setTimeout`; the fake engine supplies a controllable one. Without
156
- * this, every timing assertion in the suite would either sleep for real or
157
- * depend on the test runner's timer mocking, and NFR-103's "no device, no
158
- * network" would quietly become "no device, no network, but several seconds".
155
+ * How timers are scheduled, and the set that tracks them so `dispose()` can prove
156
+ * it released every one (SC-008): rn-core's, injectable so a test drives retry
157
+ * backoff, app-open expiry and the undecided warning by moving a clock.
159
158
  */
160
- export interface Scheduler {
161
- setTimeout(callback: () => void, ms: number): unknown;
162
- clearTimeout(handle: unknown): void;
163
- }
164
-
165
- const realScheduler: Scheduler = {
166
- setTimeout: (callback, ms) => setTimeout(callback, ms),
167
- clearTimeout: handle => clearTimeout(handle as ReturnType<typeof setTimeout>),
168
- };
169
-
170
- /**
171
- * Tracks every timer the instance owns so `dispose()` can prove it released
172
- * them. Counting is not incidental — SC-008 asserts the count reaches zero, and
173
- * a leaked timer is otherwise invisible until it fires into a disposed consumer.
174
- */
175
- export interface TimerSet {
176
- after(ms: number, callback: () => void): Unsubscribe;
177
- clearAll(): void;
178
- size(): number;
179
- }
180
-
181
- export function createTimerSet(scheduler: Scheduler = realScheduler): TimerSet {
182
- const handles = new Set<unknown>();
183
-
184
- return {
185
- after(ms, callback) {
186
- const handle = scheduler.setTimeout(() => {
187
- handles.delete(handle);
188
- callback();
189
- }, ms);
190
- handles.add(handle);
191
- let cleared = false;
192
- return () => {
193
- if (cleared) {
194
- return;
195
- }
196
- cleared = true;
197
- scheduler.clearTimeout(handle);
198
- handles.delete(handle);
199
- };
200
- },
201
- clearAll() {
202
- for (const handle of handles) {
203
- scheduler.clearTimeout(handle);
204
- }
205
- handles.clear();
206
- },
207
- size() {
208
- return handles.size;
209
- },
210
- };
211
- }
159
+ export { createTimerSet, type Scheduler, type TimerSet } from '@chipmobilesdk/rn-core';
212
160
 
213
161
  /** A loaded ad past its validity window must never be reported ready (FR-034). */
214
162
  export function isStale(loadedAtMs: number, nowMs: number, adValidityMs: number): boolean {
@@ -10,6 +10,8 @@
10
10
  * carrier for identifiers the package promises never to emit (FR-021).
11
11
  */
12
12
 
13
+ import { SdkError, createRedactor } from '@chipmobilesdk/rn-core';
14
+
13
15
  import type { AdFormat } from '../config/types';
14
16
 
15
17
  export type AdsOperation = 'initialize' | 'load' | 'show' | 'configure';
@@ -94,9 +96,8 @@ export interface AdsErrorOptions {
94
96
  readonly cause?: unknown;
95
97
  }
96
98
 
97
- export class AdsError extends Error {
98
- readonly code: AdsErrorCode;
99
- readonly retryable: boolean;
99
+ /** The package's error; an `SdkError` (`@chipmobilesdk/rn-core`) with `pkg: 'rn-ads'`. */
100
+ export class AdsError extends SdkError<AdsErrorCode> {
100
101
  readonly operation?: AdsOperation;
101
102
  readonly placementKey?: string;
102
103
  readonly format?: AdFormat;
@@ -104,15 +105,17 @@ export class AdsError extends Error {
104
105
  readonly cause?: string;
105
106
 
106
107
  constructor(code: AdsErrorCode, options: AdsErrorOptions = {}) {
107
- super(options.message ?? code);
108
+ const cause = options.cause === undefined ? undefined : redact(options.cause);
109
+ super('rn-ads', code, {
110
+ message: options.message,
111
+ retryable: RETRYABLE.has(code),
112
+ redactedCause: cause,
113
+ });
108
114
  this.name = 'AdsError';
109
- this.code = code;
110
- this.retryable = RETRYABLE.has(code);
111
115
  this.operation = options.operation;
112
116
  this.placementKey = options.placementKey;
113
117
  this.format = options.format;
114
- this.cause = options.cause === undefined ? undefined : redact(options.cause);
115
- Object.setPrototypeOf(this, AdsError.prototype);
118
+ this.cause = cause;
116
119
  }
117
120
  }
118
121
 
@@ -125,7 +128,6 @@ export class AdsConfigError extends AdsError {
125
128
  constructor(code: AdsErrorCode, options: AdsErrorOptions = {}) {
126
129
  super(code, { ...options, operation: options.operation ?? 'configure' });
127
130
  this.name = 'AdsConfigError';
128
- Object.setPrototypeOf(this, AdsConfigError.prototype);
129
131
  }
130
132
  }
131
133
 
@@ -133,23 +135,15 @@ export function isAdsError(value: unknown): value is AdsError {
133
135
  return value instanceof AdsError;
134
136
  }
135
137
 
136
- const REDACTED = '[redacted]';
137
-
138
138
  /** An AdMob unit or app id. Redacted so a log never carries a live identifier. */
139
139
  const AD_UNIT_PATTERN = /ca-app-pub-\d+[~/]\d+/g;
140
- /** GAID and any other dashed UUID an SDK might put in a message. */
141
- const UUID_PATTERN = /[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/g;
142
- /** Base64-ish runs long enough to be an encoded payload such as a consent string. */
143
- const PAYLOAD_PATTERN = /[A-Za-z0-9+/=_-]{40,}/g;
144
- /** Anything path- or URL-like, which can carry query parameters with identifiers. */
145
- const URL_PATTERN = /[a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^\s"']+/g;
146
140
 
147
141
  /**
148
142
  * Keys dropped wholesale rather than pattern-matched, because their values are
149
143
  * sensitive regardless of shape — a bare advertising id has no separator for a
150
144
  * pattern to catch.
151
145
  */
152
- const SENSITIVE_KEYS = new Set([
146
+ const SENSITIVE_KEYS = [
153
147
  'adid',
154
148
  'adUnitId',
155
149
  'advertisingId',
@@ -160,54 +154,30 @@ const SENSITIVE_KEYS = new Set([
160
154
  'tcString',
161
155
  'token',
162
156
  'userId',
163
- ]);
157
+ ];
164
158
 
165
- function redactString(value: string): string {
166
- return value
167
- .replace(URL_PATTERN, REDACTED)
168
- .replace(AD_UNIT_PATTERN, REDACTED)
169
- .replace(UUID_PATTERN, REDACTED)
170
- .replace(PAYLOAD_PATTERN, REDACTED);
171
- }
159
+ /**
160
+ * rn-core's pattern redaction (URLs, JWTs, e-mail addresses, UUIDs, payload-sized
161
+ * runs) plus ad unit ids, with the keys above dropped. Provider messages are kept
162
+ * readable: they describe the failure, and every identifier in them is shaped.
163
+ */
164
+ const redactor = createRedactor({
165
+ text: 'patterns',
166
+ patterns: [AD_UNIT_PATTERN],
167
+ dropKeys: SENSITIVE_KEYS,
168
+ maxDepth: 2,
169
+ });
172
170
 
173
171
  /**
174
172
  * Reduces an arbitrary provider value to a safe string (FR-053, NFR-006).
175
173
  *
176
174
  * Returns a string rather than a structure on purpose: a redacted object still
177
175
  * hands the consumer a reference into provider-owned memory, and the contract
178
- * promises no provider object crosses the boundary.
176
+ * promises no provider object crosses the boundary. An `AdsError` reduces to its
177
+ * code.
179
178
  */
180
- export function redact(value: unknown, depth = 0): string {
181
- if (value === null || value === undefined) {
182
- return '';
183
- }
184
- if (typeof value === 'string') {
185
- return redactString(value);
186
- }
187
- if (typeof value === 'number' || typeof value === 'boolean') {
188
- return String(value);
189
- }
190
- if (value instanceof Error) {
191
- return redactString(value.message);
192
- }
193
- if (depth >= 2) {
194
- return REDACTED;
195
- }
196
- if (Array.isArray(value)) {
197
- return value.map(item => redact(item, depth + 1)).join(', ');
198
- }
199
- if (typeof value === 'object') {
200
- const parts: string[] = [];
201
- for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
202
- if (SENSITIVE_KEYS.has(key)) {
203
- parts.push(`${key}=${REDACTED}`);
204
- continue;
205
- }
206
- parts.push(`${key}=${redact(entry, depth + 1)}`);
207
- }
208
- return parts.join(' ');
209
- }
210
- return REDACTED;
179
+ export function redact(value: unknown): string {
180
+ return redactor.text(value);
211
181
  }
212
182
 
213
183
  /**
@@ -19,7 +19,14 @@ import type {
19
19
  UnavailableReason,
20
20
  Unsubscribe,
21
21
  } from '../config/types';
22
- import { AdsError, type AdsErrorCode, type AdsOperation } from './errors';
22
+ import {
23
+ createDiagnosticChannel,
24
+ createEmitter,
25
+ type DiagnosticSeverity,
26
+ type Emitter,
27
+ } from '@chipmobilesdk/rn-core';
28
+
29
+ import { AdsError, type AdsOperation } from './errors';
23
30
 
24
31
  export type AdEventType =
25
32
  | 'initialization'
@@ -151,79 +158,83 @@ export interface AdEventEmitter {
151
158
  listenerCount(): number;
152
159
  }
153
160
 
154
- export function createEventEmitter(): AdEventEmitter {
155
- const listeners = new Set<AdEventListener>();
156
- // `${correlationId}:${type}` for everything already emitted in a cycle.
157
- const emittedOnce = new Set<string>();
158
- let disposed = false;
159
- // Guards against an internal_error thrown by a listener re-entering dispatch
160
- // and recursing forever.
161
- let reportingListenerFailure = false;
162
-
163
- function dispatch(event: AdEvent): void {
164
- for (const listener of Array.from(listeners)) {
165
- try {
166
- listener(event);
167
- } catch (error) {
168
- if (reportingListenerFailure) {
169
- continue;
170
- }
171
- reportingListenerFailure = true;
172
- try {
173
- dispatch({
174
- type: 'internal_error',
175
- timestamp: Date.now(),
176
- placementKey: event.placementKey,
177
- format: event.format,
178
- correlationId: event.correlationId,
179
- error: new AdsError('internal_error' as AdsErrorCode, {
180
- message: 'consumer event listener threw',
181
- cause: error,
182
- }),
183
- });
184
- } finally {
185
- reportingListenerFailure = false;
186
- }
187
- }
188
- }
161
+ /**
162
+ * How many once-per-cycle keys the emitter remembers. A cycle's duplicates arrive
163
+ * within that cycle, so forgetting the oldest keys past this bound loses nothing,
164
+ * and memory stays flat however long the instance lives (CORE-12, ADS-05).
165
+ */
166
+ const DEDUPE_CAPACITY = 256;
167
+
168
+ /** Every delivered event also goes to the app's SDK sinks (`installSdkSink`, CORE-08). */
169
+ const channel = createDiagnosticChannel<AdEventType>('rn-ads');
170
+
171
+ function severityOf(event: AdEvent): DiagnosticSeverity {
172
+ switch (event.type) {
173
+ case 'internal_error':
174
+ return 'error';
175
+ case 'load_failed':
176
+ case 'display_failed':
177
+ case 'config_warning':
178
+ return 'warn';
179
+ default:
180
+ return 'info';
189
181
  }
182
+ }
183
+
184
+ /** What a sink may see: where and which cycle, and an error's code. Never a payload. */
185
+ function contextOf(event: AdEvent): Record<string, unknown> {
186
+ return {
187
+ ...(event.placementKey === undefined ? {} : { placementKey: event.placementKey }),
188
+ ...(event.format === undefined ? {} : { format: event.format }),
189
+ ...('error' in event ? { code: event.error.code, retryable: event.error.retryable } : {}),
190
+ };
191
+ }
192
+
193
+ export function createEventEmitter(): AdEventEmitter {
194
+ // The event whose listeners are running, so a listener's failure is reported
195
+ // against the placement and cycle it broke (FR-056).
196
+ let dispatching: AdEvent | null = null;
197
+ const emitter: Emitter<AdEvent> = createEmitter<AdEvent>({
198
+ onListenerError(error) {
199
+ emitter.emit({
200
+ type: 'internal_error',
201
+ timestamp: Date.now(),
202
+ placementKey: dispatching?.placementKey,
203
+ format: dispatching?.format,
204
+ correlationId: dispatching?.correlationId,
205
+ error: new AdsError('internal_error', {
206
+ message: 'consumer event listener threw',
207
+ cause: error,
208
+ }),
209
+ });
210
+ },
211
+ dedupe: {
212
+ key: event =>
213
+ event.correlationId && ONCE_PER_CYCLE.has(event.type)
214
+ ? `${event.correlationId}:${event.type}`
215
+ : undefined,
216
+ capacity: DEDUPE_CAPACITY,
217
+ },
218
+ });
190
219
 
191
220
  return {
192
221
  emit(event) {
193
222
  // After dispose, a late provider callback updates nothing and emits
194
- // nothing (FR-035, SC-008).
195
- if (disposed) {
196
- return;
197
- }
198
- if (event.correlationId && ONCE_PER_CYCLE.has(event.type)) {
199
- const key = `${event.correlationId}:${event.type}`;
200
- if (emittedOnce.has(key)) {
201
- return;
223
+ // nothing (FR-035, SC-008); the rn-core emitter enforces both.
224
+ const previous = dispatching;
225
+ dispatching = event;
226
+ try {
227
+ if (emitter.emit(event)) {
228
+ channel.emit(event.type, severityOf(event), contextOf(event), event.correlationId);
202
229
  }
203
- emittedOnce.add(key);
230
+ } finally {
231
+ dispatching = previous;
204
232
  }
205
- dispatch(event);
206
- },
207
- subscribe(listener) {
208
- listeners.add(listener);
209
- let active = true;
210
- return () => {
211
- // Idempotent: a second call is a no-op, not an error (FR-005).
212
- if (!active) {
213
- return;
214
- }
215
- active = false;
216
- listeners.delete(listener);
217
- };
218
- },
219
- dispose() {
220
- disposed = true;
221
- listeners.clear();
222
- emittedOnce.clear();
223
- },
224
- listenerCount() {
225
- return listeners.size;
226
233
  },
234
+ // Idempotent unsubscribe (FR-005).
235
+ subscribe: listener => emitter.subscribe(listener),
236
+ dispose: () => emitter.dispose(),
237
+ listenerCount: () => emitter.listenerCount(),
227
238
  };
228
239
  }
229
240
 
@@ -16,6 +16,8 @@
16
16
  * applied.
17
17
  */
18
18
 
19
+ import { lazyPeer } from '@chipmobilesdk/rn-core';
20
+
19
21
  import { Platform } from 'react-native';
20
22
 
21
23
  import type { AdContentRating, FullScreenFormat, Unsubscribe } from '../config/types';
@@ -141,15 +143,24 @@ function toEnginePrivacyOptions(value: string | undefined): EngineConsentInfo['p
141
143
  }
142
144
  }
143
145
 
144
- function loadProvider(): ProviderModule | null {
146
+ /**
147
+ * The provider bridge, loaded on first use (rn-core's lazy peer). It is a required
148
+ * peer, but a consumer can still have it missing mid-install or excluded from a
149
+ * build: reporting `provider_missing` beats crashing the host app's launch
150
+ * (FR-025, NFR-005). The banner component reaches it through the same loader.
151
+ */
152
+ export const admobProvider = lazyPeer('react-native-google-mobile-ads', () => {
153
+ // Inside `try`, so Metro bundles an app that leaves this optional peer out.
145
154
  try {
146
155
  return require('react-native-google-mobile-ads') as ProviderModule;
147
- } catch {
148
- // The bridge is a required peer, but a consumer can still have it missing
149
- // mid-install or excluded from a build. Reporting `provider_missing`
150
- // beats crashing the host app's launch (FR-025, NFR-005).
151
- return null;
156
+ } catch (cause) {
157
+ throw cause;
152
158
  }
159
+ });
160
+
161
+ function loadProvider(): ProviderModule | null {
162
+ const result = admobProvider.get();
163
+ return result.ok ? result.module : null;
153
164
  }
154
165
 
155
166
  function toProviderRating(
package/src/index.ts CHANGED
@@ -16,6 +16,14 @@
16
16
  * frequency cap, and no screen name in this package (FR-045).
17
17
  */
18
18
 
19
+ import type { AdsConfig } from './config/types';
20
+ import {
21
+ createAds as createInstance,
22
+ type AdsInstance,
23
+ type CreateAdsOptions,
24
+ } from './core/adsInstance';
25
+ import { createAdMobEngine } from './engine/admobEngine';
26
+
19
27
  // Configuration and state
20
28
  export type {
21
29
  AdContentRating,
@@ -73,7 +81,17 @@ export {
73
81
  export type { EnablementResult } from './core/enablement';
74
82
 
75
83
  // Instance, lifecycle, and results
76
- export { createAds } from './core/adsInstance';
84
+
85
+ /**
86
+ * Creates an ads instance. This is the composition root: core's instance with
87
+ * the AdMob adapter as its default engine, so core/ itself never loads an
88
+ * adapter (ADS-14). The adapter resolves the provider bridge only on first use,
89
+ * so importing the package where there is no adapter, or no bridge, costs
90
+ * nothing and crashes nothing (FR-025, SC-004).
91
+ */
92
+ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): AdsInstance {
93
+ return createInstance(config, options, createAdMobEngine);
94
+ }
77
95
  export type {
78
96
  AdsInstance,
79
97
  ConsentApi,
@@ -21,6 +21,7 @@ import { View, type LayoutChangeEvent, type ViewStyle } from 'react-native';
21
21
 
22
22
  import type { PlacementState } from '../config/types';
23
23
  import { AdsError, normalizeProviderError } from '../diagnostics/errors';
24
+ import { admobProvider } from '../engine/admobEngine';
24
25
  import { useAds } from './AdsProvider';
25
26
  import type { ThrottledResult } from '../core/adsInstance';
26
27
  import { useAdsStatus } from './hooks';
@@ -38,12 +39,11 @@ interface ProviderBannerModule {
38
39
  BannerAdSize: Record<string, string>;
39
40
  }
40
41
 
42
+ // The engine's lazy loader, so the bridge is required in one place (ADS-12 still
43
+ // tracks giving the banner its own seam on the engine port).
41
44
  function loadBannerModule(): ProviderBannerModule | null {
42
- try {
43
- return require('react-native-google-mobile-ads') as ProviderBannerModule;
44
- } catch {
45
- return null;
46
- }
45
+ const result = admobProvider.get();
46
+ return result.ok ? (result.module as unknown as ProviderBannerModule) : null;
47
47
  }
48
48
 
49
49
  export function AdsBanner({ placementKey, onStateChange, style }: AdsBannerProps) {
@@ -58,6 +58,9 @@ export function AdsBanner({ placementKey, onStateChange, style }: AdsBannerProps
58
58
  // produces a genuinely re-adapted banner rather than a stretched old one.
59
59
  const [cycle, setCycle] = useState(0);
60
60
  const correlationRef = useRef<string | null>(null);
61
+ // Whether the current correlation already reported `loaded`. The provider
62
+ // auto-refreshes a banner inside one mount; each refresh is a new cycle.
63
+ const loadedInCycleRef = useRef(false);
61
64
  const startedAtRef = useRef(0);
62
65
  // Whether this mount has already taken a throttle slot, and the refusal if it
63
66
  // was refused. Resets with the mount, which is exactly the granularity FR-140
@@ -176,6 +179,7 @@ export function AdsBanner({ placementKey, onStateChange, style }: AdsBannerProps
176
179
  return;
177
180
  }
178
181
  correlationRef.current = resolution.correlationId;
182
+ loadedInCycleRef.current = false;
179
183
  startedAtRef.current = Date.now();
180
184
  move('loading');
181
185
  ads.internals.emit({
@@ -213,16 +217,26 @@ export function AdsBanner({ placementKey, onStateChange, style }: AdsBannerProps
213
217
  );
214
218
 
215
219
  const handleLoaded = useCallback(() => {
220
+ // A second load in one correlation is the provider's auto-refresh: a new
221
+ // ad, so a new cycle. Under the old correlation its `loaded` and
222
+ // `impression` were deduplicated away and revenue went uncounted (ADS-05).
223
+ // For a refresh, `durationMs` is the time since the previous load.
224
+ if (loadedInCycleRef.current && ads) {
225
+ correlationRef.current = ads.internals.nextCorrelationId();
226
+ }
227
+ loadedInCycleRef.current = true;
228
+ const loadedAt = Date.now();
216
229
  move('ready');
217
230
  emit({
218
231
  type: 'loaded',
219
- timestamp: Date.now(),
232
+ timestamp: loadedAt,
220
233
  placementKey,
221
234
  format: 'banner',
222
235
  correlationId: correlationRef.current ?? undefined,
223
- durationMs: Date.now() - startedAtRef.current,
236
+ durationMs: loadedAt - startedAtRef.current,
224
237
  });
225
- }, [emit, move, placementKey]);
238
+ startedAtRef.current = loadedAt;
239
+ }, [ads, emit, move, placementKey]);
226
240
 
227
241
  const handleFailed = useCallback(
228
242
  (error: unknown) => {
@@ -10,8 +10,8 @@
10
10
  * startup where the app can decide what to do about it.
11
11
  */
12
12
 
13
+ import { appLifecycle } from '@chipmobilesdk/rn-core';
13
14
  import React, { createContext, useContext, useEffect, type ReactNode } from 'react';
14
- import { AppState, type AppStateStatus } from 'react-native';
15
15
 
16
16
  import type { AdsInstance } from '../core/adsInstance';
17
17
 
@@ -23,7 +23,9 @@ export interface AdsProviderProps {
23
23
  * Whether the provider forwards foreground/background transitions to the
24
24
  * instance. On by default: FR-034's "never show while inactive" and
25
25
  * "re-evaluate a stale ad on return" both depend on the instance being told.
26
- * Turn it off only when the app already drives `setAppActive` itself.
26
+ * Turn it off only when the app already drives `setAppActive` itself. The
27
+ * transitions come from rn-core's shared `appLifecycle()`; `inactive` counts as
28
+ * not active.
27
29
  */
28
30
  readonly trackAppState?: boolean;
29
31
  readonly children: ReactNode;
@@ -34,12 +36,9 @@ export function AdsProvider({ instance, trackAppState = true, children }: AdsPro
34
36
  if (!trackAppState) {
35
37
  return;
36
38
  }
37
- const subscription = AppState.addEventListener('change', (status: AppStateStatus) => {
38
- instance.setAppActive(status === 'active');
39
+ return appLifecycle().subscribe(state => {
40
+ instance.setAppActive(state === 'active');
39
41
  });
40
- return () => {
41
- subscription.remove();
42
- };
43
42
  }, [instance, trackAppState]);
44
43
 
45
44
  return <AdsContext.Provider value={instance}>{children}</AdsContext.Provider>;
@@ -10,7 +10,8 @@
10
10
  * state; it never holds provider-owned memory (FR-027).
11
11
  */
12
12
 
13
- import { useEffect, useState } from 'react';
13
+ import { useExternalStore, useLatestCallback } from '@chipmobilesdk/rn-core/react';
14
+ import { useCallback, useEffect } from 'react';
14
15
 
15
16
  import type { InitStatus, PlacementState } from '../config/types';
16
17
  import type { AdEventListener } from '../diagnostics/events';
@@ -19,42 +20,33 @@ import { useAds } from './AdsProvider';
19
20
  /** Live state for one placement. `unavailable` outside a provider. */
20
21
  export function usePlacementState(placementKey: string): PlacementState {
21
22
  const ads = useAds();
22
- const [state, setState] = useState<PlacementState>(() =>
23
- ads ? ads.getPlacementState(placementKey) : 'unavailable',
23
+ const subscribe = useCallback(
24
+ (listener: () => void) =>
25
+ ads ? ads.subscribePlacement(placementKey, listener) : NO_SUBSCRIPTION,
26
+ [ads, placementKey],
24
27
  );
25
-
26
- useEffect(() => {
27
- if (!ads) {
28
- setState('unavailable');
29
- return;
30
- }
31
- // Re-read on subscribe: the state may have moved between the initial
32
- // render and this effect, and dropping that transition would leave the
33
- // UI showing a state the instance has already left.
34
- setState(ads.getPlacementState(placementKey));
35
- return ads.subscribePlacement(placementKey, setState);
36
- }, [ads, placementKey]);
37
-
38
- return state;
28
+ const getSnapshot = useCallback(
29
+ (): PlacementState => (ads ? ads.getPlacementState(placementKey) : 'unavailable'),
30
+ [ads, placementKey],
31
+ );
32
+ // useSyncExternalStore: tear-free, and no transition lost between render and
33
+ // subscription, which the useState copy had to re-read to avoid.
34
+ return useExternalStore(subscribe, getSnapshot);
39
35
  }
40
36
 
41
37
  /** Live initialization status. `unavailable` outside a provider. */
42
38
  export function useAdsStatus(): InitStatus {
43
39
  const ads = useAds();
44
- const [status, setStatus] = useState<InitStatus>(() => (ads ? ads.getStatus() : 'unavailable'));
45
-
46
- useEffect(() => {
47
- if (!ads) {
48
- setStatus('unavailable');
49
- return;
50
- }
51
- setStatus(ads.getStatus());
52
- return ads.subscribeStatus(setStatus);
53
- }, [ads]);
54
-
55
- return status;
40
+ const subscribe = useCallback(
41
+ (listener: () => void) => (ads ? ads.subscribeStatus(listener) : NO_SUBSCRIPTION),
42
+ [ads],
43
+ );
44
+ const getSnapshot = useCallback((): InitStatus => (ads ? ads.getStatus() : 'unavailable'), [ads]);
45
+ return useExternalStore(subscribe, getSnapshot);
56
46
  }
57
47
 
48
+ const NO_SUBSCRIPTION = (): void => undefined;
49
+
58
50
  /**
59
51
  * Forwards every event to a listener without causing a render.
60
52
  *
@@ -65,11 +57,14 @@ export function useAdsStatus(): InitStatus {
65
57
  */
66
58
  export function useAdsEvents(listener: AdEventListener): void {
67
59
  const ads = useAds();
60
+ // An inline listener is a new function every render; subscribing through the
61
+ // latest one keeps a single subscription per instance (PERF-13).
62
+ const latest = useLatestCallback(listener);
68
63
 
69
64
  useEffect(() => {
70
65
  if (!ads) {
71
66
  return;
72
67
  }
73
- return ads.subscribeEvents(listener);
74
- }, [ads, listener]);
68
+ return ads.subscribeEvents(latest);
69
+ }, [ads, latest]);
75
70
  }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * What any `AdsEngine` must do (IOS-12, ADS-13).
3
+ *
4
+ * The same checks run against the fake engine and against the AdMob adapter over
5
+ * the provider mock (or the real provider, from a demo screen on a device), so a
6
+ * test that drives the fake proves something about the device path. The subject
7
+ * says how the provider delivers its callbacks; the checks only use the port.
8
+ */
9
+ import {
10
+ ensure,
11
+ runChecks,
12
+ type Check,
13
+ type ConformanceReport,
14
+ } from '@chipmobilesdk/rn-core/testing';
15
+
16
+ import type { Reward } from '../config/types';
17
+ import type { AdsEngine, EngineAdEvent, EngineAdHandle } from '../engine/port';
18
+
19
+ export interface AdsEngineSubject {
20
+ readonly engine: AdsEngine;
21
+ /** The provider finishes loading the ad behind `handle`. */
22
+ deliverLoad(handle: EngineAdHandle): void;
23
+ /** The provider grants a reward for the ad behind `handle`. */
24
+ deliverReward(handle: EngineAdHandle, reward: Reward): void;
25
+ /** The user closes the ad behind `handle`. */
26
+ deliverDismiss(handle: EngineAdHandle): void;
27
+ }
28
+
29
+ export interface AdsEngineHarness {
30
+ create(): AdsEngineSubject;
31
+ }
32
+
33
+ /** Lets callbacks a provider delivers on a later microtask arrive. */
34
+ async function settle(): Promise<void> {
35
+ for (let turn = 0; turn < 3; turn += 1) {
36
+ await Promise.resolve();
37
+ }
38
+ }
39
+
40
+ export const adsEngineChecks: readonly Check<AdsEngineHarness>[] = [
41
+ {
42
+ name: 'is available, and initializes with privacy applied',
43
+ async run(harness) {
44
+ const { engine } = harness.create();
45
+ ensure(engine.isAvailable(), 'reported unavailable');
46
+ await engine.initialize({ childDirected: true, maxAdContentRating: 'G' });
47
+ },
48
+ },
49
+ {
50
+ name: 'delivers loaded to a subscriber of an interstitial, and nothing after unsubscribing',
51
+ async run(harness) {
52
+ const subject = harness.create();
53
+ const handle = await subject.engine.createFullScreen('interstitial', 'conformance/1');
54
+ ensure(typeof handle.id === 'string' && handle.id !== '', 'handle has no id');
55
+ const seen: EngineAdEvent['type'][] = [];
56
+ const stop = subject.engine.subscribe(handle, event => seen.push(event.type));
57
+ subject.deliverLoad(handle);
58
+ await settle();
59
+ ensure(seen.join(',') === 'loaded', `subscriber saw ${seen.join(',') || 'nothing'}`);
60
+ stop();
61
+ stop();
62
+ subject.deliverDismiss(handle);
63
+ await settle();
64
+ ensure(seen.length === 1, `notified after unsubscribing: ${seen.join(',')}`);
65
+ },
66
+ },
67
+ {
68
+ name: 'delivers the reward and the dismissal of a rewarded ad',
69
+ async run(harness) {
70
+ const subject = harness.create();
71
+ const handle = await subject.engine.createFullScreen('rewarded', 'conformance/2', {
72
+ personalizedAds: false,
73
+ });
74
+ const seen: EngineAdEvent[] = [];
75
+ subject.engine.subscribe(handle, event => seen.push(event));
76
+ subject.deliverLoad(handle);
77
+ await settle();
78
+ subject.deliverReward(handle, { type: 'coins', amount: 5 });
79
+ subject.deliverDismiss(handle);
80
+ await settle();
81
+ const reward = seen.find(event => event.type === 'rewarded');
82
+ ensure(reward?.type === 'rewarded', `no reward among ${seen.map(e => e.type).join(',')}`);
83
+ ensure(
84
+ reward.reward.type === 'coins' && reward.reward.amount === 5,
85
+ `reward ${JSON.stringify(reward.reward)}`,
86
+ );
87
+ ensure(seen.at(-1)?.type === 'dismissed', `last event ${seen.at(-1)?.type}`);
88
+ },
89
+ },
90
+ {
91
+ name: 'gives every ad its own handle',
92
+ async run(harness) {
93
+ const { engine } = harness.create();
94
+ const first = await engine.createFullScreen('interstitial', 'conformance/1');
95
+ const second = await engine.createFullScreen('interstitial', 'conformance/1');
96
+ ensure(first.id !== second.id, 'two ads share a handle');
97
+ },
98
+ },
99
+ {
100
+ name: 'disposes safely twice',
101
+ async run(harness) {
102
+ const { engine } = harness.create();
103
+ const handle = await engine.createFullScreen('interstitial', 'conformance/1');
104
+ engine.dispose(handle);
105
+ engine.dispose(handle);
106
+ },
107
+ },
108
+ ];
109
+
110
+ export function runAdsEngineConformance(harness: AdsEngineHarness): Promise<ConformanceReport> {
111
+ return runChecks(() => harness, adsEngineChecks);
112
+ }
@@ -203,6 +203,15 @@ export interface FakeAdsEngine extends AdsEngine {
203
203
  failConsentInfoRead(cause?: unknown): void;
204
204
  }
205
205
 
206
+ /**
207
+ * Runs `task` on the next microtask, which is when a provider's callbacks would
208
+ * arrive. Written out because React Native's type declarations omit
209
+ * `queueMicrotask`, although Hermes has it.
210
+ */
211
+ function nextMicrotask(task: () => void): void {
212
+ Promise.resolve().then(task);
213
+ }
214
+
206
215
  export function createFakeAdsEngine(options: FakeEngineOptions = {}): FakeAdsEngine {
207
216
  const calls: FakeEngineCall[] = [];
208
217
  const consentCalls: FakeConsentCall[] = [];
@@ -375,7 +384,7 @@ export function createFakeAdsEngine(options: FakeEngineOptions = {}): FakeAdsEng
375
384
  if (!options.neverCallBack) {
376
385
  // `show` is called with a listener already attached, so this one
377
386
  // can go out directly on the next microtask.
378
- queueMicrotask(() => {
387
+ nextMicrotask(() => {
379
388
  for (const listener of Array.from(listeners.get(handle.id) ?? [])) {
380
389
  listener({ type: 'opened' });
381
390
  }
@@ -395,7 +404,7 @@ export function createFakeAdsEngine(options: FakeEngineOptions = {}): FakeAdsEng
395
404
  const queued = pending.get(handle.id);
396
405
  if (queued) {
397
406
  pending.delete(handle.id);
398
- queueMicrotask(() => {
407
+ nextMicrotask(() => {
399
408
  for (const target of Array.from(listeners.get(handle.id) ?? [])) {
400
409
  target(queued);
401
410
  }
@@ -461,3 +470,6 @@ export function createFakeAdsEngine(options: FakeEngineOptions = {}): FakeAdsEng
461
470
  },
462
471
  };
463
472
  }
473
+
474
+ export { adsEngineChecks, runAdsEngineConformance } from './conformance';
475
+ export type { AdsEngineHarness, AdsEngineSubject } from './conformance';