@chipmobilesdk/rn-ads 0.6.0 → 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 +51 -0
- package/README.md +4 -2
- package/package.json +1 -1
- package/skills/sdk-rn-ads/SKILL.md +2 -1
- package/src/core/adsInstance.ts +72 -1829
- 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/diagnostics/errors.ts +24 -5
- package/src/engine/admobBanner.tsx +55 -0
- package/src/engine/admobEngine.ts +21 -12
- package/src/engine/admobErrors.ts +44 -0
- package/src/engine/port.ts +46 -1
- package/src/engine/testAdUnits.ts +0 -5
- package/src/react/AdsBanner.tsx +81 -80
- package/src/testing/clock.ts +75 -0
- package/src/testing/index.ts +29 -68
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loading full-screen ads (FR-029, FR-134 – FR-140) and the provider callbacks
|
|
3
|
+
* that follow a load through its whole cycle.
|
|
4
|
+
*/
|
|
5
|
+
import {
|
|
6
|
+
isFullScreenFormat,
|
|
7
|
+
type AdFormat,
|
|
8
|
+
type FullScreenFormat,
|
|
9
|
+
type PlacementDefinition,
|
|
10
|
+
} from '../../config/types';
|
|
11
|
+
import { AdsError, normalizeProviderError } from '../../diagnostics/errors';
|
|
12
|
+
import type { EngineAdEvent } from '../../engine/port';
|
|
13
|
+
import { canRequest } from '../initMachine';
|
|
14
|
+
import { canLoad } from '../placementMachine';
|
|
15
|
+
import { toEngineRequest } from '../privacyGate';
|
|
16
|
+
import { canRetry, delayForAttempt } from '../retryPolicy';
|
|
17
|
+
import type { InstanceContext } from './context';
|
|
18
|
+
import { currentGate, gateRefusal } from './gate';
|
|
19
|
+
import type { LoadResult } from './types';
|
|
20
|
+
|
|
21
|
+
export function unknownPlacement(
|
|
22
|
+
context: InstanceContext,
|
|
23
|
+
placementKey: string,
|
|
24
|
+
operation: 'load' | 'show',
|
|
25
|
+
): LoadResult & { status: 'failed' } {
|
|
26
|
+
return {
|
|
27
|
+
status: 'failed',
|
|
28
|
+
error: new AdsError('placement_not_found', {
|
|
29
|
+
operation,
|
|
30
|
+
placementKey,
|
|
31
|
+
message: `unknown placement "${placementKey}"; configured keys are ${[...context.resolved.placements.keys()].join(', ')}`,
|
|
32
|
+
}),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function createLoader(context: InstanceContext): {
|
|
37
|
+
load(placementKey: string): Promise<LoadResult>;
|
|
38
|
+
} {
|
|
39
|
+
const { state, engine, events, timers, resolved, lock, adReturn, now } = context;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Takes a throttle slot, or reports the refusal (FR-140). `null` means
|
|
43
|
+
* proceed. A placement still holding a `ready` ad keeps that state: the
|
|
44
|
+
* refusal is about asking again, and overwriting it would strand an ad that
|
|
45
|
+
* was already paid for (ADS-01).
|
|
46
|
+
*/
|
|
47
|
+
function checkThrottle(key: string, format: AdFormat): LoadResult | null {
|
|
48
|
+
const minRequestIntervalMs = resolved.minRequestIntervalByKey.get(key) ?? 0;
|
|
49
|
+
const decision = context.requestThrottle.tryAcquire(key, minRequestIntervalMs);
|
|
50
|
+
if (decision.allowed) {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
const runtime = context.placements.get(key);
|
|
54
|
+
if (runtime && runtime.snapshot.state !== 'ready') {
|
|
55
|
+
context.setPlacement(key, { ...runtime.snapshot, state: 'throttled' });
|
|
56
|
+
}
|
|
57
|
+
events.emit({
|
|
58
|
+
type: 'request_throttled',
|
|
59
|
+
timestamp: now(),
|
|
60
|
+
placementKey: key,
|
|
61
|
+
format,
|
|
62
|
+
retryAfterMs: decision.retryAfterMs,
|
|
63
|
+
minRequestIntervalMs,
|
|
64
|
+
});
|
|
65
|
+
return { status: 'throttled', retryAfterMs: decision.retryAfterMs };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function handleEngineEvent(
|
|
69
|
+
key: string,
|
|
70
|
+
definition: PlacementDefinition,
|
|
71
|
+
event: EngineAdEvent,
|
|
72
|
+
): void {
|
|
73
|
+
// After dispose nothing is honored (FR-035, SC-008).
|
|
74
|
+
const runtime = context.placements.get(key);
|
|
75
|
+
if (state.disposed || !runtime) {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const base = {
|
|
79
|
+
timestamp: now(),
|
|
80
|
+
placementKey: key,
|
|
81
|
+
format: definition.format,
|
|
82
|
+
correlationId: runtime.correlationId ?? undefined,
|
|
83
|
+
};
|
|
84
|
+
switch (event.type) {
|
|
85
|
+
case 'opened':
|
|
86
|
+
// The provider confirming an ad is on screen arms ad-originated return
|
|
87
|
+
// detection (FR-128); the app's own background report arrives too late.
|
|
88
|
+
if (isFullScreenFormat(definition.format)) {
|
|
89
|
+
adReturn.noteFullScreenShown(now());
|
|
90
|
+
}
|
|
91
|
+
events.emit({ ...base, type: 'display_started' });
|
|
92
|
+
// R5: no impression callback for full-screen formats, so this one is
|
|
93
|
+
// derived from the ad opening, and labelled as such.
|
|
94
|
+
events.emit({ ...base, type: 'impression', source: 'derived' });
|
|
95
|
+
return;
|
|
96
|
+
case 'clicked':
|
|
97
|
+
// A background moments later is "the ad opened a browser" (FR-128).
|
|
98
|
+
adReturn.noteClick(now());
|
|
99
|
+
events.emit({ ...base, type: 'click' });
|
|
100
|
+
return;
|
|
101
|
+
case 'rewarded': {
|
|
102
|
+
const first = runtime.rewarded?.confirmReward(event.reward) ?? null;
|
|
103
|
+
if (first) {
|
|
104
|
+
events.emit({ ...base, type: 'reward_earned', reward: first });
|
|
105
|
+
}
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
case 'dismissed': {
|
|
109
|
+
const rewardedFirst = runtime.rewarded ? runtime.rewarded.dismiss() : true;
|
|
110
|
+
if (runtime.dismissed || !rewardedFirst) {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
runtime.dismissed = true;
|
|
114
|
+
lock.release(key);
|
|
115
|
+
runtime.cancelShowTimer?.();
|
|
116
|
+
runtime.cancelShowTimer = null;
|
|
117
|
+
events.emit({
|
|
118
|
+
...base,
|
|
119
|
+
type: 'dismissed',
|
|
120
|
+
rewardEarned: runtime.rewarded ? runtime.rewarded.rewardEarned : undefined,
|
|
121
|
+
});
|
|
122
|
+
context.dispatchPlacement(key, { type: 'DISMISSED' });
|
|
123
|
+
// Deliberately NOT released: FR-049 handles the reward and the
|
|
124
|
+
// dismissal in either order, and a provider that confirms the reward
|
|
125
|
+
// after closing would otherwise reach a torn-down listener. At most one
|
|
126
|
+
// handle and one listener per placement, released by the next load or
|
|
127
|
+
// by dispose().
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
case 'failed': {
|
|
131
|
+
const normalized = normalizeProviderError(
|
|
132
|
+
event.cause,
|
|
133
|
+
{
|
|
134
|
+
operation: 'show',
|
|
135
|
+
placementKey: key,
|
|
136
|
+
format: definition.format,
|
|
137
|
+
},
|
|
138
|
+
event.kind,
|
|
139
|
+
);
|
|
140
|
+
lock.release(key);
|
|
141
|
+
events.emit({ ...base, type: 'display_failed', error: normalized });
|
|
142
|
+
context.dispatchPlacement(key, { type: 'SHOW_FAILED', error: normalized });
|
|
143
|
+
context.releasePlacementResources(key);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
default:
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function loadFailed(
|
|
151
|
+
key: string,
|
|
152
|
+
format: FullScreenFormat,
|
|
153
|
+
correlationId: string,
|
|
154
|
+
error: AdsError,
|
|
155
|
+
): LoadResult {
|
|
156
|
+
events.emit({
|
|
157
|
+
type: 'load_failed',
|
|
158
|
+
timestamp: now(),
|
|
159
|
+
placementKey: key,
|
|
160
|
+
format,
|
|
161
|
+
correlationId,
|
|
162
|
+
error,
|
|
163
|
+
});
|
|
164
|
+
context.dispatchPlacement(key, { type: 'LOAD_FAILED', error });
|
|
165
|
+
return error.code === 'no_fill' ? { status: 'no_fill' } : { status: 'failed', error };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function loadFullScreen(
|
|
169
|
+
key: string,
|
|
170
|
+
definition: PlacementDefinition & { format: FullScreenFormat },
|
|
171
|
+
adUnitId: string,
|
|
172
|
+
correlationId: string,
|
|
173
|
+
): Promise<LoadResult> {
|
|
174
|
+
const runtime = context.placements.get(key);
|
|
175
|
+
const { format } = definition;
|
|
176
|
+
const startedAt = now();
|
|
177
|
+
if (!runtime) {
|
|
178
|
+
return Promise.resolve({ status: 'unavailable', reason: 'provider_missing' });
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return new Promise<LoadResult>(resolve => {
|
|
182
|
+
let settled = false;
|
|
183
|
+
const finish = (result: LoadResult) => {
|
|
184
|
+
if (settled) {
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
settled = true;
|
|
188
|
+
runtime.cancelLoadTimer?.();
|
|
189
|
+
runtime.cancelLoadTimer = null;
|
|
190
|
+
runtime.inFlightLoad = null;
|
|
191
|
+
if (runtime.abortLoad === finish) {
|
|
192
|
+
runtime.abortLoad = null;
|
|
193
|
+
}
|
|
194
|
+
resolve(result);
|
|
195
|
+
};
|
|
196
|
+
// `settled` makes this safe: a handle the provider hands back after an
|
|
197
|
+
// abort is disposed by the `.then` below instead of adopted.
|
|
198
|
+
runtime.abortLoad = finish;
|
|
199
|
+
|
|
200
|
+
runtime.cancelLoadTimer = timers.after(resolved.timeouts.load, () => {
|
|
201
|
+
const error = new AdsError('load_timeout', {
|
|
202
|
+
operation: 'load',
|
|
203
|
+
placementKey: key,
|
|
204
|
+
format,
|
|
205
|
+
message: `load timed out after ${resolved.timeouts.load}ms`,
|
|
206
|
+
});
|
|
207
|
+
loadFailed(key, format, correlationId, error);
|
|
208
|
+
context.releasePlacementResources(key);
|
|
209
|
+
finish({ status: 'timeout' });
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
engine
|
|
213
|
+
.createFullScreen(format, adUnitId, toEngineRequest(state.currentPrivacy))
|
|
214
|
+
.then(handle => {
|
|
215
|
+
if (state.disposed || settled) {
|
|
216
|
+
engine.dispose(handle);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
runtime.handle = handle;
|
|
220
|
+
runtime.unsubscribeEngine = engine.subscribe(handle, event => {
|
|
221
|
+
if (event.type === 'loaded') {
|
|
222
|
+
context.dispatchPlacement(key, { type: 'LOADED', atMs: now() });
|
|
223
|
+
events.emit({
|
|
224
|
+
type: 'loaded',
|
|
225
|
+
timestamp: now(),
|
|
226
|
+
placementKey: key,
|
|
227
|
+
format,
|
|
228
|
+
correlationId,
|
|
229
|
+
durationMs: now() - startedAt,
|
|
230
|
+
});
|
|
231
|
+
finish({ status: 'loaded', correlationId });
|
|
232
|
+
} else if (event.type === 'failed') {
|
|
233
|
+
const error = normalizeProviderError(
|
|
234
|
+
event.cause,
|
|
235
|
+
{
|
|
236
|
+
operation: 'load',
|
|
237
|
+
placementKey: key,
|
|
238
|
+
format,
|
|
239
|
+
},
|
|
240
|
+
event.kind,
|
|
241
|
+
);
|
|
242
|
+
const result = loadFailed(key, format, correlationId, error);
|
|
243
|
+
context.releasePlacementResources(key);
|
|
244
|
+
finish(result);
|
|
245
|
+
} else {
|
|
246
|
+
handleEngineEvent(key, definition, event);
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
})
|
|
250
|
+
.catch(error => {
|
|
251
|
+
const normalized = normalizeProviderError(error, {
|
|
252
|
+
operation: 'load',
|
|
253
|
+
placementKey: key,
|
|
254
|
+
format,
|
|
255
|
+
});
|
|
256
|
+
finish(loadFailed(key, format, correlationId, normalized));
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* One load, plus however many bounded retries the policy allows (FR-134).
|
|
263
|
+
*
|
|
264
|
+
* Off unless configured. When on, only errors flagged `retryable` are retried
|
|
265
|
+
* — a configuration or consent failure is a state another attempt cannot
|
|
266
|
+
* change, and retrying it would spend a billable request on a certainty
|
|
267
|
+
* (FR-135). Every attempt is emitted before it is waited on, with the cycle's
|
|
268
|
+
* correlation id, so the stream reconciles against a bill (FR-138).
|
|
269
|
+
*/
|
|
270
|
+
async function loadWithRetry(
|
|
271
|
+
key: string,
|
|
272
|
+
definition: PlacementDefinition & { format: FullScreenFormat },
|
|
273
|
+
adUnitId: string,
|
|
274
|
+
correlationId: string,
|
|
275
|
+
): Promise<LoadResult> {
|
|
276
|
+
const runtime = context.placements.get(key);
|
|
277
|
+
const policy = resolved.retryByKey.get(key) ?? null;
|
|
278
|
+
for (;;) {
|
|
279
|
+
const result = await loadFullScreen(key, definition, adUnitId, correlationId);
|
|
280
|
+
if (result.status === 'loaded' || state.disposed || !runtime) {
|
|
281
|
+
return result;
|
|
282
|
+
}
|
|
283
|
+
const error = retryableErrorOf(result, key);
|
|
284
|
+
// A refusal is not a failure to retry: nothing was requested. And a retry
|
|
285
|
+
// is a new provider request, so it needs the whole gate (FR-017).
|
|
286
|
+
if (
|
|
287
|
+
!error?.retryable ||
|
|
288
|
+
!policy ||
|
|
289
|
+
!canRetry(policy, runtime.retryAttempts) ||
|
|
290
|
+
!currentGate(context).allowed
|
|
291
|
+
) {
|
|
292
|
+
return result;
|
|
293
|
+
}
|
|
294
|
+
runtime.retryAttempts += 1;
|
|
295
|
+
// A retry is a provider request like any other, so it waits at least as
|
|
296
|
+
// long as the placement's floor requires (FR-140, ADS-07): a 2 s backoff
|
|
297
|
+
// on a placement with a 20 s floor waits 20 s.
|
|
298
|
+
const floor = resolved.minRequestIntervalByKey.get(key) ?? 0;
|
|
299
|
+
const delayMs = Math.max(
|
|
300
|
+
delayForAttempt(policy, runtime.retryAttempts),
|
|
301
|
+
context.requestThrottle.waitFor(key, floor),
|
|
302
|
+
);
|
|
303
|
+
events.emit({
|
|
304
|
+
type: 'retry_scheduled',
|
|
305
|
+
timestamp: now(),
|
|
306
|
+
placementKey: key,
|
|
307
|
+
format: definition.format,
|
|
308
|
+
correlationId,
|
|
309
|
+
attempt: runtime.retryAttempts,
|
|
310
|
+
delayMs,
|
|
311
|
+
error,
|
|
312
|
+
});
|
|
313
|
+
const waited = await new Promise<boolean>(resolve => {
|
|
314
|
+
const cancelTimer = timers.after(delayMs, () => {
|
|
315
|
+
runtime.cancelRetry = null;
|
|
316
|
+
resolve(true);
|
|
317
|
+
});
|
|
318
|
+
// A disable or dispose that cancels resolves this wait as "do not
|
|
319
|
+
// continue" rather than leaving a promise nobody will settle.
|
|
320
|
+
runtime.cancelRetry = () => {
|
|
321
|
+
cancelTimer();
|
|
322
|
+
runtime.cancelRetry = null;
|
|
323
|
+
resolve(false);
|
|
324
|
+
};
|
|
325
|
+
});
|
|
326
|
+
if (!waited || state.disposed || !currentGate(context).allowed) {
|
|
327
|
+
return result;
|
|
328
|
+
}
|
|
329
|
+
// Recorded, so the next load is measured from this request. Refused only
|
|
330
|
+
// if the clock jumped while waiting; then the retry is skipped, not sent.
|
|
331
|
+
if (!context.requestThrottle.tryAcquire(key, floor).allowed) {
|
|
332
|
+
return result;
|
|
333
|
+
}
|
|
334
|
+
context.dispatchPlacement(key, { type: 'LOAD' });
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return {
|
|
339
|
+
async load(placementKey) {
|
|
340
|
+
if (state.disposed) {
|
|
341
|
+
return { status: 'unavailable', reason: 'provider_missing' };
|
|
342
|
+
}
|
|
343
|
+
const found = context.placement(placementKey);
|
|
344
|
+
if (!found) {
|
|
345
|
+
return unknownPlacement(context, placementKey, 'load');
|
|
346
|
+
}
|
|
347
|
+
const { runtime, definition } = found;
|
|
348
|
+
const refusal = gateRefusal(context);
|
|
349
|
+
if (refusal) {
|
|
350
|
+
context.emitRefusal('load', refusal, placementKey, definition.format);
|
|
351
|
+
return refusal;
|
|
352
|
+
}
|
|
353
|
+
const fail = (
|
|
354
|
+
code: 'not_ready' | 'busy' | 'format_mismatch',
|
|
355
|
+
detail: { format?: AdFormat; message?: string } = {},
|
|
356
|
+
): LoadResult => ({
|
|
357
|
+
status: 'failed',
|
|
358
|
+
error: new AdsError(code, { operation: 'load', placementKey, ...detail }),
|
|
359
|
+
});
|
|
360
|
+
if (!canRequest(state.init.status)) {
|
|
361
|
+
return fail('not_ready', {
|
|
362
|
+
format: definition.format,
|
|
363
|
+
message: 'initialize() must complete before loading',
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
if (!isFullScreenFormat(definition.format)) {
|
|
367
|
+
return fail('format_mismatch', {
|
|
368
|
+
format: definition.format,
|
|
369
|
+
message: `"${placementKey}" is a banner; banners render through <AdsBanner> rather than load()`,
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
// FR-029: a repeat load while one is in flight joins it.
|
|
373
|
+
if (runtime.inFlightLoad) {
|
|
374
|
+
return runtime.inFlightLoad;
|
|
375
|
+
}
|
|
376
|
+
if (runtime.snapshot.state === 'showing') {
|
|
377
|
+
return fail('busy');
|
|
378
|
+
}
|
|
379
|
+
if (!canLoad(runtime.snapshot.state)) {
|
|
380
|
+
return fail('not_ready');
|
|
381
|
+
}
|
|
382
|
+
// FR-140. After the in-flight join, so a caller that arrived twice is not
|
|
383
|
+
// charged; after the busy and not-ready guards, so a load that would not
|
|
384
|
+
// request anything spends no slot; and before the correlation id, because
|
|
385
|
+
// a refused request is not a cycle.
|
|
386
|
+
const throttled = checkThrottle(placementKey, definition.format);
|
|
387
|
+
if (throttled) {
|
|
388
|
+
return throttled;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
context.releasePlacementResources(placementKey);
|
|
392
|
+
const correlationId = context.correlation.next();
|
|
393
|
+
runtime.correlationId = correlationId;
|
|
394
|
+
runtime.dismissed = false;
|
|
395
|
+
context.dispatchPlacement(placementKey, { type: 'LOAD' });
|
|
396
|
+
context.setPlacement(placementKey, { ...runtime.snapshot, correlationId });
|
|
397
|
+
events.emit({
|
|
398
|
+
type: 'load_started',
|
|
399
|
+
timestamp: now(),
|
|
400
|
+
placementKey,
|
|
401
|
+
format: definition.format,
|
|
402
|
+
correlationId,
|
|
403
|
+
});
|
|
404
|
+
const adUnitId = resolved.catalog.byKey.get(placementKey) ?? '';
|
|
405
|
+
runtime.retryAttempts = 0;
|
|
406
|
+
const fullScreen = { ...definition, format: definition.format };
|
|
407
|
+
const promise = loadWithRetry(placementKey, fullScreen, adUnitId, correlationId);
|
|
408
|
+
runtime.inFlightLoad = promise;
|
|
409
|
+
return promise;
|
|
410
|
+
},
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** The error a failed attempt carries, for the retry decision; null for a refusal. */
|
|
415
|
+
function retryableErrorOf(result: LoadResult, key: string): AdsError | null {
|
|
416
|
+
switch (result.status) {
|
|
417
|
+
case 'failed':
|
|
418
|
+
return result.error;
|
|
419
|
+
case 'no_fill':
|
|
420
|
+
return new AdsError('no_fill', { operation: 'load', placementKey: key });
|
|
421
|
+
case 'timeout':
|
|
422
|
+
return new AdsError('load_timeout', { operation: 'load', placementKey: key });
|
|
423
|
+
default:
|
|
424
|
+
return null;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Showing full-screen ads (FR-028, FR-030, FR-034, FR-128) and the foreground
|
|
3
|
+
* re-evaluation of what was loaded.
|
|
4
|
+
*/
|
|
5
|
+
import { isFullScreenFormat } from '../../config/types';
|
|
6
|
+
import { AdsError, normalizeProviderError } from '../../diagnostics/errors';
|
|
7
|
+
import { canShow } from '../placementMachine';
|
|
8
|
+
import { createRewardedSession } from '../rewardedSession';
|
|
9
|
+
import { isStale } from '../timers';
|
|
10
|
+
import { validityFor, type InstanceContext } from './context';
|
|
11
|
+
import { gateRefusal } from './gate';
|
|
12
|
+
import { unknownPlacement } from './load';
|
|
13
|
+
import type { ShowResult } from './types';
|
|
14
|
+
|
|
15
|
+
export function createShower(context: InstanceContext): {
|
|
16
|
+
show(placementKey: string): Promise<ShowResult>;
|
|
17
|
+
setAppActive(active: boolean): void;
|
|
18
|
+
} {
|
|
19
|
+
const { state, engine, events, timers, resolved, lock, adReturn, now } = context;
|
|
20
|
+
|
|
21
|
+
/** Releases and expires a `ready` ad that outlived its validity (FR-034). */
|
|
22
|
+
function expireIfStale(key: string, at: number): boolean {
|
|
23
|
+
const found = context.placement(key);
|
|
24
|
+
if (!found) {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
const { snapshot } = found.runtime;
|
|
28
|
+
if (
|
|
29
|
+
snapshot.state !== 'ready' ||
|
|
30
|
+
snapshot.loadedAtMs === null ||
|
|
31
|
+
!isStale(snapshot.loadedAtMs, at, validityFor(context, found.definition.format))
|
|
32
|
+
) {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
context.releasePlacementResources(key);
|
|
36
|
+
context.dispatchPlacement(key, { type: 'EXPIRE' });
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
async show(placementKey) {
|
|
42
|
+
if (state.disposed) {
|
|
43
|
+
return { status: 'unavailable', reason: 'provider_missing' };
|
|
44
|
+
}
|
|
45
|
+
const found = context.placement(placementKey);
|
|
46
|
+
if (!found) {
|
|
47
|
+
return unknownPlacement(context, placementKey, 'show');
|
|
48
|
+
}
|
|
49
|
+
const { runtime, definition } = found;
|
|
50
|
+
if (!isFullScreenFormat(definition.format)) {
|
|
51
|
+
return {
|
|
52
|
+
status: 'failed',
|
|
53
|
+
error: new AdsError('format_mismatch', {
|
|
54
|
+
operation: 'show',
|
|
55
|
+
placementKey,
|
|
56
|
+
format: definition.format,
|
|
57
|
+
message: `"${placementKey}" is a banner; banners are rendered, not shown`,
|
|
58
|
+
}),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
const refusal = gateRefusal(context);
|
|
62
|
+
if (refusal) {
|
|
63
|
+
context.emitRefusal('show', refusal, placementKey, definition.format);
|
|
64
|
+
return refusal;
|
|
65
|
+
}
|
|
66
|
+
// FR-028: never open an ad while the app is not in front of the user.
|
|
67
|
+
if (!state.appActive) {
|
|
68
|
+
return { status: 'app_inactive' };
|
|
69
|
+
}
|
|
70
|
+
// FR-128. Only app-open is refused: it is the format that would fire on
|
|
71
|
+
// the return from an ad. An interstitial at the same moment is the app's
|
|
72
|
+
// own choice, and refusing it would be policy rather than capability.
|
|
73
|
+
if (definition.format === 'app_open' && adReturn.isRefusing(now())) {
|
|
74
|
+
return { status: 'ad_originated_return' };
|
|
75
|
+
}
|
|
76
|
+
if (runtime.snapshot.state === 'consumed') {
|
|
77
|
+
return { status: 'consumed' };
|
|
78
|
+
}
|
|
79
|
+
// FR-034: a stale ad is not shown and is not reported ready.
|
|
80
|
+
if (expireIfStale(placementKey, now())) {
|
|
81
|
+
return { status: 'not_ready' };
|
|
82
|
+
}
|
|
83
|
+
const handle = runtime.handle;
|
|
84
|
+
if (!canShow(runtime.snapshot.state) || !handle) {
|
|
85
|
+
return { status: 'not_ready' };
|
|
86
|
+
}
|
|
87
|
+
// FR-030. Never queued: a refused show is refused now, not deferred to a
|
|
88
|
+
// moment the app never chose.
|
|
89
|
+
if (!lock.acquire(placementKey)) {
|
|
90
|
+
return { status: 'busy' };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const correlationId = runtime.correlationId ?? context.correlation.next();
|
|
94
|
+
runtime.rewarded = definition.format === 'rewarded' ? createRewardedSession() : null;
|
|
95
|
+
context.dispatchPlacement(placementKey, { type: 'SHOW' });
|
|
96
|
+
|
|
97
|
+
const failShow = (error: AdsError): void => {
|
|
98
|
+
lock.release(placementKey);
|
|
99
|
+
events.emit({
|
|
100
|
+
type: 'display_failed',
|
|
101
|
+
timestamp: now(),
|
|
102
|
+
placementKey,
|
|
103
|
+
format: definition.format,
|
|
104
|
+
correlationId,
|
|
105
|
+
error,
|
|
106
|
+
});
|
|
107
|
+
context.dispatchPlacement(placementKey, { type: 'SHOW_FAILED', error });
|
|
108
|
+
context.releasePlacementResources(placementKey);
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
// ADS-09. Whichever of the provider's answer and the timeout comes first
|
|
112
|
+
// decides the result; the other must not act on it a second time.
|
|
113
|
+
let settled = false;
|
|
114
|
+
const showTimeout = new Promise<ShowResult>(resolve => {
|
|
115
|
+
runtime.cancelShowTimer = timers.after(resolved.timeouts.show, () => {
|
|
116
|
+
runtime.cancelShowTimer = null;
|
|
117
|
+
if (settled) {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
settled = true;
|
|
121
|
+
// The provider may still open the ad. Its handle and listener stay, so
|
|
122
|
+
// a late open, reward or dismissal is still heard and the reward is not
|
|
123
|
+
// lost (FR-049); they go with the next load or with dispose. The lock is
|
|
124
|
+
// released now and taken back if the ad does open.
|
|
125
|
+
lock.release(placementKey);
|
|
126
|
+
const error = new AdsError('show_timeout', {
|
|
127
|
+
operation: 'show',
|
|
128
|
+
placementKey,
|
|
129
|
+
format: definition.format,
|
|
130
|
+
message: `show timed out after ${resolved.timeouts.show}ms`,
|
|
131
|
+
});
|
|
132
|
+
events.emit({
|
|
133
|
+
type: 'display_failed',
|
|
134
|
+
timestamp: now(),
|
|
135
|
+
placementKey,
|
|
136
|
+
format: definition.format,
|
|
137
|
+
correlationId,
|
|
138
|
+
error,
|
|
139
|
+
});
|
|
140
|
+
context.dispatchPlacement(placementKey, { type: 'SHOW_FAILED', error });
|
|
141
|
+
resolve({ status: 'timeout' });
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const attempt = engine
|
|
146
|
+
.show(handle)
|
|
147
|
+
.then<ShowResult>(() => {
|
|
148
|
+
if (settled) {
|
|
149
|
+
// Opened after the timeout was reported. It is on screen now, so it
|
|
150
|
+
// holds the one full-screen lock until it is dismissed (FR-030).
|
|
151
|
+
lock.acquire(placementKey);
|
|
152
|
+
return { status: 'timeout' };
|
|
153
|
+
}
|
|
154
|
+
settled = true;
|
|
155
|
+
runtime.cancelShowTimer?.();
|
|
156
|
+
runtime.cancelShowTimer = null;
|
|
157
|
+
return { status: 'shown', correlationId };
|
|
158
|
+
})
|
|
159
|
+
.catch<ShowResult>(error => {
|
|
160
|
+
if (settled) {
|
|
161
|
+
// Failed after the timeout already said so: nothing new to report,
|
|
162
|
+
// and nothing will show, so its resources can go.
|
|
163
|
+
context.releasePlacementResources(placementKey);
|
|
164
|
+
return { status: 'timeout' };
|
|
165
|
+
}
|
|
166
|
+
settled = true;
|
|
167
|
+
runtime.cancelShowTimer?.();
|
|
168
|
+
runtime.cancelShowTimer = null;
|
|
169
|
+
const normalized = normalizeProviderError(error, {
|
|
170
|
+
operation: 'show',
|
|
171
|
+
placementKey,
|
|
172
|
+
format: definition.format,
|
|
173
|
+
});
|
|
174
|
+
failShow(normalized);
|
|
175
|
+
return { status: 'failed', error: normalized };
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
return Promise.race([attempt, showTimeout]);
|
|
179
|
+
},
|
|
180
|
+
|
|
181
|
+
setAppActive(active) {
|
|
182
|
+
if (state.disposed) {
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
state.appActive = active;
|
|
186
|
+
if (!active) {
|
|
187
|
+
// Arming happens when the provider reports an ad opened; this event can
|
|
188
|
+
// arrive after the ad is gone. All it adds is the browser case, a
|
|
189
|
+
// background closely following a click (FR-128).
|
|
190
|
+
adReturn.noteBackground(now());
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
adReturn.noteForeground(now());
|
|
194
|
+
// FR-034: an ad that went stale while the app was away stops reporting
|
|
195
|
+
// `ready` before the app offers a button that would fail on tap.
|
|
196
|
+
const current = now();
|
|
197
|
+
for (const key of context.placements.keys()) {
|
|
198
|
+
expireIfStale(key, current);
|
|
199
|
+
}
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
}
|