@chipmobilesdk/rn-ads 0.1.0 → 0.2.1

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.
@@ -22,17 +22,81 @@ export const DEFAULT_SHOW_TIMEOUT_MS = 10_000;
22
22
  */
23
23
  export const DEFAULT_AD_VALIDITY_MS = 3_600_000;
24
24
 
25
+ /**
26
+ * App-open's own window, deliberately shorter than the hour the other
27
+ * full-screen formats get (FR-130).
28
+ *
29
+ * Worth being explicit about why: Google expires app-open ads after **four
30
+ * hours**, which is *longer* than DEFAULT_AD_VALIDITY_MS, so the provider does
31
+ * not constrain this at all. The shorter window is a product decision. An
32
+ * app-open ad shows at the most prominent moment the app has, and a creative
33
+ * cached for an hour is stale by then; thirty minutes still spares a returning
34
+ * user the spinner of a cold load, which is both the worst app-open experience
35
+ * and the specific behavior store policy penalizes.
36
+ */
37
+ export const DEFAULT_APP_OPEN_VALIDITY_MS = 1_800_000;
38
+
39
+ /**
40
+ * How long `enabled: 'undecided'` may go unresolved before one warning fires.
41
+ *
42
+ * Ten seconds is several times a realistic worst case for the round trip this
43
+ * state exists to cover — asking a store whether the user owns an ad-removal
44
+ * entitlement — so an app doing the right thing essentially never trips it,
45
+ * while an app that forgot to resolve trips it inside a single QA session,
46
+ * which is where it needs to be caught (FR-119).
47
+ */
48
+ export const DEFAULT_UNDECIDED_WARNING_MS = 10_000;
49
+
25
50
  export const LOAD_TIMEOUT_BOUNDS = { min: 5_000, max: 120_000 } as const;
26
51
  export const SHOW_TIMEOUT_BOUNDS = { min: 5_000, max: 30_000 } as const;
27
52
  export const AD_VALIDITY_BOUNDS = { min: 60_000, max: 3_600_000 } as const;
53
+ /** Ceiling is one hour so app-open can never outlive the general window. */
54
+ export const APP_OPEN_VALIDITY_BOUNDS = { min: 60_000, max: 3_600_000 } as const;
55
+ export const UNDECIDED_WARNING_BOUNDS = { min: 1_000, max: 120_000 } as const;
56
+
57
+ /**
58
+ * Retry defaults (FR-136).
59
+ *
60
+ * Two retries rather than more: each one is a billable request, and two covers
61
+ * the transient network failure and the momentary no-fill — the cases where
62
+ * trying again plausibly helps. A third mostly pays for a failure that was not
63
+ * transient. 2s then 4s keeps both inside a window the user still associates
64
+ * with the action that triggered the load.
65
+ */
66
+ export const DEFAULT_RETRY_POLICY = {
67
+ maxRetries: 2,
68
+ initialDelayMs: 2_000,
69
+ maxDelayMs: 30_000,
70
+ } as const;
71
+
72
+ export const RETRY_BOUNDS = {
73
+ maxRetries: { min: 1, max: 5 },
74
+ initialDelayMs: { min: 500, max: 30_000 },
75
+ maxDelayMs: { min: 1_000, max: 60_000 },
76
+ } as const;
77
+
78
+ /**
79
+ * Ad-originated return detection (FR-128).
80
+ *
81
+ * A background transition within CLICK_WINDOW of a click is attributed to a
82
+ * browser the ad opened. An absence longer than MAX_AWAY is no longer
83
+ * attributable to the ad — the user did something else in between. GRACE covers
84
+ * the moment right after the return, where an app-open show would otherwise
85
+ * fire, without outliving it and refusing a genuinely later show.
86
+ */
87
+ export const AD_CLICK_WINDOW_MS = 2_000;
88
+ export const AD_RETURN_MAX_AWAY_MS = 300_000;
89
+ export const AD_RETURN_GRACE_MS = 10_000;
28
90
 
29
91
  export interface ResolvedTimeouts {
30
92
  readonly load: number;
31
93
  readonly show: number;
32
94
  readonly adValidity: number;
95
+ readonly appOpenValidity: number;
96
+ readonly undecidedWarning: number;
33
97
  }
34
98
 
