@chipmobilesdk/rn-ads 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +46 -0
- package/README.md +771 -671
- package/package.json +49 -49
- package/skills/sdk-rn-ads/SKILL.md +54 -5
- package/src/config/types.ts +22 -0
- package/src/config/validate.ts +211 -188
- package/src/core/adsInstance.ts +1670 -1554
- package/src/core/requestThrottle.ts +176 -0
- package/src/diagnostics/events.ts +13 -0
- package/src/index.ts +10 -0
- package/src/react/AdsBanner.tsx +337 -292
package/package.json
CHANGED
|
@@ -1,49 +1,49 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@chipmobilesdk/rn-ads",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"private": false,
|
|
5
|
-
"license": "UNLICENSED",
|
|
6
|
-
"main": "src/index.ts",
|
|
7
|
-
"types": "src/index.ts",
|
|
8
|
-
"exports": {
|
|
9
|
-
".": {
|
|
10
|
-
"types": "./src/index.ts",
|
|
11
|
-
"react-native": "./src/index.ts",
|
|
12
|
-
"default": "./src/index.ts"
|
|
13
|
-
},
|
|
14
|
-
"./testing": {
|
|
15
|
-
"types": "./src/testing/index.ts",
|
|
16
|
-
"react-native": "./src/testing/index.ts",
|
|
17
|
-
"default": "./src/testing/index.ts"
|
|
18
|
-
}
|
|
19
|
-
},
|
|
20
|
-
"files": [
|
|
21
|
-
"src/index.ts",
|
|
22
|
-
"src/config",
|
|
23
|
-
"src/core",
|
|
24
|
-
"src/diagnostics",
|
|
25
|
-
"src/engine",
|
|
26
|
-
"src/react",
|
|
27
|
-
"src/testing",
|
|
28
|
-
"skills",
|
|
29
|
-
"README.md",
|
|
30
|
-
"CHANGELOG.md"
|
|
31
|
-
],
|
|
32
|
-
"sideEffects": false,
|
|
33
|
-
"scripts": {
|
|
34
|
-
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
35
|
-
"pack:dry-run": "npm pack --dry-run"
|
|
36
|
-
},
|
|
37
|
-
"peerDependencies": {
|
|
38
|
-
"react": ">=19",
|
|
39
|
-
"react-native": ">=0.85",
|
|
40
|
-
"react-native-google-mobile-ads": ">=16.0.3 <16.1.0"
|
|
41
|
-
},
|
|
42
|
-
"engines": {
|
|
43
|
-
"node": ">=22.11.0"
|
|
44
|
-
},
|
|
45
|
-
"publishConfig": {
|
|
46
|
-
"access": "public",
|
|
47
|
-
"registry": "https://registry.npmjs.org/"
|
|
48
|
-
}
|
|
49
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@chipmobilesdk/rn-ads",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"types": "src/index.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./src/index.ts",
|
|
11
|
+
"react-native": "./src/index.ts",
|
|
12
|
+
"default": "./src/index.ts"
|
|
13
|
+
},
|
|
14
|
+
"./testing": {
|
|
15
|
+
"types": "./src/testing/index.ts",
|
|
16
|
+
"react-native": "./src/testing/index.ts",
|
|
17
|
+
"default": "./src/testing/index.ts"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"src/index.ts",
|
|
22
|
+
"src/config",
|
|
23
|
+
"src/core",
|
|
24
|
+
"src/diagnostics",
|
|
25
|
+
"src/engine",
|
|
26
|
+
"src/react",
|
|
27
|
+
"src/testing",
|
|
28
|
+
"skills",
|
|
29
|
+
"README.md",
|
|
30
|
+
"CHANGELOG.md"
|
|
31
|
+
],
|
|
32
|
+
"sideEffects": false,
|
|
33
|
+
"scripts": {
|
|
34
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
35
|
+
"pack:dry-run": "npm pack --dry-run"
|
|
36
|
+
},
|
|
37
|
+
"peerDependencies": {
|
|
38
|
+
"react": ">=19",
|
|
39
|
+
"react-native": ">=0.85",
|
|
40
|
+
"react-native-google-mobile-ads": ">=16.0.3 <16.1.0"
|
|
41
|
+
},
|
|
42
|
+
"engines": {
|
|
43
|
+
"node": ">=22.11.0"
|
|
44
|
+
},
|
|
45
|
+
"publishConfig": {
|
|
46
|
+
"access": "public",
|
|
47
|
+
"registry": "https://registry.npmjs.org/"
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: sdk-rn-ads
|
|
3
|
-
description: "Advertising via @chipmobilesdk/rn-ads: AdMob banner/interstitial/rewarded/app-open behind a provider-neutral contract, placement keys, optional consent orchestration, three-state enablement, bounded retry, per-environment test-ad substitution. Use when adding ads to a screen, running a consent flow without importing AdMob, deferring ads until a purchase check resolves, debugging an ad that never loads, mapping ad units per environment, or answering a Data safety question about advertising."
|
|
3
|
+
description: "Advertising via @chipmobilesdk/rn-ads: AdMob banner/interstitial/rewarded/app-open behind a provider-neutral contract, placement keys, optional consent orchestration, three-state enablement, bounded retry, per-environment test-ad substitution, per-placement request throttling. Use when adding ads to a screen, running a consent flow without importing AdMob, deferring ads until a purchase check resolves, debugging an ad that never loads or reports throttled, placing a banner inside a list or a view the user swipes between, mapping ad units per environment, or answering a Data safety question about advertising."
|
|
4
4
|
metadata:
|
|
5
5
|
package: "@chipmobilesdk/rn-ads"
|
|
6
|
-
packageVersion: "0.
|
|
6
|
+
packageVersion: "0.3.0"
|
|
7
7
|
user-invocable: true
|
|
8
8
|
disable-model-invocation: false
|
|
9
9
|
---
|
|
@@ -85,7 +85,32 @@ defect and it is fixed. The result now says which happened:
|
|
|
85
85
|
|
|
86
86
|
Existing `ads.setEnabled(false);` call sites still compile untouched.
|
|
87
87
|
|
|
88
|
-
##
|
|
88
|
+
## A banner in a list or a swipeable view gets throttled — by design
|
|
89
|
+
|
|
90
|
+
New in 0.3.0. A banner mounted inside a view the user navigates between — a day in a
|
|
91
|
+
calendar, a tab, a row that scrolls in and out — is unmounted and remounted by React on
|
|
92
|
+
every navigation, and **each remount is a billable request**. Ten quick swipes used to be
|
|
93
|
+
ten requests in a few seconds, which ad networks read as invalid traffic. The penalty is
|
|
94
|
+
account-level and no shipped fix recovers it.
|
|
95
|
+
|
|
96
|
+
So each placement now has a minimum interval between *requests*: **60s for a banner**
|
|
97
|
+
(AdMob's own refresh floor), **off for full-screen formats** — those are loaded by an
|
|
98
|
+
explicit call, not mounted by a renderer, so they never had the problem. A refusal reports
|
|
99
|
+
`throttled` on the placement and emits `request_throttled` with `retryAfterMs`.
|
|
100
|
+
|
|
101
|
+
Three things to know before you reach for `minRequestIntervalMs: 0`:
|
|
102
|
+
|
|
103
|
+
- **`throttled` is not `failed` and not a no-fill.** Nothing is wrong and nothing was
|
|
104
|
+
requested. Collapse the space if you like, but do not log it as an error.
|
|
105
|
+
- **Rotation is exempt.** A width re-adaptation inside one mount is not charged, so a
|
|
106
|
+
rotated screen keeps its banner.
|
|
107
|
+
- **The better fix is architectural.** Keep the banner outside the subtree that remounts
|
|
108
|
+
and the throttle never fires. It is a safety net, not a licence to ignore the problem.
|
|
109
|
+
|
|
110
|
+
This is **not** a frequency cap. It limits how often a placement may *ask*; how often you
|
|
111
|
+
*show* is yours, and the package deliberately ships no pacing (see `## Do not use this for`).
|
|
112
|
+
|
|
113
|
+
## Five things not to "fix"
|
|
89
114
|
|
|
90
115
|
- **The bridge pin.** `react-native-google-mobile-ads` is peered at `>=16.0.3 <16.1.0`;
|
|
91
116
|
16.1.0+ carries Kotlin 2.3 metadata that React Native 0.85's pinned compiler cannot
|
|
@@ -101,6 +126,9 @@ Existing `ads.setEnabled(false);` call sites still compile untouched.
|
|
|
101
126
|
- **A declined consent still reporting `obtained` / `canRequestAds: true`.** That is UMP's
|
|
102
127
|
meaning, not a bug: `OBTAINED` means *an answer was recorded*, and non-personalized ads
|
|
103
128
|
stay servable. The package forwards the provider's verdict rather than inventing one.
|
|
129
|
+
- **A banner reporting `throttled` right after a navigation.** That is 0.3.0's request
|
|
130
|
+
floor working — see the section above. Lower it knowingly or move the banner out of the
|
|
131
|
+
remounting subtree; do not set `0` to make the symptom go away.
|
|
104
132
|
|
|
105
133
|
## Retry is off unless you ask
|
|
106
134
|
|
|
@@ -120,7 +148,7 @@ on a return **from an ad** — or from a browser an ad opened — is refused wit
|
|
|
120
148
|
return apart from any other; the package can. It cannot survive process death, and a
|
|
121
149
|
browser opened without a preceding click is undetectable. See `## App open`.
|
|
122
150
|
|
|
123
|
-
## Public API (v0.
|
|
151
|
+
## Public API (v0.3.0)
|
|
124
152
|
|
|
125
153
|
Runtime: `createAds` (throws `AdsConfigError` on a bad catalog or an out-of-range retry
|
|
126
154
|
policy; everything else returns a typed result rather than throwing) · `AdsProvider`,
|
|
@@ -129,7 +157,9 @@ policy; everything else returns a typed result rather than throwing) · `AdsProv
|
|
|
129
157
|
`DEFAULT_SHOW_TIMEOUT_MS`, `DEFAULT_AD_VALIDITY_MS`, `DEFAULT_APP_OPEN_VALIDITY_MS`,
|
|
130
158
|
`DEFAULT_RETRY_POLICY`, `DEFAULT_UNDECIDED_WARNING_MS`, `LOAD_TIMEOUT_BOUNDS`,
|
|
131
159
|
`SHOW_TIMEOUT_BOUNDS`, `AD_VALIDITY_BOUNDS`, `APP_OPEN_VALIDITY_BOUNDS`, `RETRY_BOUNDS`,
|
|
132
|
-
`UNDECIDED_WARNING_BOUNDS
|
|
160
|
+
`UNDECIDED_WARNING_BOUNDS`, `DEFAULT_BANNER_MIN_REQUEST_INTERVAL_MS`,
|
|
161
|
+
`DEFAULT_FULL_SCREEN_MIN_REQUEST_INTERVAL_MS`, `MIN_REQUEST_INTERVAL_BOUNDS`,
|
|
162
|
+
`defaultMinRequestIntervalMs` · `createFakeAdsEngine`, `createTestClock` from
|
|
133
163
|
`@chipmobilesdk/rn-ads/testing` (test doubles only).
|
|
134
164
|
|
|
135
165
|
Instance: `initialize`, `updatePrivacy`, `setEnabled`, `setEnablement`, `setAppActive`,
|
|
@@ -151,6 +181,24 @@ Types: `AdsInstance`, `AdsConfig`, `PlacementDefinition`, `PrivacyOptions`, `AdF
|
|
|
151
181
|
Call `dispose()` when the owning screen unmounts; it is idempotent, and so is every
|
|
152
182
|
`Unsubscribe`.
|
|
153
183
|
|
|
184
|
+
## Upgrading 0.2.x → 0.3.0
|
|
185
|
+
|
|
186
|
+
One compile-time change and one behaviour change.
|
|
187
|
+
|
|
188
|
+
| Union | New member |
|
|
189
|
+
|---|---|
|
|
190
|
+
| `PlacementState` | `'throttled'` |
|
|
191
|
+
| `LoadResult` | `{ status: 'throttled', retryAfterMs }` |
|
|
192
|
+
| `AdEventType` | `'request_throttled'` |
|
|
193
|
+
|
|
194
|
+
**The behaviour change:** banner placements now have a 60s floor between requests. If your
|
|
195
|
+
banner lives inside a list row, a tab, or a view the user swipes between, it will report
|
|
196
|
+
`throttled` after a quick navigation where 0.2.x silently issued another request. That was
|
|
197
|
+
the bug; this is the fix. Read the throttle section above before setting
|
|
198
|
+
`minRequestIntervalMs: 0`.
|
|
199
|
+
|
|
200
|
+
Full-screen formats are unchanged — their default floor is `0`.
|
|
201
|
+
|
|
154
202
|
## Upgrading 0.1.0 → 0.2.0
|
|
155
203
|
|
|
156
204
|
One required change, compile-time only: five unions gained members, so an exhaustive
|
|
@@ -182,6 +230,7 @@ Read one section of `node_modules/@chipmobilesdk/rn-ads/README.md`:
|
|
|
182
230
|
| App-open and ad-originated return | `## App open` |
|
|
183
231
|
| Turning ads off, undecided, re-enabling | `## Turning ads off — three states` |
|
|
184
232
|
| Retry and reconciling it against a bill | `## Retry` |
|
|
233
|
+
| Request throttling, banners in lists | `## Request throttling` |
|
|
185
234
|
| Why a request was refused | `## Why an ad did not show` |
|
|
186
235
|
| Results, events, errors, timeouts, cleanup | `## State, events, and errors` |
|
|
187
236
|
| Tests without a device | `## Testing` |
|
package/src/config/types.ts
CHANGED
|
@@ -65,6 +65,18 @@ export interface PlacementDefinition {
|
|
|
65
65
|
* override can narrow behavior but never exceed a ceiling (FR-136a).
|
|
66
66
|
*/
|
|
67
67
|
readonly retry?: RetryPolicy | false;
|
|
68
|
+
/**
|
|
69
|
+
* Minimum milliseconds between two provider requests for this placement
|
|
70
|
+
* (FR-140), added in 0.3.0.
|
|
71
|
+
*
|
|
72
|
+
* Absent takes the format-aware default: 60s for a banner, off for full-screen
|
|
73
|
+
* formats. `0` turns it off here while leaving it on elsewhere.
|
|
74
|
+
*
|
|
75
|
+
* This is an **account-safety** floor, not a frequency cap: it limits how often
|
|
76
|
+
* a placement may *ask* for an ad, never how often the app may *show* one. If
|
|
77
|
+
* you are reaching for this to pace interstitials, that belongs in the app.
|
|
78
|
+
*/
|
|
79
|
+
readonly minRequestIntervalMs?: number;
|
|
68
80
|
}
|
|
69
81
|
|
|
70
82
|
/**
|
|
@@ -269,6 +281,16 @@ export type PlacementState =
|
|
|
269
281
|
| 'failed'
|
|
270
282
|
/** Mirrors `InitStatus.undecided` so the banner host can report it directly. */
|
|
271
283
|
| 'undecided'
|
|
284
|
+
/**
|
|
285
|
+
* Refused by the per-placement request throttle (FR-140), added in 0.3.0.
|
|
286
|
+
*
|
|
287
|
+
* Deliberately distinct from `failed` and from a no-fill: nothing is wrong,
|
|
288
|
+
* nothing was requested, and the same placement will work again shortly. An
|
|
289
|
+
* app that collapses its ad slot on `failed` will usually want to collapse on
|
|
290
|
+
* this too, but it must be able to tell the two apart when diagnosing why a
|
|
291
|
+
* banner is absent.
|
|
292
|
+
*/
|
|
293
|
+
| 'throttled'
|
|
272
294
|
| 'unavailable';
|
|
273
295
|
|
|
274
296
|
/** FR-047. The provider's reward descriptor, normalized. */
|
package/src/config/validate.ts
CHANGED
|
@@ -1,188 +1,211 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Catalog and configuration validation (FR-008), plus the fail-closed check
|
|
3
|
-
* that substitution cannot cover (FR-012).
|
|
4
|
-
*
|
|
5
|
-
* Configuration faults throw rather than returning a result. They mean the app
|
|
6
|
-
* is wired wrong, and no runtime status could describe a usable outcome — every
|
|
7
|
-
* other failure in this package is a typed result instead.
|
|
8
|
-
*
|
|
9
|
-
* Every message names the offending placement key. An error that says only
|
|
10
|
-
* "invalid configuration" makes the integrator hunt through a catalog by hand.
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
import { AdsConfigError } from '../diagnostics/errors';
|
|
14
|
-
import { resolvePlacementAdUnits, type ResolvedCatalog } from './resolveAdUnit';
|
|
15
|
-
import { resolveTimeouts, type ResolvedTimeouts } from '../core/timers';
|
|
16
|
-
import {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
readonly
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
readonly
|
|
49
|
-
|
|
50
|
-
readonly
|
|
51
|
-
|
|
52
|
-
readonly
|
|
53
|
-
/**
|
|
54
|
-
readonly
|
|
55
|
-
/**
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
*
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
if (
|
|
78
|
-
throw new AdsConfigError('
|
|
79
|
-
message:
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
throw new AdsConfigError('
|
|
99
|
-
message:
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
);
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Catalog and configuration validation (FR-008), plus the fail-closed check
|
|
3
|
+
* that substitution cannot cover (FR-012).
|
|
4
|
+
*
|
|
5
|
+
* Configuration faults throw rather than returning a result. They mean the app
|
|
6
|
+
* is wired wrong, and no runtime status could describe a usable outcome — every
|
|
7
|
+
* other failure in this package is a typed result instead.
|
|
8
|
+
*
|
|
9
|
+
* Every message names the offending placement key. An error that says only
|
|
10
|
+
* "invalid configuration" makes the integrator hunt through a catalog by hand.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { AdsConfigError } from '../diagnostics/errors';
|
|
14
|
+
import { resolvePlacementAdUnits, type ResolvedCatalog } from './resolveAdUnit';
|
|
15
|
+
import { checkBand, resolveTimeouts, type ResolvedTimeouts } from '../core/timers';
|
|
16
|
+
import {
|
|
17
|
+
defaultMinRequestIntervalMs,
|
|
18
|
+
MIN_REQUEST_INTERVAL_BOUNDS,
|
|
19
|
+
} from '../core/requestThrottle';
|
|
20
|
+
import { toEnablement } from '../core/enablement';
|
|
21
|
+
import { resolveEffectiveRetry, type ResolvedRetryPolicy } from '../core/retryPolicy';
|
|
22
|
+
import type {
|
|
23
|
+
AdFormat,
|
|
24
|
+
AdsConfig,
|
|
25
|
+
ConsentTestingOptions,
|
|
26
|
+
Enablement,
|
|
27
|
+
Environment,
|
|
28
|
+
PlacementDefinition,
|
|
29
|
+
} from './types';
|
|
30
|
+
|
|
31
|
+
const FORMATS: ReadonlySet<string> = new Set<AdFormat>([
|
|
32
|
+
'banner',
|
|
33
|
+
'interstitial',
|
|
34
|
+
'rewarded',
|
|
35
|
+
'app_open',
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
export interface ValidateOptions {
|
|
39
|
+
/**
|
|
40
|
+
* Whether this is a debug build. Injected rather than read from `__DEV__`
|
|
41
|
+
* directly so the fail-closed rule is testable without manipulating a global
|
|
42
|
+
* the test runner also depends on.
|
|
43
|
+
*/
|
|
44
|
+
readonly isDevBuild: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface ResolvedConfig {
|
|
48
|
+
readonly enablement: Enablement;
|
|
49
|
+
readonly environment: Environment;
|
|
50
|
+
readonly placements: ReadonlyMap<string, PlacementDefinition>;
|
|
51
|
+
readonly catalog: ResolvedCatalog;
|
|
52
|
+
readonly timeouts: ResolvedTimeouts;
|
|
53
|
+
/** Empty in production regardless of what was configured (FR-014). */
|
|
54
|
+
readonly testDeviceIds: readonly string[];
|
|
55
|
+
/** Effective retry per placement. `null` means retry is off there (FR-134). */
|
|
56
|
+
readonly retryByKey: ReadonlyMap<string, ResolvedRetryPolicy | null>;
|
|
57
|
+
/**
|
|
58
|
+
* Effective minimum interval between provider requests, per placement
|
|
59
|
+
* (FR-140). Zero means the throttle is off for that placement.
|
|
60
|
+
*/
|
|
61
|
+
readonly minRequestIntervalByKey: ReadonlyMap<string, number>;
|
|
62
|
+
/** Stripped entirely in production (FR-113). */
|
|
63
|
+
readonly consentTesting: ConsentTestingOptions | undefined;
|
|
64
|
+
/**
|
|
65
|
+
* Which consent tools were dropped because the environment is production.
|
|
66
|
+
* Surfaced so the inertness is observable rather than merely documented —
|
|
67
|
+
* the same treatment FR-011a gives substituted ad units.
|
|
68
|
+
*/
|
|
69
|
+
readonly ignoredConsentTools: readonly string[];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function validateConfig(config: AdsConfig, options: ValidateOptions): ResolvedConfig {
|
|
73
|
+
// FR-012. Substitution deliberately does not apply in production, so a debug
|
|
74
|
+
// build declaring `production` is the one case it cannot make safe: it would
|
|
75
|
+
// send real requests from a build that should never generate traffic. Fail
|
|
76
|
+
// closed rather than continue.
|
|
77
|
+
if (config.environment === 'production' && options.isDevBuild) {
|
|
78
|
+
throw new AdsConfigError('production_in_dev_build', {
|
|
79
|
+
message:
|
|
80
|
+
'environment is "production" but this is a development build. ' +
|
|
81
|
+
'Non-production builds must declare "development" or "test" so the package can ' +
|
|
82
|
+
'substitute official Google test ad units and keep production traffic impossible.',
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (!Array.isArray(config.placements) || config.placements.length === 0) {
|
|
87
|
+
throw new AdsConfigError('invalid_config', {
|
|
88
|
+
message: 'placements must be a non-empty array; the package can show nothing without one',
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const byKey = new Map<string, PlacementDefinition>();
|
|
93
|
+
|
|
94
|
+
for (const placement of config.placements) {
|
|
95
|
+
const key = typeof placement?.key === 'string' ? placement.key.trim() : '';
|
|
96
|
+
|
|
97
|
+
if (!key) {
|
|
98
|
+
throw new AdsConfigError('invalid_config', {
|
|
99
|
+
message: 'every placement needs a non-empty key',
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (byKey.has(key)) {
|
|
104
|
+
// Rejecting the whole catalog rather than picking a winner: a repeated
|
|
105
|
+
// key means the app's own naming produced two placements for one slot,
|
|
106
|
+
// and the package has no basis to guess which was meant.
|
|
107
|
+
throw new AdsConfigError('duplicate_placement_key', {
|
|
108
|
+
message: `duplicate placement key "${key}"`,
|
|
109
|
+
placementKey: key,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (!FORMATS.has(placement.format)) {
|
|
114
|
+
throw new AdsConfigError('invalid_config', {
|
|
115
|
+
message: `placement "${key}" has unsupported format "${String(placement.format)}"; supported formats are banner, interstitial, rewarded, and app_open`,
|
|
116
|
+
placementKey: key,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (!placement.androidAdUnitId || !placement.androidAdUnitId.trim()) {
|
|
121
|
+
throw new AdsConfigError('missing_ad_unit_id', {
|
|
122
|
+
message: `placement "${key}" is missing androidAdUnitId`,
|
|
123
|
+
placementKey: key,
|
|
124
|
+
format: placement.format,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
byKey.set(key, { ...placement, key });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const timeouts = resolveTimeouts({
|
|
132
|
+
timeouts: config.timeouts,
|
|
133
|
+
adValidityMs: config.adValidityMs,
|
|
134
|
+
appOpenValidityMs: config.appOpenValidityMs,
|
|
135
|
+
undecidedWarningMs: config.undecidedWarningMs,
|
|
136
|
+
});
|
|
137
|
+
const catalog = resolvePlacementAdUnits(Array.from(byKey.values()), config.environment);
|
|
138
|
+
|
|
139
|
+
// Resolved eagerly, for every placement, so an out-of-bounds override fails
|
|
140
|
+
// at construction rather than on the first failed load in production — which
|
|
141
|
+
// is the one moment nobody is watching for a configuration error.
|
|
142
|
+
const retryByKey = new Map<string, ResolvedRetryPolicy | null>();
|
|
143
|
+
// Resolved in the same loop and for the same reason: an out-of-bounds floor
|
|
144
|
+
// must fail at construction, not silently refuse every request in production.
|
|
145
|
+
const minRequestIntervalByKey = new Map<string, number>();
|
|
146
|
+
for (const [key, placement] of byKey) {
|
|
147
|
+
retryByKey.set(key, resolveEffectiveRetry(config.retry, placement.retry, key));
|
|
148
|
+
minRequestIntervalByKey.set(
|
|
149
|
+
key,
|
|
150
|
+
placement.minRequestIntervalMs === undefined
|
|
151
|
+
? defaultMinRequestIntervalMs(placement.format)
|
|
152
|
+
: checkBand(
|
|
153
|
+
`placements["${key}"].minRequestIntervalMs`,
|
|
154
|
+
placement.minRequestIntervalMs,
|
|
155
|
+
MIN_REQUEST_INTERVAL_BOUNDS,
|
|
156
|
+
),
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const isProduction = config.environment === 'production';
|
|
161
|
+
const { consentTesting, ignoredConsentTools } = resolveConsentTesting(
|
|
162
|
+
config.consentTesting,
|
|
163
|
+
isProduction,
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
enablement: toEnablement(config.enabled),
|
|
168
|
+
environment: config.environment,
|
|
169
|
+
placements: byKey,
|
|
170
|
+
catalog,
|
|
171
|
+
timeouts,
|
|
172
|
+
// FR-014: debug and test-device settings must not be able to alter
|
|
173
|
+
// production behavior, so they are dropped rather than carried and
|
|
174
|
+
// conditionally ignored somewhere further down.
|
|
175
|
+
testDeviceIds: isProduction ? [] : [...(config.testDeviceIds ?? [])],
|
|
176
|
+
retryByKey,
|
|
177
|
+
minRequestIntervalByKey,
|
|
178
|
+
consentTesting,
|
|
179
|
+
ignoredConsentTools,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Neutralizes the consent testing tools in production (FR-113).
|
|
185
|
+
*
|
|
186
|
+
* Dropped here, at the single validation point, rather than checked wherever
|
|
187
|
+
* they are used. That is what makes the guarantee provable: a rule enforced in
|
|
188
|
+
* one place can be asserted by a test, while one spread across the consent
|
|
189
|
+
* module's call sites is a hope. It is the same reasoning that makes the ad-unit
|
|
190
|
+
* substitution guarantee hold, and it is why both are enforced identically.
|
|
191
|
+
*
|
|
192
|
+
* The names of what was dropped are returned rather than only logged, so an
|
|
193
|
+
* automated test — and the demo app's diagnostics screen — can see it.
|
|
194
|
+
*/
|
|
195
|
+
function resolveConsentTesting(
|
|
196
|
+
testing: ConsentTestingOptions | undefined,
|
|
197
|
+
isProduction: boolean,
|
|
198
|
+
): { consentTesting: ConsentTestingOptions | undefined; ignoredConsentTools: readonly string[] } {
|
|
199
|
+
if (!testing || !isProduction) {
|
|
200
|
+
return { consentTesting: testing, ignoredConsentTools: [] };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const ignored: string[] = [];
|
|
204
|
+
if (testing.debugGeography !== undefined && testing.debugGeography !== 'disabled') {
|
|
205
|
+
ignored.push('debugGeography');
|
|
206
|
+
}
|
|
207
|
+
if (testing.resetOnStart === true) {
|
|
208
|
+
ignored.push('resetOnStart');
|
|
209
|
+
}
|
|
210
|
+
return { consentTesting: undefined, ignoredConsentTools: ignored };
|
|
211
|
+
}
|