@chipmobilesdk/rn-ads 0.1.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 +46 -0
- package/README.md +372 -0
- package/package.json +49 -0
- package/skills/sdk-rn-ads/SKILL.md +98 -0
- package/src/config/resolveAdUnit.ts +85 -0
- package/src/config/types.ts +119 -0
- package/src/config/validate.ts +112 -0
- package/src/core/adsInstance.ts +1031 -0
- package/src/core/fullScreenLock.ts +54 -0
- package/src/core/initMachine.ts +108 -0
- package/src/core/placementMachine.ts +114 -0
- package/src/core/privacyGate.ts +85 -0
- package/src/core/rewardedSession.ts +84 -0
- package/src/core/timers.ts +109 -0
- package/src/diagnostics/correlation.ts +31 -0
- package/src/diagnostics/errors.ts +210 -0
- package/src/diagnostics/events.ts +179 -0
- package/src/engine/admobEngine.ts +231 -0
- package/src/engine/port.ts +72 -0
- package/src/engine/testAdUnits.ts +58 -0
- package/src/index.ts +69 -0
- package/src/react/AdsBanner.tsx +270 -0
- package/src/react/AdsProvider.tsx +57 -0
- package/src/react/hooks.ts +75 -0
- package/src/testing/index.ts +206 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `@chipmobilesdk/rn-ads` are documented here.
|
|
4
|
+
|
|
5
|
+
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
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
|
+
|
|
9
|
+
## [0.1.0] — 2026-09-02
|
|
10
|
+
|
|
11
|
+
Initial release. No migration required.
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- **Provider-neutral public contract** for configuration, initialization, availability, load, show, events, errors, and cleanup. Consuming apps never import the Google Mobile Ads SDK; they name placement keys they chose.
|
|
16
|
+
- **Three ad formats on Android**: anchored adaptive banner (`AdsBanner`), interstitial, and rewarded.
|
|
17
|
+
- **Production-traffic guarantee.** In `development` and `test`, any ad unit that is not one of Google's official test units is replaced with one, reported as a `config_warning` naming every affected placement and surfaced through `getSubstitutedPlacements()`. `production` never substitutes. A debug build declaring `production` fails closed with `production_in_dev_build`.
|
|
18
|
+
- **Enforced consent ordering.** No provider call of any kind occurs before the app's privacy gate permits requests. Child-directed, under-age-of-consent, content-rating, and personalization options are applied before the provider initializes; changing one afterwards reports `requires_reinitialization` rather than half-applying.
|
|
19
|
+
- **Idempotent initialization.** Concurrent callers share one operation; the native SDK initializes at most once.
|
|
20
|
+
- **Single full-screen lock.** At most one interstitial or rewarded ad is ever displayed. A refused show returns `busy` immediately and is never queued.
|
|
21
|
+
- **Trustworthy rewards.** `reward_earned` is emitted only on provider confirmation, exactly once, and never inferred from dismissal. Both callback orderings produce the same outcome.
|
|
22
|
+
- **Normalized event and error model** with a per-cycle `correlationId`, stable error codes, a `retryable` flag, and redacted causes. No provider object, advertising identifier, or consent payload crosses the boundary.
|
|
23
|
+
- **Graceful degradation.** Ads disabled, an unsupported platform, a missing bridge, or a failing provider are ordinary conditions — no public operation throws.
|
|
24
|
+
- **Bounded timeouts and staleness.** Load 30s, show 10s, loaded-ad validity 60 minutes, each configurable within a documented band. Out-of-band values are a configuration error rather than silently clamped.
|
|
25
|
+
- **`./testing` entrypoint** exporting `createFakeAdsEngine`, so consumers can test their own integration with no device, network, or ad inventory.
|
|
26
|
+
|
|
27
|
+
### Deliberately not included
|
|
28
|
+
|
|
29
|
+
- **Automatic retry.** Retry pacing belongs to the app; every retry is a billable request. The contract is shaped so bounded, disableable retry can be added later without a breaking change.
|
|
30
|
+
- **A consent management platform.** The package consumes a gate outcome and enforces ordering around it. Consent UX, copy, and policy are the app's.
|
|
31
|
+
- **Frequency capping or placement rules.** The package never chooses when to interrupt a user.
|
|
32
|
+
- **Any analytics dependency.** Events are emitted; forwarding them is the app's choice.
|
|
33
|
+
|
|
34
|
+
### Known limitations
|
|
35
|
+
|
|
36
|
+
- iOS is not implemented; the package reports `unavailable` / `unsupported_platform` and does not break an iOS build.
|
|
37
|
+
- App-open, native, and rewarded-interstitial formats, and cross-network mediation, are deferred.
|
|
38
|
+
- `impression` for full-screen formats is derived from the ad opening rather than provider-reported, because the provider's full-screen event enum has no impression member. Banners carry a true provider impression. The distinction is exposed as `source: 'provider' | 'derived'`.
|
|
39
|
+
|
|
40
|
+
### Compatibility notes
|
|
41
|
+
|
|
42
|
+
- `react-native-google-mobile-ads` is pinned to **`>=16.0.3 <16.1.0`**, not an open caret. Bridge 16.1.0+ bundles Google Mobile Ads 25.x, which carries Kotlin 2.3.0 metadata that React Native 0.85's pinned Kotlin 2.1.x compiler cannot read. The closed upper bound makes that a resolution failure at install time rather than a build failure later. Revisit when React Native ships a Kotlin 2.3 compiler.
|
|
43
|
+
- Consuming apps must declare `react-native-google-mobile-ads.android_app_id` in their own `app.json`. This package carries no application ID and no ad unit ID.
|
|
44
|
+
- The provider's manifest merges `INTERNET`, `ACCESS_NETWORK_STATE`, and `WAKE_LOCK`; `com.google.android.gms.permission.AD_ID` arrives transitively from `play-services-ads`. Apps that must not access the Advertising ID remove it with `tools:node="remove"`.
|
|
45
|
+
|
|
46
|
+
[0.1.0]: https://github.com/chipmobilesdk/ChipMobileSdk/releases/tag/rn-ads%400.1.0
|
package/README.md
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
# @chipmobilesdk/rn-ads
|
|
2
|
+
|
|
3
|
+
Reusable advertising capability for ChipMobileSdk-family React Native apps.
|
|
4
|
+
|
|
5
|
+
The package owns **capability**: a safe, consistent, provider-neutral way to request and present ads. Your app owns **policy**: where ads appear, when, how often, to whom, under what consent, and whether they appear at all. There is no placement rule, no frequency cap, and no screen name in this package.
|
|
6
|
+
|
|
7
|
+
**You never import the Google Mobile Ads SDK.** You name a placement key you chose, and the package does the rest.
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## Scope
|
|
12
|
+
|
|
13
|
+
| | v1 |
|
|
14
|
+
|---|---|
|
|
15
|
+
| **Platform** | Android only. iOS returns `unavailable` / `unsupported_platform` and never breaks a build. |
|
|
16
|
+
| **Provider** | Google AdMob, via `react-native-google-mobile-ads` |
|
|
17
|
+
| **Formats** | Anchored adaptive banner, interstitial, rewarded |
|
|
18
|
+
| **Deferred** | App-open, native, rewarded-interstitial, mediation, iOS — see [Known limitations](#known-limitations) |
|
|
19
|
+
|
|
20
|
+
## Compatibility
|
|
21
|
+
|
|
22
|
+
| | Supported |
|
|
23
|
+
|---|---|
|
|
24
|
+
| React | `>=19` (verified against 19.2.3) |
|
|
25
|
+
| React Native | `>=0.85`, New Architecture (verified against 0.85.3) |
|
|
26
|
+
| Node | `>=22.11.0` |
|
|
27
|
+
| Android | `minSdk 24`, `compileSdk 36`, `targetSdk 36`, Kotlin 2.1.x |
|
|
28
|
+
| Provider bridge | `react-native-google-mobile-ads` **`>=16.0.3 <16.1.0`** |
|
|
29
|
+
| Google Mobile Ads Android SDK | 24.9.0 (transitive) |
|
|
30
|
+
|
|
31
|
+
> **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
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## Install
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
npm install @chipmobilesdk/rn-ads react-native-google-mobile-ads@16.0.3
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### Required Android configuration
|
|
42
|
+
|
|
43
|
+
Add your AdMob **application ID** to the app's `app.json`. The bridge's Gradle script reads this key and merges it into `AndroidManifest.xml`; without it the native SDK crashes on startup.
|
|
44
|
+
|
|
45
|
+
```jsonc
|
|
46
|
+
{
|
|
47
|
+
"react-native-google-mobile-ads": {
|
|
48
|
+
// Google's public test application ID. Replace with your own for release.
|
|
49
|
+
"android_app_id": "ca-app-pub-3940256099942544~3347511713"
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
**The shared package carries no application ID and no ad unit ID.** Both are per-app configuration.
|
|
55
|
+
|
|
56
|
+
### Permissions merged into your manifest
|
|
57
|
+
|
|
58
|
+
Contributed by the provider, not requested by this package:
|
|
59
|
+
|
|
60
|
+
Verified against a real merged manifest, not a list from documentation:
|
|
61
|
+
|
|
62
|
+
| Permission | Source |
|
|
63
|
+
|---|---|
|
|
64
|
+
| `android.permission.INTERNET` | bridge manifest |
|
|
65
|
+
| `android.permission.ACCESS_NETWORK_STATE` | bridge manifest |
|
|
66
|
+
| `android.permission.WAKE_LOCK` | bridge manifest |
|
|
67
|
+
| `com.google.android.gms.permission.AD_ID` | `play-services-ads-api` |
|
|
68
|
+
| `android.permission.ACCESS_ADSERVICES_AD_ID` | `play-services-ads-api` |
|
|
69
|
+
| `android.permission.ACCESS_ADSERVICES_ATTRIBUTION` | `play-services-ads-api` |
|
|
70
|
+
| `android.permission.ACCESS_ADSERVICES_TOPICS` | `play-services-ads-api` |
|
|
71
|
+
|
|
72
|
+
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
|
+
|
|
74
|
+
Verify the actual merged result before release — read the file, don't trust this table:
|
|
75
|
+
|
|
76
|
+
```sh
|
|
77
|
+
cd android && ./gradlew :app:assembleDebug
|
|
78
|
+
# then inspect android/app/build/intermediates/merged_manifests/**/AndroidManifest.xml
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
**To remove advertising identifier access** (required for child-directed apps under Play Families policy).
|
|
82
|
+
|
|
83
|
+
Removing `AD_ID` alone is **not enough** — verified by rebuild, the three Privacy Sandbox permissions survive it and must be removed explicitly:
|
|
84
|
+
|
|
85
|
+
```xml
|
|
86
|
+
<uses-permission android:name="com.google.android.gms.permission.AD_ID" tools:node="remove" />
|
|
87
|
+
<uses-permission android:name="android.permission.ACCESS_ADSERVICES_AD_ID" tools:node="remove" />
|
|
88
|
+
<uses-permission android:name="android.permission.ACCESS_ADSERVICES_ATTRIBUTION" tools:node="remove" />
|
|
89
|
+
<uses-permission android:name="android.permission.ACCESS_ADSERVICES_TOPICS" tools:node="remove" />
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Your `<manifest>` tag needs `xmlns:tools="http://schemas.android.com/tools"`. Rebuild and re-inspect the merged manifest to confirm — do not assume.
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
## Configure
|
|
97
|
+
|
|
98
|
+
Placement keys are **yours**. Provider ad unit IDs appear here in configuration and nowhere else in your code.
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
import { createAds } from '@chipmobilesdk/rn-ads';
|
|
102
|
+
|
|
103
|
+
const ads = createAds({
|
|
104
|
+
enabled: true, // your feature flag; false makes everything a safe no-op
|
|
105
|
+
environment: 'development', // 'development' | 'test' | 'production'
|
|
106
|
+
placements: [
|
|
107
|
+
{ key: 'home_footer', format: 'banner', androidAdUnitId: 'ca-app-pub-…/…' },
|
|
108
|
+
{ key: 'level_break', format: 'interstitial', androidAdUnitId: 'ca-app-pub-…/…' },
|
|
109
|
+
{ key: 'completion_reward', format: 'rewarded', androidAdUnitId: 'ca-app-pub-…/…' },
|
|
110
|
+
],
|
|
111
|
+
});
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
`createAds` **throws** on a malformed catalog — duplicate key, missing ad unit, unknown format, out-of-band timeout — naming the offending placement. Every other failure in the package is a typed result, never a throw.
|
|
115
|
+
|
|
116
|
+
Reading configuration requires no dotenv library, no remote-config service, and no feature-flag system. Supply the values however you like.
|
|
117
|
+
|
|
118
|
+
### How production traffic is made impossible
|
|
119
|
+
|
|
120
|
+
In `development` and `test`, any ad unit that is not one of Google's official test units is **replaced** with one, and the substitution is reported — as a `config_warning` event naming every affected placement, and in observable state via `ads.getSubstitutedPlacements()`.
|
|
121
|
+
|
|
122
|
+
```
|
|
123
|
+
environment: development
|
|
124
|
+
home_footer -> ca-app-pub-REAL…/1234567890
|
|
125
|
+
|
|
126
|
+
⚠ config_warning: test_ad_unit_substituted
|
|
127
|
+
placementKeys: ['home_footer']
|
|
128
|
+
→ request sent with ca-app-pub-3940256099942544/9214589741
|
|
129
|
+
→ your app keeps working
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
In `production`, nothing is substituted — your units are used exactly as configured.
|
|
133
|
+
|
|
134
|
+
The one case substitution cannot cover is a **debug build declaring `production`**: that would send real requests from a build that should never generate traffic. It fails closed with `production_in_dev_build`.
|
|
135
|
+
|
|
136
|
+
> Invalid traffic from non-production builds can get an AdMob account suspended. **Never click a production ad to verify anything.**
|
|
137
|
+
|
|
138
|
+
---
|
|
139
|
+
|
|
140
|
+
## Initialize — consent comes first
|
|
141
|
+
|
|
142
|
+
The package will not initialize anything request-capable until your privacy gate permits it. This is enforced, not documented: with the gate withheld, **zero** provider calls occur.
|
|
143
|
+
|
|
144
|
+
```tsx
|
|
145
|
+
import { AdsProvider } from '@chipmobilesdk/rn-ads';
|
|
146
|
+
|
|
147
|
+
await ads.initialize({
|
|
148
|
+
canRequestAds: true, // your consent decision — the package renders no consent UI
|
|
149
|
+
consentUndetermined: false, // true means "not asked yet", distinct from "refused"
|
|
150
|
+
childDirected: false,
|
|
151
|
+
underAgeOfConsent: false,
|
|
152
|
+
maxAdContentRating: 'G',
|
|
153
|
+
personalizedAds: true,
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
<AdsProvider instance={ads}>{children}</AdsProvider>
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
**Ordering matters.** `childDirected`, `underAgeOfConsent`, and `maxAdContentRating` only take effect if they reach the provider *before* it initializes. Change one afterwards and `updatePrivacy` returns `requires_reinitialization` rather than pretending it applied.
|
|
160
|
+
|
|
161
|
+
Resolving consent after being blocked is the normal path and creates **no** duplicate initialization:
|
|
162
|
+
|
|
163
|
+
```ts
|
|
164
|
+
await ads.initialize({ canRequestAds: false }); // → { status: 'blocked', reason: 'consent_required' }
|
|
165
|
+
await ads.updatePrivacy({ canRequestAds: true }); // → { status: 'applied' }, one native init total
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
The package logs no personal data, no consent payload, and no advertising identifier.
|
|
169
|
+
|
|
170
|
+
---
|
|
171
|
+
|
|
172
|
+
## Banner
|
|
173
|
+
|
|
174
|
+
You place it. It sizes itself to your container and re-adapts on rotation.
|
|
175
|
+
|
|
176
|
+
```tsx
|
|
177
|
+
import { AdsBanner, usePlacementState } from '@chipmobilesdk/rn-ads';
|
|
178
|
+
|
|
179
|
+
function Footer() {
|
|
180
|
+
const state = usePlacementState('home_footer');
|
|
181
|
+
const reserve = state === 'loading' || state === 'ready';
|
|
182
|
+
|
|
183
|
+
return reserve ? (
|
|
184
|
+
<View style={styles.slot}>
|
|
185
|
+
{state === 'loading' ? <YourThemedPlaceholder /> : null}
|
|
186
|
+
<AdsBanner placementKey="home_footer" />
|
|
187
|
+
</View>
|
|
188
|
+
) : null; // your decision to reclaim the space
|
|
189
|
+
}
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
The component **ships no placeholder** — that is yours, so it matches your theme. When ads are disabled or unavailable it renders `null`, not an empty view, so it leaves the accessibility tree entirely rather than taking screen-reader focus ahead of your content.
|
|
193
|
+
|
|
194
|
+
## Interstitial and rewarded
|
|
195
|
+
|
|
196
|
+
Load ahead of time; show only when *you* decide.
|
|
197
|
+
|
|
198
|
+
```ts
|
|
199
|
+
await ads.load('level_break');
|
|
200
|
+
const result = await ads.show('level_break');
|
|
201
|
+
|
|
202
|
+
switch (result.status) {
|
|
203
|
+
case 'shown': break;
|
|
204
|
+
case 'not_ready': break; // no UI opened, nothing crashed
|
|
205
|
+
case 'busy': break; // another full-screen ad is up — never queued
|
|
206
|
+
case 'consumed': break; // load a fresh one first
|
|
207
|
+
case 'app_inactive': break; // backgrounded
|
|
208
|
+
case 'blocked': break; // result.reason
|
|
209
|
+
case 'disabled': break;
|
|
210
|
+
case 'unavailable': break; // result.reason
|
|
211
|
+
case 'timeout': break;
|
|
212
|
+
case 'failed': break; // result.error
|
|
213
|
+
}
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
Rewards are reported **only** when the provider confirms them:
|
|
217
|
+
|
|
218
|
+
```ts
|
|
219
|
+
ads.subscribeEvents(event => {
|
|
220
|
+
if (event.type === 'reward_earned') {
|
|
221
|
+
grant(event.reward.type, event.reward.amount); // your grant, not the package's
|
|
222
|
+
}
|
|
223
|
+
if (event.type === 'dismissed' && event.rewardEarned === false) {
|
|
224
|
+
// closed early — no reward, and none inferred
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
`reward_earned` and `dismissed` may arrive from the provider in either order; the outcome is identical, and a duplicate confirmation is collapsed into one event. **The package grants nothing** — validating and delivering the benefit is yours.
|
|
230
|
+
|
|
231
|
+
---
|
|
232
|
+
|
|
233
|
+
## State, events, and errors
|
|
234
|
+
|
|
235
|
+
**Initialization status**: `not_started` · `blocked` · `initializing` · `ready` · `disabled` · `unavailable` · `failed`
|
|
236
|
+
|
|
237
|
+
**Placement state**: `idle` · `loading` · `ready` · `showing` · `consumed` · `failed` · `unavailable`
|
|
238
|
+
|
|
239
|
+
**Events** carry the placement key, format, timestamp, and a `correlationId` tying one load/show cycle together — enough to reconstruct an ad's whole life from the stream alone. They carry no provider object, no advertising identifier, and no consent payload.
|
|
240
|
+
|
|
241
|
+
`initialization` · `load_started` · `loaded` · `load_failed` · `display_started` · `impression` · `click` · `display_failed` · `dismissed` · `reward_earned` · `disabled` · `unavailable` · `config_warning` · `internal_error`
|
|
242
|
+
|
|
243
|
+
> **One honest asymmetry.** `impression` carries `source: 'provider' | 'derived'`. Banners get a real provider impression callback. Full-screen formats have none in the provider's event enum, so theirs is derived from the ad opening. If you reconcile these events against AdMob revenue reporting, you need to know which is which.
|
|
244
|
+
|
|
245
|
+
**Ordering and exactly-once** are documented and tested. Within one `correlationId`, `loaded` / `display_started` / `impression` / `dismissed` / `reward_earned` occur at most once; `click` may repeat. After `dispose()`, zero events are emitted — a late provider callback is dropped.
|
|
246
|
+
|
|
247
|
+
**Errors** carry a stable `code`, a log-safe `message`, the `operation`, a `retryable` flag, and a redacted `cause`. Codes are public API; renaming one is a breaking change.
|
|
248
|
+
|
|
249
|
+
Retryable: `no_fill` `network_error` `load_timeout` `show_timeout` `ad_expired` `not_ready` `busy` `consumed` `app_inactive` `provider_error`
|
|
250
|
+
Not retryable: `invalid_config` `duplicate_placement_key` `missing_ad_unit_id` `format_mismatch` `placement_not_found` `production_in_dev_build` `timeout_out_of_bounds` `unsupported_platform` `provider_missing` `disabled` `consent_required` `consent_undetermined` `requires_reinitialization` `internal_error`
|
|
251
|
+
|
|
252
|
+
Attach any logger or analytics you already use. The package depends on none:
|
|
253
|
+
|
|
254
|
+
```ts
|
|
255
|
+
const unsubscribe = ads.subscribeEvents(event => yourLogger.track(event.type, event));
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
A listener that throws is caught and reported once; it cannot break the state machine or starve other listeners.
|
|
259
|
+
|
|
260
|
+
### Timeouts, retry, and cleanup
|
|
261
|
+
|
|
262
|
+
| | Default | Configurable range |
|
|
263
|
+
|---|---|---|
|
|
264
|
+
| Load timeout | 30 000 ms | 5 000 – 120 000 |
|
|
265
|
+
| Show timeout | 10 000 ms | 5 000 – 30 000 |
|
|
266
|
+
| Loaded-ad validity | 3 600 000 ms | 60 000 – 3 600 000 |
|
|
267
|
+
|
|
268
|
+
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
|
+
|
|
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.
|
|
271
|
+
|
|
272
|
+
`dispose()` cancels every timer and subscription and releases every handle. It is idempotent, and so is every `Unsubscribe`.
|
|
273
|
+
|
|
274
|
+
### Turning ads off
|
|
275
|
+
|
|
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.
|
|
277
|
+
|
|
278
|
+
---
|
|
279
|
+
|
|
280
|
+
## Testing
|
|
281
|
+
|
|
282
|
+
The whole suite runs with **no device, no network, and no ad inventory**.
|
|
283
|
+
|
|
284
|
+
```ts
|
|
285
|
+
import { createFakeAdsEngine } from '@chipmobilesdk/rn-ads/testing';
|
|
286
|
+
|
|
287
|
+
const engine = createFakeAdsEngine();
|
|
288
|
+
const ads = createAds(config, { engine, isDevBuild: true });
|
|
289
|
+
|
|
290
|
+
await ads.initialize({ canRequestAds: true });
|
|
291
|
+
await ads.load('level_break');
|
|
292
|
+
|
|
293
|
+
expect(engine.adUnitIds()).toEqual([/* only official test units */]);
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
The fake records every call, lets you resolve or reject each load and show, and lets you emit provider callbacks in **any order** — which is how the reward-ordering and late-callback cases are tested.
|
|
297
|
+
|
|
298
|
+
```
|
|
299
|
+
engine.calls every call the package made, in order
|
|
300
|
+
engine.adUnitIds() what actually reached the provider
|
|
301
|
+
engine.liveHandles() non-empty after dispose means a leak
|
|
302
|
+
engine.emit(id, ev) you control ordering
|
|
303
|
+
engine.listenerCount() must reach zero after cleanup
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
On device, verify with Google's official test ads only. `npm run android`, then exercise each `Ads · …` screen in the demo app.
|
|
307
|
+
|
|
308
|
+
---
|
|
309
|
+
|
|
310
|
+
## Release checklists
|
|
311
|
+
|
|
312
|
+
### Before you ship
|
|
313
|
+
|
|
314
|
+
- [ ] `npm run validate:ads` is green (lint, test, typecheck, pack dry-run)
|
|
315
|
+
- [ ] `npm run android` and `npm run build:apk` both succeed
|
|
316
|
+
- [ ] Merged manifest inspected; permissions match the table above and nothing else is new
|
|
317
|
+
- [ ] Zero production ad requests observed from a `development` build — captured from logcat as release evidence
|
|
318
|
+
- [ ] `environment: 'production'` is set only in release builds
|
|
319
|
+
- [ ] No production ad unit, credential, or secret in this package
|
|
320
|
+
|
|
321
|
+
### AdMob Console (cannot be done in code)
|
|
322
|
+
|
|
323
|
+
- [ ] Android app registered under the correct package / application ID
|
|
324
|
+
- [ ] A production ad unit created per placement, in the matching format
|
|
325
|
+
- [ ] Account status and payment details verified
|
|
326
|
+
- [ ] `app-ads.txt` published if you use it
|
|
327
|
+
- [ ] Consent/privacy messaging configured if you use it
|
|
328
|
+
- [ ] Content rating (`maxAdContentRating`) reviewed — ad content affects your app's age rating
|
|
329
|
+
- [ ] Policy, traffic, and serving restrictions checked
|
|
330
|
+
- [ ] **No self-clicking, ever**
|
|
331
|
+
|
|
332
|
+
### Google Play Console
|
|
333
|
+
|
|
334
|
+
- [ ] App declared as containing ads
|
|
335
|
+
- [ ] **Data safety** updated: the Google Mobile Ads SDK collects the Advertising ID, coarse device and app information, and ad interaction signals on your behalf — this package collects and stores nothing of its own
|
|
336
|
+
- [ ] Privacy policy covers advertising data
|
|
337
|
+
- [ ] Target audience and content completed
|
|
338
|
+
- [ ] Content rating questionnaire reflects ad content
|
|
339
|
+
- [ ] Families requirements met if applicable — see below
|
|
340
|
+
- [ ] Advertising ID declaration matches your target SDK and actual behavior
|
|
341
|
+
- [ ] Regional consent requirements satisfied
|
|
342
|
+
|
|
343
|
+
> **Children and families.** Ads plus advertising identifiers in an app that may reach children is a high-risk combination. This package supports `childDirected` and `underAgeOfConsent` as enforced pre-initialization inputs and documents the `AD_ID` removal path — but **it does not and cannot determine a user's age.** That determination, and the policy built on it, are yours.
|
|
344
|
+
|
|
345
|
+
---
|
|
346
|
+
|
|
347
|
+
## Known limitations
|
|
348
|
+
|
|
349
|
+
- **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.
|
|
351
|
+
- **Mediation across multiple ad networks is out of scope.**
|
|
352
|
+
- **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
|
+
- **`impression` for full-screen formats is derived**, not provider-reported. See the asymmetry note above.
|
|
354
|
+
- **Bridge versions 16.1.0+ are unusable** under React Native 0.85. See [Compatibility](#compatibility).
|
|
355
|
+
|
|
356
|
+
## Extending to iOS
|
|
357
|
+
|
|
358
|
+
Adding iOS contributes exactly three things and changes **nothing** you have already written:
|
|
359
|
+
|
|
360
|
+
1. **`PlacementDefinition.iosAdUnitId`** — the optional field already exists in v1's type, so existing Android-only catalogs stay valid.
|
|
361
|
+
2. **A second engine adapter** behind the existing `AdsEngine` port. The port names no platform and no provider; that is what makes this additive.
|
|
362
|
+
3. **iOS entries in the private test-ad-unit table.**
|
|
363
|
+
|
|
364
|
+
Your `createAds`, `initialize`, `load`, `show`, `AdsBanner`, and every subscription keep their current signatures. The exported state, event, and error models do not change.
|
|
365
|
+
|
|
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.
|
|
367
|
+
|
|
368
|
+
## Versioning
|
|
369
|
+
|
|
370
|
+
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
|
+
|
|
372
|
+
`0.1.0` is the initial release. No migration.
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@chipmobilesdk/rn-ads",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"types": "src/index.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./src/index.ts",
|
|
11
|
+
"react-native": "./src/index.ts",
|
|
12
|
+
"default": "./src/index.ts"
|
|
13
|
+
},
|
|
14
|
+
"./testing": {
|
|
15
|
+
"types": "./src/testing/index.ts",
|
|
16
|
+
"react-native": "./src/testing/index.ts",
|
|
17
|
+
"default": "./src/testing/index.ts"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"src/index.ts",
|
|
22
|
+
"src/config",
|
|
23
|
+
"src/core",
|
|
24
|
+
"src/diagnostics",
|
|
25
|
+
"src/engine",
|
|
26
|
+
"src/react",
|
|
27
|
+
"src/testing",
|
|
28
|
+
"skills",
|
|
29
|
+
"README.md",
|
|
30
|
+
"CHANGELOG.md"
|
|
31
|
+
],
|
|
32
|
+
"sideEffects": false,
|
|
33
|
+
"scripts": {
|
|
34
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
35
|
+
"pack:dry-run": "npm pack --dry-run"
|
|
36
|
+
},
|
|
37
|
+
"peerDependencies": {
|
|
38
|
+
"react": ">=19",
|
|
39
|
+
"react-native": ">=0.85",
|
|
40
|
+
"react-native-google-mobile-ads": ">=16.0.3 <16.1.0"
|
|
41
|
+
},
|
|
42
|
+
"engines": {
|
|
43
|
+
"node": ">=22.11.0"
|
|
44
|
+
},
|
|
45
|
+
"publishConfig": {
|
|
46
|
+
"access": "public",
|
|
47
|
+
"registry": "https://registry.npmjs.org/"
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: sdk-rn-ads
|
|
3
|
+
description: "Advertising via @chipmobilesdk/rn-ads: AdMob banner/interstitial/rewarded behind a provider-neutral contract, placement keys, consent gating, per-environment test-ad substitution. Use when adding ads to a screen, debugging an ad that never loads, mapping ad units per environment, or answering a Data safety question about advertising."
|
|
4
|
+
metadata:
|
|
5
|
+
package: "@chipmobilesdk/rn-ads"
|
|
6
|
+
packageVersion: "0.1.0"
|
|
7
|
+
user-invocable: true
|
|
8
|
+
disable-model-invocation: false
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# @chipmobilesdk/rn-ads
|
|
12
|
+
|
|
13
|
+
Adaptive banner, interstitial and rewarded ads on Android via Google AdMob, behind a
|
|
14
|
+
contract that names no provider. The app names a placement key; the package owns the
|
|
15
|
+
request. Consuming code never imports the Google Mobile Ads SDK.
|
|
16
|
+
|
|
17
|
+
## Use this when
|
|
18
|
+
|
|
19
|
+
- Adding a banner, interstitial or rewarded ad to a screen
|
|
20
|
+
- Mapping AdMob ad units to placement keys per environment
|
|
21
|
+
- Debugging an ad that never loads, or a `blocked` / `unavailable` / `provider_error`
|
|
22
|
+
- Wiring consent so requests cannot precede it
|
|
23
|
+
- Answering a Data safety / App Privacy question about advertising
|
|
24
|
+
|
|
25
|
+
## Do not use this for
|
|
26
|
+
|
|
27
|
+
- **Consent UI or copy** — the package consumes a gate outcome and enforces ordering; it
|
|
28
|
+
renders no dialog and ships no legal text.
|
|
29
|
+
- **Where or how often ads appear** — no frequency cap, no placement rule, no auto-insert.
|
|
30
|
+
- **Granting the reward** — it reports `reward_earned`; entitlement is the app's.
|
|
31
|
+
- iOS — not implemented; every call returns `unavailable` / `unsupported_platform`.
|
|
32
|
+
|
|
33
|
+
## Nothing loads until consent and initialization complete
|
|
34
|
+
|
|
35
|
+
This is enforced, and it is the answer to almost every "the ad never appears". With the
|
|
36
|
+
gate withheld, **zero** provider calls occur — not even configuration. `childDirected`,
|
|
37
|
+
`underAgeOfConsent` and `maxAdContentRating` take effect only if they reach
|
|
38
|
+
`initialize()` *before* the provider starts; changing one later returns
|
|
39
|
+
`requires_reinitialization` rather than silently not applying.
|
|
40
|
+
See `## Initialize — consent comes first`.
|
|
41
|
+
|
|
42
|
+
## Never gate `<AdsBanner>` on its own placement state
|
|
43
|
+
|
|
44
|
+
`AdsBanner` is what measures the container and starts the load, so it must be mounted
|
|
45
|
+
unconditionally. Rendering it only when the state is `loading`/`ready` deadlocks at
|
|
46
|
+
`idle` and no ad ever appears. Use the state for the *surrounding* placeholder and to
|
|
47
|
+
decide whether to hold or reclaim space — never to decide whether to mount the host. It
|
|
48
|
+
renders `null` on its own when there is nothing to show.
|
|
49
|
+
|
|
50
|
+
## Two things not to "fix"
|
|
51
|
+
|
|
52
|
+
- **The bridge pin.** `react-native-google-mobile-ads` is peered at `>=16.0.3 <16.1.0`;
|
|
53
|
+
16.1.0+ carries Kotlin 2.3 metadata that React Native 0.85's pinned compiler cannot
|
|
54
|
+
read, and the Android build fails outright. Read `## Compatibility` before widening it.
|
|
55
|
+
- **Test ads in dev.** Outside `production`, non-official ad units are replaced with
|
|
56
|
+
Google's test units and reported via `config_warning` / `getSubstitutedPlacements()`.
|
|
57
|
+
That is the safety guarantee working, not a misconfiguration. Never click a production
|
|
58
|
+
ad to verify anything.
|
|
59
|
+
|
|
60
|
+
## Public API (v0.1.0)
|
|
61
|
+
|
|
62
|
+
Runtime: `createAds` (throws `AdsConfigError` on a bad catalog; everything else returns a
|
|
63
|
+
typed result rather than throwing) · `AdsProvider`, `useAds` · `AdsBanner` ·
|
|
64
|
+
`usePlacementState`, `useAdsStatus`, `useAdsEvents` · `AdsError`, `AdsConfigError`,
|
|
65
|
+
`isAdsError`, `isFullScreenFormat` · `DEFAULT_LOAD_TIMEOUT_MS`, `DEFAULT_SHOW_TIMEOUT_MS`,
|
|
66
|
+
`DEFAULT_AD_VALIDITY_MS`, `LOAD_TIMEOUT_BOUNDS`, `SHOW_TIMEOUT_BOUNDS`,
|
|
67
|
+
`AD_VALIDITY_BOUNDS` · `createFakeAdsEngine` from `@chipmobilesdk/rn-ads/testing`
|
|
68
|
+
(test doubles only).
|
|
69
|
+
|
|
70
|
+
Instance: `initialize`, `updatePrivacy`, `setEnabled`, `setAppActive`, `getStatus`,
|
|
71
|
+
`subscribeStatus`, `load`, `show`, `getPlacementState`, `subscribePlacement`,
|
|
72
|
+
`subscribeEvents`, `getSubstitutedPlacements`, `dispose`.
|
|
73
|
+
|
|
74
|
+
Types: `AdsInstance`, `AdsConfig`, `PlacementDefinition`, `PrivacyOptions`, `AdFormat`,
|
|
75
|
+
`FullScreenFormat`, `Environment`, `InitStatus`, `PlacementState`, `InitResult`,
|
|
76
|
+
`LoadResult`, `ShowResult`, `PrivacyUpdateResult`, `Reward`, `AdEvent`, `AdEventType`,
|
|
77
|
+
`AdEventListener`, `ImpressionSource`, `AdsErrorCode`, `AdsOperation`, `TimeoutConfig`,
|
|
78
|
+
`AdContentRating`, `PrivacyBlockReason`, `UnavailableReason`, `Unsubscribe`,
|
|
79
|
+
`AdsBannerProps`, `AdsProviderProps`.
|
|
80
|
+
|
|
81
|
+
Call `dispose()` when the owning screen unmounts; it is idempotent, and so is every
|
|
82
|
+
`Unsubscribe`.
|
|
83
|
+
|
|
84
|
+
## Where the detail lives
|
|
85
|
+
|
|
86
|
+
Read one section of `node_modules/@chipmobilesdk/rn-ads/README.md`:
|
|
87
|
+
|
|
88
|
+
| Need | Section |
|
|
89
|
+
|---|---|
|
|
90
|
+
| First integration | `## Install`, `## Configure` |
|
|
91
|
+
| Consent ordering | `## Initialize — consent comes first` |
|
|
92
|
+
| Rendering a banner | `## Banner` |
|
|
93
|
+
| Full-screen formats | `## Interstitial and rewarded` |
|
|
94
|
+
| Results, events, errors, timeouts, cleanup | `## State, events, and errors` |
|
|
95
|
+
| Tests without a device | `## Testing` |
|
|
96
|
+
| Permissions, Data safety, families | `## Install`, `## Release checklists` |
|
|
97
|
+
| Build failure after a bridge upgrade | `## Compatibility` |
|
|
98
|
+
| What is deferred | `## Known limitations`, `## Extending to iOS` |
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The production-traffic guarantee (FR-011, FR-011a).
|
|
3
|
+
*
|
|
4
|
+
* A pure function, deliberately. This is the single point where a production ad
|
|
5
|
+
* unit can be replaced by an official test one, which is what makes SC-002
|
|
6
|
+
* provable rather than merely intended: a guarantee enforced in one place can be
|
|
7
|
+
* asserted; one spread across three call sites is a hope.
|
|
8
|
+
*
|
|
9
|
+
* The rule, from the 2026-09-02 clarification session:
|
|
10
|
+
*
|
|
11
|
+
* production -> use what the app configured, always, no exceptions
|
|
12
|
+
* development / test -> if it is not one of our own official test units,
|
|
13
|
+
* replace it and say so, loudly, naming the key
|
|
14
|
+
*
|
|
15
|
+
* Substitution rather than rejection is a deliberate trade. Rejecting would
|
|
16
|
+
* block a developer whose only mistake was pulling a teammate's environment
|
|
17
|
+
* file, and turn a configuration smell into a broken integration at every
|
|
18
|
+
* startup. Substituting keeps the app working — which is what a non-production
|
|
19
|
+
* build is for — and reports the problem as a first-class signal. The accepted
|
|
20
|
+
* cost is that a warning can be ignored; it is bounded because production never
|
|
21
|
+
* substitutes, so the misconfiguration surfaces there as ordinary behavior
|
|
22
|
+
* rather than as hidden state.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import type { Environment, PlacementDefinition } from './types';
|
|
26
|
+
import { isOfficialTestAdUnit, testAdUnitFor } from '../engine/testAdUnits';
|
|
27
|
+
|
|
28
|
+
export interface ResolvedAdUnit {
|
|
29
|
+
readonly effectiveAdUnitId: string;
|
|
30
|
+
/** True when the configured identifier was replaced. Drives FR-011a's warning. */
|
|
31
|
+
readonly substituted: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function resolveAdUnit(
|
|
35
|
+
placement: PlacementDefinition,
|
|
36
|
+
environment: Environment,
|
|
37
|
+
): ResolvedAdUnit {
|
|
38
|
+
const configured = placement.androidAdUnitId;
|
|
39
|
+
|
|
40
|
+
// Production is the one environment where the package must not second-guess
|
|
41
|
+
// the app. Substituting here would silently rewrite configuration that was
|
|
42
|
+
// asked for on purpose, and would cost the publisher live revenue.
|
|
43
|
+
if (environment === 'production') {
|
|
44
|
+
return { effectiveAdUnitId: configured, substituted: false };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Structural detection: anything that is not a known official test unit is
|
|
48
|
+
// treated as production-owned. Not a parse of AdMob's publisher-id format —
|
|
49
|
+
// see the note in engine/testAdUnits.ts on why that rule would be a liability.
|
|
50
|
+
if (isOfficialTestAdUnit(configured)) {
|
|
51
|
+
return { effectiveAdUnitId: configured, substituted: false };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return { effectiveAdUnitId: testAdUnitFor(placement.format), substituted: true };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface ResolvedCatalog {
|
|
58
|
+
/** Placement key to the ad unit that will actually be requested. */
|
|
59
|
+
readonly byKey: ReadonlyMap<string, string>;
|
|
60
|
+
/** Every key whose identifier was replaced, in catalog order (FR-011a). */
|
|
61
|
+
readonly substitutedKeys: readonly string[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Resolves a whole catalog at once, so the warning can name every affected
|
|
66
|
+
* placement in a single event rather than trickling one out per load — an
|
|
67
|
+
* integrator wants to fix the whole file, not discover it a placement at a time.
|
|
68
|
+
*/
|
|
69
|
+
export function resolvePlacementAdUnits(
|
|
70
|
+
placements: readonly PlacementDefinition[],
|
|
71
|
+
environment: Environment,
|
|
72
|
+
): ResolvedCatalog {
|
|
73
|
+
const byKey = new Map<string, string>();
|
|
74
|
+
const substitutedKeys: string[] = [];
|
|
75
|
+
|
|
76
|
+
for (const placement of placements) {
|
|
77
|
+
const resolved = resolveAdUnit(placement, environment);
|
|
78
|
+
byKey.set(placement.key, resolved.effectiveAdUnitId);
|
|
79
|
+
if (resolved.substituted) {
|
|
80
|
+
substitutedKeys.push(placement.key);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return { byKey, substitutedKeys };
|
|
85
|
+
}
|