35
- function checkBand(
99
+ export function checkBand(
36
100
  label: string,
37
101
  value: number,
38
102
  bounds: { readonly min: number; readonly max: number },
@@ -45,10 +109,15 @@ function checkBand(
45
109
  return value;
46
110
  }
47
111
 
48
- export function resolveTimeouts(
49
- timeouts: TimeoutConfig | undefined,
50
- adValidityMs: number | undefined,
51
- ): ResolvedTimeouts {
112
+ export interface TimeoutInput {
113
+ readonly timeouts?: TimeoutConfig;
114
+ readonly adValidityMs?: number;
115
+ readonly appOpenValidityMs?: number;
116
+ readonly undecidedWarningMs?: number;
117
+ }
118
+
119
+ export function resolveTimeouts(input: TimeoutInput): ResolvedTimeouts {
120
+ const { timeouts, adValidityMs, appOpenValidityMs, undecidedWarningMs } = input;
52
121
  return {
53
122
  load: checkBand('timeouts.load', timeouts?.load ?? DEFAULT_LOAD_TIMEOUT_MS, LOAD_TIMEOUT_BOUNDS),
54
123
  show: checkBand('timeouts.show', timeouts?.show ?? DEFAULT_SHOW_TIMEOUT_MS, SHOW_TIMEOUT_BOUNDS),
@@ -57,9 +126,39 @@ export function resolveTimeouts(
57
126
  adValidityMs ?? DEFAULT_AD_VALIDITY_MS,
58
127
  AD_VALIDITY_BOUNDS,
59
128
  ),
129
+ appOpenValidity: checkBand(
130
+ 'appOpenValidityMs',
131
+ appOpenValidityMs ?? DEFAULT_APP_OPEN_VALIDITY_MS,
132
+ APP_OPEN_VALIDITY_BOUNDS,
133
+ ),
134
+ undecidedWarning: checkBand(
135
+ 'undecidedWarningMs',
136
+ undecidedWarningMs ?? DEFAULT_UNDECIDED_WARNING_MS,
137
+ UNDECIDED_WARNING_BOUNDS,
138
+ ),
60
139
  };
61
140
  }
62
141
 
142
+ /**
143
+ * How timers are actually scheduled.
144
+ *
145
+ * Injectable so a test can drive retry backoff, app-open expiry, and the
146
+ * undecided warning by moving a clock rather than by waiting. The production
147
+ * default is `setTimeout`; the fake engine supplies a controllable one. Without
148
+ * this, every timing assertion in the suite would either sleep for real or
149
+ * depend on the test runner's timer mocking, and NFR-103's "no device, no
150
+ * network" would quietly become "no device, no network, but several seconds".
151
+ */
152
+ export interface Scheduler {
153
+ setTimeout(callback: () => void, ms: number): unknown;
154
+ clearTimeout(handle: unknown): void;
155
+ }
156
+
157
+ const realScheduler: Scheduler = {
158
+ setTimeout: (callback, ms) => setTimeout(callback, ms),
159
+ clearTimeout: handle => clearTimeout(handle as ReturnType<typeof setTimeout>),
160
+ };
161
+
63
162
  /**
64
163
  * Tracks every timer the instance owns so `dispose()` can prove it released
65
164
  * them. Counting is not incidental — SC-008 asserts the count reaches zero, and
@@ -71,12 +170,12 @@ export interface TimerSet {
71
170
  size(): number;
72
171
  }
73
172
 
74
- export function createTimerSet(): TimerSet {
75
- const handles = new Set<ReturnType<typeof setTimeout>>();
173
+ export function createTimerSet(scheduler: Scheduler = realScheduler): TimerSet {
174
+ const handles = new Set<unknown>();
76
175
 
77
176
  return {
78
177
  after(ms, callback) {
79
- const handle = setTimeout(() => {
178
+ const handle = scheduler.setTimeout(() => {
80
179
  handles.delete(handle);
81
180
  callback();
82
181
  }, ms);
@@ -87,13 +186,13 @@ export function createTimerSet(): TimerSet {
87
186
  return;
88
187
  }
89
188
  cleared = true;
90
- clearTimeout(handle);
189
+ scheduler.clearTimeout(handle);
91
190
  handles.delete(handle);
92
191
  };
93
192
  },
94
193
  clearAll() {
95
194
  for (const handle of handles) {
96
- clearTimeout(handle);
195
+ scheduler.clearTimeout(handle);
97
196
  }
98
197
  handles.clear();
99
198
  },
@@ -27,10 +27,21 @@ export type AdsErrorCode =
27
27
  | 'unsupported_platform'
28
28
  | 'provider_missing'
29
29
  | 'disabled'
30
+ // The app has not decided whether ads are on. Sibling of `disabled`, and
31
+ // non-retryable for the same reason: calling again changes nothing until the
32
+ // app resolves it (FR-115).
33
+ | 'undecided'
30
34
  // Privacy — resolved by consent changing, not by calling again.
31
35
  | 'consent_required'
32
36
  | 'consent_undetermined'
33
37
  | 'requires_reinitialization'
38
+ // The package's own consent flow has not produced permission. Distinct from
39
+ // `consent_required`, which means the app's own gate is closed — a refusal
40
+ // has to name which of the two inputs is blocking (FR-105a).
41
+ | 'consent_flow_required'
42
+ // The consent flow failed. Never retried by the package: FR-135 excludes
43
+ // consent errors, and re-running the flow is the app's call, not a backoff's.
44
+ | 'consent_flow_failed'
34
45
  // Runtime.
35
46
  | 'no_fill'
36
47
  | 'network_error'
@@ -41,13 +52,26 @@ export type AdsErrorCode =
41
52
  | 'busy'
42
53
  | 'consumed'
43
54
  | 'app_inactive'
55
+ // The user came back to the app from an ad, or from a browser an ad opened.
56
+ // Only the package can know this, which is why refusing it is a capability
57
+ // rather than a policy (FR-128). Not retryable: retry covers load, and this
58
+ // is a show refusal that resolves with time rather than with another attempt.
59
+ | 'ad_originated_return'
44
60
  | 'provider_error'
45
- | 'internal_error';
61
+ | 'internal_error'
62
+ // Configuration — a retry policy outside its bounds. Rejected rather than
63
+ // clamped, matching `timeout_out_of_bounds`: silently clamping tells the app
64
+ // it configured something it did not.
65
+ | 'retry_out_of_bounds';
46
66
 
47
67
  /**
48
68
  * "Retrying this operation could plausibly succeed" — not "retry immediately".
49
- * The package ships no automatic retry in v1; pacing belongs to the app, which
50
- * is the only party that knows what a retry costs its users (FR-036).
69
+ *
70
+ * 0.2.0 added an opt-in retry policy, and this set is what it is allowed to act
71
+ * on. Everything outside it is excluded deliberately: configuration and consent
72
+ * errors, an unsupported platform, and a disabled or undecided instance are all
73
+ * states another attempt cannot change, so retrying them would spend a billable
74
+ * request on a certainty (FR-135).
51
75
  */
52
76
  const RETRYABLE: ReadonlySet<AdsErrorCode> = new Set<AdsErrorCode>([
53
77
  'no_fill',
@@ -29,6 +29,10 @@ export type AdEventType =
29
29
  | 'disabled'
30
30
  | 'unavailable'
31
31
  | 'config_warning'
32
+ // Emitted once per retry attempt, carrying the correlation id of the load
33
+ // cycle that triggered it. Each attempt is a billable request, so it has to
34
+ // be reconcilable against a bill from the event stream alone (FR-138).
35
+ | 'retry_scheduled'
32
36
  | 'internal_error';
33
37
 
34
38
  interface BaseEvent {
@@ -65,14 +69,50 @@ export type AdEvent = BaseEvent &
65
69
  readonly operation: AdsOperation;
66
70
  readonly reason: UnavailableReason | PrivacyBlockReason;
67
71
  }
72
+ | {
73
+ readonly type: 'retry_scheduled';
74
+ /** 1-based: the first retry is attempt 1, not 0. */
75
+ readonly attempt: number;
76
+ readonly delayMs: number;
77
+ readonly error: AdsError;
78
+ }
68
79
  | {
69
80
  readonly type: 'config_warning';
70
- readonly warning: 'test_ad_unit_substituted';
81
+ readonly warning: ConfigWarning;
82
+ /**
83
+ * Placements the warning concerns. Empty for warnings that are not
84
+ * placement-scoped.
85
+ *
86
+ * Present on every config warning rather than only on the one that
87
+ * uses it, so that 0.1.0 code which reads `placementKeys` after
88
+ * checking `type === 'config_warning'` keeps compiling. Splitting
89
+ * this into one variant per warning would have been tidier and
90
+ * would have broken every such consumer for no behavioral gain.
91
+ */
71
92
  readonly placementKeys: readonly string[];
93
+ /** Consent tools ignored, for `consent_tool_ignored_in_production`. */
94
+ readonly tools?: readonly string[];
95
+ /** How long the app left the state unresolved, for the undecided warning. */
96
+ readonly elapsedMs?: number;
72
97
  }
73
98
  | { readonly type: 'internal_error'; readonly error: AdsError }
74
99
  );
75
100
 
101
+ /**
102
+ * Configuration problems worth telling the app about, all under one event type.
103
+ *
104
+ * One type rather than three because FR-132 forbids a parallel vocabulary, and
105
+ * because a consumer already routing `config_warning` into its logging should
106
+ * not have to subscribe again to keep seeing everything.
107
+ */
108
+ export type ConfigWarning =
109
+ /** A production ad unit was replaced with an official test unit (FR-011a). */
110
+ | 'test_ad_unit_substituted'
111
+ /** The app left `enabled: 'undecided'` unresolved past its window (FR-119). */
112
+ | 'enablement_undecided_timeout'
113
+ /** Consent testing tools were supplied in production and ignored (FR-113). */
114
+ | 'consent_tool_ignored_in_production';
115
+
76
116
  export type AdEventListener = (event: AdEvent) => void;
77
117
 
78
118
  /** Events that may occur at most once within one correlated cycle (FR-054). */
@@ -19,7 +19,15 @@
19
19
  import { Platform } from 'react-native';
20
20
 
21
21
  import type { AdContentRating, FullScreenFormat, Unsubscribe } from '../config/types';
22
- import type { AdsEngine, EngineAdEvent, EngineAdHandle, EnginePrivacy } from './port';
22
+ import type {
23
+ AdsEngine,
24
+ ConsentPort,
25
+ EngineAdEvent,
26
+ EngineAdHandle,
27
+ EngineConsentInfo,
28
+ EngineConsentOptions,
29
+ EnginePrivacy,
30
+ } from './port';
23
31
 
24
32
  /** The slice of the bridge this adapter uses. Kept local so nothing else sees it. */
25
33
  interface ProviderFullScreenAd {
@@ -39,9 +47,79 @@ interface ProviderModule {
39
47
  RewardedAd: {
40
48
  createForAdRequest(adUnitId: string, options?: Record<string, unknown>): ProviderFullScreenAd;
41
49
  };
50
+ AppOpenAd: {
51
+ createForAdRequest(adUnitId: string, options?: Record<string, unknown>): ProviderFullScreenAd;
52
+ };
42
53
  AdEventType: Record<string, string>;
43
54
  RewardedAdEventType: Record<string, string>;
44
55
  MaxAdContentRating: Record<string, string>;
56
+ AdsConsent: ProviderConsent;
57
+ AdsConsentDebugGeography: Record<string, number>;
58
+ }
59
+
60
+ /** The slice of the bridge's consent module this adapter uses. */
61
+ interface ProviderConsent {
62
+ requestInfoUpdate(options?: Record<string, unknown>): Promise<ProviderConsentInfo>;
63
+ loadAndShowConsentFormIfRequired(): Promise<ProviderConsentInfo>;
64
+ getConsentInfo(): Promise<ProviderConsentInfo>;
65
+ reset(): void;
66
+ }
67
+
68
+ interface ProviderConsentInfo {
69
+ status?: string;
70
+ canRequestAds?: boolean;
71
+ isConsentFormAvailable?: boolean;
72
+ }
73
+
74
+ /**
75
+ * The provider's geography enum is numeric and its spelling is the provider's.
76
+ * Translating here rather than in `core/` is the same rule the rest of this file
77
+ * follows: the one module that knows a provider exists is the one that speaks
78
+ * its vocabulary.
79
+ */
80
+ function toProviderGeography(
81
+ geography: EngineConsentOptions['debugGeography'],
82
+ values: Record<string, number>,
83
+ ): number | undefined {
84
+ switch (geography) {
85
+ case 'eea':
86
+ return values.EEA;
87
+ case 'not_eea':
88
+ return values.NOT_EEA;
89
+ case 'regulated_us_state':
90
+ return values.REGULATED_US_STATE;
91
+ case 'other':
92
+ return values.OTHER;
93
+ case 'disabled':
94
+ return values.DISABLED;
95
+ default:
96
+ return undefined;
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Normalizes the provider's consent shape into the engine's.
102
+ *
103
+ * `canRequestAds` is taken as reported and never inferred from the status: the
104
+ * provider is the authority on whether a request is permitted, and second-
105
+ * guessing it here is how a package ends up requesting ads it should not
106
+ * (FR-106, FR-107).
107
+ */
108
+ function toEngineConsentInfo(info: ProviderConsentInfo): EngineConsentInfo {
109
+ const status = String(info.status ?? 'UNKNOWN').toUpperCase();
110
+ const normalized: EngineConsentInfo['status'] =
111
+ status === 'OBTAINED'
112
+ ? 'obtained'
113
+ : status === 'REQUIRED'
114
+ ? 'required'
115
+ : status === 'NOT_REQUIRED'
116
+ ? 'not_required'
117
+ : 'unknown';
118
+ return {
119
+ status: normalized,
120
+ canRequestAds: info.canRequestAds === true,
121
+ isFormAvailable: info.isConsentFormAvailable === true,
122
+ };
45
123
  }
46
124
 
47
125
  function loadProvider(): ProviderModule | null {
@@ -137,7 +215,12 @@ export function createAdMobEngine(): AdsEngine {
137
215
  throw new Error('provider_missing');
138
216
  }
139
217
 
140
- const factory = format === 'rewarded' ? module.RewardedAd : module.InterstitialAd;
218
+ const factory =
219
+ format === 'rewarded'
220
+ ? module.RewardedAd
221
+ : format === 'app_open'
222
+ ? module.AppOpenAd
223
+ : module.InterstitialAd;
141
224
  const ad = factory.createForAdRequest(adUnitId, {
142
225
  // The consumer-facing polarity is `personalizedAds`; the provider's
143
226
  // is the inverse. The flip lives here, next to the API needing it.
@@ -227,5 +310,64 @@ export function createAdMobEngine(): AdsEngine {
227
310
  dispose(handle) {
228
311
  ads.delete(handle.id);
229
312
  },
313
+
314
+ consent: createAdMobConsent(getProvider),
315
+ };
316
+ }
317
+
318
+ /**
319
+ * The consent platform adapter.
320
+ *
321
+ * It is deliberately thin. Every decision that could be made here — whether a
322
+ * region requires consent, whether an error means "no", whether a dismissed form
323
+ * counts — is made elsewhere or not at all: the provider owns the first, and
324
+ * `core/consentFlow.ts` owns the second and third. This file's only job is to
325
+ * call the right method and hand back a shape that names nothing Google.
326
+ */
327
+ function createAdMobConsent(getProvider: () => ProviderModule | null): ConsentPort {
328
+ function requireConsent(): ProviderConsent {
329
+ const module = getProvider();
330
+ if (!module?.AdsConsent) {
331
+ throw new Error('provider_missing');
332
+ }
333
+ return module.AdsConsent;
334
+ }
335
+
336
+ return {
337
+ async requestInfoUpdate(options: EngineConsentOptions) {
338
+ const module = getProvider();
339
+ if (!module?.AdsConsent) {
340
+ throw new Error('provider_missing');
341
+ }
342
+ const debugGeography = toProviderGeography(
343
+ options.debugGeography,
344
+ module.AdsConsentDebugGeography ?? {},
345
+ );
346
+ const info = await module.AdsConsent.requestInfoUpdate({
347
+ // Passing `undefined` rather than omitting keeps the call shape
348
+ // stable; the bridge treats an absent value as "no override".
349
+ debugGeography,
350
+ tagForUnderAgeOfConsent: options.underAgeOfConsent,
351
+ testDeviceIdentifiers: options.testDeviceIds
352
+ ? [...options.testDeviceIds]
353
+ : undefined,
354
+ });
355
+ return toEngineConsentInfo(info);
356
+ },
357
+
358
+ async showFormIfRequired() {
359
+ // The provider decides whether a form is needed and renders it. The
360
+ // package never draws consent UI of its own (FR-103).
361
+ const info = await requireConsent().loadAndShowConsentFormIfRequired();
362
+ return toEngineConsentInfo(info);
363
+ },
364
+
365
+ async getInfo() {
366
+ return toEngineConsentInfo(await requireConsent().getConsentInfo());
367
+ },
368
+
369
+ reset() {
370
+ requireConsent().reset();
371
+ },
230
372
  };
231
373
  }
@@ -41,6 +41,47 @@ export interface EngineAdHandle {
41
41
  readonly id: string;
42
42
  }
43
43
 
44
+ /**
45
+ * Consent status as the engine reports it — the provider's own vocabulary,
46
+ * lowercased, with nothing provider-shaped attached. `core/consentFlow.ts`
47
+ * normalizes this into the public `ConsentState`; keeping the two separate is
48
+ * what lets an iOS adapter map a different mechanism onto the same public shape
49
+ * without either side changing.
50
+ */
51
+ export interface EngineConsentInfo {
52
+ readonly status: 'unknown' | 'required' | 'not_required' | 'obtained';
53
+ readonly canRequestAds: boolean;
54
+ readonly isFormAvailable: boolean;
55
+ }
56
+
57
+ export interface EngineConsentOptions {
58
+ /** Already stripped by config validation when the environment is production. */
59
+ readonly debugGeography?: 'disabled' | 'eea' | 'not_eea' | 'regulated_us_state' | 'other';
60
+ readonly underAgeOfConsent?: boolean;
61
+ readonly testDeviceIds?: readonly string[];
62
+ }
63
+
64
+ /**
65
+ * The provider's consent platform, behind the same boundary as everything else.
66
+ *
67
+ * It is a separate member of `AdsEngine` rather than more methods on it because
68
+ * it is a genuinely separate native module - the ads SDK and the consent SDK
69
+ * initialize independently and neither requires the other.
70
+ *
71
+ * The package orchestrates; it never renders. `showFormIfRequired` asks the
72
+ * provider to present its own form and reports what came back (FR-103).
73
+ */
74
+ export interface ConsentPort {
75
+ /** Refreshes consent information, applying any testing options. */
76
+ requestInfoUpdate(options: EngineConsentOptions): Promise<EngineConsentInfo>;
77
+ /** Presents the provider's form if one is required; a no-op otherwise. */
78
+ showFormIfRequired(): Promise<EngineConsentInfo>;
79
+ /** Last known information without contacting the network. */
80
+ getInfo(): Promise<EngineConsentInfo>;
81
+ /** Clears the stored decision. Inert in production by configuration. */
82
+ reset(): void;
83
+ }
84
+
44
85
  export interface AdsEngine {
45
86
  /**
46
87
  * True when this platform has a working adapter. Checked before anything
@@ -69,4 +110,7 @@ export interface AdsEngine {
69
110
 
70
111
  /** Releases the handle's provider resources. Safe to call more than once. */
71
112
  dispose(handle: EngineAdHandle): void;
113
+
114
+ /** The provider's consent platform. Only touched if the app opts in (FR-102). */
115
+ readonly consent: ConsentPort;
72
116
  }
@@ -28,6 +28,9 @@ const ANDROID_TEST_AD_UNITS: Readonly<Record<AdFormat, string>> = {
28
28
  banner: 'ca-app-pub-3940256099942544/9214589741',
29
29
  interstitial: 'ca-app-pub-3940256099942544/1033173712',
30
30
  rewarded: 'ca-app-pub-3940256099942544/5224354917',
31
+ // Added in 0.2.0 alongside the app-open format. Cross-checked against the
32
+ // bridge's own `TestIds.APP_OPEN` at 16.0.3.
33
+ app_open: 'ca-app-pub-3940256099942544/9257395921',
31
34
  };
32
35
 
33
36
  export function testAdUnitFor(format: AdFormat): string {
package/src/index.ts CHANGED
@@ -21,6 +21,10 @@ export type {
21
21
  AdContentRating,
22
22
  AdFormat,
23
23
  AdsConfig,
24
+ ConsentSnapshot,
25
+ ConsentState,
26
+ ConsentTestingOptions,
27
+ Enablement,
24
28
  Environment,
25
29
  FullScreenFormat,
26
30
  InitStatus,
@@ -28,6 +32,7 @@ export type {
28
32
  PlacementState,
29
33
  PrivacyBlockReason,
30
34
  PrivacyOptions,
35
+ RetryPolicy,
31
36
  Reward,
32
37
  TimeoutConfig,
33
38
  UnavailableReason,
@@ -39,17 +44,28 @@ export { isFullScreenFormat } from './config/types';
39
44
  // rather than discovering them from a timeout in production (FR-032).
40
45
  export {
41
46
  AD_VALIDITY_BOUNDS,
47
+ APP_OPEN_VALIDITY_BOUNDS,
42
48
  DEFAULT_AD_VALIDITY_MS,
49
+ DEFAULT_APP_OPEN_VALIDITY_MS,
43
50
  DEFAULT_LOAD_TIMEOUT_MS,
51
+ DEFAULT_RETRY_POLICY,
44
52
  DEFAULT_SHOW_TIMEOUT_MS,
53
+ DEFAULT_UNDECIDED_WARNING_MS,
45
54
  LOAD_TIMEOUT_BOUNDS,
55
+ RETRY_BOUNDS,
46
56
  SHOW_TIMEOUT_BOUNDS,
57
+ UNDECIDED_WARNING_BOUNDS,
47
58
  } from './core/timers';
48
59
 
60
+ // Enablement. `EnablementResult` is what `setEnabled` returns in place of
61
+ // 0.1.0's `void` — the defect that let a re-enable fail in silence (FR-122).
62
+ export type { EnablementResult } from './core/enablement';
63
+
49
64
  // Instance, lifecycle, and results
50
65
  export { createAds } from './core/adsInstance';
51
66
  export type {
52
67
  AdsInstance,
68
+ ConsentApi,
53
69
  InitResult,
54
70
  LoadResult,
55
71
  PrivacyUpdateResult,
@@ -66,4 +82,10 @@ export { useAdsEvents, useAdsStatus, usePlacementState } from './react/hooks';
66
82
  // Diagnostics
67
83
  export { AdsConfigError, AdsError, isAdsError } from './diagnostics/errors';
68
84
  export type { AdsErrorCode, AdsOperation } from './diagnostics/errors';
69
- export type { AdEvent, AdEventListener, AdEventType, ImpressionSource } from './diagnostics/events';
85
+ export type {
86
+ AdEvent,
87
+ AdEventListener,
88
+ AdEventType,
89
+ ConfigWarning,
90
+ ImpressionSource,
91
+ } from './diagnostics/events';
@@ -102,8 +102,22 @@ export function AdsBanner({ placementKey, onStateChange, style }: AdsBannerProps
102
102
  // `unavailable` and let the app keep its layout decision open. Every other
103
103
  // non-ready status — disabled, blocked, failed, unsupported — means there
104
104
  // will be no ad, and the app should reclaim the space (FR-039, FR-042).
105
+ //
106
+ // `undecided` is the exception added in 0.2.0, and it is the entire reason
107
+ // the third state exists. It is reported as itself rather than folded into
108
+ // either neighbour: `idle` would say "an ad is coming", which is not known
109
+ // yet, and `unavailable` would say "there will be none", which is also not
110
+ // known. Only by reporting it honestly can the app decide whether to hold
111
+ // the space — the choice that keeps a paying user from watching an ad
112
+ // flash in and out (FR-116).
105
113
  if (status !== 'ready') {
106
- move(status === 'not_started' || status === 'initializing' ? 'idle' : 'unavailable');
114
+ move(
115
+ status === 'undecided'
116
+ ? 'undecided'
117
+ : status === 'not_started' || status === 'initializing'
118
+ ? 'idle'
119
+ : 'unavailable',
120
+ );
107
121
  return;
108
122
  }
109
123
  if (!resolution?.ok) {
@@ -229,12 +243,20 @@ export function AdsBanner({ placementKey, onStateChange, style }: AdsBannerProps
229
243
  // tree rather than render an empty host. `not_started` and `initializing` are
230
244
  // excluded on purpose: the host must stay mounted through them, because it is
231
245
  // what measures the container and starts the load once the instance is ready.
246
+ //
247
+ // `undecided` is excluded for the same reason: the app may still resolve it to
248
+ // enabled, and a host that unmounted would have to re-measure the container
249
+ // from scratch when it did. It renders no ad meanwhile — it renders the same
250
+ // empty measuring host it renders while initializing — and it reports
251
+ // `undecided` so the app can size the space it decided to keep.
232
252
  if (status === 'disabled' || status === 'unavailable' || status === 'failed') {
233
253
  return null;
234
254
  }
235
255
 
236
256
  if (resolution && !resolution.ok) {
237
257
  const refusal = resolution.result;
258
+ // `undecided` deliberately absent: unlike `disabled`, it is not a settled
259
+ // no, so the host stays mounted (FR-116).
238
260
  if (refusal.status === 'disabled' || refusal.status === 'unavailable') {
239
261
  return null;
240
262
  }