@chipmobilesdk/rn-ads 0.5.1 → 0.7.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 +178 -0
- package/README.md +12 -3
- package/package.json +5 -2
- package/skills/sdk-rn-ads/SKILL.md +3 -2
- package/src/config/validate.ts +3 -1
- package/src/core/adsInstance.ts +85 -1872
- package/src/core/consentFlow.ts +1 -20
- package/src/core/instance/consent.ts +162 -0
- package/src/core/instance/context.ts +328 -0
- package/src/core/instance/gate.ts +147 -0
- package/src/core/instance/init.ts +262 -0
- package/src/core/instance/internals.ts +138 -0
- package/src/core/instance/load.ts +426 -0
- package/src/core/instance/show.ts +202 -0
- package/src/core/instance/types.ts +198 -0
- package/src/core/requestThrottle.ts +11 -17
- package/src/core/retryPolicy.ts +12 -2
- package/src/core/timers.ts +17 -69
- package/src/diagnostics/errors.ts +52 -63
- package/src/diagnostics/events.ts +77 -66
- package/src/engine/admobBanner.tsx +55 -0
- package/src/engine/admobEngine.ts +38 -18
- package/src/engine/admobErrors.ts +44 -0
- package/src/engine/port.ts +46 -1
- package/src/engine/testAdUnits.ts +0 -5
- package/src/index.ts +19 -1
- package/src/react/AdsBanner.tsx +94 -79
- package/src/react/AdsProvider.tsx +6 -7
- package/src/react/hooks.ts +26 -31
- package/src/testing/clock.ts +75 -0
- package/src/testing/conformance.ts +112 -0
- package/src/testing/index.ts +43 -70
package/src/react/AdsBanner.tsx
CHANGED
|
@@ -16,13 +16,15 @@
|
|
|
16
16
|
* a banner through the load/show handle model, which does not fit it.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
19
|
+
import React, { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
|
|
20
20
|
import { View, type LayoutChangeEvent, type ViewStyle } from 'react-native';
|
|
21
21
|
|
|
22
22
|
import type { PlacementState } from '../config/types';
|
|
23
|
-
import {
|
|
23
|
+
import { normalizeProviderError } from '../diagnostics/errors';
|
|
24
|
+
import type { AdEvent } from '../diagnostics/events';
|
|
25
|
+
import type { EngineFailureKind } from '../engine/port';
|
|
24
26
|
import { useAds } from './AdsProvider';
|
|
25
|
-
import type
|
|
27
|
+
import { getInternals, type ThrottledResult } from '../core/adsInstance';
|
|
26
28
|
import { useAdsStatus } from './hooks';
|
|
27
29
|
|
|
28
30
|
export interface AdsBannerProps {
|
|
@@ -33,21 +35,14 @@ export interface AdsBannerProps {
|
|
|
33
35
|
readonly style?: ViewStyle;
|
|
34
36
|
}
|
|
35
37
|
|
|
36
|
-
interface ProviderBannerModule {
|
|
37
|
-
BannerAd: React.ComponentType<Record<string, unknown>>;
|
|
38
|
-
BannerAdSize: Record<string, string>;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function loadBannerModule(): ProviderBannerModule | null {
|
|
42
|
-
try {
|
|
43
|
-
return require('react-native-google-mobile-ads') as ProviderBannerModule;
|
|
44
|
-
} catch {
|
|
45
|
-
return null;
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
38
|
export function AdsBanner({ placementKey, onStateChange, style }: AdsBannerProps) {
|
|
50
39
|
const ads = useAds();
|
|
40
|
+
// The instance's banner wiring (ADS-13). An instance the app wrote by hand has
|
|
41
|
+
// none, and is treated like a missing one.
|
|
42
|
+
const internals = useMemo(() => (ads ? getInternals(ads) : null), [ads]);
|
|
43
|
+
// The throttle token for this mount: stable across renders and minted by React
|
|
44
|
+
// rather than by a counter, so a render that runs twice charges one slot.
|
|
45
|
+
const mountToken = useId();
|
|
51
46
|
// The reported state lives in a ref rather than in component state: the host
|
|
52
47
|
// itself renders the same tree for every state, so storing it would only
|
|
53
48
|
// force re-renders the PERF-002 budget does not need to spend. The instance
|
|
@@ -58,6 +53,9 @@ export function AdsBanner({ placementKey, onStateChange, style }: AdsBannerProps
|
|
|
58
53
|
// produces a genuinely re-adapted banner rather than a stretched old one.
|
|
59
54
|
const [cycle, setCycle] = useState(0);
|
|
60
55
|
const correlationRef = useRef<string | null>(null);
|
|
56
|
+
// Whether the current correlation already reported `loaded`. The provider
|
|
57
|
+
// auto-refreshes a banner inside one mount; each refresh is a new cycle.
|
|
58
|
+
const loadedInCycleRef = useRef(false);
|
|
61
59
|
const startedAtRef = useRef(0);
|
|
62
60
|
// Whether this mount has already taken a throttle slot, and the refusal if it
|
|
63
61
|
// was refused. Resets with the mount, which is exactly the granularity FR-140
|
|
@@ -66,23 +64,29 @@ export function AdsBanner({ placementKey, onStateChange, style }: AdsBannerProps
|
|
|
66
64
|
decided: boolean;
|
|
67
65
|
refusal: ThrottledResult | null;
|
|
68
66
|
}>({ decided: false, refusal: null });
|
|
67
|
+
// Which attempt this mount is on. A refused mount asks again once the window
|
|
68
|
+
// passes (ADS-06), and each attempt needs its own token: the throttle replays
|
|
69
|
+
// the stored verdict for a token it has seen.
|
|
70
|
+
const throttleAttemptRef = useRef(0);
|
|
69
71
|
|
|
70
|
-
|
|
72
|
+
// The engine's banner component (ADS-12): the host never names the provider.
|
|
73
|
+
const banner = useMemo(() => internals?.banner() ?? null, [internals]);
|
|
71
74
|
// Initialization is asynchronous and a banner is almost always mounted before
|
|
72
75
|
// it finishes. Without this the resolution below would be computed once, while
|
|
73
76
|
// the instance was still `not_started`, be refused, and never be retried — the
|
|
74
77
|
// banner would sit at `unavailable` forever on a perfectly healthy app.
|
|
75
78
|
const status = useAdsStatus();
|
|
76
79
|
|
|
77
|
-
// Keyed on `cycle` so a genuine width change
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
+
// Keyed on `cycle` so a genuine width change starts a new, separately traceable
|
|
81
|
+
// load (FR-038, FR-052), and on `status` so the banner recovers the moment the
|
|
82
|
+
// instance becomes able to request. Pure: the cycle's correlation id is minted
|
|
83
|
+
// in the effect that starts it, not here (ADS-20).
|
|
80
84
|
const resolved = useMemo(
|
|
81
85
|
() =>
|
|
82
|
-
|
|
83
|
-
? { cycle, value:
|
|
86
|
+
internals && status === 'ready'
|
|
87
|
+
? { cycle, value: internals.resolveBanner(placementKey) }
|
|
84
88
|
: null,
|
|
85
|
-
[
|
|
89
|
+
[internals, placementKey, cycle, status],
|
|
86
90
|
);
|
|
87
91
|
const resolution = resolved?.value ?? null;
|
|
88
92
|
|
|
@@ -92,10 +96,10 @@ export function AdsBanner({ placementKey, onStateChange, style }: AdsBannerProps
|
|
|
92
96
|
return;
|
|
93
97
|
}
|
|
94
98
|
stateRef.current = next;
|
|
95
|
-
|
|
99
|
+
internals?.setPlacementState(placementKey, next);
|
|
96
100
|
onStateChange?.(next);
|
|
97
101
|
},
|
|
98
|
-
[
|
|
102
|
+
[internals, onStateChange, placementKey],
|
|
99
103
|
);
|
|
100
104
|
|
|
101
105
|
// FR-140. Decided **during render**, not in the effect below, because the
|
|
@@ -108,20 +112,23 @@ export function AdsBanner({ placementKey, onStateChange, style }: AdsBannerProps
|
|
|
108
112
|
// out), while a later cycle in the *same* mount is a width re-adaptation —
|
|
109
113
|
// rotation — which must not be refused or a rotated screen loses its banner
|
|
110
114
|
// for up to a minute.
|
|
111
|
-
if (
|
|
115
|
+
if (internals && resolution?.ok && containerWidth > 0 && !throttleRef.current.decided) {
|
|
112
116
|
throttleRef.current = {
|
|
113
117
|
decided: true,
|
|
114
|
-
refusal:
|
|
118
|
+
refusal: internals.commitBannerRequest(
|
|
119
|
+
placementKey,
|
|
120
|
+
`${mountToken}:${throttleAttemptRef.current}`,
|
|
121
|
+
),
|
|
115
122
|
};
|
|
116
123
|
}
|
|
117
124
|
const throttleRefusal = throttleRef.current.refusal;
|
|
118
125
|
|
|
119
126
|
const usable = Boolean(
|
|
120
|
-
|
|
127
|
+
internals && banner && resolution?.ok && containerWidth > 0 && !throttleRefusal,
|
|
121
128
|
);
|
|
122
129
|
|
|
123
130
|
useEffect(() => {
|
|
124
|
-
if (!
|
|
131
|
+
if (!internals || !banner) {
|
|
125
132
|
move('unavailable');
|
|
126
133
|
return;
|
|
127
134
|
}
|
|
@@ -165,30 +172,39 @@ export function AdsBanner({ placementKey, onStateChange, style }: AdsBannerProps
|
|
|
165
172
|
// collapse the space for both, but only one is worth investigating.
|
|
166
173
|
if (throttleRefusal) {
|
|
167
174
|
move('throttled');
|
|
168
|
-
|
|
175
|
+
internals.emit({
|
|
169
176
|
type: 'request_throttled',
|
|
170
|
-
timestamp:
|
|
177
|
+
timestamp: internals.now(),
|
|
171
178
|
placementKey,
|
|
172
179
|
format: 'banner',
|
|
173
180
|
retryAfterMs: throttleRefusal.retryAfterMs,
|
|
174
|
-
minRequestIntervalMs:
|
|
181
|
+
minRequestIntervalMs: internals.throttleFloorFor(placementKey),
|
|
182
|
+
});
|
|
183
|
+
// ADS-06: ask again in this same mount once the window has passed. Until
|
|
184
|
+
// 0.7.0 a refused banner stayed empty until it was remounted, so a banner
|
|
185
|
+
// pinned to a screen the user returned to within a minute never filled.
|
|
186
|
+
return internals.schedule(throttleRefusal.retryAfterMs, () => {
|
|
187
|
+
throttleRef.current = { decided: false, refusal: null };
|
|
188
|
+
throttleAttemptRef.current += 1;
|
|
189
|
+
setCycle(value => value + 1);
|
|
175
190
|
});
|
|
176
|
-
return;
|
|
177
191
|
}
|
|
178
|
-
|
|
179
|
-
|
|
192
|
+
const correlationId = internals.nextCorrelationId();
|
|
193
|
+
correlationRef.current = correlationId;
|
|
194
|
+
loadedInCycleRef.current = false;
|
|
195
|
+
startedAtRef.current = internals.now();
|
|
180
196
|
move('loading');
|
|
181
|
-
|
|
197
|
+
internals.emit({
|
|
182
198
|
type: 'load_started',
|
|
183
|
-
timestamp:
|
|
199
|
+
timestamp: startedAtRef.current,
|
|
184
200
|
placementKey,
|
|
185
201
|
format: 'banner',
|
|
186
|
-
correlationId
|
|
202
|
+
correlationId,
|
|
187
203
|
});
|
|
188
204
|
// Deliberately keyed on `cycle`, not on every render: FR-041 forbids a
|
|
189
205
|
// re-render from producing a second uncontrolled request.
|
|
190
206
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
191
|
-
}, [
|
|
207
|
+
}, [internals, banner, status, resolution, containerWidth, cycle]);
|
|
192
208
|
|
|
193
209
|
const onLayout = useCallback((event: LayoutChangeEvent) => {
|
|
194
210
|
const width = Math.round(event.nativeEvent.layout.width);
|
|
@@ -206,48 +222,59 @@ export function AdsBanner({ placementKey, onStateChange, style }: AdsBannerProps
|
|
|
206
222
|
}, []);
|
|
207
223
|
|
|
208
224
|
const emit = useCallback(
|
|
209
|
-
(event:
|
|
210
|
-
|
|
225
|
+
(event: AdEvent) => {
|
|
226
|
+
internals?.emit(event);
|
|
211
227
|
},
|
|
212
|
-
[
|
|
228
|
+
[internals],
|
|
213
229
|
);
|
|
230
|
+
const clock = useCallback(() => internals?.now() ?? Date.now(), [internals]);
|
|
214
231
|
|
|
215
232
|
const handleLoaded = useCallback(() => {
|
|
233
|
+
// A second load in one correlation is the provider's auto-refresh: a new
|
|
234
|
+
// ad, so a new cycle. Under the old correlation its `loaded` and
|
|
235
|
+
// `impression` were deduplicated away and revenue went uncounted (ADS-05).
|
|
236
|
+
// For a refresh, `durationMs` is the time since the previous load.
|
|
237
|
+
if (loadedInCycleRef.current && internals) {
|
|
238
|
+
correlationRef.current = internals.nextCorrelationId();
|
|
239
|
+
}
|
|
240
|
+
loadedInCycleRef.current = true;
|
|
241
|
+
const loadedAt = clock();
|
|
216
242
|
move('ready');
|
|
217
243
|
emit({
|
|
218
244
|
type: 'loaded',
|
|
219
|
-
timestamp:
|
|
245
|
+
timestamp: loadedAt,
|
|
220
246
|
placementKey,
|
|
221
247
|
format: 'banner',
|
|
222
248
|
correlationId: correlationRef.current ?? undefined,
|
|
223
|
-
durationMs:
|
|
249
|
+
durationMs: loadedAt - startedAtRef.current,
|
|
224
250
|
});
|
|
225
|
-
|
|
251
|
+
startedAtRef.current = loadedAt;
|
|
252
|
+
}, [internals, clock, emit, move, placementKey]);
|
|
226
253
|
|
|
227
254
|
const handleFailed = useCallback(
|
|
228
|
-
(error: unknown) => {
|
|
229
|
-
const normalized = normalizeProviderError(
|
|
230
|
-
|
|
231
|
-
placementKey,
|
|
232
|
-
|
|
233
|
-
|
|
255
|
+
(error: unknown, kind?: EngineFailureKind) => {
|
|
256
|
+
const normalized = normalizeProviderError(
|
|
257
|
+
error,
|
|
258
|
+
{ operation: 'load', placementKey, format: 'banner' },
|
|
259
|
+
kind,
|
|
260
|
+
);
|
|
234
261
|
move('failed');
|
|
235
262
|
emit({
|
|
236
263
|
type: 'load_failed',
|
|
237
|
-
timestamp:
|
|
264
|
+
timestamp: clock(),
|
|
238
265
|
placementKey,
|
|
239
266
|
format: 'banner',
|
|
240
267
|
correlationId: correlationRef.current ?? undefined,
|
|
241
268
|
error: normalized,
|
|
242
269
|
});
|
|
243
270
|
},
|
|
244
|
-
[emit, move, placementKey],
|
|
271
|
+
[clock, emit, move, placementKey],
|
|
245
272
|
);
|
|
246
273
|
|
|
247
274
|
const handleImpression = useCallback(() => {
|
|
248
275
|
emit({
|
|
249
276
|
type: 'impression',
|
|
250
|
-
timestamp:
|
|
277
|
+
timestamp: clock(),
|
|
251
278
|
placementKey,
|
|
252
279
|
format: 'banner',
|
|
253
280
|
correlationId: correlationRef.current ?? undefined,
|
|
@@ -256,17 +283,17 @@ export function AdsBanner({ placementKey, onStateChange, style }: AdsBannerProps
|
|
|
256
283
|
// reconciliation, so it is recorded rather than flattened (R5).
|
|
257
284
|
source: 'provider',
|
|
258
285
|
});
|
|
259
|
-
}, [emit, placementKey]);
|
|
286
|
+
}, [clock, emit, placementKey]);
|
|
260
287
|
|
|
261
288
|
const handleClicked = useCallback(() => {
|
|
262
289
|
emit({
|
|
263
290
|
type: 'click',
|
|
264
|
-
timestamp:
|
|
291
|
+
timestamp: clock(),
|
|
265
292
|
placementKey,
|
|
266
293
|
format: 'banner',
|
|
267
294
|
correlationId: correlationRef.current ?? undefined,
|
|
268
295
|
});
|
|
269
|
-
}, [emit, placementKey]);
|
|
296
|
+
}, [clock, emit, placementKey]);
|
|
270
297
|
|
|
271
298
|
// FR-041: nothing survives unmount. No retained native view, no dangling
|
|
272
299
|
// correlation, no state written into a disposed instance.
|
|
@@ -276,7 +303,7 @@ export function AdsBanner({ placementKey, onStateChange, style }: AdsBannerProps
|
|
|
276
303
|
};
|
|
277
304
|
}, []);
|
|
278
305
|
|
|
279
|
-
if (!
|
|
306
|
+
if (!internals || !banner) {
|
|
280
307
|
// FR-042, UX-003 — out of the tree entirely, blocking no touch.
|
|
281
308
|
return null;
|
|
282
309
|
}
|
|
@@ -304,34 +331,22 @@ export function AdsBanner({ placementKey, onStateChange, style }: AdsBannerProps
|
|
|
304
331
|
}
|
|
305
332
|
}
|
|
306
333
|
|
|
307
|
-
const
|
|
334
|
+
const BannerComponent = banner.Component;
|
|
308
335
|
const adUnitId = resolution?.ok ? resolution.adUnitId : null;
|
|
309
|
-
// The provider's polarity is the inverse of the consumer-facing option.
|
|
310
|
-
const requestNonPersonalizedAdsOnly = resolution?.ok ? !resolution.personalizedAds : false;
|
|
311
336
|
|
|
312
337
|
return (
|
|
313
338
|
<View style={style} onLayout={onLayout} testID={`ads-banner-${placementKey}`}>
|
|
314
|
-
{usable && adUnitId ? (
|
|
315
|
-
<
|
|
339
|
+
{usable && adUnitId && resolution?.ok ? (
|
|
340
|
+
<BannerComponent
|
|
316
341
|
key={`${placementKey}-${cycle}`}
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
onAdClicked={handleClicked}
|
|
342
|
+
adUnitId={adUnitId}
|
|
343
|
+
personalizedAds={resolution.personalizedAds}
|
|
344
|
+
onLoaded={handleLoaded}
|
|
345
|
+
onFailed={handleFailed}
|
|
346
|
+
onImpression={handleImpression}
|
|
347
|
+
onClicked={handleClicked}
|
|
324
348
|
/>
|
|
325
349
|
) : null}
|
|
326
350
|
</View>
|
|
327
351
|
);
|
|
328
352
|
}
|
|
329
|
-
|
|
330
|
-
/** Exposed for tests that assert the failure taxonomy without a renderer. */
|
|
331
|
-
export function bannerUnavailableError(placementKey: string): AdsError {
|
|
332
|
-
return new AdsError('unsupported_platform', {
|
|
333
|
-
operation: 'load',
|
|
334
|
-
placementKey,
|
|
335
|
-
format: 'banner',
|
|
336
|
-
});
|
|
337
|
-
}
|
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
* startup where the app can decide what to do about it.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
+
import { appLifecycle } from '@chipmobilesdk/rn-core';
|
|
13
14
|
import React, { createContext, useContext, useEffect, type ReactNode } from 'react';
|
|
14
|
-
import { AppState, type AppStateStatus } from 'react-native';
|
|
15
15
|
|
|
16
16
|
import type { AdsInstance } from '../core/adsInstance';
|
|
17
17
|
|
|
@@ -23,7 +23,9 @@ export interface AdsProviderProps {
|
|
|
23
23
|
* Whether the provider forwards foreground/background transitions to the
|
|
24
24
|
* instance. On by default: FR-034's "never show while inactive" and
|
|
25
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.
|
|
26
|
+
* Turn it off only when the app already drives `setAppActive` itself. The
|
|
27
|
+
* transitions come from rn-core's shared `appLifecycle()`; `inactive` counts as
|
|
28
|
+
* not active.
|
|
27
29
|
*/
|
|
28
30
|
readonly trackAppState?: boolean;
|
|
29
31
|
readonly children: ReactNode;
|
|
@@ -34,12 +36,9 @@ export function AdsProvider({ instance, trackAppState = true, children }: AdsPro
|
|
|
34
36
|
if (!trackAppState) {
|
|
35
37
|
return;
|
|
36
38
|
}
|
|
37
|
-
|
|
38
|
-
instance.setAppActive(
|
|
39
|
+
return appLifecycle().subscribe(state => {
|
|
40
|
+
instance.setAppActive(state === 'active');
|
|
39
41
|
});
|
|
40
|
-
return () => {
|
|
41
|
-
subscription.remove();
|
|
42
|
-
};
|
|
43
42
|
}, [instance, trackAppState]);
|
|
44
43
|
|
|
45
44
|
return <AdsContext.Provider value={instance}>{children}</AdsContext.Provider>;
|
package/src/react/hooks.ts
CHANGED
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
* state; it never holds provider-owned memory (FR-027).
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import {
|
|
13
|
+
import { useExternalStore, useLatestCallback } from '@chipmobilesdk/rn-core/react';
|
|
14
|
+
import { useCallback, useEffect } from 'react';
|
|
14
15
|
|
|
15
16
|
import type { InitStatus, PlacementState } from '../config/types';
|
|
16
17
|
import type { AdEventListener } from '../diagnostics/events';
|
|
@@ -19,42 +20,33 @@ import { useAds } from './AdsProvider';
|
|
|
19
20
|
/** Live state for one placement. `unavailable` outside a provider. */
|
|
20
21
|
export function usePlacementState(placementKey: string): PlacementState {
|
|
21
22
|
const ads = useAds();
|
|
22
|
-
const
|
|
23
|
-
|
|
23
|
+
const subscribe = useCallback(
|
|
24
|
+
(listener: () => void) =>
|
|
25
|
+
ads ? ads.subscribePlacement(placementKey, listener) : NO_SUBSCRIPTION,
|
|
26
|
+
[ads, placementKey],
|
|
24
27
|
);
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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;
|
|
28
|
+
const getSnapshot = useCallback(
|
|
29
|
+
(): PlacementState => (ads ? ads.getPlacementState(placementKey) : 'unavailable'),
|
|
30
|
+
[ads, placementKey],
|
|
31
|
+
);
|
|
32
|
+
// useSyncExternalStore: tear-free, and no transition lost between render and
|
|
33
|
+
// subscription, which the useState copy had to re-read to avoid.
|
|
34
|
+
return useExternalStore(subscribe, getSnapshot);
|
|
39
35
|
}
|
|
40
36
|
|
|
41
37
|
/** Live initialization status. `unavailable` outside a provider. */
|
|
42
38
|
export function useAdsStatus(): InitStatus {
|
|
43
39
|
const ads = useAds();
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
}
|
|
51
|
-
setStatus(ads.getStatus());
|
|
52
|
-
return ads.subscribeStatus(setStatus);
|
|
53
|
-
}, [ads]);
|
|
54
|
-
|
|
55
|
-
return status;
|
|
40
|
+
const subscribe = useCallback(
|
|
41
|
+
(listener: () => void) => (ads ? ads.subscribeStatus(listener) : NO_SUBSCRIPTION),
|
|
42
|
+
[ads],
|
|
43
|
+
);
|
|
44
|
+
const getSnapshot = useCallback((): InitStatus => (ads ? ads.getStatus() : 'unavailable'), [ads]);
|
|
45
|
+
return useExternalStore(subscribe, getSnapshot);
|
|
56
46
|
}
|
|
57
47
|
|
|
48
|
+
const NO_SUBSCRIPTION = (): void => undefined;
|
|
49
|
+
|
|
58
50
|
/**
|
|
59
51
|
* Forwards every event to a listener without causing a render.
|
|
60
52
|
*
|
|
@@ -65,11 +57,14 @@ export function useAdsStatus(): InitStatus {
|
|
|
65
57
|
*/
|
|
66
58
|
export function useAdsEvents(listener: AdEventListener): void {
|
|
67
59
|
const ads = useAds();
|
|
60
|
+
// An inline listener is a new function every render; subscribing through the
|
|
61
|
+
// latest one keeps a single subscription per instance (PERF-13).
|
|
62
|
+
const latest = useLatestCallback(listener);
|
|
68
63
|
|
|
69
64
|
useEffect(() => {
|
|
70
65
|
if (!ads) {
|
|
71
66
|
return;
|
|
72
67
|
}
|
|
73
|
-
return ads.subscribeEvents(
|
|
74
|
-
}, [ads,
|
|
68
|
+
return ads.subscribeEvents(latest);
|
|
69
|
+
}, [ads, latest]);
|
|
75
70
|
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A clock the test drives (ADS-11).
|
|
3
|
+
*
|
|
4
|
+
* Every bounded wait in this package — retry backoff, the request throttle, a
|
|
5
|
+
* consent form that never answers — is measured against a clock. Injecting this
|
|
6
|
+
* one is what lets a suite assert the behaviour at the boundary instead of
|
|
7
|
+
* sleeping and hoping.
|
|
8
|
+
*/
|
|
9
|
+
import type { Scheduler } from '../core/timers';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A clock a test can move by hand.
|
|
13
|
+
*
|
|
14
|
+
* Retry backoff, app-open expiry, the undecided-state warning, and the
|
|
15
|
+
* ad-originated-return windows are all time-dependent, and all four must be
|
|
16
|
+
* assertable exactly rather than approximately. Advancing a clock makes "two
|
|
17
|
+
* retries, 2s then 4s" a precise assertion; sleeping would make it a flaky one.
|
|
18
|
+
*
|
|
19
|
+
* Callbacks fire in due order, and a callback scheduled *during* an advance is
|
|
20
|
+
* honored within the same advance if its deadline has also passed — matching how
|
|
21
|
+
* real timers behave when work chains.
|
|
22
|
+
*/
|
|
23
|
+
export interface TestClock {
|
|
24
|
+
now(): number;
|
|
25
|
+
advance(ms: number): void;
|
|
26
|
+
readonly scheduler: Scheduler;
|
|
27
|
+
pending(): number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function createTestClock(startMs = 1_000_000): TestClock {
|
|
31
|
+
let current = startMs;
|
|
32
|
+
let sequence = 0;
|
|
33
|
+
const timers = new Map<number, { at: number; callback: () => void }>();
|
|
34
|
+
|
|
35
|
+
const scheduler: Scheduler = {
|
|
36
|
+
setTimeout(callback, ms) {
|
|
37
|
+
sequence += 1;
|
|
38
|
+
timers.set(sequence, { at: current + ms, callback });
|
|
39
|
+
return sequence;
|
|
40
|
+
},
|
|
41
|
+
clearTimeout(handle) {
|
|
42
|
+
timers.delete(handle as number);
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
now: () => current,
|
|
48
|
+
advance(ms) {
|
|
49
|
+
const target = current + ms;
|
|
50
|
+
// Loop rather than iterate once: a fired callback may schedule another
|
|
51
|
+
// timer that is also due before `target`, which is exactly what a retry
|
|
52
|
+
// chain does.
|
|
53
|
+
for (;;) {
|
|
54
|
+
let nextId: number | null = null;
|
|
55
|
+
let nextAt = Number.POSITIVE_INFINITY;
|
|
56
|
+
for (const [id, timer] of timers) {
|
|
57
|
+
if (timer.at <= target && timer.at < nextAt) {
|
|
58
|
+
nextAt = timer.at;
|
|
59
|
+
nextId = id;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (nextId === null) {
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
const timer = timers.get(nextId)!;
|
|
66
|
+
timers.delete(nextId);
|
|
67
|
+
current = timer.at;
|
|
68
|
+
timer.callback();
|
|
69
|
+
}
|
|
70
|
+
current = target;
|
|
71
|
+
},
|
|
72
|
+
scheduler,
|
|
73
|
+
pending: () => timers.size,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What any `AdsEngine` must do (IOS-12, ADS-13).
|
|
3
|
+
*
|
|
4
|
+
* The same checks run against the fake engine and against the AdMob adapter over
|
|
5
|
+
* the provider mock (or the real provider, from a demo screen on a device), so a
|
|
6
|
+
* test that drives the fake proves something about the device path. The subject
|
|
7
|
+
* says how the provider delivers its callbacks; the checks only use the port.
|
|
8
|
+
*/
|
|
9
|
+
import {
|
|
10
|
+
ensure,
|
|
11
|
+
runChecks,
|
|
12
|
+
type Check,
|
|
13
|
+
type ConformanceReport,
|
|
14
|
+
} from '@chipmobilesdk/rn-core/testing';
|
|
15
|
+
|
|
16
|
+
import type { Reward } from '../config/types';
|
|
17
|
+
import type { AdsEngine, EngineAdEvent, EngineAdHandle } from '../engine/port';
|
|
18
|
+
|
|
19
|
+
export interface AdsEngineSubject {
|
|
20
|
+
readonly engine: AdsEngine;
|
|
21
|
+
/** The provider finishes loading the ad behind `handle`. */
|
|
22
|
+
deliverLoad(handle: EngineAdHandle): void;
|
|
23
|
+
/** The provider grants a reward for the ad behind `handle`. */
|
|
24
|
+
deliverReward(handle: EngineAdHandle, reward: Reward): void;
|
|
25
|
+
/** The user closes the ad behind `handle`. */
|
|
26
|
+
deliverDismiss(handle: EngineAdHandle): void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface AdsEngineHarness {
|
|
30
|
+
create(): AdsEngineSubject;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Lets callbacks a provider delivers on a later microtask arrive. */
|
|
34
|
+
async function settle(): Promise<void> {
|
|
35
|
+
for (let turn = 0; turn < 3; turn += 1) {
|
|
36
|
+
await Promise.resolve();
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const adsEngineChecks: readonly Check<AdsEngineHarness>[] = [
|
|
41
|
+
{
|
|
42
|
+
name: 'is available, and initializes with privacy applied',
|
|
43
|
+
async run(harness) {
|
|
44
|
+
const { engine } = harness.create();
|
|
45
|
+
ensure(engine.isAvailable(), 'reported unavailable');
|
|
46
|
+
await engine.initialize({ childDirected: true, maxAdContentRating: 'G' });
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
name: 'delivers loaded to a subscriber of an interstitial, and nothing after unsubscribing',
|
|
51
|
+
async run(harness) {
|
|
52
|
+
const subject = harness.create();
|
|
53
|
+
const handle = await subject.engine.createFullScreen('interstitial', 'conformance/1');
|
|
54
|
+
ensure(typeof handle.id === 'string' && handle.id !== '', 'handle has no id');
|
|
55
|
+
const seen: EngineAdEvent['type'][] = [];
|
|
56
|
+
const stop = subject.engine.subscribe(handle, event => seen.push(event.type));
|
|
57
|
+
subject.deliverLoad(handle);
|
|
58
|
+
await settle();
|
|
59
|
+
ensure(seen.join(',') === 'loaded', `subscriber saw ${seen.join(',') || 'nothing'}`);
|
|
60
|
+
stop();
|
|
61
|
+
stop();
|
|
62
|
+
subject.deliverDismiss(handle);
|
|
63
|
+
await settle();
|
|
64
|
+
ensure(seen.length === 1, `notified after unsubscribing: ${seen.join(',')}`);
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
name: 'delivers the reward and the dismissal of a rewarded ad',
|
|
69
|
+
async run(harness) {
|
|
70
|
+
const subject = harness.create();
|
|
71
|
+
const handle = await subject.engine.createFullScreen('rewarded', 'conformance/2', {
|
|
72
|
+
personalizedAds: false,
|
|
73
|
+
});
|
|
74
|
+
const seen: EngineAdEvent[] = [];
|
|
75
|
+
subject.engine.subscribe(handle, event => seen.push(event));
|
|
76
|
+
subject.deliverLoad(handle);
|
|
77
|
+
await settle();
|
|
78
|
+
subject.deliverReward(handle, { type: 'coins', amount: 5 });
|
|
79
|
+
subject.deliverDismiss(handle);
|
|
80
|
+
await settle();
|
|
81
|
+
const reward = seen.find(event => event.type === 'rewarded');
|
|
82
|
+
ensure(reward?.type === 'rewarded', `no reward among ${seen.map(e => e.type).join(',')}`);
|
|
83
|
+
ensure(
|
|
84
|
+
reward.reward.type === 'coins' && reward.reward.amount === 5,
|
|
85
|
+
`reward ${JSON.stringify(reward.reward)}`,
|
|
86
|
+
);
|
|
87
|
+
ensure(seen.at(-1)?.type === 'dismissed', `last event ${seen.at(-1)?.type}`);
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
name: 'gives every ad its own handle',
|
|
92
|
+
async run(harness) {
|
|
93
|
+
const { engine } = harness.create();
|
|
94
|
+
const first = await engine.createFullScreen('interstitial', 'conformance/1');
|
|
95
|
+
const second = await engine.createFullScreen('interstitial', 'conformance/1');
|
|
96
|
+
ensure(first.id !== second.id, 'two ads share a handle');
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
name: 'disposes safely twice',
|
|
101
|
+
async run(harness) {
|
|
102
|
+
const { engine } = harness.create();
|
|
103
|
+
const handle = await engine.createFullScreen('interstitial', 'conformance/1');
|
|
104
|
+
engine.dispose(handle);
|
|
105
|
+
engine.dispose(handle);
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
];
|
|
109
|
+
|
|
110
|
+
export function runAdsEngineConformance(harness: AdsEngineHarness): Promise<ConformanceReport> {
|
|
111
|
+
return runChecks(() => harness, adsEngineChecks);
|
|
112
|
+
}
|