@chipmobilesdk/rn-ads 0.3.0 → 0.4.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,53 @@ 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.4.0] — 2026-10-12
10
+
11
+ Additive minor release closing one gap that surfaced when `0.3.x` met an app that must let users change their consent choice. It is a **capability** gap, the same category as consent orchestration in `0.2.0`: the app could not close it without importing the provider, or without misusing `consent.reset()`, a testing-only call that wipes the stored decision while the form is open.
12
+
13
+ **Upgrading requires no code change.** No existing exported union gained a member. Three behaviour changes are worth reading — see [Changed](#changed) — and one defect is fixed — see [Fixed](#fixed).
14
+
15
+ ### Added
16
+
17
+ - **Privacy options: the "change my choice" form** (`ads.consent.showPrivacyOptionsForm()`). Presents the provider's privacy options form and returns a typed `PrivacyOptionsFormResult`, never throwing:
18
+ - `shown`, carrying the `ConsentSnapshot` the form left behind, already applied to the gate. A user who backs out unchanged lands here with the state they had.
19
+ - `not_required`, `info_update_required`, `busy`: refusals that present nothing.
20
+ - `failed`, carrying an `AdsError` (`consent_flow_failed`).
21
+ - `unavailable`: `unsupported_platform` on iOS, `provider_missing` after `dispose()`.
22
+
23
+ The outcome feeds the privacy gate by itself, exactly like `showFormIfRequired`, and combines with the app's own gate as most-restrictive-wins. The package still draws no UI. Where the entry lives, its copy and when to offer it stay the app's.
24
+
25
+ - **`ConsentSnapshot.privacyOptionsRequirement`** (`'required' | 'not_required' | 'unknown'`): whether to show that entry at all. Refreshed by `requestInfoUpdate()` and published through `consent.subscribe`. It is the provider's verdict forwarded, never a region judgement of the package's. It is `unknown` before the first successful update, after `reset()`, after a failed update, and on iOS.
26
+
27
+ - **A failed form is never consent.** When the form fails, the package asks the provider what it still holds rather than assuming: the form never completed, so the stored decision has not changed, and revoking it would punish a user for a form that failed to open. If the provider cannot answer either, the state becomes `error` and the gate closes.
28
+
29
+ - **`./testing`**: `setPrivacyOptionsFormResult`, `failPrivacyOptionsForm`, `holdPrivacyOptionsForm` (keeps the next form open until the test closes or fails it, so `busy`, a consent update or `dispose()` can happen while it is on screen), `failConsentInfoRead`, and a `privacyOptions` field on the scripted consent info. `FakeConsentCall` gained `{ kind: 'showPrivacyOptionsForm' }`.
30
+
31
+ - Exported types `PrivacyOptionsFormResult` and `PrivacyOptionsRequirement`.
32
+
33
+ No new event type. Consent changes caused by the form arrive through `consent.subscribe`, as `initialization` events when the status moves, and as `unavailable` for a load the withdrawal cancels. No payload carries the TCF string or any identifier.
34
+
35
+ No bridge change. The pinned `react-native-google-mobile-ads` `16.0.3` already exposes `AdsConsent.showPrivacyOptionsForm()` and `privacyOptionsRequirementStatus`, verified against the installed type declarations and its Android module. The range stays `>=16.0.3 <16.1.0`, and the merged manifest gains no permission.
36
+
37
+ ### Changed
38
+
39
+ - **`initialize()` waits for the package's consent flow, once the app uses it.** After any `ads.consent.*` call, `initialize()` on a flow that does not permit requests — declined, not yet answered, or failed — returns `{ status: 'blocked', reason: 'consent_flow_required' | 'consent_flow_error' }` and initializes nothing natively. `0.3.0` checked only the app's own gate here, so it returned `ready` and initialized the provider for a user who had just declined, while `load` refused; that contradicts the provider's guidance not to initialize before consent permits requests. When the flow later permits requests, through either consent form, the package initializes the provider itself, once. An app that runs update → form → `initialize()` sees no difference for a user who consents. An app that never calls `ads.consent.*` is unaffected.
40
+
41
+ - **A consent gate that closes while the instance is `ready` now moves it to `blocked`, and stops what is in flight.** `0.3.0` kept `ready` while `load` already refused. That was harmless while the only consent outcome that could close the gate ran before anything loaded. The privacy options form changes that: a user can withdraw with a banner mounted, and a banner host keyed on `ready` would have gone on requesting. Now in-flight loads settle with `{ status: 'blocked', reason }` instead of being left waiting, loaded-but-unshown ads are released, pending retries are cancelled, and a mounted `AdsBanner` leaves the tree. An ad already on screen is still not force-closed. This also applies when a later `requestInfoUpdate()` fails.
42
+
43
+ - **Reopening a gate on an already-initialized instance resumes without a second native initialization.** `0.3.0` initialized the provider again on `ready → blocked → reopened`, through the consent flow or through `updatePrivacy`, against the one-initialization-per-instance rule. Now the status returns to `ready` with no native call, including when the app re-runs `initialize()` after a consent change. Resuming goes through the whole gate, so re-running `initialize()` cannot reopen a consent the user just withdrew. One exception keeps the old path: if an init-time flag (`childDirected`, `underAgeOfConsent`, `maxAdContentRating`) changed in between, a real initialization still runs, because that is the only way the flag can take effect.
44
+
45
+ ### Fixed
46
+
47
+ - **A `load()` in flight when ads are turned off, turned `undecided`, or the instance is disposed now settles.** It resolves with `{ status: 'disabled' }`, `{ status: 'undecided' }`, or `{ status: 'unavailable', reason: 'provider_missing' }` — the answer a `load` started at that moment gets. Before `0.4.0` the resources were released but the promise was never resolved, so an app awaiting a load across an ad-removal purchase waited forever. Disabling and undeciding also emit the matching refusal event for the abandoned load; dispose emits nothing, as always.
48
+ - **A handle the provider returned after such a disable is disposed instead of adopted.** Previously it was subscribed and went on loading behind a disabled instance.
49
+
50
+ ### Not added, deliberately
51
+
52
+ - **No Settings entry, copy or offer timing.** Those are policy, and they stay the app's.
53
+ - **No region detection of the package's own.** The requirement is the provider's answer.
54
+ - **iOS** stays `unavailable` / `unsupported_platform`.
55
+
9
56
  ## [0.3.0] — 2026-10-10
10
57
 
11
58
  Additive minor release closing one gap that surfaced when `0.2.x` met an app placing a banner inside a view the user swipes between. It is a **capability** gap: the app could not close it itself without reaching past the package, which is the thing this package exists to prevent.
@@ -56,7 +103,7 @@ Additive minor release closing one gap that surfaced when `0.2.x` met an app pla
56
103
 
57
104
  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.
58
105
 
59
- **Upgrading requires one change, and it is compile-time only**: see [Changed](#changed) and the migration guide in the README.
106
+ **Upgrading requires one change, and it is compile-time only**: see [Changed](#changed-2) and the migration guide in the README.
60
107
 
61
108
  ### Added
62
109
 
package/README.md CHANGED
@@ -10,12 +10,12 @@ The package owns **capability**: a safe, consistent, provider-neutral way to req
10
10
 
11
11
  ## Scope
12
12
 
13
- | | 0.3.0 |
13
+ | | 0.4.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
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 |
18
+ | **Consent** | Optional orchestration of the provider's consent flow, including the form a user changes their answer through — you still import nothing of theirs |
19
19
  | **Account safety** | Non-production builds cannot reach a production ad unit; each placement has a request floor — see [Request throttling](#request-throttling) |
20
20
  | **Deferred** | Native, rewarded-interstitial, mediation, iOS — see [Known limitations](#known-limitations) |
21
21
 
@@ -31,7 +31,7 @@ The package owns **capability**: a safe, consistent, provider-neutral way to req
31
31
  | Google Mobile Ads Android SDK | 24.9.0 (transitive) |
32
32
  | Google UMP (consent) | 4.0.0 (transitive) |
33
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.
34
+ > **`0.2.0` required no bridge bump, and neither did `0.4.0`.** The pinned `16.0.3` already exposes everything the consent flow, the app-open format and the privacy options form need (`AdsConsent.showPrivacyOptionsForm()` and `privacyOptionsRequirementStatus` on the consent info) — verified against the installed type declarations and the bridge's Android module rather than assumed from documentation. The Kotlin constraint below is therefore untouched by both releases, and the merged manifest gains no permission.
35
35
 
36
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
37
 
@@ -172,6 +172,18 @@ await ads.initialize({ canRequestAds: false }); // → { status: 'blocked', re
172
172
  await ads.updatePrivacy({ canRequestAds: true }); // → { status: 'applied' }, one native init total
173
173
  ```
174
174
 
175
+ **If you use the package's consent flow, it is part of the gate too.** Once you have called `ads.consent.*`, `initialize()` waits for the flow to permit requests, exactly as `load` does:
176
+
177
+ ```ts
178
+ await ads.consent.requestInfoUpdate(); // the user declines, or the flow fails
179
+ await ads.initialize({ canRequestAds: true }); // → { status: 'blocked', reason: 'consent_flow_required' }
180
+ // no native initialization
181
+ // ...later the user consents, through either consent form:
182
+ // the package initializes the provider itself — once — and the status becomes `ready`.
183
+ ```
184
+
185
+ Before `0.4.0` this returned `ready` and initialized the provider even though `load` refused, which goes against the provider's own guidance not to initialize before consent permits requests. Calling `initialize()` *before* any `ads.consent.*` call still initializes on your own gate alone, as in `0.1.0`.
186
+
175
187
  The package logs no personal data, no consent payload, and no advertising identifier.
176
188
 
177
189
  ---
@@ -515,7 +527,54 @@ Three rules the package will not bend:
515
527
  - **The package does not decide which regions require consent.** `not_required` is the provider's answer forwarded, not this package's judgement.
516
528
  - **A consent failure never crashes your app or blocks startup.** It is a normalized error you can act on or ignore.
517
529
 
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.
530
+ The package renders **no consent UI**. The form is the provider's. Timing, surrounding copy, and where you offer a user the chance to change their mind stay yours — as does deciding whether to run the flow at all. A user declining is a normal path, not an error.
531
+
532
+ ### Letting users change their choice
533
+
534
+ **New in `0.4.0`.** GDPR requires that withdrawing consent be as easy as giving it. The provider models that as a separate capability — a *privacy options* form, and a status saying whether you must offer it — and the package now exposes both. Before `0.4.0` the only route was importing the provider, or calling `consent.reset()` and re-showing the form: a testing-only call that wipes the stored decision while the form is open.
535
+
536
+ You need two things from the package: **whether to show the entry**, and **a call that shows the form**.
537
+
538
+ ```ts
539
+ // After requestInfoUpdate(), read the provider's verdict.
540
+ const { privacyOptionsRequirement } = ads.consent.getState();
541
+ const showEntry = privacyOptionsRequirement === 'required';
542
+
543
+ // In your Settings screen, behind your own row, label and copy:
544
+ const result = await ads.consent.showPrivacyOptionsForm();
545
+ ```
546
+
547
+ `privacyOptionsRequirement` lives on the consent snapshot, so `consent.subscribe` already tells you when it changes.
548
+
549
+ | `privacyOptionsRequirement` | Meaning | Your entry |
550
+ |---|---|---|
551
+ | `required` | The provider says this user must be able to change their choice. | Show it |
552
+ | `not_required` | The provider says no entry is needed here. | Hide it |
553
+ | `unknown` | No successful `requestInfoUpdate()` yet, or after `reset()`, after a failed update, or on iOS. | Hide it, but do not treat it as `not_required` |
554
+
555
+ `showPrivacyOptionsForm()` never throws. It returns:
556
+
557
+ | Result | What happened | Presented? |
558
+ |---|---|---|
559
+ | `{ status: 'shown', consent }` | The form opened and closed. `consent` is the state it left behind, already applied to the gate. A user who backed out unchanged lands here too, with the state they had. | Yes |
560
+ | `{ status: 'not_required' }` | The requirement is not `required`. | No |
561
+ | `{ status: 'info_update_required' }` | The requirement is `unknown`; call `requestInfoUpdate()` first. | No |
562
+ | `{ status: 'busy' }` | A consent form is already on screen. | No |
563
+ | `{ status: 'failed', error }` | The form could not open, or failed while open. `error.code` is `consent_flow_failed`. | Maybe |
564
+ | `{ status: 'unavailable', reason }` | iOS (`unsupported_platform`), or after `dispose()` (`provider_missing`). | No |
565
+
566
+ **What the outcome does to the gate.** You wire nothing; it feeds the privacy gate exactly as `showFormIfRequired` does.
567
+
568
+ - **Consent withdrawn** (`canRequestAds` false): the status moves from `ready` to `blocked`, every in-flight load settles with `{ status: 'blocked', reason: 'consent_flow_required' }`, loaded-but-unshown ads are released, pending retries are cancelled, and a mounted `AdsBanner` leaves the tree. No new request is made.
569
+ - **Consent granted again**: requests resume and the status returns to `ready` **without a second native initialization** — one per instance, however many times the user changes their mind. That holds if your app re-runs `initialize()` on a consent change, too.
570
+ - **An ad already on screen is not force-closed.** The provider offers no safe way to close one.
571
+ - **Most-restrictive-wins is unchanged.** If your own gate is closed, a user re-granting consent in the form does not reopen it.
572
+
573
+ Keep in mind that a user who rejects every purpose in the form usually still leaves `canRequestAds` **true**: in the provider's terms, `obtained` means an answer was recorded, and the ads SDK applies that answer — limited or non-personalized ads — to later requests by itself. So the common outcome of the form is "the gate stays open, and later ads honour the new choice". The withdrawal path above is what happens when the provider does report `canRequestAds` false.
574
+
575
+ **A failure is still never consent.** If the form fails, the package asks the provider what it still holds — the form never completed, so the stored decision has not changed — and reports that. If the provider cannot answer either, the state becomes `error` and the gate closes. Neither path grants anything.
576
+
577
+ **What stays yours:** where the entry lives, its label, its copy, and when to offer it — for example, hiding it from a user who bought ad removal. The package never shows the form on its own.
519
578
 
520
579
  ### Package flow or your own gate?
521
580
 
@@ -627,6 +686,9 @@ Exactly one reason is reported — the most useful one for diagnosis, not the fi
627
686
  | Placement sits at `undecided` forever | Enablement never resolved from `'undecided'` |
628
687
  | Placement sits at `idle`, banner never appears | `AdsBanner` gated on its own state, or a zero-width container |
629
688
  | `load` returns `blocked` | Consent gate closed |
689
+ | `initialize` returns `blocked` · `consent_flow_required` | The consent flow has not permitted requests yet; the package initializes by itself once it does |
690
+ | Status went `ready` → `blocked` after the privacy options form | The user withdrew consent; re-granting resumes with no second initialization |
691
+ | Status went `ready` → `blocked` after a later `requestInfoUpdate()` | The re-check failed (`consent_flow_error`); a successful re-check resumes |
630
692
 
631
693
  **Changed in `0.2.0`:** on an unavailable platform with ads also disabled, the reported reason is now `unsupported_platform` where `0.1.0` reported `disabled`. The old outcome depended on which condition arrived last. No request counts change.
632
694
 
@@ -658,6 +720,22 @@ engine.emit(id, ev) you control ordering
658
720
  engine.listenerCount() must reach zero after cleanup
659
721
  ```
660
722
 
723
+ Consent is scripted the same way. The privacy options form can be held open, so `busy`, a consent update, or `dispose()` can happen while it is on screen:
724
+
725
+ ```ts
726
+ const engine = createFakeAdsEngine({
727
+ consentInfo: { status: 'obtained', canRequestAds: true, privacyOptions: 'required' },
728
+ });
729
+
730
+ engine.setPrivacyOptionsFormResult({ status: 'required', canRequestAds: false }); // withdraws
731
+ const held = engine.holdPrivacyOptionsForm(); // next form stays open
732
+ held.close({ status: 'obtained', canRequestAds: true }); // ...until you settle it
733
+ held.fail(); // or fail it
734
+ engine.failPrivacyOptionsForm(); // every later form rejects
735
+ engine.failConsentInfoRead(); // the re-read after a failure rejects too
736
+ engine.consentCalls // includes 'showPrivacyOptionsForm'
737
+ ```
738
+
661
739
  On device, verify with Google's official test ads only. `npm run android`, then exercise each `Ads · …` screen in the demo app.
662
740
 
663
741
  ---
@@ -727,6 +805,36 @@ Still to be done at that time, none of it touching the public contract: CocoaPod
727
805
 
728
806
  Semantic versioning. The event catalog, error codes, state names, and result unions are public API. New formats, new platforms, and new optional configuration arrive as additive minor releases; renaming or removing any of the above is a major.
729
807
 
808
+ ### Migrating `0.3.x` to `0.4.0`
809
+
810
+ **No existing exported union gained a member**, so no exhaustive `switch` in your code needs a new case. `0.3.0` call sites compile and behave as before, apart from the behaviour changes and fixes below.
811
+
812
+ What was added, all of it opt-in:
813
+
814
+ | Addition | Kind |
815
+ |---|---|
816
+ | `ConsentApi.showPrivacyOptionsForm()` | new method |
817
+ | `PrivacyOptionsFormResult` | new union: `shown`, `not_required`, `info_update_required`, `busy`, `failed`, `unavailable` |
818
+ | `PrivacyOptionsRequirement` | new union: `required`, `not_required`, `unknown` |
819
+ | `ConsentSnapshot.privacyOptionsRequirement` | new field |
820
+ | `FakeConsentCall` (`./testing`) | gained `{ kind: 'showPrivacyOptionsForm' }` |
821
+ | Fake engine (`./testing`) | `setPrivacyOptionsFormResult`, `failPrivacyOptionsForm`, `holdPrivacyOptionsForm`, `failConsentInfoRead`; `consentInfo` takes `privacyOptions` |
822
+
823
+ Two places need a touch only if you have them:
824
+
825
+ - A hand-written `ConsentSnapshot` object literal, typically a test mock, needs `privacyOptionsRequirement`.
826
+ - A hand-written implementation of `ConsentApi` needs `showPrivacyOptionsForm`.
827
+
828
+ No new event type was added. A consent change caused by the form arrives through `consent.subscribe`, as an `initialization` event when the status moves, and as `unavailable` for a load it cancels.
829
+
830
+ **Three behaviour changes:**
831
+
832
+ - **`initialize()` waits for the package's consent flow, once you use it.** After any `ads.consent.*` call, `initialize()` on a flow that does not permit requests (declined, not yet answered, or failed) returns `{ status: 'blocked', reason: 'consent_flow_required' | 'consent_flow_error' }` and initializes nothing natively. `0.3.0` returned `ready` and initialized the provider while `load` refused. The flow opening later initializes the provider itself, once. An app that runs update → form → `initialize()` gets `ready` exactly as before when the user consents; the difference is only for a user who did not. Apps that never call `ads.consent.*` are unaffected.
833
+ - **A consent gate that closes while the instance is `ready` now moves it to `blocked`.** `0.3.0` kept `ready` while `load` already refused, so a mounted banner went on requesting. This happens on a withdrawal through the new form, and also when a later `requestInfoUpdate()` fails. In-flight loads now settle with the block reason, loaded-but-unshown ads are released, and an ad on screen is left alone.
834
+ - **Reopening a gate on an instance that was already initialized no longer initializes the provider again.** That applies to the consent flow and to your own gate through `updatePrivacy`. `0.3.0` made a second native initialization on `ready → blocked → reopened`. One exception keeps the old path: if an init-time flag (`childDirected`, `underAgeOfConsent`, `maxAdContentRating`) changed in between, a real initialization still runs, because that is the only way the flag can apply.
835
+
836
+ **One fix that needs no action:** a `load()` in flight when ads are turned off, turned `undecided`, or the instance is disposed now **settles** — with `disabled`, `undecided`, or `unavailable` / `provider_missing` respectively. `0.3.0` released the resources but never resolved the promise, so code awaiting it waited forever; and a handle the provider returned after the disable was adopted and went on loading. Now it is disposed.
837
+
730
838
  ### Migrating `0.2.x` to `0.3.0`
731
839
 
732
840
  **One compile-time change.** If you have an exhaustive `switch` over any of these, add the new case or a `default`:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chipmobilesdk/rn-ads",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "main": "src/index.ts",
@@ -1,9 +1,9 @@
1
1
  ---
2
2
  name: sdk-rn-ads
3
- description: "Advertising via @chipmobilesdk/rn-ads: AdMob banner/interstitial/rewarded/app-open behind a provider-neutral contract, placement keys, optional consent orchestration, three-state enablement, bounded retry, per-environment test-ad substitution, per-placement request throttling. Use when adding ads to a screen, running a consent flow without importing AdMob, deferring ads until a purchase check resolves, debugging an ad that never loads or reports throttled, placing a banner inside a list or a view the user swipes between, mapping ad units per environment, or answering a Data safety question about advertising."
3
+ description: "Advertising via @chipmobilesdk/rn-ads: AdMob banner/interstitial/rewarded/app-open behind a provider-neutral contract, placement keys, optional consent orchestration incl. the privacy options (change my consent) form, three-state enablement, bounded retry, per-environment test-ad substitution, per-placement request throttling. Use when adding ads to a screen, running a consent flow without importing AdMob, adding a Privacy options entry to Settings, deferring ads until a purchase check resolves, debugging an ad that never loads or reports throttled, placing a banner inside a list or a view the user swipes between, mapping ad units per environment, or answering a Data safety question about advertising."
4
4
  metadata:
5
5
  package: "@chipmobilesdk/rn-ads"
6
- packageVersion: "0.3.0"
6
+ packageVersion: "0.4.0"
7
7
  user-invocable: true
8
8
  disable-model-invocation: false
9
9
  ---
@@ -19,6 +19,7 @@ consent.
19
19
 
20
20
  - Adding a banner, interstitial, rewarded or app-open ad to a screen
21
21
  - Running a consent flow in a region that requires one, without importing AdMob
22
+ - Letting a user change their consent later (a "Privacy options" entry in Settings)
22
23
  - Deferring ads at startup until a purchase or entitlement check answers
23
24
  - Turning ads back on after a subscription lapses or a refund lands
24
25
  - Mapping AdMob ad units to placement keys per environment
@@ -30,7 +31,7 @@ consent.
30
31
 
31
32
  - **Consent UI or copy** — the package *orchestrates* the provider's flow and normalizes
32
33
  the outcome, but the form is drawn by the provider and the legal text is theirs. Timing,
33
- surrounding copy and the "change your mind" path stay yours.
34
+ surrounding copy, and where the "change your mind" entry lives stay yours.
34
35
  - **Where or how often ads appear** — no frequency cap, no placement rule, no auto-insert.
35
36
  The package never shows an ad on its own, app-open included.
36
37
  - **Granting the reward** — it reports `reward_earned`; entitlement is the app's.
@@ -45,8 +46,21 @@ gate withheld, **zero** provider calls occur — not even configuration. `childD
45
46
  `underAgeOfConsent` and `maxAdContentRating` take effect only if they reach
46
47
  `initialize()` *before* the provider starts; changing one later returns
47
48
  `requires_reinitialization` rather than silently not applying.
49
+ Once you call `ads.consent.*`, the flow is part of that gate: `initialize()` returns
50
+ `blocked` · `consent_flow_required` until it permits, and the package initializes by
51
+ itself when it does — do not retry `initialize()` in a loop.
48
52
  See `## Initialize — consent comes first` and `## Consent`.
49
53
 
54
+ ## "Change my consent" is covered — never `reset()` + re-show
55
+
56
+ New in 0.4.0. Show your Settings entry when `consent.getState().privacyOptionsRequirement`
57
+ is `'required'` (hide it on `'unknown'` and `'not_required'`), and call
58
+ `ads.consent.showPrivacyOptionsForm()` from it. Where the entry lives, and its copy, stay
59
+ yours. The result feeds the gate by itself. A withdrawal moves `ready` → `blocked` and
60
+ cancels in-flight loads; a re-grant resumes with **no second native initialization**.
61
+ `consent.reset()` is testing-only — it wipes the stored decision while the form is open.
62
+ See `### Letting users change their choice` under `## Consent`.
63
+
50
64
  ## Three gates, one reason — check this order first
51
65
 
52
66
  A refused request reports exactly one reason, and the precedence is fixed:
@@ -114,8 +128,9 @@ This is **not** a frequency cap. It limits how often a placement may *ask*; how
114
128
 
115
129
  - **The bridge pin.** `react-native-google-mobile-ads` is peered at `>=16.0.3 <16.1.0`;
116
130
  16.1.0+ carries Kotlin 2.3 metadata that React Native 0.85's pinned compiler cannot
117
- read, and the Android build fails outright. `0.2.0` needed no bump — consent and
118
- app-open are both already in `16.0.3`. Read `## Compatibility` before widening it.
131
+ read, and the Android build fails outright. `0.2.0` and `0.4.0` needed no bump —
132
+ consent, app-open and privacy options are all already in `16.0.3`. Read
133
+ `## Compatibility` before widening it.
119
134
  - **Test ads in dev.** Outside `production`, non-official ad units are replaced with
120
135
  Google's test units and reported via `config_warning` / `getSubstitutedPlacements()`.
121
136
  That is the safety guarantee working, not a misconfiguration. Never click a production
@@ -148,7 +163,7 @@ on a return **from an ad** — or from a browser an ad opened — is refused wit
148
163
  return apart from any other; the package can. It cannot survive process death, and a
149
164
  browser opened without a preceding click is undetectable. See `## App open`.
150
165
 
151
- ## Public API (v0.3.0)
166
+ ## Public API (v0.4.0)
152
167
 
153
168
  Runtime: `createAds` (throws `AdsConfigError` on a bad catalog or an out-of-range retry
154
169
  policy; everything else returns a typed result rather than throwing) · `AdsProvider`,
@@ -168,11 +183,13 @@ Instance: `initialize`, `updatePrivacy`, `setEnabled`, `setEnablement`, `setAppA
168
183
  `getIgnoredConsentTools`, `dispose`.
169
184
 
170
185
  Consent namespace (`ads.consent.*`, opt-in — untouched, nothing starts):
171
- `requestInfoUpdate`, `showFormIfRequired`, `getState`, `subscribe`, `reset`.
186
+ `requestInfoUpdate`, `showFormIfRequired`, `showPrivacyOptionsForm`, `getState`,
187
+ `subscribe`, `reset`.
172
188
 
173
189
  Types: `AdsInstance`, `AdsConfig`, `PlacementDefinition`, `PrivacyOptions`, `AdFormat`,
174
190
  `FullScreenFormat`, `Environment`, `Enablement`, `EnablementResult`, `ConsentApi`,
175
- `ConsentState`, `ConsentSnapshot`, `ConsentTestingOptions`, `RetryPolicy`, `InitStatus`,
191
+ `ConsentState`, `ConsentSnapshot`, `ConsentTestingOptions`, `PrivacyOptionsRequirement`,
192
+ `PrivacyOptionsFormResult`, `RetryPolicy`, `InitStatus`,
176
193
  `PlacementState`, `InitResult`, `LoadResult`, `ShowResult`, `PrivacyUpdateResult`,
177
194
  `Reward`, `AdEvent`, `AdEventType`, `AdEventListener`, `ConfigWarning`, `ImpressionSource`,
178
195
  `AdsErrorCode`, `AdsOperation`, `TimeoutConfig`, `AdContentRating`, `PrivacyBlockReason`,
@@ -181,6 +198,22 @@ Types: `AdsInstance`, `AdsConfig`, `PlacementDefinition`, `PrivacyOptions`, `AdF
181
198
  Call `dispose()` when the owning screen unmounts; it is idempotent, and so is every
182
199
  `Unsubscribe`.
183
200
 
201
+ ## Upgrading 0.3.x → 0.4.0
202
+
203
+ No code change: no existing union gained a member. New: `showPrivacyOptionsForm`,
204
+ `ConsentSnapshot.privacyOptionsRequirement`, and the types above. A hand-written
205
+ `ConsentSnapshot` literal (a test mock) needs the new field.
206
+
207
+ **Three behaviour changes:** `initialize()` after a declined or failed consent flow
208
+ returns `blocked` and initializes nothing (0.3.0 returned `ready` and initialized); a
209
+ consent gate closing while `ready` now reports `blocked` and cancels in-flight loads
210
+ (0.3.0 stayed `ready` and a mounted banner kept requesting); and reopening a gate
211
+ resumes with no second native initialization (0.3.0 initialized again), unless an
212
+ init-time flag changed in between.
213
+
214
+ **Fixed:** a `load()` in flight when ads are disabled, undecided or disposed now resolves
215
+ (`disabled` / `undecided` / `unavailable`) instead of never settling.
216
+
184
217
  ## Upgrading 0.2.x → 0.3.0
185
218
 
186
219
  One compile-time change and one behaviour change.
@@ -225,6 +258,7 @@ Read one section of `node_modules/@chipmobilesdk/rn-ads/README.md`:
225
258
  | First integration | `## Install`, `## Configure` |
226
259
  | Consent ordering | `## Initialize — consent comes first` |
227
260
  | Running the consent flow | `## Consent` |
261
+ | A "Privacy options" entry, withdrawal and re-grant | `### Letting users change their choice` |
228
262
  | Rendering a banner | `## Banner` |
229
263
  | Full-screen formats | `## Interstitial and rewarded` |
230
264
  | App-open and ad-originated return | `## App open` |
@@ -151,11 +151,29 @@ export type ConsentState =
151
151
  */
152
152
  | 'error';
153
153
 
154
+ /**
155
+ * Whether the user must be offered a way to change their consent choice
156
+ * (FR-150), added in 0.4.0.
157
+ *
158
+ * The provider's verdict, forwarded — never the package's judgement about a
159
+ * region, exactly as `not_required` in `ConsentState` is not. `required` is the
160
+ * app's cue to show its own "Privacy options" entry; where that entry lives and
161
+ * what it says stay the app's.
162
+ *
163
+ * `unknown` before the first successful `requestInfoUpdate()`, after `reset()`,
164
+ * after a failed update, and on a platform with no adapter. It is not `not_required`:
165
+ * an app that hides the entry on `unknown` is right to, but it must not conclude
166
+ * the user has no right to one.
167
+ */
168
+ export type PrivacyOptionsRequirement = 'required' | 'not_required' | 'unknown';
169
+
154
170
  export interface ConsentSnapshot {
155
171
  readonly state: ConsentState;
156
172
  /** Always false when `state` is `error`. */
157
173
  readonly canRequestAds: boolean;
158
174
  readonly isFormAvailable: boolean;
175
+ /** Whether to offer a "change my choice" entry (FR-150). Added in 0.4.0. */
176
+ readonly privacyOptionsRequirement: PrivacyOptionsRequirement;
159
177
  readonly error: AdsError | null;
160
178
  }
161
179