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