@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
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Google's official test ad units (FR-013).
|
|
3
|
+
*
|
|
4
|
+
* PRIVATE. Never exported from `src/index.ts`.
|
|
5
|
+
*
|
|
6
|
+
* FR-013 forbids the package from carrying production identifiers, credentials,
|
|
7
|
+
* or host-app rules, and explicitly permits official test identifiers provided
|
|
8
|
+
* they are documented as such — this file is that documentation.
|
|
9
|
+
*
|
|
10
|
+
* They are hard-coded rather than read from configuration on purpose: FR-011's
|
|
11
|
+
* substitution guarantee must hold even when the app's configuration is the
|
|
12
|
+
* thing that is wrong, so it cannot depend on the app supplying anything. They
|
|
13
|
+
* are also not re-exported from the provider bridge's own `TestIds`, because
|
|
14
|
+
* that would put a provider symbol on this package's dependency path for a
|
|
15
|
+
* value that is really just four documented constants (FR-002).
|
|
16
|
+
*
|
|
17
|
+
* Source: Google Mobile Ads "Test ads" documentation, cross-checked against the
|
|
18
|
+
* bridge's `src/TestIds.ts` at version 16.0.3. These identifiers are public,
|
|
19
|
+
* are the same for every publisher, and always fill.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { AdFormat } from '../config/types';
|
|
23
|
+
|
|
24
|
+
/** Android. iOS units are added alongside these when the iOS adapter lands (FR-006). */
|
|
25
|
+
const ANDROID_TEST_AD_UNITS: Readonly<Record<AdFormat, string>> = {
|
|
26
|
+
// The adaptive-banner unit, not the fixed-size one, because adaptive is the
|
|
27
|
+
// format this package actually requests (FR-038).
|
|
28
|
+
banner: 'ca-app-pub-3940256099942544/9214589741',
|
|
29
|
+
interstitial: 'ca-app-pub-3940256099942544/1033173712',
|
|
30
|
+
rewarded: 'ca-app-pub-3940256099942544/5224354917',
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export function testAdUnitFor(format: AdFormat): string {
|
|
34
|
+
return ANDROID_TEST_AD_UNITS[format];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Whether an identifier is one of ours.
|
|
39
|
+
*
|
|
40
|
+
* This is the structural test that drives substitution: anything that is not a
|
|
41
|
+
* known official test unit is treated as production-owned. Deliberately not a
|
|
42
|
+
* parse of AdMob's publisher-id format — a rule like that becomes wrong the day
|
|
43
|
+
* Google changes the format, and being wrong here means either sending a
|
|
44
|
+
* production request or blocking a legitimate one.
|
|
45
|
+
*/
|
|
46
|
+
export function isOfficialTestAdUnit(adUnitId: string): boolean {
|
|
47
|
+
for (const value of Object.values(ANDROID_TEST_AD_UNITS)) {
|
|
48
|
+
if (value === adUnitId) {
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Exposed for the package's own tests and for documentation generation. */
|
|
56
|
+
export function allTestAdUnits(): Readonly<Record<AdFormat, string>> {
|
|
57
|
+
return ANDROID_TEST_AD_UNITS;
|
|
58
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @chipmobilesdk/rn-ads — reusable advertising capability for
|
|
3
|
+
* ChipMobileSdk-family React Native apps.
|
|
4
|
+
*
|
|
5
|
+
* This is the public entrypoint (LIB-003). Modules under `config/`, `core/`,
|
|
6
|
+
* `engine/`, `react/`, and `diagnostics/` are private; import them only through
|
|
7
|
+
* this file. The fake engine lives behind `@chipmobilesdk/rn-ads/testing` so
|
|
8
|
+
* test doubles cannot reach a production bundle.
|
|
9
|
+
*
|
|
10
|
+
* Nothing exported here names Google, AdMob, or any provider type (FR-002).
|
|
11
|
+
* `src/__tests__/public-api.test.ts` asserts that mechanically.
|
|
12
|
+
*
|
|
13
|
+
* The package owns capability: a safe, consistent way to request and present
|
|
14
|
+
* ads. The consuming app owns policy — where ads appear, when, how often, under
|
|
15
|
+
* what consent, and whether they appear at all. There is no placement rule, no
|
|
16
|
+
* frequency cap, and no screen name in this package (FR-045).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
// Configuration and state
|
|
20
|
+
export type {
|
|
21
|
+
AdContentRating,
|
|
22
|
+
AdFormat,
|
|
23
|
+
AdsConfig,
|
|
24
|
+
Environment,
|
|
25
|
+
FullScreenFormat,
|
|
26
|
+
InitStatus,
|
|
27
|
+
PlacementDefinition,
|
|
28
|
+
PlacementState,
|
|
29
|
+
PrivacyBlockReason,
|
|
30
|
+
PrivacyOptions,
|
|
31
|
+
Reward,
|
|
32
|
+
TimeoutConfig,
|
|
33
|
+
UnavailableReason,
|
|
34
|
+
Unsubscribe,
|
|
35
|
+
} from './config/types';
|
|
36
|
+
export { isFullScreenFormat } from './config/types';
|
|
37
|
+
|
|
38
|
+
// Timeout policy — exported so a consumer can read the defaults it is bound by
|
|
39
|
+
// rather than discovering them from a timeout in production (FR-032).
|
|
40
|
+
export {
|
|
41
|
+
AD_VALIDITY_BOUNDS,
|
|
42
|
+
DEFAULT_AD_VALIDITY_MS,
|
|
43
|
+
DEFAULT_LOAD_TIMEOUT_MS,
|
|
44
|
+
DEFAULT_SHOW_TIMEOUT_MS,
|
|
45
|
+
LOAD_TIMEOUT_BOUNDS,
|
|
46
|
+
SHOW_TIMEOUT_BOUNDS,
|
|
47
|
+
} from './core/timers';
|
|
48
|
+
|
|
49
|
+
// Instance, lifecycle, and results
|
|
50
|
+
export { createAds } from './core/adsInstance';
|
|
51
|
+
export type {
|
|
52
|
+
AdsInstance,
|
|
53
|
+
InitResult,
|
|
54
|
+
LoadResult,
|
|
55
|
+
PrivacyUpdateResult,
|
|
56
|
+
ShowResult,
|
|
57
|
+
} from './core/adsInstance';
|
|
58
|
+
|
|
59
|
+
// React surface
|
|
60
|
+
export { AdsBanner } from './react/AdsBanner';
|
|
61
|
+
export type { AdsBannerProps } from './react/AdsBanner';
|
|
62
|
+
export { AdsProvider, useAds } from './react/AdsProvider';
|
|
63
|
+
export type { AdsProviderProps } from './react/AdsProvider';
|
|
64
|
+
export { useAdsEvents, useAdsStatus, usePlacementState } from './react/hooks';
|
|
65
|
+
|
|
66
|
+
// Diagnostics
|
|
67
|
+
export { AdsConfigError, AdsError, isAdsError } from './diagnostics/errors';
|
|
68
|
+
export type { AdsErrorCode, AdsOperation } from './diagnostics/errors';
|
|
69
|
+
export type { AdEvent, AdEventListener, AdEventType, ImpressionSource } from './diagnostics/events';
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adaptive banner host (FR-038 – FR-042).
|
|
3
|
+
*
|
|
4
|
+
* The app puts this where it wants a banner. The component never inserts itself
|
|
5
|
+
* into the tree, never overlays app content, and ships no placeholder — the
|
|
6
|
+
* surrounding layout, and any loading or fallback presentation, belong to the
|
|
7
|
+
* app and its theme (FR-040, UX-001).
|
|
8
|
+
*
|
|
9
|
+
* When ads are disabled or unavailable it renders `null` rather than a
|
|
10
|
+
* zero-height view. That is not a micro-optimization: a rendered empty view
|
|
11
|
+
* still sits in the accessibility tree and can take screen-reader focus ahead of
|
|
12
|
+
* real content. `null` leaves the tree entirely (UX-003).
|
|
13
|
+
*
|
|
14
|
+
* The provider owns the banner's whole lifecycle through its own component, so
|
|
15
|
+
* this adapts those callbacks into the normalized event model instead of routing
|
|
16
|
+
* a banner through the load/show handle model, which does not fit it.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
20
|
+
import { View, type LayoutChangeEvent, type ViewStyle } from 'react-native';
|
|
21
|
+
|
|
22
|
+
import type { PlacementState } from '../config/types';
|
|
23
|
+
import { AdsError, normalizeProviderError } from '../diagnostics/errors';
|
|
24
|
+
import { useAds } from './AdsProvider';
|
|
25
|
+
import { useAdsStatus } from './hooks';
|
|
26
|
+
|
|
27
|
+
export interface AdsBannerProps {
|
|
28
|
+
readonly placementKey: string;
|
|
29
|
+
/** Called on every state change so the app can hold or reclaim the space. */
|
|
30
|
+
readonly onStateChange?: (state: PlacementState) => void;
|
|
31
|
+
/** Applied to the container only. Never to ad content, which is not ours. */
|
|
32
|
+
readonly style?: ViewStyle;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface ProviderBannerModule {
|
|
36
|
+
BannerAd: React.ComponentType<Record<string, unknown>>;
|
|
37
|
+
BannerAdSize: Record<string, string>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function loadBannerModule(): ProviderBannerModule | null {
|
|
41
|
+
try {
|
|
42
|
+
return require('react-native-google-mobile-ads') as ProviderBannerModule;
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function AdsBanner({ placementKey, onStateChange, style }: AdsBannerProps) {
|
|
49
|
+
const ads = useAds();
|
|
50
|
+
// The reported state lives in a ref rather than in component state: the host
|
|
51
|
+
// itself renders the same tree for every state, so storing it would only
|
|
52
|
+
// force re-renders the PERF-002 budget does not need to spend. The instance
|
|
53
|
+
// holds the authoritative value; `onStateChange` tells the app.
|
|
54
|
+
const stateRef = useRef<PlacementState>('idle');
|
|
55
|
+
const [containerWidth, setContainerWidth] = useState(0);
|
|
56
|
+
// A new cycle id remounts the provider component, which is how a width change
|
|
57
|
+
// produces a genuinely re-adapted banner rather than a stretched old one.
|
|
58
|
+
const [cycle, setCycle] = useState(0);
|
|
59
|
+
const correlationRef = useRef<string | null>(null);
|
|
60
|
+
const startedAtRef = useRef(0);
|
|
61
|
+
|
|
62
|
+
const provider = useMemo(loadBannerModule, []);
|
|
63
|
+
// Initialization is asynchronous and a banner is almost always mounted before
|
|
64
|
+
// it finishes. Without this the resolution below would be computed once, while
|
|
65
|
+
// the instance was still `not_started`, be refused, and never be retried — the
|
|
66
|
+
// banner would sit at `unavailable` forever on a perfectly healthy app.
|
|
67
|
+
const status = useAdsStatus();
|
|
68
|
+
|
|
69
|
+
// Keyed on `cycle` so a genuine width change mints a fresh correlation id and
|
|
70
|
+
// starts a new, separately traceable load (FR-038, FR-052), and on `status` so
|
|
71
|
+
// the banner recovers the moment the instance becomes able to request.
|
|
72
|
+
const resolved = useMemo(
|
|
73
|
+
() =>
|
|
74
|
+
ads && status === 'ready'
|
|
75
|
+
? { cycle, value: ads.internals.resolveBanner(placementKey) }
|
|
76
|
+
: null,
|
|
77
|
+
[ads, placementKey, cycle, status],
|
|
78
|
+
);
|
|
79
|
+
const resolution = resolved?.value ?? null;
|
|
80
|
+
|
|
81
|
+
const move = useCallback(
|
|
82
|
+
(next: PlacementState) => {
|
|
83
|
+
if (stateRef.current === next) {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
stateRef.current = next;
|
|
87
|
+
ads?.internals.setPlacementState(placementKey, next);
|
|
88
|
+
onStateChange?.(next);
|
|
89
|
+
},
|
|
90
|
+
[ads, onStateChange, placementKey],
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
const usable = Boolean(ads && provider && resolution?.ok && containerWidth > 0);
|
|
94
|
+
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
if (!ads || !provider) {
|
|
97
|
+
move('unavailable');
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
// `not_started` and `initializing` are transient: the app is still on its
|
|
101
|
+
// way and the banner will resolve shortly, so report `idle` rather than
|
|
102
|
+
// `unavailable` and let the app keep its layout decision open. Every other
|
|
103
|
+
// non-ready status — disabled, blocked, failed, unsupported — means there
|
|
104
|
+
// will be no ad, and the app should reclaim the space (FR-039, FR-042).
|
|
105
|
+
if (status !== 'ready') {
|
|
106
|
+
move(status === 'not_started' || status === 'initializing' ? 'idle' : 'unavailable');
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (!resolution?.ok) {
|
|
110
|
+
move('unavailable');
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (containerWidth <= 0) {
|
|
114
|
+
// FR-039: a zero-width container is reported rather than turned into a
|
|
115
|
+
// request for an ad at a nonsensical size.
|
|
116
|
+
move('idle');
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
correlationRef.current = resolution.correlationId;
|
|
120
|
+
startedAtRef.current = Date.now();
|
|
121
|
+
move('loading');
|
|
122
|
+
ads.internals.emit({
|
|
123
|
+
type: 'load_started',
|
|
124
|
+
timestamp: Date.now(),
|
|
125
|
+
placementKey,
|
|
126
|
+
format: 'banner',
|
|
127
|
+
correlationId: resolution.correlationId,
|
|
128
|
+
});
|
|
129
|
+
// Deliberately keyed on `cycle`, not on every render: FR-041 forbids a
|
|
130
|
+
// re-render from producing a second uncontrolled request.
|
|
131
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
132
|
+
}, [ads, provider, status, resolution, containerWidth, cycle]);
|
|
133
|
+
|
|
134
|
+
const onLayout = useCallback(
|
|
135
|
+
(event: LayoutChangeEvent) => {
|
|
136
|
+
const width = Math.round(event.nativeEvent.layout.width);
|
|
137
|
+
setContainerWidth(previous => {
|
|
138
|
+
if (previous === width) {
|
|
139
|
+
return previous;
|
|
140
|
+
}
|
|
141
|
+
// FR-038: a genuine width or orientation change re-adapts. The
|
|
142
|
+
// rounding above stops sub-pixel layout jitter from doing so.
|
|
143
|
+
if (previous > 0 && width > 0) {
|
|
144
|
+
setCycle(value => value + 1);
|
|
145
|
+
}
|
|
146
|
+
return width;
|
|
147
|
+
});
|
|
148
|
+
},
|
|
149
|
+
[],
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
const emit = useCallback(
|
|
153
|
+
(event: Parameters<NonNullable<typeof ads>['internals']['emit']>[0]) => {
|
|
154
|
+
ads?.internals.emit(event);
|
|
155
|
+
},
|
|
156
|
+
[ads],
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
const handleLoaded = useCallback(() => {
|
|
160
|
+
move('ready');
|
|
161
|
+
emit({
|
|
162
|
+
type: 'loaded',
|
|
163
|
+
timestamp: Date.now(),
|
|
164
|
+
placementKey,
|
|
165
|
+
format: 'banner',
|
|
166
|
+
correlationId: correlationRef.current ?? undefined,
|
|
167
|
+
durationMs: Date.now() - startedAtRef.current,
|
|
168
|
+
});
|
|
169
|
+
}, [emit, move, placementKey]);
|
|
170
|
+
|
|
171
|
+
const handleFailed = useCallback(
|
|
172
|
+
(error: unknown) => {
|
|
173
|
+
const normalized = normalizeProviderError(error, {
|
|
174
|
+
operation: 'load',
|
|
175
|
+
placementKey,
|
|
176
|
+
format: 'banner',
|
|
177
|
+
});
|
|
178
|
+
move('failed');
|
|
179
|
+
emit({
|
|
180
|
+
type: 'load_failed',
|
|
181
|
+
timestamp: Date.now(),
|
|
182
|
+
placementKey,
|
|
183
|
+
format: 'banner',
|
|
184
|
+
correlationId: correlationRef.current ?? undefined,
|
|
185
|
+
error: normalized,
|
|
186
|
+
});
|
|
187
|
+
},
|
|
188
|
+
[emit, move, placementKey],
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
const handleImpression = useCallback(() => {
|
|
192
|
+
emit({
|
|
193
|
+
type: 'impression',
|
|
194
|
+
timestamp: Date.now(),
|
|
195
|
+
placementKey,
|
|
196
|
+
format: 'banner',
|
|
197
|
+
correlationId: correlationRef.current ?? undefined,
|
|
198
|
+
// A real provider impression, unlike the derived one full-screen
|
|
199
|
+
// formats get. The distinction is load-bearing for revenue
|
|
200
|
+
// reconciliation, so it is recorded rather than flattened (R5).
|
|
201
|
+
source: 'provider',
|
|
202
|
+
});
|
|
203
|
+
}, [emit, placementKey]);
|
|
204
|
+
|
|
205
|
+
const handleClicked = useCallback(() => {
|
|
206
|
+
emit({
|
|
207
|
+
type: 'click',
|
|
208
|
+
timestamp: Date.now(),
|
|
209
|
+
placementKey,
|
|
210
|
+
format: 'banner',
|
|
211
|
+
correlationId: correlationRef.current ?? undefined,
|
|
212
|
+
});
|
|
213
|
+
}, [emit, placementKey]);
|
|
214
|
+
|
|
215
|
+
// FR-041: nothing survives unmount. No retained native view, no dangling
|
|
216
|
+
// correlation, no state written into a disposed instance.
|
|
217
|
+
useEffect(() => {
|
|
218
|
+
return () => {
|
|
219
|
+
correlationRef.current = null;
|
|
220
|
+
};
|
|
221
|
+
}, []);
|
|
222
|
+
|
|
223
|
+
if (!ads || !provider) {
|
|
224
|
+
// FR-042, UX-003 — out of the tree entirely, blocking no touch.
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// A terminal non-ready status means there will be no ad at all, so leave the
|
|
229
|
+
// tree rather than render an empty host. `not_started` and `initializing` are
|
|
230
|
+
// excluded on purpose: the host must stay mounted through them, because it is
|
|
231
|
+
// what measures the container and starts the load once the instance is ready.
|
|
232
|
+
if (status === 'disabled' || status === 'unavailable' || status === 'failed') {
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (resolution && !resolution.ok) {
|
|
237
|
+
const refusal = resolution.result;
|
|
238
|
+
if (refusal.status === 'disabled' || refusal.status === 'unavailable') {
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const { BannerAd, BannerAdSize } = provider;
|
|
244
|
+
const adUnitId = resolution?.ok ? resolution.adUnitId : null;
|
|
245
|
+
|
|
246
|
+
return (
|
|
247
|
+
<View style={style} onLayout={onLayout} testID={`ads-banner-${placementKey}`}>
|
|
248
|
+
{usable && adUnitId ? (
|
|
249
|
+
<BannerAd
|
|
250
|
+
key={`${placementKey}-${cycle}`}
|
|
251
|
+
unitId={adUnitId}
|
|
252
|
+
size={BannerAdSize.ANCHORED_ADAPTIVE_BANNER}
|
|
253
|
+
onAdLoaded={handleLoaded}
|
|
254
|
+
onAdFailedToLoad={handleFailed}
|
|
255
|
+
onAdImpression={handleImpression}
|
|
256
|
+
onAdClicked={handleClicked}
|
|
257
|
+
/>
|
|
258
|
+
) : null}
|
|
259
|
+
</View>
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Exposed for tests that assert the failure taxonomy without a renderer. */
|
|
264
|
+
export function bannerUnavailableError(placementKey: string): AdsError {
|
|
265
|
+
return new AdsError('unsupported_platform', {
|
|
266
|
+
operation: 'load',
|
|
267
|
+
placementKey,
|
|
268
|
+
format: 'banner',
|
|
269
|
+
});
|
|
270
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Instance context (US1).
|
|
3
|
+
*
|
|
4
|
+
* Supplies one `AdsInstance` to the tree so `AdsBanner` and the hooks can reach
|
|
5
|
+
* it without every screen threading it through props.
|
|
6
|
+
*
|
|
7
|
+
* The provider does not create the instance. The app does, and passes it in —
|
|
8
|
+
* because `createAds` throws on a misconfigured catalog (FR-008, FR-012), and a
|
|
9
|
+
* throw during render is a far worse place to discover that than a throw at
|
|
10
|
+
* startup where the app can decide what to do about it.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import React, { createContext, useContext, useEffect, type ReactNode } from 'react';
|
|
14
|
+
import { AppState, type AppStateStatus } from 'react-native';
|
|
15
|
+
|
|
16
|
+
import type { AdsInstance } from '../core/adsInstance';
|
|
17
|
+
|
|
18
|
+
const AdsContext = createContext<AdsInstance | null>(null);
|
|
19
|
+
|
|
20
|
+
export interface AdsProviderProps {
|
|
21
|
+
readonly instance: AdsInstance;
|
|
22
|
+
/**
|
|
23
|
+
* Whether the provider forwards foreground/background transitions to the
|
|
24
|
+
* instance. On by default: FR-034's "never show while inactive" and
|
|
25
|
+
* "re-evaluate a stale ad on return" both depend on the instance being told.
|
|
26
|
+
* Turn it off only when the app already drives `setAppActive` itself.
|
|
27
|
+
*/
|
|
28
|
+
readonly trackAppState?: boolean;
|
|
29
|
+
readonly children: ReactNode;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function AdsProvider({ instance, trackAppState = true, children }: AdsProviderProps) {
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
if (!trackAppState) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const subscription = AppState.addEventListener('change', (status: AppStateStatus) => {
|
|
38
|
+
instance.setAppActive(status === 'active');
|
|
39
|
+
});
|
|
40
|
+
return () => {
|
|
41
|
+
subscription.remove();
|
|
42
|
+
};
|
|
43
|
+
}, [instance, trackAppState]);
|
|
44
|
+
|
|
45
|
+
return <AdsContext.Provider value={instance}>{children}</AdsContext.Provider>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Returns the instance, or null outside a provider.
|
|
50
|
+
*
|
|
51
|
+
* Null rather than a throw on purpose: a screen that renders a banner should
|
|
52
|
+
* degrade to rendering nothing when ads are not wired up, not take the screen
|
|
53
|
+
* down with it (NFR-005).
|
|
54
|
+
*/
|
|
55
|
+
export function useAds(): AdsInstance | null {
|
|
56
|
+
return useContext(AdsContext);
|
|
57
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React bindings (US1, FR-027).
|
|
3
|
+
*
|
|
4
|
+
* Each hook subscribes to exactly what it reads. `usePlacementState` subscribes
|
|
5
|
+
* to one placement rather than to the whole event stream, which is what keeps a
|
|
6
|
+
* banner from re-rendering because an unrelated interstitial was clicked —
|
|
7
|
+
* PERF-002's bounded re-render assertion depends on it.
|
|
8
|
+
*
|
|
9
|
+
* None of these hand back an ad object. A consumer queries and subscribes to
|
|
10
|
+
* state; it never holds provider-owned memory (FR-027).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { useEffect, useState } from 'react';
|
|
14
|
+
|
|
15
|
+
import type { InitStatus, PlacementState } from '../config/types';
|
|
16
|
+
import type { AdEventListener } from '../diagnostics/events';
|
|
17
|
+
import { useAds } from './AdsProvider';
|
|
18
|
+
|
|
19
|
+
/** Live state for one placement. `unavailable` outside a provider. */
|
|
20
|
+
export function usePlacementState(placementKey: string): PlacementState {
|
|
21
|
+
const ads = useAds();
|
|
22
|
+
const [state, setState] = useState<PlacementState>(() =>
|
|
23
|
+
ads ? ads.getPlacementState(placementKey) : 'unavailable',
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
if (!ads) {
|
|
28
|
+
setState('unavailable');
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
// Re-read on subscribe: the state may have moved between the initial
|
|
32
|
+
// render and this effect, and dropping that transition would leave the
|
|
33
|
+
// UI showing a state the instance has already left.
|
|
34
|
+
setState(ads.getPlacementState(placementKey));
|
|
35
|
+
return ads.subscribePlacement(placementKey, setState);
|
|
36
|
+
}, [ads, placementKey]);
|
|
37
|
+
|
|
38
|
+
return state;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Live initialization status. `unavailable` outside a provider. */
|
|
42
|
+
export function useAdsStatus(): InitStatus {
|
|
43
|
+
const ads = useAds();
|
|
44
|
+
const [status, setStatus] = useState<InitStatus>(() => (ads ? ads.getStatus() : 'unavailable'));
|
|
45
|
+
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
if (!ads) {
|
|
48
|
+
setStatus('unavailable');
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
setStatus(ads.getStatus());
|
|
52
|
+
return ads.subscribeStatus(setStatus);
|
|
53
|
+
}, [ads]);
|
|
54
|
+
|
|
55
|
+
return status;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Forwards every event to a listener without causing a render.
|
|
60
|
+
*
|
|
61
|
+
* Deliberately does not accumulate events into state: a package that kept an
|
|
62
|
+
* event log would be persisting exactly the kind of history it promises not to
|
|
63
|
+
* (FR-021), and re-rendering on every impression would defeat PERF-002. A screen
|
|
64
|
+
* that wants a visible log keeps its own.
|
|
65
|
+
*/
|
|
66
|
+
export function useAdsEvents(listener: AdEventListener): void {
|
|
67
|
+
const ads = useAds();
|
|
68
|
+
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
if (!ads) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
return ads.subscribeEvents(listener);
|
|
74
|
+
}, [ads, listener]);
|
|
75
|
+
}
|