@chipmobilesdk/rn-ads 0.3.0 → 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.
@@ -61,7 +61,9 @@ import {
61
61
  buildConsentOptions,
62
62
  INITIAL_CONSENT,
63
63
  needsForm,
64
+ normalizeConsent,
64
65
  runConsentStep,
66
+ toConsentError,
65
67
  toGateInput,
66
68
  } from './consentFlow';
67
69
 
@@ -106,6 +108,12 @@ export interface ThrottledResult {
106
108
  readonly retryAfterMs: number;
107
109
  }
108
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
+
109
117
  export type ShowResult =
110
118
  | { readonly status: 'shown'; readonly correlationId: string }
111
119
  | { readonly status: 'not_ready' }
@@ -126,6 +134,37 @@ export type ShowResult =
126
134
  | { readonly status: 'timeout' }
127
135
  | { readonly status: 'failed'; readonly error: AdsError };
128
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
+
129
168
  /**
130
169
  * The optional consent capability (FR-101).
131
170
  *
@@ -139,6 +178,15 @@ export interface ConsentApi {
139
178
  requestInfoUpdate(): Promise<ConsentSnapshot>;
140
179
  /** Presents the provider's form if one is required. Renders nothing itself. */
141
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>;
142
190
  getState(): ConsentSnapshot;
143
191
  subscribe(listener: (snapshot: ConsentSnapshot) => void): Unsubscribe;
144
192
  /** Clears the stored decision. Inert in production (FR-113). */
@@ -243,6 +291,12 @@ interface PlacementRuntime {
243
291
  handle: EngineAdHandle | null;
244
292
  unsubscribeEngine: Unsubscribe | null;
245
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;
246
300
  cancelLoadTimer: Unsubscribe | null;
247
301
  cancelShowTimer: Unsubscribe | null;
248
302
  rewarded: RewardedSession | null;
@@ -296,6 +350,21 @@ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): Ad
296
350
  let appActive = true;
297
351
  let consent: ConsentSnapshot = INITIAL_CONSENT;
298
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;
299
368
  let undecidedWarned = false;
300
369
  let cancelUndecidedTimer: Unsubscribe | null = null;
301
370
  let appliedPrivacy: PrivacyOptions | null = null;
@@ -315,6 +384,7 @@ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): Ad
315
384
  handle: null,
316
385
  unsubscribeEngine: null,
317
386
  inFlightLoad: null,
387
+ abortLoad: null,
318
388
  cancelLoadTimer: null,
319
389
  cancelShowTimer: null,
320
390
  rewarded: null,
@@ -594,11 +664,41 @@ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): Ad
594
664
  runtime.handle = null;
595
665
  }
596
666
  runtime.inFlightLoad = null;
667
+ runtime.abortLoad = null;
597
668
  runtime.rewarded = null;
598
669
  runtime.cancelRetry?.();
599
670
  runtime.cancelRetry = null;
600
671
  }
601
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
+
602
702
  /** App-open expires on its own, shorter window (FR-130). */
603
703
  function validityFor(format: AdFormat): number {
604
704
  return format === 'app_open'
@@ -612,6 +712,10 @@ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): Ad
612
712
  // The adapter applies privacy configuration before initializing the
613
713
  // provider; that ordering is the whole reason FR-017 is enforceable.
614
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;
615
719
  appliedPrivacy = privacy;
616
720
  setInit(initReducer(init, { type: 'SUCCEED' }));
617
721
  warnSubstitutionOnce();
@@ -736,8 +840,14 @@ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): Ad
736
840
  runtime.cancelLoadTimer?.();
737
841
  runtime.cancelLoadTimer = null;
738
842
  runtime.inFlightLoad = null;
843
+ if (runtime.abortLoad === finish) {
844
+ runtime.abortLoad = null;
845
+ }
739
846
  resolve(result);
740
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;
741
851
 
