@chipmobilesdk/rn-ads 0.5.1 → 0.7.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 +178 -0
- package/README.md +12 -3
- package/package.json +5 -2
- package/skills/sdk-rn-ads/SKILL.md +3 -2
- package/src/config/validate.ts +3 -1
- package/src/core/adsInstance.ts +85 -1872
- package/src/core/consentFlow.ts +1 -20
- package/src/core/instance/consent.ts +162 -0
- package/src/core/instance/context.ts +328 -0
- package/src/core/instance/gate.ts +147 -0
- package/src/core/instance/init.ts +262 -0
- package/src/core/instance/internals.ts +138 -0
- package/src/core/instance/load.ts +426 -0
- package/src/core/instance/show.ts +202 -0
- package/src/core/instance/types.ts +198 -0
- package/src/core/requestThrottle.ts +11 -17
- package/src/core/retryPolicy.ts +12 -2
- package/src/core/timers.ts +17 -69
- package/src/diagnostics/errors.ts +52 -63
- package/src/diagnostics/events.ts +77 -66
- package/src/engine/admobBanner.tsx +55 -0
- package/src/engine/admobEngine.ts +38 -18
- package/src/engine/admobErrors.ts +44 -0
- package/src/engine/port.ts +46 -1
- package/src/engine/testAdUnits.ts +0 -5
- package/src/index.ts +19 -1
- package/src/react/AdsBanner.tsx +94 -79
- package/src/react/AdsProvider.tsx +6 -7
- package/src/react/hooks.ts +26 -31
- package/src/testing/clock.ts +75 -0
- package/src/testing/conformance.ts +112 -0
- package/src/testing/index.ts +43 -70
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Initialization, privacy updates and enablement (FR-017 – FR-022, FR-115 –
|
|
3
|
+
* FR-125).
|
|
4
|
+
*
|
|
5
|
+
* One native initialization however many callers race (FR-022), none before
|
|
6
|
+
* the whole gate permits it (FR-017), and a gate that closes and reopens
|
|
7
|
+
* resumes instead of initializing again (FR-154).
|
|
8
|
+
*/
|
|
9
|
+
import { normalizeProviderError } from '../../diagnostics/errors';
|
|
10
|
+
import { canResume, type EnablementResult } from '../enablement';
|
|
11
|
+
import { canStart, initReducer } from '../initMachine';
|
|
12
|
+
import { requiresReinitialization, evaluateGate, toEnginePrivacy } from '../privacyGate';
|
|
13
|
+
import type { Enablement, PrivacyOptions } from '../../config/types';
|
|
14
|
+
import type { InstanceContext } from './context';
|
|
15
|
+
import {
|
|
16
|
+
armUndecidedWarning,
|
|
17
|
+
cancelAllRetries,
|
|
18
|
+
closeGate,
|
|
19
|
+
currentGate,
|
|
20
|
+
disarmUndecidedWarning,
|
|
21
|
+
refusalFor,
|
|
22
|
+
} from './gate';
|
|
23
|
+
import type { InitResult, PrivacyUpdateResult } from './types';
|
|
24
|
+
|
|
25
|
+
export interface InitController {
|
|
26
|
+
initialize(privacy: PrivacyOptions): Promise<InitResult>;
|
|
27
|
+
updatePrivacy(privacy: PrivacyOptions): Promise<PrivacyUpdateResult>;
|
|
28
|
+
setEnablement(next: Enablement): EnablementResult;
|
|
29
|
+
/** Applies a consent outcome to the gate, initializing at most once (FR-105). */
|
|
30
|
+
reevaluateAfterConsent(): Promise<void>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function createInitController(context: InstanceContext): InitController {
|
|
34
|
+
const { state, engine, events, resolved, now } = context;
|
|
35
|
+
|
|
36
|
+
function warnSubstitutionOnce(): void {
|
|
37
|
+
if (state.warnedSubstitution || resolved.catalog.substitutedKeys.length === 0) {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
state.warnedSubstitution = true;
|
|
41
|
+
events.emit({
|
|
42
|
+
type: 'config_warning',
|
|
43
|
+
timestamp: now(),
|
|
44
|
+
warning: 'test_ad_unit_substituted',
|
|
45
|
+
placementKeys: resolved.catalog.substitutedKeys,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function doInitialize(privacy: PrivacyOptions): Promise<InitResult> {
|
|
50
|
+
try {
|
|
51
|
+
// The adapter applies privacy configuration before initializing the
|
|
52
|
+
// provider; that ordering is the whole reason FR-017 is enforceable.
|
|
53
|
+
await engine.initialize(toEnginePrivacy(privacy, resolved.testDeviceIds));
|
|
54
|
+
// Recorded before the status moves, and whether or not it moves: a gate
|
|
55
|
+
// that closed while this was in flight leaves the status `blocked`, but
|
|
56
|
+
// the provider is initialized all the same and must not be again.
|
|
57
|
+
state.providerInitialized = true;
|
|
58
|
+
state.appliedPrivacy = privacy;
|
|
59
|
+
context.setInit(initReducer(state.init, { type: 'SUCCEED' }));
|
|
60
|
+
warnSubstitutionOnce();
|
|
61
|
+
return { status: 'ready' };
|
|
62
|
+
} catch (error) {
|
|
63
|
+
const normalized = normalizeProviderError(error, { operation: 'initialize' });
|
|
64
|
+
context.setInit(initReducer(state.init, { type: 'FAIL', error: normalized }));
|
|
65
|
+
return { status: 'failed', error: normalized };
|
|
66
|
+
} finally {
|
|
67
|
+
state.inFlightInit = null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Answers `initialize` without a native call when the provider is already up
|
|
73
|
+
* (FR-022, FR-154), or returns `null` to let a real initialization proceed.
|
|
74
|
+
* Init-time-only flags that changed meanwhile fall through to a real
|
|
75
|
+
* initialization: it is the only way they can take effect.
|
|
76
|
+
*/
|
|
77
|
+
function resumeInitialized(privacy: PrivacyOptions): InitResult | null {
|
|
78
|
+
if (
|
|
79
|
+
!state.providerInitialized ||
|
|
80
|
+
!canStart(state.init.status) ||
|
|
81
|
+
requiresReinitialization(state.appliedPrivacy, privacy)
|
|
82
|
+
) {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
context.setInit(initReducer(state.init, { type: 'RESUME' }));
|
|
86
|
+
return { status: 'ready' };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const controller: InitController = {
|
|
90
|
+
async initialize(privacy) {
|
|
91
|
+
if (state.disposed) {
|
|
92
|
+
return { status: 'unavailable', reason: 'provider_missing' };
|
|
93
|
+
}
|
|
94
|
+
state.currentPrivacy = privacy;
|
|
95
|
+
|
|
96
|
+
// Availability, then enablement, then consent — the one order, evaluated
|
|
97
|
+
// in the one place (FR-140). Nothing provider-facing happens on any
|
|
98
|
+
// refusal: not a configuration call, not an initialization (FR-017,
|
|
99
|
+
// FR-158). The consent flow opening the gate later initializes, through
|
|
100
|
+
// `reevaluateAfterConsent`.
|
|
101
|
+
const decision = currentGate(context);
|
|
102
|
+
if (!decision.allowed) {
|
|
103
|
+
const refusal = refusalFor(decision);
|
|
104
|
+
switch (refusal.status) {
|
|
105
|
+
case 'unavailable':
|
|
106
|
+
context.setInit(
|
|
107
|
+
initReducer(state.init, { type: 'UNAVAILABLE', reason: refusal.reason }),
|
|
108
|
+
);
|
|
109
|
+
break;
|
|
110
|
+
case 'disabled':
|
|
111
|
+
context.setInit(initReducer(state.init, { type: 'DISABLE' }));
|
|
112
|
+
break;
|
|
113
|
+
case 'undecided':
|
|
114
|
+
// FR-115: the status says `undecided`, so a banner host can hold
|
|
115
|
+
// its space instead of reclaiming it.
|
|
116
|
+
context.setInit(initReducer(state.init, { type: 'UNDECIDE' }));
|
|
117
|
+
armUndecidedWarning(context);
|
|
118
|
+
break;
|
|
119
|
+
default:
|
|
120
|
+
context.setInit(initReducer(state.init, { type: 'BLOCK', reason: refusal.reason }));
|
|
121
|
+
}
|
|
122
|
+
context.emitRefusal('initialize', refusal);
|
|
123
|
+
return refusal;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (state.init.status === 'ready') {
|
|
127
|
+
return { status: 'ready' };
|
|
128
|
+
}
|
|
129
|
+
const resumed = resumeInitialized(privacy);
|
|
130
|
+
if (resumed) {
|
|
131
|
+
return resumed;
|
|
132
|
+
}
|
|
133
|
+
// FR-022, SC-005. Racing callers share one operation.
|
|
134
|
+
if (state.inFlightInit) {
|
|
135
|
+
return state.inFlightInit;
|
|
136
|
+
}
|
|
137
|
+
if (!canStart(state.init.status)) {
|
|
138
|
+
return { status: 'ready' };
|
|
139
|
+
}
|
|
140
|
+
context.setInit(initReducer(state.init, { type: 'START' }));
|
|
141
|
+
state.inFlightInit = doInitialize(privacy);
|
|
142
|
+
return state.inFlightInit;
|
|
143
|
+
},
|
|
144
|
+
|
|
145
|
+
async updatePrivacy(privacy) {
|
|
146
|
+
if (state.disposed) {
|
|
147
|
+
// The same answer initialize and load give a disposed instance.
|
|
148
|
+
return { status: 'unavailable', reason: 'provider_missing' };
|
|
149
|
+
}
|
|
150
|
+
if (state.enablement === 'undecided') {
|
|
151
|
+
return { status: 'undecided' };
|
|
152
|
+
}
|
|
153
|
+
if (state.enablement === 'disabled') {
|
|
154
|
+
return { status: 'disabled' };
|
|
155
|
+
}
|
|
156
|
+
if (!engine.isAvailable()) {
|
|
157
|
+
return { status: 'unavailable', reason: 'unsupported_platform' };
|
|
158
|
+
}
|
|
159
|
+
// FR-020: a flag the provider only honors at initialization cannot be
|
|
160
|
+
// applied now. Say so, rather than accepting it and running half-old.
|
|
161
|
+
if (requiresReinitialization(state.appliedPrivacy, privacy)) {
|
|
162
|
+
return { status: 'requires_reinitialization' };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
state.currentPrivacy = privacy;
|
|
166
|
+
const gate = evaluateGate(privacy);
|
|
167
|
+
if (!gate.allowed) {
|
|
168
|
+
// FR-017, FR-137, FR-153: the same teardown as a consent withdrawal.
|
|
169
|
+
closeGate(context, { status: 'blocked', reason: gate.reason });
|
|
170
|
+
return { status: 'blocked', reason: gate.reason };
|
|
171
|
+
}
|
|
172
|
+
// FR-019: resolving consent after a block must not initialize twice.
|
|
173
|
+
if (state.init.status === 'ready') {
|
|
174
|
+
return { status: 'applied' };
|
|
175
|
+
}
|
|
176
|
+
// Exhaustive on purpose (ADS-04): a new InitResult member fails to
|
|
177
|
+
// compile here instead of falling through to `applied`.
|
|
178
|
+
const result = await controller.initialize(privacy);
|
|
179
|
+
switch (result.status) {
|
|
180
|
+
case 'ready':
|
|
181
|
+
return { status: 'applied' };
|
|
182
|
+
case 'blocked':
|
|
183
|
+
case 'disabled':
|
|
184
|
+
case 'undecided':
|
|
185
|
+
case 'unavailable':
|
|
186
|
+
case 'failed':
|
|
187
|
+
return result;
|
|
188
|
+
default: {
|
|
189
|
+
const unreachable: never = result;
|
|
190
|
+
return unreachable;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
},
|
|
194
|
+
|
|
195
|
+
setEnablement(next) {
|
|
196
|
+
if (state.disposed) {
|
|
197
|
+
return { status: 'unavailable', reason: 'provider_missing' };
|
|
198
|
+
}
|
|
199
|
+
if (state.enablement === next) {
|
|
200
|
+
return { status: 'unchanged' };
|
|
201
|
+
}
|
|
202
|
+
const previous = state.enablement;
|
|
203
|
+
state.enablement = next;
|
|
204
|
+
|
|
205
|
+
if (next === 'disabled' || next === 'undecided') {
|
|
206
|
+
// FR-037, FR-125: new work stops and in-flight resources are released;
|
|
207
|
+
// an ad already on screen is left to finish.
|
|
208
|
+
closeGate(context, { status: next });
|
|
209
|
+
if (next === 'undecided') {
|
|
210
|
+
armUndecidedWarning(context);
|
|
211
|
+
return { status: 'undecided' };
|
|
212
|
+
}
|
|
213
|
+
disarmUndecidedWarning(context);
|
|
214
|
+
return { status: 'disabled' };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Turning ads on. 0.1.0 dropped the status to `not_started` and returned
|
|
218
|
+
// nothing, so every later load was refused in silence (FR-121).
|
|
219
|
+
disarmUndecidedWarning(context);
|
|
220
|
+
const resumable = canResume(state.init.resumeTo);
|
|
221
|
+
context.setInit(initReducer(state.init, { type: 'ENABLE' }));
|
|
222
|
+
const { init } = state;
|
|
223
|
+
if (init.status === 'unavailable') {
|
|
224
|
+
return { status: 'unavailable', reason: init.unavailableReason ?? 'unsupported_platform' };
|
|
225
|
+
}
|
|
226
|
+
// The provider was never un-initialized, and FR-022 forbids doing it twice.
|
|
227
|
+
if (init.status === 'ready' && (resumable || previous === 'undecided')) {
|
|
228
|
+
return { status: 'resumed', initStatus: init.status };
|
|
229
|
+
}
|
|
230
|
+
// Said out loud: the package will not hide an asynchronous, billable
|
|
231
|
+
// initialization behind a setter the caller may not be awaiting.
|
|
232
|
+
return { status: 'requires_initialization' };
|
|
233
|
+
},
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* When consent opens the gate and the instance was blocked, it initializes —
|
|
237
|
+
* once, because `initialize` shares one in-flight operation (FR-022,
|
|
238
|
+
* SC-102). When consent closes it, requests stop, pending retries are
|
|
239
|
+
* cancelled (FR-137), and a `ready` instance moves to `blocked` so a banner
|
|
240
|
+
* host keyed on `ready` stops requesting (FR-153).
|
|
241
|
+
*/
|
|
242
|
+
async reevaluateAfterConsent() {
|
|
243
|
+
if (state.disposed || !state.currentPrivacy) {
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
const decision = currentGate(context);
|
|
247
|
+
if (!decision.allowed) {
|
|
248
|
+
if (decision.kind === 'blocked') {
|
|
249
|
+
closeGate(context, { status: 'blocked', reason: decision.reason });
|
|
250
|
+
} else {
|
|
251
|
+
cancelAllRetries(context);
|
|
252
|
+
}
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
if (state.init.status === 'ready' || state.init.status === 'initializing') {
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
await controller.initialize(state.currentPrivacy);
|
|
259
|
+
},
|
|
260
|
+
};
|
|
261
|
+
return controller;
|
|
262
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What `AdsBanner` needs from an instance, held off the public type (ADS-13).
|
|
3
|
+
*
|
|
4
|
+
* 0.5.0 exposed this as `AdsInstance.internals`, so any consumer could emit an
|
|
5
|
+
* `impression` or a `reward_earned` into the stream used for revenue
|
|
6
|
+
* reconciliation, and every change here was a breaking type change. It now
|
|
7
|
+
* lives in a WeakMap keyed by the instance: the banner host looks it up, and an
|
|
8
|
+
* instance an app wrote by hand simply has none.
|
|
9
|
+
*/
|
|
10
|
+
import type { PlacementState } from '../../config/types';
|
|
11
|
+
import { AdsError } from '../../diagnostics/errors';
|
|
12
|
+
import type { AdEvent } from '../../diagnostics/events';
|
|
13
|
+
import type { EngineBanner } from '../../engine/port';
|
|
14
|
+
import { permitsRequests } from '../enablement';
|
|
15
|
+
import { canRequest } from '../initMachine';
|
|
16
|
+
import { toEngineRequest } from '../privacyGate';
|
|
17
|
+
import type { InstanceContext } from './context';
|
|
18
|
+
import { gateRefusal } from './gate';
|
|
19
|
+
import type { AdsInstance, LoadResult, ThrottledResult } from './types';
|
|
20
|
+
|
|
21
|
+
export interface InstanceInternals {
|
|
22
|
+
/**
|
|
23
|
+
* Whether a banner for this placement may request now, and with what. Pure:
|
|
24
|
+
* it mints nothing, so the host can call it during render (ADS-20).
|
|
25
|
+
*/
|
|
26
|
+
resolveBanner(placementKey: string):
|
|
27
|
+
| {
|
|
28
|
+
readonly ok: true;
|
|
29
|
+
readonly adUnitId: string;
|
|
30
|
+
/** Current at resolution time, so a banner request honors `updatePrivacy`. */
|
|
31
|
+
readonly personalizedAds: boolean;
|
|
32
|
+
}
|
|
33
|
+
| { readonly ok: false; readonly result: LoadResult };
|
|
34
|
+
/**
|
|
35
|
+
* Takes the throttle slot for a banner mount (FR-140).
|
|
36
|
+
*
|
|
37
|
+
* Called during render, immediately before the provider component mounts:
|
|
38
|
+
* that component requests as part of the same render, so an effect would be
|
|
39
|
+
* too late. Idempotent for one `token`, so a render that runs twice charges
|
|
40
|
+
* one slot. Emits nothing and sets no state; the host reports a refusal from
|
|
41
|
+
* its effect.
|
|
42
|
+
*/
|
|
43
|
+
commitBannerRequest(placementKey: string, token: string): ThrottledResult | null;
|
|
44
|
+
/** The configured floor for a placement, so the host can report the cause. */
|
|
45
|
+
throttleFloorFor(placementKey: string): number;
|
|
46
|
+
/** The engine's banner when it can render here, or null (ADS-12). */
|
|
47
|
+
banner(): EngineBanner | null;
|
|
48
|
+
emit(event: AdEvent): void;
|
|
49
|
+
setPlacementState(key: string, state: PlacementState): void;
|
|
50
|
+
isReady(): boolean;
|
|
51
|
+
nextCorrelationId(): string;
|
|
52
|
+
/** The instance's clock, so banner events and full-screen events agree (ADS-20). */
|
|
53
|
+
now(): number;
|
|
54
|
+
/**
|
|
55
|
+
* Runs `task` after `delayMs` on the instance's timers, so a test clock drives
|
|
56
|
+
* it and dispose() cancels it. Returns a cancel.
|
|
57
|
+
*/
|
|
58
|
+
schedule(delayMs: number, task: () => void): () => void;
|
|
59
|
+
timerCount(): number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const registry = new WeakMap<AdsInstance, InstanceInternals>();
|
|
63
|
+
|
|
64
|
+
/** The banner host's view of an instance `createAds` made; null for any other. */
|
|
65
|
+
export function getInternals(instance: AdsInstance): InstanceInternals | null {
|
|
66
|
+
return registry.get(instance) ?? null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function attachInternals(instance: AdsInstance, internals: InstanceInternals): void {
|
|
70
|
+
registry.set(instance, internals);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function createInternals(context: InstanceContext): InstanceInternals {
|
|
74
|
+
const { state, engine, resolved, events, timers, requestThrottle, now } = context;
|
|
75
|
+
return {
|
|
76
|
+
resolveBanner(placementKey) {
|
|
77
|
+
const definition = resolved.placements.get(placementKey);
|
|
78
|
+
if (!definition || definition.format !== 'banner') {
|
|
79
|
+
const code = definition ? 'format_mismatch' : 'placement_not_found';
|
|
80
|
+
return {
|
|
81
|
+
ok: false,
|
|
82
|
+
result: {
|
|
83
|
+
status: 'failed',
|
|
84
|
+
error: new AdsError(code, {
|
|
85
|
+
operation: 'load',
|
|
86
|
+
placementKey,
|
|
87
|
+
format: definition?.format,
|
|
88
|
+
message: definition
|
|
89
|
+
? `"${placementKey}" is not a banner placement`
|
|
90
|
+
: `unknown placement "${placementKey}"`,
|
|
91
|
+
}),
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
const refusal = gateRefusal(context);
|
|
96
|
+
if (refusal) {
|
|
97
|
+
return { ok: false, result: refusal };
|
|
98
|
+
}
|
|
99
|
+
if (!canRequest(state.init.status)) {
|
|
100
|
+
return { ok: false, result: { status: 'blocked', reason: 'consent_required' } };
|
|
101
|
+
}
|
|
102
|
+
// The throttle is deliberately not consulted here: a fresh mount must be
|
|
103
|
+
// throttled while a width re-adaptation of a mounted banner must not be,
|
|
104
|
+
// and only the host knows which it is (FR-140).
|
|
105
|
+
return {
|
|
106
|
+
ok: true,
|
|
107
|
+
adUnitId: resolved.catalog.byKey.get(placementKey) ?? '',
|
|
108
|
+
personalizedAds: toEngineRequest(state.currentPrivacy).personalizedAds,
|
|
109
|
+
};
|
|
110
|
+
},
|
|
111
|
+
commitBannerRequest(placementKey, token) {
|
|
112
|
+
if (!resolved.placements.has(placementKey)) {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
const decision = requestThrottle.tryAcquire(
|
|
116
|
+
placementKey,
|
|
117
|
+
resolved.minRequestIntervalByKey.get(placementKey) ?? 0,
|
|
118
|
+
token,
|
|
119
|
+
);
|
|
120
|
+
return decision.allowed ? null : { status: 'throttled', retryAfterMs: decision.retryAfterMs };
|
|
121
|
+
},
|
|
122
|
+
throttleFloorFor: placementKey => resolved.minRequestIntervalByKey.get(placementKey) ?? 0,
|
|
123
|
+
banner: () => (engine.banner?.isAvailable() ? engine.banner : null),
|
|
124
|
+
emit: event => events.emit(event),
|
|
125
|
+
setPlacementState(key, next) {
|
|
126
|
+
const runtime = context.placements.get(key);
|
|
127
|
+
if (runtime) {
|
|
128
|
+
context.setPlacement(key, { ...runtime.snapshot, state: next });
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
isReady: () =>
|
|
132
|
+
permitsRequests(state.enablement) && engine.isAvailable() && canRequest(state.init.status),
|
|
133
|
+
nextCorrelationId: () => context.correlation.next(),
|
|
134
|
+
now,
|
|
135
|
+
schedule: (delayMs, task) => timers.after(delayMs, task),
|
|
136
|
+
timerCount: () => timers.size(),
|
|
137
|
+
};
|
|
138
|
+
}
|