@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 +48 -1
- package/README.md +112 -4
- package/package.json +1 -1
- package/skills/sdk-rn-ads/SKILL.md +42 -8
- package/src/config/types.ts +18 -0
- package/src/core/adsInstance.ts +240 -20
- package/src/core/consentFlow.ts +14 -1
- package/src/core/initMachine.ts +13 -0
- package/src/engine/admobEngine.ts +26 -0
- package/src/engine/port.ts +9 -0
- package/src/index.ts +103 -101
- package/src/testing/index.ts +101 -2
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.
|
|
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
|
|
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
|
|
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,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.
|
|
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"
|
|
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 —
|
|
118
|
-
app-open are
|
|
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.
|
|
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`, `
|
|
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`, `
|
|
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` |
|
package/src/config/types.ts
CHANGED
|
@@ -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
|
|