@chipmobilesdk/rn-ads 0.4.0 → 0.5.1
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 +224 -196
- package/README.md +6 -1
- package/package.json +2 -5
- package/skills/sdk-rn-ads/SKILL.md +1 -2
- package/src/config/resolveAdUnit.ts +85 -85
- package/src/config/types.ts +318 -321
- package/src/config/validate.ts +1 -4
- package/src/core/adOriginatedReturn.ts +134 -134
- package/src/core/adsInstance.ts +76 -37
- package/src/core/consentFlow.ts +182 -180
- package/src/core/enablement.ts +76 -76
- package/src/core/fullScreenLock.ts +54 -54
- package/src/core/gates.ts +94 -91
- package/src/core/initMachine.ts +187 -187
- package/src/core/placementMachine.ts +124 -114
- package/src/core/privacyGate.ts +93 -85
- package/src/core/retryPolicy.ts +127 -127
- package/src/core/rewardedSession.ts +84 -84
- package/src/core/timers.ts +216 -208
- package/src/diagnostics/correlation.ts +31 -31
- package/src/diagnostics/errors.ts +230 -234
- package/src/diagnostics/events.ts +238 -232
- package/src/engine/admobEngine.ts +399 -399
- package/src/engine/port.ts +144 -125
- package/src/engine/testAdUnits.ts +61 -61
- package/src/react/AdsBanner.tsx +17 -17
- package/src/react/AdsProvider.tsx +57 -57
- package/src/react/hooks.ts +75 -75
- package/src/testing/index.ts +463 -460
|
@@ -1,134 +1,134 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Ad-originated return detection (FR-128).
|
|
3
|
-
*
|
|
4
|
-
* This is the reason app-open belongs in the package rather than in the app.
|
|
5
|
-
* Every other app-open rule — when to show one, how often, on which screens — is
|
|
6
|
-
* policy the app owns. This one is not policy; it is a fact only the package
|
|
7
|
-
* holds. When a user dismisses a full-screen ad, or comes back from a browser an
|
|
8
|
-
* ad opened, the app sees an ordinary return to the foreground and has no way to
|
|
9
|
-
* tell it apart from any other. Showing an app-open ad on that return means
|
|
10
|
-
* showing an ad immediately after an ad, which is both a poor experience and the
|
|
11
|
-
* kind of thing store review notices.
|
|
12
|
-
*
|
|
13
|
-
* The package knows, because it holds the full-screen lock and it sees the click
|
|
14
|
-
* events. So it refuses, with a reason distinct from `busy` and `not_ready` so
|
|
15
|
-
* the app can tell what happened.
|
|
16
|
-
*
|
|
17
|
-
* **Bounded on both sides, deliberately.** Arming has to be tied to something
|
|
18
|
-
* that actually happened, and it has to expire:
|
|
19
|
-
*
|
|
20
|
-
* - Armed when the provider reports a full-screen ad opened, or on a click —
|
|
21
|
-
* that second case is a browser the ad opened, which is the only signal the
|
|
22
|
-
* provider gives for it. Arming is deliberately not tied to the app
|
|
23
|
-
* reporting that it went to the background; see `noteFullScreenShown`.
|
|
24
|
-
* - Disarmed without firing if the app was away longer than
|
|
25
|
-
* `AD_RETURN_MAX_AWAY_MS`. Someone who left, did something else, and came
|
|
26
|
-
* back an hour later is not returning from an ad, and treating them as if
|
|
27
|
-
* they were would refuse a legitimate show.
|
|
28
|
-
* - Refusals last `AD_RETURN_GRACE_MS` after the return, then stop. The window
|
|
29
|
-
* only needs to cover the moment right after the return, where an app-open
|
|
30
|
-
* show would otherwise fire. A window that outlived that moment would start
|
|
31
|
-
* refusing shows that have nothing to do with the ad.
|
|
32
|
-
*
|
|
33
|
-
* Three limits are worth stating plainly, because they are limits rather than
|
|
34
|
-
* defects and the documentation must not imply otherwise:
|
|
35
|
-
*
|
|
36
|
-
* 1. It cannot survive process death. If the OS kills the app while an ad is
|
|
37
|
-
* on screen, the return is a cold start and nothing links it to the ad.
|
|
38
|
-
* 2. It depends on the app reporting `setAppActive` faithfully — an obligation
|
|
39
|
-
* 0.1.0 already imposes for staleness re-evaluation, not a new one.
|
|
40
|
-
* 3. A browser opened by an ad without a preceding click event is undetectable.
|
|
41
|
-
*
|
|
42
|
-
* Pure state. No listener, no timer — it is driven entirely by transitions the
|
|
43
|
-
* package already observes, which is why `dispose()` has nothing extra to
|
|
44
|
-
* release for it.
|
|
45
|
-
*/
|
|
46
|
-
|
|
47
|
-
import { AD_CLICK_WINDOW_MS, AD_RETURN_GRACE_MS, AD_RETURN_MAX_AWAY_MS } from './timers';
|
|
48
|
-
|
|
49
|
-
export interface AdOriginatedTracker {
|
|
50
|
-
/** A full-screen ad is on screen, per the provider's own `opened` callback. */
|
|
51
|
-
noteFullScreenShown(nowMs: number): void;
|
|
52
|
-
/** Record an ad click — the only signal that an ad opened a browser. */
|
|
53
|
-
noteClick(nowMs: number): void;
|
|
54
|
-
/** The app went to the background. Kept for the click case's timing. */
|
|
55
|
-
noteBackground(nowMs: number): void;
|
|
56
|
-
/** The app came back. Decides whether this return was ad-originated. */
|
|
57
|
-
noteForeground(nowMs: number): void;
|
|
58
|
-
/** Whether an app-open show should be refused right now. */
|
|
59
|
-
isRefusing(nowMs: number): boolean;
|
|
60
|
-
/** Drops all state. For dispose, and for tests asserting a clean slate. */
|
|
61
|
-
reset(): void;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
export function createAdOriginatedTracker(): AdOriginatedTracker {
|
|
65
|
-
let lastClickAtMs: number | null = null;
|
|
66
|
-
let armedAtMs: number | null = null;
|
|
67
|
-
let refusalUntilMs: number | null = null;
|
|
68
|
-
|
|
69
|
-
return {
|
|
70
|
-
/**
|
|
71
|
-
* Arming happens here — when the provider says an ad is on screen — and
|
|
72
|
-
* deliberately **not** when the app reports going to the background.
|
|
73
|
-
*
|
|
74
|
-
* That was the first design and it does not survive a real device. React
|
|
75
|
-
* Native delivers app-state changes over the bridge, and while the app is
|
|
76
|
-
* backgrounded its JS thread is throttled, so the `background` event is
|
|
77
|
-
* routinely processed only once the app is already returning. By then the
|
|
78
|
-
* ad has been dismissed and the full-screen lock released, so sampling the
|
|
79
|
-
* lock at that moment reports "no ad was showing" and the return is let
|
|
80
|
-
* through — the exact case FR-128 exists to catch. Arming on a fact the
|
|
81
|
-
* provider reports, rather than on the timeliness of an event, removes the
|
|
82
|
-
* race entirely.
|
|
83
|
-
*/
|
|
84
|
-
noteFullScreenShown(nowMs) {
|
|
85
|
-
armedAtMs = nowMs;
|
|
86
|
-
},
|
|
87
|
-
|
|
88
|
-
noteClick(nowMs) {
|
|
89
|
-
lastClickAtMs = nowMs;
|
|
90
|
-
// A click is itself grounds to arm: the ad may be opening a browser,
|
|
91
|
-
// and if it is, no `opened` callback will follow.
|
|
92
|
-
armedAtMs = nowMs;
|
|
93
|
-
},
|
|
94
|
-
|
|
95
|
-
noteBackground(nowMs) {
|
|
96
|
-
// Only meaningful for the browser case, and only as a refinement: a
|
|
97
|
-
// background that closely follows a click is that browser opening.
|
|
98
|
-
if (lastClickAtMs !== null && nowMs - lastClickAtMs <= AD_CLICK_WINDOW_MS) {
|
|
99
|
-
armedAtMs = nowMs;
|
|
100
|
-
}
|
|
101
|
-
},
|
|
102
|
-
|
|
103
|
-
noteForeground(nowMs) {
|
|
104
|
-
if (armedAtMs === null) {
|
|
105
|
-
return;
|
|
106
|
-
}
|
|
107
|
-
const away = nowMs - armedAtMs;
|
|
108
|
-
armedAtMs = null;
|
|
109
|
-
lastClickAtMs = null;
|
|
110
|
-
if (away > AD_RETURN_MAX_AWAY_MS) {
|
|
111
|
-
// Too long to still be attributable to the ad. Not a refusal.
|
|
112
|
-
return;
|
|
113
|
-
}
|
|
114
|
-
refusalUntilMs = nowMs + AD_RETURN_GRACE_MS;
|
|
115
|
-
},
|
|
116
|
-
|
|
117
|
-
isRefusing(nowMs) {
|
|
118
|
-
if (refusalUntilMs === null) {
|
|
119
|
-
return false;
|
|
120
|
-
}
|
|
121
|
-
if (nowMs >= refusalUntilMs) {
|
|
122
|
-
refusalUntilMs = null;
|
|
123
|
-
return false;
|
|
124
|
-
}
|
|
125
|
-
return true;
|
|
126
|
-
},
|
|
127
|
-
|
|
128
|
-
reset() {
|
|
129
|
-
lastClickAtMs = null;
|
|
130
|
-
armedAtMs = null;
|
|
131
|
-
refusalUntilMs = null;
|
|
132
|
-
},
|
|
133
|
-
};
|
|
134
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Ad-originated return detection (FR-128).
|
|
3
|
+
*
|
|
4
|
+
* This is the reason app-open belongs in the package rather than in the app.
|
|
5
|
+
* Every other app-open rule — when to show one, how often, on which screens — is
|
|
6
|
+
* policy the app owns. This one is not policy; it is a fact only the package
|
|
7
|
+
* holds. When a user dismisses a full-screen ad, or comes back from a browser an
|
|
8
|
+
* ad opened, the app sees an ordinary return to the foreground and has no way to
|
|
9
|
+
* tell it apart from any other. Showing an app-open ad on that return means
|
|
10
|
+
* showing an ad immediately after an ad, which is both a poor experience and the
|
|
11
|
+
* kind of thing store review notices.
|
|
12
|
+
*
|
|
13
|
+
* The package knows, because it holds the full-screen lock and it sees the click
|
|
14
|
+
* events. So it refuses, with a reason distinct from `busy` and `not_ready` so
|
|
15
|
+
* the app can tell what happened.
|
|
16
|
+
*
|
|
17
|
+
* **Bounded on both sides, deliberately.** Arming has to be tied to something
|
|
18
|
+
* that actually happened, and it has to expire:
|
|
19
|
+
*
|
|
20
|
+
* - Armed when the provider reports a full-screen ad opened, or on a click —
|
|
21
|
+
* that second case is a browser the ad opened, which is the only signal the
|
|
22
|
+
* provider gives for it. Arming is deliberately not tied to the app
|
|
23
|
+
* reporting that it went to the background; see `noteFullScreenShown`.
|
|
24
|
+
* - Disarmed without firing if the app was away longer than
|
|
25
|
+
* `AD_RETURN_MAX_AWAY_MS`. Someone who left, did something else, and came
|
|
26
|
+
* back an hour later is not returning from an ad, and treating them as if
|
|
27
|
+
* they were would refuse a legitimate show.
|
|
28
|
+
* - Refusals last `AD_RETURN_GRACE_MS` after the return, then stop. The window
|
|
29
|
+
* only needs to cover the moment right after the return, where an app-open
|
|
30
|
+
* show would otherwise fire. A window that outlived that moment would start
|
|
31
|
+
* refusing shows that have nothing to do with the ad.
|
|
32
|
+
*
|
|
33
|
+
* Three limits are worth stating plainly, because they are limits rather than
|
|
34
|
+
* defects and the documentation must not imply otherwise:
|
|
35
|
+
*
|
|
36
|
+
* 1. It cannot survive process death. If the OS kills the app while an ad is
|
|
37
|
+
* on screen, the return is a cold start and nothing links it to the ad.
|
|
38
|
+
* 2. It depends on the app reporting `setAppActive` faithfully — an obligation
|
|
39
|
+
* 0.1.0 already imposes for staleness re-evaluation, not a new one.
|
|
40
|
+
* 3. A browser opened by an ad without a preceding click event is undetectable.
|
|
41
|
+
*
|
|
42
|
+
* Pure state. No listener, no timer — it is driven entirely by transitions the
|
|
43
|
+
* package already observes, which is why `dispose()` has nothing extra to
|
|
44
|
+
* release for it.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
import { AD_CLICK_WINDOW_MS, AD_RETURN_GRACE_MS, AD_RETURN_MAX_AWAY_MS } from './timers';
|
|
48
|
+
|
|
49
|
+
export interface AdOriginatedTracker {
|
|
50
|
+
/** A full-screen ad is on screen, per the provider's own `opened` callback. */
|
|
51
|
+
noteFullScreenShown(nowMs: number): void;
|
|
52
|
+
/** Record an ad click — the only signal that an ad opened a browser. */
|
|
53
|
+
noteClick(nowMs: number): void;
|
|
54
|
+
/** The app went to the background. Kept for the click case's timing. */
|
|
55
|
+
noteBackground(nowMs: number): void;
|
|
56
|
+
/** The app came back. Decides whether this return was ad-originated. */
|
|
57
|
+
noteForeground(nowMs: number): void;
|
|
58
|
+
/** Whether an app-open show should be refused right now. */
|
|
59
|
+
isRefusing(nowMs: number): boolean;
|
|
60
|
+
/** Drops all state. For dispose, and for tests asserting a clean slate. */
|
|
61
|
+
reset(): void;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function createAdOriginatedTracker(): AdOriginatedTracker {
|
|
65
|
+
let lastClickAtMs: number | null = null;
|
|
66
|
+
let armedAtMs: number | null = null;
|
|
67
|
+
let refusalUntilMs: number | null = null;
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
/**
|
|
71
|
+
* Arming happens here — when the provider says an ad is on screen — and
|
|
72
|
+
* deliberately **not** when the app reports going to the background.
|
|
73
|
+
*
|
|
74
|
+
* That was the first design and it does not survive a real device. React
|
|
75
|
+
* Native delivers app-state changes over the bridge, and while the app is
|
|
76
|
+
* backgrounded its JS thread is throttled, so the `background` event is
|
|
77
|
+
* routinely processed only once the app is already returning. By then the
|
|
78
|
+
* ad has been dismissed and the full-screen lock released, so sampling the
|
|
79
|
+
* lock at that moment reports "no ad was showing" and the return is let
|
|
80
|
+
* through — the exact case FR-128 exists to catch. Arming on a fact the
|
|
81
|
+
* provider reports, rather than on the timeliness of an event, removes the
|
|
82
|
+
* race entirely.
|
|
83
|
+
*/
|
|
84
|
+
noteFullScreenShown(nowMs) {
|
|
85
|
+
armedAtMs = nowMs;
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
noteClick(nowMs) {
|
|
89
|
+
lastClickAtMs = nowMs;
|
|
90
|
+
// A click is itself grounds to arm: the ad may be opening a browser,
|
|
91
|
+
// and if it is, no `opened` callback will follow.
|
|
92
|
+
armedAtMs = nowMs;
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
noteBackground(nowMs) {
|
|
96
|
+
// Only meaningful for the browser case, and only as a refinement: a
|
|
97
|
+
// background that closely follows a click is that browser opening.
|
|
98
|
+
if (lastClickAtMs !== null && nowMs - lastClickAtMs <= AD_CLICK_WINDOW_MS) {
|
|
99
|
+
armedAtMs = nowMs;
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
|
|
103
|
+
noteForeground(nowMs) {
|
|
104
|
+
if (armedAtMs === null) {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const away = nowMs - armedAtMs;
|
|
108
|
+
armedAtMs = null;
|
|
109
|
+
lastClickAtMs = null;
|
|
110
|
+
if (away > AD_RETURN_MAX_AWAY_MS) {
|
|
111
|
+
// Too long to still be attributable to the ad. Not a refusal.
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
refusalUntilMs = nowMs + AD_RETURN_GRACE_MS;
|
|
115
|
+
},
|
|
116
|
+
|
|
117
|
+
isRefusing(nowMs) {
|
|
118
|
+
if (refusalUntilMs === null) {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
if (nowMs >= refusalUntilMs) {
|
|
122
|
+
refusalUntilMs = null;
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
return true;
|
|
126
|
+
},
|
|
127
|
+
|
|
128
|
+
reset() {
|
|
129
|
+
lastClickAtMs = null;
|
|
130
|
+
armedAtMs = null;
|
|
131
|
+
refusalUntilMs = null;
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
}
|
package/src/core/adsInstance.ts
CHANGED
|
@@ -50,7 +50,12 @@ import {
|
|
|
50
50
|
placementReducer,
|
|
51
51
|
type PlacementSnapshot,
|
|
52
52
|
} from './placementMachine';
|
|
53
|
-
import {
|
|
53
|
+
import {
|
|
54
|
+
evaluateGate,
|
|
55
|
+
requiresReinitialization,
|
|
56
|
+
toEnginePrivacy,
|
|
57
|
+
toEngineRequest,
|
|
58
|
+
} from './privacyGate';
|
|
54
59
|
import { evaluateGates, type ConsentGateInput, type GateDecision } from './gates';
|
|
55
60
|
import { createRewardedSession, type RewardedSession } from './rewardedSession';
|
|
56
61
|
import { createTimerSet, isStale, type Scheduler } from './timers';
|
|
@@ -82,7 +87,13 @@ export type PrivacyUpdateResult =
|
|
|
82
87
|
| { readonly status: 'requires_reinitialization' }
|
|
83
88
|
| { readonly status: 'disabled' }
|
|
84
89
|
| { readonly status: 'undecided' }
|
|
85
|
-
| { readonly status: 'unavailable'; readonly reason: UnavailableReason }
|
|
90
|
+
| { readonly status: 'unavailable'; readonly reason: UnavailableReason }
|
|
91
|
+
/**
|
|
92
|
+
* The initialization this update triggered failed (added in 0.5.0). Before,
|
|
93
|
+
* it was reported as `applied`, so the app never retried and no ad ever
|
|
94
|
+
* showed, with nothing to say why.
|
|
95
|
+
*/
|
|
96
|
+
| { readonly status: 'failed'; readonly error: AdsError };
|
|
86
97
|
|
|
87
98
|
export type LoadResult =
|
|
88
99
|
| { readonly status: 'loaded'; readonly correlationId: string }
|
|
@@ -231,7 +242,13 @@ export interface AdsInstance {
|
|
|
231
242
|
/** What `AdsBanner` needs. Deliberately not documented as consumer API. */
|
|
232
243
|
export interface InstanceInternals {
|
|
233
244
|
resolveBanner(placementKey: string):
|
|
234
|
-
| {
|
|
245
|
+
| {
|
|
246
|
+
readonly ok: true;
|
|
247
|
+
readonly adUnitId: string;
|
|
248
|
+
readonly correlationId: string;
|
|
249
|
+
/** Current at resolution time, so a banner request honors `updatePrivacy`. */
|
|
250
|
+
readonly personalizedAds: boolean;
|
|
251
|
+
}
|
|
235
252
|
| { readonly ok: false; readonly result: LoadResult };
|
|
236
253
|
/**
|
|
237
254
|
* Takes the throttle slot for a banner cycle (FR-140).
|
|
@@ -247,10 +264,7 @@ export interface InstanceInternals {
|
|
|
247
264
|
* Emits nothing and sets no state: it is called during render, so the host
|
|
248
265
|
* reports the refusal from its effect instead.
|
|
249
266
|
*/
|
|
250
|
-
commitBannerRequest(
|
|
251
|
-
placementKey: string,
|
|
252
|
-
correlationId: string,
|
|
253
|
-
): ThrottledResult | null;
|
|
267
|
+
commitBannerRequest(placementKey: string, correlationId: string): ThrottledResult | null;
|
|
254
268
|
/** The configured floor for a placement, so the host can report the cause. */
|
|
255
269
|
throttleFloorFor(placementKey: string): number;
|
|
256
270
|
emit(event: AdEvent): void;
|
|
@@ -604,6 +618,10 @@ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): Ad
|
|
|
604
618
|
* guard clause. On refusal it moves the placement to `throttled` and emits,
|
|
605
619
|
* which is what makes the refusal observable rather than a silent absence of
|
|
606
620
|
* ads — the app would otherwise have no way to tell this from no-fill.
|
|
621
|
+
*
|
|
622
|
+
* A placement still holding a `ready` ad keeps that state: the refusal is
|
|
623
|
+
* about asking again, and overwriting it would strand an ad that was already
|
|
624
|
+
* paid for and is still showable.
|
|
607
625
|
*/
|
|
608
626
|
function reportThrottled(
|
|
609
627
|
key: string,
|
|
@@ -612,7 +630,7 @@ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): Ad
|
|
|
612
630
|
minRequestIntervalMs: number,
|
|
613
631
|
): LoadResult {
|
|
614
632
|
const runtime = placements.get(key);
|
|
615
|
-
if (runtime) {
|
|
633
|
+
if (runtime && runtime.snapshot.state !== 'ready') {
|
|
616
634
|
setPlacement(key, { ...runtime.snapshot, state: 'throttled' });
|
|
617
635
|
}
|
|
618
636
|
events.emit({
|
|
@@ -701,9 +719,7 @@ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): Ad
|
|
|
701
719
|
|
|
702
720
|
/** App-open expires on its own, shorter window (FR-130). */
|
|
703
721
|
function validityFor(format: AdFormat): number {
|
|
704
|
-
return format === 'app_open'
|
|
705
|
-
? resolved.timeouts.appOpenValidity
|
|
706
|
-
: resolved.timeouts.adValidity;
|
|
722
|
+
return format === 'app_open' ? resolved.timeouts.appOpenValidity : resolved.timeouts.adValidity;
|
|
707
723
|
}
|
|
708
724
|
|
|
709
725
|
async function doInitialize(privacy: PrivacyOptions): Promise<InitResult> {
|
|
@@ -870,7 +886,7 @@ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): Ad
|
|
|
870
886
|
});
|
|
871
887
|
|
|
872
888
|
engine
|
|
873
|
-
.createFullScreen(format, adUnitId)
|
|
889
|
+
.createFullScreen(format, adUnitId, toEngineRequest(currentPrivacy))
|
|
874
890
|
.then(handle => {
|
|
875
891
|
if (disposed || settled) {
|
|
876
892
|
engine.dispose(handle);
|
|
@@ -984,7 +1000,9 @@ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): Ad
|
|
|
984
1000
|
if (!error || !error.retryable) {
|
|
985
1001
|
return result;
|
|
986
1002
|
}
|
|
987
|
-
|
|
1003
|
+
// The whole gate, not just enablement: a retry is a new provider request
|
|
1004
|
+
// and needs the same permission the first one had (FR-017).
|
|
1005
|
+
if (!canRetry(policy, runtime.retryAttempts) || !currentGate().allowed) {
|
|
988
1006
|
return result;
|
|
989
1007
|
}
|
|
990
1008
|
|
|
@@ -1017,7 +1035,7 @@ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): Ad
|
|
|
1017
1035
|
};
|
|
1018
1036
|
});
|
|
1019
1037
|
|
|
1020
|
-
if (!waited || disposed || !
|
|
1038
|
+
if (!waited || disposed || !currentGate().allowed) {
|
|
1021
1039
|
return result;
|
|
1022
1040
|
}
|
|
1023
1041
|
|
|
@@ -1163,7 +1181,8 @@ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): Ad
|
|
|
1163
1181
|
|
|
1164
1182
|
async updatePrivacy(privacy) {
|
|
1165
1183
|
if (disposed) {
|
|
1166
|
-
|
|
1184
|
+
// The same answer initialize and load give a disposed instance.
|
|
1185
|
+
return { status: 'unavailable', reason: 'provider_missing' };
|
|
1167
1186
|
}
|
|
1168
1187
|
if (enablement === 'undecided') {
|
|
1169
1188
|
return { status: 'undecided' };
|
|
@@ -1184,6 +1203,11 @@ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): Ad
|
|
|
1184
1203
|
currentPrivacy = privacy;
|
|
1185
1204
|
const gate = evaluateGate(privacy);
|
|
1186
1205
|
if (!gate.allowed) {
|
|
1206
|
+
// FR-017, FR-137, FR-153: the same teardown as a consent outcome that
|
|
1207
|
+
// closes the gate. Without it a retry already waiting on its backoff
|
|
1208
|
+
// would still reach the provider after the app withdrew consent.
|
|
1209
|
+
cancelAllRetries();
|
|
1210
|
+
haltPlacements({ status: 'blocked', reason: gate.reason });
|
|
1187
1211
|
setInit(initReducer(init, { type: 'BLOCK', reason: gate.reason }));
|
|
1188
1212
|
return { status: 'blocked', reason: gate.reason };
|
|
1189
1213
|
}
|
|
@@ -1193,12 +1217,24 @@ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): Ad
|
|
|
1193
1217
|
if (init.status === 'ready') {
|
|
1194
1218
|
return { status: 'applied' };
|
|
1195
1219
|
}
|
|
1220
|
+
// Exhaustive on purpose (ADS-04): the old ternary mapped only `ready` and
|
|
1221
|
+
// `blocked`, and every other outcome — `failed` included — fell through to
|
|
1222
|
+
// `applied`. A new InitResult member now fails to compile here.
|
|
1196
1223
|
const result = await instance.initialize(privacy);
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1224
|
+
switch (result.status) {
|
|
1225
|
+
case 'ready':
|
|
1226
|
+
return { status: 'applied' };
|
|
1227
|
+
case 'blocked':
|
|
1228
|
+
case 'disabled':
|
|
1229
|
+
case 'undecided':
|
|
1230
|
+
case 'unavailable':
|
|
1231
|
+
case 'failed':
|
|
1232
|
+
return result;
|
|
1233
|
+
default: {
|
|
1234
|
+
const unreachable: never = result;
|
|
1235
|
+
return unreachable;
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1202
1238
|
},
|
|
1203
1239
|
|
|
1204
1240
|
setEnabled(next) {
|
|
@@ -1362,18 +1398,11 @@ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): Ad
|
|
|
1362
1398
|
return runtime.inFlightLoad;
|
|
1363
1399
|
}
|
|
1364
1400
|
|
|
1365
|
-
// FR-140. Checked after the in-flight join so a caller that merely
|
|
1366
|
-
// arrived twice for one cycle is answered by the join, not charged a
|
|
1367
|
-
// throttle slot — and before the correlation id and `load_started`,
|
|
1368
|
-
// because a refused request is not a cycle and must not look like one
|
|
1369
|
-
// in the event stream.
|
|
1370
|
-
const throttled = checkThrottle(placementKey, definition.format);
|
|
1371
|
-
if (throttled) {
|
|
1372
|
-
return throttled;
|
|
1373
|
-
}
|
|
1374
|
-
|
|
1375
1401
|
if (runtime.snapshot.state === 'showing') {
|
|
1376
|
-
return {
|
|
1402
|
+
return {
|
|
1403
|
+
status: 'failed',
|
|
1404
|
+
error: new AdsError('busy', { operation: 'load', placementKey }),
|
|
1405
|
+
};
|
|
1377
1406
|
}
|
|
1378
1407
|
|
|
1379
1408
|
if (!canLoad(runtime.snapshot.state)) {
|
|
@@ -1383,6 +1412,17 @@ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): Ad
|
|
|
1383
1412
|
};
|
|
1384
1413
|
}
|
|
1385
1414
|
|
|
1415
|
+
// FR-140. Checked after the in-flight join so a caller that merely
|
|
1416
|
+
// arrived twice for one cycle is answered by the join, not charged a
|
|
1417
|
+
// throttle slot; after the busy and not-ready guards so a load that was
|
|
1418
|
+
// never going to request anything does not spend one either; and before
|
|
1419
|
+
// the correlation id and `load_started`, because a refused request is
|
|
1420
|
+
// not a cycle and must not look like one in the event stream.
|
|
1421
|
+
const throttled = checkThrottle(placementKey, definition.format);
|
|
1422
|
+
if (throttled) {
|
|
1423
|
+
return throttled;
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1386
1426
|
releasePlacementResources(placementKey);
|
|
1387
1427
|
|
|
1388
1428
|
const correlationId = correlation.next();
|
|
@@ -1712,9 +1752,7 @@ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): Ad
|
|
|
1712
1752
|
}
|
|
1713
1753
|
setConsent(next);
|
|
1714
1754
|
await reevaluateAfterConsent();
|
|
1715
|
-
return failure
|
|
1716
|
-
? { status: 'failed', error: failure }
|
|
1717
|
-
: { status: 'shown', consent: next };
|
|
1755
|
+
return failure ? { status: 'failed', error: failure } : { status: 'shown', consent: next };
|
|
1718
1756
|
},
|
|
1719
1757
|
|
|
1720
1758
|
getState() {
|
|
@@ -1800,14 +1838,14 @@ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): Ad
|
|
|
1800
1838
|
ok: true,
|
|
1801
1839
|
adUnitId: resolved.catalog.byKey.get(placementKey)!,
|
|
1802
1840
|
correlationId: correlation.next(),
|
|
1841
|
+
personalizedAds: toEngineRequest(currentPrivacy).personalizedAds,
|
|
1803
1842
|
};
|
|
1804
1843
|
},
|
|
1805
1844
|
commitBannerRequest(placementKey, correlationId) {
|
|
1806
1845
|
if (!resolved.placements.has(placementKey)) {
|
|
1807
1846
|
return null;
|
|
1808
1847
|
}
|
|
1809
|
-
const minRequestIntervalMs =
|
|
1810
|
-
resolved.minRequestIntervalByKey.get(placementKey) ?? 0;
|
|
1848
|
+
const minRequestIntervalMs = resolved.minRequestIntervalByKey.get(placementKey) ?? 0;
|
|
1811
1849
|
const decision = requestThrottle.tryAcquire(
|
|
1812
1850
|
placementKey,
|
|
1813
1851
|
minRequestIntervalMs,
|
|
@@ -1883,7 +1921,8 @@ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): Ad
|
|
|
1883
1921
|
function createDefaultEngine(): AdsEngine {
|
|
1884
1922
|
// Deferred require keeps the provider off the import graph of every module
|
|
1885
1923
|
// that merely mentions `createAds`.
|
|
1886
|
-
const { createAdMobEngine } =
|
|
1924
|
+
const { createAdMobEngine } =
|
|
1925
|
+
require('../engine/admobEngine') as typeof import('../engine/admobEngine');
|
|
1887
1926
|
return createAdMobEngine();
|
|
1888
1927
|
}
|
|
1889
1928
|
|