@chipmobilesdk/rn-ads 0.4.0 → 0.5.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/README.md CHANGED
@@ -1,879 +1,881 @@
1
- # @chipmobilesdk/rn-ads
2
-
3
- Reusable advertising capability for ChipMobileSdk-family React Native apps.
4
-
5
- The package owns **capability**: a safe, consistent, provider-neutral way to request and present ads. Your app owns **policy**: where ads appear, when, how often, to whom, under what consent, and whether they appear at all. There is no placement rule, no frequency cap, and no screen name in this package.
6
-
7
- **You never import the Google Mobile Ads SDK.** You name a placement key you chose, and the package does the rest.
8
-
9
- ---
10
-
11
- ## Scope
12
-
13
- | | 0.4.0 |
14
- |---|---|
15
- | **Platform** | Android only. iOS returns `unavailable` / `unsupported_platform` and never breaks a build. |
16
- | **Provider** | Google AdMob, via `react-native-google-mobile-ads` |
17
- | **Formats** | Anchored adaptive banner, interstitial, rewarded, app-open |
18
- | **Consent** | Optional orchestration of the provider's consent flow, including the form a user changes their answer through — you still import nothing of theirs |
19
- | **Account safety** | Non-production builds cannot reach a production ad unit; each placement has a request floor — see [Request throttling](#request-throttling) |
20
- | **Deferred** | Native, rewarded-interstitial, mediation, iOS — see [Known limitations](#known-limitations) |
21
-
22
- ## Compatibility
23
-
24
- | | Supported |
25
- |---|---|
26
- | React | `>=19` (verified against 19.2.3) |
27
- | React Native | `>=0.85`, New Architecture (verified against 0.85.3) |
28
- | Node | `>=22.11.0` |
29
- | Android | `minSdk 24`, `compileSdk 36`, `targetSdk 36`, Kotlin 2.1.x |
30
- | Provider bridge | `react-native-google-mobile-ads` **`>=16.0.3 <16.1.0`** |
31
- | Google Mobile Ads Android SDK | 24.9.0 (transitive) |
32
- | Google UMP (consent) | 4.0.0 (transitive) |
33
-
34
- > **`0.2.0` required no bridge bump, and neither did `0.4.0`.** The pinned `16.0.3` already exposes everything the consent flow, the app-open format and the privacy options form need (`AdsConsent.showPrivacyOptionsForm()` and `privacyOptionsRequirementStatus` on the consent info) — verified against the installed type declarations and the bridge's Android module rather than assumed from documentation. The Kotlin constraint below is therefore untouched by both releases, and the merged manifest gains no permission.
35
-
36
- > **Why the bridge range is closed at the top.** Bridge 16.1.0+ moves to Google Mobile Ads 25.x, which is compiled with Kotlin 2.3.0 metadata. React Native 0.85.3 pins the Kotlin **compiler** at 2.1.x, and the build fails with `Module was compiled with an incompatible version of Kotlin`. Raising the root `ext.kotlinVersion` does not fix it — that moves only `kotlin-stdlib`, not the compiler. An open caret would let a routine `npm install` walk straight back into a build failure, so the range refuses rather than drifts. Revisit when React Native ships a Kotlin 2.3 compiler.
37
-
38
- ---
39
-
40
- ## Install
41
-
42
- ```sh
43
- npm install @chipmobilesdk/rn-ads react-native-google-mobile-ads@16.0.3
44
- ```
45
-
46
- ### Required Android configuration
47
-
48
- Add your AdMob **application ID** to the app's `app.json`. The bridge's Gradle script reads this key and merges it into `AndroidManifest.xml`; without it the native SDK crashes on startup.
49
-
50
- ```jsonc
51
- {
52
- "react-native-google-mobile-ads": {
53
- // Google's public test application ID. Replace with your own for release.
54
- "android_app_id": "ca-app-pub-3940256099942544~3347511713"
55
- }
56
- }
57
- ```
58
-
59
- **The shared package carries no application ID and no ad unit ID.** Both are per-app configuration.
60
-
61
- ### Permissions merged into your manifest
62
-
63
- Contributed by the provider, not requested by this package:
64
-
65
- Verified against a real merged manifest, not a list from documentation:
66
-
67
- | Permission | Source |
68
- |---|---|
69
- | `android.permission.INTERNET` | bridge manifest |
70
- | `android.permission.ACCESS_NETWORK_STATE` | bridge manifest |
71
- | `android.permission.WAKE_LOCK` | bridge manifest |
72
- | `com.google.android.gms.permission.AD_ID` | `play-services-ads-api` |
73
- | `android.permission.ACCESS_ADSERVICES_AD_ID` | `play-services-ads-api` |
74
- | `android.permission.ACCESS_ADSERVICES_ATTRIBUTION` | `play-services-ads-api` |
75
- | `android.permission.ACCESS_ADSERVICES_TOPICS` | `play-services-ads-api` |
76
-
77
- **Re-verified for `0.2.0`.** Adding the app-open format and activating the consent module contributed **no new permission** — both live in the already-pinned bridge, so the merged result is byte-identical to `0.1.0`'s.
78
-
79
- The last three are **Android Privacy Sandbox** permissions covering the Privacy Sandbox ad ID, attribution reporting, and the Topics API. They matter for your Data safety declaration and they are easy to miss, because they appear only in the merged result — no library README lists them.
80
-
81
- Verify the actual merged result before release — read the file, don't trust this table:
82
-
83
- ```sh
84
- cd android && ./gradlew :app:assembleDebug
85
- # then inspect android/app/build/intermediates/merged_manifests/**/AndroidManifest.xml
86
- ```
87
-
88
- **To remove advertising identifier access** (required for child-directed apps under Play Families policy).
89
-
90
- Removing `AD_ID` alone is **not enough** — verified by rebuild, the three Privacy Sandbox permissions survive it and must be removed explicitly:
91
-
92
- ```xml
93
- <uses-permission android:name="com.google.android.gms.permission.AD_ID" tools:node="remove" />
94
- <uses-permission android:name="android.permission.ACCESS_ADSERVICES_AD_ID" tools:node="remove" />
95
- <uses-permission android:name="android.permission.ACCESS_ADSERVICES_ATTRIBUTION" tools:node="remove" />
96
- <uses-permission android:name="android.permission.ACCESS_ADSERVICES_TOPICS" tools:node="remove" />
97
- ```
98
-
99
- Your `<manifest>` tag needs `xmlns:tools="http://schemas.android.com/tools"`. Rebuild and re-inspect the merged manifest to confirm — do not assume.
100
-
101
- ---
102
-
103
- ## Configure
104
-
105
- Placement keys are **yours**. Provider ad unit IDs appear here in configuration and nowhere else in your code.
106
-
107
- ```ts
108
- import { createAds } from '@chipmobilesdk/rn-ads';
109
-
110
- const ads = createAds({
111
- enabled: true, // your feature flag; false makes everything a safe no-op
112
- environment: 'development', // 'development' | 'test' | 'production'
113
- placements: [
114
- { key: 'home_footer', format: 'banner', androidAdUnitId: 'ca-app-pub-…/…' },
115
- { key: 'level_break', format: 'interstitial', androidAdUnitId: 'ca-app-pub-…/…' },
116
- { key: 'completion_reward', format: 'rewarded', androidAdUnitId: 'ca-app-pub-…/…' },
117
- ],
118
- });
119
- ```
120
-
121
- `createAds` **throws** on a malformed catalog — duplicate key, missing ad unit, unknown format, out-of-band timeout — naming the offending placement. Every other failure in the package is a typed result, never a throw.
122
-
123
- Reading configuration requires no dotenv library, no remote-config service, and no feature-flag system. Supply the values however you like.
124
-
125
- ### How production traffic is made impossible
126
-
127
- In `development` and `test`, any ad unit that is not one of Google's official test units is **replaced** with one, and the substitution is reported — as a `config_warning` event naming every affected placement, and in observable state via `ads.getSubstitutedPlacements()`.
128
-
129
- ```
130
- environment: development
131
- home_footer -> ca-app-pub-REAL…/1234567890
132
-
133
- ⚠ config_warning: test_ad_unit_substituted
134
- placementKeys: ['home_footer']
135
- → request sent with ca-app-pub-3940256099942544/9214589741
136
- → your app keeps working
137
- ```
138
-
139
- In `production`, nothing is substituted — your units are used exactly as configured.
140
-
141
- The one case substitution cannot cover is a **debug build declaring `production`**: that would send real requests from a build that should never generate traffic. It fails closed with `production_in_dev_build`.
142
-
143
- > Invalid traffic from non-production builds can get an AdMob account suspended. **Never click a production ad to verify anything.**
144
-
145
- ---
146
-
147
- ## Initialize — consent comes first
148
-
149
- The package will not initialize anything request-capable until your privacy gate permits it. This is enforced, not documented: with the gate withheld, **zero** provider calls occur.
150
-
151
- ```tsx
152
- import { AdsProvider } from '@chipmobilesdk/rn-ads';
153
-
154
- await ads.initialize({
155
- canRequestAds: true, // your consent decision — the package renders no consent UI
156
- consentUndetermined: false, // true means "not asked yet", distinct from "refused"
157
- childDirected: false,
158
- underAgeOfConsent: false,
159
- maxAdContentRating: 'G',
160
- personalizedAds: true,
161
- });
162
-
163
- <AdsProvider instance={ads}>{children}</AdsProvider>
164
- ```
165
-
166
- **Ordering matters.** `childDirected`, `underAgeOfConsent`, and `maxAdContentRating` only take effect if they reach the provider *before* it initializes. Change one afterwards and `updatePrivacy` returns `requires_reinitialization` rather than pretending it applied.
167
-
168
- Resolving consent after being blocked is the normal path and creates **no** duplicate initialization:
169
-
170
- ```ts
171
- await ads.initialize({ canRequestAds: false }); // → { status: 'blocked', reason: 'consent_required' }
172
- await ads.updatePrivacy({ canRequestAds: true }); // → { status: 'applied' }, one native init total
173
- ```
174
-
175
- **If you use the package's consent flow, it is part of the gate too.** Once you have called `ads.consent.*`, `initialize()` waits for the flow to permit requests, exactly as `load` does:
176
-
177
- ```ts
178
- await ads.consent.requestInfoUpdate(); // the user declines, or the flow fails
179
- await ads.initialize({ canRequestAds: true }); // → { status: 'blocked', reason: 'consent_flow_required' }
180
- // no native initialization
181
- // ...later the user consents, through either consent form:
182
- // the package initializes the provider itself — once — and the status becomes `ready`.
183
- ```
184
-
185
- Before `0.4.0` this returned `ready` and initialized the provider even though `load` refused, which goes against the provider's own guidance not to initialize before consent permits requests. Calling `initialize()` *before* any `ads.consent.*` call still initializes on your own gate alone, as in `0.1.0`.
186
-
187
- The package logs no personal data, no consent payload, and no advertising identifier.
188
-
189
- ---
190
-
191
- ## Banner
192
-
193
- You place it. It sizes itself to your container and re-adapts on rotation.
194
-
195
- ```tsx
196
- import { AdsBanner, usePlacementState } from '@chipmobilesdk/rn-ads';
197
-
198
- function Footer() {
199
- const state = usePlacementState('home_footer');
200
- const reserve = state === 'loading' || state === 'ready';
201
-
202
- return reserve ? (
203
- <View style={styles.slot}>
204
- {state === 'loading' ? <YourThemedPlaceholder /> : null}
205
- <AdsBanner placementKey="home_footer" />
206
- </View>
207
- ) : null; // your decision to reclaim the space
208
- }
209
- ```
210
-
211
- The component **ships no placeholder** — that is yours, so it matches your theme. When ads are disabled or unavailable it renders `null`, not an empty view, so it leaves the accessibility tree entirely rather than taking screen-reader focus ahead of your content.
212
-
213
- ## Interstitial and rewarded
214
-
215
- Load ahead of time; show only when *you* decide.
216
-
217
- ```ts
218
- await ads.load('level_break');
219
- const result = await ads.show('level_break');
220
-
221
- switch (result.status) {
222
- case 'shown': break;
223
- case 'not_ready': break; // no UI opened, nothing crashed
224
- case 'busy': break; // another full-screen ad is up — never queued
225
- case 'consumed': break; // load a fresh one first
226
- case 'app_inactive': break; // backgrounded
227
- case 'blocked': break; // result.reason
228
- case 'disabled': break;
229
- case 'unavailable': break; // result.reason
230
- case 'timeout': break;
231
- case 'failed': break; // result.error
232
- }
233
- ```
234
-
235
- Rewards are reported **only** when the provider confirms them:
236
-
237
- ```ts
238
- ads.subscribeEvents(event => {
239
- if (event.type === 'reward_earned') {
240
- grant(event.reward.type, event.reward.amount); // your grant, not the package's
241
- }
242
- if (event.type === 'dismissed' && event.rewardEarned === false) {
243
- // closed early — no reward, and none inferred
244
- }
245
- });
246
- ```
247
-
248
- `reward_earned` and `dismissed` may arrive from the provider in either order; the outcome is identical, and a duplicate confirmation is collapsed into one event. **The package grants nothing** — validating and delivering the benefit is yours.
249
-
250
- ---
251
-
252
- ## State, events, and errors
253
-
254
- **Initialization status**: `not_started` · `blocked` · `initializing` · `ready` · `disabled` · `unavailable` · `failed`
255
-
256
- **Placement state**: `idle` · `loading` · `ready` · `showing` · `consumed` · `failed` · `unavailable`
257
-
258
- **Events** carry the placement key, format, timestamp, and a `correlationId` tying one load/show cycle together — enough to reconstruct an ad's whole life from the stream alone. They carry no provider object, no advertising identifier, and no consent payload.
259
-
260
- `initialization` · `load_started` · `loaded` · `load_failed` · `display_started` · `impression` · `click` · `display_failed` · `dismissed` · `reward_earned` · `disabled` · `unavailable` · `config_warning` · `internal_error`
261
-
262
- > **One honest asymmetry.** `impression` carries `source: 'provider' | 'derived'`. Banners get a real provider impression callback. Full-screen formats have none in the provider's event enum, so theirs is derived from the ad opening. If you reconcile these events against AdMob revenue reporting, you need to know which is which.
263
-
264
- **Ordering and exactly-once** are documented and tested. Within one `correlationId`, `loaded` / `display_started` / `impression` / `dismissed` / `reward_earned` occur at most once; `click` may repeat. After `dispose()`, zero events are emitted — a late provider callback is dropped.
265
-
266
- **Errors** carry a stable `code`, a log-safe `message`, the `operation`, a `retryable` flag, and a redacted `cause`. Codes are public API; renaming one is a breaking change.
267
-
268
- Retryable: `no_fill` `network_error` `load_timeout` `show_timeout` `ad_expired` `not_ready` `busy` `consumed` `app_inactive` `provider_error`
269
- Not retryable: `invalid_config` `duplicate_placement_key` `missing_ad_unit_id` `format_mismatch` `placement_not_found` `production_in_dev_build` `timeout_out_of_bounds` `unsupported_platform` `provider_missing` `disabled` `consent_required` `consent_undetermined` `requires_reinitialization` `internal_error`
270
-
271
- Attach any logger or analytics you already use. The package depends on none:
272
-
273
- ```ts
274
- const unsubscribe = ads.subscribeEvents(event => yourLogger.track(event.type, event));
275
- ```
276
-
277
- A listener that throws is caught and reported once; it cannot break the state machine or starve other listeners.
278
-
279
- ### Timeouts and cleanup
280
-
281
- | | Default | Configurable range |
282
- |---|---|---|
283
- | Load timeout | 30 000 ms | 5 000 – 120 000 |
284
- | Show timeout | 10 000 ms | 5 000 – 30 000 |
285
- | Loaded-ad validity | 3 600 000 ms | 60 000 – 3 600 000 |
286
- | App-open validity | 1 800 000 ms | 60 000 – 3 600 000 |
287
- | Undecided warning | 10 000 ms | 1 000 – 120 000 |
288
-
289
- Out-of-band values are a configuration error, not silently clamped. A loaded full-screen ad older than its validity window stops reporting `ready` and requires a reload — so you never render a button for an ad that will fail on tap.
290
-
291
- `dispose()` cancels every timer and subscription and releases every handle — including a consent flow in progress, a pending retry, and the undecided-state warning. It is idempotent, and so is every `Unsubscribe`.
292
-
293
- ---
294
-
295
- ## Retry
296
-
297
- **Off by default.** Upgrading from `0.1.0` without configuring `retry` sends exactly the requests it always did.
298
-
299
- ```ts
300
- createAds({
301
- // ...
302
- retry: { maxRetries: 2, initialDelayMs: 2_000, maxDelayMs: 30_000 },
303
- placements: [
304
- { key: 'home_footer', format: 'banner', androidAdUnitId: '...' },
305
- // Off for this placement only: a rewarded retry happens with a user
306
- // waiting on the other side of it.
307
- { key: 'bonus', format: 'rewarded', androidAdUnitId: '...', retry: false },
308
- ],
309
- });
310
- ```
311
-
312
- | | Default | Range |
313
- |---|---|---|
314
- | `maxRetries` (attempts after the first) | 2 | 1 – 5 |
315
- | `initialDelayMs` | 2 000 | 500 – 30 000 |
316
- | `maxDelayMs` | 30 000 | 1 000 – 60 000 |
317
-
318
- Backoff is exponential with a fixed x2 multiplier and no jitter — a single device has no fleet to desynchronize, and determinism makes the attempt count exactly assertable.
319
-
320
- **Only `retryable` errors are retried.** Configuration errors, consent errors, an unsupported platform, and a disabled or undecided instance never are: another attempt cannot change any of them, so retrying would spend a billable request on a certainty.
321
-
322
- A per-placement `retry` **replaces** the instance-wide policy rather than merging into it, so the effective policy for a placement is readable from the configuration without simulating a merge. Both are held to the same bounds, so an override can narrow behavior but never exceed a ceiling. Out-of-range values throw at construction — you find out when you build the instance, not on the first failed load in production.
323
-
324
- Disabling ads, moving to `undecided`, losing consent, or `dispose()` cancels **every** pending retry across every placement immediately.
325
-
326
- ### Reconciling retries against a bill
327
-
328
- Each attempt emits a `retry_scheduled` event carrying the **same `correlationId`** as the load cycle that triggered it:
329
-
330
- ```ts
331
- ads.subscribeEvents(event => {
332
- if (event.type === 'retry_scheduled') {
333
- analytics.track('ad_retry', {
334
- placement: event.placementKey,
335
- attempt: event.attempt,
336
- delayMs: event.delayMs,
337
- reason: event.error.code,
338
- cycle: event.correlationId,
339
- });
340
- }
341
- });
342
- ```
343
-
344
- Counting `load_started` plus `retry_scheduled` for a placement gives the number of requests that placement actually cost you. You keep the option of ignoring all of this and writing your own retry — enabling the package's is a choice, not a replacement.
345
-
346
- ---
347
-
348
- ## Request throttling
349
-
350
- **New in 0.3.0.** Each placement has a minimum interval between provider *requests*.
351
-
352
- ### The problem it solves
353
-
354
- A banner mounted inside a view the user navigates between — a day in a calendar, a tab, a row that scrolls in and out of a list — is unmounted and remounted by React on every navigation. **Each remount is a billable request.** A user swiping quickly through ten days produces ten requests in a few seconds, and nothing about that is you doing something wrong.
355
-
356
- Ad networks read that pattern as invalid traffic. The penalty is account-level and shipping a fix does not recover it, which is why this is enforced by the package rather than left as advice.
357
-
358
- Full-screen formats do not have the problem: they are loaded by an explicit call, not mounted by a renderer. That asymmetry is why the defaults differ.
359
-
360
- ### Defaults
361
-
362
- | Format | Default floor | Why |
363
- |---|---|---|
364
- | `banner` | **60 000 ms** | AdMob's own documented floor for banner auto-refresh. Asking more often than that is, by the provider's own standard, more often than it considers reasonable. |
365
- | `interstitial`, `rewarded`, `app_open` | **0** (off) | `load` → `show` → `load` is the healthy cycle. An app that legitimately sells three things in two minutes loads three times, and a default floor would break correct apps to solve a problem these formats do not have. |
366
-
367
- Override per placement:
368
-
369
- ```ts
370
- placements: [
371
- {
372
- key: 'timeline_row',
373
- format: 'banner',
374
- androidAdUnitId: 'ca-app-pub-…/…',
375
- minRequestIntervalMs: 30_000, // lower the floor knowingly
376
- },
377
- {
378
- key: 'after_save',
379
- format: 'interstitial',
380
- androidAdUnitId: 'ca-app-pub-…/…',
381
- minRequestIntervalMs: 20_000, // add a floor where there is none by default
382
- },
383
- ]
384
- ```
385
-
386
- Bounds are `0`–`600 000` ms, and an out-of-range value throws `AdsConfigError` at `createAds` — naming the placement — rather than refusing every request later in production.
387
-
388
- ### What a refusal looks like
389
-
390
- ```ts
391
- const result = await ads.load('after_save');
392
- if (result.status === 'throttled') {
393
- // Nothing was requested. Nothing is wrong.
394
- console.log(`asked too recently; retry in ${result.retryAfterMs}ms`);
395
- }
396
- ```
397
-
398
- - The placement reports `'throttled'` — **not** `'failed'`, and **not** a no-fill. No-fill means the network had nothing to give; this means we did not ask. An app that conflates them will misdiagnose the first time a placement goes quiet.
399
- - A `request_throttled` event carries `retryAfterMs` and the `minRequestIntervalMs` that refused it, so the cause is readable from your logs alone.
400
- - No `load_started` is emitted. A refused request is not a cycle, and the event stream must not claim a billable request that never happened.
401
-
402
- ### Two exemptions, both deliberate
403
-
404
- **Rotation is not charged.** A width re-adaptation inside one mount — the device rotated, the container resized — is the same banner the placement already legitimately requested. Refusing it would leave a rotated screen with no banner for up to a minute, which this was never meant to cost. The slot is charged once per *mount*, not per request.
405
-
406
- **A concurrent duplicate load is not charged.** Two callers arriving for one cycle already join the in-flight request; charging the second would turn an existing guarantee into a refusal.
407
-
408
- ### The better fix is architectural
409
-
410
- > Keep the banner **outside** the subtree that remounts and the throttle never fires. A banner pinned above or below a swipeable area stays mounted across navigation, keeps its ad, and costs one request.
411
- >
412
- > This throttle is a safety net for when that is not possible — a banner genuinely inside a list, for instance. It is not a licence to ignore the placement problem, and reaching for `minRequestIntervalMs: 0` to silence a `throttled` state puts your ad account back where it was.
413
-
414
- ### This is not a frequency cap
415
-
416
- It limits how often a placement may **ask** for an ad. It says nothing about how often you may **show** one, and the package deliberately ships no pacing mechanism at all.
417
-
418
- Pacing is a product decision with no correct default: one app counts items created, another counts screens opened, another counts minutes elapsed. Shipping one here would make the package own policy that belongs to you. If you want "an interstitial every third save", count the saves in your app and call `show` when your rule says so.
419
-
420
- ---
421
-
422
- ## Turning ads off — three states
423
-
424
- `enabled` accepts `true`, `false`, or `'undecided'`.
425
-
426
- | State | Requests sent | Init status | Banner reports | When to use it |
427
- |---|---|---|---|---|
428
- | `true` | yes | normal | normal | Ads are on |
429
- | `false` | **zero** | `disabled` | `unavailable` | A settled no |
430
- | `'undecided'` | **zero** | `undecided` | `undecided` | You do not know yet |
431
-
432
- ### Why the third state exists
433
-
434
- If you sell an ad-removal tier, at cold start you do not yet know whether this user has paid — that answer arrives after a store sync. Both of the other options are wrong: `true` flashes an ad at someone who paid not to see one, and `false` delays ads for every free user and drops you into the re-enable path below.
435
-
436
- `undecided` sends nothing, exactly like `disabled`, but reports itself **distinctly** so your banner host can hold the space rather than reclaim it:
437
-
438
- ```tsx
439
- <AdsBanner placementKey="home_footer" onStateChange={setState} />
440
- {state === 'undecided' ? <YourThemedPlaceholder /> : null}
441
- ```
442
-
443
- Resolve it exactly once when you know:
444
-
445
- ```ts
446
- const result = ads.setEnablement(userOwnsAdRemoval ? 'disabled' : 'enabled');
447
- ```
448
-
449
- The package **never resolves it for you** — that would mean guessing at a business decision it is forbidden to know about. If you leave it unresolved past `undecidedWarningMs` you get one `config_warning` (`enablement_undecided_timeout`) and nothing else. One, not a repeating stream: a warning that fires every ten seconds becomes noise that gets filtered.
450
-
451
- ### Re-enabling
452
-
453
- **This changed in `0.2.0`, and the old behavior was a defect.** In `0.1.0`, `setEnabled(true)` after a disable dropped the init status to `not_started`, so every later `load` and `show` was refused — and `setEnabled` returned `void`, so nothing told you. An app whose user's subscription lapsed would turn ads back on and silently show none.
454
-
455
- Now the result is typed and always says what happened:
456
-
457
- ```ts
458
- const result = ads.setEnabled(true);
459
-
460
- switch (result.status) {
461
- case 'resumed':
462
- // The provider was never un-initialized. Nothing to do — load away.
463
- break;
464
- case 'requires_initialization':
465
- // Never reached ready, privacy changed, or the platform went away.
466
- await ads.initialize(privacy);
467
- break;
468
- default:
469
- break;
470
- }
471
- ```
472
-
473
- The package does **not** initialize implicitly on your behalf: that would hide an asynchronous, billable operation behind a setter you may not be awaiting, and a failure would surface with no call site to attribute it to.
474
-
475
- If your privacy options have not changed you do not supply them again. They live in memory for the lifetime of the instance and are never persisted.
476
-
477
- In every off state — `false` or `'undecided'` — in-flight loads are released deterministically, and an ad **already on screen is not force-closed**: the provider offers no safe way to do so, and yanking a full-screen ad out from under the user would be worse than letting it finish.
478
-
479
- ### Wiring it to an external decision
480
-
481
- The package has no idea what an entitlement is. That is deliberate — it receives a decision, never a reason:
482
-
483
- ```ts
484
- const ads = createAds({ enabled: 'undecided', /* ... */ });
485
-
486
- // Whatever answers this for you: a billing library, your own backend, a cached
487
- // receipt. The package neither knows nor stores which.
488
- entitlements.subscribe(owned => {
489
- ads.setEnablement(owned ? 'disabled' : 'enabled');
490
- });
491
- ```
492
-
493
- ---
494
-
495
- ## Consent
496
-
497
- **Optional.** If you already supply your own gate through `PrivacyOptions.canRequestAds`, nothing here starts on your behalf and your `0.1.0` integration is untouched.
498
-
499
- Use it when you ship to a region that requires consent. Before `0.2.0` that meant importing the provider's consent SDK directly — which broke the one promise this package exists to keep — and hand-writing the same order-sensitive orchestration every other such app was also writing.
500
-
501
- ```ts
502
- // Nothing here imports the ad provider.
503
- const info = await ads.consent.requestInfoUpdate();
504
-
505
- if (info.state === 'required') {
506
- await ads.consent.showFormIfRequired(); // the provider draws the form
507
- }
508
-
509
- // The outcome feeds the privacy gate by itself. No wiring, and no second
510
- // native initialization.
511
- await ads.load('level_break');
512
- ```
513
-
514
- ### Normalized consent states
515
-
516
- | State | Meaning | `canRequestAds` | Your next step |
517
- |---|---|---|---|
518
- | `undetermined` | Not asked yet. Initial value, and the value after a reset. | `false` | `requestInfoUpdate()` |
519
- | `not_required` | The provider says this region does not require consent. | `true` | Nothing — ads proceed |
520
- | `required` | Required and not yet obtained. | `false` | `showFormIfRequired()` |
521
- | `obtained` | Obtained. | `true` | Nothing — ads proceed |
522
- | `error` | The flow failed. | **always `false`** | Optionally retry the flow |
523
-
524
- Three rules the package will not bend:
525
-
526
- - **A failure is never consent.** A provider error, a network failure, a cancellation, and a form dismissed without an answer all produce `error`, and `error` never carries permission. The failure this prevents is invisible in production: a request that should not have happened looks exactly like one that should.
527
- - **The package does not decide which regions require consent.** `not_required` is the provider's answer forwarded, not this package's judgement.
528
- - **A consent failure never crashes your app or blocks startup.** It is a normalized error you can act on or ignore.
529
-
530
- The package renders **no consent UI**. The form is the provider's. Timing, surrounding copy, and where you offer a user the chance to change their mind stay yours — as does deciding whether to run the flow at all. A user declining is a normal path, not an error.
531
-
532
- ### Letting users change their choice
533
-
534
- **New in `0.4.0`.** GDPR requires that withdrawing consent be as easy as giving it. The provider models that as a separate capability — a *privacy options* form, and a status saying whether you must offer it — and the package now exposes both. Before `0.4.0` the only route was importing the provider, or calling `consent.reset()` and re-showing the form: a testing-only call that wipes the stored decision while the form is open.
535
-
536
- You need two things from the package: **whether to show the entry**, and **a call that shows the form**.
537
-
538
- ```ts
539
- // After requestInfoUpdate(), read the provider's verdict.
540
- const { privacyOptionsRequirement } = ads.consent.getState();
541
- const showEntry = privacyOptionsRequirement === 'required';
542
-
543
- // In your Settings screen, behind your own row, label and copy:
544
- const result = await ads.consent.showPrivacyOptionsForm();
545
- ```
546
-
547
- `privacyOptionsRequirement` lives on the consent snapshot, so `consent.subscribe` already tells you when it changes.
548
-
549
- | `privacyOptionsRequirement` | Meaning | Your entry |
550
- |---|---|---|
551
- | `required` | The provider says this user must be able to change their choice. | Show it |
552
- | `not_required` | The provider says no entry is needed here. | Hide it |
553
- | `unknown` | No successful `requestInfoUpdate()` yet, or after `reset()`, after a failed update, or on iOS. | Hide it, but do not treat it as `not_required` |
554
-
555
- `showPrivacyOptionsForm()` never throws. It returns:
556
-
557
- | Result | What happened | Presented? |
558
- |---|---|---|
559
- | `{ status: 'shown', consent }` | The form opened and closed. `consent` is the state it left behind, already applied to the gate. A user who backed out unchanged lands here too, with the state they had. | Yes |
560
- | `{ status: 'not_required' }` | The requirement is not `required`. | No |
561
- | `{ status: 'info_update_required' }` | The requirement is `unknown`; call `requestInfoUpdate()` first. | No |
562
- | `{ status: 'busy' }` | A consent form is already on screen. | No |
563
- | `{ status: 'failed', error }` | The form could not open, or failed while open. `error.code` is `consent_flow_failed`. | Maybe |
564
- | `{ status: 'unavailable', reason }` | iOS (`unsupported_platform`), or after `dispose()` (`provider_missing`). | No |
565
-
566
- **What the outcome does to the gate.** You wire nothing; it feeds the privacy gate exactly as `showFormIfRequired` does.
567
-
568
- - **Consent withdrawn** (`canRequestAds` false): the status moves from `ready` to `blocked`, every in-flight load settles with `{ status: 'blocked', reason: 'consent_flow_required' }`, loaded-but-unshown ads are released, pending retries are cancelled, and a mounted `AdsBanner` leaves the tree. No new request is made.
569
- - **Consent granted again**: requests resume and the status returns to `ready` **without a second native initialization** — one per instance, however many times the user changes their mind. That holds if your app re-runs `initialize()` on a consent change, too.
570
- - **An ad already on screen is not force-closed.** The provider offers no safe way to close one.
571
- - **Most-restrictive-wins is unchanged.** If your own gate is closed, a user re-granting consent in the form does not reopen it.
572
-
573
- Keep in mind that a user who rejects every purpose in the form usually still leaves `canRequestAds` **true**: in the provider's terms, `obtained` means an answer was recorded, and the ads SDK applies that answer — limited or non-personalized ads — to later requests by itself. So the common outcome of the form is "the gate stays open, and later ads honour the new choice". The withdrawal path above is what happens when the provider does report `canRequestAds` false.
574
-
575
- **A failure is still never consent.** If the form fails, the package asks the provider what it still holds — the form never completed, so the stored decision has not changed — and reports that. If the provider cannot answer either, the state becomes `error` and the gate closes. Neither path grants anything.
576
-
577
- **What stays yours:** where the entry lives, its label, its copy, and when to offer it — for example, hiding it from a user who bought ad removal. The package never shows the form on its own.
578
-
579
- ### Package flow or your own gate?
580
-
581
- | | Package flow | Your own gate |
582
- |---|---|---|
583
- | You call | `ads.consent.*` | `initialize({ canRequestAds })` |
584
- | Form drawn by | the provider | you, or a third-party CMP |
585
- | Good when | you use the provider's consent messages | you already run a consent platform |
586
-
587
- You can do both. They combine as **most-restrictive-wins**: requesting is permitted only when both permit it, and either alone can block. So if you obtained consent but independently blocked ads for a reason of your own, your block survives — the package is forbidden to know your reason and therefore must never overrule it. The reported reason names which input is blocking.
588
-
589
- ### Testing the flow from outside the region
590
-
591
- ```ts
592
- createAds({
593
- environment: 'development',
594
- consentTesting: { debugGeography: 'eea', resetOnStart: true },
595
- // ...
596
- });
597
- ```
598
-
599
- **Both tools are stripped in `production`.** Not checked at each call site — removed at configuration validation, so no code path can forget. The fact that they were ignored is observable, not merely documented: a `config_warning` (`consent_tool_ignored_in_production`) naming each one, and `getIgnoredConsentTools()` on the instance.
600
-
601
- ### What gets stored on the device
602
-
603
- The consent platform persists the user's decision on the device under IAB TCF keys. This package neither reads, logs, nor includes it in any event — but **your Google Play Data safety declaration depends on it**, so review that before shipping the consent flow.
604
-
605
- ---
606
-
607
- ## App open
608
-
609
- A fourth format, declared in the catalog exactly like the other three and using the same placement states, events, and error codes.
610
-
611
- ```ts
612
- { key: 'cold_start', format: 'app_open', androidAdUnitId: 'ca-app-pub-.../...' }
613
- ```
614
-
615
- ```ts
616
- await ads.load('cold_start');
617
- const result = await ads.show('cold_start'); // you decide when
618
- ```
619
-
620
- **The package never shows one on its own.** As with interstitial and rewarded, every show is an explicit call you make.
621
-
622
- It shares the **same single full-screen lock** as interstitial and rewarded, so two full-screen ads are never open at once, and a refused request is never queued.
623
-
624
- ### Ad-originated return
625
-
626
- This is the reason app-open belongs in the package rather than in your app.
627
-
628
- When a user dismisses a full-screen ad — or comes back from a browser an ad opened — your app sees an ordinary return to the foreground and has no way to tell it apart from any other. Showing an app-open there means an ad immediately after an ad. The package can tell, because it holds the lock and sees the clicks, so it refuses:
629
-
630
- ```ts
631
- const result = await ads.show('cold_start');
632
- if (result.status === 'ad_originated_return') {
633
- // Distinct from `busy` and `not_ready` on purpose: neither of those would
634
- // have told you what actually happened, and both suggest a wrong remedy.
635
- }
636
- ```
637
-
638
- **Three boundaries, which are limits rather than defects:**
639
-
640
- 1. It cannot survive process death. If the OS kills your app while an ad is on screen, the return is a cold start and nothing links it to the ad.
641
- 2. It depends on `setAppActive` being reported faithfully — an obligation `0.1.0` already imposes for staleness re-evaluation, so not a new one. `AdsProvider` does it for you unless you pass `trackAppState={false}`.
642
- 3. A browser opened by an ad without a preceding click event is undetectable, because the click is the only signal the provider gives.
643
-
644
- ### Validity window
645
-
646
- App-open expires after **30 minutes** by default, configurable from 1 minute to 1 hour via `appOpenValidityMs` — shorter than the hour the other full-screen formats get.
647
-
648
- Worth being explicit: Google expires app-open ads after **four hours**, which is *longer* than this package's general window. The shorter window is a product decision, not a provider limit. An app-open ad shows at the most prominent moment your app has, and a creative cached for an hour is stale by then; thirty minutes still spares a returning user the spinner of a cold load.
649
-
650
- ### Store policy
651
-
652
- An app-open ad **must not block the user from entering your app, and must be dismissible.** The package enforces neither — presentation belongs to the provider — and a violation is the publishing app's responsibility. App-open is also the most visible placement a store reviewer will encounter, so reflect it in your review notes.
653
-
654
- ---
655
-
656
- ## Why an ad did not show
657
-
658
- Three independent gates can be closed. When more than one is, the reported reason follows this precedence, **most immovable first**:
659
-
660
- | Priority | Gate | Reasons |
661
- |---|---|---|
662
- | 1 | Platform availability | `unsupported_platform`, `provider_missing` |
663
- | 2 | Enablement | `disabled`, `undecided` |
664
- | 3 | Consent | `consent_required`, `consent_undetermined`, `consent_flow_required`, `consent_flow_error` |
665
-
666
- Full table:
667
-
668
- | Availability | Enablement | Consent | Reported |
669
- |---|---|---|---|
670
- | ok | on | open | *(allowed)* |
671
- | ok | on | your gate closed | `consent_required` / `consent_undetermined` |
672
- | ok | on | flow not permitted | `consent_flow_required` |
673
- | ok | on | flow errored | `consent_flow_error` |
674
- | ok | `disabled` | any | `disabled` |
675
- | ok | `undecided` | any | `undecided` |
676
- | unavailable | any | any | `unsupported_platform` |
677
- | provider missing | any | any | `provider_missing` |
678
-
679
- Exactly one reason is reported — the most useful one for diagnosis, not the first one checked. An unsupported platform makes the other two unfixable, so telling you "consent required" there would send you off to build a flow that will never help.
680
-
681
- **A fourth reason, outside the gates: `throttled`.** The gates above all mean "you may not ask". Throttling means "you may, but not yet" — the placement asked too recently (see [Request throttling](#request-throttling)). It is checked after the gates, because a closed gate is the more useful thing to report, and it carries `retryAfterMs` where a gate carries a reason.
682
-
683
- | Symptom | Likely cause |
684
- |---|---|
685
- | Placement sits at `throttled` after a navigation | A banner inside a remounting subtree, hitting the 60s floor |
686
- | Placement sits at `undecided` forever | Enablement never resolved from `'undecided'` |
687
- | Placement sits at `idle`, banner never appears | `AdsBanner` gated on its own state, or a zero-width container |
688
- | `load` returns `blocked` | Consent gate closed |
689
- | `initialize` returns `blocked` · `consent_flow_required` | The consent flow has not permitted requests yet; the package initializes by itself once it does |
690
- | Status went `ready` → `blocked` after the privacy options form | The user withdrew consent; re-granting resumes with no second initialization |
691
- | Status went `ready` → `blocked` after a later `requestInfoUpdate()` | The re-check failed (`consent_flow_error`); a successful re-check resumes |
692
-
693
- **Changed in `0.2.0`:** on an unavailable platform with ads also disabled, the reported reason is now `unsupported_platform` where `0.1.0` reported `disabled`. The old outcome depended on which condition arrived last. No request counts change.
694
-
695
- ---
696
-
697
- ## Testing
698
-
699
- The whole suite runs with **no device, no network, and no ad inventory**.
700
-
701
- ```ts
702
- import { createFakeAdsEngine } from '@chipmobilesdk/rn-ads/testing';
703
-
704
- const engine = createFakeAdsEngine();
705
- const ads = createAds(config, { engine, isDevBuild: true });
706
-
707
- await ads.initialize({ canRequestAds: true });
708
- await ads.load('level_break');
709
-
710
- expect(engine.adUnitIds()).toEqual([/* only official test units */]);
711
- ```
712
-
713
- The fake records every call, lets you resolve or reject each load and show, and lets you emit provider callbacks in **any order** — which is how the reward-ordering and late-callback cases are tested.
714
-
715
- ```
716
- engine.calls every call the package made, in order
717
- engine.adUnitIds() what actually reached the provider
718
- engine.liveHandles() non-empty after dispose means a leak
719
- engine.emit(id, ev) you control ordering
720
- engine.listenerCount() must reach zero after cleanup
721
- ```
722
-
723
- Consent is scripted the same way. The privacy options form can be held open, so `busy`, a consent update, or `dispose()` can happen while it is on screen:
724
-
725
- ```ts
726
- const engine = createFakeAdsEngine({
727
- consentInfo: { status: 'obtained', canRequestAds: true, privacyOptions: 'required' },
728
- });
729
-
730
- engine.setPrivacyOptionsFormResult({ status: 'required', canRequestAds: false }); // withdraws
731
- const held = engine.holdPrivacyOptionsForm(); // next form stays open
732
- held.close({ status: 'obtained', canRequestAds: true }); // ...until you settle it
733
- held.fail(); // or fail it
734
- engine.failPrivacyOptionsForm(); // every later form rejects
735
- engine.failConsentInfoRead(); // the re-read after a failure rejects too
736
- engine.consentCalls // includes 'showPrivacyOptionsForm'
737
- ```
738
-
739
- On device, verify with Google's official test ads only. `npm run android`, then exercise each `Ads · …` screen in the demo app.
740
-
741
- ---
742
-
743
- ## Release checklists
744
-
745
- ### Before you ship
746
-
747
- - [ ] `npm run validate:ads` is green (lint, test, typecheck, pack dry-run)
748
- - [ ] `npm run android` and `npm run build:apk` both succeed
749
- - [ ] Merged manifest inspected; permissions match the table above and nothing else is new
750
- - [ ] Zero production ad requests observed from a `development` build — captured from logcat as release evidence
751
- - [ ] `environment: 'production'` is set only in release builds
752
- - [ ] No production ad unit, credential, or secret in this package
753
-
754
- ### AdMob Console (cannot be done in code)
755
-
756
- - [ ] Android app registered under the correct package / application ID
757
- - [ ] A production ad unit created per placement, in the matching format
758
- - [ ] Account status and payment details verified
759
- - [ ] `app-ads.txt` published if you use it
760
- - [ ] Consent/privacy messaging configured if you use it
761
- - [ ] Content rating (`maxAdContentRating`) reviewed — ad content affects your app's age rating
762
- - [ ] Policy, traffic, and serving restrictions checked
763
- - [ ] **No self-clicking, ever**
764
-
765
- ### Google Play Console
766
-
767
- - [ ] App declared as containing ads
768
- - [ ] **Data safety** updated: the Google Mobile Ads SDK collects the Advertising ID, coarse device and app information, and ad interaction signals on your behalf — this package collects and stores nothing of its own
769
- - [ ] Privacy policy covers advertising data
770
- - [ ] Target audience and content completed
771
- - [ ] Content rating questionnaire reflects ad content
772
- - [ ] Families requirements met if applicable — see below
773
- - [ ] Advertising ID declaration matches your target SDK and actual behavior
774
- - [ ] Regional consent requirements satisfied
775
-
776
- > **Children and families.** Ads plus advertising identifiers in an app that may reach children is a high-risk combination. This package supports `childDirected` and `underAgeOfConsent` as enforced pre-initialization inputs and documents the `AD_ID` removal path — but **it does not and cannot determine a user's age.** That determination, and the policy built on it, are yours.
777
-
778
- ---
779
-
780
- ## Known limitations
781
-
782
- - **iOS is not implemented.** Importing and calling the package on iOS is safe and returns `unavailable` / `unsupported_platform`.
783
- - **Native and rewarded-interstitial ads are deferred.** Each has its own lifecycle, view construction, or presentation policy. They are additive later — a new `AdFormat` member, a factory branch in the engine adapter, and their own official test units. (App-open shipped in `0.2.0`.)
784
- - **Ad-originated return detection cannot survive process death**, and cannot see a browser an ad opened without a preceding click event. See [App open](#app-open).
785
- - **Mediation across multiple ad networks is out of scope.**
786
- - **Full-screen presentation, focus handling, and dismissal affordances are rendered by the provider** and outside this package's control. The package cannot make them accessible; it can only report what happened.
787
- - **`impression` for full-screen formats is derived**, not provider-reported. See the asymmetry note above.
788
- - **Bridge versions 16.1.0+ are unusable** under React Native 0.85. See [Compatibility](#compatibility).
789
-
790
- ## Extending to iOS
791
-
792
- Adding iOS contributes exactly three things and changes **nothing** you have already written:
793
-
794
- 1. **`PlacementDefinition.iosAdUnitId`** — the optional field already exists in v1's type, so existing Android-only catalogs stay valid.
795
- 2. **A second engine adapter** behind the existing `AdsEngine` port. The port names no platform and no provider; that is what makes this additive.
796
- 3. **iOS entries in the private test-ad-unit table.**
797
-
798
- Your `createAds`, `initialize`, `load`, `show`, `AdsBanner`, and every subscription keep their current signatures. The exported state, event, and error models do not change.
799
-
800
- Still to be done at that time, none of it touching the public contract: CocoaPods linking, the AdMob application ID in `Info.plist`, the SKAdNetwork identifiers Google requires, the privacy manifest, and App Store privacy labels.
801
-
802
- **Consent and App Tracking Transparency are two different things with a binding order.** ATT governs access to the device advertising identifier; the consent flow governs whether personalized ads may be requested at all. On iOS the ATT prompt must come first, and only then does the consent outcome mean what it means. The normalized `ConsentState` in `0.2.0` was shaped to express both mechanisms and their ordering **without changing shape** — an iOS adapter contributes a second `ConsentPort` implementation and nothing else. The tri-state enablement flag and the retry policy are entirely platform-neutral and carry no Android assumption.
803
-
804
- ## Versioning
805
-
806
- Semantic versioning. The event catalog, error codes, state names, and result unions are public API. New formats, new platforms, and new optional configuration arrive as additive minor releases; renaming or removing any of the above is a major.
807
-
808
- ### Migrating `0.3.x` to `0.4.0`
809
-
810
- **No existing exported union gained a member**, so no exhaustive `switch` in your code needs a new case. `0.3.0` call sites compile and behave as before, apart from the behaviour changes and fixes below.
811
-
812
- What was added, all of it opt-in:
813
-
814
- | Addition | Kind |
815
- |---|---|
816
- | `ConsentApi.showPrivacyOptionsForm()` | new method |
817
- | `PrivacyOptionsFormResult` | new union: `shown`, `not_required`, `info_update_required`, `busy`, `failed`, `unavailable` |
818
- | `PrivacyOptionsRequirement` | new union: `required`, `not_required`, `unknown` |
819
- | `ConsentSnapshot.privacyOptionsRequirement` | new field |
820
- | `FakeConsentCall` (`./testing`) | gained `{ kind: 'showPrivacyOptionsForm' }` |
821
- | Fake engine (`./testing`) | `setPrivacyOptionsFormResult`, `failPrivacyOptionsForm`, `holdPrivacyOptionsForm`, `failConsentInfoRead`; `consentInfo` takes `privacyOptions` |
822
-
823
- Two places need a touch only if you have them:
824
-
825
- - A hand-written `ConsentSnapshot` object literal, typically a test mock, needs `privacyOptionsRequirement`.
826
- - A hand-written implementation of `ConsentApi` needs `showPrivacyOptionsForm`.
827
-
828
- No new event type was added. A consent change caused by the form arrives through `consent.subscribe`, as an `initialization` event when the status moves, and as `unavailable` for a load it cancels.
829
-
830
- **Three behaviour changes:**
831
-
832
- - **`initialize()` waits for the package's consent flow, once you use it.** After any `ads.consent.*` call, `initialize()` on a flow that does not permit requests (declined, not yet answered, or failed) returns `{ status: 'blocked', reason: 'consent_flow_required' | 'consent_flow_error' }` and initializes nothing natively. `0.3.0` returned `ready` and initialized the provider while `load` refused. The flow opening later initializes the provider itself, once. An app that runs update → form → `initialize()` gets `ready` exactly as before when the user consents; the difference is only for a user who did not. Apps that never call `ads.consent.*` are unaffected.
833
- - **A consent gate that closes while the instance is `ready` now moves it to `blocked`.** `0.3.0` kept `ready` while `load` already refused, so a mounted banner went on requesting. This happens on a withdrawal through the new form, and also when a later `requestInfoUpdate()` fails. In-flight loads now settle with the block reason, loaded-but-unshown ads are released, and an ad on screen is left alone.
834
- - **Reopening a gate on an instance that was already initialized no longer initializes the provider again.** That applies to the consent flow and to your own gate through `updatePrivacy`. `0.3.0` made a second native initialization on `ready → blocked → reopened`. One exception keeps the old path: if an init-time flag (`childDirected`, `underAgeOfConsent`, `maxAdContentRating`) changed in between, a real initialization still runs, because that is the only way the flag can apply.
835
-
836
- **One fix that needs no action:** a `load()` in flight when ads are turned off, turned `undecided`, or the instance is disposed now **settles** — with `disabled`, `undecided`, or `unavailable` / `provider_missing` respectively. `0.3.0` released the resources but never resolved the promise, so code awaiting it waited forever; and a handle the provider returned after the disable was adopted and went on loading. Now it is disposed.
837
-
838
- ### Migrating `0.2.x` to `0.3.0`
839
-
840
- **One compile-time change.** If you have an exhaustive `switch` over any of these, add the new case or a `default`:
841
-
842
- | Union | New member |
843
- |---|---|
844
- | `PlacementState` | `'throttled'` |
845
- | `LoadResult` | `{ status: 'throttled', retryAfterMs }` |
846
- | `AdEventType` | `'request_throttled'` |
847
-
848
- **One behaviour change, and it is the point of the release.** Banner placements now have a 60s floor between requests ([Request throttling](#request-throttling)). A banner inside a list row, a tab, or a view the user swipes between will report `'throttled'` after a quick navigation where `0.2.x` silently issued another request — the silent request being the defect this fixes.
849
-
850
- Full-screen formats are unchanged: their default floor is `0`, so request counts for `interstitial`, `rewarded` and `app_open` are identical to `0.2.x` unless you configure a floor.
851
-
852
- **What has not changed:** no frequency cap was added, and none is planned. Pacing stays entirely yours.
853
-
854
- ### Migrating `0.1.0` to `0.2.0`
855
-
856
- **What you must do: one thing, and it is compile-time only.**
857
-
858
- If you have an exhaustive `switch` over any of these unions, add the new cases or a `default`:
859
-
860
- | Union | New members |
861
- |---|---|
862
- | `AdFormat` | `'app_open'` |
863
- | `InitStatus` | `'undecided'` |
864
- | `PlacementState` | `'undecided'` |
865
- | `PrivacyBlockReason` | `'consent_flow_required'`, `'consent_flow_error'` |
866
- | `AdsErrorCode` | `'undecided'`, `'ad_originated_return'`, `'consent_flow_required'`, `'consent_flow_failed'`, `'retry_out_of_bounds'` |
867
-
868
- There is no runtime equivalent. If you change nothing else you get identical behavior and an identical number of requests — proven by a test in this package, not merely promised.
869
-
870
- **What you may ignore, entirely:**
871
-
872
- - Consent — nothing starts unless you call `ads.consent.*`.
873
- - The `'undecided'` enablement state — `enabled: true` and `enabled: false` mean exactly what they meant.
874
- - App-open — a format you do not have to declare.
875
- - Retry — **off** unless you supply `retry`.
876
-
877
- **One behavior change requiring no action:** on an unavailable platform with ads also disabled, the reported reason is now `unsupported_platform` rather than `disabled`. The `0.1.0` outcome depended on which condition arrived last. Request counts are unchanged.
878
-
879
- **Worth adopting if it applies to you:** if you sell an ad-removal tier, `enabled: 'undecided'` and the typed `setEnabled` result together close a real defect — see [Turning ads off](#turning-ads-off--three-states).
1
+ # @chipmobilesdk/rn-ads
2
+
3
+ Reusable advertising capability for ChipMobileSdk-family React Native apps.
4
+
5
+ The package owns **capability**: a safe, consistent, provider-neutral way to request and present ads. Your app owns **policy**: where ads appear, when, how often, to whom, under what consent, and whether they appear at all. There is no placement rule, no frequency cap, and no screen name in this package.
6
+
7
+ **You never import the Google Mobile Ads SDK.** You name a placement key you chose, and the package does the rest.
8
+
9
+ ---
10
+
11
+ ## Scope
12
+
13
+ | | 0.4.0 |
14
+ |---|---|
15
+ | **Platform** | Android only. iOS returns `unavailable` / `unsupported_platform` and never breaks a build. |
16
+ | **Provider** | Google AdMob, via `react-native-google-mobile-ads` |
17
+ | **Formats** | Anchored adaptive banner, interstitial, rewarded, app-open |
18
+ | **Consent** | Optional orchestration of the provider's consent flow, including the form a user changes their answer through — you still import nothing of theirs |
19
+ | **Account safety** | Non-production builds cannot reach a production ad unit; each placement has a request floor — see [Request throttling](#request-throttling) |
20
+ | **Deferred** | Native, rewarded-interstitial, mediation, iOS — see [Known limitations](#known-limitations) |
21
+
22
+ ## Compatibility
23
+
24
+ | | Supported |
25
+ |---|---|
26
+ | React | `>=19` (verified against 19.2.3) |
27
+ | React Native | `>=0.85`, New Architecture (verified against 0.85.3) |
28
+ | Node | `>=22.11.0` |
29
+ | Android | `minSdk 24`, `compileSdk 36`, `targetSdk 36`, Kotlin 2.1.x |
30
+ | Provider bridge | `react-native-google-mobile-ads` **`>=16.0.3 <16.1.0`** |
31
+ | Google Mobile Ads Android SDK | 24.9.0 (transitive) |
32
+ | Google UMP (consent) | 4.0.0 (transitive) |
33
+
34
+ > **`0.2.0` required no bridge bump, and neither did `0.4.0`.** The pinned `16.0.3` already exposes everything the consent flow, the app-open format and the privacy options form need (`AdsConsent.showPrivacyOptionsForm()` and `privacyOptionsRequirementStatus` on the consent info) — verified against the installed type declarations and the bridge's Android module rather than assumed from documentation. The Kotlin constraint below is therefore untouched by both releases, and the merged manifest gains no permission.
35
+
36
+ > **Why the bridge range is closed at the top.** Bridge 16.1.0+ moves to Google Mobile Ads 25.x, which is compiled with Kotlin 2.3.0 metadata. React Native 0.85.3 pins the Kotlin **compiler** at 2.1.x, and the build fails with `Module was compiled with an incompatible version of Kotlin`. Raising the root `ext.kotlinVersion` does not fix it — that moves only `kotlin-stdlib`, not the compiler. An open caret would let a routine `npm install` walk straight back into a build failure, so the range refuses rather than drifts. Revisit when React Native ships a Kotlin 2.3 compiler.
37
+
38
+ ---
39
+
40
+ ## Install
41
+
42
+ ```sh
43
+ npm install @chipmobilesdk/rn-ads react-native-google-mobile-ads@16.0.3
44
+ ```
45
+
46
+ ### Required Android configuration
47
+
48
+ Add your AdMob **application ID** to the app's `app.json`. The bridge's Gradle script reads this key and merges it into `AndroidManifest.xml`; without it the native SDK crashes on startup.
49
+
50
+ ```jsonc
51
+ {
52
+ "react-native-google-mobile-ads": {
53
+ // Google's public test application ID. Replace with your own for release.
54
+ "android_app_id": "ca-app-pub-3940256099942544~3347511713"
55
+ }
56
+ }
57
+ ```
58
+
59
+ **The shared package carries no application ID and no ad unit ID.** Both are per-app configuration.
60
+
61
+ ### Permissions merged into your manifest
62
+
63
+ Contributed by the provider, not requested by this package:
64
+
65
+ Verified against a real merged manifest, not a list from documentation:
66
+
67
+ | Permission | Source |
68
+ |---|---|
69
+ | `android.permission.INTERNET` | bridge manifest |
70
+ | `android.permission.ACCESS_NETWORK_STATE` | bridge manifest |
71
+ | `android.permission.WAKE_LOCK` | bridge manifest |
72
+ | `com.google.android.gms.permission.AD_ID` | `play-services-ads-api` |
73
+ | `android.permission.ACCESS_ADSERVICES_AD_ID` | `play-services-ads-api` |
74
+ | `android.permission.ACCESS_ADSERVICES_ATTRIBUTION` | `play-services-ads-api` |
75
+ | `android.permission.ACCESS_ADSERVICES_TOPICS` | `play-services-ads-api` |
76
+
77
+ **Re-verified for `0.2.0`.** Adding the app-open format and activating the consent module contributed **no new permission** — both live in the already-pinned bridge, so the merged result is byte-identical to `0.1.0`'s.
78
+
79
+ The last three are **Android Privacy Sandbox** permissions covering the Privacy Sandbox ad ID, attribution reporting, and the Topics API. They matter for your Data safety declaration and they are easy to miss, because they appear only in the merged result — no library README lists them.
80
+
81
+ Verify the actual merged result before release — read the file, don't trust this table:
82
+
83
+ ```sh
84
+ cd android && ./gradlew :app:assembleDebug
85
+ # then inspect android/app/build/intermediates/merged_manifests/**/AndroidManifest.xml
86
+ ```
87
+
88
+ **To remove advertising identifier access** (required for child-directed apps under Play Families policy).
89
+
90
+ Removing `AD_ID` alone is **not enough** — verified by rebuild, the three Privacy Sandbox permissions survive it and must be removed explicitly:
91
+
92
+ ```xml
93
+ <uses-permission android:name="com.google.android.gms.permission.AD_ID" tools:node="remove" />
94
+ <uses-permission android:name="android.permission.ACCESS_ADSERVICES_AD_ID" tools:node="remove" />
95
+ <uses-permission android:name="android.permission.ACCESS_ADSERVICES_ATTRIBUTION" tools:node="remove" />
96
+ <uses-permission android:name="android.permission.ACCESS_ADSERVICES_TOPICS" tools:node="remove" />
97
+ ```
98
+
99
+ Your `<manifest>` tag needs `xmlns:tools="http://schemas.android.com/tools"`. Rebuild and re-inspect the merged manifest to confirm — do not assume.
100
+
101
+ ---
102
+
103
+ ## Configure
104
+
105
+ Placement keys are **yours**. Provider ad unit IDs appear here in configuration and nowhere else in your code.
106
+
107
+ ```ts
108
+ import { createAds } from '@chipmobilesdk/rn-ads';
109
+
110
+ const ads = createAds({
111
+ enabled: true, // your feature flag; false makes everything a safe no-op
112
+ environment: 'development', // 'development' | 'test' | 'production'
113
+ placements: [
114
+ { key: 'home_footer', format: 'banner', androidAdUnitId: 'ca-app-pub-…/…' },
115
+ { key: 'level_break', format: 'interstitial', androidAdUnitId: 'ca-app-pub-…/…' },
116
+ { key: 'completion_reward', format: 'rewarded', androidAdUnitId: 'ca-app-pub-…/…' },
117
+ ],
118
+ });
119
+ ```
120
+
121
+ `createAds` **throws** on a malformed catalog — duplicate key, missing ad unit, unknown format, out-of-band timeout — naming the offending placement. Every other failure in the package is a typed result, never a throw.
122
+
123
+ Reading configuration requires no dotenv library, no remote-config service, and no feature-flag system. Supply the values however you like.
124
+
125
+ ### How production traffic is made impossible
126
+
127
+ In `development` and `test`, any ad unit that is not one of Google's official test units is **replaced** with one, and the substitution is reported — as a `config_warning` event naming every affected placement, and in observable state via `ads.getSubstitutedPlacements()`.
128
+
129
+ ```
130
+ environment: development
131
+ home_footer -> ca-app-pub-REAL…/1234567890
132
+
133
+ ⚠ config_warning: test_ad_unit_substituted
134
+ placementKeys: ['home_footer']
135
+ → request sent with ca-app-pub-3940256099942544/9214589741
136
+ → your app keeps working
137
+ ```
138
+
139
+ In `production`, nothing is substituted — your units are used exactly as configured.
140
+
141
+ The one case substitution cannot cover is a **debug build declaring `production`**: that would send real requests from a build that should never generate traffic. It fails closed with `production_in_dev_build`.
142
+
143
+ > Invalid traffic from non-production builds can get an AdMob account suspended. **Never click a production ad to verify anything.**
144
+
145
+ ---
146
+
147
+ ## Initialize — consent comes first
148
+
149
+ The package will not initialize anything request-capable until your privacy gate permits it. This is enforced, not documented: with the gate withheld, **zero** provider calls occur.
150
+
151
+ ```tsx
152
+ import { AdsProvider } from '@chipmobilesdk/rn-ads';
153
+
154
+ await ads.initialize({
155
+ canRequestAds: true, // your consent decision — the package renders no consent UI
156
+ consentUndetermined: false, // true means "not asked yet", distinct from "refused"
157
+ childDirected: false,
158
+ underAgeOfConsent: false,
159
+ maxAdContentRating: 'G',
160
+ personalizedAds: true,
161
+ });
162
+
163
+ <AdsProvider instance={ads}>{children}</AdsProvider>
164
+ ```
165
+
166
+ **Ordering matters.** `childDirected`, `underAgeOfConsent`, and `maxAdContentRating` only take effect if they reach the provider *before* it initializes. Change one afterwards and `updatePrivacy` returns `requires_reinitialization` rather than pretending it applied.
167
+
168
+ Resolving consent after being blocked is the normal path and creates **no** duplicate initialization:
169
+
170
+ ```ts
171
+ await ads.initialize({ canRequestAds: false }); // → { status: 'blocked', reason: 'consent_required' }
172
+ await ads.updatePrivacy({ canRequestAds: true }); // → { status: 'applied' }, one native init total
173
+ ```
174
+
175
+ If that initialization fails, `updatePrivacy` returns `{ status: 'failed', error }` — the same error `initialize()` would have given — so retry it the way you retry `initialize()`.
176
+
177
+ **If you use the package's consent flow, it is part of the gate too.** Once you have called `ads.consent.*`, `initialize()` waits for the flow to permit requests, exactly as `load` does:
178
+
179
+ ```ts
180
+ await ads.consent.requestInfoUpdate(); // the user declines, or the flow fails
181
+ await ads.initialize({ canRequestAds: true }); // → { status: 'blocked', reason: 'consent_flow_required' }
182
+ // no native initialization
183
+ // ...later the user consents, through either consent form:
184
+ // the package initializes the provider itself — once — and the status becomes `ready`.
185
+ ```
186
+
187
+ Before `0.4.0` this returned `ready` and initialized the provider even though `load` refused, which goes against the provider's own guidance not to initialize before consent permits requests. Calling `initialize()` *before* any `ads.consent.*` call still initializes on your own gate alone, as in `0.1.0`.
188
+
189
+ The package logs no personal data, no consent payload, and no advertising identifier.
190
+
191
+ ---
192
+
193
+ ## Banner
194
+
195
+ You place it. It sizes itself to your container and re-adapts on rotation.
196
+
197
+ ```tsx
198
+ import { AdsBanner, usePlacementState } from '@chipmobilesdk/rn-ads';
199
+
200
+ function Footer() {
201
+ const state = usePlacementState('home_footer');
202
+ const reserve = state === 'loading' || state === 'ready';
203
+
204
+ return reserve ? (
205
+ <View style={styles.slot}>
206
+ {state === 'loading' ? <YourThemedPlaceholder /> : null}
207
+ <AdsBanner placementKey="home_footer" />
208
+ </View>
209
+ ) : null; // your decision to reclaim the space
210
+ }
211
+ ```
212
+
213
+ The component **ships no placeholder** — that is yours, so it matches your theme. When ads are disabled or unavailable it renders `null`, not an empty view, so it leaves the accessibility tree entirely rather than taking screen-reader focus ahead of your content.
214
+
215
+ ## Interstitial and rewarded
216
+
217
+ Load ahead of time; show only when *you* decide.
218
+
219
+ ```ts
220
+ await ads.load('level_break');
221
+ const result = await ads.show('level_break');
222
+
223
+ switch (result.status) {
224
+ case 'shown': break;
225
+ case 'not_ready': break; // no UI opened, nothing crashed
226
+ case 'busy': break; // another full-screen ad is up — never queued
227
+ case 'consumed': break; // load a fresh one first
228
+ case 'app_inactive': break; // backgrounded
229
+ case 'blocked': break; // result.reason
230
+ case 'disabled': break;
231
+ case 'unavailable': break; // result.reason
232
+ case 'timeout': break;
233
+ case 'failed': break; // result.error
234
+ }
235
+ ```
236
+
237
+ Rewards are reported **only** when the provider confirms them:
238
+
239
+ ```ts
240
+ ads.subscribeEvents(event => {
241
+ if (event.type === 'reward_earned') {
242
+ grant(event.reward.type, event.reward.amount); // your grant, not the package's
243
+ }
244
+ if (event.type === 'dismissed' && event.rewardEarned === false) {
245
+ // closed early — no reward, and none inferred
246
+ }
247
+ });
248
+ ```
249
+
250
+ `reward_earned` and `dismissed` may arrive from the provider in either order; the outcome is identical, and a duplicate confirmation is collapsed into one event. **The package grants nothing** — validating and delivering the benefit is yours.
251
+
252
+ ---
253
+
254
+ ## State, events, and errors
255
+
256
+ **Initialization status**: `not_started` · `blocked` · `initializing` · `ready` · `disabled` · `unavailable` · `failed`
257
+
258
+ **Placement state**: `idle` · `loading` · `ready` · `showing` · `consumed` · `failed` · `unavailable`
259
+
260
+ **Events** carry the placement key, format, timestamp, and a `correlationId` tying one load/show cycle together — enough to reconstruct an ad's whole life from the stream alone. They carry no provider object, no advertising identifier, and no consent payload.
261
+
262
+ `initialization` · `load_started` · `loaded` · `load_failed` · `display_started` · `impression` · `click` · `display_failed` · `dismissed` · `reward_earned` · `disabled` · `unavailable` · `config_warning` · `internal_error`
263
+
264
+ > **One honest asymmetry.** `impression` carries `source: 'provider' | 'derived'`. Banners get a real provider impression callback. Full-screen formats have none in the provider's event enum, so theirs is derived from the ad opening. If you reconcile these events against AdMob revenue reporting, you need to know which is which.
265
+
266
+ **Ordering and exactly-once** are documented and tested. Within one `correlationId`, `loaded` / `display_started` / `impression` / `dismissed` / `reward_earned` occur at most once; `click` may repeat. After `dispose()`, zero events are emitted — a late provider callback is dropped.
267
+
268
+ **Errors** carry a stable `code`, a log-safe `message`, the `operation`, a `retryable` flag, and a redacted `cause`. Codes are public API; renaming one is a breaking change.
269
+
270
+ Retryable: `no_fill` `network_error` `load_timeout` `show_timeout` `ad_expired` `not_ready` `busy` `consumed` `app_inactive` `provider_error`
271
+ Not retryable: `invalid_config` `duplicate_placement_key` `missing_ad_unit_id` `format_mismatch` `placement_not_found` `production_in_dev_build` `timeout_out_of_bounds` `unsupported_platform` `provider_missing` `disabled` `consent_required` `consent_undetermined` `requires_reinitialization` `internal_error`
272
+
273
+ Attach any logger or analytics you already use. The package depends on none:
274
+
275
+ ```ts
276
+ const unsubscribe = ads.subscribeEvents(event => yourLogger.track(event.type, event));
277
+ ```
278
+
279
+ A listener that throws is caught and reported once; it cannot break the state machine or starve other listeners.
280
+
281
+ ### Timeouts and cleanup
282
+
283
+ | | Default | Configurable range |
284
+ |---|---|---|
285
+ | Load timeout | 30 000 ms | 5 000 – 120 000 |
286
+ | Show timeout | 10 000 ms | 5 000 – 30 000 |
287
+ | Loaded-ad validity | 3 600 000 ms | 60 000 – 3 600 000 |
288
+ | App-open validity | 1 800 000 ms | 60 000 – 3 600 000 |
289
+ | Undecided warning | 10 000 ms | 1 000 – 120 000 |
290
+
291
+ Out-of-band values are a configuration error, not silently clamped. A loaded full-screen ad older than its validity window stops reporting `ready` and requires a reload — so you never render a button for an ad that will fail on tap.
292
+
293
+ `dispose()` cancels every timer and subscription and releases every handle — including a consent flow in progress, a pending retry, and the undecided-state warning. It is idempotent, and so is every `Unsubscribe`.
294
+
295
+ ---
296
+
297
+ ## Retry
298
+
299
+ **Off by default.** Upgrading from `0.1.0` without configuring `retry` sends exactly the requests it always did.
300
+
301
+ ```ts
302
+ createAds({
303
+ // ...
304
+ retry: { maxRetries: 2, initialDelayMs: 2_000, maxDelayMs: 30_000 },
305
+ placements: [
306
+ { key: 'home_footer', format: 'banner', androidAdUnitId: '...' },
307
+ // Off for this placement only: a rewarded retry happens with a user
308
+ // waiting on the other side of it.
309
+ { key: 'bonus', format: 'rewarded', androidAdUnitId: '...', retry: false },
310
+ ],
311
+ });
312
+ ```
313
+
314
+ | | Default | Range |
315
+ |---|---|---|
316
+ | `maxRetries` (attempts after the first) | 2 | 1 – 5 |
317
+ | `initialDelayMs` | 2 000 | 500 – 30 000 |
318
+ | `maxDelayMs` | 30 000 | 1 000 – 60 000 |
319
+
320
+ Backoff is exponential with a fixed x2 multiplier and no jitter — a single device has no fleet to desynchronize, and determinism makes the attempt count exactly assertable.
321
+
322
+ **Only `retryable` errors are retried.** Configuration errors, consent errors, an unsupported platform, and a disabled or undecided instance never are: another attempt cannot change any of them, so retrying would spend a billable request on a certainty.
323
+
324
+ A per-placement `retry` **replaces** the instance-wide policy rather than merging into it, so the effective policy for a placement is readable from the configuration without simulating a merge. Both are held to the same bounds, so an override can narrow behavior but never exceed a ceiling. Out-of-range values throw at construction — you find out when you build the instance, not on the first failed load in production.
325
+
326
+ Disabling ads, moving to `undecided`, losing consent, or `dispose()` cancels **every** pending retry across every placement immediately.
327
+
328
+ ### Reconciling retries against a bill
329
+
330
+ Each attempt emits a `retry_scheduled` event carrying the **same `correlationId`** as the load cycle that triggered it:
331
+
332
+ ```ts
333
+ ads.subscribeEvents(event => {
334
+ if (event.type === 'retry_scheduled') {
335
+ analytics.track('ad_retry', {
336
+ placement: event.placementKey,
337
+ attempt: event.attempt,
338
+ delayMs: event.delayMs,
339
+ reason: event.error.code,
340
+ cycle: event.correlationId,
341
+ });
342
+ }
343
+ });
344
+ ```
345
+
346
+ Counting `load_started` plus `retry_scheduled` for a placement gives the number of requests that placement actually cost you. You keep the option of ignoring all of this and writing your own retry — enabling the package's is a choice, not a replacement.
347
+
348
+ ---
349
+
350
+ ## Request throttling
351
+
352
+ **New in 0.3.0.** Each placement has a minimum interval between provider *requests*.
353
+
354
+ ### The problem it solves
355
+
356
+ A banner mounted inside a view the user navigates between — a day in a calendar, a tab, a row that scrolls in and out of a list — is unmounted and remounted by React on every navigation. **Each remount is a billable request.** A user swiping quickly through ten days produces ten requests in a few seconds, and nothing about that is you doing something wrong.
357
+
358
+ Ad networks read that pattern as invalid traffic. The penalty is account-level and shipping a fix does not recover it, which is why this is enforced by the package rather than left as advice.
359
+
360
+ Full-screen formats do not have the problem: they are loaded by an explicit call, not mounted by a renderer. That asymmetry is why the defaults differ.
361
+
362
+ ### Defaults
363
+
364
+ | Format | Default floor | Why |
365
+ |---|---|---|
366
+ | `banner` | **60 000 ms** | AdMob's own documented floor for banner auto-refresh. Asking more often than that is, by the provider's own standard, more often than it considers reasonable. |
367
+ | `interstitial`, `rewarded`, `app_open` | **0** (off) | `load` → `show` → `load` is the healthy cycle. An app that legitimately sells three things in two minutes loads three times, and a default floor would break correct apps to solve a problem these formats do not have. |
368
+
369
+ Override per placement:
370
+
371
+ ```ts
372
+ placements: [
373
+ {
374
+ key: 'timeline_row',
375
+ format: 'banner',
376
+ androidAdUnitId: 'ca-app-pub-…/…',
377
+ minRequestIntervalMs: 30_000, // lower the floor knowingly
378
+ },
379
+ {
380
+ key: 'after_save',
381
+ format: 'interstitial',
382
+ androidAdUnitId: 'ca-app-pub-…/…',
383
+ minRequestIntervalMs: 20_000, // add a floor where there is none by default
384
+ },
385
+ ]
386
+ ```
387
+
388
+ Bounds are `0`–`600 000` ms, and an out-of-range value throws `AdsConfigError` at `createAds` — naming the placement — rather than refusing every request later in production.
389
+
390
+ ### What a refusal looks like
391
+
392
+ ```ts
393
+ const result = await ads.load('after_save');
394
+ if (result.status === 'throttled') {
395
+ // Nothing was requested. Nothing is wrong.
396
+ console.log(`asked too recently; retry in ${result.retryAfterMs}ms`);
397
+ }
398
+ ```
399
+
400
+ - The placement reports `'throttled'` — **not** `'failed'`, and **not** a no-fill. No-fill means the network had nothing to give; this means we did not ask. An app that conflates them will misdiagnose the first time a placement goes quiet.
401
+ - A `request_throttled` event carries `retryAfterMs` and the `minRequestIntervalMs` that refused it, so the cause is readable from your logs alone.
402
+ - No `load_started` is emitted. A refused request is not a cycle, and the event stream must not claim a billable request that never happened.
403
+
404
+ ### Two exemptions, both deliberate
405
+
406
+ **Rotation is not charged.** A width re-adaptation inside one mount — the device rotated, the container resized — is the same banner the placement already legitimately requested. Refusing it would leave a rotated screen with no banner for up to a minute, which this was never meant to cost. The slot is charged once per *mount*, not per request.
407
+
408
+ **A concurrent duplicate load is not charged.** Two callers arriving for one cycle already join the in-flight request; charging the second would turn an existing guarantee into a refusal.
409
+
410
+ ### The better fix is architectural
411
+
412
+ > Keep the banner **outside** the subtree that remounts and the throttle never fires. A banner pinned above or below a swipeable area stays mounted across navigation, keeps its ad, and costs one request.
413
+ >
414
+ > This throttle is a safety net for when that is not possible — a banner genuinely inside a list, for instance. It is not a licence to ignore the placement problem, and reaching for `minRequestIntervalMs: 0` to silence a `throttled` state puts your ad account back where it was.
415
+
416
+ ### This is not a frequency cap
417
+
418
+ It limits how often a placement may **ask** for an ad. It says nothing about how often you may **show** one, and the package deliberately ships no pacing mechanism at all.
419
+
420
+ Pacing is a product decision with no correct default: one app counts items created, another counts screens opened, another counts minutes elapsed. Shipping one here would make the package own policy that belongs to you. If you want "an interstitial every third save", count the saves in your app and call `show` when your rule says so.
421
+
422
+ ---
423
+
424
+ ## Turning ads off — three states
425
+
426
+ `enabled` accepts `true`, `false`, or `'undecided'`.
427
+
428
+ | State | Requests sent | Init status | Banner reports | When to use it |
429
+ |---|---|---|---|---|
430
+ | `true` | yes | normal | normal | Ads are on |
431
+ | `false` | **zero** | `disabled` | `unavailable` | A settled no |
432
+ | `'undecided'` | **zero** | `undecided` | `undecided` | You do not know yet |
433
+
434
+ ### Why the third state exists
435
+
436
+ If you sell an ad-removal tier, at cold start you do not yet know whether this user has paid — that answer arrives after a store sync. Both of the other options are wrong: `true` flashes an ad at someone who paid not to see one, and `false` delays ads for every free user and drops you into the re-enable path below.
437
+
438
+ `undecided` sends nothing, exactly like `disabled`, but reports itself **distinctly** so your banner host can hold the space rather than reclaim it:
439
+
440
+ ```tsx
441
+ <AdsBanner placementKey="home_footer" onStateChange={setState} />
442
+ {state === 'undecided' ? <YourThemedPlaceholder /> : null}
443
+ ```
444
+
445
+ Resolve it exactly once when you know:
446
+
447
+ ```ts
448
+ const result = ads.setEnablement(userOwnsAdRemoval ? 'disabled' : 'enabled');
449
+ ```
450
+
451
+ The package **never resolves it for you** — that would mean guessing at a business decision it is forbidden to know about. If you leave it unresolved past `undecidedWarningMs` you get one `config_warning` (`enablement_undecided_timeout`) and nothing else. One, not a repeating stream: a warning that fires every ten seconds becomes noise that gets filtered.
452
+
453
+ ### Re-enabling
454
+
455
+ **This changed in `0.2.0`, and the old behavior was a defect.** In `0.1.0`, `setEnabled(true)` after a disable dropped the init status to `not_started`, so every later `load` and `show` was refused — and `setEnabled` returned `void`, so nothing told you. An app whose user's subscription lapsed would turn ads back on and silently show none.
456
+
457
+ Now the result is typed and always says what happened:
458
+
459
+ ```ts
460
+ const result = ads.setEnabled(true);
461
+
462
+ switch (result.status) {
463
+ case 'resumed':
464
+ // The provider was never un-initialized. Nothing to do — load away.
465
+ break;
466
+ case 'requires_initialization':
467
+ // Never reached ready, privacy changed, or the platform went away.
468
+ await ads.initialize(privacy);
469
+ break;
470
+ default:
471
+ break;
472
+ }
473
+ ```
474
+
475
+ The package does **not** initialize implicitly on your behalf: that would hide an asynchronous, billable operation behind a setter you may not be awaiting, and a failure would surface with no call site to attribute it to.
476
+
477
+ If your privacy options have not changed you do not supply them again. They live in memory for the lifetime of the instance and are never persisted.
478
+
479
+ In every off state — `false` or `'undecided'` — in-flight loads are released deterministically, and an ad **already on screen is not force-closed**: the provider offers no safe way to do so, and yanking a full-screen ad out from under the user would be worse than letting it finish.
480
+
481
+ ### Wiring it to an external decision
482
+
483
+ The package has no idea what an entitlement is. That is deliberate — it receives a decision, never a reason:
484
+
485
+ ```ts
486
+ const ads = createAds({ enabled: 'undecided', /* ... */ });
487
+
488
+ // Whatever answers this for you: a billing library, your own backend, a cached
489
+ // receipt. The package neither knows nor stores which.
490
+ entitlements.subscribe(owned => {
491
+ ads.setEnablement(owned ? 'disabled' : 'enabled');
492
+ });
493
+ ```
494
+
495
+ ---
496
+
497
+ ## Consent
498
+
499
+ **Optional.** If you already supply your own gate through `PrivacyOptions.canRequestAds`, nothing here starts on your behalf and your `0.1.0` integration is untouched.
500
+
501
+ Use it when you ship to a region that requires consent. Before `0.2.0` that meant importing the provider's consent SDK directly — which broke the one promise this package exists to keep — and hand-writing the same order-sensitive orchestration every other such app was also writing.
502
+
503
+ ```ts
504
+ // Nothing here imports the ad provider.
505
+ const info = await ads.consent.requestInfoUpdate();
506
+
507
+ if (info.state === 'required') {
508
+ await ads.consent.showFormIfRequired(); // the provider draws the form
509
+ }
510
+
511
+ // The outcome feeds the privacy gate by itself. No wiring, and no second
512
+ // native initialization.
513
+ await ads.load('level_break');
514
+ ```
515
+
516
+ ### Normalized consent states
517
+
518
+ | State | Meaning | `canRequestAds` | Your next step |
519
+ |---|---|---|---|
520
+ | `undetermined` | Not asked yet. Initial value, and the value after a reset. | `false` | `requestInfoUpdate()` |
521
+ | `not_required` | The provider says this region does not require consent. | `true` | Nothing — ads proceed |
522
+ | `required` | Required and not yet obtained. | `false` | `showFormIfRequired()` |
523
+ | `obtained` | Obtained. | `true` | Nothing — ads proceed |
524
+ | `error` | The flow failed. | **always `false`** | Optionally retry the flow |
525
+
526
+ Three rules the package will not bend:
527
+
528
+ - **A failure is never consent.** A provider error, a network failure, a cancellation, and a form dismissed without an answer all produce `error`, and `error` never carries permission. The failure this prevents is invisible in production: a request that should not have happened looks exactly like one that should.
529
+ - **The package does not decide which regions require consent.** `not_required` is the provider's answer forwarded, not this package's judgement.
530
+ - **A consent failure never crashes your app or blocks startup.** It is a normalized error you can act on or ignore.
531
+
532
+ The package renders **no consent UI**. The form is the provider's. Timing, surrounding copy, and where you offer a user the chance to change their mind stay yours — as does deciding whether to run the flow at all. A user declining is a normal path, not an error.
533
+
534
+ ### Letting users change their choice
535
+
536
+ **New in `0.4.0`.** GDPR requires that withdrawing consent be as easy as giving it. The provider models that as a separate capability — a *privacy options* form, and a status saying whether you must offer it — and the package now exposes both. Before `0.4.0` the only route was importing the provider, or calling `consent.reset()` and re-showing the form: a testing-only call that wipes the stored decision while the form is open.
537
+
538
+ You need two things from the package: **whether to show the entry**, and **a call that shows the form**.
539
+
540
+ ```ts
541
+ // After requestInfoUpdate(), read the provider's verdict.
542
+ const { privacyOptionsRequirement } = ads.consent.getState();
543
+ const showEntry = privacyOptionsRequirement === 'required';
544
+
545
+ // In your Settings screen, behind your own row, label and copy:
546
+ const result = await ads.consent.showPrivacyOptionsForm();
547
+ ```
548
+
549
+ `privacyOptionsRequirement` lives on the consent snapshot, so `consent.subscribe` already tells you when it changes.
550
+
551
+ | `privacyOptionsRequirement` | Meaning | Your entry |
552
+ |---|---|---|
553
+ | `required` | The provider says this user must be able to change their choice. | Show it |
554
+ | `not_required` | The provider says no entry is needed here. | Hide it |
555
+ | `unknown` | No successful `requestInfoUpdate()` yet, or after `reset()`, after a failed update, or on iOS. | Hide it, but do not treat it as `not_required` |
556
+
557
+ `showPrivacyOptionsForm()` never throws. It returns:
558
+
559
+ | Result | What happened | Presented? |
560
+ |---|---|---|
561
+ | `{ status: 'shown', consent }` | The form opened and closed. `consent` is the state it left behind, already applied to the gate. A user who backed out unchanged lands here too, with the state they had. | Yes |
562
+ | `{ status: 'not_required' }` | The requirement is not `required`. | No |
563
+ | `{ status: 'info_update_required' }` | The requirement is `unknown`; call `requestInfoUpdate()` first. | No |
564
+ | `{ status: 'busy' }` | A consent form is already on screen. | No |
565
+ | `{ status: 'failed', error }` | The form could not open, or failed while open. `error.code` is `consent_flow_failed`. | Maybe |
566
+ | `{ status: 'unavailable', reason }` | iOS (`unsupported_platform`), or after `dispose()` (`provider_missing`). | No |
567
+
568
+ **What the outcome does to the gate.** You wire nothing; it feeds the privacy gate exactly as `showFormIfRequired` does.
569
+
570
+ - **Consent withdrawn** (`canRequestAds` false): the status moves from `ready` to `blocked`, every in-flight load settles with `{ status: 'blocked', reason: 'consent_flow_required' }`, loaded-but-unshown ads are released, pending retries are cancelled, and a mounted `AdsBanner` leaves the tree. No new request is made.
571
+ - **Consent granted again**: requests resume and the status returns to `ready` **without a second native initialization** — one per instance, however many times the user changes their mind. That holds if your app re-runs `initialize()` on a consent change, too.
572
+ - **An ad already on screen is not force-closed.** The provider offers no safe way to close one.
573
+ - **Most-restrictive-wins is unchanged.** If your own gate is closed, a user re-granting consent in the form does not reopen it.
574
+
575
+ Keep in mind that a user who rejects every purpose in the form usually still leaves `canRequestAds` **true**: in the provider's terms, `obtained` means an answer was recorded, and the ads SDK applies that answer — limited or non-personalized ads — to later requests by itself. So the common outcome of the form is "the gate stays open, and later ads honour the new choice". The withdrawal path above is what happens when the provider does report `canRequestAds` false.
576
+
577
+ **A failure is still never consent.** If the form fails, the package asks the provider what it still holds — the form never completed, so the stored decision has not changed — and reports that. If the provider cannot answer either, the state becomes `error` and the gate closes. Neither path grants anything.
578
+
579
+ **What stays yours:** where the entry lives, its label, its copy, and when to offer it — for example, hiding it from a user who bought ad removal. The package never shows the form on its own.
580
+
581
+ ### Package flow or your own gate?
582
+
583
+ | | Package flow | Your own gate |
584
+ |---|---|---|
585
+ | You call | `ads.consent.*` | `initialize({ canRequestAds })` |
586
+ | Form drawn by | the provider | you, or a third-party CMP |
587
+ | Good when | you use the provider's consent messages | you already run a consent platform |
588
+
589
+ You can do both. They combine as **most-restrictive-wins**: requesting is permitted only when both permit it, and either alone can block. So if you obtained consent but independently blocked ads for a reason of your own, your block survives — the package is forbidden to know your reason and therefore must never overrule it. The reported reason names which input is blocking.
590
+
591
+ ### Testing the flow from outside the region
592
+
593
+ ```ts
594
+ createAds({
595
+ environment: 'development',
596
+ consentTesting: { debugGeography: 'eea', resetOnStart: true },
597
+ // ...
598
+ });
599
+ ```
600
+
601
+ **Both tools are stripped in `production`.** Not checked at each call site — removed at configuration validation, so no code path can forget. The fact that they were ignored is observable, not merely documented: a `config_warning` (`consent_tool_ignored_in_production`) naming each one, and `getIgnoredConsentTools()` on the instance.
602
+
603
+ ### What gets stored on the device
604
+
605
+ The consent platform persists the user's decision on the device under IAB TCF keys. This package neither reads, logs, nor includes it in any event — but **your Google Play Data safety declaration depends on it**, so review that before shipping the consent flow.
606
+
607
+ ---
608
+
609
+ ## App open
610
+
611
+ A fourth format, declared in the catalog exactly like the other three and using the same placement states, events, and error codes.
612
+
613
+ ```ts
614
+ { key: 'cold_start', format: 'app_open', androidAdUnitId: 'ca-app-pub-.../...' }
615
+ ```
616
+
617
+ ```ts
618
+ await ads.load('cold_start');
619
+ const result = await ads.show('cold_start'); // you decide when
620
+ ```
621
+
622
+ **The package never shows one on its own.** As with interstitial and rewarded, every show is an explicit call you make.
623
+
624
+ It shares the **same single full-screen lock** as interstitial and rewarded, so two full-screen ads are never open at once, and a refused request is never queued.
625
+
626
+ ### Ad-originated return
627
+
628
+ This is the reason app-open belongs in the package rather than in your app.
629
+
630
+ When a user dismisses a full-screen ad — or comes back from a browser an ad opened — your app sees an ordinary return to the foreground and has no way to tell it apart from any other. Showing an app-open there means an ad immediately after an ad. The package can tell, because it holds the lock and sees the clicks, so it refuses:
631
+
632
+ ```ts
633
+ const result = await ads.show('cold_start');
634
+ if (result.status === 'ad_originated_return') {
635
+ // Distinct from `busy` and `not_ready` on purpose: neither of those would
636
+ // have told you what actually happened, and both suggest a wrong remedy.
637
+ }
638
+ ```
639
+
640
+ **Three boundaries, which are limits rather than defects:**
641
+
642
+ 1. It cannot survive process death. If the OS kills your app while an ad is on screen, the return is a cold start and nothing links it to the ad.
643
+ 2. It depends on `setAppActive` being reported faithfully — an obligation `0.1.0` already imposes for staleness re-evaluation, so not a new one. `AdsProvider` does it for you unless you pass `trackAppState={false}`.
644
+ 3. A browser opened by an ad without a preceding click event is undetectable, because the click is the only signal the provider gives.
645
+
646
+ ### Validity window
647
+
648
+ App-open expires after **30 minutes** by default, configurable from 1 minute to 1 hour via `appOpenValidityMs` — shorter than the hour the other full-screen formats get.
649
+
650
+ Worth being explicit: Google expires app-open ads after **four hours**, which is *longer* than this package's general window. The shorter window is a product decision, not a provider limit. An app-open ad shows at the most prominent moment your app has, and a creative cached for an hour is stale by then; thirty minutes still spares a returning user the spinner of a cold load.
651
+
652
+ ### Store policy
653
+
654
+ An app-open ad **must not block the user from entering your app, and must be dismissible.** The package enforces neither — presentation belongs to the provider — and a violation is the publishing app's responsibility. App-open is also the most visible placement a store reviewer will encounter, so reflect it in your review notes.
655
+
656
+ ---
657
+
658
+ ## Why an ad did not show
659
+
660
+ Three independent gates can be closed. When more than one is, the reported reason follows this precedence, **most immovable first**:
661
+
662
+ | Priority | Gate | Reasons |
663
+ |---|---|---|
664
+ | 1 | Platform availability | `unsupported_platform`, `provider_missing` |
665
+ | 2 | Enablement | `disabled`, `undecided` |
666
+ | 3 | Consent | `consent_required`, `consent_undetermined`, `consent_flow_required`, `consent_flow_error` |
667
+
668
+ Full table:
669
+
670
+ | Availability | Enablement | Consent | Reported |
671
+ |---|---|---|---|
672
+ | ok | on | open | *(allowed)* |
673
+ | ok | on | your gate closed | `consent_required` / `consent_undetermined` |
674
+ | ok | on | flow not permitted | `consent_flow_required` |
675
+ | ok | on | flow errored | `consent_flow_error` |
676
+ | ok | `disabled` | any | `disabled` |
677
+ | ok | `undecided` | any | `undecided` |
678
+ | unavailable | any | any | `unsupported_platform` |
679
+ | provider missing | any | any | `provider_missing` |
680
+
681
+ Exactly one reason is reported — the most useful one for diagnosis, not the first one checked. An unsupported platform makes the other two unfixable, so telling you "consent required" there would send you off to build a flow that will never help.
682
+
683
+ **A fourth reason, outside the gates: `throttled`.** The gates above all mean "you may not ask". Throttling means "you may, but not yet" — the placement asked too recently (see [Request throttling](#request-throttling)). It is checked after the gates, because a closed gate is the more useful thing to report, and it carries `retryAfterMs` where a gate carries a reason.
684
+
685
+ | Symptom | Likely cause |
686
+ |---|---|
687
+ | Placement sits at `throttled` after a navigation | A banner inside a remounting subtree, hitting the 60s floor |
688
+ | Placement sits at `undecided` forever | Enablement never resolved from `'undecided'` |
689
+ | Placement sits at `idle`, banner never appears | `AdsBanner` gated on its own state, or a zero-width container |
690
+ | `load` returns `blocked` | Consent gate closed |
691
+ | `initialize` returns `blocked` · `consent_flow_required` | The consent flow has not permitted requests yet; the package initializes by itself once it does |
692
+ | Status went `ready` → `blocked` after the privacy options form | The user withdrew consent; re-granting resumes with no second initialization |
693
+ | Status went `ready` → `blocked` after a later `requestInfoUpdate()` | The re-check failed (`consent_flow_error`); a successful re-check resumes |
694
+
695
+ **Changed in `0.2.0`:** on an unavailable platform with ads also disabled, the reported reason is now `unsupported_platform` where `0.1.0` reported `disabled`. The old outcome depended on which condition arrived last. No request counts change.
696
+
697
+ ---
698
+
699
+ ## Testing
700
+
701
+ The whole suite runs with **no device, no network, and no ad inventory**.
702
+
703
+ ```ts
704
+ import { createFakeAdsEngine } from '@chipmobilesdk/rn-ads/testing';
705
+
706
+ const engine = createFakeAdsEngine();
707
+ const ads = createAds(config, { engine, isDevBuild: true });
708
+
709
+ await ads.initialize({ canRequestAds: true });
710
+ await ads.load('level_break');
711
+
712
+ expect(engine.adUnitIds()).toEqual([/* only official test units */]);
713
+ ```
714
+
715
+ The fake records every call, lets you resolve or reject each load and show, and lets you emit provider callbacks in **any order** — which is how the reward-ordering and late-callback cases are tested.
716
+
717
+ ```
718
+ engine.calls every call the package made, in order
719
+ engine.adUnitIds() what actually reached the provider
720
+ engine.liveHandles() non-empty after dispose means a leak
721
+ engine.emit(id, ev) you control ordering
722
+ engine.listenerCount() must reach zero after cleanup
723
+ ```
724
+
725
+ Consent is scripted the same way. The privacy options form can be held open, so `busy`, a consent update, or `dispose()` can happen while it is on screen:
726
+
727
+ ```ts
728
+ const engine = createFakeAdsEngine({
729
+ consentInfo: { status: 'obtained', canRequestAds: true, privacyOptions: 'required' },
730
+ });
731
+
732
+ engine.setPrivacyOptionsFormResult({ status: 'required', canRequestAds: false }); // withdraws
733
+ const held = engine.holdPrivacyOptionsForm(); // next form stays open
734
+ held.close({ status: 'obtained', canRequestAds: true }); // ...until you settle it
735
+ held.fail(); // or fail it
736
+ engine.failPrivacyOptionsForm(); // every later form rejects
737
+ engine.failConsentInfoRead(); // the re-read after a failure rejects too
738
+ engine.consentCalls // includes 'showPrivacyOptionsForm'
739
+ ```
740
+
741
+ On device, verify with Google's official test ads only. `npm run android`, then exercise each `Ads · …` screen in the demo app.
742
+
743
+ ---
744
+
745
+ ## Release checklists
746
+
747
+ ### Before you ship
748
+
749
+ - [ ] `npm run validate:ads` is green (lint, test, typecheck, pack dry-run)
750
+ - [ ] `npm run android` and `npm run build:apk` both succeed
751
+ - [ ] Merged manifest inspected; permissions match the table above and nothing else is new
752
+ - [ ] Zero production ad requests observed from a `development` build — captured from logcat as release evidence
753
+ - [ ] `environment: 'production'` is set only in release builds
754
+ - [ ] No production ad unit, credential, or secret in this package
755
+
756
+ ### AdMob Console (cannot be done in code)
757
+
758
+ - [ ] Android app registered under the correct package / application ID
759
+ - [ ] A production ad unit created per placement, in the matching format
760
+ - [ ] Account status and payment details verified
761
+ - [ ] `app-ads.txt` published if you use it
762
+ - [ ] Consent/privacy messaging configured if you use it
763
+ - [ ] Content rating (`maxAdContentRating`) reviewed — ad content affects your app's age rating
764
+ - [ ] Policy, traffic, and serving restrictions checked
765
+ - [ ] **No self-clicking, ever**
766
+
767
+ ### Google Play Console
768
+
769
+ - [ ] App declared as containing ads
770
+ - [ ] **Data safety** updated: the Google Mobile Ads SDK collects the Advertising ID, coarse device and app information, and ad interaction signals on your behalf — this package collects and stores nothing of its own
771
+ - [ ] Privacy policy covers advertising data
772
+ - [ ] Target audience and content completed
773
+ - [ ] Content rating questionnaire reflects ad content
774
+ - [ ] Families requirements met if applicable — see below
775
+ - [ ] Advertising ID declaration matches your target SDK and actual behavior
776
+ - [ ] Regional consent requirements satisfied
777
+
778
+ > **Children and families.** Ads plus advertising identifiers in an app that may reach children is a high-risk combination. This package supports `childDirected` and `underAgeOfConsent` as enforced pre-initialization inputs and documents the `AD_ID` removal path — but **it does not and cannot determine a user's age.** That determination, and the policy built on it, are yours.
779
+
780
+ ---
781
+
782
+ ## Known limitations
783
+
784
+ - **iOS is not implemented.** Importing and calling the package on iOS is safe and returns `unavailable` / `unsupported_platform`.
785
+ - **Native and rewarded-interstitial ads are deferred.** Each has its own lifecycle, view construction, or presentation policy. They are additive later — a new `AdFormat` member, a factory branch in the engine adapter, and their own official test units. (App-open shipped in `0.2.0`.)
786
+ - **Ad-originated return detection cannot survive process death**, and cannot see a browser an ad opened without a preceding click event. See [App open](#app-open).
787
+ - **Mediation across multiple ad networks is out of scope.**
788
+ - **Full-screen presentation, focus handling, and dismissal affordances are rendered by the provider** and outside this package's control. The package cannot make them accessible; it can only report what happened.
789
+ - **`impression` for full-screen formats is derived**, not provider-reported. See the asymmetry note above.
790
+ - **Bridge versions 16.1.0+ are unusable** under React Native 0.85. See [Compatibility](#compatibility).
791
+
792
+ ## Extending to iOS
793
+
794
+ Adding iOS contributes exactly three things and changes **nothing** you have already written:
795
+
796
+ 1. **`PlacementDefinition.iosAdUnitId`** — the optional field already exists in v1's type, so existing Android-only catalogs stay valid.
797
+ 2. **A second engine adapter** behind the existing `AdsEngine` port. The port names no platform and no provider; that is what makes this additive.
798
+ 3. **iOS entries in the private test-ad-unit table.**
799
+
800
+ Your `createAds`, `initialize`, `load`, `show`, `AdsBanner`, and every subscription keep their current signatures. The exported state, event, and error models do not change.
801
+
802
+ Still to be done at that time, none of it touching the public contract: CocoaPods linking, the AdMob application ID in `Info.plist`, the SKAdNetwork identifiers Google requires, the privacy manifest, and App Store privacy labels.
803
+
804
+ **Consent and App Tracking Transparency are two different things with a binding order.** ATT governs access to the device advertising identifier; the consent flow governs whether personalized ads may be requested at all. On iOS the ATT prompt must come first, and only then does the consent outcome mean what it means. The normalized `ConsentState` in `0.2.0` was shaped to express both mechanisms and their ordering **without changing shape** — an iOS adapter contributes a second `ConsentPort` implementation and nothing else. The tri-state enablement flag and the retry policy are entirely platform-neutral and carry no Android assumption.
805
+
806
+ ## Versioning
807
+
808
+ Semantic versioning. The event catalog, error codes, state names, and result unions are public API. New formats, new platforms, and new optional configuration arrive as additive minor releases; renaming or removing any of the above is a major.
809
+
810
+ ### Migrating `0.3.x` to `0.4.0`
811
+
812
+ **No existing exported union gained a member**, so no exhaustive `switch` in your code needs a new case. `0.3.0` call sites compile and behave as before, apart from the behaviour changes and fixes below.
813
+
814
+ What was added, all of it opt-in:
815
+
816
+ | Addition | Kind |
817
+ |---|---|
818
+ | `ConsentApi.showPrivacyOptionsForm()` | new method |
819
+ | `PrivacyOptionsFormResult` | new union: `shown`, `not_required`, `info_update_required`, `busy`, `failed`, `unavailable` |
820
+ | `PrivacyOptionsRequirement` | new union: `required`, `not_required`, `unknown` |
821
+ | `ConsentSnapshot.privacyOptionsRequirement` | new field |
822
+ | `FakeConsentCall` (`./testing`) | gained `{ kind: 'showPrivacyOptionsForm' }` |
823
+ | Fake engine (`./testing`) | `setPrivacyOptionsFormResult`, `failPrivacyOptionsForm`, `holdPrivacyOptionsForm`, `failConsentInfoRead`; `consentInfo` takes `privacyOptions` |
824
+
825
+ Two places need a touch only if you have them:
826
+
827
+ - A hand-written `ConsentSnapshot` object literal, typically a test mock, needs `privacyOptionsRequirement`.
828
+ - A hand-written implementation of `ConsentApi` needs `showPrivacyOptionsForm`.
829
+
830
+ No new event type was added. A consent change caused by the form arrives through `consent.subscribe`, as an `initialization` event when the status moves, and as `unavailable` for a load it cancels.
831
+
832
+ **Three behaviour changes:**
833
+
834
+ - **`initialize()` waits for the package's consent flow, once you use it.** After any `ads.consent.*` call, `initialize()` on a flow that does not permit requests (declined, not yet answered, or failed) returns `{ status: 'blocked', reason: 'consent_flow_required' | 'consent_flow_error' }` and initializes nothing natively. `0.3.0` returned `ready` and initialized the provider while `load` refused. The flow opening later initializes the provider itself, once. An app that runs update → form → `initialize()` gets `ready` exactly as before when the user consents; the difference is only for a user who did not. Apps that never call `ads.consent.*` are unaffected.
835
+ - **A consent gate that closes while the instance is `ready` now moves it to `blocked`.** `0.3.0` kept `ready` while `load` already refused, so a mounted banner went on requesting. This happens on a withdrawal through the new form, and also when a later `requestInfoUpdate()` fails. In-flight loads now settle with the block reason, loaded-but-unshown ads are released, and an ad on screen is left alone.
836
+ - **Reopening a gate on an instance that was already initialized no longer initializes the provider again.** That applies to the consent flow and to your own gate through `updatePrivacy`. `0.3.0` made a second native initialization on `ready → blocked → reopened`. One exception keeps the old path: if an init-time flag (`childDirected`, `underAgeOfConsent`, `maxAdContentRating`) changed in between, a real initialization still runs, because that is the only way the flag can apply.
837
+
838
+ **One fix that needs no action:** a `load()` in flight when ads are turned off, turned `undecided`, or the instance is disposed now **settles** — with `disabled`, `undecided`, or `unavailable` / `provider_missing` respectively. `0.3.0` released the resources but never resolved the promise, so code awaiting it waited forever; and a handle the provider returned after the disable was adopted and went on loading. Now it is disposed.
839
+
840
+ ### Migrating `0.2.x` to `0.3.0`
841
+
842
+ **One compile-time change.** If you have an exhaustive `switch` over any of these, add the new case or a `default`:
843
+
844
+ | Union | New member |
845
+ |---|---|
846
+ | `PlacementState` | `'throttled'` |
847
+ | `LoadResult` | `{ status: 'throttled', retryAfterMs }` |
848
+ | `AdEventType` | `'request_throttled'` |
849
+
850
+ **One behaviour change, and it is the point of the release.** Banner placements now have a 60s floor between requests ([Request throttling](#request-throttling)). A banner inside a list row, a tab, or a view the user swipes between will report `'throttled'` after a quick navigation where `0.2.x` silently issued another request — the silent request being the defect this fixes.
851
+
852
+ Full-screen formats are unchanged: their default floor is `0`, so request counts for `interstitial`, `rewarded` and `app_open` are identical to `0.2.x` unless you configure a floor.
853
+
854
+ **What has not changed:** no frequency cap was added, and none is planned. Pacing stays entirely yours.
855
+
856
+ ### Migrating `0.1.0` to `0.2.0`
857
+
858
+ **What you must do: one thing, and it is compile-time only.**
859
+
860
+ If you have an exhaustive `switch` over any of these unions, add the new cases or a `default`:
861
+
862
+ | Union | New members |
863
+ |---|---|
864
+ | `AdFormat` | `'app_open'` |
865
+ | `InitStatus` | `'undecided'` |
866
+ | `PlacementState` | `'undecided'` |
867
+ | `PrivacyBlockReason` | `'consent_flow_required'`, `'consent_flow_error'` |
868
+ | `AdsErrorCode` | `'undecided'`, `'ad_originated_return'`, `'consent_flow_required'`, `'consent_flow_failed'`, `'retry_out_of_bounds'` |
869
+
870
+ There is no runtime equivalent. If you change nothing else you get identical behavior and an identical number of requests — proven by a test in this package, not merely promised.
871
+
872
+ **What you may ignore, entirely:**
873
+
874
+ - Consent — nothing starts unless you call `ads.consent.*`.
875
+ - The `'undecided'` enablement state — `enabled: true` and `enabled: false` mean exactly what they meant.
876
+ - App-open — a format you do not have to declare.
877
+ - Retry — **off** unless you supply `retry`.
878
+
879
+ **One behavior change requiring no action:** on an unavailable platform with ads also disabled, the reported reason is now `unsupported_platform` rather than `disabled`. The `0.1.0` outcome depended on which condition arrived last. Request counts are unchanged.
880
+
881
+ **Worth adopting if it applies to you:** if you sell an ad-removal tier, `enabled: 'undecided'` and the typed `setEnabled` result together close a real defect — see [Turning ads off](#turning-ads-off--three-states).