@chipmobilesdk/rn-ads 0.1.0 → 0.2.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.
@@ -20,6 +20,8 @@ import {
20
20
  isFullScreenFormat,
21
21
  type AdFormat,
22
22
  type AdsConfig,
23
+ type ConsentSnapshot,
24
+ type Enablement,
23
25
  type FullScreenFormat,
24
26
  type InitStatus,
25
27
  type PlacementDefinition,
@@ -48,13 +50,26 @@ import {
48
50
  type PlacementSnapshot,
49
51
  } from './placementMachine';
50
52
  import { evaluateGate, requiresReinitialization, toEnginePrivacy } from './privacyGate';
53
+ import { evaluateGates, type ConsentGateInput, type GateDecision } from './gates';
51
54
  import { createRewardedSession, type RewardedSession } from './rewardedSession';
52
- import { createTimerSet, isStale } from './timers';
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';
53
66
 
54
67
  export type InitResult =
55
68
  | { readonly status: 'ready' }
56
69
  | { readonly status: 'blocked'; readonly reason: PrivacyBlockReason }
57
70
  | { readonly status: 'disabled' }
71
+ /** The app has not decided whether ads are on. Nothing was requested (FR-115). */
72
+ | { readonly status: 'undecided' }
58
73
  | { readonly status: 'unavailable'; readonly reason: UnavailableReason }
59
74
  | { readonly status: 'failed'; readonly error: AdsError };
60
75
 
@@ -63,11 +78,13 @@ export type PrivacyUpdateResult =
63
78
  | { readonly status: 'blocked'; readonly reason: PrivacyBlockReason }
64
79
  | { readonly status: 'requires_reinitialization' }
65
80
  | { readonly status: 'disabled' }
81
+ | { readonly status: 'undecided' }
66
82
  | { readonly status: 'unavailable'; readonly reason: UnavailableReason };
67
83
 
68
84
  export type LoadResult =
69
85
  | { readonly status: 'loaded'; readonly correlationId: string }
70
86
  | { readonly status: 'disabled' }
87
+ | { readonly status: 'undecided' }
71
88
  | { readonly status: 'blocked'; readonly reason: PrivacyBlockReason }
72
89
  | { readonly status: 'unavailable'; readonly reason: UnavailableReason }
73
90
  | { readonly status: 'no_fill' }
@@ -80,17 +97,54 @@ export type ShowResult =
80
97
  | { readonly status: 'busy' }
81
98
  | { readonly status: 'consumed' }
82
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' }
83
107
  | { readonly status: 'disabled' }
108
+ | { readonly status: 'undecided' }
84
109
  | { readonly status: 'blocked'; readonly reason: PrivacyBlockReason }
85
110
  | { readonly status: 'unavailable'; readonly reason: UnavailableReason }
86
111
  | { readonly status: 'timeout' }
87
112
  | { readonly status: 'failed'; readonly error: AdsError };
88
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
+
89
133
  export interface AdsInstance {
90
134
  initialize(privacy: PrivacyOptions): Promise<InitResult>;
91
135
  updatePrivacy(privacy: PrivacyOptions): Promise<PrivacyUpdateResult>;
92
- setEnabled(enabled: boolean): void;
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;
93
146
  setAppActive(active: boolean): void;
147
+ readonly consent: ConsentApi;
94
148
  getStatus(): InitStatus;
95
149
  subscribeStatus(listener: (status: InitStatus) => void): Unsubscribe;
96
150
 
@@ -103,6 +157,8 @@ export interface AdsInstance {
103
157
 
104
158
  /** FR-011a — substitution is observable state, not only a log line. */
105
159
  getSubstitutedPlacements(): readonly string[];
160
+ /** FR-113 — the same treatment for consent tools dropped in production. */
161
+ getIgnoredConsentTools(): readonly string[];
106
162
  /** Internal wiring for the banner host; not part of the documented surface. */
107
163
  readonly internals: InstanceInternals;
108
164
 
@@ -127,6 +183,12 @@ export interface CreateAdsOptions {
127
183
  /** Injected so the FR-012 rule is testable without touching a global. */
128
184
  readonly isDevBuild?: boolean;
129
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;
130
192
  }
131
193
 
132
194
  interface PlacementRuntime {
@@ -150,6 +212,10 @@ interface PlacementRuntime {
150
212
  cancelShowTimer: Unsubscribe | null;
151
213
  rewarded: RewardedSession | null;
152
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;
153
219
  }
154
220
 
155
221
  function detectDevBuild(): boolean {
@@ -167,12 +233,34 @@ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): Ad
167
233
  const engine: AdsEngine = options.engine ?? createDefaultEngine();
168
234
  const events = createEventEmitter();
169
235
  const correlation = createCorrelationIssuer();
170
- const timers = createTimerSet();
236
+ const timers = createTimerSet(options.scheduler);
171
237
  const lock = createFullScreenLock();
238
+ const adReturn = createAdOriginatedTracker();
172
239
 
173
- let init: InitSnapshot = initialInit;
174
- let enabled = resolved.enabled;
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;
175
259
  let appActive = true;
260
+ let consent: ConsentSnapshot = INITIAL_CONSENT;
261
+ let consentStarted = false;
262
+ let undecidedWarned = false;
263
+ let cancelUndecidedTimer: Unsubscribe | null = null;
176
264
  let appliedPrivacy: PrivacyOptions | null = null;
177
265
  let currentPrivacy: PrivacyOptions | null = null;
178
266
  let inFlightInit: Promise<InitResult> | null = null;
@@ -194,9 +282,78 @@ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): Ad
194
282
  cancelShowTimer: null,
195
283
  rewarded: null,
196
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
+ });
197
341
  });
