@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 +127 -0
- package/README.md +9 -2
- package/package.json +5 -2
- package/skills/sdk-rn-ads/SKILL.md +2 -2
- package/src/config/validate.ts +3 -1
- package/src/core/adsInstance.ts +45 -75
- package/src/core/retryPolicy.ts +12 -2
- package/src/core/timers.ts +17 -69
- package/src/diagnostics/errors.ts +28 -58
- package/src/diagnostics/events.ts +77 -66
- package/src/engine/admobEngine.ts +17 -6
- package/src/index.ts +19 -1
- package/src/react/AdsBanner.tsx +22 -8
- package/src/react/AdsProvider.tsx +6 -7
- package/src/react/hooks.ts +26 -31
- package/src/testing/conformance.ts +112 -0
- package/src/testing/index.ts +14 -2
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.
|
|
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
|
|
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
|
|
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`,
|
package/src/config/validate.ts
CHANGED
|
@@ -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
|
-
:
|
|
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
|
}
|
package/src/core/adsInstance.ts
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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 ??
|
|
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
|
-
|
|
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:
|
|
430
|
+
listeners: createListenerSet(listenerOptions),
|
|
406
431
|
retryAttempts: 0,
|
|
407
432
|
cancelRetry: null,
|
|
408
433
|
});
|
|
409
434
|
}
|
|
410
435
|
|
|
411
|
-
const consentListeners =
|
|
436
|
+
const consentListeners = createListenerSet<[ConsentSnapshot]>(listenerOptions);
|
|
412
437
|
|
|
413
438
|
function setConsent(next: ConsentSnapshot): void {
|
|
414
439
|
consent = next;
|
|
415
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1919
|
-
|
|
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;
|
package/src/core/retryPolicy.ts
CHANGED
|
@@ -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
|
-
|
|
121
|
-
|
|
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. */
|
package/src/core/timers.ts
CHANGED
|
@@ -8,8 +8,10 @@
|
|
|
8
8
|
* something it did not (FR-008).
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import {
|
|
12
|
-
|
|
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:
|
|
109
|
+
bounds: Range,
|
|
110
|
+
code: AdsErrorCode = 'timeout_out_of_bounds',
|
|
111
|
+
unit = 'ms',
|
|
103
112
|
): number {
|
|
104
|
-
|
|
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
|
|
152
|
-
*
|
|
153
|
-
*
|
|
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
|
|
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
|
-
|
|
98
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
|
181
|
-
|
|
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 {
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
package/src/react/AdsBanner.tsx
CHANGED
|
@@ -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
|
-
|
|
43
|
-
|
|
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:
|
|
232
|
+
timestamp: loadedAt,
|
|
220
233
|
placementKey,
|
|
221
234
|
format: 'banner',
|
|
222
235
|
correlationId: correlationRef.current ?? undefined,
|
|
223
|
-
durationMs:
|
|
236
|
+
durationMs: loadedAt - startedAtRef.current,
|
|
224
237
|
});
|
|
225
|
-
|
|
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
|
-
|
|
38
|
-
instance.setAppActive(
|
|
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>;
|
package/src/react/hooks.ts
CHANGED
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
* state; it never holds provider-owned memory (FR-027).
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import {
|
|
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
|
|
23
|
-
|
|
23
|
+
const subscribe = useCallback(
|
|
24
|
+
(listener: () => void) =>
|
|
25
|
+
ads ? ads.subscribePlacement(placementKey, listener) : NO_SUBSCRIPTION,
|
|
26
|
+
[ads, placementKey],
|
|
24
27
|
);
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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(
|
|
74
|
-
}, [ads,
|
|
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
|
+
}
|
package/src/testing/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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';
|