@chipmobilesdk/rn-ads 0.2.0 → 0.3.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.
@@ -1,1554 +1,1670 @@
1
- /**
2
- * The ads instance — the public contract's implementation.
3
- *
4
- * Every guarantee the specification treats as a rule rather than a convention
5
- * converges here, and none of it needs a device to prove:
6
- *
7
- * FR-022 one native initialization, however many callers race
8
- * FR-017 no request before the privacy gate permits one
9
- * FR-011 no production ad unit outside production
10
- * FR-029 one in-flight provider request per placement
11
- * FR-030 exactly one full-screen ad, never a queue
12
- * FR-031 a consumed ad is never reused
13
- * FR-035 after dispose, a late callback updates nothing and emits nothing
14
- *
15
- * Nothing in this file imports the provider. It talks to the engine port, which
16
- * the caller injects — the AdMob adapter in production, the fake in tests.
17
- */
18
-
19
- import {
20
- isFullScreenFormat,
21
- type AdFormat,
22
- type AdsConfig,
23
- type ConsentSnapshot,
24
- type Enablement,
25
- type FullScreenFormat,
26
- type InitStatus,
27
- type PlacementDefinition,
28
- type PlacementState,
29
- type PrivacyBlockReason,
30
- type PrivacyOptions,
31
- type UnavailableReason,
32
- type Unsubscribe,
33
- } from '../config/types';
34
- import { validateConfig, type ResolvedConfig } from '../config/validate';
35
- import { AdsError, normalizeProviderError } from '../diagnostics/errors';
36
- import {
37
- createEventEmitter,
38
- type AdEvent,
39
- type AdEventListener,
40
- type ImpressionSource,
41
- } from '../diagnostics/events';
42
- import { createCorrelationIssuer } from '../diagnostics/correlation';
43
- import type { AdsEngine, EngineAdEvent, EngineAdHandle } from '../engine/port';
44
- import { createFullScreenLock } from './fullScreenLock';
45
- import { canRequest, canStart, initialInit, initReducer, type InitSnapshot } from './initMachine';
46
- import {
47
- canLoad,
48
- initialPlacement,
49
- placementReducer,
50
- type PlacementSnapshot,
51
- } from './placementMachine';
52
- import { evaluateGate, requiresReinitialization, toEnginePrivacy } from './privacyGate';
53
- import { evaluateGates, type ConsentGateInput, type GateDecision } from './gates';
54
- import { createRewardedSession, type RewardedSession } from './rewardedSession';
55
- import { createTimerSet, isStale, type Scheduler } from './timers';
56
- import { canResume, permitsRequests, type EnablementResult } from './enablement';
57
- import { canRetry, delayForAttempt } from './retryPolicy';
58
- import { createAdOriginatedTracker } from './adOriginatedReturn';
59
- import {
60
- buildConsentOptions,
61
- INITIAL_CONSENT,
62
- needsForm,
63
- runConsentStep,
64
- toGateInput,
65
- } from './consentFlow';
66
-
67
- export type InitResult =
68
- | { readonly status: 'ready' }
69
- | { readonly status: 'blocked'; readonly reason: PrivacyBlockReason }
70
- | { readonly status: 'disabled' }
71
- /** The app has not decided whether ads are on. Nothing was requested (FR-115). */
72
- | { readonly status: 'undecided' }
73
- | { readonly status: 'unavailable'; readonly reason: UnavailableReason }
74
- | { readonly status: 'failed'; readonly error: AdsError };
75
-
76
- export type PrivacyUpdateResult =
77
- | { readonly status: 'applied' }
78
- | { readonly status: 'blocked'; readonly reason: PrivacyBlockReason }
79
- | { readonly status: 'requires_reinitialization' }
80
- | { readonly status: 'disabled' }
81
- | { readonly status: 'undecided' }
82
- | { readonly status: 'unavailable'; readonly reason: UnavailableReason };
83
-
84
- export type LoadResult =
85
- | { readonly status: 'loaded'; readonly correlationId: string }
86
- | { readonly status: 'disabled' }
87
- | { readonly status: 'undecided' }
88
- | { readonly status: 'blocked'; readonly reason: PrivacyBlockReason }
89
- | { readonly status: 'unavailable'; readonly reason: UnavailableReason }
90
- | { readonly status: 'no_fill' }
91
- | { readonly status: 'timeout' }
92
- | { readonly status: 'failed'; readonly error: AdsError };
93
-
94
- export type ShowResult =
95
- | { readonly status: 'shown'; readonly correlationId: string }
96
- | { readonly status: 'not_ready' }
97
- | { readonly status: 'busy' }
98
- | { readonly status: 'consumed' }
99
- | { readonly status: 'app_inactive' }
100
- /**
101
- * The user came back to the app from an ad, or from a browser an ad opened,
102
- * so an app-open ad would be an ad immediately after an ad. Distinct from
103
- * `busy` and `not_ready` because the app cannot detect this itself and
104
- * therefore cannot infer it from either of those (FR-128).
105
- */
106
- | { readonly status: 'ad_originated_return' }
107
- | { readonly status: 'disabled' }
108
- | { readonly status: 'undecided' }
109
- | { readonly status: 'blocked'; readonly reason: PrivacyBlockReason }
110
- | { readonly status: 'unavailable'; readonly reason: UnavailableReason }
111
- | { readonly status: 'timeout' }
112
- | { readonly status: 'failed'; readonly error: AdsError };
113
-
114
- /**
115
- * The optional consent capability (FR-101).
116
- *
117
- * Grouped under its own namespace rather than flattened onto the instance so
118
- * that FR-102's optionality stays legible: an app that never touches
119
- * `ads.consent` never starts a flow. Five loose methods on the instance would
120
- * invite the assumption that they are part of the normal path.
121
- */
122
- export interface ConsentApi {
123
- /** Refreshes consent information from the provider. */
124
- requestInfoUpdate(): Promise<ConsentSnapshot>;
125
- /** Presents the provider's form if one is required. Renders nothing itself. */
126
- showFormIfRequired(): Promise<ConsentSnapshot>;
127
- getState(): ConsentSnapshot;
128
- subscribe(listener: (snapshot: ConsentSnapshot) => void): Unsubscribe;
129
- /** Clears the stored decision. Inert in production (FR-113). */
130
- reset(): Promise<void>;
131
- }
132
-
133
- export interface AdsInstance {
134
- initialize(privacy: PrivacyOptions): Promise<InitResult>;
135
- updatePrivacy(privacy: PrivacyOptions): Promise<PrivacyUpdateResult>;
136
- /**
137
- * Turns ads on or off. Returns a result rather than `void` (FR-122).
138
- *
139
- * 0.1.0 returned nothing, which meant an app re-enabling ads after a lapsed
140
- * subscription had no way to learn that it also had to re-initialize — and
141
- * every load and show after that was refused in silence.
142
- */
143
- setEnabled(enabled: boolean): EnablementResult;
144
- /** The same, including the third state the app has not resolved yet. */
145
- setEnablement(next: Enablement): EnablementResult;
146
- setAppActive(active: boolean): void;
147
- readonly consent: ConsentApi;
148
- getStatus(): InitStatus;
149
- subscribeStatus(listener: (status: InitStatus) => void): Unsubscribe;
150
-
151
- load(placementKey: string): Promise<LoadResult>;
152
- show(placementKey: string): Promise<ShowResult>;
153
- getPlacementState(placementKey: string): PlacementState;
154
- subscribePlacement(key: string, listener: (state: PlacementState) => void): Unsubscribe;
155
-
156
- subscribeEvents(listener: AdEventListener): Unsubscribe;
157
-
158
- /** FR-011a — substitution is observable state, not only a log line. */
159
- getSubstitutedPlacements(): readonly string[];
160
- /** FR-113 — the same treatment for consent tools dropped in production. */
161
- getIgnoredConsentTools(): readonly string[];
162
- /** Internal wiring for the banner host; not part of the documented surface. */
163
- readonly internals: InstanceInternals;
164
-
165
- dispose(): void;
166
- }
167
-
168
- /** What `AdsBanner` needs. Deliberately not documented as consumer API. */
169
- export interface InstanceInternals {
170
- resolveBanner(placementKey: string):
171
- | { readonly ok: true; readonly adUnitId: string; readonly correlationId: string }
172
- | { readonly ok: false; readonly result: LoadResult };
173
- emit(event: AdEvent): void;
174
- setPlacementState(key: string, state: PlacementState): void;
175
- isReady(): boolean;
176
- nextCorrelationId(): string;
177
- timerCount(): number;
178
- }
179
-
180
- export interface CreateAdsOptions {
181
- /** Injected in tests; the AdMob adapter is the default in production. */
182
- readonly engine?: AdsEngine;
183
- /** Injected so the FR-012 rule is testable without touching a global. */
184
- readonly isDevBuild?: boolean;
185
- readonly now?: () => number;
186
- /**
187
- * Injected so retry backoff, app-open expiry, and the undecided warning can
188
- * be driven by a test clock instead of by waiting. Production uses real
189
- * timers.
190
- */
191
- readonly scheduler?: Scheduler;
192
- }
193
-
194
- interface PlacementRuntime {
195
- snapshot: PlacementSnapshot;
196
- /**
197
- * The cycle's correlation id, held here rather than read from the snapshot:
198
- * the reducer resets the snapshot on dismissal, and events emitted around
199
- * that transition must still carry the id of the cycle they belong to.
200
- */
201
- correlationId: string | null;
202
- /**
203
- * Whether this cycle already reported a dismissal. Format-independent — the
204
- * rewarded session guards its own, but interstitials need the same guard and
205
- * would otherwise report a repeated provider callback twice.
206
- */
207
- dismissed: boolean;
208
- handle: EngineAdHandle | null;
209
- unsubscribeEngine: Unsubscribe | null;
210
- inFlightLoad: Promise<LoadResult> | null;
211
- cancelLoadTimer: Unsubscribe | null;
212
- cancelShowTimer: Unsubscribe | null;
213
- rewarded: RewardedSession | null;
214
- listeners: Set<(state: PlacementState) => void>;
215
- /** Retries already spent in this load cycle. Reset when a new cycle starts. */
216
- retryAttempts: number;
217
- /** Cancels a scheduled retry. Called by disable, undecided, and dispose. */
218
- cancelRetry: Unsubscribe | null;
219
- }
220
-
221
- function detectDevBuild(): boolean {
222
- return typeof __DEV__ !== 'undefined' ? Boolean(__DEV__) : false;
223
- }
224
-
225
- export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): AdsInstance {
226
- const isDevBuild = options.isDevBuild ?? detectDevBuild();
227
- const now = options.now ?? (() => Date.now());
228
-
229
- // Throws on a structural fault or the FR-012 fail-closed case. Deliberately
230
- // before anything else: a misconfigured instance should not exist at all.
231
- const resolved: ResolvedConfig = validateConfig(config, { isDevBuild });
232
-
233
- const engine: AdsEngine = options.engine ?? createDefaultEngine();
234
- const events = createEventEmitter();
235
- const correlation = createCorrelationIssuer();
236
- const timers = createTimerSet(options.scheduler);
237
- const lock = createFullScreenLock();
238
- const adReturn = createAdOriginatedTracker();
239
-
240
- /**
241
- * An instance told it is `undecided` says so from the moment it exists.
242
- *
243
- * Without this it reports `not_started` until `initialize()` runs, which is
244
- * indistinguishable from a disabled instance at exactly the point an app is
245
- * deciding whether to reserve the banner's space — so FR-116 would hold only
246
- * after a call the app has no reason to make yet. Found on device, not in a
247
- * unit test, because the demo screen defers initialization the way a real
248
- * entitlement-gated app does.
249
- *
250
- * `disabled` deliberately keeps 0.1.0's `not_started` here: that behavior is
251
- * observable today and BC-004 protects it. The asymmetry is the cost of not
252
- * changing something that already shipped.
253
- */
254
- let init: InitSnapshot =
255
- resolved.enablement === 'undecided'
256
- ? initReducer(initialInit, { type: 'UNDECIDE' })
257
- : initialInit;
258
- let enablement: Enablement = resolved.enablement;
259
- let appActive = true;
260
- let consent: ConsentSnapshot = INITIAL_CONSENT;
261
- let consentStarted = false;
262
- let undecidedWarned = false;
263
- let cancelUndecidedTimer: Unsubscribe | null = null;
264
- let appliedPrivacy: PrivacyOptions | null = null;
265
- let currentPrivacy: PrivacyOptions | null = null;
266
- let inFlightInit: Promise<InitResult> | null = null;
267
- let disposed = false;
268
- let warnedSubstitution = false;
269
-
270
- const statusListeners = new Set<(status: InitStatus) => void>();
271
- const placements = new Map<string, PlacementRuntime>();
272
-
273
- for (const key of resolved.placements.keys()) {
274
- placements.set(key, {
275
- snapshot: initialPlacement,
276
- correlationId: null,
277
- dismissed: false,
278
- handle: null,
279
- unsubscribeEngine: null,
280
- inFlightLoad: null,
281
- cancelLoadTimer: null,
282
- cancelShowTimer: null,
283
- rewarded: null,
284
- listeners: new Set(),
285
- retryAttempts: 0,
286
- cancelRetry: null,
287
- });
288
- }
289
-
290
- const consentListeners = new Set<(snapshot: ConsentSnapshot) => void>();
291
-
292
- function setConsent(next: ConsentSnapshot): void {
293
- consent = next;
294
- for (const listener of Array.from(consentListeners)) {
295
- try {
296
- listener(next);
297
- } catch {
298
- // Consumer code; isolated exactly as event listeners are (FR-056).
299
- }
300
- }
301
- }
302
-
303
- /**
304
- * The consent flow's contribution to the gate, or `null` when the app never
305
- * opted in.
306
- *
307
- * `null` is what makes FR-102 structural rather than conditional: with no
308
- * flow started, the gate sees only the app's own input and behaves exactly as
309
- * 0.1.0 did. There is no code path to skip and therefore none to forget.
310
- */
311
- function consentGateInput(): ConsentGateInput {
312
- return consentStarted ? toGateInput(consent) : null;
313
- }
314
-
315
- /**
316
- * Warns once if the app leaves `undecided` unresolved (FR-119).
317
- *
318
- * Once, not repeatedly: a warning that fires every ten seconds becomes noise
319
- * that gets filtered, which defeats the diagnostic. And it only warns — the
320
- * package never resolves the state itself, because doing so would mean
321
- * guessing at exactly the business decision FR-120 forbids it from knowing.
322
- */
323
- function armUndecidedWarning(): void {
324
- if (enablement !== 'undecided' || undecidedWarned || cancelUndecidedTimer) {
325
- return;
326
- }
327
- const armedAt = now();
328
- cancelUndecidedTimer = timers.after(resolved.timeouts.undecidedWarning, () => {
329
- cancelUndecidedTimer = null;
330
- if (disposed || enablement !== 'undecided' || undecidedWarned) {
331
- return;
332
- }
333
- undecidedWarned = true;
334
- events.emit({
335
- type: 'config_warning',
336
- timestamp: now(),
337
- warning: 'enablement_undecided_timeout',
338
- placementKeys: [],
339
- elapsedMs: now() - armedAt,
340
- });
341
- });
342
- }
343
-
344
- function disarmUndecidedWarning(): void {
345
- cancelUndecidedTimer?.();
346
- cancelUndecidedTimer = null;
347
- }
348
-
349
- /** Cancels every pending retry, everywhere (FR-137). */
350
- function cancelAllRetries(): void {
351
- for (const runtime of placements.values()) {
352
- runtime.cancelRetry?.();
353
- runtime.cancelRetry = null;
354
- }
355
- }
356
-
357
- function setInit(next: InitSnapshot): void {
358
- if (next === init || next.status === init.status) {
359
- init = next;
360
- return;
361
- }
362
- init = next;
363
- events.emit({
364
- type: 'initialization',
365
- timestamp: now(),
366
- status: next.status,
367
- retryable: next.error?.retryable,
368
- });
369
- for (const listener of Array.from(statusListeners)) {
370
- try {
371
- listener(next.status);
372
- } catch {
373
- // A status listener is consumer code; its failure is isolated the
374
- // same way an event listener's is (FR-056).
375
- }
376
- }
377
- }
378
-
379
- function setPlacement(key: string, next: PlacementSnapshot): void {
380
- const runtime = placements.get(key);
381
- if (!runtime || runtime.snapshot.state === next.state) {
382
- if (runtime) {
383
- runtime.snapshot = next;
384
- }
385
- return;
386
- }
387
- runtime.snapshot = next;
388
- for (const listener of Array.from(runtime.listeners)) {
389
- try {
390
- listener(next.state);
391
- } catch {
392
- // Isolated, as above.
393
- }
394
- }
395
- }
396
-
397
- function dispatchPlacement(key: string, event: Parameters<typeof placementReducer>[1]): void {
398
- const runtime = placements.get(key);
399
- const definition = resolved.placements.get(key);
400
- if (!runtime || !definition) {
401
- return;
402
- }
403
- setPlacement(key, placementReducer(runtime.snapshot, event, definition.format));
404
- }
405
-
406
- /**
407
- * The single gate evaluation (FR-140).
408
- *
409
- * Assembles the three inputs and defers the precedence entirely to
410
- * `evaluateGates`. Nothing in this file compares gate state itself — the
411
- * whole point of FR-140 is that the order is a decision made once, not four
412
- * `if` chains that happen to agree today.
413
- *
414
- * Note the order differs from 0.1.0's, which checked `enabled` before
415
- * availability. That was never observable in isolation, only when two gates
416
- * were closed at once, and the case it changes — disabled on an unsupported
417
- * platform, previously decided by whichever transition arrived last — is the
418
- * incidental ordering FR-140a exists to fix.
419
- */
420
- function currentGate(): GateDecision {
421
- return evaluateGates({
422
- available: engine.isAvailable()
423
- ? { ok: true }
424
- : { ok: false, reason: 'unsupported_platform' },
425
- enablement,
426
- appGate: (() => {
427
- const gate = evaluateGate(currentPrivacy);
428
- return gate.allowed
429
- ? ({ permitted: true } as const)
430
- : ({ permitted: false, reason: gate.reason } as const);
431
- })(),
432
- consentGate: consentGateInput(),
433
- });
434
- }
435
-
436
- /** Maps a closed gate onto the refusal shape load, show, and the banner share. */
437
- function refusalFor(
438
- decision: Extract<GateDecision, { allowed: false }>,
439
- ): Extract<LoadResult, { status: 'disabled' | 'undecided' | 'blocked' | 'unavailable' }> {
440
- switch (decision.kind) {
441
- case 'unavailable':
442
- return { status: 'unavailable', reason: decision.reason };
443
- case 'disabled':
444
- return { status: 'disabled' };
445
- case 'undecided':
446
- return { status: 'undecided' };
447
- default:
448
- return { status: 'blocked', reason: decision.reason };
449
- }
450
- }
451
-
452
- /** Every refusal that applies before a placement is even looked up. */
453
- function gateCheck():
454
- | { readonly ok: true }
455
- | {
456
- readonly ok: false;
457
- readonly result: Extract<
458
- LoadResult,
459
- { status: 'disabled' | 'undecided' | 'blocked' | 'unavailable' }
460
- >;
461
- } {
462
- const decision = currentGate();
463
- if (decision.allowed) {
464
- return { ok: true };
465
- }
466
- return { ok: false, result: refusalFor(decision) };
467
- }
468
-
469
- function emitRefusal(
470
- operation: 'initialize' | 'load' | 'show',
471
- result: { readonly status: string; readonly reason?: string },
472
- placementKey?: string,
473
- format?: AdFormat,
474
- ): void {
475
- if (result.status === 'disabled' || result.status === 'undecided') {
476
- // `undecided` reuses the `disabled` event rather than minting a new
477
- // one: for an observer of the event stream the fact is the same — no
478
- // request was made — and the distinction that matters is carried by
479
- // the typed result and the init status, where the app reads it.
480
- events.emit({ type: 'disabled', timestamp: now(), operation, placementKey, format });
481
- } else if (result.status === 'unavailable' || result.status === 'blocked') {
482
- events.emit({
483
- type: 'unavailable',
484
- timestamp: now(),
485
- operation,
486
- placementKey,
487
- format,
488
- reason: (result.reason ?? 'unsupported_platform') as UnavailableReason | PrivacyBlockReason,
489
- });
490
- }
491
- }
492
-
493
- function warnSubstitutionOnce(): void {
494
- if (warnedSubstitution || resolved.catalog.substitutedKeys.length === 0) {
495
- return;
496
- }
497
- warnedSubstitution = true;
498
- events.emit({
499
- type: 'config_warning',
500
- timestamp: now(),
501
- warning: 'test_ad_unit_substituted',
502
- placementKeys: resolved.catalog.substitutedKeys,
503
- });
504
- }
505
-
506
- function releasePlacementResources(key: string): void {
507
- const runtime = placements.get(key);
508
- if (!runtime) {
509
- return;
510
- }
511
- runtime.cancelLoadTimer?.();
512
- runtime.cancelLoadTimer = null;
513
- runtime.cancelShowTimer?.();
514
- runtime.cancelShowTimer = null;
515
- runtime.unsubscribeEngine?.();
516
- runtime.unsubscribeEngine = null;
517
- if (runtime.handle) {
518
- engine.dispose(runtime.handle);
519
- runtime.handle = null;
520
- }
521
- runtime.inFlightLoad = null;
522
- runtime.rewarded = null;
523
- runtime.cancelRetry?.();
524
- runtime.cancelRetry = null;
525
- }
526
-
527
- /** App-open expires on its own, shorter window (FR-130). */
528
- function validityFor(format: AdFormat): number {
529
- return format === 'app_open'
530
- ? resolved.timeouts.appOpenValidity
531
- : resolved.timeouts.adValidity;
532
- }
533
-
534
- async function doInitialize(privacy: PrivacyOptions): Promise<InitResult> {
535
- const enginePrivacy = toEnginePrivacy(privacy, resolved.testDeviceIds);
536
- try {
537
- // The adapter applies privacy configuration before initializing the
538
- // provider; that ordering is the whole reason FR-017 is enforceable.
539
- await engine.initialize(enginePrivacy);
540
- appliedPrivacy = privacy;
541
- setInit(initReducer(init, { type: 'SUCCEED' }));
542
- warnSubstitutionOnce();
543
- return { status: 'ready' };
544
- } catch (error) {
545
- const normalized = normalizeProviderError(error, { operation: 'initialize' });
546
- setInit(initReducer(init, { type: 'FAIL', error: normalized }));
547
- return { status: 'failed', error: normalized };
548
- } finally {
549
- inFlightInit = null;
550
- }
551
- }
552
-
553
- function handleEngineEvent(
554
- key: string,
555
- definition: PlacementDefinition,
556
- event: EngineAdEvent,
557
- ): void {
558
- // After dispose nothing is honored: a late provider callback must not
559
- // update a consumer that has gone away (FR-035, SC-008).
560
- if (disposed) {
561
- return;
562
- }
563
- const runtime = placements.get(key);
564
- if (!runtime) {
565
- return;
566
- }
567
- const correlationId = runtime.correlationId ?? undefined;
568
- const base = { timestamp: now(), placementKey: key, format: definition.format, correlationId };
569
-
570
- switch (event.type) {
571
- case 'opened': {
572
- // The provider confirming an ad is on screen is what arms
573
- // ad-originated return detection (FR-128). Taken from here rather
574
- // than from the app's background report, which arrives too late to
575
- // be trusted — see core/adOriginatedReturn.ts.
576
- if (isFullScreenFormat(definition.format)) {
577
- adReturn.noteFullScreenShown(now());
578
- }
579
- events.emit({ ...base, type: 'display_started' });
580
- // R5: the provider exposes no impression callback for full-screen
581
- // formats, so this one is derived from the ad opening. Labelled
582
- // honestly rather than passed off as the provider's own.
583
- events.emit({ ...base, type: 'impression', source: 'derived' as ImpressionSource });
584
- break;
585
- }
586
- case 'clicked':
587
- // Recorded so that a background transition moments later is
588
- // recognized as "the ad opened a browser" — the only signal the
589
- // provider gives for that case (FR-128).
590
- adReturn.noteClick(now());
591
- events.emit({ ...base, type: 'click' });
592
- break;
593
- case 'rewarded': {
594
- const first = runtime.rewarded?.confirmReward(event.reward) ?? null;
595
- if (first) {
596
- events.emit({ ...base, type: 'reward_earned', reward: first });
597
- }
598
- break;
599
- }
600
- case 'dismissed': {
601
- const rewardedFirst = runtime.rewarded ? runtime.rewarded.dismiss() : true;
602
- if (runtime.dismissed || !rewardedFirst) {
603
- break;
604
- }
605
- runtime.dismissed = true;
606
- lock.release(key);
607
- runtime.cancelShowTimer?.();
608
- runtime.cancelShowTimer = null;
609
- events.emit({
610
- ...base,
611
- type: 'dismissed',
612
- rewardEarned: runtime.rewarded ? runtime.rewarded.rewardEarned : undefined,
613
- });
614
- dispatchPlacement(key, { type: 'DISMISSED' });
615
- // Deliberately NOT released here. FR-049 requires the reward and
616
- // the dismissal to be handled in either order, and a provider that
617
- // confirms the reward *after* closing the ad would otherwise have
618
- // its confirmation dropped by a torn-down listener — silently
619
- // costing the user a reward they earned.
620
- //
621
- // This is a retained resource, not a leak: at most one handle and
622
- // one listener per placement, released deterministically by the
623
- // next load of that placement or by dispose().
624
- break;
625
- }
626
- case 'failed': {
627
- const normalized = normalizeProviderError(event.cause, {
628
- operation: 'show',
629
- placementKey: key,
630
- format: definition.format,
631
- });
632
- lock.release(key);
633
- events.emit({ ...base, type: 'display_failed', error: normalized });
634
- dispatchPlacement(key, { type: 'SHOW_FAILED', error: normalized });
635
- releasePlacementResources(key);
636
- break;
637
- }
638
- default:
639
- break;
640
- }
641
- }
642
-
643
- function loadFullScreen(
644
- key: string,
645
- definition: PlacementDefinition,
646
- adUnitId: string,
647
- correlationId: string,
648
- ): Promise<LoadResult> {
649
- const runtime = placements.get(key)!;
650
- const format = definition.format as FullScreenFormat;
651
- const startedAt = now();
652
-
653
- return new Promise<LoadResult>(resolve => {
654
- let settled = false;
655
-
656
- const finish = (result: LoadResult) => {
657
- if (settled) {
658
- return;
659
- }
660
- settled = true;
661
- runtime.cancelLoadTimer?.();
662
- runtime.cancelLoadTimer = null;
663
- runtime.inFlightLoad = null;
664
- resolve(result);
665
- };
666
-
667
- runtime.cancelLoadTimer = timers.after(resolved.timeouts.load, () => {
668
- const error = new AdsError('load_timeout', {
669
- operation: 'load',
670
- placementKey: key,
671
- format,
672
- message: `load timed out after ${resolved.timeouts.load}ms`,
673
- });
674
- events.emit({
675
- type: 'load_failed',
676
- timestamp: now(),
677
- placementKey: key,
678
- format,
679
- correlationId,
680
- error,
681
- });
682
- dispatchPlacement(key, { type: 'LOAD_FAILED', error });
683
- releasePlacementResources(key);
684
- finish({ status: 'timeout' });
685
- });
686
-
687
- engine
688
- .createFullScreen(format, adUnitId)
689
- .then(handle => {
690
- if (disposed || settled) {
691
- engine.dispose(handle);
692
- return;
693
- }
694
- runtime.handle = handle;
695
- runtime.unsubscribeEngine = engine.subscribe(handle, event => {
696
- if (event.type === 'loaded') {
697
- dispatchPlacement(key, { type: 'LOADED', atMs: now() });
698
- events.emit({
699
- type: 'loaded',
700
- timestamp: now(),
701
- placementKey: key,
702
- format,
703
- correlationId,
704
- durationMs: now() - startedAt,
705
- });
706
- finish({ status: 'loaded', correlationId });
707
- return;
708
- }
709
- if (event.type === 'failed') {
710
- const normalized = normalizeProviderError(event.cause, {
711
- operation: 'load',
712
- placementKey: key,
713
- format,
714
- });
715
- events.emit({
716
- type: 'load_failed',
717
- timestamp: now(),
718
- placementKey: key,
719
- format,
720
- correlationId,
721
- error: normalized,
722
- });
723
- dispatchPlacement(key, { type: 'LOAD_FAILED', error: normalized });
724
- releasePlacementResources(key);
725
- finish(
726
- normalized.code === 'no_fill'
727
- ? { status: 'no_fill' }
728
- : { status: 'failed', error: normalized },
729
- );
730
- return;
731
- }
732
- handleEngineEvent(key, definition, event);
733
- });
734
- })
735
- .catch(error => {
736
- const normalized = normalizeProviderError(error, {
737
- operation: 'load',
738
- placementKey: key,
739
- format,
740
- });
741
- events.emit({
742
- type: 'load_failed',
743
- timestamp: now(),
744
- placementKey: key,
745
- format,
746
- correlationId,
747
- error: normalized,
748
- });
749
- dispatchPlacement(key, { type: 'LOAD_FAILED', error: normalized });
750
- finish(
751
- normalized.code === 'no_fill'
752
- ? { status: 'no_fill' }
753
- : { status: 'failed', error: normalized },
754
- );
755
- });
756
- });
757
- }
758
-
759
- /**
760
- * One load, plus however many bounded retries the policy allows (FR-134).
761
- *
762
- * Off unless configured, so an upgraded 0.1.0 consumer goes through here once
763
- * and issues exactly the requests it always did. When on, only errors already
764
- * flagged `retryable` are retried — a configuration or consent failure is a
765
- * state another attempt cannot change, and retrying it would spend a billable
766
- * request on a certainty (FR-135).
767
- *
768
- * Every attempt is emitted before it is waited on, carrying the cycle's
769
- * correlation id, so the event stream can be reconciled against a bill
770
- * without inferring anything (FR-138).
771
- */
772
- async function loadWithRetry(
773
- key: string,
774
- definition: PlacementDefinition,
775
- adUnitId: string,
776
- correlationId: string,
777
- ): Promise<LoadResult> {
778
- const runtime = placements.get(key)!;
779
- const policy = resolved.retryByKey.get(key) ?? null;
780
-
781
- for (;;) {
782
- const result = await loadFullScreen(key, definition, adUnitId, correlationId);
783
- if (result.status === 'loaded' || disposed) {
784
- return result;
785
- }
786
-
787
- const error =
788
- result.status === 'failed'
789
- ? result.error
790
- : result.status === 'no_fill'
791
- ? new AdsError('no_fill', { operation: 'load', placementKey: key })
792
- : result.status === 'timeout'
793
- ? new AdsError('load_timeout', { operation: 'load', placementKey: key })
794
- : null;
795
-
796
- // A refusal — disabled, undecided, blocked, unavailable — is not a
797
- // failure to retry. Nothing was requested, and nothing about asking
798
- // again would change that.
799
- if (!error || !error.retryable) {
800
- return result;
801
- }
802
- if (!canRetry(policy, runtime.retryAttempts) || !permitsRequests(enablement)) {
803
- return result;
804
- }
805
-
806
- runtime.retryAttempts += 1;
807
- const delayMs = delayForAttempt(policy!, runtime.retryAttempts);
808
- events.emit({
809
- type: 'retry_scheduled',
810
- timestamp: now(),
811
- placementKey: key,
812
- format: definition.format,
813
- correlationId,
814
- attempt: runtime.retryAttempts,
815
- delayMs,
816
- error,
817
- });
818
-
819
- const waited = await new Promise<boolean>(resolve => {
820
- runtime.cancelRetry = timers.after(delayMs, () => {
821
- runtime.cancelRetry = null;
822
- resolve(true);
823
- });
824
- const cancel = runtime.cancelRetry;
825
- // Cancellation replaces the handle, so a disable or dispose that
826
- // clears it resolves this wait as "do not continue" rather than
827
- // leaving a promise nobody will settle.
828
- runtime.cancelRetry = () => {
829
- cancel?.();
830
- runtime.cancelRetry = null;
831
- resolve(false);
832
- };
833
- });
834
-
835
- if (!waited || disposed || !permitsRequests(enablement)) {
836
- return result;
837
- }
838
-
839
- dispatchPlacement(key, { type: 'LOAD' });
840
- }
841
- }
842
-
843
- /**
844
- * Applies a consent outcome to the gate without a second initialization.
845
- *
846
- * This is FR-105's "the app does not wire it by hand" in one function. When
847
- * consent opens the gate and the instance was blocked, it initializes — and
848
- * because `initialize` is idempotent and shares one in-flight operation, the
849
- * total native initialization count across a whole consent flow stays at one
850
- * (FR-022, SC-102). When consent closes the gate, requests stop and every
851
- * pending retry is cancelled with them (FR-137).
852
- */
853
- async function reevaluateAfterConsent(): Promise<void> {
854
- if (disposed || !currentPrivacy) {
855
- return;
856
- }
857
- const decision = currentGate();
858
- if (!decision.allowed) {
859
- cancelAllRetries();
860
- if (decision.kind === 'blocked' && init.status !== 'ready') {
861
- setInit(initReducer(init, { type: 'BLOCK', reason: decision.reason }));
862
- }
863
- return;
864
- }
865
- if (init.status === 'ready' || init.status === 'initializing') {
866
- return;
867
- }
868
- await instance.initialize(currentPrivacy);
869
- }
870
-
871
- const instance: AdsInstance = {
872
- async initialize(privacy) {
873
- if (disposed) {
874
- return { status: 'unavailable', reason: 'provider_missing' };
875
- }
876
- currentPrivacy = privacy;
877
-
878
- // Availability first, then enablement, then consent — the one order,
879
- // evaluated in the one place (FR-140).
880
- const decision = currentGate();
881
- if (!decision.allowed && decision.kind === 'unavailable') {
882
- setInit(initReducer(init, { type: 'UNAVAILABLE', reason: decision.reason }));
883
- const result = { status: 'unavailable', reason: decision.reason } as const;
884
- emitRefusal('initialize', result);
885
- return result;
886
- }
887
- if (!decision.allowed && decision.kind === 'disabled') {
888
- setInit(initReducer(init, { type: 'DISABLE' }));
889
- const result = { status: 'disabled' } as const;
890
- emitRefusal('initialize', result);
891
- return result;
892
- }
893
- if (!decision.allowed && decision.kind === 'undecided') {
894
- // FR-115. Nothing provider-facing happens, exactly as when
895
- // disabled — but the status the app observes says `undecided`, so
896
- // a banner host can hold its space instead of reclaiming it.
897
- setInit(initReducer(init, { type: 'UNDECIDE' }));
898
- armUndecidedWarning();
899
- const result = { status: 'undecided' } as const;
900
- emitRefusal('initialize', result);
901
- return result;
902
- }
903
-
904
- const gate = evaluateGate(privacy);
905
- if (!gate.allowed) {
906
- // FR-017. Nothing provider-facing happens here at all — not a
907
- // configuration call, not an initialization. The engine is untouched.
908
- setInit(initReducer(init, { type: 'BLOCK', reason: gate.reason }));
909
- const result = { status: 'blocked', reason: gate.reason } as const;
910
- emitRefusal('initialize', result);
911
- return result;
912
- }
913
-
914
- if (init.status === 'ready') {
915
- return { status: 'ready' };
916
- }
917
-
918
- // FR-022, SC-005. Racing callers share one operation, so the provider
919
- // initializes exactly once however many arrive.
920
- if (inFlightInit) {
921
- return inFlightInit;
922
- }
923
- if (!canStart(init.status)) {
924
- return { status: 'ready' };
925
- }
926
-
927
- setInit(initReducer(init, { type: 'START' }));
928
- inFlightInit = doInitialize(privacy);
929
- return inFlightInit;
930
- },
931
-
932
- async updatePrivacy(privacy) {
933
- if (disposed) {
934
- return { status: 'disabled' };
935
- }
936
- if (enablement === 'undecided') {
937
- return { status: 'undecided' };
938
- }
939
- if (enablement === 'disabled') {
940
- return { status: 'disabled' };
941
- }
942
- if (!engine.isAvailable()) {
943
- return { status: 'unavailable', reason: 'unsupported_platform' };
944
- }
945
-
946
- // FR-020: a flag the provider only honors at initialization cannot be
947
- // applied now. Say so, rather than accepting it and running half-old.
948
- if (requiresReinitialization(appliedPrivacy, privacy)) {
949
- return { status: 'requires_reinitialization' };
950
- }
951
-
952
- currentPrivacy = privacy;
953
- const gate = evaluateGate(privacy);
954
- if (!gate.allowed) {
955
- setInit(initReducer(init, { type: 'BLOCK', reason: gate.reason }));
956
- return { status: 'blocked', reason: gate.reason };
957
- }
958
-
959
- // FR-019: resolving consent after a block must not produce a second
960
- // native initialization.
961
- if (init.status === 'ready') {
962
- return { status: 'applied' };
963
- }
964
- const result = await instance.initialize(privacy);
965
- return result.status === 'ready'
966
- ? { status: 'applied' }
967
- : result.status === 'blocked'
968
- ? { status: 'blocked', reason: result.reason }
969
- : { status: 'applied' };
970
- },
971
-
972
- setEnabled(next) {
973
- return instance.setEnablement(next ? 'enabled' : 'disabled');
974
- },
975
-
976
- setEnablement(next) {
977
- if (disposed) {
978
- return { status: 'unavailable', reason: 'provider_missing' };
979
- }
980
- if (enablement === next) {
981
- return { status: 'unchanged' };
982
- }
983
-
984
- const previous = enablement;
985
- enablement = next;
986
-
987
- if (next === 'disabled' || next === 'undecided') {
988
- // FR-037 and FR-125: new work is prevented and in-flight resources
989
- // released deterministically, and moving to `undecided` from
990
- // `enabled` follows the same rules as disabling. An ad already on
991
- // screen is left alone — the provider offers no safe way to close
992
- // it, and yanking it would be worse than letting it finish.
993
- cancelAllRetries();
994
- for (const [key, runtime] of placements) {
995
- if (runtime.snapshot.state === 'showing') {
996
- continue;
997
- }
998
- releasePlacementResources(key);
999
- dispatchPlacement(key, { type: 'RESET' });
1000
- }
1001
- setInit(initReducer(init, { type: next === 'disabled' ? 'DISABLE' : 'UNDECIDE' }));
1002
- if (next === 'undecided') {
1003
- armUndecidedWarning();
1004
- return { status: 'undecided' };
1005
- }
1006
- disarmUndecidedWarning();
1007
- return { status: 'disabled' };
1008
- }
1009
-
1010
- // Turning ads on. This is the path 0.1.0 got wrong: it dropped the
1011
- // status to `not_started` and returned nothing, so an app whose user
1012
- // lost their entitlement turned ads back on and then had every load
1013
- // silently refused (FR-121).
1014
- disarmUndecidedWarning();
1015
- const resumable = canResume(init.resumeTo);
1016
- setInit(initReducer(init, { type: 'ENABLE' }));
1017
-
1018
- if (init.status === 'unavailable') {
1019
- return { status: 'unavailable', reason: init.unavailableReason ?? 'unsupported_platform' };
1020
- }
1021
- if (resumable && init.status === 'ready') {
1022
- // The provider was never un-initialized, so there is genuinely
1023
- // nothing to redo — and FR-022 forbids initializing it twice.
1024
- return { status: 'resumed', initStatus: init.status };
1025
- }
1026
- if (previous === 'undecided' && init.status === 'ready') {
1027
- return { status: 'resumed', initStatus: init.status };
1028
- }
1029
- // Said out loud rather than left for the app to discover from a
1030
- // refused load. The package will not initialize implicitly: that would
1031
- // hide an asynchronous, billable operation behind a setter the caller
1032
- // may not be awaiting.
1033
- return { status: 'requires_initialization' };
1034
- },
1035
-
1036
- setAppActive(active) {
1037
- if (disposed) {
1038
- return;
1039
- }
1040
- appActive = active;
1041
- if (!active) {
1042
- // Arming happens when the provider reports an ad opened, not here:
1043
- // this event can be delivered late enough that the ad is already
1044
- // gone. All this adds is the browser case, where a background
1045
- // closely follows a click (FR-128).
1046
- adReturn.noteBackground(now());
1047
- return;
1048
- }
1049
- adReturn.noteForeground(now());
1050
- // FR-034: on return to the foreground, re-evaluate what was loaded.
1051
- // An ad that went stale while the app was away must stop reporting
1052
- // `ready` before the app offers a button that would fail on tap.
1053
- const current = now();
1054
- for (const [key, runtime] of placements) {
1055
- const definition = resolved.placements.get(key);
1056
- if (
1057
- runtime.snapshot.state === 'ready' &&
1058
- runtime.snapshot.loadedAtMs !== null &&
1059
- definition !== undefined &&
1060
- isStale(runtime.snapshot.loadedAtMs, current, validityFor(definition.format))
1061
- ) {
1062
- releasePlacementResources(key);
1063
- dispatchPlacement(key, { type: 'EXPIRE' });
1064
- }
1065
- }
1066
- },
1067
-
1068
- getStatus() {
1069
- return init.status;
1070
- },
1071
-
1072
- subscribeStatus(listener) {
1073
- statusListeners.add(listener);
1074
- let active = true;
1075
- return () => {
1076
- if (!active) {
1077
- return;
1078
- }
1079
- active = false;
1080
- statusListeners.delete(listener);
1081
- };
1082
- },
1083
-
1084
- async load(placementKey) {
1085
- if (disposed) {
1086
- return { status: 'unavailable', reason: 'provider_missing' };
1087
- }
1088
-
1089
- const definition = resolved.placements.get(placementKey);
1090
- if (!definition) {
1091
- return {
1092
- status: 'failed',
1093
- error: new AdsError('placement_not_found', {
1094
- operation: 'load',
1095
- placementKey,
1096
- message: `unknown placement "${placementKey}"; configured keys are ${[...resolved.placements.keys()].join(', ')}`,
1097
- }),
1098
- };
1099
- }
1100
-
1101
- const gate = gateCheck();
1102
- if (!gate.ok) {
1103
- emitRefusal('load', gate.result, placementKey, definition.format);
1104
- return gate.result;
1105
- }
1106
-
1107
- if (!canRequest(init.status)) {
1108
- return {
1109
- status: 'failed',
1110
- error: new AdsError('not_ready', {
1111
- operation: 'load',
1112
- placementKey,
1113
- format: definition.format,
1114
- message: 'initialize() must complete before loading',
1115
- }),
1116
- };
1117
- }
1118
-
1119
- if (definition.format === 'banner') {
1120
- return {
1121
- status: 'failed',
1122
- error: new AdsError('format_mismatch', {
1123
- operation: 'load',
1124
- placementKey,
1125
- format: definition.format,
1126
- message: `"${placementKey}" is a banner; banners render through <AdsBanner> rather than load()`,
1127
- }),
1128
- };
1129
- }
1130
-
1131
- const runtime = placements.get(placementKey)!;
1132
-
1133
- // FR-029, SC-002's sibling guarantee: a repeat load while one is in
1134
- // flight joins it instead of creating a second provider request.
1135
- if (runtime.inFlightLoad) {
1136
- return runtime.inFlightLoad;
1137
- }
1138
-
1139
- if (runtime.snapshot.state === 'showing') {
1140
- return { status: 'failed', error: new AdsError('busy', { operation: 'load', placementKey }) };
1141
- }
1142
-
1143
- if (!canLoad(runtime.snapshot.state)) {
1144
- return {
1145
- status: 'failed',
1146
- error: new AdsError('not_ready', { operation: 'load', placementKey }),
1147
- };
1148
- }
1149
-
1150
- releasePlacementResources(placementKey);
1151
-
1152
- const correlationId = correlation.next();
1153
- runtime.correlationId = correlationId;
1154
- runtime.dismissed = false;
1155
- dispatchPlacement(placementKey, { type: 'LOAD' });
1156
- setPlacement(placementKey, { ...runtime.snapshot, correlationId });
1157
- events.emit({
1158
- type: 'load_started',
1159
- timestamp: now(),
1160
- placementKey,
1161
- format: definition.format,
1162
- correlationId,
1163
- });
1164
-
1165
- const adUnitId = resolved.catalog.byKey.get(placementKey)!;
1166
- runtime.retryAttempts = 0;
1167
- const promise = loadWithRetry(placementKey, definition, adUnitId, correlationId);
1168
- runtime.inFlightLoad = promise;
1169
- return promise;
1170
- },
1171
-
1172
- async show(placementKey) {
1173
- if (disposed) {
1174
- return { status: 'unavailable', reason: 'provider_missing' };
1175
- }
1176
-
1177
- const definition = resolved.placements.get(placementKey);
1178
- if (!definition) {
1179
- return {
1180
- status: 'failed',
1181
- error: new AdsError('placement_not_found', {
1182
- operation: 'show',
1183
- placementKey,
1184
- message: `unknown placement "${placementKey}"; configured keys are ${[...resolved.placements.keys()].join(', ')}`,
1185
- }),
1186
- };
1187
- }
1188
-
1189
- if (!isFullScreenFormat(definition.format)) {
1190
- return {
1191
- status: 'failed',
1192
- error: new AdsError('format_mismatch', {
1193
- operation: 'show',
1194
- placementKey,
1195
- format: definition.format,
1196
- message: `"${placementKey}" is a banner; banners are rendered, not shown`,
1197
- }),
1198
- };
1199
- }
1200
-
1201
- const gate = gateCheck();
1202
- if (!gate.ok) {
1203
- emitRefusal('show', gate.result, placementKey, definition.format);
1204
- return gate.result;
1205
- }
1206
-
1207
- // FR-028: never open an ad while the app is not in front of the user.
1208
- if (!appActive) {
1209
- return { status: 'app_inactive' };
1210
- }
1211
-
1212
- // FR-128. Only app-open is refused: the user just came back from an ad
1213
- // or from a browser an ad opened, and app-open is the format that
1214
- // would fire on exactly that return. An interstitial at the same
1215
- // moment is the app's own deliberate choice, made somewhere the
1216
- // package cannot see, so refusing it would be policy rather than
1217
- // capability.
1218
- if (definition.format === 'app_open' && adReturn.isRefusing(now())) {
1219
- return { status: 'ad_originated_return' };
1220
- }
1221
-
1222
- const runtime = placements.get(placementKey)!;
1223
-
1224
- if (runtime.snapshot.state === 'consumed') {
1225
- return { status: 'consumed' };
1226
- }
1227
-
1228
- // FR-034: a stale ad is not shown and is not reported ready.
1229
- if (
1230
- runtime.snapshot.state === 'ready' &&
1231
- runtime.snapshot.loadedAtMs !== null &&
1232
- isStale(runtime.snapshot.loadedAtMs, now(), validityFor(definition.format))
1233
- ) {
1234
- releasePlacementResources(placementKey);
1235
- dispatchPlacement(placementKey, { type: 'EXPIRE' });
1236
- return { status: 'not_ready' };
1237
- }
1238
-
1239
- if (runtime.snapshot.state !== 'ready' || !runtime.handle) {
1240
- return { status: 'not_ready' };
1241
- }
1242
-
1243
- // FR-030. Acquired before anything else can open a screen, and never
1244
- // queued: a refused show is refused now, not deferred to a moment the
1245
- // app never chose.
1246
- if (!lock.acquire(placementKey)) {
1247
- return { status: 'busy' };
1248
- }
1249
-
1250
- const correlationId = runtime.correlationId ?? correlation.next();
1251
- runtime.rewarded = definition.format === 'rewarded' ? createRewardedSession() : null;
1252
- dispatchPlacement(placementKey, { type: 'SHOW' });
1253
-
1254
- let settled = false;
1255
- const showTimeout = new Promise<ShowResult>(resolve => {
1256
- runtime.cancelShowTimer = timers.after(resolved.timeouts.show, () => {
1257
- if (settled) {
1258
- return;
1259
- }
1260
- settled = true;
1261
- const error = new AdsError('show_timeout', {
1262
- operation: 'show',
1263
- placementKey,
1264
- format: definition.format,
1265
- message: `show timed out after ${resolved.timeouts.show}ms`,
1266
- });
1267
- lock.release(placementKey);
1268
- events.emit({
1269
- type: 'display_failed',
1270
- timestamp: now(),
1271
- placementKey,
1272
- format: definition.format,
1273
- correlationId,
1274
- error,
1275
- });
1276
- dispatchPlacement(placementKey, { type: 'SHOW_FAILED', error });
1277
- releasePlacementResources(placementKey);
1278
- resolve({ status: 'timeout' });
1279
- });
1280
- });
1281
-
1282
- const attempt = engine
1283
- .show(runtime.handle)
1284
- .then<ShowResult>(() => {
1285
- settled = true;
1286
- runtime.cancelShowTimer?.();
1287
- runtime.cancelShowTimer = null;
1288
- return { status: 'shown', correlationId };
1289
- })
1290
- .catch<ShowResult>(error => {
1291
- settled = true;
1292
- runtime.cancelShowTimer?.();
1293
- runtime.cancelShowTimer = null;
1294
- const normalized = normalizeProviderError(error, {
1295
- operation: 'show',
1296
- placementKey,
1297
- format: definition.format,
1298
- });
1299
- lock.release(placementKey);
1300
- events.emit({
1301
- type: 'display_failed',
1302
- timestamp: now(),
1303
- placementKey,
1304
- format: definition.format,
1305
- correlationId,
1306
- error: normalized,
1307
- });
1308
- dispatchPlacement(placementKey, { type: 'SHOW_FAILED', error: normalized });
1309
- releasePlacementResources(placementKey);
1310
- return { status: 'failed', error: normalized };
1311
- });
1312
-
1313
- return Promise.race([attempt, showTimeout]);
1314
- },
1315
-
1316
- getPlacementState(placementKey) {
1317
- if (!resolved.placements.has(placementKey)) {
1318
- return 'unavailable';
1319
- }
1320
- if (!engine.isAvailable() || enablement === 'disabled') {
1321
- return 'unavailable';
1322
- }
1323
- // Reported distinctly from `unavailable` so the app can choose to hold
1324
- // the banner's space rather than reclaim it. That choice is the entire
1325
- // reason the third state is worth having (FR-116).
1326
- if (enablement === 'undecided') {
1327
- return 'undecided';
1328
- }
1329
- return placements.get(placementKey)?.snapshot.state ?? 'idle';
1330
- },
1331
-
1332
- subscribePlacement(key, listener) {
1333
- const runtime = placements.get(key);
1334
- if (!runtime) {
1335
- return () => undefined;
1336
- }
1337
- runtime.listeners.add(listener);
1338
- let active = true;
1339
- return () => {
1340
- if (!active) {
1341
- return;
1342
- }
1343
- active = false;
1344
- runtime.listeners.delete(listener);
1345
- };
1346
- },
1347
-
1348
- subscribeEvents(listener) {
1349
- return events.subscribe(listener);
1350
- },
1351
-
1352
- getSubstitutedPlacements() {
1353
- return resolved.catalog.substitutedKeys;
1354
- },
1355
-
1356
- getIgnoredConsentTools() {
1357
- return resolved.ignoredConsentTools;
1358
- },
1359
-
1360
- consent: {
1361
- async requestInfoUpdate() {
1362
- if (disposed) {
1363
- return consent;
1364
- }
1365
- // Marks the flow as in use. Until this is called the gate sees no
1366
- // consent input at all, which is how an app that supplies its own
1367
- // gate keeps 0.1.0 behavior without opting out of anything
1368
- // (FR-102).
1369
- consentStarted = true;
1370
- if (resolved.consentTesting?.resetOnStart) {
1371
- try {
1372
- engine.consent.reset();
1373
- } catch {
1374
- // A reset that fails must not abort the update; the flow
1375
- // continues against whatever state is stored.
1376
- }
1377
- }
1378
- const next = await runConsentStep(
1379
- () =>
1380
- engine.consent.requestInfoUpdate(
1381
- buildConsentOptions(
1382
- resolved.consentTesting,
1383
- currentPrivacy?.underAgeOfConsent,
1384
- resolved.testDeviceIds,
1385
- ),
1386
- ),
1387
- 'consent information update failed',
1388
- );
1389
- if (disposed) {
1390
- return consent;
1391
- }
1392
- setConsent(next);
1393
- await reevaluateAfterConsent();
1394
- return next;
1395
- },
1396
-
1397
- async showFormIfRequired() {
1398
- if (disposed) {
1399
- return consent;
1400
- }
1401
- consentStarted = true;
1402
- if (!needsForm(consent)) {
1403
- // Nothing to present. Not an error: a region that does not
1404
- // require consent is the normal case, and showing a form there
1405
- // is exactly what FR-107 forbids the package from deciding.
1406
- return consent;
1407
- }
1408
- const next = await runConsentStep(
1409
- () => engine.consent.showFormIfRequired(),
1410
- 'consent form failed',
1411
- );
1412
- if (disposed) {
1413
- return consent;
1414
- }
1415
- setConsent(next);
1416
- await reevaluateAfterConsent();
1417
- return next;
1418
- },
1419
-
1420
- getState() {
1421
- return consent;
1422
- },
1423
-
1424
- subscribe(listener) {
1425
- consentListeners.add(listener);
1426
- let active = true;
1427
- return () => {
1428
- if (!active) {
1429
- return;
1430
- }
1431
- active = false;
1432
- consentListeners.delete(listener);
1433
- };
1434
- },
1435
-
1436
- async reset() {
1437
- if (disposed) {
1438
- return;
1439
- }
1440
- // Config validation already stripped the testing tools in
1441
- // production, so this is inert there without a check here (FR-113).
1442
- if (!resolved.consentTesting) {
1443
- return;
1444
- }
1445
- try {
1446
- engine.consent.reset();
1447
- } catch {
1448
- // Nothing to report: the stored state is the provider's, and a
1449
- // failed reset leaves it as it was.
1450
- }
1451
- setConsent(INITIAL_CONSENT);
1452
- },
1453
- },
1454
-
1455
- internals: {
1456
- resolveBanner(placementKey) {
1457
- const definition = resolved.placements.get(placementKey);
1458
- if (!definition) {
1459
- return {
1460
- ok: false,
1461
- result: {
1462
- status: 'failed',
1463
- error: new AdsError('placement_not_found', {
1464
- operation: 'load',
1465
- placementKey,
1466
- message: `unknown placement "${placementKey}"`,
1467
- }),
1468
- },
1469
- };
1470
- }
1471
- if (definition.format !== 'banner') {
1472
- return {
1473
- ok: false,
1474
- result: {
1475
- status: 'failed',
1476
- error: new AdsError('format_mismatch', {
1477
- operation: 'load',
1478
- placementKey,
1479
- format: definition.format,
1480
- message: `"${placementKey}" is not a banner placement`,
1481
- }),
1482
- },
1483
- };
1484
- }
1485
- const gate = gateCheck();
1486
- if (!gate.ok) {
1487
- return { ok: false, result: gate.result };
1488
- }
1489
- if (!canRequest(init.status)) {
1490
- return { ok: false, result: { status: 'blocked', reason: 'consent_required' } };
1491
- }
1492
- return {
1493
- ok: true,
1494
- adUnitId: resolved.catalog.byKey.get(placementKey)!,
1495
- correlationId: correlation.next(),
1496
- };
1497
- },
1498
- emit(event) {
1499
- events.emit(event);
1500
- },
1501
- setPlacementState(key, state) {
1502
- const runtime = placements.get(key);
1503
- if (runtime) {
1504
- setPlacement(key, { ...runtime.snapshot, state });
1505
- }
1506
- },
1507
- isReady() {
1508
- return permitsRequests(enablement) && engine.isAvailable() && canRequest(init.status);
1509
- },
1510
- nextCorrelationId() {
1511
- return correlation.next();
1512
- },
1513
- timerCount() {
1514
- return timers.size();
1515
- },
1516
- },
1517
-
1518
- dispose() {
1519
- // FR-005: idempotent. A second call is a no-op, not an error.
1520
- if (disposed) {
1521
- return;
1522
- }
1523
- disposed = true;
1524
- cancelAllRetries();
1525
- disarmUndecidedWarning();
1526
- for (const key of placements.keys()) {
1527
- releasePlacementResources(key);
1528
- placements.get(key)?.listeners.clear();
1529
- }
1530
- timers.clearAll();
1531
- lock.reset();
1532
- adReturn.reset();
1533
- statusListeners.clear();
1534
- consentListeners.clear();
1535
- events.dispose();
1536
- inFlightInit = null;
1537
- },
1538
- };
1539
-
1540
- return instance;
1541
- }
1542
-
1543
- /**
1544
- * Resolved lazily so importing the package on a platform with no adapter — or
1545
- * with the bridge absent — costs nothing and crashes nothing (FR-025, SC-004).
1546
- */
1547
- function createDefaultEngine(): AdsEngine {
1548
- // Deferred require keeps the provider off the import graph of every module
1549
- // that merely mentions `createAds`.
1550
- const { createAdMobEngine } = require('../engine/admobEngine') as typeof import('../engine/admobEngine');
1551
- return createAdMobEngine();
1552
- }
1553
-
1554
- declare const __DEV__: boolean | undefined;
1
+ /**
2
+ * The ads instance — the public contract's implementation.
3
+ *
4
+ * Every guarantee the specification treats as a rule rather than a convention
5
+ * converges here, and none of it needs a device to prove:
6
+ *
7
+ * FR-022 one native initialization, however many callers race
8
+ * FR-017 no request before the privacy gate permits one
9
+ * FR-011 no production ad unit outside production
10
+ * FR-029 one in-flight provider request per placement
11
+ * FR-030 exactly one full-screen ad, never a queue
12
+ * FR-031 a consumed ad is never reused
13
+ * FR-035 after dispose, a late callback updates nothing and emits nothing
14
+ *
15
+ * Nothing in this file imports the provider. It talks to the engine port, which
16
+ * the caller injects — the AdMob adapter in production, the fake in tests.
17
+ */
18
+
19
+ import {
20
+ isFullScreenFormat,
21
+ type AdFormat,
22
+ type AdsConfig,
23
+ type ConsentSnapshot,
24
+ type Enablement,
25
+ type FullScreenFormat,
26
+ type InitStatus,
27
+ type PlacementDefinition,
28
+ type PlacementState,
29
+ type PrivacyBlockReason,
30
+ type PrivacyOptions,
31
+ type UnavailableReason,
32
+ type Unsubscribe,
33
+ } from '../config/types';
34
+ import { validateConfig, type ResolvedConfig } from '../config/validate';
35
+ import { AdsError, normalizeProviderError } from '../diagnostics/errors';
36
+ import {
37
+ createEventEmitter,
38
+ type AdEvent,
39
+ type AdEventListener,
40
+ type ImpressionSource,
41
+ } from '../diagnostics/events';
42
+ import { createRequestThrottle } from './requestThrottle';
43
+ import { createCorrelationIssuer } from '../diagnostics/correlation';
44
+ import type { AdsEngine, EngineAdEvent, EngineAdHandle } from '../engine/port';
45
+ import { createFullScreenLock } from './fullScreenLock';
46
+ import { canRequest, canStart, initialInit, initReducer, type InitSnapshot } from './initMachine';
47
+ import {
48
+ canLoad,
49
+ initialPlacement,
50
+ placementReducer,
51
+ type PlacementSnapshot,
52
+ } from './placementMachine';
53
+ import { evaluateGate, requiresReinitialization, toEnginePrivacy } from './privacyGate';
54
+ import { evaluateGates, type ConsentGateInput, type GateDecision } from './gates';
55
+ import { createRewardedSession, type RewardedSession } from './rewardedSession';
56
+ import { createTimerSet, isStale, type Scheduler } from './timers';
57
+ import { canResume, permitsRequests, type EnablementResult } from './enablement';
58
+ import { canRetry, delayForAttempt } from './retryPolicy';
59
+ import { createAdOriginatedTracker } from './adOriginatedReturn';
60
+ import {
61
+ buildConsentOptions,
62
+ INITIAL_CONSENT,
63
+ needsForm,
64
+ runConsentStep,
65
+ toGateInput,
66
+ } from './consentFlow';
67
+
68
+ export type InitResult =
69
+ | { readonly status: 'ready' }
70
+ | { readonly status: 'blocked'; readonly reason: PrivacyBlockReason }
71
+ | { readonly status: 'disabled' }
72
+ /** The app has not decided whether ads are on. Nothing was requested (FR-115). */
73
+ | { readonly status: 'undecided' }
74
+ | { readonly status: 'unavailable'; readonly reason: UnavailableReason }
75
+ | { readonly status: 'failed'; readonly error: AdsError };
76
+
77
+ export type PrivacyUpdateResult =
78
+ | { readonly status: 'applied' }
79
+ | { readonly status: 'blocked'; readonly reason: PrivacyBlockReason }
80
+ | { readonly status: 'requires_reinitialization' }
81
+ | { readonly status: 'disabled' }
82
+ | { readonly status: 'undecided' }
83
+ | { readonly status: 'unavailable'; readonly reason: UnavailableReason };
84
+
85
+ export type LoadResult =
86
+ | { readonly status: 'loaded'; readonly correlationId: string }
87
+ | { readonly status: 'disabled' }
88
+ | { readonly status: 'undecided' }
89
+ | { readonly status: 'blocked'; readonly reason: PrivacyBlockReason }
90
+ | { readonly status: 'unavailable'; readonly reason: UnavailableReason }
91
+ | { readonly status: 'no_fill' }
92
+ /**
93
+ * Refused by the per-placement request throttle (FR-140), added in 0.3.0.
94
+ *
95
+ * Nothing was requested and nothing is broken — this placement asked too
96
+ * recently. Distinct from `no_fill` on purpose: no-fill means the network had
97
+ * nothing to give, this means we did not ask. An app that treats them the same
98
+ * will misdiagnose the first time a banner goes quiet.
99
+ */
100
+ | ThrottledResult
101
+ | { readonly status: 'timeout' }
102
+ | { readonly status: 'failed'; readonly error: AdsError };
103
+
104
+ export interface ThrottledResult {
105
+ readonly status: 'throttled';
106
+ readonly retryAfterMs: number;
107
+ }
108
+
109
+ export type ShowResult =
110
+ | { readonly status: 'shown'; readonly correlationId: string }
111
+ | { readonly status: 'not_ready' }
112
+ | { readonly status: 'busy' }
113
+ | { readonly status: 'consumed' }
114
+ | { readonly status: 'app_inactive' }
115
+ /**
116
+ * The user came back to the app from an ad, or from a browser an ad opened,
117
+ * so an app-open ad would be an ad immediately after an ad. Distinct from
118
+ * `busy` and `not_ready` because the app cannot detect this itself and
119
+ * therefore cannot infer it from either of those (FR-128).
120
+ */
121
+ | { readonly status: 'ad_originated_return' }
122
+ | { readonly status: 'disabled' }
123
+ | { readonly status: 'undecided' }
124
+ | { readonly status: 'blocked'; readonly reason: PrivacyBlockReason }
125
+ | { readonly status: 'unavailable'; readonly reason: UnavailableReason }
126
+ | { readonly status: 'timeout' }
127
+ | { readonly status: 'failed'; readonly error: AdsError };
128
+
129
+ /**
130
+ * The optional consent capability (FR-101).
131
+ *
132
+ * Grouped under its own namespace rather than flattened onto the instance so
133
+ * that FR-102's optionality stays legible: an app that never touches
134
+ * `ads.consent` never starts a flow. Five loose methods on the instance would
135
+ * invite the assumption that they are part of the normal path.
136
+ */
137
+ export interface ConsentApi {
138
+ /** Refreshes consent information from the provider. */
139
+ requestInfoUpdate(): Promise<ConsentSnapshot>;
140
+ /** Presents the provider's form if one is required. Renders nothing itself. */
141
+ showFormIfRequired(): Promise<ConsentSnapshot>;
142
+ getState(): ConsentSnapshot;
143
+ subscribe(listener: (snapshot: ConsentSnapshot) => void): Unsubscribe;
144
+ /** Clears the stored decision. Inert in production (FR-113). */
145
+ reset(): Promise<void>;
146
+ }
147
+
148
+ export interface AdsInstance {
149
+ initialize(privacy: PrivacyOptions): Promise<InitResult>;
150
+ updatePrivacy(privacy: PrivacyOptions): Promise<PrivacyUpdateResult>;
151
+ /**
152
+ * Turns ads on or off. Returns a result rather than `void` (FR-122).
153
+ *
154
+ * 0.1.0 returned nothing, which meant an app re-enabling ads after a lapsed
155
+ * subscription had no way to learn that it also had to re-initialize — and
156
+ * every load and show after that was refused in silence.
157
+ */
158
+ setEnabled(enabled: boolean): EnablementResult;
159
+ /** The same, including the third state the app has not resolved yet. */
160
+ setEnablement(next: Enablement): EnablementResult;
161
+ setAppActive(active: boolean): void;
162
+ readonly consent: ConsentApi;
163
+ getStatus(): InitStatus;
164
+ subscribeStatus(listener: (status: InitStatus) => void): Unsubscribe;
165
+
166
+ load(placementKey: string): Promise<LoadResult>;
167
+ show(placementKey: string): Promise<ShowResult>;
168
+ getPlacementState(placementKey: string): PlacementState;
169
+ subscribePlacement(key: string, listener: (state: PlacementState) => void): Unsubscribe;
170
+
171
+ subscribeEvents(listener: AdEventListener): Unsubscribe;
172
+
173
+ /** FR-011a — substitution is observable state, not only a log line. */
174
+ getSubstitutedPlacements(): readonly string[];
175
+ /** FR-113 — the same treatment for consent tools dropped in production. */
176
+ getIgnoredConsentTools(): readonly string[];
177
+ /** Internal wiring for the banner host; not part of the documented surface. */
178
+ readonly internals: InstanceInternals;
179
+
180
+ dispose(): void;
181
+ }
182
+
183
+ /** What `AdsBanner` needs. Deliberately not documented as consumer API. */
184
+ export interface InstanceInternals {
185
+ resolveBanner(placementKey: string):
186
+ | { readonly ok: true; readonly adUnitId: string; readonly correlationId: string }
187
+ | { readonly ok: false; readonly result: LoadResult };
188
+ /**
189
+ * Takes the throttle slot for a banner cycle (FR-140).
190
+ *
191
+ * Called from the banner host's effect, after `resolveBanner` allowed it and
192
+ * immediately before the provider component mounts. Idempotent for one
193
+ * `correlationId`, so React running the effect twice charges one slot.
194
+ *
195
+ * Returns the refusal if the window closed between resolve and commit — rare,
196
+ * but a second banner for the same placement mounting in the same tick would
197
+ * do it.
198
+ *
199
+ * Emits nothing and sets no state: it is called during render, so the host
200
+ * reports the refusal from its effect instead.
201
+ */
202
+ commitBannerRequest(
203
+ placementKey: string,
204
+ correlationId: string,
205
+ ): ThrottledResult | null;
206
+ /** The configured floor for a placement, so the host can report the cause. */
207
+ throttleFloorFor(placementKey: string): number;
208
+ emit(event: AdEvent): void;
209
+ setPlacementState(key: string, state: PlacementState): void;
210
+ isReady(): boolean;
211
+ nextCorrelationId(): string;
212
+ timerCount(): number;
213
+ }
214
+
215
+ export interface CreateAdsOptions {
216
+ /** Injected in tests; the AdMob adapter is the default in production. */
217
+ readonly engine?: AdsEngine;
218
+ /** Injected so the FR-012 rule is testable without touching a global. */
219
+ readonly isDevBuild?: boolean;
220
+ readonly now?: () => number;
221
+ /**
222
+ * Injected so retry backoff, app-open expiry, and the undecided warning can
223
+ * be driven by a test clock instead of by waiting. Production uses real
224
+ * timers.
225
+ */
226
+ readonly scheduler?: Scheduler;
227
+ }
228
+
229
+ interface PlacementRuntime {
230
+ snapshot: PlacementSnapshot;
231
+ /**
232
+ * The cycle's correlation id, held here rather than read from the snapshot:
233
+ * the reducer resets the snapshot on dismissal, and events emitted around
234
+ * that transition must still carry the id of the cycle they belong to.
235
+ */
236
+ correlationId: string | null;
237
+ /**
238
+ * Whether this cycle already reported a dismissal. Format-independent — the
239
+ * rewarded session guards its own, but interstitials need the same guard and
240
+ * would otherwise report a repeated provider callback twice.
241
+ */
242
+ dismissed: boolean;
243
+ handle: EngineAdHandle | null;
244
+ unsubscribeEngine: Unsubscribe | null;
245
+ inFlightLoad: Promise<LoadResult> | null;
246
+ cancelLoadTimer: Unsubscribe | null;
247
+ cancelShowTimer: Unsubscribe | null;
248
+ rewarded: RewardedSession | null;
249
+ listeners: Set<(state: PlacementState) => void>;
250
+ /** Retries already spent in this load cycle. Reset when a new cycle starts. */
251
+ retryAttempts: number;
252
+ /** Cancels a scheduled retry. Called by disable, undecided, and dispose. */
253
+ cancelRetry: Unsubscribe | null;
254
+ }
255
+
256
+ function detectDevBuild(): boolean {
257
+ return typeof __DEV__ !== 'undefined' ? Boolean(__DEV__) : false;
258
+ }
259
+
260
+ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): AdsInstance {
261
+ const isDevBuild = options.isDevBuild ?? detectDevBuild();
262
+ const now = options.now ?? (() => Date.now());
263
+
264
+ // Throws on a structural fault or the FR-012 fail-closed case. Deliberately
265
+ // before anything else: a misconfigured instance should not exist at all.
266
+ const resolved: ResolvedConfig = validateConfig(config, { isDevBuild });
267
+
268
+ const engine: AdsEngine = options.engine ?? createDefaultEngine();
269
+ const events = createEventEmitter();
270
+ const correlation = createCorrelationIssuer();
271
+ // FR-140. Account safety, not product policy — see core/requestThrottle.ts.
272
+ const requestThrottle = createRequestThrottle(now);
273
+ const timers = createTimerSet(options.scheduler);
274
+ const lock = createFullScreenLock();
275
+ const adReturn = createAdOriginatedTracker();
276
+
277
+ /**
278
+ * An instance told it is `undecided` says so from the moment it exists.
279
+ *
280
+ * Without this it reports `not_started` until `initialize()` runs, which is
281
+ * indistinguishable from a disabled instance at exactly the point an app is
282
+ * deciding whether to reserve the banner's space — so FR-116 would hold only
283
+ * after a call the app has no reason to make yet. Found on device, not in a
284
+ * unit test, because the demo screen defers initialization the way a real
285
+ * entitlement-gated app does.
286
+ *
287
+ * `disabled` deliberately keeps 0.1.0's `not_started` here: that behavior is
288
+ * observable today and BC-004 protects it. The asymmetry is the cost of not
289
+ * changing something that already shipped.
290
+ */
291
+ let init: InitSnapshot =
292
+ resolved.enablement === 'undecided'
293
+ ? initReducer(initialInit, { type: 'UNDECIDE' })
294
+ : initialInit;
295
+ let enablement: Enablement = resolved.enablement;
296
+ let appActive = true;
297
+ let consent: ConsentSnapshot = INITIAL_CONSENT;
298
+ let consentStarted = false;
299
+ let undecidedWarned = false;
300
+ let cancelUndecidedTimer: Unsubscribe | null = null;
301
+ let appliedPrivacy: PrivacyOptions | null = null;
302
+ let currentPrivacy: PrivacyOptions | null = null;
303
+ let inFlightInit: Promise<InitResult> | null = null;
304
+ let disposed = false;
305
+ let warnedSubstitution = false;
306
+
307
+ const statusListeners = new Set<(status: InitStatus) => void>();
308
+ const placements = new Map<string, PlacementRuntime>();
309
+
310
+ for (const key of resolved.placements.keys()) {
311
+ placements.set(key, {
312
+ snapshot: initialPlacement,
313
+ correlationId: null,
314
+ dismissed: false,
315
+ handle: null,
316
+ unsubscribeEngine: null,
317
+ inFlightLoad: null,
318
+ cancelLoadTimer: null,
319
+ cancelShowTimer: null,
320
+ rewarded: null,
321
+ listeners: new Set(),
322
+ retryAttempts: 0,
323
+ cancelRetry: null,
324
+ });
325
+ }
326
+
327
+ const consentListeners = new Set<(snapshot: ConsentSnapshot) => void>();
328
+
329
+ function setConsent(next: ConsentSnapshot): void {
330
+ consent = next;
331
+ for (const listener of Array.from(consentListeners)) {
332
+ try {
333
+ listener(next);
334
+ } catch {
335
+ // Consumer code; isolated exactly as event listeners are (FR-056).
336
+ }
337
+ }
338
+ }
339
+
340
+ /**
341
+ * The consent flow's contribution to the gate, or `null` when the app never
342
+ * opted in.
343
+ *
344
+ * `null` is what makes FR-102 structural rather than conditional: with no
345
+ * flow started, the gate sees only the app's own input and behaves exactly as
346
+ * 0.1.0 did. There is no code path to skip and therefore none to forget.
347
+ */
348
+ function consentGateInput(): ConsentGateInput {
349
+ return consentStarted ? toGateInput(consent) : null;
350
+ }
351
+
352
+ /**
353
+ * Warns once if the app leaves `undecided` unresolved (FR-119).
354
+ *
355
+ * Once, not repeatedly: a warning that fires every ten seconds becomes noise
356
+ * that gets filtered, which defeats the diagnostic. And it only warns — the
357
+ * package never resolves the state itself, because doing so would mean
358
+ * guessing at exactly the business decision FR-120 forbids it from knowing.
359
+ */
360
+ function armUndecidedWarning(): void {
361
+ if (enablement !== 'undecided' || undecidedWarned || cancelUndecidedTimer) {
362
+ return;
363
+ }
364
+ const armedAt = now();
365
+ cancelUndecidedTimer = timers.after(resolved.timeouts.undecidedWarning, () => {
366
+ cancelUndecidedTimer = null;
367
+ if (disposed || enablement !== 'undecided' || undecidedWarned) {
368
+ return;
369
+ }
370
+ undecidedWarned = true;
371
+ events.emit({
372
+ type: 'config_warning',
373
+ timestamp: now(),
374
+ warning: 'enablement_undecided_timeout',
375
+ placementKeys: [],
376
+ elapsedMs: now() - armedAt,
377
+ });
378
+ });
379
+ }
380
+
381
+ function disarmUndecidedWarning(): void {
382
+ cancelUndecidedTimer?.();
383
+ cancelUndecidedTimer = null;
384
+ }
385
+
386
+ /** Cancels every pending retry, everywhere (FR-137). */
387
+ function cancelAllRetries(): void {
388
+ for (const runtime of placements.values()) {
389
+ runtime.cancelRetry?.();
390
+ runtime.cancelRetry = null;
391
+ }
392
+ }
393
+
394
+ function setInit(next: InitSnapshot): void {
395
+ if (next === init || next.status === init.status) {
396
+ init = next;
397
+ return;
398
+ }
399
+ init = next;
400
+ events.emit({
401
+ type: 'initialization',
402
+ timestamp: now(),
403
+ status: next.status,
404
+ retryable: next.error?.retryable,
405
+ });
406
+ for (const listener of Array.from(statusListeners)) {
407
+ try {
408
+ listener(next.status);
409
+ } catch {
410
+ // A status listener is consumer code; its failure is isolated the
411
+ // same way an event listener's is (FR-056).
412
+ }
413
+ }
414
+ }
415
+
416
+ function setPlacement(key: string, next: PlacementSnapshot): void {
417
+ const runtime = placements.get(key);
418
+ if (!runtime || runtime.snapshot.state === next.state) {
419
+ if (runtime) {
420
+ runtime.snapshot = next;
421
+ }
422
+ return;
423
+ }
424
+ runtime.snapshot = next;
425
+ for (const listener of Array.from(runtime.listeners)) {
426
+ try {
427
+ listener(next.state);
428
+ } catch {
429
+ // Isolated, as above.
430
+ }
431
+ }
432
+ }
433
+
434
+ function dispatchPlacement(key: string, event: Parameters<typeof placementReducer>[1]): void {
435
+ const runtime = placements.get(key);
436
+ const definition = resolved.placements.get(key);
437
+ if (!runtime || !definition) {
438
+ return;
439
+ }
440
+ setPlacement(key, placementReducer(runtime.snapshot, event, definition.format));
441
+ }
442
+
443
+ /**
444
+ * The single gate evaluation (FR-140).
445
+ *
446
+ * Assembles the three inputs and defers the precedence entirely to
447
+ * `evaluateGates`. Nothing in this file compares gate state itself — the
448
+ * whole point of FR-140 is that the order is a decision made once, not four
449
+ * `if` chains that happen to agree today.
450
+ *
451
+ * Note the order differs from 0.1.0's, which checked `enabled` before
452
+ * availability. That was never observable in isolation, only when two gates
453
+ * were closed at once, and the case it changes — disabled on an unsupported
454
+ * platform, previously decided by whichever transition arrived last — is the
455
+ * incidental ordering FR-140a exists to fix.
456
+ */
457
+ function currentGate(): GateDecision {
458
+ return evaluateGates({
459
+ available: engine.isAvailable()
460
+ ? { ok: true }
461
+ : { ok: false, reason: 'unsupported_platform' },
462
+ enablement,
463
+ appGate: (() => {
464
+ const gate = evaluateGate(currentPrivacy);
465
+ return gate.allowed
466
+ ? ({ permitted: true } as const)
467
+ : ({ permitted: false, reason: gate.reason } as const);
468
+ })(),
469
+ consentGate: consentGateInput(),
470
+ });
471
+ }
472
+
473
+ /** Maps a closed gate onto the refusal shape load, show, and the banner share. */
474
+ function refusalFor(
475
+ decision: Extract<GateDecision, { allowed: false }>,
476
+ ): Extract<LoadResult, { status: 'disabled' | 'undecided' | 'blocked' | 'unavailable' }> {
477
+ switch (decision.kind) {
478
+ case 'unavailable':
479
+ return { status: 'unavailable', reason: decision.reason };
480
+ case 'disabled':
481
+ return { status: 'disabled' };
482
+ case 'undecided':
483
+ return { status: 'undecided' };
484
+ default:
485
+ return { status: 'blocked', reason: decision.reason };
486
+ }
487
+ }
488
+
489
+ /** Every refusal that applies before a placement is even looked up. */
490
+ function gateCheck():
491
+ | { readonly ok: true }
492
+ | {
493
+ readonly ok: false;
494
+ readonly result: Extract<
495
+ LoadResult,
496
+ { status: 'disabled' | 'undecided' | 'blocked' | 'unavailable' }
497
+ >;
498
+ } {
499
+ const decision = currentGate();
500
+ if (decision.allowed) {
501
+ return { ok: true };
502
+ }
503
+ return { ok: false, result: refusalFor(decision) };
504
+ }
505
+
506
+ function emitRefusal(
507
+ operation: 'initialize' | 'load' | 'show',
508
+ result: { readonly status: string; readonly reason?: string },
509
+ placementKey?: string,
510
+ format?: AdFormat,
511
+ ): void {
512
+ if (result.status === 'disabled' || result.status === 'undecided') {
513
+ // `undecided` reuses the `disabled` event rather than minting a new
514
+ // one: for an observer of the event stream the fact is the same — no
515
+ // request was made — and the distinction that matters is carried by
516
+ // the typed result and the init status, where the app reads it.
517
+ events.emit({ type: 'disabled', timestamp: now(), operation, placementKey, format });
518
+ } else if (result.status === 'unavailable' || result.status === 'blocked') {
519
+ events.emit({
520
+ type: 'unavailable',
521
+ timestamp: now(),
522
+ operation,
523
+ placementKey,
524
+ format,
525
+ reason: (result.reason ?? 'unsupported_platform') as UnavailableReason | PrivacyBlockReason,
526
+ });
527
+ }
528
+ }
529
+
530
+ /**
531
+ * Consumes a throttle slot for a placement, or reports the refusal (FR-140).
532
+ *
533
+ * Returns `null` when the request may proceed, so the two call sites read as a
534
+ * guard clause. On refusal it moves the placement to `throttled` and emits,
535
+ * which is what makes the refusal observable rather than a silent absence of
536
+ * ads — the app would otherwise have no way to tell this from no-fill.
537
+ */
538
+ function reportThrottled(
539
+ key: string,
540
+ format: AdFormat,
541
+ retryAfterMs: number,
542
+ minRequestIntervalMs: number,
543
+ ): LoadResult {
544
+ const runtime = placements.get(key);
545
+ if (runtime) {
546
+ setPlacement(key, { ...runtime.snapshot, state: 'throttled' });
547
+ }
548
+ events.emit({
549
+ type: 'request_throttled',
550
+ timestamp: now(),
551
+ placementKey: key,
552
+ format,
553
+ retryAfterMs,
554
+ minRequestIntervalMs,
555
+ });
556
+ return { status: 'throttled', retryAfterMs };
557
+ }
558
+
559
+ /** Consumes a slot, or reports the refusal. `null` means proceed. */
560
+ function checkThrottle(key: string, format: AdFormat, token?: string): LoadResult | null {
561
+ const minRequestIntervalMs = resolved.minRequestIntervalByKey.get(key) ?? 0;
562
+ const decision = requestThrottle.tryAcquire(key, minRequestIntervalMs, token);
563
+ return decision.allowed
564
+ ? null
565
+ : reportThrottled(key, format, decision.retryAfterMs, minRequestIntervalMs);
566
+ }
567
+
568
+ function warnSubstitutionOnce(): void {
569
+ if (warnedSubstitution || resolved.catalog.substitutedKeys.length === 0) {
570
+ return;
571
+ }
572
+ warnedSubstitution = true;
573
+ events.emit({
574
+ type: 'config_warning',
575
+ timestamp: now(),
576
+ warning: 'test_ad_unit_substituted',
577
+ placementKeys: resolved.catalog.substitutedKeys,
578
+ });
579
+ }
580
+
581
+ function releasePlacementResources(key: string): void {
582
+ const runtime = placements.get(key);
583
+ if (!runtime) {
584
+ return;
585
+ }
586
+ runtime.cancelLoadTimer?.();
587
+ runtime.cancelLoadTimer = null;
588
+ runtime.cancelShowTimer?.();
589
+ runtime.cancelShowTimer = null;
590
+ runtime.unsubscribeEngine?.();
591
+ runtime.unsubscribeEngine = null;
592
+ if (runtime.handle) {
593
+ engine.dispose(runtime.handle);
594
+ runtime.handle = null;
595
+ }
596
+ runtime.inFlightLoad = null;
597
+ runtime.rewarded = null;
598
+ runtime.cancelRetry?.();
599
+ runtime.cancelRetry = null;
600
+ }
601
+
602
+ /** App-open expires on its own, shorter window (FR-130). */
603
+ function validityFor(format: AdFormat): number {
604
+ return format === 'app_open'
605
+ ? resolved.timeouts.appOpenValidity
606
+ : resolved.timeouts.adValidity;
607
+ }
608
+
609
+ async function doInitialize(privacy: PrivacyOptions): Promise<InitResult> {
610
+ const enginePrivacy = toEnginePrivacy(privacy, resolved.testDeviceIds);
611
+ try {
612
+ // The adapter applies privacy configuration before initializing the
613
+ // provider; that ordering is the whole reason FR-017 is enforceable.
614
+ await engine.initialize(enginePrivacy);
615
+ appliedPrivacy = privacy;
616
+ setInit(initReducer(init, { type: 'SUCCEED' }));
617
+ warnSubstitutionOnce();
618
+ return { status: 'ready' };
619
+ } catch (error) {
620
+ const normalized = normalizeProviderError(error, { operation: 'initialize' });
621
+ setInit(initReducer(init, { type: 'FAIL', error: normalized }));
622
+ return { status: 'failed', error: normalized };
623
+ } finally {
624
+ inFlightInit = null;
625
+ }
626
+ }
627
+
628
+ function handleEngineEvent(
629
+ key: string,
630
+ definition: PlacementDefinition,
631
+ event: EngineAdEvent,
632
+ ): void {
633
+ // After dispose nothing is honored: a late provider callback must not
634
+ // update a consumer that has gone away (FR-035, SC-008).
635
+ if (disposed) {
636
+ return;
637
+ }
638
+ const runtime = placements.get(key);
639
+ if (!runtime) {
640
+ return;
641
+ }
642
+ const correlationId = runtime.correlationId ?? undefined;
643
+ const base = { timestamp: now(), placementKey: key, format: definition.format, correlationId };
644
+
645
+ switch (event.type) {
646
+ case 'opened': {
647
+ // The provider confirming an ad is on screen is what arms
648
+ // ad-originated return detection (FR-128). Taken from here rather
649
+ // than from the app's background report, which arrives too late to
650
+ // be trusted — see core/adOriginatedReturn.ts.
651
+ if (isFullScreenFormat(definition.format)) {
652
+ adReturn.noteFullScreenShown(now());
653
+ }
654
+ events.emit({ ...base, type: 'display_started' });
655
+ // R5: the provider exposes no impression callback for full-screen
656
+ // formats, so this one is derived from the ad opening. Labelled
657
+ // honestly rather than passed off as the provider's own.
658
+ events.emit({ ...base, type: 'impression', source: 'derived' as ImpressionSource });
659
+ break;
660
+ }
661
+ case 'clicked':
662
+ // Recorded so that a background transition moments later is
663
+ // recognized as "the ad opened a browser" — the only signal the
664
+ // provider gives for that case (FR-128).
665
+ adReturn.noteClick(now());
666
+ events.emit({ ...base, type: 'click' });
667
+ break;
668
+ case 'rewarded': {
669
+ const first = runtime.rewarded?.confirmReward(event.reward) ?? null;
670
+ if (first) {
671
+ events.emit({ ...base, type: 'reward_earned', reward: first });
672
+ }
673
+ break;
674
+ }
675
+ case 'dismissed': {
676
+ const rewardedFirst = runtime.rewarded ? runtime.rewarded.dismiss() : true;
677
+ if (runtime.dismissed || !rewardedFirst) {
678
+ break;
679
+ }
680
+ runtime.dismissed = true;
681
+ lock.release(key);
682
+ runtime.cancelShowTimer?.();
683
+ runtime.cancelShowTimer = null;
684
+ events.emit({
685
+ ...base,
686
+ type: 'dismissed',
687
+ rewardEarned: runtime.rewarded ? runtime.rewarded.rewardEarned : undefined,
688
+ });
689
+ dispatchPlacement(key, { type: 'DISMISSED' });
690
+ // Deliberately NOT released here. FR-049 requires the reward and
691
+ // the dismissal to be handled in either order, and a provider that
692
+ // confirms the reward *after* closing the ad would otherwise have
693
+ // its confirmation dropped by a torn-down listener — silently
694
+ // costing the user a reward they earned.
695
+ //
696
+ // This is a retained resource, not a leak: at most one handle and
697
+ // one listener per placement, released deterministically by the
698
+ // next load of that placement or by dispose().
699
+ break;
700
+ }
701
+ case 'failed': {
702
+ const normalized = normalizeProviderError(event.cause, {
703
+ operation: 'show',
704
+ placementKey: key,
705
+ format: definition.format,
706
+ });
707
+ lock.release(key);
708
+ events.emit({ ...base, type: 'display_failed', error: normalized });
709
+ dispatchPlacement(key, { type: 'SHOW_FAILED', error: normalized });
710
+ releasePlacementResources(key);
711
+ break;
712
+ }
713
+ default:
714
+ break;
715
+ }
716
+ }
717
+
718
+ function loadFullScreen(
719
+ key: string,
720
+ definition: PlacementDefinition,
721
+ adUnitId: string,
722
+ correlationId: string,
723
+ ): Promise<LoadResult> {
724
+ const runtime = placements.get(key)!;
725
+ const format = definition.format as FullScreenFormat;
726
+ const startedAt = now();
727
+
728
+ return new Promise<LoadResult>(resolve => {
729
+ let settled = false;
730
+
731
+ const finish = (result: LoadResult) => {
732
+ if (settled) {
733
+ return;
734
+ }
735
+ settled = true;
736
+ runtime.cancelLoadTimer?.();
737
+ runtime.cancelLoadTimer = null;
738
+ runtime.inFlightLoad = null;
739
+ resolve(result);
740
+ };
741
+
742
+ runtime.cancelLoadTimer = timers.after(resolved.timeouts.load, () => {
743
+ const error = new AdsError('load_timeout', {
744
+ operation: 'load',
745
+ placementKey: key,
746
+ format,
747
+ message: `load timed out after ${resolved.timeouts.load}ms`,
748
+ });
749
+ events.emit({
750
+ type: 'load_failed',
751
+ timestamp: now(),
752
+ placementKey: key,
753
+ format,
754
+ correlationId,
755
+ error,
756
+ });
757
+ dispatchPlacement(key, { type: 'LOAD_FAILED', error });
758
+ releasePlacementResources(key);
759
+ finish({ status: 'timeout' });
760
+ });
761
+
762
+ engine
763
+ .createFullScreen(format, adUnitId)
764
+ .then(handle => {
765
+ if (disposed || settled) {
766
+ engine.dispose(handle);
767
+ return;
768
+ }
769
+ runtime.handle = handle;
770
+ runtime.unsubscribeEngine = engine.subscribe(handle, event => {
771
+ if (event.type === 'loaded') {
772
+ dispatchPlacement(key, { type: 'LOADED', atMs: now() });
773
+ events.emit({
774
+ type: 'loaded',
775
+ timestamp: now(),
776
+ placementKey: key,
777
+ format,
778
+ correlationId,
779
+ durationMs: now() - startedAt,
780
+ });
781
+ finish({ status: 'loaded', correlationId });
782
+ return;
783
+ }
784
+ if (event.type === 'failed') {
785
+ const normalized = normalizeProviderError(event.cause, {
786
+ operation: 'load',
787
+ placementKey: key,
788
+ format,
789
+ });
790
+ events.emit({
791
+ type: 'load_failed',
792
+ timestamp: now(),
793
+ placementKey: key,
794
+ format,
795
+ correlationId,
796
+ error: normalized,
797
+ });
798
+ dispatchPlacement(key, { type: 'LOAD_FAILED', error: normalized });
799
+ releasePlacementResources(key);
800
+ finish(
801
+ normalized.code === 'no_fill'
802
+ ? { status: 'no_fill' }
803
+ : { status: 'failed', error: normalized },
804
+ );
805
+ return;
806
+ }
807
+ handleEngineEvent(key, definition, event);
808
+ });
809
+ })
810
+ .catch(error => {
811
+ const normalized = normalizeProviderError(error, {
812
+ operation: 'load',
813
+ placementKey: key,
814
+ format,
815
+ });
816
+ events.emit({
817
+ type: 'load_failed',
818
+ timestamp: now(),
819
+ placementKey: key,
820
+ format,
821
+ correlationId,
822
+ error: normalized,
823
+ });
824
+ dispatchPlacement(key, { type: 'LOAD_FAILED', error: normalized });
825
+ finish(
826
+ normalized.code === 'no_fill'
827
+ ? { status: 'no_fill' }
828
+ : { status: 'failed', error: normalized },
829
+ );
830
+ });
831
+ });
832
+ }
833
+
834
+ /**
835
+ * One load, plus however many bounded retries the policy allows (FR-134).
836
+ *
837
+ * Off unless configured, so an upgraded 0.1.0 consumer goes through here once
838
+ * and issues exactly the requests it always did. When on, only errors already
839
+ * flagged `retryable` are retried — a configuration or consent failure is a
840
+ * state another attempt cannot change, and retrying it would spend a billable
841
+ * request on a certainty (FR-135).
842
+ *
843
+ * Every attempt is emitted before it is waited on, carrying the cycle's
844
+ * correlation id, so the event stream can be reconciled against a bill
845
+ * without inferring anything (FR-138).
846
+ */
847
+ async function loadWithRetry(
848
+ key: string,
849
+ definition: PlacementDefinition,
850
+ adUnitId: string,
851
+ correlationId: string,
852
+ ): Promise<LoadResult> {
853
+ const runtime = placements.get(key)!;
854
+ const policy = resolved.retryByKey.get(key) ?? null;
855
+
856
+ for (;;) {
857
+ const result = await loadFullScreen(key, definition, adUnitId, correlationId);
858
+ if (result.status === 'loaded' || disposed) {
859
+ return result;
860
+ }
861
+
862
+ const error =
863
+ result.status === 'failed'
864
+ ? result.error
865
+ : result.status === 'no_fill'
866
+ ? new AdsError('no_fill', { operation: 'load', placementKey: key })
867
+ : result.status === 'timeout'
868
+ ? new AdsError('load_timeout', { operation: 'load', placementKey: key })
869
+ : null;
870
+
871
+ // A refusal — disabled, undecided, blocked, unavailable — is not a
872
+ // failure to retry. Nothing was requested, and nothing about asking
873
+ // again would change that.
874
+ if (!error || !error.retryable) {
875
+ return result;
876
+ }
877
+ if (!canRetry(policy, runtime.retryAttempts) || !permitsRequests(enablement)) {
878
+ return result;
879
+ }
880
+
881
+ runtime.retryAttempts += 1;
882
+ const delayMs = delayForAttempt(policy!, runtime.retryAttempts);
883
+ events.emit({
884
+ type: 'retry_scheduled',
885
+ timestamp: now(),
886
+ placementKey: key,
887
+ format: definition.format,
888
+ correlationId,
889
+ attempt: runtime.retryAttempts,
890
+ delayMs,
891
+ error,
892
+ });
893
+
894
+ const waited = await new Promise<boolean>(resolve => {
895
+ runtime.cancelRetry = timers.after(delayMs, () => {
896
+ runtime.cancelRetry = null;
897
+ resolve(true);
898
+ });
899
+ const cancel = runtime.cancelRetry;
900
+ // Cancellation replaces the handle, so a disable or dispose that
901
+ // clears it resolves this wait as "do not continue" rather than
902
+ // leaving a promise nobody will settle.
903
+ runtime.cancelRetry = () => {
904
+ cancel?.();
905
+ runtime.cancelRetry = null;
906
+ resolve(false);
907
+ };
908
+ });
909
+
910
+ if (!waited || disposed || !permitsRequests(enablement)) {
911
+ return result;
912
+ }
913
+
914
+ dispatchPlacement(key, { type: 'LOAD' });
915
+ }
916
+ }
917
+
918
+ /**
919
+ * Applies a consent outcome to the gate without a second initialization.
920
+ *
921
+ * This is FR-105's "the app does not wire it by hand" in one function. When
922
+ * consent opens the gate and the instance was blocked, it initializes — and
923
+ * because `initialize` is idempotent and shares one in-flight operation, the
924
+ * total native initialization count across a whole consent flow stays at one
925
+ * (FR-022, SC-102). When consent closes the gate, requests stop and every
926
+ * pending retry is cancelled with them (FR-137).
927
+ */
928
+ async function reevaluateAfterConsent(): Promise<void> {
929
+ if (disposed || !currentPrivacy) {
930
+ return;
931
+ }
932
+ const decision = currentGate();
933
+ if (!decision.allowed) {
934
+ cancelAllRetries();
935
+ if (decision.kind === 'blocked' && init.status !== 'ready') {
936
+ setInit(initReducer(init, { type: 'BLOCK', reason: decision.reason }));
937
+ }
938
+ return;
939
+ }
940
+ if (init.status === 'ready' || init.status === 'initializing') {
941
+ return;
942
+ }
943
+ await instance.initialize(currentPrivacy);
944
+ }
945
+
946
+ const instance: AdsInstance = {
947
+ async initialize(privacy) {
948
+ if (disposed) {
949
+ return { status: 'unavailable', reason: 'provider_missing' };
950
+ }
951
+ currentPrivacy = privacy;
952
+
953
+ // Availability first, then enablement, then consent — the one order,
954
+ // evaluated in the one place (FR-140).
955
+ const decision = currentGate();
956
+ if (!decision.allowed && decision.kind === 'unavailable') {
957
+ setInit(initReducer(init, { type: 'UNAVAILABLE', reason: decision.reason }));
958
+ const result = { status: 'unavailable', reason: decision.reason } as const;
959
+ emitRefusal('initialize', result);
960
+ return result;
961
+ }
962
+ if (!decision.allowed && decision.kind === 'disabled') {
963
+ setInit(initReducer(init, { type: 'DISABLE' }));
964
+ const result = { status: 'disabled' } as const;
965
+ emitRefusal('initialize', result);
966
+ return result;
967
+ }
968
+ if (!decision.allowed && decision.kind === 'undecided') {
969
+ // FR-115. Nothing provider-facing happens, exactly as when
970
+ // disabled — but the status the app observes says `undecided`, so
971
+ // a banner host can hold its space instead of reclaiming it.
972
+ setInit(initReducer(init, { type: 'UNDECIDE' }));
973
+ armUndecidedWarning();
974
+ const result = { status: 'undecided' } as const;
975
+ emitRefusal('initialize', result);
976
+ return result;
977
+ }
978
+
979
+ const gate = evaluateGate(privacy);
980
+ if (!gate.allowed) {
981
+ // FR-017. Nothing provider-facing happens here at all — not a
982
+ // configuration call, not an initialization. The engine is untouched.
983
+ setInit(initReducer(init, { type: 'BLOCK', reason: gate.reason }));
984
+ const result = { status: 'blocked', reason: gate.reason } as const;
985
+ emitRefusal('initialize', result);
986
+ return result;
987
+ }
988
+
989
+ if (init.status === 'ready') {
990
+ return { status: 'ready' };
991
+ }
992
+
993
+ // FR-022, SC-005. Racing callers share one operation, so the provider
994
+ // initializes exactly once however many arrive.
995
+ if (inFlightInit) {
996
+ return inFlightInit;
997
+ }
998
+ if (!canStart(init.status)) {
999
+ return { status: 'ready' };
1000
+ }
1001
+
1002
+ setInit(initReducer(init, { type: 'START' }));
1003
+ inFlightInit = doInitialize(privacy);
1004
+ return inFlightInit;
1005
+ },
1006
+
1007
+ async updatePrivacy(privacy) {
1008
+ if (disposed) {
1009
+ return { status: 'disabled' };
1010
+ }
1011
+ if (enablement === 'undecided') {
1012
+ return { status: 'undecided' };
1013
+ }
1014
+ if (enablement === 'disabled') {
1015
+ return { status: 'disabled' };
1016
+ }
1017
+ if (!engine.isAvailable()) {
1018
+ return { status: 'unavailable', reason: 'unsupported_platform' };
1019
+ }
1020
+
1021
+ // FR-020: a flag the provider only honors at initialization cannot be
1022
+ // applied now. Say so, rather than accepting it and running half-old.
1023
+ if (requiresReinitialization(appliedPrivacy, privacy)) {
1024
+ return { status: 'requires_reinitialization' };
1025
+ }
1026
+
1027
+ currentPrivacy = privacy;
1028
+ const gate = evaluateGate(privacy);
1029
+ if (!gate.allowed) {
1030
+ setInit(initReducer(init, { type: 'BLOCK', reason: gate.reason }));
1031
+ return { status: 'blocked', reason: gate.reason };
1032
+ }
1033
+
1034
+ // FR-019: resolving consent after a block must not produce a second
1035
+ // native initialization.
1036
+ if (init.status === 'ready') {
1037
+ return { status: 'applied' };
1038
+ }
1039
+ const result = await instance.initialize(privacy);
1040
+ return result.status === 'ready'
1041
+ ? { status: 'applied' }
1042
+ : result.status === 'blocked'
1043
+ ? { status: 'blocked', reason: result.reason }
1044
+ : { status: 'applied' };
1045
+ },
1046
+
1047
+ setEnabled(next) {
1048
+ return instance.setEnablement(next ? 'enabled' : 'disabled');
1049
+ },
1050
+
1051
+ setEnablement(next) {
1052
+ if (disposed) {
1053
+ return { status: 'unavailable', reason: 'provider_missing' };
1054
+ }
1055
+ if (enablement === next) {
1056
+ return { status: 'unchanged' };
1057
+ }
1058
+
1059
+ const previous = enablement;
1060
+ enablement = next;
1061
+
1062
+ if (next === 'disabled' || next === 'undecided') {
1063
+ // FR-037 and FR-125: new work is prevented and in-flight resources
1064
+ // released deterministically, and moving to `undecided` from
1065
+ // `enabled` follows the same rules as disabling. An ad already on
1066
+ // screen is left alone — the provider offers no safe way to close
1067
+ // it, and yanking it would be worse than letting it finish.
1068
+ cancelAllRetries();
1069
+ for (const [key, runtime] of placements) {
1070
+ if (runtime.snapshot.state === 'showing') {
1071
+ continue;
1072
+ }
1073
+ releasePlacementResources(key);
1074
+ dispatchPlacement(key, { type: 'RESET' });
1075
+ }
1076
+ setInit(initReducer(init, { type: next === 'disabled' ? 'DISABLE' : 'UNDECIDE' }));
1077
+ if (next === 'undecided') {
1078
+ armUndecidedWarning();
1079
+ return { status: 'undecided' };
1080
+ }
1081
+ disarmUndecidedWarning();
1082
+ return { status: 'disabled' };
1083
+ }
1084
+
1085
+ // Turning ads on. This is the path 0.1.0 got wrong: it dropped the
1086
+ // status to `not_started` and returned nothing, so an app whose user
1087
+ // lost their entitlement turned ads back on and then had every load
1088
+ // silently refused (FR-121).
1089
+ disarmUndecidedWarning();
1090
+ const resumable = canResume(init.resumeTo);
1091
+ setInit(initReducer(init, { type: 'ENABLE' }));
1092
+
1093
+ if (init.status === 'unavailable') {
1094
+ return { status: 'unavailable', reason: init.unavailableReason ?? 'unsupported_platform' };
1095
+ }
1096
+ if (resumable && init.status === 'ready') {
1097
+ // The provider was never un-initialized, so there is genuinely
1098
+ // nothing to redo — and FR-022 forbids initializing it twice.
1099
+ return { status: 'resumed', initStatus: init.status };
1100
+ }
1101
+ if (previous === 'undecided' && init.status === 'ready') {
1102
+ return { status: 'resumed', initStatus: init.status };
1103
+ }
1104
+ // Said out loud rather than left for the app to discover from a
1105
+ // refused load. The package will not initialize implicitly: that would
1106
+ // hide an asynchronous, billable operation behind a setter the caller
1107
+ // may not be awaiting.
1108
+ return { status: 'requires_initialization' };
1109
+ },
1110
+
1111
+ setAppActive(active) {
1112
+ if (disposed) {
1113
+ return;
1114
+ }
1115
+ appActive = active;
1116
+ if (!active) {
1117
+ // Arming happens when the provider reports an ad opened, not here:
1118
+ // this event can be delivered late enough that the ad is already
1119
+ // gone. All this adds is the browser case, where a background
1120
+ // closely follows a click (FR-128).
1121
+ adReturn.noteBackground(now());
1122
+ return;
1123
+ }
1124
+ adReturn.noteForeground(now());
1125
+ // FR-034: on return to the foreground, re-evaluate what was loaded.
1126
+ // An ad that went stale while the app was away must stop reporting
1127
+ // `ready` before the app offers a button that would fail on tap.
1128
+ const current = now();
1129
+ for (const [key, runtime] of placements) {
1130
+ const definition = resolved.placements.get(key);
1131
+ if (
1132
+ runtime.snapshot.state === 'ready' &&
1133
+ runtime.snapshot.loadedAtMs !== null &&
1134
+ definition !== undefined &&
1135
+ isStale(runtime.snapshot.loadedAtMs, current, validityFor(definition.format))
1136
+ ) {
1137
+ releasePlacementResources(key);
1138
+ dispatchPlacement(key, { type: 'EXPIRE' });
1139
+ }
1140
+ }
1141
+ },
1142
+
1143
+ getStatus() {
1144
+ return init.status;
1145
+ },
1146
+
1147
+ subscribeStatus(listener) {
1148
+ statusListeners.add(listener);
1149
+ let active = true;
1150
+ return () => {
1151
+ if (!active) {
1152
+ return;
1153
+ }
1154
+ active = false;
1155
+ statusListeners.delete(listener);
1156
+ };
1157
+ },
1158
+
1159
+ async load(placementKey) {
1160
+ if (disposed) {
1161
+ return { status: 'unavailable', reason: 'provider_missing' };
1162
+ }
1163
+
1164
+ const definition = resolved.placements.get(placementKey);
1165
+ if (!definition) {
1166
+ return {
1167
+ status: 'failed',
1168
+ error: new AdsError('placement_not_found', {
1169
+ operation: 'load',
1170
+ placementKey,
1171
+ message: `unknown placement "${placementKey}"; configured keys are ${[...resolved.placements.keys()].join(', ')}`,
1172
+ }),
1173
+ };
1174
+ }
1175
+
1176
+ const gate = gateCheck();
1177
+ if (!gate.ok) {
1178
+ emitRefusal('load', gate.result, placementKey, definition.format);
1179
+ return gate.result;
1180
+ }
1181
+
1182
+ if (!canRequest(init.status)) {
1183
+ return {
1184
+ status: 'failed',
1185
+ error: new AdsError('not_ready', {
1186
+ operation: 'load',
1187
+ placementKey,
1188
+ format: definition.format,
1189
+ message: 'initialize() must complete before loading',
1190
+ }),
1191
+ };
1192
+ }
1193
+
1194
+ if (definition.format === 'banner') {
1195
+ return {
1196
+ status: 'failed',
1197
+ error: new AdsError('format_mismatch', {
1198
+ operation: 'load',
1199
+ placementKey,
1200
+ format: definition.format,
1201
+ message: `"${placementKey}" is a banner; banners render through <AdsBanner> rather than load()`,
1202
+ }),
1203
+ };
1204
+ }
1205
+
1206
+ const runtime = placements.get(placementKey)!;
1207
+
1208
+ // FR-029, SC-002's sibling guarantee: a repeat load while one is in
1209
+ // flight joins it instead of creating a second provider request.
1210
+ if (runtime.inFlightLoad) {
1211
+ return runtime.inFlightLoad;
1212
+ }
1213
+
1214
+ // FR-140. Checked after the in-flight join so a caller that merely
1215
+ // arrived twice for one cycle is answered by the join, not charged a
1216
+ // throttle slot — and before the correlation id and `load_started`,
1217
+ // because a refused request is not a cycle and must not look like one
1218
+ // in the event stream.
1219
+ const throttled = checkThrottle(placementKey, definition.format);
1220
+ if (throttled) {
1221
+ return throttled;
1222
+ }
1223
+
1224
+ if (runtime.snapshot.state === 'showing') {
1225
+ return { status: 'failed', error: new AdsError('busy', { operation: 'load', placementKey }) };
1226
+ }
1227
+
1228
+ if (!canLoad(runtime.snapshot.state)) {
1229
+ return {
1230
+ status: 'failed',
1231
+ error: new AdsError('not_ready', { operation: 'load', placementKey }),
1232
+ };
1233
+ }
1234
+
1235
+ releasePlacementResources(placementKey);
1236
+
1237
+ const correlationId = correlation.next();
1238
+ runtime.correlationId = correlationId;
1239
+ runtime.dismissed = false;
1240
+ dispatchPlacement(placementKey, { type: 'LOAD' });
1241
+ setPlacement(placementKey, { ...runtime.snapshot, correlationId });
1242
+ events.emit({
1243
+ type: 'load_started',
1244
+ timestamp: now(),
1245
+ placementKey,
1246
+ format: definition.format,
1247
+ correlationId,
1248
+ });
1249
+
1250
+ const adUnitId = resolved.catalog.byKey.get(placementKey)!;
1251
+ runtime.retryAttempts = 0;
1252
+ const promise = loadWithRetry(placementKey, definition, adUnitId, correlationId);
1253
+ runtime.inFlightLoad = promise;
1254
+ return promise;
1255
+ },
1256
+
1257
+ async show(placementKey) {
1258
+ if (disposed) {
1259
+ return { status: 'unavailable', reason: 'provider_missing' };
1260
+ }
1261
+
1262
+ const definition = resolved.placements.get(placementKey);
1263
+ if (!definition) {
1264
+ return {
1265
+ status: 'failed',
1266
+ error: new AdsError('placement_not_found', {
1267
+ operation: 'show',
1268
+ placementKey,
1269
+ message: `unknown placement "${placementKey}"; configured keys are ${[...resolved.placements.keys()].join(', ')}`,
1270
+ }),
1271
+ };
1272
+ }
1273
+
1274
+ if (!isFullScreenFormat(definition.format)) {
1275
+ return {
1276
+ status: 'failed',
1277
+ error: new AdsError('format_mismatch', {
1278
+ operation: 'show',
1279
+ placementKey,
1280
+ format: definition.format,
1281
+ message: `"${placementKey}" is a banner; banners are rendered, not shown`,
1282
+ }),
1283
+ };
1284
+ }
1285
+
1286
+ const gate = gateCheck();
1287
+ if (!gate.ok) {
1288
+ emitRefusal('show', gate.result, placementKey, definition.format);
1289
+ return gate.result;
1290
+ }
1291
+
1292
+ // FR-028: never open an ad while the app is not in front of the user.
1293
+ if (!appActive) {
1294
+ return { status: 'app_inactive' };
1295
+ }
1296
+
1297
+ // FR-128. Only app-open is refused: the user just came back from an ad
1298
+ // or from a browser an ad opened, and app-open is the format that
1299
+ // would fire on exactly that return. An interstitial at the same
1300
+ // moment is the app's own deliberate choice, made somewhere the
1301
+ // package cannot see, so refusing it would be policy rather than
1302
+ // capability.
1303
+ if (definition.format === 'app_open' && adReturn.isRefusing(now())) {
1304
+ return { status: 'ad_originated_return' };
1305
+ }
1306
+
1307
+ const runtime = placements.get(placementKey)!;
1308
+
1309
+ if (runtime.snapshot.state === 'consumed') {
1310
+ return { status: 'consumed' };
1311
+ }
1312
+
1313
+ // FR-034: a stale ad is not shown and is not reported ready.
1314
+ if (
1315
+ runtime.snapshot.state === 'ready' &&
1316
+ runtime.snapshot.loadedAtMs !== null &&
1317
+ isStale(runtime.snapshot.loadedAtMs, now(), validityFor(definition.format))
1318
+ ) {
1319
+ releasePlacementResources(placementKey);
1320
+ dispatchPlacement(placementKey, { type: 'EXPIRE' });
1321
+ return { status: 'not_ready' };
1322
+ }
1323
+
1324
+ if (runtime.snapshot.state !== 'ready' || !runtime.handle) {
1325
+ return { status: 'not_ready' };
1326
+ }
1327
+
1328
+ // FR-030. Acquired before anything else can open a screen, and never
1329
+ // queued: a refused show is refused now, not deferred to a moment the
1330
+ // app never chose.
1331
+ if (!lock.acquire(placementKey)) {
1332
+ return { status: 'busy' };
1333
+ }
1334
+
1335
+ const correlationId = runtime.correlationId ?? correlation.next();
1336
+ runtime.rewarded = definition.format === 'rewarded' ? createRewardedSession() : null;
1337
+ dispatchPlacement(placementKey, { type: 'SHOW' });
1338
+
1339
+ let settled = false;
1340
+ const showTimeout = new Promise<ShowResult>(resolve => {
1341
+ runtime.cancelShowTimer = timers.after(resolved.timeouts.show, () => {
1342
+ if (settled) {
1343
+ return;
1344
+ }
1345
+ settled = true;
1346
+ const error = new AdsError('show_timeout', {
1347
+ operation: 'show',
1348
+ placementKey,
1349
+ format: definition.format,
1350
+ message: `show timed out after ${resolved.timeouts.show}ms`,
1351
+ });
1352
+ lock.release(placementKey);
1353
+ events.emit({
1354
+ type: 'display_failed',
1355
+ timestamp: now(),
1356
+ placementKey,
1357
+ format: definition.format,
1358
+ correlationId,
1359
+ error,
1360
+ });
1361
+ dispatchPlacement(placementKey, { type: 'SHOW_FAILED', error });
1362
+ releasePlacementResources(placementKey);
1363
+ resolve({ status: 'timeout' });
1364
+ });
1365
+ });
1366
+
1367
+ const attempt = engine
1368
+ .show(runtime.handle)
1369
+ .then<ShowResult>(() => {
1370
+ settled = true;
1371
+ runtime.cancelShowTimer?.();
1372
+ runtime.cancelShowTimer = null;
1373
+ return { status: 'shown', correlationId };
1374
+ })
1375
+ .catch<ShowResult>(error => {
1376
+ settled = true;
1377
+ runtime.cancelShowTimer?.();
1378
+ runtime.cancelShowTimer = null;
1379
+ const normalized = normalizeProviderError(error, {
1380
+ operation: 'show',
1381
+ placementKey,
1382
+ format: definition.format,
1383
+ });
1384
+ lock.release(placementKey);
1385
+ events.emit({
1386
+ type: 'display_failed',
1387
+ timestamp: now(),
1388
+ placementKey,
1389
+ format: definition.format,
1390
+ correlationId,
1391
+ error: normalized,
1392
+ });
1393
+ dispatchPlacement(placementKey, { type: 'SHOW_FAILED', error: normalized });
1394
+ releasePlacementResources(placementKey);
1395
+ return { status: 'failed', error: normalized };
1396
+ });
1397
+
1398
+ return Promise.race([attempt, showTimeout]);
1399
+ },
1400
+
1401
+ getPlacementState(placementKey) {
1402
+ if (!resolved.placements.has(placementKey)) {
1403
+ return 'unavailable';
1404
+ }
1405
+ if (!engine.isAvailable() || enablement === 'disabled') {
1406
+ return 'unavailable';
1407
+ }
1408
+ // Reported distinctly from `unavailable` so the app can choose to hold
1409
+ // the banner's space rather than reclaim it. That choice is the entire
1410
+ // reason the third state is worth having (FR-116).
1411
+ if (enablement === 'undecided') {
1412
+ return 'undecided';
1413
+ }
1414
+ return placements.get(placementKey)?.snapshot.state ?? 'idle';
1415
+ },
1416
+
1417
+ subscribePlacement(key, listener) {
1418
+ const runtime = placements.get(key);
1419
+ if (!runtime) {
1420
+ return () => undefined;
1421
+ }
1422
+ runtime.listeners.add(listener);
1423
+ let active = true;
1424
+ return () => {
1425
+ if (!active) {
1426
+ return;
1427
+ }
1428
+ active = false;
1429
+ runtime.listeners.delete(listener);
1430
+ };
1431
+ },
1432
+
1433
+ subscribeEvents(listener) {
1434
+ return events.subscribe(listener);
1435
+ },
1436
+
1437
+ getSubstitutedPlacements() {
1438
+ return resolved.catalog.substitutedKeys;
1439
+ },
1440
+
1441
+ getIgnoredConsentTools() {
1442
+ return resolved.ignoredConsentTools;
1443
+ },
1444
+
1445
+ consent: {
1446
+ async requestInfoUpdate() {
1447
+ if (disposed) {
1448
+ return consent;
1449
+ }
1450
+ // Marks the flow as in use. Until this is called the gate sees no
1451
+ // consent input at all, which is how an app that supplies its own
1452
+ // gate keeps 0.1.0 behavior without opting out of anything
1453
+ // (FR-102).
1454
+ consentStarted = true;
1455
+ if (resolved.consentTesting?.resetOnStart) {
1456
+ try {
1457
+ engine.consent.reset();
1458
+ } catch {
1459
+ // A reset that fails must not abort the update; the flow
1460
+ // continues against whatever state is stored.
1461
+ }
1462
+ }
1463
+ const next = await runConsentStep(
1464
+ () =>
1465
+ engine.consent.requestInfoUpdate(
1466
+ buildConsentOptions(
1467
+ resolved.consentTesting,
1468
+ currentPrivacy?.underAgeOfConsent,
1469
+ resolved.testDeviceIds,
1470
+ ),
1471
+ ),
1472
+ 'consent information update failed',
1473
+ );
1474
+ if (disposed) {
1475
+ return consent;
1476
+ }
1477
+ setConsent(next);
1478
+ await reevaluateAfterConsent();
1479
+ return next;
1480
+ },
1481
+
1482
+ async showFormIfRequired() {
1483
+ if (disposed) {
1484
+ return consent;
1485
+ }
1486
+ consentStarted = true;
1487
+ if (!needsForm(consent)) {
1488
+ // Nothing to present. Not an error: a region that does not
1489
+ // require consent is the normal case, and showing a form there
1490
+ // is exactly what FR-107 forbids the package from deciding.
1491
+ return consent;
1492
+ }
1493
+ const next = await runConsentStep(
1494
+ () => engine.consent.showFormIfRequired(),
1495
+ 'consent form failed',
1496
+ );
1497
+ if (disposed) {
1498
+ return consent;
1499
+ }
1500
+ setConsent(next);
1501
+ await reevaluateAfterConsent();
1502
+ return next;
1503
+ },
1504
+
1505
+ getState() {
1506
+ return consent;
1507
+ },
1508
+
1509
+ subscribe(listener) {
1510
+ consentListeners.add(listener);
1511
+ let active = true;
1512
+ return () => {
1513
+ if (!active) {
1514
+ return;
1515
+ }
1516
+ active = false;
1517
+ consentListeners.delete(listener);
1518
+ };
1519
+ },
1520
+
1521
+ async reset() {
1522
+ if (disposed) {
1523
+ return;
1524
+ }
1525
+ // Config validation already stripped the testing tools in
1526
+ // production, so this is inert there without a check here (FR-113).
1527
+ if (!resolved.consentTesting) {
1528
+ return;
1529
+ }
1530
+ try {
1531
+ engine.consent.reset();
1532
+ } catch {
1533
+ // Nothing to report: the stored state is the provider's, and a
1534
+ // failed reset leaves it as it was.
1535
+ }
1536
+ setConsent(INITIAL_CONSENT);
1537
+ },
1538
+ },
1539
+
1540
+ internals: {
1541
+ resolveBanner(placementKey) {
1542
+ const definition = resolved.placements.get(placementKey);
1543
+ if (!definition) {
1544
+ return {
1545
+ ok: false,
1546
+ result: {
1547
+ status: 'failed',
1548
+ error: new AdsError('placement_not_found', {
1549
+ operation: 'load',
1550
+ placementKey,
1551
+ message: `unknown placement "${placementKey}"`,
1552
+ }),
1553
+ },
1554
+ };
1555
+ }
1556
+ if (definition.format !== 'banner') {
1557
+ return {
1558
+ ok: false,
1559
+ result: {
1560
+ status: 'failed',
1561
+ error: new AdsError('format_mismatch', {
1562
+ operation: 'load',
1563
+ placementKey,
1564
+ format: definition.format,
1565
+ message: `"${placementKey}" is not a banner placement`,
1566
+ }),
1567
+ },
1568
+ };
1569
+ }
1570
+ const gate = gateCheck();
1571
+ if (!gate.ok) {
1572
+ return { ok: false, result: gate.result };
1573
+ }
1574
+ if (!canRequest(init.status)) {
1575
+ return { ok: false, result: { status: 'blocked', reason: 'consent_required' } };
1576
+ }
1577
+ // The throttle is deliberately **not** consulted here. It cannot be
1578
+ // decided from configuration alone: a fresh mount must be throttled
1579
+ // (that is the navigation burst the feature exists for) while a width
1580
+ // re-adaptation of an already-mounted banner must not be (that is
1581
+ // rotation, and refusing it would leave the user with no banner for a
1582
+ // minute). Only the host knows which it is, so the host calls
1583
+ // `commitBannerRequest` (FR-140).
1584
+ return {
1585
+ ok: true,
1586
+ adUnitId: resolved.catalog.byKey.get(placementKey)!,
1587
+ correlationId: correlation.next(),
1588
+ };
1589
+ },
1590
+ commitBannerRequest(placementKey, correlationId) {
1591
+ if (!resolved.placements.has(placementKey)) {
1592
+ return null;
1593
+ }
1594
+ const minRequestIntervalMs =
1595
+ resolved.minRequestIntervalByKey.get(placementKey) ?? 0;
1596
+ const decision = requestThrottle.tryAcquire(
1597
+ placementKey,
1598
+ minRequestIntervalMs,
1599
+ correlationId,
1600
+ );
1601
+ // Deliberately silent: the host calls this during render, because the
1602
+ // provider component requests as part of that same render and an effect
1603
+ // would be too late to stop it. Emitting an event or setting placement
1604
+ // state here would mean mutating during another component's render, so
1605
+ // the refusal is only returned — the host reports it from its effect.
1606
+ return decision.allowed
1607
+ ? null
1608
+ : { status: 'throttled', retryAfterMs: decision.retryAfterMs };
1609
+ },
1610
+ throttleFloorFor(placementKey) {
1611
+ return resolved.minRequestIntervalByKey.get(placementKey) ?? 0;
1612
+ },
1613
+ emit(event) {
1614
+ events.emit(event);
1615
+ },
1616
+ setPlacementState(key, state) {
1617
+ const runtime = placements.get(key);
1618
+ if (runtime) {
1619
+ setPlacement(key, { ...runtime.snapshot, state });
1620
+ }
1621
+ },
1622
+ isReady() {
1623
+ return permitsRequests(enablement) && engine.isAvailable() && canRequest(init.status);
1624
+ },
1625
+ nextCorrelationId() {
1626
+ return correlation.next();
1627
+ },
1628
+ timerCount() {
1629
+ return timers.size();
1630
+ },
1631
+ },
1632
+
1633
+ dispose() {
1634
+ // FR-005: idempotent. A second call is a no-op, not an error.
1635
+ if (disposed) {
1636
+ return;
1637
+ }
1638
+ disposed = true;
1639
+ cancelAllRetries();
1640
+ disarmUndecidedWarning();
1641
+ for (const key of placements.keys()) {
1642
+ releasePlacementResources(key);
1643
+ placements.get(key)?.listeners.clear();
1644
+ }
1645
+ timers.clearAll();
1646
+ requestThrottle.clear();
1647
+ lock.reset();
1648
+ adReturn.reset();
1649
+ statusListeners.clear();
1650
+ consentListeners.clear();
1651
+ events.dispose();
1652
+ inFlightInit = null;
1653
+ },
1654
+ };
1655
+
1656
+ return instance;
1657
+ }
1658
+
1659
+ /**
1660
+ * Resolved lazily so importing the package on a platform with no adapter — or
1661
+ * with the bridge absent — costs nothing and crashes nothing (FR-025, SC-004).
1662
+ */
1663
+ function createDefaultEngine(): AdsEngine {
1664
+ // Deferred require keeps the provider off the import graph of every module
1665
+ // that merely mentions `createAds`.
1666
+ const { createAdMobEngine } = require('../engine/admobEngine') as typeof import('../engine/admobEngine');
1667
+ return createAdMobEngine();
1668
+ }
1669
+
1670
+ declare const __DEV__: boolean | undefined;