198
342
  }
199
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
+
200
357
  function setInit(next: InitSnapshot): void {
201
358
  if (next === init || next.status === init.status) {
202
359
  init = next;
@@ -246,24 +403,67 @@ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): Ad
246
403
  setPlacement(key, placementReducer(runtime.snapshot, event, definition.format));
247
404
  }
248
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
+
249
452
  /** Every refusal that applies before a placement is even looked up. */
250
453
  function gateCheck():
251
454
  | { readonly ok: true }
252
- | { readonly ok: false; readonly result: Extract<LoadResult, { status: 'disabled' | 'blocked' | 'unavailable' }> } {
253
- if (!enabled) {
254
- return { ok: false, result: { status: 'disabled' } };
255
- }
256
- if (!engine.isAvailable()) {
257
- return {
258
- ok: false,
259
- result: { status: 'unavailable', reason: 'unsupported_platform' },
260
- };
261
- }
262
- const gate = evaluateGate(currentPrivacy);
263
- if (!gate.allowed) {
264
- return { ok: false, result: { status: 'blocked', reason: gate.reason } };
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 };
265
465
  }
266
- return { ok: true };
466
+ return { ok: false, result: refusalFor(decision) };
267
467
  }
268
468
 
269
469
  function emitRefusal(
@@ -272,7 +472,11 @@ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): Ad
272
472
  placementKey?: string,
273
473
  format?: AdFormat,
274
474
  ): void {
275
- if (result.status === 'disabled') {
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.
276
480
  events.emit({ type: 'disabled', timestamp: now(), operation, placementKey, format });
277
481
  } else if (result.status === 'unavailable' || result.status === 'blocked') {
278
482
  events.emit({
@@ -316,6 +520,15 @@ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): Ad
316
520
  }
317
521
  runtime.inFlightLoad = null;
318
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;
319
532
  }
320
533
 
321
534
  async function doInitialize(privacy: PrivacyOptions): Promise<InitResult> {
@@ -356,6 +569,13 @@ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): Ad
356
569
 
357
570
  switch (event.type) {
358
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
+ }
359
579
  events.emit({ ...base, type: 'display_started' });
360
580
  // R5: the provider exposes no impression callback for full-screen
361
581
  // formats, so this one is derived from the ad opening. Labelled
@@ -364,6 +584,10 @@ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): Ad
364
584
  break;
365
585
  }
366
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());
367
591
  events.emit({ ...base, type: 'click' });
368
592
  break;
369
593
  case 'rewarded': {
@@ -532,6 +756,118 @@ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): Ad
532
756
  });
533
757
  }
534
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
+
535
871
  const instance: AdsInstance = {
536
872
  async initialize(privacy) {
537
873
  if (disposed) {
@@ -539,16 +875,28 @@ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): Ad
539
875
  }
540
876
  currentPrivacy = privacy;
541
877
 
542
- if (!enabled) {
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') {
543
888
  setInit(initReducer(init, { type: 'DISABLE' }));
544
889
  const result = { status: 'disabled' } as const;
545
890
  emitRefusal('initialize', result);
546
891
  return result;
547
892
  }
548
-
549
- if (!engine.isAvailable()) {
550
- setInit(initReducer(init, { type: 'UNAVAILABLE', reason: 'unsupported_platform' }));
551
- const result = { status: 'unavailable', reason: 'unsupported_platform' } as const;
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;
552
900
  emitRefusal('initialize', result);
553
901
  return result;
554
902
  }
@@ -582,7 +930,13 @@ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): Ad
582
930
  },
583
931
 
584
932
  async updatePrivacy(privacy) {
585
- if (disposed || !enabled) {
933
+ if (disposed) {
934
+ return { status: 'disabled' };
935
+ }
936
+ if (enablement === 'undecided') {
937
+ return { status: 'undecided' };
938
+ }
939
+ if (enablement === 'disabled') {
586
940
  return { status: 'disabled' };
587
941
  }
588
942
  if (!engine.isAvailable()) {
@@ -616,15 +970,27 @@ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): Ad
616
970
  },
617
971
 
618
972
  setEnabled(next) {
619
- if (disposed || enabled === next) {
620
- return;
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' };
621
982
  }
622
- enabled = next;
623
- if (!next) {
624
- // FR-037: new work is prevented and in-flight resources released
625
- // deterministically. An ad already on screen is left alone — the
626
- // provider offers no safe way to close it, and yanking it would be
627
- // worse than letting it finish.
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();
628
994
  for (const [key, runtime] of placements) {
629
995
  if (runtime.snapshot.state === 'showing') {
630
996
  continue;
@@ -632,10 +998,39 @@ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): Ad
632
998
  releasePlacementResources(key);
633
999
  dispatchPlacement(key, { type: 'RESET' });
634
1000
  }
635
- setInit(initReducer(init, { type: 'DISABLE' }));
636
- } else {
637
- setInit(initReducer(init, { type: 'ENABLE' }));
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 };
638
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' };
639
1034
  },
640
1035
 
641
1036
  setAppActive(active) {
@@ -644,17 +1039,25 @@ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): Ad
644
1039
  }
