@chipmobilesdk/rn-ads 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,671 +1,771 @@
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.2.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 — you still import nothing of theirs |
19
- | **Deferred** | Native, rewarded-interstitial, mediation, iOS — see [Known limitations](#known-limitations) |
20
-
21
- ## Compatibility
22
-
23
- | | Supported |
24
- |---|---|
25
- | React | `>=19` (verified against 19.2.3) |
26
- | React Native | `>=0.85`, New Architecture (verified against 0.85.3) |
27
- | Node | `>=22.11.0` |
28
- | Android | `minSdk 24`, `compileSdk 36`, `targetSdk 36`, Kotlin 2.1.x |
29
- | Provider bridge | `react-native-google-mobile-ads` **`>=16.0.3 <16.1.0`** |
30
- | Google Mobile Ads Android SDK | 24.9.0 (transitive) |
31
- | Google UMP (consent) | 4.0.0 (transitive) |
32
-
33
- > **`0.2.0` required no bridge bump.** The pinned `16.0.3` already exposes everything the consent flow and the app-open format need — verified against the installed type declarations rather than assumed from documentation. The Kotlin constraint below is therefore untouched by this release.
34
-
35
- > **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.
36
-
37
- ---
38
-
39
- ## Install
40
-
41
- ```sh
42
- npm install @chipmobilesdk/rn-ads react-native-google-mobile-ads@16.0.3
43
- ```
44
-
45
- ### Required Android configuration
46
-
47
- 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.
48
-
49
- ```jsonc
50
- {
51
- "react-native-google-mobile-ads": {
52
- // Google's public test application ID. Replace with your own for release.
53
- "android_app_id": "ca-app-pub-3940256099942544~3347511713"
54
- }
55
- }
56
- ```
57
-
58
- **The shared package carries no application ID and no ad unit ID.** Both are per-app configuration.
59
-
60
- ### Permissions merged into your manifest
61
-
62
- Contributed by the provider, not requested by this package:
63
-
64
- Verified against a real merged manifest, not a list from documentation:
65
-
66
- | Permission | Source |
67
- |---|---|
68
- | `android.permission.INTERNET` | bridge manifest |
69
- | `android.permission.ACCESS_NETWORK_STATE` | bridge manifest |
70
- | `android.permission.WAKE_LOCK` | bridge manifest |
71
- | `com.google.android.gms.permission.AD_ID` | `play-services-ads-api` |
72
- | `android.permission.ACCESS_ADSERVICES_AD_ID` | `play-services-ads-api` |
73
- | `android.permission.ACCESS_ADSERVICES_ATTRIBUTION` | `play-services-ads-api` |
74
- | `android.permission.ACCESS_ADSERVICES_TOPICS` | `play-services-ads-api` |
75
-
76
- **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.
77
-
78
- 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.
79
-
80
- Verify the actual merged result before release — read the file, don't trust this table:
81
-
82
- ```sh
83
- cd android && ./gradlew :app:assembleDebug
84
- # then inspect android/app/build/intermediates/merged_manifests/**/AndroidManifest.xml
85
- ```
86
-
87
- **To remove advertising identifier access** (required for child-directed apps under Play Families policy).
88
-
89
- Removing `AD_ID` alone is **not enough** — verified by rebuild, the three Privacy Sandbox permissions survive it and must be removed explicitly:
90
-
91
- ```xml
92
- <uses-permission android:name="com.google.android.gms.permission.AD_ID" tools:node="remove" />
93
- <uses-permission android:name="android.permission.ACCESS_ADSERVICES_AD_ID" tools:node="remove" />
94
- <uses-permission android:name="android.permission.ACCESS_ADSERVICES_ATTRIBUTION" tools:node="remove" />
95
- <uses-permission android:name="android.permission.ACCESS_ADSERVICES_TOPICS" tools:node="remove" />
96
- ```
97
-
98
- Your `<manifest>` tag needs `xmlns:tools="http://schemas.android.com/tools"`. Rebuild and re-inspect the merged manifest to confirm — do not assume.
99
-
100
- ---
101
-
102
- ## Configure
103
-
104
- Placement keys are **yours**. Provider ad unit IDs appear here in configuration and nowhere else in your code.
105
-
106
- ```ts
107
- import { createAds } from '@chipmobilesdk/rn-ads';
108
-
109
- const ads = createAds({
110
- enabled: true, // your feature flag; false makes everything a safe no-op
111
- environment: 'development', // 'development' | 'test' | 'production'
112
- placements: [
113
- { key: 'home_footer', format: 'banner', androidAdUnitId: 'ca-app-pub-…/…' },
114
- { key: 'level_break', format: 'interstitial', androidAdUnitId: 'ca-app-pub-…/…' },
115
- { key: 'completion_reward', format: 'rewarded', androidAdUnitId: 'ca-app-pub-…/…' },
116
- ],
117
- });
118
- ```
119
-
120
- `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.
121
-
122
- Reading configuration requires no dotenv library, no remote-config service, and no feature-flag system. Supply the values however you like.
123
-
124
- ### How production traffic is made impossible
125
-
126
- 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()`.
127
-
128
- ```
129
- environment: development
130
- home_footer -> ca-app-pub-REAL…/1234567890
131
-
132
- ⚠ config_warning: test_ad_unit_substituted
133
- placementKeys: ['home_footer']
134
- → request sent with ca-app-pub-3940256099942544/9214589741
135
- → your app keeps working
136
- ```
137
-
138
- In `production`, nothing is substituted — your units are used exactly as configured.
139
-
140
- 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`.
141
-
142
- > Invalid traffic from non-production builds can get an AdMob account suspended. **Never click a production ad to verify anything.**
143
-
144
- ---
145
-
146
- ## Initialize — consent comes first
147
-
148
- 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.
149
-
150
- ```tsx
151
- import { AdsProvider } from '@chipmobilesdk/rn-ads';
152
-
153
- await ads.initialize({
154
- canRequestAds: true, // your consent decision — the package renders no consent UI
155
- consentUndetermined: false, // true means "not asked yet", distinct from "refused"
156
- childDirected: false,
157
- underAgeOfConsent: false,
158
- maxAdContentRating: 'G',
159
- personalizedAds: true,
160
- });
161
-
162
- <AdsProvider instance={ads}>{children}</AdsProvider>
163
- ```
164
-
165
- **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.
166
-
167
- Resolving consent after being blocked is the normal path and creates **no** duplicate initialization:
168
-
169
- ```ts
170
- await ads.initialize({ canRequestAds: false }); // → { status: 'blocked', reason: 'consent_required' }
171
- await ads.updatePrivacy({ canRequestAds: true }); // → { status: 'applied' }, one native init total
172
- ```
173
-
174
- The package logs no personal data, no consent payload, and no advertising identifier.
175
-
176
- ---
177
-
178
- ## Banner
179
-
180
- You place it. It sizes itself to your container and re-adapts on rotation.
181
-
182
- ```tsx
183
- import { AdsBanner, usePlacementState } from '@chipmobilesdk/rn-ads';
184
-
185
- function Footer() {
186
- const state = usePlacementState('home_footer');
187
- const reserve = state === 'loading' || state === 'ready';
188
-
189
- return reserve ? (
190
- <View style={styles.slot}>
191
- {state === 'loading' ? <YourThemedPlaceholder /> : null}
192
- <AdsBanner placementKey="home_footer" />
193
- </View>
194
- ) : null; // your decision to reclaim the space
195
- }
196
- ```
197
-
198
- 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.
199
-
200
- ## Interstitial and rewarded
201
-
202
- Load ahead of time; show only when *you* decide.
203
-
204
- ```ts
205
- await ads.load('level_break');
206
- const result = await ads.show('level_break');
207
-
208
- switch (result.status) {
209
- case 'shown': break;
210
- case 'not_ready': break; // no UI opened, nothing crashed
211
- case 'busy': break; // another full-screen ad is up — never queued
212
- case 'consumed': break; // load a fresh one first
213
- case 'app_inactive': break; // backgrounded
214
- case 'blocked': break; // result.reason
215
- case 'disabled': break;
216
- case 'unavailable': break; // result.reason
217
- case 'timeout': break;
218
- case 'failed': break; // result.error
219
- }
220
- ```
221
-
222
- Rewards are reported **only** when the provider confirms them:
223
-
224
- ```ts
225
- ads.subscribeEvents(event => {
226
- if (event.type === 'reward_earned') {
227
- grant(event.reward.type, event.reward.amount); // your grant, not the package's
228
- }
229
- if (event.type === 'dismissed' && event.rewardEarned === false) {
230
- // closed early — no reward, and none inferred
231
- }
232
- });
233
- ```
234
-
235
- `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.
236
-
237
- ---
238
-
239
- ## State, events, and errors
240
-
241
- **Initialization status**: `not_started` · `blocked` · `initializing` · `ready` · `disabled` · `unavailable` · `failed`
242
-
243
- **Placement state**: `idle` · `loading` · `ready` · `showing` · `consumed` · `failed` · `unavailable`
244
-
245
- **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.
246
-
247
- `initialization` · `load_started` · `loaded` · `load_failed` · `display_started` · `impression` · `click` · `display_failed` · `dismissed` · `reward_earned` · `disabled` · `unavailable` · `config_warning` · `internal_error`
248
-
249
- > **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.
250
-
251
- **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.
252
-
253
- **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.
254
-
255
- Retryable: `no_fill` `network_error` `load_timeout` `show_timeout` `ad_expired` `not_ready` `busy` `consumed` `app_inactive` `provider_error`
256
- 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`
257
-
258
- Attach any logger or analytics you already use. The package depends on none:
259
-
260
- ```ts
261
- const unsubscribe = ads.subscribeEvents(event => yourLogger.track(event.type, event));
262
- ```
263
-
264
- A listener that throws is caught and reported once; it cannot break the state machine or starve other listeners.
265
-
266
- ### Timeouts and cleanup
267
-
268
- | | Default | Configurable range |
269
- |---|---|---|
270
- | Load timeout | 30 000 ms | 5 000 – 120 000 |
271
- | Show timeout | 10 000 ms | 5 000 – 30 000 |
272
- | Loaded-ad validity | 3 600 000 ms | 60 000 – 3 600 000 |
273
- | App-open validity | 1 800 000 ms | 60 000 – 3 600 000 |
274
- | Undecided warning | 10 000 ms | 1 000 – 120 000 |
275
-
276
- 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.
277
-
278
- `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`.
279
-
280
- ---
281
-
282
- ## Retry
283
-
284
- **Off by default.** Upgrading from `0.1.0` without configuring `retry` sends exactly the requests it always did.
285
-
286
- ```ts
287
- createAds({
288
- // ...
289
- retry: { maxRetries: 2, initialDelayMs: 2_000, maxDelayMs: 30_000 },
290
- placements: [
291
- { key: 'home_footer', format: 'banner', androidAdUnitId: '...' },
292
- // Off for this placement only: a rewarded retry happens with a user
293
- // waiting on the other side of it.
294
- { key: 'bonus', format: 'rewarded', androidAdUnitId: '...', retry: false },
295
- ],
296
- });
297
- ```
298
-
299
- | | Default | Range |
300
- |---|---|---|
301
- | `maxRetries` (attempts after the first) | 2 | 1 – 5 |
302
- | `initialDelayMs` | 2 000 | 500 – 30 000 |
303
- | `maxDelayMs` | 30 000 | 1 000 – 60 000 |
304
-
305
- 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.
306
-
307
- **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.
308
-
309
- 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.
310
-
311
- Disabling ads, moving to `undecided`, losing consent, or `dispose()` cancels **every** pending retry across every placement immediately.
312
-
313
- ### Reconciling retries against a bill
314
-
315
- Each attempt emits a `retry_scheduled` event carrying the **same `correlationId`** as the load cycle that triggered it:
316
-
317
- ```ts
318
- ads.subscribeEvents(event => {
319
- if (event.type === 'retry_scheduled') {
320
- analytics.track('ad_retry', {
321
- placement: event.placementKey,
322
- attempt: event.attempt,
323
- delayMs: event.delayMs,
324
- reason: event.error.code,
325
- cycle: event.correlationId,
326
- });
327
- }
328
- });
329
- ```
330
-
331
- 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.
332
-
333
- ---
334
-
335
- ## Turning ads off — three states
336
-
337
- `enabled` accepts `true`, `false`, or `'undecided'`.
338
-
339
- | State | Requests sent | Init status | Banner reports | When to use it |
340
- |---|---|---|---|---|
341
- | `true` | yes | normal | normal | Ads are on |
342
- | `false` | **zero** | `disabled` | `unavailable` | A settled no |
343
- | `'undecided'` | **zero** | `undecided` | `undecided` | You do not know yet |
344
-
345
- ### Why the third state exists
346
-
347
- 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.
348
-
349
- `undecided` sends nothing, exactly like `disabled`, but reports itself **distinctly** so your banner host can hold the space rather than reclaim it:
350
-
351
- ```tsx
352
- <AdsBanner placementKey="home_footer" onStateChange={setState} />
353
- {state === 'undecided' ? <YourThemedPlaceholder /> : null}
354
- ```
355
-
356
- Resolve it exactly once when you know:
357
-
358
- ```ts
359
- const result = ads.setEnablement(userOwnsAdRemoval ? 'disabled' : 'enabled');
360
- ```
361
-
362
- 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.
363
-
364
- ### Re-enabling
365
-
366
- **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.
367
-
368
- Now the result is typed and always says what happened:
369
-
370
- ```ts
371
- const result = ads.setEnabled(true);
372
-
373
- switch (result.status) {
374
- case 'resumed':
375
- // The provider was never un-initialized. Nothing to do — load away.
376
- break;
377
- case 'requires_initialization':
378
- // Never reached ready, privacy changed, or the platform went away.
379
- await ads.initialize(privacy);
380
- break;
381
- default:
382
- break;
383
- }
384
- ```
385
-
386
- 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.
387
-
388
- 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.
389
-
390
- 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.
391
-
392
- ### Wiring it to an external decision
393
-
394
- The package has no idea what an entitlement is. That is deliberate — it receives a decision, never a reason:
395
-
396
- ```ts
397
- const ads = createAds({ enabled: 'undecided', /* ... */ });
398
-
399
- // Whatever answers this for you: a billing library, your own backend, a cached
400
- // receipt. The package neither knows nor stores which.
401
- entitlements.subscribe(owned => {
402
- ads.setEnablement(owned ? 'disabled' : 'enabled');
403
- });
404
- ```
405
-
406
- ---
407
-
408
- ## Consent
409
-
410
- **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.
411
-
412
- 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.
413
-
414
- ```ts
415
- // Nothing here imports the ad provider.
416
- const info = await ads.consent.requestInfoUpdate();
417
-
418
- if (info.state === 'required') {
419
- await ads.consent.showFormIfRequired(); // the provider draws the form
420
- }
421
-
422
- // The outcome feeds the privacy gate by itself. No wiring, and no second
423
- // native initialization.
424
- await ads.load('level_break');
425
- ```
426
-
427
- ### Normalized consent states
428
-
429
- | State | Meaning | `canRequestAds` | Your next step |
430
- |---|---|---|---|
431
- | `undetermined` | Not asked yet. Initial value, and the value after a reset. | `false` | `requestInfoUpdate()` |
432
- | `not_required` | The provider says this region does not require consent. | `true` | Nothing — ads proceed |
433
- | `required` | Required and not yet obtained. | `false` | `showFormIfRequired()` |
434
- | `obtained` | Obtained. | `true` | Nothing — ads proceed |
435
- | `error` | The flow failed. | **always `false`** | Optionally retry the flow |
436
-
437
- Three rules the package will not bend:
438
-
439
- - **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.
440
- - **The package does not decide which regions require consent.** `not_required` is the provider's answer forwarded, not this package's judgement.
441
- - **A consent failure never crashes your app or blocks startup.** It is a normalized error you can act on or ignore.
442
-
443
- The package renders **no consent UI**. The form is the provider's. Timing, surrounding copy, and the experience when a user later changes their mind stay yours — as does deciding whether to run the flow at all. A user declining is a normal path, not an error.
444
-
445
- ### Package flow or your own gate?
446
-
447
- | | Package flow | Your own gate |
448
- |---|---|---|
449
- | You call | `ads.consent.*` | `initialize({ canRequestAds })` |
450
- | Form drawn by | the provider | you, or a third-party CMP |
451
- | Good when | you use the provider's consent messages | you already run a consent platform |
452
-
453
- 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.
454
-
455
- ### Testing the flow from outside the region
456
-
457
- ```ts
458
- createAds({
459
- environment: 'development',
460
- consentTesting: { debugGeography: 'eea', resetOnStart: true },
461
- // ...
462
- });
463
- ```
464
-
465
- **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.
466
-
467
- ### What gets stored on the device
468
-
469
- 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.
470
-
471
- ---
472
-
473
- ## App open
474
-
475
- A fourth format, declared in the catalog exactly like the other three and using the same placement states, events, and error codes.
476
-
477
- ```ts
478
- { key: 'cold_start', format: 'app_open', androidAdUnitId: 'ca-app-pub-.../...' }
479
- ```
480
-
481
- ```ts
482
- await ads.load('cold_start');
483
- const result = await ads.show('cold_start'); // you decide when
484
- ```
485
-
486
- **The package never shows one on its own.** As with interstitial and rewarded, every show is an explicit call you make.
487
-
488
- 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.
489
-
490
- ### Ad-originated return
491
-
492
- This is the reason app-open belongs in the package rather than in your app.
493
-
494
- 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:
495
-
496
- ```ts
497
- const result = await ads.show('cold_start');
498
- if (result.status === 'ad_originated_return') {
499
- // Distinct from `busy` and `not_ready` on purpose: neither of those would
500
- // have told you what actually happened, and both suggest a wrong remedy.
501
- }
502
- ```
503
-
504
- **Three boundaries, which are limits rather than defects:**
505
-
506
- 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.
507
- 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}`.
508
- 3. A browser opened by an ad without a preceding click event is undetectable, because the click is the only signal the provider gives.
509
-
510
- ### Validity window
511
-
512
- 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.
513
-
514
- 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.
515
-
516
- ### Store policy
517
-
518
- 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.
519
-
520
- ---
521
-
522
- ## Why an ad did not show
523
-
524
- Three independent gates can be closed. When more than one is, the reported reason follows this precedence, **most immovable first**:
525
-
526
- | Priority | Gate | Reasons |
527
- |---|---|---|
528
- | 1 | Platform availability | `unsupported_platform`, `provider_missing` |
529
- | 2 | Enablement | `disabled`, `undecided` |
530
- | 3 | Consent | `consent_required`, `consent_undetermined`, `consent_flow_required`, `consent_flow_error` |
531
-
532
- Full table:
533
-
534
- | Availability | Enablement | Consent | Reported |
535
- |---|---|---|---|
536
- | ok | on | open | *(allowed)* |
537
- | ok | on | your gate closed | `consent_required` / `consent_undetermined` |
538
- | ok | on | flow not permitted | `consent_flow_required` |
539
- | ok | on | flow errored | `consent_flow_error` |
540
- | ok | `disabled` | any | `disabled` |
541
- | ok | `undecided` | any | `undecided` |
542
- | unavailable | any | any | `unsupported_platform` |
543
- | provider missing | any | any | `provider_missing` |
544
-
545
- 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.
546
-
547
- **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.
548
-
549
- ---
550
-
551
- ## Testing
552
-
553
- The whole suite runs with **no device, no network, and no ad inventory**.
554
-
555
- ```ts
556
- import { createFakeAdsEngine } from '@chipmobilesdk/rn-ads/testing';
557
-
558
- const engine = createFakeAdsEngine();
559
- const ads = createAds(config, { engine, isDevBuild: true });
560
-
561
- await ads.initialize({ canRequestAds: true });
562
- await ads.load('level_break');
563
-
564
- expect(engine.adUnitIds()).toEqual([/* only official test units */]);
565
- ```
566
-
567
- 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.
568
-
569
- ```
570
- engine.calls every call the package made, in order
571
- engine.adUnitIds() what actually reached the provider
572
- engine.liveHandles() non-empty after dispose means a leak
573
- engine.emit(id, ev) you control ordering
574
- engine.listenerCount() must reach zero after cleanup
575
- ```
576
-
577
- On device, verify with Google's official test ads only. `npm run android`, then exercise each `Ads · …` screen in the demo app.
578
-
579
- ---
580
-
581
- ## Release checklists
582
-
583
- ### Before you ship
584
-
585
- - [ ] `npm run validate:ads` is green (lint, test, typecheck, pack dry-run)
586
- - [ ] `npm run android` and `npm run build:apk` both succeed
587
- - [ ] Merged manifest inspected; permissions match the table above and nothing else is new
588
- - [ ] Zero production ad requests observed from a `development` build — captured from logcat as release evidence
589
- - [ ] `environment: 'production'` is set only in release builds
590
- - [ ] No production ad unit, credential, or secret in this package
591
-
592
- ### AdMob Console (cannot be done in code)
593
-
594
- - [ ] Android app registered under the correct package / application ID
595
- - [ ] A production ad unit created per placement, in the matching format
596
- - [ ] Account status and payment details verified
597
- - [ ] `app-ads.txt` published if you use it
598
- - [ ] Consent/privacy messaging configured if you use it
599
- - [ ] Content rating (`maxAdContentRating`) reviewed — ad content affects your app's age rating
600
- - [ ] Policy, traffic, and serving restrictions checked
601
- - [ ] **No self-clicking, ever**
602
-
603
- ### Google Play Console
604
-
605
- - [ ] App declared as containing ads
606
- - [ ] **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
607
- - [ ] Privacy policy covers advertising data
608
- - [ ] Target audience and content completed
609
- - [ ] Content rating questionnaire reflects ad content
610
- - [ ] Families requirements met if applicable — see below
611
- - [ ] Advertising ID declaration matches your target SDK and actual behavior
612
- - [ ] Regional consent requirements satisfied
613
-
614
- > **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.
615
-
616
- ---
617
-
618
- ## Known limitations
619
-
620
- - **iOS is not implemented.** Importing and calling the package on iOS is safe and returns `unavailable` / `unsupported_platform`.
621
- - **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`.)
622
- - **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).
623
- - **Mediation across multiple ad networks is out of scope.**
624
- - **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.
625
- - **`impression` for full-screen formats is derived**, not provider-reported. See the asymmetry note above.
626
- - **Bridge versions 16.1.0+ are unusable** under React Native 0.85. See [Compatibility](#compatibility).
627
-
628
- ## Extending to iOS
629
-
630
- Adding iOS contributes exactly three things and changes **nothing** you have already written:
631
-
632
- 1. **`PlacementDefinition.iosAdUnitId`** — the optional field already exists in v1's type, so existing Android-only catalogs stay valid.
633
- 2. **A second engine adapter** behind the existing `AdsEngine` port. The port names no platform and no provider; that is what makes this additive.
634
- 3. **iOS entries in the private test-ad-unit table.**
635
-
636
- Your `createAds`, `initialize`, `load`, `show`, `AdsBanner`, and every subscription keep their current signatures. The exported state, event, and error models do not change.
637
-
638
- 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.
639
-
640
- **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.
641
-
642
- ## Versioning
643
-
644
- 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.
645
-
646
- ### Migrating `0.1.0` to `0.2.0`
647
-
648
- **What you must do: one thing, and it is compile-time only.**
649
-
650
- If you have an exhaustive `switch` over any of these unions, add the new cases or a `default`:
651
-
652
- | Union | New members |
653
- |---|---|
654
- | `AdFormat` | `'app_open'` |
655
- | `InitStatus` | `'undecided'` |
656
- | `PlacementState` | `'undecided'` |
657
- | `PrivacyBlockReason` | `'consent_flow_required'`, `'consent_flow_error'` |
658
- | `AdsErrorCode` | `'undecided'`, `'ad_originated_return'`, `'consent_flow_required'`, `'consent_flow_failed'`, `'retry_out_of_bounds'` |
659
-
660
- 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.
661
-
662
- **What you may ignore, entirely:**
663
-
664
- - Consent — nothing starts unless you call `ads.consent.*`.
665
- - The `'undecided'` enablement state — `enabled: true` and `enabled: false` mean exactly what they meant.
666
- - App-open — a format you do not have to declare.
667
- - Retry — **off** unless you supply `retry`.
668
-
669
- **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.
670
-
671
- **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.3.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 — 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.** The pinned `16.0.3` already exposes everything the consent flow and the app-open format need — verified against the installed type declarations rather than assumed from documentation. The Kotlin constraint below is therefore untouched by this release.
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
+ The package logs no personal data, no consent payload, and no advertising identifier.
176
+
177
+ ---
178
+
179
+ ## Banner
180
+
181
+ You place it. It sizes itself to your container and re-adapts on rotation.
182
+
183
+ ```tsx
184
+ import { AdsBanner, usePlacementState } from '@chipmobilesdk/rn-ads';
185
+
186
+ function Footer() {
187
+ const state = usePlacementState('home_footer');
188
+ const reserve = state === 'loading' || state === 'ready';
189
+
190
+ return reserve ? (
191
+ <View style={styles.slot}>
192
+ {state === 'loading' ? <YourThemedPlaceholder /> : null}
193
+ <AdsBanner placementKey="home_footer" />
194
+ </View>
195
+ ) : null; // your decision to reclaim the space
196
+ }
197
+ ```
198
+
199
+ 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.
200
+
201
+ ## Interstitial and rewarded
202
+
203
+ Load ahead of time; show only when *you* decide.
204
+
205
+ ```ts
206
+ await ads.load('level_break');
207
+ const result = await ads.show('level_break');
208
+
209
+ switch (result.status) {
210
+ case 'shown': break;
211
+ case 'not_ready': break; // no UI opened, nothing crashed
212
+ case 'busy': break; // another full-screen ad is up — never queued
213
+ case 'consumed': break; // load a fresh one first
214
+ case 'app_inactive': break; // backgrounded
215
+ case 'blocked': break; // result.reason
216
+ case 'disabled': break;
217
+ case 'unavailable': break; // result.reason
218
+ case 'timeout': break;
219
+ case 'failed': break; // result.error
220
+ }
221
+ ```
222
+
223
+ Rewards are reported **only** when the provider confirms them:
224
+
225
+ ```ts
226
+ ads.subscribeEvents(event => {
227
+ if (event.type === 'reward_earned') {
228
+ grant(event.reward.type, event.reward.amount); // your grant, not the package's
229
+ }
230
+ if (event.type === 'dismissed' && event.rewardEarned === false) {
231
+ // closed early — no reward, and none inferred
232
+ }
233
+ });
234
+ ```
235
+
236
+ `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.
237
+
238
+ ---
239
+
240
+ ## State, events, and errors
241
+
242
+ **Initialization status**: `not_started` · `blocked` · `initializing` · `ready` · `disabled` · `unavailable` · `failed`
243
+
244
+ **Placement state**: `idle` · `loading` · `ready` · `showing` · `consumed` · `failed` · `unavailable`
245
+
246
+ **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.
247
+
248
+ `initialization` · `load_started` · `loaded` · `load_failed` · `display_started` · `impression` · `click` · `display_failed` · `dismissed` · `reward_earned` · `disabled` · `unavailable` · `config_warning` · `internal_error`
249
+
250
+ > **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.
251
+
252
+ **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.
253
+
254
+ **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.
255
+
256
+ Retryable: `no_fill` `network_error` `load_timeout` `show_timeout` `ad_expired` `not_ready` `busy` `consumed` `app_inactive` `provider_error`
257
+ 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`
258
+
259
+ Attach any logger or analytics you already use. The package depends on none:
260
+
261
+ ```ts
262
+ const unsubscribe = ads.subscribeEvents(event => yourLogger.track(event.type, event));
263
+ ```
264
+
265
+ A listener that throws is caught and reported once; it cannot break the state machine or starve other listeners.
266
+
267
+ ### Timeouts and cleanup
268
+
269
+ | | Default | Configurable range |
270
+ |---|---|---|
271
+ | Load timeout | 30 000 ms | 5 000 – 120 000 |
272
+ | Show timeout | 10 000 ms | 5 000 – 30 000 |
273
+ | Loaded-ad validity | 3 600 000 ms | 60 000 – 3 600 000 |
274
+ | App-open validity | 1 800 000 ms | 60 000 – 3 600 000 |
275
+ | Undecided warning | 10 000 ms | 1 000 – 120 000 |
276
+
277
+ 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.
278
+
279
+ `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`.
280
+
281
+ ---
282
+
283
+ ## Retry
284
+
285
+ **Off by default.** Upgrading from `0.1.0` without configuring `retry` sends exactly the requests it always did.
286
+
287
+ ```ts
288
+ createAds({
289
+ // ...
290
+ retry: { maxRetries: 2, initialDelayMs: 2_000, maxDelayMs: 30_000 },
291
+ placements: [
292
+ { key: 'home_footer', format: 'banner', androidAdUnitId: '...' },
293
+ // Off for this placement only: a rewarded retry happens with a user
294
+ // waiting on the other side of it.
295
+ { key: 'bonus', format: 'rewarded', androidAdUnitId: '...', retry: false },
296
+ ],
297
+ });
298
+ ```
299
+
300
+ | | Default | Range |
301
+ |---|---|---|
302
+ | `maxRetries` (attempts after the first) | 2 | 1 – 5 |
303
+ | `initialDelayMs` | 2 000 | 500 – 30 000 |
304
+ | `maxDelayMs` | 30 000 | 1 000 – 60 000 |
305
+
306
+ 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.
307
+
308
+ **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.
309
+
310
+ 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.
311
+
312
+ Disabling ads, moving to `undecided`, losing consent, or `dispose()` cancels **every** pending retry across every placement immediately.
313
+
314
+ ### Reconciling retries against a bill
315
+
316
+ Each attempt emits a `retry_scheduled` event carrying the **same `correlationId`** as the load cycle that triggered it:
317
+
318
+ ```ts
319
+ ads.subscribeEvents(event => {
320
+ if (event.type === 'retry_scheduled') {
321
+ analytics.track('ad_retry', {
322
+ placement: event.placementKey,
323
+ attempt: event.attempt,
324
+ delayMs: event.delayMs,
325
+ reason: event.error.code,
326
+ cycle: event.correlationId,
327
+ });
328
+ }
329
+ });
330
+ ```
331
+
332
+ 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.
333
+
334
+ ---
335
+
336
+ ## Request throttling
337
+
338
+ **New in 0.3.0.** Each placement has a minimum interval between provider *requests*.
339
+
340
+ ### The problem it solves
341
+
342
+ 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.
343
+
344
+ 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.
345
+
346
+ 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.
347
+
348
+ ### Defaults
349
+
350
+ | Format | Default floor | Why |
351
+ |---|---|---|
352
+ | `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. |
353
+ | `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. |
354
+
355
+ Override per placement:
356
+
357
+ ```ts
358
+ placements: [
359
+ {
360
+ key: 'timeline_row',
361
+ format: 'banner',
362
+ androidAdUnitId: 'ca-app-pub-…/…',
363
+ minRequestIntervalMs: 30_000, // lower the floor knowingly
364
+ },
365
+ {
366
+ key: 'after_save',
367
+ format: 'interstitial',
368
+ androidAdUnitId: 'ca-app-pub-…/…',
369
+ minRequestIntervalMs: 20_000, // add a floor where there is none by default
370
+ },
371
+ ]
372
+ ```
373
+
374
+ 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.
375
+
376
+ ### What a refusal looks like
377
+
378
+ ```ts
379
+ const result = await ads.load('after_save');
380
+ if (result.status === 'throttled') {
381
+ // Nothing was requested. Nothing is wrong.
382
+ console.log(`asked too recently; retry in ${result.retryAfterMs}ms`);
383
+ }
384
+ ```
385
+
386
+ - 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.
387
+ - A `request_throttled` event carries `retryAfterMs` and the `minRequestIntervalMs` that refused it, so the cause is readable from your logs alone.
388
+ - 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.
389
+
390
+ ### Two exemptions, both deliberate
391
+
392
+ **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.
393
+
394
+ **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.
395
+
396
+ ### The better fix is architectural
397
+
398
+ > 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.
399
+ >
400
+ > 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.
401
+
402
+ ### This is not a frequency cap
403
+
404
+ 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.
405
+
406
+ 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.
407
+
408
+ ---
409
+
410
+ ## Turning ads off — three states
411
+
412
+ `enabled` accepts `true`, `false`, or `'undecided'`.
413
+
414
+ | State | Requests sent | Init status | Banner reports | When to use it |
415
+ |---|---|---|---|---|
416
+ | `true` | yes | normal | normal | Ads are on |
417
+ | `false` | **zero** | `disabled` | `unavailable` | A settled no |
418
+ | `'undecided'` | **zero** | `undecided` | `undecided` | You do not know yet |
419
+
420
+ ### Why the third state exists
421
+
422
+ 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.
423
+
424
+ `undecided` sends nothing, exactly like `disabled`, but reports itself **distinctly** so your banner host can hold the space rather than reclaim it:
425
+
426
+ ```tsx
427
+ <AdsBanner placementKey="home_footer" onStateChange={setState} />
428
+ {state === 'undecided' ? <YourThemedPlaceholder /> : null}
429
+ ```
430
+
431
+ Resolve it exactly once when you know:
432
+
433
+ ```ts
434
+ const result = ads.setEnablement(userOwnsAdRemoval ? 'disabled' : 'enabled');
435
+ ```
436
+
437
+ 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.
438
+
439
+ ### Re-enabling
440
+
441
+ **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.
442
+
443
+ Now the result is typed and always says what happened:
444
+
445
+ ```ts
446
+ const result = ads.setEnabled(true);
447
+
448
+ switch (result.status) {
449
+ case 'resumed':
450
+ // The provider was never un-initialized. Nothing to do — load away.
451
+ break;
452
+ case 'requires_initialization':
453
+ // Never reached ready, privacy changed, or the platform went away.
454
+ await ads.initialize(privacy);
455
+ break;
456
+ default:
457
+ break;
458
+ }
459
+ ```
460
+
461
+ 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.
462
+
463
+ 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.
464
+
465
+ 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.
466
+
467
+ ### Wiring it to an external decision
468
+
469
+ The package has no idea what an entitlement is. That is deliberate — it receives a decision, never a reason:
470
+
471
+ ```ts
472
+ const ads = createAds({ enabled: 'undecided', /* ... */ });
473
+
474
+ // Whatever answers this for you: a billing library, your own backend, a cached
475
+ // receipt. The package neither knows nor stores which.
476
+ entitlements.subscribe(owned => {
477
+ ads.setEnablement(owned ? 'disabled' : 'enabled');
478
+ });
479
+ ```
480
+
481
+ ---
482
+
483
+ ## Consent
484
+
485
+ **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.
486
+
487
+ 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.
488
+
489
+ ```ts
490
+ // Nothing here imports the ad provider.
491
+ const info = await ads.consent.requestInfoUpdate();
492
+
493
+ if (info.state === 'required') {
494
+ await ads.consent.showFormIfRequired(); // the provider draws the form
495
+ }
496
+
497
+ // The outcome feeds the privacy gate by itself. No wiring, and no second
498
+ // native initialization.
499
+ await ads.load('level_break');
500
+ ```
501
+
502
+ ### Normalized consent states
503
+
504
+ | State | Meaning | `canRequestAds` | Your next step |
505
+ |---|---|---|---|
506
+ | `undetermined` | Not asked yet. Initial value, and the value after a reset. | `false` | `requestInfoUpdate()` |
507
+ | `not_required` | The provider says this region does not require consent. | `true` | Nothing — ads proceed |
508
+ | `required` | Required and not yet obtained. | `false` | `showFormIfRequired()` |
509
+ | `obtained` | Obtained. | `true` | Nothing — ads proceed |
510
+ | `error` | The flow failed. | **always `false`** | Optionally retry the flow |
511
+
512
+ Three rules the package will not bend:
513
+
514
+ - **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.
515
+ - **The package does not decide which regions require consent.** `not_required` is the provider's answer forwarded, not this package's judgement.
516
+ - **A consent failure never crashes your app or blocks startup.** It is a normalized error you can act on or ignore.
517
+
518
+ The package renders **no consent UI**. The form is the provider's. Timing, surrounding copy, and the experience when a user later changes their mind stay yours — as does deciding whether to run the flow at all. A user declining is a normal path, not an error.
519
+
520
+ ### Package flow or your own gate?
521
+
522
+ | | Package flow | Your own gate |
523
+ |---|---|---|
524
+ | You call | `ads.consent.*` | `initialize({ canRequestAds })` |
525
+ | Form drawn by | the provider | you, or a third-party CMP |
526
+ | Good when | you use the provider's consent messages | you already run a consent platform |
527
+
528
+ 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.
529
+
530
+ ### Testing the flow from outside the region
531
+
532
+ ```ts
533
+ createAds({
534
+ environment: 'development',
535
+ consentTesting: { debugGeography: 'eea', resetOnStart: true },
536
+ // ...
537
+ });
538
+ ```
539
+
540
+ **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.
541
+
542
+ ### What gets stored on the device
543
+
544
+ 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.
545
+
546
+ ---
547
+
548
+ ## App open
549
+
550
+ A fourth format, declared in the catalog exactly like the other three and using the same placement states, events, and error codes.
551
+
552
+ ```ts
553
+ { key: 'cold_start', format: 'app_open', androidAdUnitId: 'ca-app-pub-.../...' }
554
+ ```
555
+
556
+ ```ts
557
+ await ads.load('cold_start');
558
+ const result = await ads.show('cold_start'); // you decide when
559
+ ```
560
+
561
+ **The package never shows one on its own.** As with interstitial and rewarded, every show is an explicit call you make.
562
+
563
+ 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.
564
+
565
+ ### Ad-originated return
566
+
567
+ This is the reason app-open belongs in the package rather than in your app.
568
+
569
+ 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:
570
+
571
+ ```ts
572
+ const result = await ads.show('cold_start');
573
+ if (result.status === 'ad_originated_return') {
574
+ // Distinct from `busy` and `not_ready` on purpose: neither of those would
575
+ // have told you what actually happened, and both suggest a wrong remedy.
576
+ }
577
+ ```
578
+
579
+ **Three boundaries, which are limits rather than defects:**
580
+
581
+ 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.
582
+ 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}`.
583
+ 3. A browser opened by an ad without a preceding click event is undetectable, because the click is the only signal the provider gives.
584
+
585
+ ### Validity window
586
+
587
+ 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.
588
+
589
+ 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.
590
+
591
+ ### Store policy
592
+
593
+ 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.
594
+
595
+ ---
596
+
597
+ ## Why an ad did not show
598
+
599
+ Three independent gates can be closed. When more than one is, the reported reason follows this precedence, **most immovable first**:
600
+
601
+ | Priority | Gate | Reasons |
602
+ |---|---|---|
603
+ | 1 | Platform availability | `unsupported_platform`, `provider_missing` |
604
+ | 2 | Enablement | `disabled`, `undecided` |
605
+ | 3 | Consent | `consent_required`, `consent_undetermined`, `consent_flow_required`, `consent_flow_error` |
606
+
607
+ Full table:
608
+
609
+ | Availability | Enablement | Consent | Reported |
610
+ |---|---|---|---|
611
+ | ok | on | open | *(allowed)* |
612
+ | ok | on | your gate closed | `consent_required` / `consent_undetermined` |
613
+ | ok | on | flow not permitted | `consent_flow_required` |
614
+ | ok | on | flow errored | `consent_flow_error` |
615
+ | ok | `disabled` | any | `disabled` |
616
+ | ok | `undecided` | any | `undecided` |
617
+ | unavailable | any | any | `unsupported_platform` |
618
+ | provider missing | any | any | `provider_missing` |
619
+
620
+ 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.
621
+
622
+ **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.
623
+
624
+ | Symptom | Likely cause |
625
+ |---|---|
626
+ | Placement sits at `throttled` after a navigation | A banner inside a remounting subtree, hitting the 60s floor |
627
+ | Placement sits at `undecided` forever | Enablement never resolved from `'undecided'` |
628
+ | Placement sits at `idle`, banner never appears | `AdsBanner` gated on its own state, or a zero-width container |
629
+ | `load` returns `blocked` | Consent gate closed |
630
+
631
+ **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.
632
+
633
+ ---
634
+
635
+ ## Testing
636
+
637
+ The whole suite runs with **no device, no network, and no ad inventory**.
638
+
639
+ ```ts
640
+ import { createFakeAdsEngine } from '@chipmobilesdk/rn-ads/testing';
641
+
642
+ const engine = createFakeAdsEngine();
643
+ const ads = createAds(config, { engine, isDevBuild: true });
644
+
645
+ await ads.initialize({ canRequestAds: true });
646
+ await ads.load('level_break');
647
+
648
+ expect(engine.adUnitIds()).toEqual([/* only official test units */]);
649
+ ```
650
+
651
+ 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.
652
+
653
+ ```
654
+ engine.calls every call the package made, in order
655
+ engine.adUnitIds() what actually reached the provider
656
+ engine.liveHandles() non-empty after dispose means a leak
657
+ engine.emit(id, ev) you control ordering
658
+ engine.listenerCount() must reach zero after cleanup
659
+ ```
660
+
661
+ On device, verify with Google's official test ads only. `npm run android`, then exercise each `Ads · …` screen in the demo app.
662
+
663
+ ---
664
+
665
+ ## Release checklists
666
+
667
+ ### Before you ship
668
+
669
+ - [ ] `npm run validate:ads` is green (lint, test, typecheck, pack dry-run)
670
+ - [ ] `npm run android` and `npm run build:apk` both succeed
671
+ - [ ] Merged manifest inspected; permissions match the table above and nothing else is new
672
+ - [ ] Zero production ad requests observed from a `development` build — captured from logcat as release evidence
673
+ - [ ] `environment: 'production'` is set only in release builds
674
+ - [ ] No production ad unit, credential, or secret in this package
675
+
676
+ ### AdMob Console (cannot be done in code)
677
+
678
+ - [ ] Android app registered under the correct package / application ID
679
+ - [ ] A production ad unit created per placement, in the matching format
680
+ - [ ] Account status and payment details verified
681
+ - [ ] `app-ads.txt` published if you use it
682
+ - [ ] Consent/privacy messaging configured if you use it
683
+ - [ ] Content rating (`maxAdContentRating`) reviewed — ad content affects your app's age rating
684
+ - [ ] Policy, traffic, and serving restrictions checked
685
+ - [ ] **No self-clicking, ever**
686
+
687
+ ### Google Play Console
688
+
689
+ - [ ] App declared as containing ads
690
+ - [ ] **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
691
+ - [ ] Privacy policy covers advertising data
692
+ - [ ] Target audience and content completed
693
+ - [ ] Content rating questionnaire reflects ad content
694
+ - [ ] Families requirements met if applicable — see below
695
+ - [ ] Advertising ID declaration matches your target SDK and actual behavior
696
+ - [ ] Regional consent requirements satisfied
697
+
698
+ > **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.
699
+
700
+ ---
701
+
702
+ ## Known limitations
703
+
704
+ - **iOS is not implemented.** Importing and calling the package on iOS is safe and returns `unavailable` / `unsupported_platform`.
705
+ - **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`.)
706
+ - **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).
707
+ - **Mediation across multiple ad networks is out of scope.**
708
+ - **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.
709
+ - **`impression` for full-screen formats is derived**, not provider-reported. See the asymmetry note above.
710
+ - **Bridge versions 16.1.0+ are unusable** under React Native 0.85. See [Compatibility](#compatibility).
711
+
712
+ ## Extending to iOS
713
+
714
+ Adding iOS contributes exactly three things and changes **nothing** you have already written:
715
+
716
+ 1. **`PlacementDefinition.iosAdUnitId`** — the optional field already exists in v1's type, so existing Android-only catalogs stay valid.
717
+ 2. **A second engine adapter** behind the existing `AdsEngine` port. The port names no platform and no provider; that is what makes this additive.
718
+ 3. **iOS entries in the private test-ad-unit table.**
719
+
720
+ Your `createAds`, `initialize`, `load`, `show`, `AdsBanner`, and every subscription keep their current signatures. The exported state, event, and error models do not change.
721
+
722
+ 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.
723
+
724
+ **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.
725
+
726
+ ## Versioning
727
+
728
+ 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.
729
+
730
+ ### Migrating `0.2.x` to `0.3.0`
731
+
732
+ **One compile-time change.** If you have an exhaustive `switch` over any of these, add the new case or a `default`:
733
+
734
+ | Union | New member |
735
+ |---|---|
736
+ | `PlacementState` | `'throttled'` |
737
+ | `LoadResult` | `{ status: 'throttled', retryAfterMs }` |
738
+ | `AdEventType` | `'request_throttled'` |
739
+
740
+ **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.
741
+
742
+ 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.
743
+
744
+ **What has not changed:** no frequency cap was added, and none is planned. Pacing stays entirely yours.
745
+
746
+ ### Migrating `0.1.0` to `0.2.0`
747
+
748
+ **What you must do: one thing, and it is compile-time only.**
749
+
750
+ If you have an exhaustive `switch` over any of these unions, add the new cases or a `default`:
751
+
752
+ | Union | New members |
753
+ |---|---|
754
+ | `AdFormat` | `'app_open'` |
755
+ | `InitStatus` | `'undecided'` |
756
+ | `PlacementState` | `'undecided'` |
757
+ | `PrivacyBlockReason` | `'consent_flow_required'`, `'consent_flow_error'` |
758
+ | `AdsErrorCode` | `'undecided'`, `'ad_originated_return'`, `'consent_flow_required'`, `'consent_flow_failed'`, `'retry_out_of_bounds'` |
759
+
760
+ 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.
761
+
762
+ **What you may ignore, entirely:**
763
+
764
+ - Consent — nothing starts unless you call `ads.consent.*`.
765
+ - The `'undecided'` enablement state — `enabled: true` and `enabled: false` mean exactly what they meant.
766
+ - App-open — a format you do not have to declare.
767
+ - Retry — **off** unless you supply `retry`.
768
+
769
+ **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.
770
+
771
+ **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).