@chipmobilesdk/rn-ads 0.1.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 +46 -0
- package/README.md +372 -0
- package/package.json +49 -0
- package/skills/sdk-rn-ads/SKILL.md +98 -0
- package/src/config/resolveAdUnit.ts +85 -0
- package/src/config/types.ts +119 -0
- package/src/config/validate.ts +112 -0
- package/src/core/adsInstance.ts +1031 -0
- package/src/core/fullScreenLock.ts +54 -0
- package/src/core/initMachine.ts +108 -0
- package/src/core/placementMachine.ts +114 -0
- package/src/core/privacyGate.ts +85 -0
- package/src/core/rewardedSession.ts +84 -0
- package/src/core/timers.ts +109 -0
- package/src/diagnostics/correlation.ts +31 -0
- package/src/diagnostics/errors.ts +210 -0
- package/src/diagnostics/events.ts +179 -0
- package/src/engine/admobEngine.ts +231 -0
- package/src/engine/port.ts +72 -0
- package/src/engine/testAdUnits.ts +58 -0
- package/src/index.ts +69 -0
- package/src/react/AdsBanner.tsx +270 -0
- package/src/react/AdsProvider.tsx +57 -0
- package/src/react/hooks.ts +75 -0
- package/src/testing/index.ts +206 -0
|
@@ -0,0 +1,1031 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ads instance — the public contract's implementation.
|
|
3
|
+
*
|
|
4
|
+
* Every guarantee the specification treats as a rule rather than a convention
|
|
5
|
+
* converges here, and none of it needs a device to prove:
|
|
6
|
+
*
|
|
7
|
+
* FR-022 one native initialization, however many callers race
|
|
8
|
+
* FR-017 no request before the privacy gate permits one
|
|
9
|
+
* FR-011 no production ad unit outside production
|
|
10
|
+
* FR-029 one in-flight provider request per placement
|
|
11
|
+
* FR-030 exactly one full-screen ad, never a queue
|
|
12
|
+
* FR-031 a consumed ad is never reused
|
|
13
|
+
* FR-035 after dispose, a late callback updates nothing and emits nothing
|
|
14
|
+
*
|
|
15
|
+
* Nothing in this file imports the provider. It talks to the engine port, which
|
|
16
|
+
* the caller injects — the AdMob adapter in production, the fake in tests.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
isFullScreenFormat,
|
|
21
|
+
type AdFormat,
|
|
22
|
+
type AdsConfig,
|
|
23
|
+
type FullScreenFormat,
|
|
24
|
+
type InitStatus,
|
|
25
|
+
type PlacementDefinition,
|
|
26
|
+
type PlacementState,
|
|
27
|
+
type PrivacyBlockReason,
|
|
28
|
+
type PrivacyOptions,
|
|
29
|
+
type UnavailableReason,
|
|
30
|
+
type Unsubscribe,
|
|
31
|
+
} from '../config/types';
|
|
32
|
+
import { validateConfig, type ResolvedConfig } from '../config/validate';
|
|
33
|
+
import { AdsError, normalizeProviderError } from '../diagnostics/errors';
|
|
34
|
+
import {
|
|
35
|
+
createEventEmitter,
|
|
36
|
+
type AdEvent,
|
|
37
|
+
type AdEventListener,
|
|
38
|
+
type ImpressionSource,
|
|
39
|
+
} from '../diagnostics/events';
|
|
40
|
+
import { createCorrelationIssuer } from '../diagnostics/correlation';
|
|
41
|
+
import type { AdsEngine, EngineAdEvent, EngineAdHandle } from '../engine/port';
|
|
42
|
+
import { createFullScreenLock } from './fullScreenLock';
|
|
43
|
+
import { canRequest, canStart, initialInit, initReducer, type InitSnapshot } from './initMachine';
|
|
44
|
+
import {
|
|
45
|
+
canLoad,
|
|
46
|
+
initialPlacement,
|
|
47
|
+
placementReducer,
|
|
48
|
+
type PlacementSnapshot,
|
|
49
|
+
} from './placementMachine';
|
|
50
|
+
import { evaluateGate, requiresReinitialization, toEnginePrivacy } from './privacyGate';
|
|
51
|
+
import { createRewardedSession, type RewardedSession } from './rewardedSession';
|
|
52
|
+
import { createTimerSet, isStale } from './timers';
|
|
53
|
+
|
|
54
|
+
export type InitResult =
|
|
55
|
+
| { readonly status: 'ready' }
|
|
56
|
+
| { readonly status: 'blocked'; readonly reason: PrivacyBlockReason }
|
|
57
|
+
| { readonly status: 'disabled' }
|
|
58
|
+
| { readonly status: 'unavailable'; readonly reason: UnavailableReason }
|
|
59
|
+
| { readonly status: 'failed'; readonly error: AdsError };
|
|
60
|
+
|
|
61
|
+
export type PrivacyUpdateResult =
|
|
62
|
+
| { readonly status: 'applied' }
|
|
63
|
+
| { readonly status: 'blocked'; readonly reason: PrivacyBlockReason }
|
|
64
|
+
| { readonly status: 'requires_reinitialization' }
|
|
65
|
+
| { readonly status: 'disabled' }
|
|
66
|
+
| { readonly status: 'unavailable'; readonly reason: UnavailableReason };
|
|
67
|
+
|
|
68
|
+
export type LoadResult =
|
|
69
|
+
| { readonly status: 'loaded'; readonly correlationId: string }
|
|
70
|
+
| { readonly status: 'disabled' }
|
|
71
|
+
| { readonly status: 'blocked'; readonly reason: PrivacyBlockReason }
|
|
72
|
+
| { readonly status: 'unavailable'; readonly reason: UnavailableReason }
|
|
73
|
+
| { readonly status: 'no_fill' }
|
|
74
|
+
| { readonly status: 'timeout' }
|
|
75
|
+
| { readonly status: 'failed'; readonly error: AdsError };
|
|
76
|
+
|
|
77
|
+
export type ShowResult =
|
|
78
|
+
| { readonly status: 'shown'; readonly correlationId: string }
|
|
79
|
+
| { readonly status: 'not_ready' }
|
|
80
|
+
| { readonly status: 'busy' }
|
|
81
|
+
| { readonly status: 'consumed' }
|
|
82
|
+
| { readonly status: 'app_inactive' }
|
|
83
|
+
| { readonly status: 'disabled' }
|
|
84
|
+
| { readonly status: 'blocked'; readonly reason: PrivacyBlockReason }
|
|
85
|
+
| { readonly status: 'unavailable'; readonly reason: UnavailableReason }
|
|
86
|
+
| { readonly status: 'timeout' }
|
|
87
|
+
| { readonly status: 'failed'; readonly error: AdsError };
|
|
88
|
+
|
|
89
|
+
export interface AdsInstance {
|
|
90
|
+
initialize(privacy: PrivacyOptions): Promise<InitResult>;
|
|
91
|
+
updatePrivacy(privacy: PrivacyOptions): Promise<PrivacyUpdateResult>;
|
|
92
|
+
setEnabled(enabled: boolean): void;
|
|
93
|
+
setAppActive(active: boolean): void;
|
|
94
|
+
getStatus(): InitStatus;
|
|
95
|
+
subscribeStatus(listener: (status: InitStatus) => void): Unsubscribe;
|
|
96
|
+
|
|
97
|
+
load(placementKey: string): Promise<LoadResult>;
|
|
98
|
+
show(placementKey: string): Promise<ShowResult>;
|
|
99
|
+
getPlacementState(placementKey: string): PlacementState;
|
|
100
|
+
subscribePlacement(key: string, listener: (state: PlacementState) => void): Unsubscribe;
|
|
101
|
+
|
|
102
|
+
subscribeEvents(listener: AdEventListener): Unsubscribe;
|
|
103
|
+
|
|
104
|
+
/** FR-011a — substitution is observable state, not only a log line. */
|
|
105
|
+
getSubstitutedPlacements(): readonly string[];
|
|
106
|
+
/** Internal wiring for the banner host; not part of the documented surface. */
|
|
107
|
+
readonly internals: InstanceInternals;
|
|
108
|
+
|
|
109
|
+
dispose(): void;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** What `AdsBanner` needs. Deliberately not documented as consumer API. */
|
|
113
|
+
export interface InstanceInternals {
|
|
114
|
+
resolveBanner(placementKey: string):
|
|
115
|
+
| { readonly ok: true; readonly adUnitId: string; readonly correlationId: string }
|
|
116
|
+
| { readonly ok: false; readonly result: LoadResult };
|
|
117
|
+
emit(event: AdEvent): void;
|
|
118
|
+
setPlacementState(key: string, state: PlacementState): void;
|
|
119
|
+
isReady(): boolean;
|
|
120
|
+
nextCorrelationId(): string;
|
|
121
|
+
timerCount(): number;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export interface CreateAdsOptions {
|
|
125
|
+
/** Injected in tests; the AdMob adapter is the default in production. */
|
|
126
|
+
readonly engine?: AdsEngine;
|
|
127
|
+
/** Injected so the FR-012 rule is testable without touching a global. */
|
|
128
|
+
readonly isDevBuild?: boolean;
|
|
129
|
+
readonly now?: () => number;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
interface PlacementRuntime {
|
|
133
|
+
snapshot: PlacementSnapshot;
|
|
134
|
+
/**
|
|
135
|
+
* The cycle's correlation id, held here rather than read from the snapshot:
|
|
136
|
+
* the reducer resets the snapshot on dismissal, and events emitted around
|
|
137
|
+
* that transition must still carry the id of the cycle they belong to.
|
|
138
|
+
*/
|
|
139
|
+
correlationId: string | null;
|
|
140
|
+
/**
|
|
141
|
+
* Whether this cycle already reported a dismissal. Format-independent — the
|
|
142
|
+
* rewarded session guards its own, but interstitials need the same guard and
|
|
143
|
+
* would otherwise report a repeated provider callback twice.
|
|
144
|
+
*/
|
|
145
|
+
dismissed: boolean;
|
|
146
|
+
handle: EngineAdHandle | null;
|
|
147
|
+
unsubscribeEngine: Unsubscribe | null;
|
|
148
|
+
inFlightLoad: Promise<LoadResult> | null;
|
|
149
|
+
cancelLoadTimer: Unsubscribe | null;
|
|
150
|
+
cancelShowTimer: Unsubscribe | null;
|
|
151
|
+
rewarded: RewardedSession | null;
|
|
152
|
+
listeners: Set<(state: PlacementState) => void>;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function detectDevBuild(): boolean {
|
|
156
|
+
return typeof __DEV__ !== 'undefined' ? Boolean(__DEV__) : false;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): AdsInstance {
|
|
160
|
+
const isDevBuild = options.isDevBuild ?? detectDevBuild();
|
|
161
|
+
const now = options.now ?? (() => Date.now());
|
|
162
|
+
|
|
163
|
+
// Throws on a structural fault or the FR-012 fail-closed case. Deliberately
|
|
164
|
+
// before anything else: a misconfigured instance should not exist at all.
|
|
165
|
+
const resolved: ResolvedConfig = validateConfig(config, { isDevBuild });
|
|
166
|
+
|
|
167
|
+
const engine: AdsEngine = options.engine ?? createDefaultEngine();
|
|
168
|
+
const events = createEventEmitter();
|
|
169
|
+
const correlation = createCorrelationIssuer();
|
|
170
|
+
const timers = createTimerSet();
|
|
171
|
+
const lock = createFullScreenLock();
|
|
172
|
+
|
|
173
|
+
let init: InitSnapshot = initialInit;
|
|
174
|
+
let enabled = resolved.enabled;
|
|
175
|
+
let appActive = true;
|
|
176
|
+
let appliedPrivacy: PrivacyOptions | null = null;
|
|
177
|
+
let currentPrivacy: PrivacyOptions | null = null;
|
|
178
|
+
let inFlightInit: Promise<InitResult> | null = null;
|
|
179
|
+
let disposed = false;
|
|
180
|
+
let warnedSubstitution = false;
|
|
181
|
+
|
|
182
|
+
const statusListeners = new Set<(status: InitStatus) => void>();
|
|
183
|
+
const placements = new Map<string, PlacementRuntime>();
|
|
184
|
+
|
|
185
|
+
for (const key of resolved.placements.keys()) {
|
|
186
|
+
placements.set(key, {
|
|
187
|
+
snapshot: initialPlacement,
|
|
188
|
+
correlationId: null,
|
|
189
|
+
dismissed: false,
|
|
190
|
+
handle: null,
|
|
191
|
+
unsubscribeEngine: null,
|
|
192
|
+
inFlightLoad: null,
|
|
193
|
+
cancelLoadTimer: null,
|
|
194
|
+
cancelShowTimer: null,
|
|
195
|
+
rewarded: null,
|
|
196
|
+
listeners: new Set(),
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function setInit(next: InitSnapshot): void {
|
|
201
|
+
if (next === init || next.status === init.status) {
|
|
202
|
+
init = next;
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
init = next;
|
|
206
|
+
events.emit({
|
|
207
|
+
type: 'initialization',
|
|
208
|
+
timestamp: now(),
|
|
209
|
+
status: next.status,
|
|
210
|
+
retryable: next.error?.retryable,
|
|
211
|
+
});
|
|
212
|
+
for (const listener of Array.from(statusListeners)) {
|
|
213
|
+
try {
|
|
214
|
+
listener(next.status);
|
|
215
|
+
} catch {
|
|
216
|
+
// A status listener is consumer code; its failure is isolated the
|
|
217
|
+
// same way an event listener's is (FR-056).
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function setPlacement(key: string, next: PlacementSnapshot): void {
|
|
223
|
+
const runtime = placements.get(key);
|
|
224
|
+
if (!runtime || runtime.snapshot.state === next.state) {
|
|
225
|
+
if (runtime) {
|
|
226
|
+
runtime.snapshot = next;
|
|
227
|
+
}
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
runtime.snapshot = next;
|
|
231
|
+
for (const listener of Array.from(runtime.listeners)) {
|
|
232
|
+
try {
|
|
233
|
+
listener(next.state);
|
|
234
|
+
} catch {
|
|
235
|
+
// Isolated, as above.
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function dispatchPlacement(key: string, event: Parameters<typeof placementReducer>[1]): void {
|
|
241
|
+
const runtime = placements.get(key);
|
|
242
|
+
const definition = resolved.placements.get(key);
|
|
243
|
+
if (!runtime || !definition) {
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
setPlacement(key, placementReducer(runtime.snapshot, event, definition.format));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Every refusal that applies before a placement is even looked up. */
|
|
250
|
+
function gateCheck():
|
|
251
|
+
| { readonly ok: true }
|
|
252
|
+
| { readonly ok: false; readonly result: Extract<LoadResult, { status: 'disabled' | 'blocked' | 'unavailable' }> } {
|
|
253
|
+
if (!enabled) {
|
|
254
|
+
return { ok: false, result: { status: 'disabled' } };
|
|
255
|
+
}
|
|
256
|
+
if (!engine.isAvailable()) {
|
|
257
|
+
return {
|
|
258
|
+
ok: false,
|
|
259
|
+
result: { status: 'unavailable', reason: 'unsupported_platform' },
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
const gate = evaluateGate(currentPrivacy);
|
|
263
|
+
if (!gate.allowed) {
|
|
264
|
+
return { ok: false, result: { status: 'blocked', reason: gate.reason } };
|
|
265
|
+
}
|
|
266
|
+
return { ok: true };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function emitRefusal(
|
|
270
|
+
operation: 'initialize' | 'load' | 'show',
|
|
271
|
+
result: { readonly status: string; readonly reason?: string },
|
|
272
|
+
placementKey?: string,
|
|
273
|
+
format?: AdFormat,
|
|
274
|
+
): void {
|
|
275
|
+
if (result.status === 'disabled') {
|
|
276
|
+
events.emit({ type: 'disabled', timestamp: now(), operation, placementKey, format });
|
|
277
|
+
} else if (result.status === 'unavailable' || result.status === 'blocked') {
|
|
278
|
+
events.emit({
|
|
279
|
+
type: 'unavailable',
|
|
280
|
+
timestamp: now(),
|
|
281
|
+
operation,
|
|
282
|
+
placementKey,
|
|
283
|
+
format,
|
|
284
|
+
reason: (result.reason ?? 'unsupported_platform') as UnavailableReason | PrivacyBlockReason,
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function warnSubstitutionOnce(): void {
|
|
290
|
+
if (warnedSubstitution || resolved.catalog.substitutedKeys.length === 0) {
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
warnedSubstitution = true;
|
|
294
|
+
events.emit({
|
|
295
|
+
type: 'config_warning',
|
|
296
|
+
timestamp: now(),
|
|
297
|
+
warning: 'test_ad_unit_substituted',
|
|
298
|
+
placementKeys: resolved.catalog.substitutedKeys,
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function releasePlacementResources(key: string): void {
|
|
303
|
+
const runtime = placements.get(key);
|
|
304
|
+
if (!runtime) {
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
runtime.cancelLoadTimer?.();
|
|
308
|
+
runtime.cancelLoadTimer = null;
|
|
309
|
+
runtime.cancelShowTimer?.();
|
|
310
|
+
runtime.cancelShowTimer = null;
|
|
311
|
+
runtime.unsubscribeEngine?.();
|
|
312
|
+
runtime.unsubscribeEngine = null;
|
|
313
|
+
if (runtime.handle) {
|
|
314
|
+
engine.dispose(runtime.handle);
|
|
315
|
+
runtime.handle = null;
|
|
316
|
+
}
|
|
317
|
+
runtime.inFlightLoad = null;
|
|
318
|
+
runtime.rewarded = null;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async function doInitialize(privacy: PrivacyOptions): Promise<InitResult> {
|
|
322
|
+
const enginePrivacy = toEnginePrivacy(privacy, resolved.testDeviceIds);
|
|
323
|
+
try {
|
|
324
|
+
// The adapter applies privacy configuration before initializing the
|
|
325
|
+
// provider; that ordering is the whole reason FR-017 is enforceable.
|
|
326
|
+
await engine.initialize(enginePrivacy);
|
|
327
|
+
appliedPrivacy = privacy;
|
|
328
|
+
setInit(initReducer(init, { type: 'SUCCEED' }));
|
|
329
|
+
warnSubstitutionOnce();
|
|
330
|
+
return { status: 'ready' };
|
|
331
|
+
} catch (error) {
|
|
332
|
+
const normalized = normalizeProviderError(error, { operation: 'initialize' });
|
|
333
|
+
setInit(initReducer(init, { type: 'FAIL', error: normalized }));
|
|
334
|
+
return { status: 'failed', error: normalized };
|
|
335
|
+
} finally {
|
|
336
|
+
inFlightInit = null;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function handleEngineEvent(
|
|
341
|
+
key: string,
|
|
342
|
+
definition: PlacementDefinition,
|
|
343
|
+
event: EngineAdEvent,
|
|
344
|
+
): void {
|
|
345
|
+
// After dispose nothing is honored: a late provider callback must not
|
|
346
|
+
// update a consumer that has gone away (FR-035, SC-008).
|
|
347
|
+
if (disposed) {
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
const runtime = placements.get(key);
|
|
351
|
+
if (!runtime) {
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
const correlationId = runtime.correlationId ?? undefined;
|
|
355
|
+
const base = { timestamp: now(), placementKey: key, format: definition.format, correlationId };
|
|
356
|
+
|
|
357
|
+
switch (event.type) {
|
|
358
|
+
case 'opened': {
|
|
359
|
+
events.emit({ ...base, type: 'display_started' });
|
|
360
|
+
// R5: the provider exposes no impression callback for full-screen
|
|
361
|
+
// formats, so this one is derived from the ad opening. Labelled
|
|
362
|
+
// honestly rather than passed off as the provider's own.
|
|
363
|
+
events.emit({ ...base, type: 'impression', source: 'derived' as ImpressionSource });
|
|
364
|
+
break;
|
|
365
|
+
}
|
|
366
|
+
case 'clicked':
|
|
367
|
+
events.emit({ ...base, type: 'click' });
|
|
368
|
+
break;
|
|
369
|
+
case 'rewarded': {
|
|
370
|
+
const first = runtime.rewarded?.confirmReward(event.reward) ?? null;
|
|
371
|
+
if (first) {
|
|
372
|
+
events.emit({ ...base, type: 'reward_earned', reward: first });
|
|
373
|
+
}
|
|
374
|
+
break;
|
|
375
|
+
}
|
|
376
|
+
case 'dismissed': {
|
|
377
|
+
const rewardedFirst = runtime.rewarded ? runtime.rewarded.dismiss() : true;
|
|
378
|
+
if (runtime.dismissed || !rewardedFirst) {
|
|
379
|
+
break;
|
|
380
|
+
}
|
|
381
|
+
runtime.dismissed = true;
|
|
382
|
+
lock.release(key);
|
|
383
|
+
runtime.cancelShowTimer?.();
|
|
384
|
+
runtime.cancelShowTimer = null;
|
|
385
|
+
events.emit({
|
|
386
|
+
...base,
|
|
387
|
+
type: 'dismissed',
|
|
388
|
+
rewardEarned: runtime.rewarded ? runtime.rewarded.rewardEarned : undefined,
|
|
389
|
+
});
|
|
390
|
+
dispatchPlacement(key, { type: 'DISMISSED' });
|
|
391
|
+
// Deliberately NOT released here. FR-049 requires the reward and
|
|
392
|
+
// the dismissal to be handled in either order, and a provider that
|
|
393
|
+
// confirms the reward *after* closing the ad would otherwise have
|
|
394
|
+
// its confirmation dropped by a torn-down listener — silently
|
|
395
|
+
// costing the user a reward they earned.
|
|
396
|
+
//
|
|
397
|
+
// This is a retained resource, not a leak: at most one handle and
|
|
398
|
+
// one listener per placement, released deterministically by the
|
|
399
|
+
// next load of that placement or by dispose().
|
|
400
|
+
break;
|
|
401
|
+
}
|
|
402
|
+
case 'failed': {
|
|
403
|
+
const normalized = normalizeProviderError(event.cause, {
|
|
404
|
+
operation: 'show',
|
|
405
|
+
placementKey: key,
|
|
406
|
+
format: definition.format,
|
|
407
|
+
});
|
|
408
|
+
lock.release(key);
|
|
409
|
+
events.emit({ ...base, type: 'display_failed', error: normalized });
|
|
410
|
+
dispatchPlacement(key, { type: 'SHOW_FAILED', error: normalized });
|
|
411
|
+
releasePlacementResources(key);
|
|
412
|
+
break;
|
|
413
|
+
}
|
|
414
|
+
default:
|
|
415
|
+
break;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function loadFullScreen(
|
|
420
|
+
key: string,
|
|
421
|
+
definition: PlacementDefinition,
|
|
422
|
+
adUnitId: string,
|
|
423
|
+
correlationId: string,
|
|
424
|
+
): Promise<LoadResult> {
|
|
425
|
+
const runtime = placements.get(key)!;
|
|
426
|
+
const format = definition.format as FullScreenFormat;
|
|
427
|
+
const startedAt = now();
|
|
428
|
+
|
|
429
|
+
return new Promise<LoadResult>(resolve => {
|
|
430
|
+
let settled = false;
|
|
431
|
+
|
|
432
|
+
const finish = (result: LoadResult) => {
|
|
433
|
+
if (settled) {
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
settled = true;
|
|
437
|
+
runtime.cancelLoadTimer?.();
|
|
438
|
+
runtime.cancelLoadTimer = null;
|
|
439
|
+
runtime.inFlightLoad = null;
|
|
440
|
+
resolve(result);
|
|
441
|
+
};
|
|
442
|
+
|
|
443
|
+
runtime.cancelLoadTimer = timers.after(resolved.timeouts.load, () => {
|
|
444
|
+
const error = new AdsError('load_timeout', {
|
|
445
|
+
operation: 'load',
|
|
446
|
+
placementKey: key,
|
|
447
|
+
format,
|
|
448
|
+
message: `load timed out after ${resolved.timeouts.load}ms`,
|
|
449
|
+
});
|
|
450
|
+
events.emit({
|
|
451
|
+
type: 'load_failed',
|
|
452
|
+
timestamp: now(),
|
|
453
|
+
placementKey: key,
|
|
454
|
+
format,
|
|
455
|
+
correlationId,
|
|
456
|
+
error,
|
|
457
|
+
});
|
|
458
|
+
dispatchPlacement(key, { type: 'LOAD_FAILED', error });
|
|
459
|
+
releasePlacementResources(key);
|
|
460
|
+
finish({ status: 'timeout' });
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
engine
|
|
464
|
+
.createFullScreen(format, adUnitId)
|
|
465
|
+
.then(handle => {
|
|
466
|
+
if (disposed || settled) {
|
|
467
|
+
engine.dispose(handle);
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
runtime.handle = handle;
|
|
471
|
+
runtime.unsubscribeEngine = engine.subscribe(handle, event => {
|
|
472
|
+
if (event.type === 'loaded') {
|
|
473
|
+
dispatchPlacement(key, { type: 'LOADED', atMs: now() });
|
|
474
|
+
events.emit({
|
|
475
|
+
type: 'loaded',
|
|
476
|
+
timestamp: now(),
|
|
477
|
+
placementKey: key,
|
|
478
|
+
format,
|
|
479
|
+
correlationId,
|
|
480
|
+
durationMs: now() - startedAt,
|
|
481
|
+
});
|
|
482
|
+
finish({ status: 'loaded', correlationId });
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
if (event.type === 'failed') {
|
|
486
|
+
const normalized = normalizeProviderError(event.cause, {
|
|
487
|
+
operation: 'load',
|
|
488
|
+
placementKey: key,
|
|
489
|
+
format,
|
|
490
|
+
});
|
|
491
|
+
events.emit({
|
|
492
|
+
type: 'load_failed',
|
|
493
|
+
timestamp: now(),
|
|
494
|
+
placementKey: key,
|
|
495
|
+
format,
|
|
496
|
+
correlationId,
|
|
497
|
+
error: normalized,
|
|
498
|
+
});
|
|
499
|
+
dispatchPlacement(key, { type: 'LOAD_FAILED', error: normalized });
|
|
500
|
+
releasePlacementResources(key);
|
|
501
|
+
finish(
|
|
502
|
+
normalized.code === 'no_fill'
|
|
503
|
+
? { status: 'no_fill' }
|
|
504
|
+
: { status: 'failed', error: normalized },
|
|
505
|
+
);
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
handleEngineEvent(key, definition, event);
|
|
509
|
+
});
|
|
510
|
+
})
|
|
511
|
+
.catch(error => {
|
|
512
|
+
const normalized = normalizeProviderError(error, {
|
|
513
|
+
operation: 'load',
|
|
514
|
+
placementKey: key,
|
|
515
|
+
format,
|
|
516
|
+
});
|
|
517
|
+
events.emit({
|
|
518
|
+
type: 'load_failed',
|
|
519
|
+
timestamp: now(),
|
|
520
|
+
placementKey: key,
|
|
521
|
+
format,
|
|
522
|
+
correlationId,
|
|
523
|
+
error: normalized,
|
|
524
|
+
});
|
|
525
|
+
dispatchPlacement(key, { type: 'LOAD_FAILED', error: normalized });
|
|
526
|
+
finish(
|
|
527
|
+
normalized.code === 'no_fill'
|
|
528
|
+
? { status: 'no_fill' }
|
|
529
|
+
: { status: 'failed', error: normalized },
|
|
530
|
+
);
|
|
531
|
+
});
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const instance: AdsInstance = {
|
|
536
|
+
async initialize(privacy) {
|
|
537
|
+
if (disposed) {
|
|
538
|
+
return { status: 'unavailable', reason: 'provider_missing' };
|
|
539
|
+
}
|
|
540
|
+
currentPrivacy = privacy;
|
|
541
|
+
|
|
542
|
+
if (!enabled) {
|
|
543
|
+
setInit(initReducer(init, { type: 'DISABLE' }));
|
|
544
|
+
const result = { status: 'disabled' } as const;
|
|
545
|
+
emitRefusal('initialize', result);
|
|
546
|
+
return result;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
if (!engine.isAvailable()) {
|
|
550
|
+
setInit(initReducer(init, { type: 'UNAVAILABLE', reason: 'unsupported_platform' }));
|
|
551
|
+
const result = { status: 'unavailable', reason: 'unsupported_platform' } as const;
|
|
552
|
+
emitRefusal('initialize', result);
|
|
553
|
+
return result;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const gate = evaluateGate(privacy);
|
|
557
|
+
if (!gate.allowed) {
|
|
558
|
+
// FR-017. Nothing provider-facing happens here at all — not a
|
|
559
|
+
// configuration call, not an initialization. The engine is untouched.
|
|
560
|
+
setInit(initReducer(init, { type: 'BLOCK', reason: gate.reason }));
|
|
561
|
+
const result = { status: 'blocked', reason: gate.reason } as const;
|
|
562
|
+
emitRefusal('initialize', result);
|
|
563
|
+
return result;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
if (init.status === 'ready') {
|
|
567
|
+
return { status: 'ready' };
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// FR-022, SC-005. Racing callers share one operation, so the provider
|
|
571
|
+
// initializes exactly once however many arrive.
|
|
572
|
+
if (inFlightInit) {
|
|
573
|
+
return inFlightInit;
|
|
574
|
+
}
|
|
575
|
+
if (!canStart(init.status)) {
|
|
576
|
+
return { status: 'ready' };
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
setInit(initReducer(init, { type: 'START' }));
|
|
580
|
+
inFlightInit = doInitialize(privacy);
|
|
581
|
+
return inFlightInit;
|
|
582
|
+
},
|
|
583
|
+
|
|
584
|
+
async updatePrivacy(privacy) {
|
|
585
|
+
if (disposed || !enabled) {
|
|
586
|
+
return { status: 'disabled' };
|
|
587
|
+
}
|
|
588
|
+
if (!engine.isAvailable()) {
|
|
589
|
+
return { status: 'unavailable', reason: 'unsupported_platform' };
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// FR-020: a flag the provider only honors at initialization cannot be
|
|
593
|
+
// applied now. Say so, rather than accepting it and running half-old.
|
|
594
|
+
if (requiresReinitialization(appliedPrivacy, privacy)) {
|
|
595
|
+
return { status: 'requires_reinitialization' };
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
currentPrivacy = privacy;
|
|
599
|
+
const gate = evaluateGate(privacy);
|
|
600
|
+
if (!gate.allowed) {
|
|
601
|
+
setInit(initReducer(init, { type: 'BLOCK', reason: gate.reason }));
|
|
602
|
+
return { status: 'blocked', reason: gate.reason };
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// FR-019: resolving consent after a block must not produce a second
|
|
606
|
+
// native initialization.
|
|
607
|
+
if (init.status === 'ready') {
|
|
608
|
+
return { status: 'applied' };
|
|
609
|
+
}
|
|
610
|
+
const result = await instance.initialize(privacy);
|
|
611
|
+
return result.status === 'ready'
|
|
612
|
+
? { status: 'applied' }
|
|
613
|
+
: result.status === 'blocked'
|
|
614
|
+
? { status: 'blocked', reason: result.reason }
|
|
615
|
+
: { status: 'applied' };
|
|
616
|
+
},
|
|
617
|
+
|
|
618
|
+
setEnabled(next) {
|
|
619
|
+
if (disposed || enabled === next) {
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
enabled = next;
|
|
623
|
+
if (!next) {
|
|
624
|
+
// FR-037: new work is prevented and in-flight resources released
|
|
625
|
+
// deterministically. An ad already on screen is left alone — the
|
|
626
|
+
// provider offers no safe way to close it, and yanking it would be
|
|
627
|
+
// worse than letting it finish.
|
|
628
|
+
for (const [key, runtime] of placements) {
|
|
629
|
+
if (runtime.snapshot.state === 'showing') {
|
|
630
|
+
continue;
|
|
631
|
+
}
|
|
632
|
+
releasePlacementResources(key);
|
|
633
|
+
dispatchPlacement(key, { type: 'RESET' });
|
|
634
|
+
}
|
|
635
|
+
setInit(initReducer(init, { type: 'DISABLE' }));
|
|
636
|
+
} else {
|
|
637
|
+
setInit(initReducer(init, { type: 'ENABLE' }));
|
|
638
|
+
}
|
|
639
|
+
},
|
|
640
|
+
|
|
641
|
+
setAppActive(active) {
|
|
642
|
+
if (disposed) {
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
appActive = active;
|
|
646
|
+
if (!active) {
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
// FR-034: on return to the foreground, re-evaluate what was loaded.
|
|
650
|
+
// An ad that went stale while the app was away must stop reporting
|
|
651
|
+
// `ready` before the app offers a button that would fail on tap.
|
|
652
|
+
const current = now();
|
|
653
|
+
for (const [key, runtime] of placements) {
|
|
654
|
+
if (
|
|
655
|
+
runtime.snapshot.state === 'ready' &&
|
|
656
|
+
runtime.snapshot.loadedAtMs !== null &&
|
|
657
|
+
isStale(runtime.snapshot.loadedAtMs, current, resolved.timeouts.adValidity)
|
|
658
|
+
) {
|
|
659
|
+
releasePlacementResources(key);
|
|
660
|
+
dispatchPlacement(key, { type: 'EXPIRE' });
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
},
|
|
664
|
+
|
|
665
|
+
getStatus() {
|
|
666
|
+
return init.status;
|
|
667
|
+
},
|
|
668
|
+
|
|
669
|
+
subscribeStatus(listener) {
|
|
670
|
+
statusListeners.add(listener);
|
|
671
|
+
let active = true;
|
|
672
|
+
return () => {
|
|
673
|
+
if (!active) {
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
active = false;
|
|
677
|
+
statusListeners.delete(listener);
|
|
678
|
+
};
|
|
679
|
+
},
|
|
680
|
+
|
|
681
|
+
async load(placementKey) {
|
|
682
|
+
if (disposed) {
|
|
683
|
+
return { status: 'unavailable', reason: 'provider_missing' };
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
const definition = resolved.placements.get(placementKey);
|
|
687
|
+
if (!definition) {
|
|
688
|
+
return {
|
|
689
|
+
status: 'failed',
|
|
690
|
+
error: new AdsError('placement_not_found', {
|
|
691
|
+
operation: 'load',
|
|
692
|
+
placementKey,
|
|
693
|
+
message: `unknown placement "${placementKey}"; configured keys are ${[...resolved.placements.keys()].join(', ')}`,
|
|
694
|
+
}),
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
const gate = gateCheck();
|
|
699
|
+
if (!gate.ok) {
|
|
700
|
+
emitRefusal('load', gate.result, placementKey, definition.format);
|
|
701
|
+
return gate.result;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
if (!canRequest(init.status)) {
|
|
705
|
+
return {
|
|
706
|
+
status: 'failed',
|
|
707
|
+
error: new AdsError('not_ready', {
|
|
708
|
+
operation: 'load',
|
|
709
|
+
placementKey,
|
|
710
|
+
format: definition.format,
|
|
711
|
+
message: 'initialize() must complete before loading',
|
|
712
|
+
}),
|
|
713
|
+
};
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
if (definition.format === 'banner') {
|
|
717
|
+
return {
|
|
718
|
+
status: 'failed',
|
|
719
|
+
error: new AdsError('format_mismatch', {
|
|
720
|
+
operation: 'load',
|
|
721
|
+
placementKey,
|
|
722
|
+
format: definition.format,
|
|
723
|
+
message: `"${placementKey}" is a banner; banners render through <AdsBanner> rather than load()`,
|
|
724
|
+
}),
|
|
725
|
+
};
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
const runtime = placements.get(placementKey)!;
|
|
729
|
+
|
|
730
|
+
// FR-029, SC-002's sibling guarantee: a repeat load while one is in
|
|
731
|
+
// flight joins it instead of creating a second provider request.
|
|
732
|
+
if (runtime.inFlightLoad) {
|
|
733
|
+
return runtime.inFlightLoad;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
if (runtime.snapshot.state === 'showing') {
|
|
737
|
+
return { status: 'failed', error: new AdsError('busy', { operation: 'load', placementKey }) };
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
if (!canLoad(runtime.snapshot.state)) {
|
|
741
|
+
return {
|
|
742
|
+
status: 'failed',
|
|
743
|
+
error: new AdsError('not_ready', { operation: 'load', placementKey }),
|
|
744
|
+
};
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
releasePlacementResources(placementKey);
|
|
748
|
+
|
|
749
|
+
const correlationId = correlation.next();
|
|
750
|
+
runtime.correlationId = correlationId;
|
|
751
|
+
runtime.dismissed = false;
|
|
752
|
+
dispatchPlacement(placementKey, { type: 'LOAD' });
|
|
753
|
+
setPlacement(placementKey, { ...runtime.snapshot, correlationId });
|
|
754
|
+
events.emit({
|
|
755
|
+
type: 'load_started',
|
|
756
|
+
timestamp: now(),
|
|
757
|
+
placementKey,
|
|
758
|
+
format: definition.format,
|
|
759
|
+
correlationId,
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
const adUnitId = resolved.catalog.byKey.get(placementKey)!;
|
|
763
|
+
const promise = loadFullScreen(placementKey, definition, adUnitId, correlationId);
|
|
764
|
+
runtime.inFlightLoad = promise;
|
|
765
|
+
return promise;
|
|
766
|
+
},
|
|
767
|
+
|
|
768
|
+
async show(placementKey) {
|
|
769
|
+
if (disposed) {
|
|
770
|
+
return { status: 'unavailable', reason: 'provider_missing' };
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
const definition = resolved.placements.get(placementKey);
|
|
774
|
+
if (!definition) {
|
|
775
|
+
return {
|
|
776
|
+
status: 'failed',
|
|
777
|
+
error: new AdsError('placement_not_found', {
|
|
778
|
+
operation: 'show',
|
|
779
|
+
placementKey,
|
|
780
|
+
message: `unknown placement "${placementKey}"; configured keys are ${[...resolved.placements.keys()].join(', ')}`,
|
|
781
|
+
}),
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
if (!isFullScreenFormat(definition.format)) {
|
|
786
|
+
return {
|
|
787
|
+
status: 'failed',
|
|
788
|
+
error: new AdsError('format_mismatch', {
|
|
789
|
+
operation: 'show',
|
|
790
|
+
placementKey,
|
|
791
|
+
format: definition.format,
|
|
792
|
+
message: `"${placementKey}" is a banner; banners are rendered, not shown`,
|
|
793
|
+
}),
|
|
794
|
+
};
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
const gate = gateCheck();
|
|
798
|
+
if (!gate.ok) {
|
|
799
|
+
emitRefusal('show', gate.result, placementKey, definition.format);
|
|
800
|
+
return gate.result;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// FR-028: never open an ad while the app is not in front of the user.
|
|
804
|
+
if (!appActive) {
|
|
805
|
+
return { status: 'app_inactive' };
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
const runtime = placements.get(placementKey)!;
|
|
809
|
+
|
|
810
|
+
if (runtime.snapshot.state === 'consumed') {
|
|
811
|
+
return { status: 'consumed' };
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
// FR-034: a stale ad is not shown and is not reported ready.
|
|
815
|
+
if (
|
|
816
|
+
runtime.snapshot.state === 'ready' &&
|
|
817
|
+
runtime.snapshot.loadedAtMs !== null &&
|
|
818
|
+
isStale(runtime.snapshot.loadedAtMs, now(), resolved.timeouts.adValidity)
|
|
819
|
+
) {
|
|
820
|
+
releasePlacementResources(placementKey);
|
|
821
|
+
dispatchPlacement(placementKey, { type: 'EXPIRE' });
|
|
822
|
+
return { status: 'not_ready' };
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
if (runtime.snapshot.state !== 'ready' || !runtime.handle) {
|
|
826
|
+
return { status: 'not_ready' };
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
// FR-030. Acquired before anything else can open a screen, and never
|
|
830
|
+
// queued: a refused show is refused now, not deferred to a moment the
|
|
831
|
+
// app never chose.
|
|
832
|
+
if (!lock.acquire(placementKey)) {
|
|
833
|
+
return { status: 'busy' };
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
const correlationId = runtime.correlationId ?? correlation.next();
|
|
837
|
+
runtime.rewarded = definition.format === 'rewarded' ? createRewardedSession() : null;
|
|
838
|
+
dispatchPlacement(placementKey, { type: 'SHOW' });
|
|
839
|
+
|
|
840
|
+
let settled = false;
|
|
841
|
+
const showTimeout = new Promise<ShowResult>(resolve => {
|
|
842
|
+
runtime.cancelShowTimer = timers.after(resolved.timeouts.show, () => {
|
|
843
|
+
if (settled) {
|
|
844
|
+
return;
|
|
845
|
+
}
|
|
846
|
+
settled = true;
|
|
847
|
+
const error = new AdsError('show_timeout', {
|
|
848
|
+
operation: 'show',
|
|
849
|
+
placementKey,
|
|
850
|
+
format: definition.format,
|
|
851
|
+
message: `show timed out after ${resolved.timeouts.show}ms`,
|
|
852
|
+
});
|
|
853
|
+
lock.release(placementKey);
|
|
854
|
+
events.emit({
|
|
855
|
+
type: 'display_failed',
|
|
856
|
+
timestamp: now(),
|
|
857
|
+
placementKey,
|
|
858
|
+
format: definition.format,
|
|
859
|
+
correlationId,
|
|
860
|
+
error,
|
|
861
|
+
});
|
|
862
|
+
dispatchPlacement(placementKey, { type: 'SHOW_FAILED', error });
|
|
863
|
+
releasePlacementResources(placementKey);
|
|
864
|
+
resolve({ status: 'timeout' });
|
|
865
|
+
});
|
|
866
|
+
});
|
|
867
|
+
|
|
868
|
+
const attempt = engine
|
|
869
|
+
.show(runtime.handle)
|
|
870
|
+
.then<ShowResult>(() => {
|
|
871
|
+
settled = true;
|
|
872
|
+
runtime.cancelShowTimer?.();
|
|
873
|
+
runtime.cancelShowTimer = null;
|
|
874
|
+
return { status: 'shown', correlationId };
|
|
875
|
+
})
|
|
876
|
+
.catch<ShowResult>(error => {
|
|
877
|
+
settled = true;
|
|
878
|
+
runtime.cancelShowTimer?.();
|
|
879
|
+
runtime.cancelShowTimer = null;
|
|
880
|
+
const normalized = normalizeProviderError(error, {
|
|
881
|
+
operation: 'show',
|
|
882
|
+
placementKey,
|
|
883
|
+
format: definition.format,
|
|
884
|
+
});
|
|
885
|
+
lock.release(placementKey);
|
|
886
|
+
events.emit({
|
|
887
|
+
type: 'display_failed',
|
|
888
|
+
timestamp: now(),
|
|
889
|
+
placementKey,
|
|
890
|
+
format: definition.format,
|
|
891
|
+
correlationId,
|
|
892
|
+
error: normalized,
|
|
893
|
+
});
|
|
894
|
+
dispatchPlacement(placementKey, { type: 'SHOW_FAILED', error: normalized });
|
|
895
|
+
releasePlacementResources(placementKey);
|
|
896
|
+
return { status: 'failed', error: normalized };
|
|
897
|
+
});
|
|
898
|
+
|
|
899
|
+
return Promise.race([attempt, showTimeout]);
|
|
900
|
+
},
|
|
901
|
+
|
|
902
|
+
getPlacementState(placementKey) {
|
|
903
|
+
if (!resolved.placements.has(placementKey)) {
|
|
904
|
+
return 'unavailable';
|
|
905
|
+
}
|
|
906
|
+
if (!enabled || !engine.isAvailable()) {
|
|
907
|
+
return 'unavailable';
|
|
908
|
+
}
|
|
909
|
+
return placements.get(placementKey)?.snapshot.state ?? 'idle';
|
|
910
|
+
},
|
|
911
|
+
|
|
912
|
+
subscribePlacement(key, listener) {
|
|
913
|
+
const runtime = placements.get(key);
|
|
914
|
+
if (!runtime) {
|
|
915
|
+
return () => undefined;
|
|
916
|
+
}
|
|
917
|
+
runtime.listeners.add(listener);
|
|
918
|
+
let active = true;
|
|
919
|
+
return () => {
|
|
920
|
+
if (!active) {
|
|
921
|
+
return;
|
|
922
|
+
}
|
|
923
|
+
active = false;
|
|
924
|
+
runtime.listeners.delete(listener);
|
|
925
|
+
};
|
|
926
|
+
},
|
|
927
|
+
|
|
928
|
+
subscribeEvents(listener) {
|
|
929
|
+
return events.subscribe(listener);
|
|
930
|
+
},
|
|
931
|
+
|
|
932
|
+
getSubstitutedPlacements() {
|
|
933
|
+
return resolved.catalog.substitutedKeys;
|
|
934
|
+
},
|
|
935
|
+
|
|
936
|
+
internals: {
|
|
937
|
+
resolveBanner(placementKey) {
|
|
938
|
+
const definition = resolved.placements.get(placementKey);
|
|
939
|
+
if (!definition) {
|
|
940
|
+
return {
|
|
941
|
+
ok: false,
|
|
942
|
+
result: {
|
|
943
|
+
status: 'failed',
|
|
944
|
+
error: new AdsError('placement_not_found', {
|
|
945
|
+
operation: 'load',
|
|
946
|
+
placementKey,
|
|
947
|
+
message: `unknown placement "${placementKey}"`,
|
|
948
|
+
}),
|
|
949
|
+
},
|
|
950
|
+
};
|
|
951
|
+
}
|
|
952
|
+
if (definition.format !== 'banner') {
|
|
953
|
+
return {
|
|
954
|
+
ok: false,
|
|
955
|
+
result: {
|
|
956
|
+
status: 'failed',
|
|
957
|
+
error: new AdsError('format_mismatch', {
|
|
958
|
+
operation: 'load',
|
|
959
|
+
placementKey,
|
|
960
|
+
format: definition.format,
|
|
961
|
+
message: `"${placementKey}" is not a banner placement`,
|
|
962
|
+
}),
|
|
963
|
+
},
|
|
964
|
+
};
|
|
965
|
+
}
|
|
966
|
+
const gate = gateCheck();
|
|
967
|
+
if (!gate.ok) {
|
|
968
|
+
return { ok: false, result: gate.result };
|
|
969
|
+
}
|
|
970
|
+
if (!canRequest(init.status)) {
|
|
971
|
+
return { ok: false, result: { status: 'blocked', reason: 'consent_required' } };
|
|
972
|
+
}
|
|
973
|
+
return {
|
|
974
|
+
ok: true,
|
|
975
|
+
adUnitId: resolved.catalog.byKey.get(placementKey)!,
|
|
976
|
+
correlationId: correlation.next(),
|
|
977
|
+
};
|
|
978
|
+
},
|
|
979
|
+
emit(event) {
|
|
980
|
+
events.emit(event);
|
|
981
|
+
},
|
|
982
|
+
setPlacementState(key, state) {
|
|
983
|
+
const runtime = placements.get(key);
|
|
984
|
+
if (runtime) {
|
|
985
|
+
setPlacement(key, { ...runtime.snapshot, state });
|
|
986
|
+
}
|
|
987
|
+
},
|
|
988
|
+
isReady() {
|
|
989
|
+
return enabled && engine.isAvailable() && canRequest(init.status);
|
|
990
|
+
},
|
|
991
|
+
nextCorrelationId() {
|
|
992
|
+
return correlation.next();
|
|
993
|
+
},
|
|
994
|
+
timerCount() {
|
|
995
|
+
return timers.size();
|
|
996
|
+
},
|
|
997
|
+
},
|
|
998
|
+
|
|
999
|
+
dispose() {
|
|
1000
|
+
// FR-005: idempotent. A second call is a no-op, not an error.
|
|
1001
|
+
if (disposed) {
|
|
1002
|
+
return;
|
|
1003
|
+
}
|
|
1004
|
+
disposed = true;
|
|
1005
|
+
for (const key of placements.keys()) {
|
|
1006
|
+
releasePlacementResources(key);
|
|
1007
|
+
placements.get(key)?.listeners.clear();
|
|
1008
|
+
}
|
|
1009
|
+
timers.clearAll();
|
|
1010
|
+
lock.reset();
|
|
1011
|
+
statusListeners.clear();
|
|
1012
|
+
events.dispose();
|
|
1013
|
+
inFlightInit = null;
|
|
1014
|
+
},
|
|
1015
|
+
};
|
|
1016
|
+
|
|
1017
|
+
return instance;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
/**
|
|
1021
|
+
* Resolved lazily so importing the package on a platform with no adapter — or
|
|
1022
|
+
* with the bridge absent — costs nothing and crashes nothing (FR-025, SC-004).
|
|
1023
|
+
*/
|
|
1024
|
+
function createDefaultEngine(): AdsEngine {
|
|
1025
|
+
// Deferred require keeps the provider off the import graph of every module
|
|
1026
|
+
// that merely mentions `createAds`.
|
|
1027
|
+
const { createAdMobEngine } = require('../engine/admobEngine') as typeof import('../engine/admobEngine');
|
|
1028
|
+
return createAdMobEngine();
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
declare const __DEV__: boolean | undefined;
|