645
1040
  appActive = active;
646
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());
647
1047
  return;
648
1048
  }
1049
+ adReturn.noteForeground(now());
649
1050
  // FR-034: on return to the foreground, re-evaluate what was loaded.
650
1051
  // An ad that went stale while the app was away must stop reporting
651
1052
  // `ready` before the app offers a button that would fail on tap.
652
1053
  const current = now();
653
1054
  for (const [key, runtime] of placements) {
1055
+ const definition = resolved.placements.get(key);
654
1056
  if (
655
1057
  runtime.snapshot.state === 'ready' &&
656
1058
  runtime.snapshot.loadedAtMs !== null &&
657
- isStale(runtime.snapshot.loadedAtMs, current, resolved.timeouts.adValidity)
1059
+ definition !== undefined &&
1060
+ isStale(runtime.snapshot.loadedAtMs, current, validityFor(definition.format))
658
1061
  ) {
659
1062
  releasePlacementResources(key);
660
1063
  dispatchPlacement(key, { type: 'EXPIRE' });
@@ -760,7 +1163,8 @@ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): Ad
760
1163
  });
761
1164
 
762
1165
  const adUnitId = resolved.catalog.byKey.get(placementKey)!;
763
- const promise = loadFullScreen(placementKey, definition, adUnitId, correlationId);
1166
+ runtime.retryAttempts = 0;
1167
+ const promise = loadWithRetry(placementKey, definition, adUnitId, correlationId);
764
1168
  runtime.inFlightLoad = promise;
765
1169
  return promise;
766
1170
  },
@@ -805,6 +1209,16 @@ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): Ad
805
1209
  return { status: 'app_inactive' };
806
1210
  }
807
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
+
808
1222
  const runtime = placements.get(placementKey)!;
809
1223
 
810
1224
  if (runtime.snapshot.state === 'consumed') {
@@ -815,7 +1229,7 @@ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): Ad
815
1229
  if (
816
1230
  runtime.snapshot.state === 'ready' &&
817
1231
  runtime.snapshot.loadedAtMs !== null &&
818
- isStale(runtime.snapshot.loadedAtMs, now(), resolved.timeouts.adValidity)
1232
+ isStale(runtime.snapshot.loadedAtMs, now(), validityFor(definition.format))
819
1233
  ) {
820
1234
  releasePlacementResources(placementKey);
821
1235
  dispatchPlacement(placementKey, { type: 'EXPIRE' });
@@ -903,9 +1317,15 @@ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): Ad
903
1317
  if (!resolved.placements.has(placementKey)) {
904
1318
  return 'unavailable';
905
1319
  }
906
- if (!enabled || !engine.isAvailable()) {
1320
+ if (!engine.isAvailable() || enablement === 'disabled') {
907
1321
  return 'unavailable';
908
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
+ }
909
1329
  return placements.get(placementKey)?.snapshot.state ?? 'idle';
910
1330
  },
911
1331
 
@@ -933,6 +1353,105 @@ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): Ad
933
1353
  return resolved.catalog.substitutedKeys;
934
1354
  },
935
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
+
936
1455
  internals: {
937
1456
  resolveBanner(placementKey) {
938
1457
  const definition = resolved.placements.get(placementKey);
@@ -986,7 +1505,7 @@ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): Ad
986
1505
  }
987
1506
  },
988
1507
  isReady() {
989
- return enabled && engine.isAvailable() && canRequest(init.status);
1508
+ return permitsRequests(enablement) && engine.isAvailable() && canRequest(init.status);
990
1509
  },
991
1510
  nextCorrelationId() {
992
1511
  return correlation.next();
@@ -1002,13 +1521,17 @@ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): Ad
1002
1521
  return;
1003
1522
  }
1004
1523
  disposed = true;
1524
+ cancelAllRetries();
1525
+ disarmUndecidedWarning();
1005
1526
  for (const key of placements.keys()) {
1006
1527
  releasePlacementResources(key);
1007
1528
  placements.get(key)?.listeners.clear();
1008
1529
  }
1009
1530
  timers.clearAll();
1010
1531
  lock.reset();
1532
+ adReturn.reset();
1011
1533
  statusListeners.clear();
1534
+ consentListeners.clear();
1012
1535
  events.dispose();
1013
1536
  inFlightInit = null;
1014
1537
  },