@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.
- package/CHANGELOG.md +48 -1
- package/README.md +112 -4
- package/package.json +1 -1
- package/skills/sdk-rn-ads/SKILL.md +42 -8
- package/src/config/types.ts +18 -0
- package/src/core/adsInstance.ts +240 -20
- package/src/core/consentFlow.ts +14 -1
- package/src/core/initMachine.ts +13 -0
- package/src/engine/admobEngine.ts +26 -0
- package/src/engine/port.ts +9 -0
- package/src/index.ts +103 -101
- package/src/testing/index.ts +101 -2
package/src/core/adsInstance.ts
CHANGED
|
@@ -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'
|
|
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
|
-
|
|
980
|
-
|
|
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
|
-
|
|
984
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
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
|
|
1856
|
+
for (const [key, runtime] of placements) {
|
|
1857
|
+
const abort = runtime.abortLoad;
|
|
1642
1858
|
releasePlacementResources(key);
|
|
1643
|
-
|
|
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();
|
package/src/core/consentFlow.ts
CHANGED
|
@@ -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 {
|
|
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
|
};
|
package/src/core/initMachine.ts
CHANGED
|
@@ -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
|
},
|
package/src/engine/port.ts
CHANGED
|
@@ -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. */
|