@chipmobilesdk/rn-ads 0.5.0 → 0.6.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 +351 -214
- package/README.md +891 -881
- package/package.json +49 -49
- package/skills/sdk-rn-ads/SKILL.md +273 -274
- package/src/config/resolveAdUnit.ts +85 -85
- package/src/config/types.ts +318 -321
- package/src/config/validate.ts +4 -5
- package/src/core/adOriginatedReturn.ts +134 -134
- package/src/core/adsInstance.ts +1899 -1933
- package/src/core/consentFlow.ts +182 -180
- package/src/core/enablement.ts +76 -76
- package/src/core/fullScreenLock.ts +54 -54
- package/src/core/gates.ts +94 -91
- package/src/core/initMachine.ts +187 -187
- package/src/core/placementMachine.ts +124 -124
- package/src/core/privacyGate.ts +93 -94
- package/src/core/retryPolicy.ts +137 -127
- package/src/core/rewardedSession.ts +84 -84
- package/src/core/timers.ts +164 -208
- package/src/diagnostics/correlation.ts +31 -31
- package/src/diagnostics/errors.ts +200 -234
- package/src/diagnostics/events.ts +249 -232
- package/src/engine/admobEngine.ts +410 -404
- package/src/engine/port.ts +144 -144
- package/src/engine/testAdUnits.ts +61 -61
- package/src/index.ts +19 -1
- package/src/react/AdsBanner.tsx +351 -340
- package/src/react/AdsProvider.tsx +56 -57
- package/src/react/hooks.ts +70 -75
- package/src/testing/conformance.ts +112 -0
- package/src/testing/index.ts +475 -463
|
@@ -1,57 +1,56 @@
|
|
|
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
|
|
14
|
-
import {
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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 { appLifecycle } from '@chipmobilesdk/rn-core';
|
|
14
|
+
import React, { createContext, useContext, useEffect, type ReactNode } from 'react';
|
|
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. The
|
|
27
|
+
* transitions come from rn-core's shared `appLifecycle()`; `inactive` counts as
|
|
28
|
+
* not active.
|
|
29
|
+
*/
|
|
30
|
+
readonly trackAppState?: boolean;
|
|
31
|
+
readonly children: ReactNode;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function AdsProvider({ instance, trackAppState = true, children }: AdsProviderProps) {
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
if (!trackAppState) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
return appLifecycle().subscribe(state => {
|
|
40
|
+
instance.setAppActive(state === 'active');
|
|
41
|
+
});
|
|
42
|
+
}, [instance, trackAppState]);
|
|
43
|
+
|
|
44
|
+
return <AdsContext.Provider value={instance}>{children}</AdsContext.Provider>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Returns the instance, or null outside a provider.
|
|
49
|
+
*
|
|
50
|
+
* Null rather than a throw on purpose: a screen that renders a banner should
|
|
51
|
+
* degrade to rendering nothing when ads are not wired up, not take the screen
|
|
52
|
+
* down with it (NFR-005).
|
|
53
|
+
*/
|
|
54
|
+
export function useAds(): AdsInstance | null {
|
|
55
|
+
return useContext(AdsContext);
|
|
56
|
+
}
|
package/src/react/hooks.ts
CHANGED
|
@@ -1,75 +1,70 @@
|
|
|
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 {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
import type {
|
|
17
|
-
import {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
return;
|
|
72
|
-
}
|
|
73
|
-
return ads.subscribeEvents(listener);
|
|
74
|
-
}, [ads, listener]);
|
|
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 { useExternalStore, useLatestCallback } from '@chipmobilesdk/rn-core/react';
|
|
14
|
+
import { useCallback, useEffect } from 'react';
|
|
15
|
+
|
|
16
|
+
import type { InitStatus, PlacementState } from '../config/types';
|
|
17
|
+
import type { AdEventListener } from '../diagnostics/events';
|
|
18
|
+
import { useAds } from './AdsProvider';
|
|
19
|
+
|
|
20
|
+
/** Live state for one placement. `unavailable` outside a provider. */
|
|
21
|
+
export function usePlacementState(placementKey: string): PlacementState {
|
|
22
|
+
const ads = useAds();
|
|
23
|
+
const subscribe = useCallback(
|
|
24
|
+
(listener: () => void) =>
|
|
25
|
+
ads ? ads.subscribePlacement(placementKey, listener) : NO_SUBSCRIPTION,
|
|
26
|
+
[ads, placementKey],
|
|
27
|
+
);
|
|
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);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Live initialization status. `unavailable` outside a provider. */
|
|
38
|
+
export function useAdsStatus(): InitStatus {
|
|
39
|
+
const ads = useAds();
|
|
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);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const NO_SUBSCRIPTION = (): void => undefined;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Forwards every event to a listener without causing a render.
|
|
52
|
+
*
|
|
53
|
+
* Deliberately does not accumulate events into state: a package that kept an
|
|
54
|
+
* event log would be persisting exactly the kind of history it promises not to
|
|
55
|
+
* (FR-021), and re-rendering on every impression would defeat PERF-002. A screen
|
|
56
|
+
* that wants a visible log keeps its own.
|
|
57
|
+
*/
|
|
58
|
+
export function useAdsEvents(listener: AdEventListener): void {
|
|
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);
|
|
63
|
+
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
if (!ads) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
return ads.subscribeEvents(latest);
|
|
69
|
+
}, [ads, latest]);
|
|
70
|
+
}
|
|
@@ -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
|
+
}
|