@chipmobilesdk/rn-ads 0.1.0 → 0.2.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 +57 -0
- package/README.md +310 -11
- package/package.json +1 -1
- package/skills/sdk-rn-ads/SKILL.md +98 -98
- package/src/config/types.ts +170 -8
- package/src/config/validate.ts +83 -7
- package/src/core/adOriginatedReturn.ts +134 -0
- package/src/core/adsInstance.ts +565 -42
- package/src/core/consentFlow.ts +167 -0
- package/src/core/enablement.ts +76 -0
- package/src/core/gates.ts +91 -0
- package/src/core/initMachine.ts +73 -7
- package/src/core/retryPolicy.ts +127 -0
- package/src/core/timers.ts +109 -10
- package/src/diagnostics/errors.ts +27 -3
- package/src/diagnostics/events.ts +41 -1
- package/src/engine/admobEngine.ts +144 -2
- package/src/engine/port.ts +44 -0
- package/src/engine/testAdUnits.ts +3 -0
- package/src/index.ts +23 -1
- package/src/react/AdsBanner.tsx +23 -1
- package/src/testing/index.ts +155 -0
package/src/core/adsInstance.ts
CHANGED
|
@@ -20,6 +20,8 @@ import {
|
|
|
20
20
|
isFullScreenFormat,
|
|
21
21
|
type AdFormat,
|
|
22
22
|
type AdsConfig,
|
|
23
|
+
type ConsentSnapshot,
|
|
24
|
+
type Enablement,
|
|
23
25
|
type FullScreenFormat,
|
|
24
26
|
type InitStatus,
|
|
25
27
|
type PlacementDefinition,
|
|
@@ -48,13 +50,26 @@ import {
|
|
|
48
50
|
type PlacementSnapshot,
|
|
49
51
|
} from './placementMachine';
|
|
50
52
|
import { evaluateGate, requiresReinitialization, toEnginePrivacy } from './privacyGate';
|
|
53
|
+
import { evaluateGates, type ConsentGateInput, type GateDecision } from './gates';
|
|
51
54
|
import { createRewardedSession, type RewardedSession } from './rewardedSession';
|
|
52
|
-
import { createTimerSet, isStale } from './timers';
|
|
55
|
+
import { createTimerSet, isStale, type Scheduler } from './timers';
|
|
56
|
+
import { canResume, permitsRequests, type EnablementResult } from './enablement';
|
|
57
|
+
import { canRetry, delayForAttempt } from './retryPolicy';
|
|
58
|
+
import { createAdOriginatedTracker } from './adOriginatedReturn';
|
|
59
|
+
import {
|
|
60
|
+
buildConsentOptions,
|
|
61
|
+
INITIAL_CONSENT,
|
|
62
|
+
needsForm,
|
|
63
|
+
runConsentStep,
|
|
64
|
+
toGateInput,
|
|
65
|
+
} from './consentFlow';
|
|
53
66
|
|
|
54
67
|
export type InitResult =
|
|
55
68
|
| { readonly status: 'ready' }
|
|
56
69
|
| { readonly status: 'blocked'; readonly reason: PrivacyBlockReason }
|
|
57
70
|
| { readonly status: 'disabled' }
|
|
71
|
+
/** The app has not decided whether ads are on. Nothing was requested (FR-115). */
|
|
72
|
+
| { readonly status: 'undecided' }
|
|
58
73
|
| { readonly status: 'unavailable'; readonly reason: UnavailableReason }
|
|
59
74
|
| { readonly status: 'failed'; readonly error: AdsError };
|
|
60
75
|
|
|
@@ -63,11 +78,13 @@ export type PrivacyUpdateResult =
|
|
|
63
78
|
| { readonly status: 'blocked'; readonly reason: PrivacyBlockReason }
|
|
64
79
|
| { readonly status: 'requires_reinitialization' }
|
|
65
80
|
| { readonly status: 'disabled' }
|
|
81
|
+
| { readonly status: 'undecided' }
|
|
66
82
|
| { readonly status: 'unavailable'; readonly reason: UnavailableReason };
|
|
67
83
|
|
|
68
84
|
export type LoadResult =
|
|
69
85
|
| { readonly status: 'loaded'; readonly correlationId: string }
|
|
70
86
|
| { readonly status: 'disabled' }
|
|
87
|
+
| { readonly status: 'undecided' }
|
|
71
88
|
| { readonly status: 'blocked'; readonly reason: PrivacyBlockReason }
|
|
72
89
|
| { readonly status: 'unavailable'; readonly reason: UnavailableReason }
|
|
73
90
|
| { readonly status: 'no_fill' }
|
|
@@ -80,17 +97,54 @@ export type ShowResult =
|
|
|
80
97
|
| { readonly status: 'busy' }
|
|
81
98
|
| { readonly status: 'consumed' }
|
|
82
99
|
| { readonly status: 'app_inactive' }
|
|
100
|
+
/**
|
|
101
|
+
* The user came back to the app from an ad, or from a browser an ad opened,
|
|
102
|
+
* so an app-open ad would be an ad immediately after an ad. Distinct from
|
|
103
|
+
* `busy` and `not_ready` because the app cannot detect this itself and
|
|
104
|
+
* therefore cannot infer it from either of those (FR-128).
|
|
105
|
+
*/
|
|
106
|
+
| { readonly status: 'ad_originated_return' }
|
|
83
107
|
| { readonly status: 'disabled' }
|
|
108
|
+
| { readonly status: 'undecided' }
|
|
84
109
|
| { readonly status: 'blocked'; readonly reason: PrivacyBlockReason }
|
|
85
110
|
| { readonly status: 'unavailable'; readonly reason: UnavailableReason }
|
|
86
111
|
| { readonly status: 'timeout' }
|
|
87
112
|
| { readonly status: 'failed'; readonly error: AdsError };
|
|
88
113
|
|
|
114
|
+
/**
|
|
115
|
+
* The optional consent capability (FR-101).
|
|
116
|
+
*
|
|
117
|
+
* Grouped under its own namespace rather than flattened onto the instance so
|
|
118
|
+
* that FR-102's optionality stays legible: an app that never touches
|
|
119
|
+
* `ads.consent` never starts a flow. Five loose methods on the instance would
|
|
120
|
+
* invite the assumption that they are part of the normal path.
|
|
121
|
+
*/
|
|
122
|
+
export interface ConsentApi {
|
|
123
|
+
/** Refreshes consent information from the provider. */
|
|
124
|
+
requestInfoUpdate(): Promise<ConsentSnapshot>;
|
|
125
|
+
/** Presents the provider's form if one is required. Renders nothing itself. */
|
|
126
|
+
showFormIfRequired(): Promise<ConsentSnapshot>;
|
|
127
|
+
getState(): ConsentSnapshot;
|
|
128
|
+
subscribe(listener: (snapshot: ConsentSnapshot) => void): Unsubscribe;
|
|
129
|
+
/** Clears the stored decision. Inert in production (FR-113). */
|
|
130
|
+
reset(): Promise<void>;
|
|
131
|
+
}
|
|
132
|
+
|
|
89
133
|
export interface AdsInstance {
|
|
90
134
|
initialize(privacy: PrivacyOptions): Promise<InitResult>;
|
|
91
135
|
updatePrivacy(privacy: PrivacyOptions): Promise<PrivacyUpdateResult>;
|
|
92
|
-
|
|
136
|
+
/**
|
|
137
|
+
* Turns ads on or off. Returns a result rather than `void` (FR-122).
|
|
138
|
+
*
|
|
139
|
+
* 0.1.0 returned nothing, which meant an app re-enabling ads after a lapsed
|
|
140
|
+
* subscription had no way to learn that it also had to re-initialize — and
|
|
141
|
+
* every load and show after that was refused in silence.
|
|
142
|
+
*/
|
|
143
|
+
setEnabled(enabled: boolean): EnablementResult;
|
|
144
|
+
/** The same, including the third state the app has not resolved yet. */
|
|
145
|
+
setEnablement(next: Enablement): EnablementResult;
|
|
93
146
|
setAppActive(active: boolean): void;
|
|
147
|
+
readonly consent: ConsentApi;
|
|
94
148
|
getStatus(): InitStatus;
|
|
95
149
|
subscribeStatus(listener: (status: InitStatus) => void): Unsubscribe;
|
|
96
150
|
|
|
@@ -103,6 +157,8 @@ export interface AdsInstance {
|
|
|
103
157
|
|
|
104
158
|
/** FR-011a — substitution is observable state, not only a log line. */
|
|
105
159
|
getSubstitutedPlacements(): readonly string[];
|
|
160
|
+
/** FR-113 — the same treatment for consent tools dropped in production. */
|
|
161
|
+
getIgnoredConsentTools(): readonly string[];
|
|
106
162
|
/** Internal wiring for the banner host; not part of the documented surface. */
|
|
107
163
|
readonly internals: InstanceInternals;
|
|
108
164
|
|
|
@@ -127,6 +183,12 @@ export interface CreateAdsOptions {
|
|
|
127
183
|
/** Injected so the FR-012 rule is testable without touching a global. */
|
|
128
184
|
readonly isDevBuild?: boolean;
|
|
129
185
|
readonly now?: () => number;
|
|
186
|
+
/**
|
|
187
|
+
* Injected so retry backoff, app-open expiry, and the undecided warning can
|
|
188
|
+
* be driven by a test clock instead of by waiting. Production uses real
|
|
189
|
+
* timers.
|
|
190
|
+
*/
|
|
191
|
+
readonly scheduler?: Scheduler;
|
|
130
192
|
}
|
|
131
193
|
|
|
132
194
|
interface PlacementRuntime {
|
|
@@ -150,6 +212,10 @@ interface PlacementRuntime {
|
|
|
150
212
|
cancelShowTimer: Unsubscribe | null;
|
|
151
213
|
rewarded: RewardedSession | null;
|
|
152
214
|
listeners: Set<(state: PlacementState) => void>;
|
|
215
|
+
/** Retries already spent in this load cycle. Reset when a new cycle starts. */
|
|
216
|
+
retryAttempts: number;
|
|
217
|
+
/** Cancels a scheduled retry. Called by disable, undecided, and dispose. */
|
|
218
|
+
cancelRetry: Unsubscribe | null;
|
|
153
219
|
}
|
|
154
220
|
|
|
155
221
|
function detectDevBuild(): boolean {
|
|
@@ -167,12 +233,34 @@ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): Ad
|
|
|
167
233
|
const engine: AdsEngine = options.engine ?? createDefaultEngine();
|
|
168
234
|
const events = createEventEmitter();
|
|
169
235
|
const correlation = createCorrelationIssuer();
|
|
170
|
-
const timers = createTimerSet();
|
|
236
|
+
const timers = createTimerSet(options.scheduler);
|
|
171
237
|
const lock = createFullScreenLock();
|
|
238
|
+
const adReturn = createAdOriginatedTracker();
|
|
172
239
|
|
|
173
|
-
|
|
174
|
-
|
|
240
|
+
/**
|
|
241
|
+
* An instance told it is `undecided` says so from the moment it exists.
|
|
242
|
+
*
|
|
243
|
+
* Without this it reports `not_started` until `initialize()` runs, which is
|
|
244
|
+
* indistinguishable from a disabled instance at exactly the point an app is
|
|
245
|
+
* deciding whether to reserve the banner's space — so FR-116 would hold only
|
|
246
|
+
* after a call the app has no reason to make yet. Found on device, not in a
|
|
247
|
+
* unit test, because the demo screen defers initialization the way a real
|
|
248
|
+
* entitlement-gated app does.
|
|
249
|
+
*
|
|
250
|
+
* `disabled` deliberately keeps 0.1.0's `not_started` here: that behavior is
|
|
251
|
+
* observable today and BC-004 protects it. The asymmetry is the cost of not
|
|
252
|
+
* changing something that already shipped.
|
|
253
|
+
*/
|
|
254
|
+
let init: InitSnapshot =
|
|
255
|
+
resolved.enablement === 'undecided'
|
|
256
|
+
? initReducer(initialInit, { type: 'UNDECIDE' })
|
|
257
|
+
: initialInit;
|
|
258
|
+
let enablement: Enablement = resolved.enablement;
|
|
175
259
|
let appActive = true;
|
|
260
|
+
let consent: ConsentSnapshot = INITIAL_CONSENT;
|
|
261
|
+
let consentStarted = false;
|
|
262
|
+
let undecidedWarned = false;
|
|
263
|
+
let cancelUndecidedTimer: Unsubscribe | null = null;
|
|
176
264
|
let appliedPrivacy: PrivacyOptions | null = null;
|
|
177
265
|
let currentPrivacy: PrivacyOptions | null = null;
|
|
178
266
|
let inFlightInit: Promise<InitResult> | null = null;
|
|
@@ -194,9 +282,78 @@ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): Ad
|
|
|
194
282
|
cancelShowTimer: null,
|
|
195
283
|
rewarded: null,
|
|
196
284
|
listeners: new Set(),
|
|
285
|
+
retryAttempts: 0,
|
|
286
|
+
cancelRetry: null,
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const consentListeners = new Set<(snapshot: ConsentSnapshot) => void>();
|
|
291
|
+
|
|
292
|
+
function setConsent(next: ConsentSnapshot): void {
|
|
293
|
+
consent = next;
|
|
294
|
+
for (const listener of Array.from(consentListeners)) {
|
|
295
|
+
try {
|
|
296
|
+
listener(next);
|
|
297
|
+
} catch {
|
|
298
|
+
// Consumer code; isolated exactly as event listeners are (FR-056).
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* The consent flow's contribution to the gate, or `null` when the app never
|
|
305
|
+
* opted in.
|
|
306
|
+
*
|
|
307
|
+
* `null` is what makes FR-102 structural rather than conditional: with no
|
|
308
|
+
* flow started, the gate sees only the app's own input and behaves exactly as
|
|
309
|
+
* 0.1.0 did. There is no code path to skip and therefore none to forget.
|
|
310
|
+
*/
|
|
311
|
+
function consentGateInput(): ConsentGateInput {
|
|
312
|
+
return consentStarted ? toGateInput(consent) : null;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Warns once if the app leaves `undecided` unresolved (FR-119).
|
|
317
|
+
*
|
|
318
|
+
* Once, not repeatedly: a warning that fires every ten seconds becomes noise
|
|
319
|
+
* that gets filtered, which defeats the diagnostic. And it only warns — the
|
|
320
|
+
* package never resolves the state itself, because doing so would mean
|
|
321
|
+
* guessing at exactly the business decision FR-120 forbids it from knowing.
|
|
322
|
+
*/
|
|
323
|
+
function armUndecidedWarning(): void {
|
|
324
|
+
if (enablement !== 'undecided' || undecidedWarned || cancelUndecidedTimer) {
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
const armedAt = now();
|
|
328
|
+
cancelUndecidedTimer = timers.after(resolved.timeouts.undecidedWarning, () => {
|
|
329
|
+
cancelUndecidedTimer = null;
|
|
330
|
+
if (disposed || enablement !== 'undecided' || undecidedWarned) {
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
undecidedWarned = true;
|
|
334
|
+
events.emit({
|
|
335
|
+
type: 'config_warning',
|
|
336
|
+
timestamp: now(),
|
|
337
|
+
warning: 'enablement_undecided_timeout',
|
|
338
|
+
placementKeys: [],
|
|
339
|
+
elapsedMs: now() - armedAt,
|
|
340
|
+
});
|
|
197
341
|
});
|
|
198
342
|
}
|
|
199
343
|
|
|
344
|
+
function disarmUndecidedWarning(): void {
|
|
345
|
+
cancelUndecidedTimer?.();
|
|
346
|
+
cancelUndecidedTimer = null;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** Cancels every pending retry, everywhere (FR-137). */
|
|
350
|
+
function cancelAllRetries(): void {
|
|
351
|
+
for (const runtime of placements.values()) {
|
|
352
|
+
runtime.cancelRetry?.();
|
|
353
|
+
runtime.cancelRetry = null;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
200
357
|
function setInit(next: InitSnapshot): void {
|
|
201
358
|
if (next === init || next.status === init.status) {
|
|
202
359
|
init = next;
|
|
@@ -246,24 +403,67 @@ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): Ad
|
|
|
246
403
|
setPlacement(key, placementReducer(runtime.snapshot, event, definition.format));
|
|
247
404
|
}
|
|
248
405
|
|
|
406
|
+
/**
|
|
407
|
+
* The single gate evaluation (FR-140).
|
|
408
|
+
*
|
|
409
|
+
* Assembles the three inputs and defers the precedence entirely to
|
|
410
|
+
* `evaluateGates`. Nothing in this file compares gate state itself — the
|
|
411
|
+
* whole point of FR-140 is that the order is a decision made once, not four
|
|
412
|
+
* `if` chains that happen to agree today.
|
|
413
|
+
*
|
|
414
|
+
* Note the order differs from 0.1.0's, which checked `enabled` before
|
|
415
|
+
* availability. That was never observable in isolation, only when two gates
|
|
416
|
+
* were closed at once, and the case it changes — disabled on an unsupported
|
|
417
|
+
* platform, previously decided by whichever transition arrived last — is the
|
|
418
|
+
* incidental ordering FR-140a exists to fix.
|
|
419
|
+
*/
|
|
420
|
+
function currentGate(): GateDecision {
|
|
421
|
+
return evaluateGates({
|
|
422
|
+
available: engine.isAvailable()
|
|
423
|
+
? { ok: true }
|
|
424
|
+
: { ok: false, reason: 'unsupported_platform' },
|
|
425
|
+
enablement,
|
|
426
|
+
appGate: (() => {
|
|
427
|
+
const gate = evaluateGate(currentPrivacy);
|
|
428
|
+
return gate.allowed
|
|
429
|
+
? ({ permitted: true } as const)
|
|
430
|
+
: ({ permitted: false, reason: gate.reason } as const);
|
|
431
|
+
})(),
|
|
432
|
+
consentGate: consentGateInput(),
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** Maps a closed gate onto the refusal shape load, show, and the banner share. */
|
|
437
|
+
function refusalFor(
|
|
438
|
+
decision: Extract<GateDecision, { allowed: false }>,
|
|
439
|
+
): Extract<LoadResult, { status: 'disabled' | 'undecided' | 'blocked' | 'unavailable' }> {
|
|
440
|
+
switch (decision.kind) {
|
|
441
|
+
case 'unavailable':
|
|
442
|
+
return { status: 'unavailable', reason: decision.reason };
|
|
443
|
+
case 'disabled':
|
|
444
|
+
return { status: 'disabled' };
|
|
445
|
+
case 'undecided':
|
|
446
|
+
return { status: 'undecided' };
|
|
447
|
+
default:
|
|
448
|
+
return { status: 'blocked', reason: decision.reason };
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
249
452
|
/** Every refusal that applies before a placement is even looked up. */
|
|
250
453
|
function gateCheck():
|
|
251
454
|
| { readonly ok: true }
|
|
252
|
-
| {
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
const gate = evaluateGate(currentPrivacy);
|
|
263
|
-
if (!gate.allowed) {
|
|
264
|
-
return { ok: false, result: { status: 'blocked', reason: gate.reason } };
|
|
455
|
+
| {
|
|
456
|
+
readonly ok: false;
|
|
457
|
+
readonly result: Extract<
|
|
458
|
+
LoadResult,
|
|
459
|
+
{ status: 'disabled' | 'undecided' | 'blocked' | 'unavailable' }
|
|
460
|
+
>;
|
|
461
|
+
} {
|
|
462
|
+
const decision = currentGate();
|
|
463
|
+
if (decision.allowed) {
|
|
464
|
+
return { ok: true };
|
|
265
465
|
}
|
|
266
|
-
return { ok:
|
|
466
|
+
return { ok: false, result: refusalFor(decision) };
|
|
267
467
|
}
|
|
268
468
|
|
|
269
469
|
function emitRefusal(
|
|
@@ -272,7 +472,11 @@ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): Ad
|
|
|
272
472
|
placementKey?: string,
|
|
273
473
|
format?: AdFormat,
|
|
274
474
|
): void {
|
|
275
|
-
if (result.status === 'disabled') {
|
|
475
|
+
if (result.status === 'disabled' || result.status === 'undecided') {
|
|
476
|
+
// `undecided` reuses the `disabled` event rather than minting a new
|
|
477
|
+
// one: for an observer of the event stream the fact is the same — no
|
|
478
|
+
// request was made — and the distinction that matters is carried by
|
|
479
|
+
// the typed result and the init status, where the app reads it.
|
|
276
480
|
events.emit({ type: 'disabled', timestamp: now(), operation, placementKey, format });
|
|
277
481
|
} else if (result.status === 'unavailable' || result.status === 'blocked') {
|
|
278
482
|
events.emit({
|
|
@@ -316,6 +520,15 @@ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): Ad
|
|
|
316
520
|
}
|
|
317
521
|
runtime.inFlightLoad = null;
|
|
318
522
|
runtime.rewarded = null;
|
|
523
|
+
runtime.cancelRetry?.();
|
|
524
|
+
runtime.cancelRetry = null;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/** App-open expires on its own, shorter window (FR-130). */
|
|
528
|
+
function validityFor(format: AdFormat): number {
|
|
529
|
+
return format === 'app_open'
|
|
530
|
+
? resolved.timeouts.appOpenValidity
|
|
531
|
+
: resolved.timeouts.adValidity;
|
|
319
532
|
}
|
|
320
533
|
|
|
321
534
|
async function doInitialize(privacy: PrivacyOptions): Promise<InitResult> {
|
|
@@ -356,6 +569,13 @@ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): Ad
|
|
|
356
569
|
|
|
357
570
|
switch (event.type) {
|
|
358
571
|
case 'opened': {
|
|
572
|
+
// The provider confirming an ad is on screen is what arms
|
|
573
|
+
// ad-originated return detection (FR-128). Taken from here rather
|
|
574
|
+
// than from the app's background report, which arrives too late to
|
|
575
|
+
// be trusted — see core/adOriginatedReturn.ts.
|
|
576
|
+
if (isFullScreenFormat(definition.format)) {
|
|
577
|
+
adReturn.noteFullScreenShown(now());
|
|
578
|
+
}
|
|
359
579
|
events.emit({ ...base, type: 'display_started' });
|
|
360
580
|
// R5: the provider exposes no impression callback for full-screen
|
|
361
581
|
// formats, so this one is derived from the ad opening. Labelled
|
|
@@ -364,6 +584,10 @@ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): Ad
|
|
|
364
584
|
break;
|
|
365
585
|
}
|
|
366
586
|
case 'clicked':
|
|
587
|
+
// Recorded so that a background transition moments later is
|
|
588
|
+
// recognized as "the ad opened a browser" — the only signal the
|
|
589
|
+
// provider gives for that case (FR-128).
|
|
590
|
+
adReturn.noteClick(now());
|
|
367
591
|
events.emit({ ...base, type: 'click' });
|
|
368
592
|
break;
|
|
369
593
|
case 'rewarded': {
|
|
@@ -532,6 +756,118 @@ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): Ad
|
|
|
532
756
|
});
|
|
533
757
|
}
|
|
534
758
|
|
|
759
|
+
/**
|
|
760
|
+
* One load, plus however many bounded retries the policy allows (FR-134).
|
|
761
|
+
*
|
|
762
|
+
* Off unless configured, so an upgraded 0.1.0 consumer goes through here once
|
|
763
|
+
* and issues exactly the requests it always did. When on, only errors already
|
|
764
|
+
* flagged `retryable` are retried — a configuration or consent failure is a
|
|
765
|
+
* state another attempt cannot change, and retrying it would spend a billable
|
|
766
|
+
* request on a certainty (FR-135).
|
|
767
|
+
*
|
|
768
|
+
* Every attempt is emitted before it is waited on, carrying the cycle's
|
|
769
|
+
* correlation id, so the event stream can be reconciled against a bill
|
|
770
|
+
* without inferring anything (FR-138).
|
|
771
|
+
*/
|
|
772
|
+
async function loadWithRetry(
|
|
773
|
+
key: string,
|
|
774
|
+
definition: PlacementDefinition,
|
|
775
|
+
adUnitId: string,
|
|
776
|
+
correlationId: string,
|
|
777
|
+
): Promise<LoadResult> {
|
|
778
|
+
const runtime = placements.get(key)!;
|
|
779
|
+
const policy = resolved.retryByKey.get(key) ?? null;
|
|
780
|
+
|
|
781
|
+
for (;;) {
|
|
782
|
+
const result = await loadFullScreen(key, definition, adUnitId, correlationId);
|
|
783
|
+
if (result.status === 'loaded' || disposed) {
|
|
784
|
+
return result;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
const error =
|
|
788
|
+
result.status === 'failed'
|
|
789
|
+
? result.error
|
|
790
|
+
: result.status === 'no_fill'
|
|
791
|
+
? new AdsError('no_fill', { operation: 'load', placementKey: key })
|
|
792
|
+
: result.status === 'timeout'
|
|
793
|
+
? new AdsError('load_timeout', { operation: 'load', placementKey: key })
|
|
794
|
+
: null;
|
|
795
|
+
|
|
796
|
+
// A refusal — disabled, undecided, blocked, unavailable — is not a
|
|
797
|
+
// failure to retry. Nothing was requested, and nothing about asking
|
|
798
|
+
// again would change that.
|
|
799
|
+
if (!error || !error.retryable) {
|
|
800
|
+
return result;
|
|
801
|
+
}
|
|
802
|
+
if (!canRetry(policy, runtime.retryAttempts) || !permitsRequests(enablement)) {
|
|
803
|
+
return result;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
runtime.retryAttempts += 1;
|
|
807
|
+
const delayMs = delayForAttempt(policy!, runtime.retryAttempts);
|
|
808
|
+
events.emit({
|
|
809
|
+
type: 'retry_scheduled',
|
|
810
|
+
timestamp: now(),
|
|
811
|
+
placementKey: key,
|
|
812
|
+
format: definition.format,
|
|
813
|
+
correlationId,
|
|
814
|
+
attempt: runtime.retryAttempts,
|
|
815
|
+
delayMs,
|
|
816
|
+
error,
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
const waited = await new Promise<boolean>(resolve => {
|
|
820
|
+
runtime.cancelRetry = timers.after(delayMs, () => {
|
|
821
|
+
runtime.cancelRetry = null;
|
|
822
|
+
resolve(true);
|
|
823
|
+
});
|
|
824
|
+
const cancel = runtime.cancelRetry;
|
|
825
|
+
// Cancellation replaces the handle, so a disable or dispose that
|
|
826
|
+
// clears it resolves this wait as "do not continue" rather than
|
|
827
|
+
// leaving a promise nobody will settle.
|
|
828
|
+
runtime.cancelRetry = () => {
|
|
829
|
+
cancel?.();
|
|
830
|
+
runtime.cancelRetry = null;
|
|
831
|
+
resolve(false);
|
|
832
|
+
};
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
if (!waited || disposed || !permitsRequests(enablement)) {
|
|
836
|
+
return result;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
dispatchPlacement(key, { type: 'LOAD' });
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
/**
|
|
844
|
+
* Applies a consent outcome to the gate without a second initialization.
|
|
845
|
+
*
|
|
846
|
+
* This is FR-105's "the app does not wire it by hand" in one function. When
|
|
847
|
+
* consent opens the gate and the instance was blocked, it initializes — and
|
|
848
|
+
* because `initialize` is idempotent and shares one in-flight operation, the
|
|
849
|
+
* total native initialization count across a whole consent flow stays at one
|
|
850
|
+
* (FR-022, SC-102). When consent closes the gate, requests stop and every
|
|
851
|
+
* pending retry is cancelled with them (FR-137).
|
|
852
|
+
*/
|
|
853
|
+
async function reevaluateAfterConsent(): Promise<void> {
|
|
854
|
+
if (disposed || !currentPrivacy) {
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
857
|
+
const decision = currentGate();
|
|
858
|
+
if (!decision.allowed) {
|
|
859
|
+
cancelAllRetries();
|
|
860
|
+
if (decision.kind === 'blocked' && init.status !== 'ready') {
|
|
861
|
+
setInit(initReducer(init, { type: 'BLOCK', reason: decision.reason }));
|
|
862
|
+
}
|
|
863
|
+
return;
|
|
864
|
+
}
|
|
865
|
+
if (init.status === 'ready' || init.status === 'initializing') {
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
868
|
+
await instance.initialize(currentPrivacy);
|
|
869
|
+
}
|
|
870
|
+
|
|
535
871
|
const instance: AdsInstance = {
|
|
536
872
|
async initialize(privacy) {
|
|
537
873
|
if (disposed) {
|
|
@@ -539,16 +875,28 @@ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): Ad
|
|
|
539
875
|
}
|
|
540
876
|
currentPrivacy = privacy;
|
|
541
877
|
|
|
542
|
-
|
|
878
|
+
// Availability first, then enablement, then consent — the one order,
|
|
879
|
+
// evaluated in the one place (FR-140).
|
|
880
|
+
const decision = currentGate();
|
|
881
|
+
if (!decision.allowed && decision.kind === 'unavailable') {
|
|
882
|
+
setInit(initReducer(init, { type: 'UNAVAILABLE', reason: decision.reason }));
|
|
883
|
+
const result = { status: 'unavailable', reason: decision.reason } as const;
|
|
884
|
+
emitRefusal('initialize', result);
|
|
885
|
+
return result;
|
|
886
|
+
}
|
|
887
|
+
if (!decision.allowed && decision.kind === 'disabled') {
|
|
543
888
|
setInit(initReducer(init, { type: 'DISABLE' }));
|
|
544
889
|
const result = { status: 'disabled' } as const;
|
|
545
890
|
emitRefusal('initialize', result);
|
|
546
891
|
return result;
|
|
547
892
|
}
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
893
|
+
if (!decision.allowed && decision.kind === 'undecided') {
|
|
894
|
+
// FR-115. Nothing provider-facing happens, exactly as when
|
|
895
|
+
// disabled — but the status the app observes says `undecided`, so
|
|
896
|
+
// a banner host can hold its space instead of reclaiming it.
|
|
897
|
+
setInit(initReducer(init, { type: 'UNDECIDE' }));
|
|
898
|
+
armUndecidedWarning();
|
|
899
|
+
const result = { status: 'undecided' } as const;
|
|
552
900
|
emitRefusal('initialize', result);
|
|
553
901
|
return result;
|
|
554
902
|
}
|
|
@@ -582,7 +930,13 @@ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): Ad
|
|
|
582
930
|
},
|
|
583
931
|
|
|
584
932
|
async updatePrivacy(privacy) {
|
|
585
|
-
if (disposed
|
|
933
|
+
if (disposed) {
|
|
934
|
+
return { status: 'disabled' };
|
|
935
|
+
}
|
|
936
|
+
if (enablement === 'undecided') {
|
|
937
|
+
return { status: 'undecided' };
|
|
938
|
+
}
|
|
939
|
+
if (enablement === 'disabled') {
|
|
586
940
|
return { status: 'disabled' };
|
|
587
941
|
}
|
|
588
942
|
if (!engine.isAvailable()) {
|
|
@@ -616,15 +970,27 @@ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): Ad
|
|
|
616
970
|
},
|
|
617
971
|
|
|
618
972
|
setEnabled(next) {
|
|
619
|
-
|
|
620
|
-
|
|
973
|
+
return instance.setEnablement(next ? 'enabled' : 'disabled');
|
|
974
|
+
},
|
|
975
|
+
|
|
976
|
+
setEnablement(next) {
|
|
977
|
+
if (disposed) {
|
|
978
|
+
return { status: 'unavailable', reason: 'provider_missing' };
|
|
979
|
+
}
|
|
980
|
+
if (enablement === next) {
|
|
981
|
+
return { status: 'unchanged' };
|
|
621
982
|
}
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
//
|
|
983
|
+
|
|
984
|
+
const previous = enablement;
|
|
985
|
+
enablement = next;
|
|
986
|
+
|
|
987
|
+
if (next === 'disabled' || next === 'undecided') {
|
|
988
|
+
// FR-037 and FR-125: new work is prevented and in-flight resources
|
|
989
|
+
// released deterministically, and moving to `undecided` from
|
|
990
|
+
// `enabled` follows the same rules as disabling. An ad already on
|
|
991
|
+
// screen is left alone — the provider offers no safe way to close
|
|
992
|
+
// it, and yanking it would be worse than letting it finish.
|
|
993
|
+
cancelAllRetries();
|
|
628
994
|
for (const [key, runtime] of placements) {
|
|
629
995
|
if (runtime.snapshot.state === 'showing') {
|
|
630
996
|
continue;
|
|
@@ -632,10 +998,39 @@ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): Ad
|
|
|
632
998
|
releasePlacementResources(key);
|
|
633
999
|
dispatchPlacement(key, { type: 'RESET' });
|
|
634
1000
|
}
|
|
635
|
-
setInit(initReducer(init, { type: 'DISABLE' }));
|
|
636
|
-
|
|
637
|
-
|
|
1001
|
+
setInit(initReducer(init, { type: next === 'disabled' ? 'DISABLE' : 'UNDECIDE' }));
|
|
1002
|
+
if (next === 'undecided') {
|
|
1003
|
+
armUndecidedWarning();
|
|
1004
|
+
return { status: 'undecided' };
|
|
1005
|
+
}
|
|
1006
|
+
disarmUndecidedWarning();
|
|
1007
|
+
return { status: 'disabled' };
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
// Turning ads on. This is the path 0.1.0 got wrong: it dropped the
|
|
1011
|
+
// status to `not_started` and returned nothing, so an app whose user
|
|
1012
|
+
// lost their entitlement turned ads back on and then had every load
|
|
1013
|
+
// silently refused (FR-121).
|
|
1014
|
+
disarmUndecidedWarning();
|
|
1015
|
+
const resumable = canResume(init.resumeTo);
|
|
1016
|
+
setInit(initReducer(init, { type: 'ENABLE' }));
|
|
1017
|
+
|
|
1018
|
+
if (init.status === 'unavailable') {
|
|
1019
|
+
return { status: 'unavailable', reason: init.unavailableReason ?? 'unsupported_platform' };
|
|
1020
|
+
}
|
|
1021
|
+
if (resumable && init.status === 'ready') {
|
|
1022
|
+
// The provider was never un-initialized, so there is genuinely
|
|
1023
|
+
// nothing to redo — and FR-022 forbids initializing it twice.
|
|
1024
|
+
return { status: 'resumed', initStatus: init.status };
|
|
1025
|
+
}
|
|
1026
|
+
if (previous === 'undecided' && init.status === 'ready') {
|
|
1027
|
+
return { status: 'resumed', initStatus: init.status };
|
|
638
1028
|
}
|
|
1029
|
+
// Said out loud rather than left for the app to discover from a
|
|
1030
|
+
// refused load. The package will not initialize implicitly: that would
|
|
1031
|
+
// hide an asynchronous, billable operation behind a setter the caller
|
|
1032
|
+
// may not be awaiting.
|
|
1033
|
+
return { status: 'requires_initialization' };
|
|
639
1034
|
},
|
|
640
1035
|
|
|
641
1036
|
setAppActive(active) {
|
|
@@ -644,17 +1039,25 @@ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): Ad
|
|
|
644
1039
|
}
|
|
645
1040
|
appActive = active;
|
|
646
1041
|
if (!active) {
|
|
1042
|
+
// Arming happens when the provider reports an ad opened, not here:
|
|
1043
|
+
// this event can be delivered late enough that the ad is already
|
|
1044
|
+
// gone. All this adds is the browser case, where a background
|
|
1045
|
+
// closely follows a click (FR-128).
|
|
1046
|
+
adReturn.noteBackground(now());
|
|
647
1047
|
return;
|
|
648
1048
|
}
|
|
1049
|
+
adReturn.noteForeground(now());
|
|
649
1050
|
// FR-034: on return to the foreground, re-evaluate what was loaded.
|
|
650
1051
|
// An ad that went stale while the app was away must stop reporting
|
|
651
1052
|
// `ready` before the app offers a button that would fail on tap.
|
|
652
1053
|
const current = now();
|
|
653
1054
|
for (const [key, runtime] of placements) {
|
|
1055
|
+
const definition = resolved.placements.get(key);
|
|
654
1056
|
if (
|
|
655
1057
|
runtime.snapshot.state === 'ready' &&
|
|
656
1058
|
runtime.snapshot.loadedAtMs !== null &&
|
|
657
|
-
|
|
1059
|
+
definition !== undefined &&
|
|
1060
|
+
isStale(runtime.snapshot.loadedAtMs, current, validityFor(definition.format))
|
|
658
1061
|
) {
|
|
659
1062
|
releasePlacementResources(key);
|
|
660
1063
|
dispatchPlacement(key, { type: 'EXPIRE' });
|
|
@@ -760,7 +1163,8 @@ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): Ad
|
|
|
760
1163
|
});
|
|
761
1164
|
|
|
762
1165
|
const adUnitId = resolved.catalog.byKey.get(placementKey)!;
|
|
763
|
-
|
|
1166
|
+
runtime.retryAttempts = 0;
|
|
1167
|
+
const promise = loadWithRetry(placementKey, definition, adUnitId, correlationId);
|
|
764
1168
|
runtime.inFlightLoad = promise;
|
|
765
1169
|
return promise;
|
|
766
1170
|
},
|
|
@@ -805,6 +1209,16 @@ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): Ad
|
|
|
805
1209
|
return { status: 'app_inactive' };
|
|
806
1210
|
}
|
|
807
1211
|
|
|
1212
|
+
// FR-128. Only app-open is refused: the user just came back from an ad
|
|
1213
|
+
// or from a browser an ad opened, and app-open is the format that
|
|
1214
|
+
// would fire on exactly that return. An interstitial at the same
|
|
1215
|
+
// moment is the app's own deliberate choice, made somewhere the
|
|
1216
|
+
// package cannot see, so refusing it would be policy rather than
|
|
1217
|
+
// capability.
|
|
1218
|
+
if (definition.format === 'app_open' && adReturn.isRefusing(now())) {
|
|
1219
|
+
return { status: 'ad_originated_return' };
|
|
1220
|
+
}
|
|
1221
|
+
|
|
808
1222
|
const runtime = placements.get(placementKey)!;
|
|
809
1223
|
|
|
810
1224
|
if (runtime.snapshot.state === 'consumed') {
|
|
@@ -815,7 +1229,7 @@ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): Ad
|
|
|
815
1229
|
if (
|
|
816
1230
|
runtime.snapshot.state === 'ready' &&
|
|
817
1231
|
runtime.snapshot.loadedAtMs !== null &&
|
|
818
|
-
isStale(runtime.snapshot.loadedAtMs, now(),
|
|
1232
|
+
isStale(runtime.snapshot.loadedAtMs, now(), validityFor(definition.format))
|
|
819
1233
|
) {
|
|
820
1234
|
releasePlacementResources(placementKey);
|
|
821
1235
|
dispatchPlacement(placementKey, { type: 'EXPIRE' });
|
|
@@ -903,9 +1317,15 @@ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): Ad
|
|
|
903
1317
|
if (!resolved.placements.has(placementKey)) {
|
|
904
1318
|
return 'unavailable';
|
|
905
1319
|
}
|
|
906
|
-
if (!
|
|
1320
|
+
if (!engine.isAvailable() || enablement === 'disabled') {
|
|
907
1321
|
return 'unavailable';
|
|
908
1322
|
}
|
|
1323
|
+
// Reported distinctly from `unavailable` so the app can choose to hold
|
|
1324
|
+
// the banner's space rather than reclaim it. That choice is the entire
|
|
1325
|
+
// reason the third state is worth having (FR-116).
|
|
1326
|
+
if (enablement === 'undecided') {
|
|
1327
|
+
return 'undecided';
|
|
1328
|
+
}
|
|
909
1329
|
return placements.get(placementKey)?.snapshot.state ?? 'idle';
|
|
910
1330
|
},
|
|
911
1331
|
|
|
@@ -933,6 +1353,105 @@ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): Ad
|
|
|
933
1353
|
return resolved.catalog.substitutedKeys;
|
|
934
1354
|
},
|
|
935
1355
|
|
|
1356
|
+
getIgnoredConsentTools() {
|
|
1357
|
+
return resolved.ignoredConsentTools;
|
|
1358
|
+
},
|
|
1359
|
+
|
|
1360
|
+
consent: {
|
|
1361
|
+
async requestInfoUpdate() {
|
|
1362
|
+
if (disposed) {
|
|
1363
|
+
return consent;
|
|
1364
|
+
}
|
|
1365
|
+
// Marks the flow as in use. Until this is called the gate sees no
|
|
1366
|
+
// consent input at all, which is how an app that supplies its own
|
|
1367
|
+
// gate keeps 0.1.0 behavior without opting out of anything
|
|
1368
|
+
// (FR-102).
|
|
1369
|
+
consentStarted = true;
|
|
1370
|
+
if (resolved.consentTesting?.resetOnStart) {
|
|
1371
|
+
try {
|
|
1372
|
+
engine.consent.reset();
|
|
1373
|
+
} catch {
|
|
1374
|
+
// A reset that fails must not abort the update; the flow
|
|
1375
|
+
// continues against whatever state is stored.
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
const next = await runConsentStep(
|
|
1379
|
+
() =>
|
|
1380
|
+
engine.consent.requestInfoUpdate(
|
|
1381
|
+
buildConsentOptions(
|
|
1382
|
+
resolved.consentTesting,
|
|
1383
|
+
currentPrivacy?.underAgeOfConsent,
|
|
1384
|
+
resolved.testDeviceIds,
|
|
1385
|
+
),
|
|
1386
|
+
),
|
|
1387
|
+
'consent information update failed',
|
|
1388
|
+
);
|
|
1389
|
+
if (disposed) {
|
|
1390
|
+
return consent;
|
|
1391
|
+
}
|
|
1392
|
+
setConsent(next);
|
|
1393
|
+
await reevaluateAfterConsent();
|
|
1394
|
+
return next;
|
|
1395
|
+
},
|
|
1396
|
+
|
|
1397
|
+
async showFormIfRequired() {
|
|
1398
|
+
if (disposed) {
|
|
1399
|
+
return consent;
|
|
1400
|
+
}
|
|
1401
|
+
consentStarted = true;
|
|
1402
|
+
if (!needsForm(consent)) {
|
|
1403
|
+
// Nothing to present. Not an error: a region that does not
|
|
1404
|
+
// require consent is the normal case, and showing a form there
|
|
1405
|
+
// is exactly what FR-107 forbids the package from deciding.
|
|
1406
|
+
return consent;
|
|
1407
|
+
}
|
|
1408
|
+
const next = await runConsentStep(
|
|
1409
|
+
() => engine.consent.showFormIfRequired(),
|
|
1410
|
+
'consent form failed',
|
|
1411
|
+
);
|
|
1412
|
+
if (disposed) {
|
|
1413
|
+
return consent;
|
|
1414
|
+
}
|
|
1415
|
+
setConsent(next);
|
|
1416
|
+
await reevaluateAfterConsent();
|
|
1417
|
+
return next;
|
|
1418
|
+
},
|
|
1419
|
+
|
|
1420
|
+
getState() {
|
|
1421
|
+
return consent;
|
|
1422
|
+
},
|
|
1423
|
+
|
|
1424
|
+
subscribe(listener) {
|
|
1425
|
+
consentListeners.add(listener);
|
|
1426
|
+
let active = true;
|
|
1427
|
+
return () => {
|
|
1428
|
+
if (!active) {
|
|
1429
|
+
return;
|
|
1430
|
+
}
|
|
1431
|
+
active = false;
|
|
1432
|
+
consentListeners.delete(listener);
|
|
1433
|
+
};
|
|
1434
|
+
},
|
|
1435
|
+
|
|
1436
|
+
async reset() {
|
|
1437
|
+
if (disposed) {
|
|
1438
|
+
return;
|
|
1439
|
+
}
|
|
1440
|
+
// Config validation already stripped the testing tools in
|
|
1441
|
+
// production, so this is inert there without a check here (FR-113).
|
|
1442
|
+
if (!resolved.consentTesting) {
|
|
1443
|
+
return;
|
|
1444
|
+
}
|
|
1445
|
+
try {
|
|
1446
|
+
engine.consent.reset();
|
|
1447
|
+
} catch {
|
|
1448
|
+
// Nothing to report: the stored state is the provider's, and a
|
|
1449
|
+
// failed reset leaves it as it was.
|
|
1450
|
+
}
|
|
1451
|
+
setConsent(INITIAL_CONSENT);
|
|
1452
|
+
},
|
|
1453
|
+
},
|
|
1454
|
+
|
|
936
1455
|
internals: {
|
|
937
1456
|
resolveBanner(placementKey) {
|
|
938
1457
|
const definition = resolved.placements.get(placementKey);
|
|
@@ -986,7 +1505,7 @@ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): Ad
|
|
|
986
1505
|
}
|
|
987
1506
|
},
|
|
988
1507
|
isReady() {
|
|
989
|
-
return
|
|
1508
|
+
return permitsRequests(enablement) && engine.isAvailable() && canRequest(init.status);
|
|
990
1509
|
},
|
|
991
1510
|
nextCorrelationId() {
|
|
992
1511
|
return correlation.next();
|
|
@@ -1002,13 +1521,17 @@ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): Ad
|
|
|
1002
1521
|
return;
|
|
1003
1522
|
}
|
|
1004
1523
|
disposed = true;
|
|
1524
|
+
cancelAllRetries();
|
|
1525
|
+
disarmUndecidedWarning();
|
|
1005
1526
|
for (const key of placements.keys()) {
|
|
1006
1527
|
releasePlacementResources(key);
|
|
1007
1528
|
placements.get(key)?.listeners.clear();
|
|
1008
1529
|
}
|
|
1009
1530
|
timers.clearAll();
|
|
1010
1531
|
lock.reset();
|
|
1532
|
+
adReturn.reset();
|
|
1011
1533
|
statusListeners.clear();
|
|
1534
|
+
consentListeners.clear();
|
|
1012
1535
|
events.dispose();
|
|
1013
1536
|
inFlightInit = null;
|
|
1014
1537
|
},
|