742
852
  runtime.cancelLoadTimer = timers.after(resolved.timeouts.load, () => {
743
853
  const error = new AdsError('load_timeout', {
@@ -924,6 +1034,15 @@ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): Ad
924
1034
  * total native initialization count across a whole consent flow stays at one
925
1035
  * (FR-022, SC-102). When consent closes the gate, requests stop and every
926
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).
927
1046
  */
928
1047
  async function reevaluateAfterConsent(): Promise<void> {
929
1048
  if (disposed || !currentPrivacy) {
@@ -932,7 +1051,8 @@ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): Ad
932
1051
  const decision = currentGate();
933
1052
  if (!decision.allowed) {
934
1053
  cancelAllRetries();
935
- if (decision.kind === 'blocked' && init.status !== 'ready') {
1054
+ if (decision.kind === 'blocked') {
1055
+ haltPlacements({ status: 'blocked', reason: decision.reason });
936
1056
  setInit(initReducer(init, { type: 'BLOCK', reason: decision.reason }));
937
1057
  }
938
1058
  return;
@@ -943,6 +1063,31 @@ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): Ad
943
1063
  await instance.initialize(currentPrivacy);
944
1064
  }
945
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
+
946
1091
  const instance: AdsInstance = {
947
1092
  async initialize(privacy) {
948
1093
  if (disposed) {
@@ -976,12 +1121,19 @@ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): Ad
976
1121
  return result;
977
1122
  }
978
1123
 
979
- const gate = evaluateGate(privacy);
980
- if (!gate.allowed) {
981
- // FR-017. Nothing provider-facing happens here at all — not a
1124
+ if (!decision.allowed && decision.kind === 'blocked') {
1125
+ // FR-017, FR-158. Nothing provider-facing happens here at all — not a
982
1126
  // configuration call, not an initialization. The engine is untouched.
983
- setInit(initReducer(init, { type: 'BLOCK', reason: gate.reason }));
984
- const result = { status: 'blocked', reason: gate.reason } as const;
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;
985
1137
  emitRefusal('initialize', result);
986
1138
  return result;
987
1139
  }
@@ -990,6 +1142,11 @@ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): Ad
990
1142
  return { status: 'ready' };
991
1143
  }
992
1144
 
1145
+ const resumed = resumeInitialized(privacy);
1146
+ if (resumed) {
1147
+ return resumed;
1148
+ }
1149
+
993
1150
  // FR-022, SC-005. Racing callers share one operation, so the provider
994
1151
  // initializes exactly once however many arrive.
995
1152
  if (inFlightInit) {
@@ -1066,13 +1223,7 @@ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): Ad
1066
1223
  // screen is left alone — the provider offers no safe way to close
1067
1224
  // it, and yanking it would be worse than letting it finish.
1068
1225
  cancelAllRetries();
1069
- for (const [key, runtime] of placements) {
1070
- if (runtime.snapshot.state === 'showing') {
1071
- continue;
1072
- }
1073
- releasePlacementResources(key);
1074
- dispatchPlacement(key, { type: 'RESET' });
1075
- }
1226
+ haltPlacements(next === 'disabled' ? { status: 'disabled' } : { status: 'undecided' });
1076
1227
  setInit(initReducer(init, { type: next === 'disabled' ? 'DISABLE' : 'UNDECIDE' }));
1077
1228
  if (next === 'undecided') {
1078
1229
  armUndecidedWarning();
@@ -1484,16 +1635,24 @@ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): Ad
1484
1635
  return consent;
1485
1636
  }
1486
1637
  consentStarted = true;
1487
- if (!needsForm(consent)) {
1638
+ if (!needsForm(consent) || privacyOptionsFormOpen) {
1488
1639
  // Nothing to present. Not an error: a region that does not
1489
1640
  // require consent is the normal case, and showing a form there
1490
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.
1491
1644
  return consent;
1492
1645
  }
1493
- const next = await runConsentStep(
1494
- () => engine.consent.showFormIfRequired(),
1495
- 'consent form failed',
1496
- );
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
+ }
1497
1656
  if (disposed) {
1498
1657
  return consent;
1499
1658
  }
@@ -1502,6 +1661,62 @@ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): Ad
1502
1661
  return next;
1503
1662
  },
1504
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
+
1505
1720
  getState() {
1506
1721
  return consent;
1507
1722
  },
@@ -1638,9 +1853,14 @@ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): Ad
1638
1853
  disposed = true;
