@chipmobilesdk/rn-ads 0.1.0 → 0.2.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 +57 -0
- package/README.md +310 -11
- package/package.json +1 -1
- package/skills/sdk-rn-ads/SKILL.md +125 -32
- 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
|
@@ -1,33 +1,41 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: sdk-rn-ads
|
|
3
|
-
description: "Advertising via @chipmobilesdk/rn-ads: AdMob banner/interstitial/rewarded behind a provider-neutral contract, placement keys, consent
|
|
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."
|
|
4
4
|
metadata:
|
|
5
5
|
package: "@chipmobilesdk/rn-ads"
|
|
6
|
-
packageVersion: "0.
|
|
6
|
+
packageVersion: "0.2.0"
|
|
7
7
|
user-invocable: true
|
|
8
8
|
disable-model-invocation: false
|
|
9
9
|
---
|
|
10
10
|
|
|
11
11
|
# @chipmobilesdk/rn-ads
|
|
12
12
|
|
|
13
|
-
Adaptive banner, interstitial and
|
|
14
|
-
contract that names no provider. The app names a placement key; the package owns
|
|
15
|
-
request. Consuming code never imports the Google Mobile Ads SDK
|
|
13
|
+
Adaptive banner, interstitial, rewarded and app-open ads on Android via Google AdMob,
|
|
14
|
+
behind a contract that names no provider. The app names a placement key; the package owns
|
|
15
|
+
the request. Consuming code never imports the Google Mobile Ads SDK — including for
|
|
16
|
+
consent.
|
|
16
17
|
|
|
17
18
|
## Use this when
|
|
18
19
|
|
|
19
|
-
- Adding a banner, interstitial or
|
|
20
|
+
- Adding a banner, interstitial, rewarded or app-open ad to a screen
|
|
21
|
+
- Running a consent flow in a region that requires one, without importing AdMob
|
|
22
|
+
- Deferring ads at startup until a purchase or entitlement check answers
|
|
23
|
+
- Turning ads back on after a subscription lapses or a refund lands
|
|
20
24
|
- Mapping AdMob ad units to placement keys per environment
|
|
21
|
-
- Debugging an ad that never loads, or a `blocked` / `
|
|
22
|
-
|
|
25
|
+
- Debugging an ad that never loads, or a `blocked` / `undecided` / `unavailable` /
|
|
26
|
+
`ad_originated_return` / `provider_error`
|
|
23
27
|
- Answering a Data safety / App Privacy question about advertising
|
|
24
28
|
|
|
25
29
|
## Do not use this for
|
|
26
30
|
|
|
27
|
-
- **Consent UI or copy** — the package
|
|
28
|
-
|
|
31
|
+
- **Consent UI or copy** — the package *orchestrates* the provider's flow and normalizes
|
|
32
|
+
the outcome, but the form is drawn by the provider and the legal text is theirs. Timing,
|
|
33
|
+
surrounding copy and the "change your mind" path stay yours.
|
|
29
34
|
- **Where or how often ads appear** — no frequency cap, no placement rule, no auto-insert.
|
|
35
|
+
The package never shows an ad on its own, app-open included.
|
|
30
36
|
- **Granting the reward** — it reports `reward_earned`; entitlement is the app's.
|
|
37
|
+
- **Anything about payments** — the undecided state exists *because* apps sell ad removal,
|
|
38
|
+
but the package receives a decision and never a reason, receipt or entitlement.
|
|
31
39
|
- iOS — not implemented; every call returns `unavailable` / `unsupported_platform`.
|
|
32
40
|
|
|
33
41
|
## Nothing loads until consent and initialization complete
|
|
@@ -37,7 +45,14 @@ gate withheld, **zero** provider calls occur — not even configuration. `childD
|
|
|
37
45
|
`underAgeOfConsent` and `maxAdContentRating` take effect only if they reach
|
|
38
46
|
`initialize()` *before* the provider starts; changing one later returns
|
|
39
47
|
`requires_reinitialization` rather than silently not applying.
|
|
40
|
-
See `## Initialize — consent comes first`.
|
|
48
|
+
See `## Initialize — consent comes first` and `## Consent`.
|
|
49
|
+
|
|
50
|
+
## Three gates, one reason — check this order first
|
|
51
|
+
|
|
52
|
+
A refused request reports exactly one reason, and the precedence is fixed:
|
|
53
|
+
**platform availability → enablement → consent**, most immovable first. So
|
|
54
|
+
`unsupported_platform` on a disabled instance is reported as `unsupported_platform` —
|
|
55
|
+
turning ads on would not have helped. Full table in `## Why an ad did not show`.
|
|
41
56
|
|
|
42
57
|
## Never gate `<AdsBanner>` on its own placement state
|
|
43
58
|
|
|
@@ -47,40 +62,112 @@ unconditionally. Rendering it only when the state is `loading`/`ready` deadlocks
|
|
|
47
62
|
decide whether to hold or reclaim space — never to decide whether to mount the host. It
|
|
48
63
|
renders `null` on its own when there is nothing to show.
|
|
49
64
|
|
|
50
|
-
##
|
|
65
|
+
## `undecided` is not `disabled`, and that is the whole point
|
|
66
|
+
|
|
67
|
+
`enabled` takes `true`, `false` or `'undecided'`. All three send zero requests except
|
|
68
|
+
`true`. `'undecided'` reports itself distinctly — in `getStatus()` and in the banner's
|
|
69
|
+
placement state — so an app that has not yet heard back from the store can **hold** the
|
|
70
|
+
banner's space instead of reclaiming it, and a paying user never sees an ad flash in and
|
|
71
|
+
out. `disabled` means a settled no; reclaim the space.
|
|
72
|
+
|
|
73
|
+
The package never resolves it for you. Leave it unresolved past `undecidedWarningMs`
|
|
74
|
+
(default 10s) and you get exactly one `config_warning`, never a stream.
|
|
75
|
+
|
|
76
|
+
## `setEnabled` returns a result — read it
|
|
77
|
+
|
|
78
|
+
In 0.1.0 it returned `void` and re-enabling after a disable silently dropped the init
|
|
79
|
+
status, so every later `load` and `show` was refused with nothing to say so. That was a
|
|
80
|
+
defect and it is fixed. The result now says which happened:
|
|
81
|
+
|
|
82
|
+
- `resumed` — the provider was never un-initialized; load away, zero provider calls made.
|
|
83
|
+
- `requires_initialization` — call `initialize()` yourself. The package will **not** do it
|
|
84
|
+
implicitly, because that would hide an async, billable operation behind a setter.
|
|
85
|
+
|
|
86
|
+
Existing `ads.setEnabled(false);` call sites still compile untouched.
|
|
87
|
+
|
|
88
|
+
## Four things not to "fix"
|
|
51
89
|
|
|
52
90
|
- **The bridge pin.** `react-native-google-mobile-ads` is peered at `>=16.0.3 <16.1.0`;
|
|
53
91
|
16.1.0+ carries Kotlin 2.3 metadata that React Native 0.85's pinned compiler cannot
|
|
54
|
-
read, and the Android build fails outright.
|
|
92
|
+
read, and the Android build fails outright. `0.2.0` needed no bump — consent and
|
|
93
|
+
app-open are both already in `16.0.3`. Read `## Compatibility` before widening it.
|
|
55
94
|
- **Test ads in dev.** Outside `production`, non-official ad units are replaced with
|
|
56
95
|
Google's test units and reported via `config_warning` / `getSubstitutedPlacements()`.
|
|
57
96
|
That is the safety guarantee working, not a misconfiguration. Never click a production
|
|
58
97
|
ad to verify anything.
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
98
|
+
- **Consent tools going quiet in production.** `consentTesting.debugGeography` and
|
|
99
|
+
`resetOnStart` are stripped at config validation in `production` and listed by
|
|
100
|
+
`getIgnoredConsentTools()`. Working as designed.
|
|
101
|
+
- **A declined consent still reporting `obtained` / `canRequestAds: true`.** That is UMP's
|
|
102
|
+
meaning, not a bug: `OBTAINED` means *an answer was recorded*, and non-personalized ads
|
|
103
|
+
stay servable. The package forwards the provider's verdict rather than inventing one.
|
|
104
|
+
|
|
105
|
+
## Retry is off unless you ask
|
|
106
|
+
|
|
107
|
+
No `retry` in config means the request count is byte-identical to 0.1.0 — a test in the
|
|
108
|
+
package proves it. When enabled it is bounded (default 2 retries, 2s then 4s, no jitter),
|
|
109
|
+
retries only errors already flagged `retryable`, and cancels everything on disable,
|
|
110
|
+
undecided, consent loss or `dispose()`. Every attempt emits `retry_scheduled` carrying the
|
|
111
|
+
load cycle's `correlationId`, because every attempt is a billable request.
|
|
112
|
+
Per-placement `retry: false` turns it off for one placement.
|
|
113
|
+
|
|
114
|
+
## App-open: the one rule you cannot implement yourself
|
|
115
|
+
|
|
116
|
+
App-open shares the single full-screen lock with interstitial and rewarded, and expires on
|
|
117
|
+
its own shorter window (30 min default, vs 60). The part worth knowing: a show requested
|
|
118
|
+
on a return **from an ad** — or from a browser an ad opened — is refused with
|
|
119
|
+
`ad_originated_return`, distinct from `busy` and `not_ready`. Your app cannot tell that
|
|
120
|
+
return apart from any other; the package can. It cannot survive process death, and a
|
|
121
|
+
browser opened without a preceding click is undetectable. See `## App open`.
|
|
122
|
+
|
|
123
|
+
## Public API (v0.2.0)
|
|
124
|
+
|
|
125
|
+
Runtime: `createAds` (throws `AdsConfigError` on a bad catalog or an out-of-range retry
|
|
126
|
+
policy; everything else returns a typed result rather than throwing) · `AdsProvider`,
|
|
127
|
+
`useAds` · `AdsBanner` · `usePlacementState`, `useAdsStatus`, `useAdsEvents` · `AdsError`,
|
|
128
|
+
`AdsConfigError`, `isAdsError`, `isFullScreenFormat` · `DEFAULT_LOAD_TIMEOUT_MS`,
|
|
129
|
+
`DEFAULT_SHOW_TIMEOUT_MS`, `DEFAULT_AD_VALIDITY_MS`, `DEFAULT_APP_OPEN_VALIDITY_MS`,
|
|
130
|
+
`DEFAULT_RETRY_POLICY`, `DEFAULT_UNDECIDED_WARNING_MS`, `LOAD_TIMEOUT_BOUNDS`,
|
|
131
|
+
`SHOW_TIMEOUT_BOUNDS`, `AD_VALIDITY_BOUNDS`, `APP_OPEN_VALIDITY_BOUNDS`, `RETRY_BOUNDS`,
|
|
132
|
+
`UNDECIDED_WARNING_BOUNDS` · `createFakeAdsEngine`, `createTestClock` from
|
|
133
|
+
`@chipmobilesdk/rn-ads/testing` (test doubles only).
|
|
134
|
+
|
|
135
|
+
Instance: `initialize`, `updatePrivacy`, `setEnabled`, `setEnablement`, `setAppActive`,
|
|
136
|
+
`consent`, `getStatus`, `subscribeStatus`, `load`, `show`, `getPlacementState`,
|
|
137
|
+
`subscribePlacement`, `subscribeEvents`, `getSubstitutedPlacements`,
|
|
138
|
+
`getIgnoredConsentTools`, `dispose`.
|
|
139
|
+
|
|
140
|
+
Consent namespace (`ads.consent.*`, opt-in — untouched, nothing starts):
|
|
141
|
+
`requestInfoUpdate`, `showFormIfRequired`, `getState`, `subscribe`, `reset`.
|
|
73
142
|
|
|
74
143
|
Types: `AdsInstance`, `AdsConfig`, `PlacementDefinition`, `PrivacyOptions`, `AdFormat`,
|
|
75
|
-
`FullScreenFormat`, `Environment`, `
|
|
76
|
-
`
|
|
77
|
-
`
|
|
78
|
-
`
|
|
79
|
-
`
|
|
144
|
+
`FullScreenFormat`, `Environment`, `Enablement`, `EnablementResult`, `ConsentApi`,
|
|
145
|
+
`ConsentState`, `ConsentSnapshot`, `ConsentTestingOptions`, `RetryPolicy`, `InitStatus`,
|
|
146
|
+
`PlacementState`, `InitResult`, `LoadResult`, `ShowResult`, `PrivacyUpdateResult`,
|
|
147
|
+
`Reward`, `AdEvent`, `AdEventType`, `AdEventListener`, `ConfigWarning`, `ImpressionSource`,
|
|
148
|
+
`AdsErrorCode`, `AdsOperation`, `TimeoutConfig`, `AdContentRating`, `PrivacyBlockReason`,
|
|
149
|
+
`UnavailableReason`, `Unsubscribe`, `AdsBannerProps`, `AdsProviderProps`.
|
|
80
150
|
|
|
81
151
|
Call `dispose()` when the owning screen unmounts; it is idempotent, and so is every
|
|
82
152
|
`Unsubscribe`.
|
|
83
153
|
|
|
154
|
+
## Upgrading 0.1.0 → 0.2.0
|
|
155
|
+
|
|
156
|
+
One required change, compile-time only: five unions gained members, so an exhaustive
|
|
157
|
+
`switch` over any of them needs the new cases or a `default`.
|
|
158
|
+
|
|
159
|
+
| Union | New members |
|
|
160
|
+
|---|---|
|
|
161
|
+
| `AdFormat` | `'app_open'` |
|
|
162
|
+
| `InitStatus` | `'undecided'` |
|
|
163
|
+
| `PlacementState` | `'undecided'` |
|
|
164
|
+
| `PrivacyBlockReason` | `'consent_flow_required'`, `'consent_flow_error'` |
|
|
165
|
+
| `AdsErrorCode` | `'undecided'`, `'ad_originated_return'`, `'consent_flow_required'`, `'consent_flow_failed'`, `'retry_out_of_bounds'` |
|
|
166
|
+
|
|
167
|
+
Everything else is opt-in: consent, `'undecided'`, app-open and retry are all off or absent
|
|
168
|
+
until configured. One behavior change needs no action — on an unavailable platform with
|
|
169
|
+
ads also disabled, the reason is now `unsupported_platform` where 0.1.0 said `disabled`.
|
|
170
|
+
|
|
84
171
|
## Where the detail lives
|
|
85
172
|
|
|
86
173
|
Read one section of `node_modules/@chipmobilesdk/rn-ads/README.md`:
|
|
@@ -89,10 +176,16 @@ Read one section of `node_modules/@chipmobilesdk/rn-ads/README.md`:
|
|
|
89
176
|
|---|---|
|
|
90
177
|
| First integration | `## Install`, `## Configure` |
|
|
91
178
|
| Consent ordering | `## Initialize — consent comes first` |
|
|
179
|
+
| Running the consent flow | `## Consent` |
|
|
92
180
|
| Rendering a banner | `## Banner` |
|
|
93
181
|
| Full-screen formats | `## Interstitial and rewarded` |
|
|
182
|
+
| App-open and ad-originated return | `## App open` |
|
|
183
|
+
| Turning ads off, undecided, re-enabling | `## Turning ads off — three states` |
|
|
184
|
+
| Retry and reconciling it against a bill | `## Retry` |
|
|
185
|
+
| Why a request was refused | `## Why an ad did not show` |
|
|
94
186
|
| Results, events, errors, timeouts, cleanup | `## State, events, and errors` |
|
|
95
187
|
| Tests without a device | `## Testing` |
|
|
96
188
|
| Permissions, Data safety, families | `## Install`, `## Release checklists` |
|
|
97
189
|
| Build failure after a bridge upgrade | `## Compatibility` |
|
|
190
|
+
| Migration detail | `## Versioning` |
|
|
98
191
|
| What is deferred | `## Known limitations`, `## Extending to iOS` |
|
package/src/config/types.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { AdsError } from '../diagnostics/errors';
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Public configuration and state types (FR-001, FR-003, FR-004).
|
|
3
5
|
*
|
|
@@ -9,14 +11,26 @@
|
|
|
9
11
|
* one instance and is released by `dispose()`.
|
|
10
12
|
*/
|
|
11
13
|
|
|
12
|
-
/**
|
|
13
|
-
|
|
14
|
+
/**
|
|
15
|
+
* Supported formats. Native and rewarded-interstitial remain deferred.
|
|
16
|
+
*
|
|
17
|
+
* `app_open` joined in 0.2.0. It is a member of this union rather than a
|
|
18
|
+
* separate concept because FR-132 forbids a parallel vocabulary: app-open uses
|
|
19
|
+
* the same placement states, events, and error codes as everything else. The
|
|
20
|
+
* cost is that a consumer's exhaustive `switch` over this union stops compiling
|
|
21
|
+
* — the one required migration step, documented in the 0.2.0 release notes.
|
|
22
|
+
*/
|
|
23
|
+
export type AdFormat = 'banner' | 'interstitial' | 'rewarded' | 'app_open';
|
|
14
24
|
|
|
15
|
-
/**
|
|
16
|
-
|
|
25
|
+
/**
|
|
26
|
+
* Formats that take over the screen, and so contend for the single-show lock.
|
|
27
|
+
* App-open is included deliberately: two full-screen ads at once is a broken
|
|
28
|
+
* app regardless of which formats produced them (FR-129).
|
|
29
|
+
*/
|
|
30
|
+
export type FullScreenFormat = Extract<AdFormat, 'interstitial' | 'rewarded' | 'app_open'>;
|
|
17
31
|
|
|
18
32
|
export function isFullScreenFormat(format: AdFormat): format is FullScreenFormat {
|
|
19
|
-
return format === 'interstitial' || format === 'rewarded';
|
|
33
|
+
return format === 'interstitial' || format === 'rewarded' || format === 'app_open';
|
|
20
34
|
}
|
|
21
35
|
|
|
22
36
|
/**
|
|
@@ -41,6 +55,96 @@ export interface PlacementDefinition {
|
|
|
41
55
|
* type change for every existing consumer.
|
|
42
56
|
*/
|
|
43
57
|
readonly iosAdUnitId?: string;
|
|
58
|
+
/**
|
|
59
|
+
* Per-placement retry, overriding `AdsConfig.retry` for this placement only.
|
|
60
|
+
* Absent inherits; `false` turns retry off here while leaving it on elsewhere.
|
|
61
|
+
*
|
|
62
|
+
* An override *replaces* the instance-wide policy rather than merging into
|
|
63
|
+
* it, so the effective policy for a placement is readable from configuration
|
|
64
|
+
* without simulating a merge. Both are held to the same bounds, so an
|
|
65
|
+
* override can narrow behavior but never exceed a ceiling (FR-136a).
|
|
66
|
+
*/
|
|
67
|
+
readonly retry?: RetryPolicy | false;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Bounded retry for failed loads. Off unless `AdsConfig.retry` is supplied —
|
|
72
|
+
* upgrading from 0.1.0 without configuring this issues not one extra request
|
|
73
|
+
* (FR-134).
|
|
74
|
+
*
|
|
75
|
+
* Only errors already flagged `retryable` are retried. Configuration errors,
|
|
76
|
+
* consent errors, an unsupported platform, and a disabled or undecided instance
|
|
77
|
+
* never are (FR-135).
|
|
78
|
+
*
|
|
79
|
+
* Backoff is exponential with a fixed x2 multiplier and no jitter. Jitter exists
|
|
80
|
+
* to desynchronize a fleet hitting one server; a single device retrying its own
|
|
81
|
+
* load has no herd to avoid, and omitting it keeps the attempt count an exact
|
|
82
|
+
* assertion rather than a statistical one.
|
|
83
|
+
*/
|
|
84
|
+
export interface RetryPolicy {
|
|
85
|
+
/** Attempts *after* the first. Default 2, bounds 1-5. */
|
|
86
|
+
readonly maxRetries?: number;
|
|
87
|
+
/** Delay before the first retry. Default 2000ms, bounds 500-30000. */
|
|
88
|
+
readonly initialDelayMs?: number;
|
|
89
|
+
/** Ceiling the doubling cannot pass. Default 30000ms, bounds 1000-60000. */
|
|
90
|
+
readonly maxDelayMs?: number;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Whether the app wants ads, including the case where it does not know yet.
|
|
95
|
+
*
|
|
96
|
+
* The package never resolves `undecided` on the app's behalf and never learns
|
|
97
|
+
* why the app chose what it chose — no entitlement, no receipt, no reason
|
|
98
|
+
* (FR-119, FR-120).
|
|
99
|
+
*/
|
|
100
|
+
export type Enablement = 'enabled' | 'disabled' | 'undecided';
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Tools for exercising the consent flow from a device outside a
|
|
104
|
+
* consent-required region.
|
|
105
|
+
*
|
|
106
|
+
* **Stripped entirely when `environment` is `production`.** That is enforced at
|
|
107
|
+
* configuration validation rather than at each call site, so no path through the
|
|
108
|
+
* consent module can forget it, and the fact that they were ignored is
|
|
109
|
+
* observable through a `config_warning` event and `getIgnoredConsentTools()` —
|
|
110
|
+
* not merely documented (FR-113).
|
|
111
|
+
*/
|
|
112
|
+
export interface ConsentTestingOptions {
|
|
113
|
+
/** Makes the flow behave as though the device were in this region. */
|
|
114
|
+
readonly debugGeography?: 'disabled' | 'eea' | 'not_eea' | 'regulated_us_state' | 'other';
|
|
115
|
+
/** Clears any stored decision so the flow runs from the beginning. */
|
|
116
|
+
readonly resetOnStart?: boolean;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Consent status, normalized. Carries no provider type and no payload (FR-104).
|
|
121
|
+
*
|
|
122
|
+
* Shaped so an iOS adapter can later express both a consent flow and the
|
|
123
|
+
* platform's tracking-permission mechanism, plus the ordering between them,
|
|
124
|
+
* without changing shape.
|
|
125
|
+
*/
|
|
126
|
+
export type ConsentState =
|
|
127
|
+
/** Not yet asked. The initial value, and the value after a reset. */
|
|
128
|
+
| 'undetermined'
|
|
129
|
+
/** The provider reports this region does not require consent. */
|
|
130
|
+
| 'not_required'
|
|
131
|
+
/** Required and not yet obtained. */
|
|
132
|
+
| 'required'
|
|
133
|
+
/** Obtained. */
|
|
134
|
+
| 'obtained'
|
|
135
|
+
/**
|
|
136
|
+
* The flow failed — a provider error, a network failure, a cancellation, or
|
|
137
|
+
* a form dismissed without an answer. Never, under any circumstance, read as
|
|
138
|
+
* consent granted (FR-106).
|
|
139
|
+
*/
|
|
140
|
+
| 'error';
|
|
141
|
+
|
|
142
|
+
export interface ConsentSnapshot {
|
|
143
|
+
readonly state: ConsentState;
|
|
144
|
+
/** Always false when `state` is `error`. */
|
|
145
|
+
readonly canRequestAds: boolean;
|
|
146
|
+
readonly isFormAvailable: boolean;
|
|
147
|
+
readonly error: AdsError | null;
|
|
44
148
|
}
|
|
45
149
|
|
|
46
150
|
/** Bounds exist because an unbounded timeout is the same defect as no timeout. */
|
|
@@ -50,8 +154,16 @@ export interface TimeoutConfig {
|
|
|
50
154
|
}
|
|
51
155
|
|
|
52
156
|
export interface AdsConfig {
|
|
53
|
-
/**
|
|
54
|
-
|
|
157
|
+
/**
|
|
158
|
+
* FR-009. When `false`, every operation is a safe no-op reporting `disabled`.
|
|
159
|
+
*
|
|
160
|
+
* `'undecided'` was added in 0.2.0 for an app that cannot answer this at
|
|
161
|
+
* construction time — one selling an ad-removal tier has to ask a store
|
|
162
|
+
* first. Both boolean spellings keep their exact 0.1.0 meaning; widening an
|
|
163
|
+
* *input* union cannot break a consumer, because consumers write this value
|
|
164
|
+
* rather than switching on it (FR-114, BC-003).
|
|
165
|
+
*/
|
|
166
|
+
readonly enabled: boolean | 'undecided';
|
|
55
167
|
readonly environment: Environment;
|
|
56
168
|
readonly placements: readonly PlacementDefinition[];
|
|
57
169
|
readonly timeouts?: TimeoutConfig;
|
|
@@ -59,6 +171,30 @@ export interface AdsConfig {
|
|
|
59
171
|
readonly adValidityMs?: number;
|
|
60
172
|
/** Ignored entirely in `production` (FR-014). */
|
|
61
173
|
readonly testDeviceIds?: readonly string[];
|
|
174
|
+
/**
|
|
175
|
+
* How long a loaded app-open ad stays presentable. Separate from
|
|
176
|
+
* `adValidityMs`, which continues to govern interstitial and rewarded.
|
|
177
|
+
*
|
|
178
|
+
* Default 30 minutes, half the other formats' window. Note this is *our*
|
|
179
|
+
* constraint, not the provider's — Google expires app-open ads after four
|
|
180
|
+
* hours, which is longer than this package's general full-screen window. The
|
|
181
|
+
* shorter window trades some fill for a fresher creative at the most
|
|
182
|
+
* prominent moment in the app (FR-130).
|
|
183
|
+
*/
|
|
184
|
+
readonly appOpenValidityMs?: number;
|
|
185
|
+
/**
|
|
186
|
+
* How long `enabled: 'undecided'` may go unresolved before the package emits
|
|
187
|
+
* one diagnostic warning. Default 10 seconds.
|
|
188
|
+
*
|
|
189
|
+
* It warns and does nothing else. Resolving on the app's behalf would mean
|
|
190
|
+
* guessing at exactly the business decision FR-120 forbids the package from
|
|
191
|
+
* knowing.
|
|
192
|
+
*/
|
|
193
|
+
readonly undecidedWarningMs?: number;
|
|
194
|
+
/** Instance-wide retry. Absent means retry is entirely off (FR-134). */
|
|
195
|
+
readonly retry?: RetryPolicy;
|
|
196
|
+
/** Consent testing tools. Stripped in `production` (FR-113). */
|
|
197
|
+
readonly consentTesting?: ConsentTestingOptions;
|
|
62
198
|
}
|
|
63
199
|
|
|
64
200
|
/** Content ratings, normalized. Maps onto the provider's own rating scale. */
|
|
@@ -82,7 +218,20 @@ export interface PrivacyOptions {
|
|
|
82
218
|
readonly personalizedAds?: boolean;
|
|
83
219
|
}
|
|
84
220
|
|
|
85
|
-
|
|
221
|
+
/**
|
|
222
|
+
* Why the consent gate is closed.
|
|
223
|
+
*
|
|
224
|
+
* The two original members keep their exact v1 meaning — the app's own gate —
|
|
225
|
+
* so no existing consumer's interpretation changes. The two added in 0.2.0 name
|
|
226
|
+
* the package's consent flow instead. The split exists because the gate has two
|
|
227
|
+
* independent inputs that combine as most-restrictive-wins, and a refusal has to
|
|
228
|
+
* say *which* of them is blocking or the app cannot act on it (FR-105a).
|
|
229
|
+
*/
|
|
230
|
+
export type PrivacyBlockReason =
|
|
231
|
+
| 'consent_required'
|
|
232
|
+
| 'consent_undetermined'
|
|
233
|
+
| 'consent_flow_required'
|
|
234
|
+
| 'consent_flow_error';
|
|
86
235
|
|
|
87
236
|
export type UnavailableReason = 'unsupported_platform' | 'provider_missing';
|
|
88
237
|
|
|
@@ -93,6 +242,17 @@ export type InitStatus =
|
|
|
93
242
|
| 'initializing'
|
|
94
243
|
| 'ready'
|
|
95
244
|
| 'disabled'
|
|
245
|
+
/**
|
|
246
|
+
* The app has not decided whether ads are on yet — it is still waiting on
|
|
247
|
+
* whatever answers that question, typically a store entitlement sync.
|
|
248
|
+
*
|
|
249
|
+
* Behaves exactly like `disabled` for requests (zero reach the provider) but
|
|
250
|
+
* is deliberately a separate value. That distinction is the entire point:
|
|
251
|
+
* `disabled` means a settled no, and a banner host can reclaim its space;
|
|
252
|
+
* `undecided` means not yet, and the app may want to hold the space so a
|
|
253
|
+
* paying user never sees an ad flash in and out (FR-116).
|
|
254
|
+
*/
|
|
255
|
+
| 'undecided'
|
|
96
256
|
| 'unavailable'
|
|
97
257
|
| 'failed';
|
|
98
258
|
|
|
@@ -107,6 +267,8 @@ export type PlacementState =
|
|
|
107
267
|
| 'showing'
|
|
108
268
|
| 'consumed'
|
|
109
269
|
| 'failed'
|
|
270
|
+
/** Mirrors `InitStatus.undecided` so the banner host can report it directly. */
|
|
271
|
+
| 'undecided'
|
|
110
272
|
| 'unavailable';
|
|
111
273
|
|
|
112
274
|
/** FR-047. The provider's reward descriptor, normalized. */
|
package/src/config/validate.ts
CHANGED
|
@@ -13,9 +13,23 @@
|
|
|
13
13
|
import { AdsConfigError } from '../diagnostics/errors';
|
|
14
14
|
import { resolvePlacementAdUnits, type ResolvedCatalog } from './resolveAdUnit';
|
|
15
15
|
import { resolveTimeouts, type ResolvedTimeouts } from '../core/timers';
|
|
16
|
-
import
|
|
16
|
+
import { toEnablement } from '../core/enablement';
|
|
17
|
+
import { resolveEffectiveRetry, type ResolvedRetryPolicy } from '../core/retryPolicy';
|
|
18
|
+
import type {
|
|
19
|
+
AdFormat,
|
|
20
|
+
AdsConfig,
|
|
21
|
+
ConsentTestingOptions,
|
|
22
|
+
Enablement,
|
|
23
|
+
Environment,
|
|
24
|
+
PlacementDefinition,
|
|
25
|
+
} from './types';
|
|
17
26
|
|
|
18
|
-
const FORMATS: ReadonlySet<string> = new Set<AdFormat>([
|
|
27
|
+
const FORMATS: ReadonlySet<string> = new Set<AdFormat>([
|
|
28
|
+
'banner',
|
|
29
|
+
'interstitial',
|
|
30
|
+
'rewarded',
|
|
31
|
+
'app_open',
|
|
32
|
+
]);
|
|
19
33
|
|
|
20
34
|
export interface ValidateOptions {
|
|
21
35
|
/**
|
|
@@ -27,13 +41,23 @@ export interface ValidateOptions {
|
|
|
27
41
|
}
|
|
28
42
|
|
|
29
43
|
export interface ResolvedConfig {
|
|
30
|
-
readonly
|
|
44
|
+
readonly enablement: Enablement;
|
|
31
45
|
readonly environment: Environment;
|
|
32
46
|
readonly placements: ReadonlyMap<string, PlacementDefinition>;
|
|
33
47
|
readonly catalog: ResolvedCatalog;
|
|
34
48
|
readonly timeouts: ResolvedTimeouts;
|
|
35
49
|
/** Empty in production regardless of what was configured (FR-014). */
|
|
36
50
|
readonly testDeviceIds: readonly string[];
|
|
51
|
+
/** Effective retry per placement. `null` means retry is off there (FR-134). */
|
|
52
|
+
readonly retryByKey: ReadonlyMap<string, ResolvedRetryPolicy | null>;
|
|
53
|
+
/** Stripped entirely in production (FR-113). */
|
|
54
|
+
readonly consentTesting: ConsentTestingOptions | undefined;
|
|
55
|
+
/**
|
|
56
|
+
* Which consent tools were dropped because the environment is production.
|
|
57
|
+
* Surfaced so the inertness is observable rather than merely documented —
|
|
58
|
+
* the same treatment FR-011a gives substituted ad units.
|
|
59
|
+
*/
|
|
60
|
+
readonly ignoredConsentTools: readonly string[];
|
|
37
61
|
}
|
|
38
62
|
|
|
39
63
|
export function validateConfig(config: AdsConfig, options: ValidateOptions): ResolvedConfig {
|
|
@@ -79,7 +103,7 @@ export function validateConfig(config: AdsConfig, options: ValidateOptions): Res
|
|
|
79
103
|
|
|
80
104
|
if (!FORMATS.has(placement.format)) {
|
|
81
105
|
throw new AdsConfigError('invalid_config', {
|
|
82
|
-
message: `placement "${key}" has unsupported format "${String(placement.format)}";
|
|
106
|
+
message: `placement "${key}" has unsupported format "${String(placement.format)}"; supported formats are banner, interstitial, rewarded, and app_open`,
|
|
83
107
|
placementKey: key,
|
|
84
108
|
});
|
|
85
109
|
}
|
|
@@ -95,11 +119,30 @@ export function validateConfig(config: AdsConfig, options: ValidateOptions): Res
|
|
|
95
119
|
byKey.set(key, { ...placement, key });
|
|
96
120
|
}
|
|
97
121
|
|
|
98
|
-
const timeouts = resolveTimeouts(
|
|
122
|
+
const timeouts = resolveTimeouts({
|
|
123
|
+
timeouts: config.timeouts,
|
|
124
|
+
adValidityMs: config.adValidityMs,
|
|
125
|
+
appOpenValidityMs: config.appOpenValidityMs,
|
|
126
|
+
undecidedWarningMs: config.undecidedWarningMs,
|
|
127
|
+
});
|
|
99
128
|
const catalog = resolvePlacementAdUnits(Array.from(byKey.values()), config.environment);
|
|
100
129
|
|
|
130
|
+
// Resolved eagerly, for every placement, so an out-of-bounds override fails
|
|
131
|
+
// at construction rather than on the first failed load in production — which
|
|
132
|
+
// is the one moment nobody is watching for a configuration error.
|
|
133
|
+
const retryByKey = new Map<string, ResolvedRetryPolicy | null>();
|
|
134
|
+
for (const [key, placement] of byKey) {
|
|
135
|
+
retryByKey.set(key, resolveEffectiveRetry(config.retry, placement.retry, key));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const isProduction = config.environment === 'production';
|
|
139
|
+
const { consentTesting, ignoredConsentTools } = resolveConsentTesting(
|
|
140
|
+
config.consentTesting,
|
|
141
|
+
isProduction,
|
|
142
|
+
);
|
|
143
|
+
|
|
101
144
|
return {
|
|
102
|
-
|
|
145
|
+
enablement: toEnablement(config.enabled),
|
|
103
146
|
environment: config.environment,
|
|
104
147
|
placements: byKey,
|
|
105
148
|
catalog,
|
|
@@ -107,6 +150,39 @@ export function validateConfig(config: AdsConfig, options: ValidateOptions): Res
|
|
|
107
150
|
// FR-014: debug and test-device settings must not be able to alter
|
|
108
151
|
// production behavior, so they are dropped rather than carried and
|
|
109
152
|
// conditionally ignored somewhere further down.
|
|
110
|
-
testDeviceIds:
|
|
153
|
+
testDeviceIds: isProduction ? [] : [...(config.testDeviceIds ?? [])],
|
|
154
|
+
retryByKey,
|
|
155
|
+
consentTesting,
|
|
156
|
+
ignoredConsentTools,
|
|
111
157
|
};
|
|
112
158
|
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Neutralizes the consent testing tools in production (FR-113).
|
|
162
|
+
*
|
|
163
|
+
* Dropped here, at the single validation point, rather than checked wherever
|
|
164
|
+
* they are used. That is what makes the guarantee provable: a rule enforced in
|
|
165
|
+
* one place can be asserted by a test, while one spread across the consent
|
|
166
|
+
* module's call sites is a hope. It is the same reasoning that makes the ad-unit
|
|
167
|
+
* substitution guarantee hold, and it is why both are enforced identically.
|
|
168
|
+
*
|
|
169
|
+
* The names of what was dropped are returned rather than only logged, so an
|
|
170
|
+
* automated test — and the demo app's diagnostics screen — can see it.
|
|
171
|
+
*/
|
|
172
|
+
function resolveConsentTesting(
|
|
173
|
+
testing: ConsentTestingOptions | undefined,
|
|
174
|
+
isProduction: boolean,
|
|
175
|
+
): { consentTesting: ConsentTestingOptions | undefined; ignoredConsentTools: readonly string[] } {
|
|
176
|
+
if (!testing || !isProduction) {
|
|
177
|
+
return { consentTesting: testing, ignoredConsentTools: [] };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const ignored: string[] = [];
|
|
181
|
+
if (testing.debugGeography !== undefined && testing.debugGeography !== 'disabled') {
|
|
182
|
+
ignored.push('debugGeography');
|
|
183
|
+
}
|
|
184
|
+
if (testing.resetOnStart === true) {
|
|
185
|
+
ignored.push('resetOnStart');
|
|
186
|
+
}
|
|
187
|
+
return { consentTesting: undefined, ignoredConsentTools: ignored };
|
|
188
|
+
}
|