@chipmobilesdk/rn-ads 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,63 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
6
6
 
7
7
  The event catalog, error codes, state names, and result unions are **public API**. Renaming or removing any of them is a major change.
8
8
 
9
+ ## [0.2.0] — 2026-09-13
10
+
11
+ Additive minor release closing five gaps that surfaced when `0.1.0` met an app selling an ad-removal tier. Four of the five were gaps in **capability** — an app could not patch them itself without importing the provider directly, which is the one thing this package exists to prevent.
12
+
13
+ **Upgrading requires one change, and it is compile-time only**: see [Changed](#changed) and the migration guide in the README.
14
+
15
+ ### Added
16
+
17
+ - **Consent orchestration** (`ads.consent.*`), optional. Runs the provider's complete consent flow — information update, form presentation, outcome — with your app importing nothing of the provider's. The outcome feeds the existing privacy gate by itself, in the order the gate requires, without producing a second native initialization. The package renders no consent UI; the form is the provider's.
18
+ - Normalized, provider-neutral `ConsentState`: `undetermined`, `not_required`, `required`, `obtained`, `error`. No provider type, no raw payload, no encoded consent string.
19
+ - **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.
20
+ - Combines with an app-supplied gate as **most-restrictive-wins** — either input alone can block, and the reported reason names which one is blocking.
21
+ - **Consent testing tools**: `consentTesting.debugGeography` forces a region, `consentTesting.resetOnStart` clears the stored decision. **Both are stripped in `production`**, at configuration validation rather than at each call site, and the fact that they were ignored is observable through a `config_warning` and the new `getIgnoredConsentTools()`.
22
+ - **Three-state enablement.** `enabled` now accepts `'undecided'` alongside `true` and `false`, for an app that cannot answer at construction time. Sends zero requests exactly like `disabled`, but reports itself distinctly so a banner host can hold its space instead of reclaiming it. The package never resolves it for you; leaving it unresolved past `undecidedWarningMs` emits one `config_warning`, and only one.
23
+ - **`setEnablement(next)`** for the three-state value, alongside `setEnabled(boolean)`.
24
+ - **App-open format** (`format: 'app_open'`), sharing the same single full-screen lock as interstitial and rewarded, with its own 30-minute validity window (`appOpenValidityMs`, 1 minute – 1 hour) and its own official test ad unit. The package never shows one on its own.
25
+ - **Ad-originated return refusal.** A show requested on a return from a full-screen ad — or from a browser an ad opened — is refused with `ad_originated_return`, distinct from `busy` and `not_ready`. Only the package can know this, which is why the rule lives here rather than in your app. It cannot survive process death; see the README for the boundaries.
26
+ - **Bounded retry**, **off by default**, configurable instance-wide via `retry` and per placement via `PlacementDefinition.retry` (`false` disables one placement). Two retries at 2s then 4s by default, exponential with a fixed x2 multiplier and no jitter. Only `retryable` errors are retried. Disabling ads, moving to undecided, losing consent, or `dispose()` cancels every pending retry across every placement. Every attempt emits a `retry_scheduled` event carrying the load cycle's `correlationId`, so a bill can be reconciled against the event stream.
27
+ - **Defined precedence for the three blocking gates** — platform availability, then enablement, then consent — documented as a full lookup table in the README and enforced by a single function rather than by four agreeing `if` chains.
28
+ - New exported constants: `DEFAULT_APP_OPEN_VALIDITY_MS`, `APP_OPEN_VALIDITY_BOUNDS`, `DEFAULT_RETRY_POLICY`, `RETRY_BOUNDS`, `DEFAULT_UNDECIDED_WARNING_MS`, `UNDECIDED_WARNING_BOUNDS`.
29
+ - `./testing` gained consent control (`setConsentInfo`, `failConsentRequest`, `failConsentForm`, `consentCalls`), app-open support, and `createTestClock` — so retry timing, app-open expiry, the undecided warning, and the ad-originated windows are all testable with no device and no waiting.
30
+
31
+ ### Changed
32
+
33
+ - **`setEnabled` returns `EnablementResult` instead of `void`.** Existing call sites that ignore the return value still compile — a value-returning function is assignable to a `void`-returning signature.
34
+ - **Five public unions gained members**, which breaks exhaustive `switch` statements in consumer TypeScript. This is the single required migration step, it is compile-time only, and it has no runtime equivalent:
35
+
36
+ | Union | New members |
37
+ |---|---|
38
+ | `AdFormat` | `'app_open'` |
39
+ | `InitStatus` | `'undecided'` |
40
+ | `PlacementState` | `'undecided'` |
41
+ | `PrivacyBlockReason` | `'consent_flow_required'`, `'consent_flow_error'` |
42
+ | `AdsErrorCode` | `'undecided'`, `'ad_originated_return'`, `'consent_flow_required'`, `'consent_flow_failed'`, `'retry_out_of_bounds'` |
43
+
44
+ - **Behavior change — gate reason reported when two gates are closed at once.** On an unavailable platform with ads also disabled, the reported reason is now `unsupported_platform` where `0.1.0` reported `disabled`. In `0.1.0` the surviving state depended on which condition arrived last, so the outcome was not deterministic; availability now wins consistently. **Request counts are unchanged** and no action is required. This is listed here rather than among the additions because it is a real, if narrow, change in observable output.
45
+
46
+ ### Fixed
47
+
48
+ - **Re-enabling ads no longer fails in silence.** In `0.1.0`, `setEnabled(true)` after a disable dropped the init status to `not_started`, so every subsequent `load` and `show` was refused, and the `void` return gave the caller no signal. It now resumes to the state held before disabling — with zero provider calls, because disabling never un-initialized the provider — or returns `requires_initialization` explicitly. The package does not initialize implicitly on your behalf.
49
+ - **`UNAVAILABLE` and `DISABLE` had no mutual guard** in the initialization machine, so the surviving state depended on arrival order. Availability now dominates deterministically.
50
+
51
+ ### Compatibility
52
+
53
+ - **No dependency change.** `peerDependencies` is untouched: the pinned `react-native-google-mobile-ads@>=16.0.3 <16.1.0` already exposes everything the consent flow and app-open need, verified against the installed type declarations. The Kotlin toolchain constraint documented in `0.1.0` is not reopened.
54
+ - **With no new configuration, runtime behavior is identical to `0.1.0`, request counts included.** This is verified by `src/__tests__/compat-0-1-0.test.ts`, which asserts an identical engine call sequence — in count and order — rather than leaving it as a promise in this file.
55
+ - The complete `0.1.0` test suite passes against this release, with two mechanical exceptions that could not survive by construction: the test pinning the version string, and a test that used `app_open` as its example of an *unsupported* format.
56
+
57
+ ### Deliberately still not included
58
+
59
+ - **Frequency caps, placement rules, interruption timing, and user segmentation.** Still app policy. Still a decision, not an omission.
60
+ - **Any knowledge of payments, entitlements, or premium tiers.** The `'undecided'` state exists *because* consuming apps have such a model, but the package receives a decision and never a reason.
61
+ - **Self-rendered consent UI, legal copy, or jurisdiction classification.** The package orchestrates; the provider renders; the publisher decides.
62
+ - **Third-party consent platforms** other than the one shipping with the ad provider.
63
+ - **Native ads, rewarded-interstitial, and mediation.**
64
+ - **iOS.** Every surface added here was shaped so an iOS adapter stays purely additive — in particular, `ConsentState` can express both a consent flow and App Tracking Transparency, and their ordering, without changing shape.
65
+
9
66
  ## [0.1.0] — 2026-09-02
10
67
 
11
68
  Initial release. No migration required.
package/README.md CHANGED
@@ -10,12 +10,13 @@ The package owns **capability**: a safe, consistent, provider-neutral way to req
10
10
 
11
11
  ## Scope
12
12
 
13
- | | v1 |
13
+ | | 0.2.0 |
14
14
  |---|---|
15
15
  | **Platform** | Android only. iOS returns `unavailable` / `unsupported_platform` and never breaks a build. |
16
16
  | **Provider** | Google AdMob, via `react-native-google-mobile-ads` |
17
- | **Formats** | Anchored adaptive banner, interstitial, rewarded |
18
- | **Deferred** | App-open, native, rewarded-interstitial, mediation, iOS — see [Known limitations](#known-limitations) |
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) |
19
20
 
20
21
  ## Compatibility
21
22
 
@@ -27,6 +28,9 @@ The package owns **capability**: a safe, consistent, provider-neutral way to req
27
28
  | Android | `minSdk 24`, `compileSdk 36`, `targetSdk 36`, Kotlin 2.1.x |
28
29
  | Provider bridge | `react-native-google-mobile-ads` **`>=16.0.3 <16.1.0`** |
29
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.
30
34
 
31
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.
32
36
 
@@ -69,6 +73,8 @@ Verified against a real merged manifest, not a list from documentation:
69
73
  | `android.permission.ACCESS_ADSERVICES_ATTRIBUTION` | `play-services-ads-api` |
70
74
  | `android.permission.ACCESS_ADSERVICES_TOPICS` | `play-services-ads-api` |
71
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
+
72
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.
73
79
 
74
80
  Verify the actual merged result before release — read the file, don't trust this table:
@@ -257,23 +263,288 @@ const unsubscribe = ads.subscribeEvents(event => yourLogger.track(event.type, ev
257
263
 
258
264
  A listener that throws is caught and reported once; it cannot break the state machine or starve other listeners.
259
265
 
260
- ### Timeouts, retry, and cleanup
266
+ ### Timeouts and cleanup
261
267
 
262
268
  | | Default | Configurable range |
263
269
  |---|---|---|
264
270
  | Load timeout | 30 000 ms | 5 000 – 120 000 |
265
271
  | Show timeout | 10 000 ms | 5 000 – 30 000 |
266
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 |
267
275
 
268
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.
269
277
 
270
- **There is no automatic retry in v1.** Retry pacing is yours: you are the only party who knows what a retry costs your users, and every retry is a billable request.
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.
271
413
 
272
- `dispose()` cancels every timer and subscription and releases every handle. It is idempotent, and so is every `Unsubscribe`.
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
+ ```
273
464
 
274
- ### Turning ads off
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.
275
466
 
276
- `enabled: false` at construction, or `setEnabled(false)` at runtime, makes initialize/load/show safe no-ops that create no request and report `disabled`. In-flight loads are released deterministically. 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.
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.
277
548
 
278
549
  ---
279
550
 
@@ -347,7 +618,8 @@ On device, verify with Google's official test ads only. `npm run android`, then
347
618
  ## Known limitations
348
619
 
349
620
  - **iOS is not implemented.** Importing and calling the package on iOS is safe and returns `unavailable` / `unsupported_platform`.
350
- - **App-open, native, and rewarded-interstitial ads are deferred.** Each has its own lifecycle, view construction, or presentation policy that does not fit the three formats v1 models. They are additive later — a new `AdFormat` member, a factory branch in the engine adapter, and their own official test units.
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).
351
623
  - **Mediation across multiple ad networks is out of scope.**
352
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.
353
625
  - **`impression` for full-screen formats is derived**, not provider-reported. See the asymmetry note above.
@@ -363,10 +635,37 @@ Adding iOS contributes exactly three things and changes **nothing** you have alr
363
635
 
364
636
  Your `createAds`, `initialize`, `load`, `show`, `AdsBanner`, and every subscription keep their current signatures. The exported state, event, and error models do not change.
365
637
 
366
- 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, App Tracking Transparency, UMP/consent evaluation, the privacy manifest, and App Store privacy labels.
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.
367
641
 
368
642
  ## Versioning
369
643
 
370
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.
371
645
 
372
- `0.1.0` is the initial release. No migration.
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).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chipmobilesdk/rn-ads",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "main": "src/index.ts",