1639
1854
  cancelAllRetries();
1640
1855
  disarmUndecidedWarning();
1641
- for (const key of placements.keys()) {
1856
+ for (const [key, runtime] of placements) {
1857
+ const abort = runtime.abortLoad;
1642
1858
  releasePlacementResources(key);
1643
- placements.get(key)?.listeners.clear();
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' });
1644
1864
  }
1645
1865
  timers.clearAll();
1646
1866
  requestThrottle.clear();
@@ -34,6 +34,7 @@ export const INITIAL_CONSENT: ConsentSnapshot = {
34
34
  state: 'undetermined',
35
35
  canRequestAds: false,
36
36
  isFormAvailable: false,
37
+ privacyOptionsRequirement: 'unknown',
37
38
  error: null,
38
39
  };
39
40
 
@@ -59,6 +60,7 @@ export function normalizeConsent(info: EngineConsentInfo): ConsentSnapshot {
59
60
  state,
60
61
  canRequestAds: info.canRequestAds === true,
61
62
  isFormAvailable: info.isFormAvailable === true,
63
+ privacyOptionsRequirement: info.privacyOptions,
62
64
  error: null,
63
65
  };
64
66
  }
@@ -70,9 +72,19 @@ export function normalizeConsent(info: EngineConsentInfo): ConsentSnapshot {
70
72
  * There is no failure mode in which "the flow broke" should mean "go ahead", and
71
73
  * making that structural rather than conditional means no future edit can
72
74
  * accidentally reintroduce the inference.
75
+ *
76
+ * The privacy-options requirement is `unknown` for the same reason: a broken
77
+ * flow has no provider verdict to forward, and reporting `not_required` would be
78
+ * the package deciding the user has no right to change their mind (FR-150).
73
79
  */
74
80
  export function consentFailure(error: AdsError): ConsentSnapshot {
75
- return { state: 'error', canRequestAds: false, isFormAvailable: false, error };
81
+ return {
82
+ state: 'error',
83
+ canRequestAds: false,
84
+ isFormAvailable: false,
85
+ privacyOptionsRequirement: 'unknown',
86
+ error,
87
+ };
76
88
  }
77
89
 
78
90
  export function toConsentError(cause: unknown, message: string): AdsError {
@@ -161,6 +173,7 @@ export function createConsentPortGuard(port: ConsentPort | undefined): ConsentPo
161
173
  return {
162
174
  requestInfoUpdate: unavailable,
163
175
  showFormIfRequired: unavailable,
176
+ showPrivacyOptionsForm: unavailable,
164
177
  getInfo: unavailable,
165
178
  reset: () => undefined,
166
179
  };
@@ -23,6 +23,13 @@ export type InitEvent =
23
23
  | { readonly type: 'UNDECIDE' }
24
24
  | { readonly type: 'ENABLE' }
25
25
  | { readonly type: 'SUCCEED' }
26
+ /**
27
+ * Back to `ready` with no native call, because the provider was initialized
28
+ * earlier and nothing has torn it down since (FR-154). The instance decides
29
+ * whether that holds; this machine only refuses to resume from a state a
30
+ * fresh start could not leave either.
31
+ */
32
+ | { readonly type: 'RESUME' }
26
33
  | { readonly type: 'FAIL'; readonly error: AdsError };
27
34
 
28
35
  export interface InitSnapshot {
@@ -165,6 +172,12 @@ export function initReducer(state: InitSnapshot, event: InitEvent): InitSnapshot
165
172
  // resurrect a state the app deliberately left.
166
173
  return state.status === 'initializing' ? at('ready') : state;
167
174
 
175
+ case 'RESUME':
176
+ // Same guard as START, for the same reason: `disabled`, `undecided` and
177
+ // `unavailable` are not left by a gate reopening. `canStart` excludes
178
+ // them, and `initializing`, whose own outcome is still on its way.
179
+ return canStart(state.status) ? at('ready') : state;
180
+
168
181
  case 'FAIL':
169
182
  return state.status === 'initializing' ? at('failed', { error: event.error }) : state;
170
183
 
@@ -61,6 +61,7 @@ interface ProviderModule {
61
61
  interface ProviderConsent {
62
62
  requestInfoUpdate(options?: Record<string, unknown>): Promise<ProviderConsentInfo>;
63
63
  loadAndShowConsentFormIfRequired(): Promise<ProviderConsentInfo>;
64
+ showPrivacyOptionsForm(): Promise<ProviderConsentInfo>;
64
65
  getConsentInfo(): Promise<ProviderConsentInfo>;
65
66
  reset(): void;
66
67
  }
@@ -69,6 +70,7 @@ interface ProviderConsentInfo {
69
70
  status?: string;
70
71
  canRequestAds?: boolean;
71
72
  isConsentFormAvailable?: boolean;
73
+ privacyOptionsRequirementStatus?: string;
72
74
  }
73
75
 
74
76
  /**
@@ -119,9 +121,25 @@ function toEngineConsentInfo(info: ProviderConsentInfo): EngineConsentInfo {
119
121
  status: normalized,
120
122
  canRequestAds: info.canRequestAds === true,
121
123
  isFormAvailable: info.isConsentFormAvailable === true,
124
+ privacyOptions: toEnginePrivacyOptions(info.privacyOptionsRequirementStatus),
122
125
  };
123
126
  }
124
127
 
128
+ /**
129
+ * Same rule as the status: an unrecognized value is `unknown`, never a guess in
130
+ * either direction (FR-150).
131
+ */
132
+ function toEnginePrivacyOptions(value: string | undefined): EngineConsentInfo['privacyOptions'] {
133
+ switch (String(value ?? 'UNKNOWN').toUpperCase()) {
134
+ case 'REQUIRED':
135
+ return 'required';
136
+ case 'NOT_REQUIRED':
137
+ return 'not_required';
138
+ default:
139
+ return 'unknown';
140
+ }
141
+ }
142
+
125
143
  function loadProvider(): ProviderModule | null {
126
144
  try {
127
145
  return require('react-native-google-mobile-ads') as ProviderModule;
@@ -362,6 +380,14 @@ function createAdMobConsent(getProvider: () => ProviderModule | null): ConsentPo
362
380
  return toEngineConsentInfo(info);
363
381
  },
364
382
 
383
+ async showPrivacyOptionsForm() {
384
+ // Present in the pinned bridge 16.0.3, so no range change (FR-155).
385
+ // Resolves with the consent information as it stands once the form
386
+ // closes, whether the user changed anything or not.
387
+ const info = await requireConsent().showPrivacyOptionsForm();
388
+ return toEngineConsentInfo(info);
389
+ },
390
+
365
391
  async getInfo() {
366
392
  return toEngineConsentInfo(await requireConsent().getConsentInfo());
367
393
  },
@@ -52,6 +52,8 @@ export interface EngineConsentInfo {
52
52
  readonly status: 'unknown' | 'required' | 'not_required' | 'obtained';
53
53
  readonly canRequestAds: boolean;
54
54
  readonly isFormAvailable: boolean;
55
+ /** Whether the provider requires a "change my choice" entry point (FR-150). */
56
+ readonly privacyOptions: 'unknown' | 'required' | 'not_required';
55
57
  }
56
58
 
57
59
  export interface EngineConsentOptions {
@@ -76,6 +78,13 @@ export interface ConsentPort {
76
78
  requestInfoUpdate(options: EngineConsentOptions): Promise<EngineConsentInfo>;
77
79
  /** Presents the provider's form if one is required; a no-op otherwise. */
78
80
  showFormIfRequired(): Promise<EngineConsentInfo>;
81
+ /**
82
+ * Presents the provider's privacy options form, through which a user changes
83
+ * an answer already given (FR-151). Resolves when the form closes, whether or
84
+ * not anything changed; rejects if it could not be presented. The caller has
85
+ * already checked the requirement, so the adapter does not re-check.
86
+ */
87
+ showPrivacyOptionsForm(): Promise<EngineConsentInfo>;
79
88
  /** Last known information without contacting the network. */
80
89
  getInfo(): Promise<EngineConsentInfo>;
81
90
  /** Clears the stored decision. Inert in production by configuration. */