@chipmobilesdk/rn-ads 0.5.0 → 0.5.1
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 +224 -214
- package/README.md +884 -881
- package/package.json +46 -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 +1 -4
- package/src/core/adOriginatedReturn.ts +134 -134
- package/src/core/adsInstance.ts +1929 -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 +127 -127
- package/src/core/rewardedSession.ts +84 -84
- package/src/core/timers.ts +216 -208
- package/src/diagnostics/correlation.ts +31 -31
- package/src/diagnostics/errors.ts +230 -234
- package/src/diagnostics/events.ts +238 -232
- package/src/engine/admobEngine.ts +399 -404
- package/src/engine/port.ts +144 -144
- package/src/engine/testAdUnits.ts +61 -61
- package/src/react/AdsBanner.tsx +337 -340
- package/src/react/AdsProvider.tsx +57 -57
- package/src/react/hooks.ts +75 -75
- package/src/testing/index.ts +463 -463
|
@@ -1,124 +1,124 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Per-placement lifecycle (FR-026 – FR-031, FR-034).
|
|
3
|
-
*
|
|
4
|
-
* Pure and synchronous, per data-model.md §3.2. Two variants share one reducer:
|
|
5
|
-
*
|
|
6
|
-
* full-screen — load, show once, become `consumed`, reload before the next show
|
|
7
|
-
* banner — continuous; it renders and may refresh, and never reaches
|
|
8
|
-
* `consumed`, because a banner is not a single-use resource
|
|
9
|
-
*
|
|
10
|
-
* Staleness is handled here rather than at the show site so that a stale ad can
|
|
11
|
-
* never be *reported* ready either — a consumer that renders a "Watch ad" button
|
|
12
|
-
* from `state === 'ready'` would otherwise offer a button that fails on tap.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import type { AdFormat, PlacementState, UnavailableReason } from '../config/types';
|
|
16
|
-
import type { AdsError } from '../diagnostics/errors';
|
|
17
|
-
|
|
18
|
-
export type PlacementEvent =
|
|
19
|
-
| { readonly type: 'LOAD' }
|
|
20
|
-
| { readonly type: 'LOADED'; readonly atMs: number }
|
|
21
|
-
| { readonly type: 'LOAD_FAILED'; readonly error: AdsError }
|
|
22
|
-
| { readonly type: 'SHOW' }
|
|
23
|
-
| { readonly type: 'SHOW_FAILED'; readonly error: AdsError }
|
|
24
|
-
| { readonly type: 'DISMISSED' }
|
|
25
|
-
| { readonly type: 'EXPIRE' }
|
|
26
|
-
| { readonly type: 'UNAVAILABLE'; readonly reason: UnavailableReason }
|
|
27
|
-
| { readonly type: 'RESET' };
|
|
28
|
-
|
|
29
|
-
export interface PlacementSnapshot {
|
|
30
|
-
readonly state: PlacementState;
|
|
31
|
-
readonly loadedAtMs: number | null;
|
|
32
|
-
readonly error: AdsError | null;
|
|
33
|
-
readonly correlationId: string | null;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export const initialPlacement: PlacementSnapshot = {
|
|
37
|
-
state: 'idle',
|
|
38
|
-
loadedAtMs: null,
|
|
39
|
-
error: null,
|
|
40
|
-
correlationId: null,
|
|
41
|
-
};
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* A load is only legal from a settled state; `loading` is where FR-029 bites.
|
|
45
|
-
* `throttled` is settled too: it records that the last request was refused, and
|
|
46
|
-
* the throttle itself decides whether the next one may proceed.
|
|
47
|
-
*/
|
|
48
|
-
export function canLoad(state: PlacementState): boolean {
|
|
49
|
-
return (
|
|
50
|
-
state === 'idle' ||
|
|
51
|
-
state === 'failed' ||
|
|
52
|
-
state === 'consumed' ||
|
|
53
|
-
state === 'ready' ||
|
|
54
|
-
state === 'throttled'
|
|
55
|
-
);
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/** Only a ready ad may be shown. Everything else is a determinate refusal (FR-028). */
|
|
59
|
-
export function canShow(state: PlacementState): boolean {
|
|
60
|
-
return state === 'ready';
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
export function placementReducer(
|
|
64
|
-
state: PlacementSnapshot,
|
|
65
|
-
event: PlacementEvent,
|
|
66
|
-
format: AdFormat,
|
|
67
|
-
): PlacementSnapshot {
|
|
68
|
-
switch (event.type) {
|
|
69
|
-
case 'LOAD':
|
|
70
|
-
// FR-029: a load while already loading is absorbed. The instance
|
|
71
|
-
// returns the in-flight promise; the state does not restart.
|
|
72
|
-
if (state.state === 'loading' || state.state === 'showing') {
|
|
73
|
-
return state;
|
|
74
|
-
}
|
|
75
|
-
if (state.state === 'unavailable') {
|
|
76
|
-
return state;
|
|
77
|
-
}
|
|
78
|
-
return { ...initialPlacement, state: 'loading' };
|
|
79
|
-
|
|
80
|
-
case 'LOADED':
|
|
81
|
-
return state.state === 'loading'
|
|
82
|
-
? { ...state, state: 'ready', loadedAtMs: event.atMs, error: null }
|
|
83
|
-
: state;
|
|
84
|
-
|
|
85
|
-
case 'LOAD_FAILED':
|
|
86
|
-
return state.state === 'loading'
|
|
87
|
-
? { ...initialPlacement, state: 'failed', error: event.error }
|
|
88
|
-
: state;
|
|
89
|
-
|
|
90
|
-
case 'SHOW':
|
|
91
|
-
return state.state === 'ready' ? { ...state, state: 'showing' } : state;
|
|
92
|
-
|
|
93
|
-
case 'SHOW_FAILED':
|
|
94
|
-
// A failed presentation consumes the ad regardless: the provider has
|
|
95
|
-
// taken it, and re-showing the same object is what FR-031 forbids.
|
|
96
|
-
return state.state === 'showing'
|
|
97
|
-
? { ...initialPlacement, state: 'failed', error: event.error }
|
|
98
|
-
: state;
|
|
99
|
-
|
|
100
|
-
case 'DISMISSED':
|
|
101
|
-
if (state.state !== 'showing') {
|
|
102
|
-
return state;
|
|
103
|
-
}
|
|
104
|
-
// A banner is continuous — dismissal is not part of its life. A
|
|
105
|
-
// full-screen ad is spent, and must be reloaded before the next show.
|
|
106
|
-
return format === 'banner'
|
|
107
|
-
? { ...initialPlacement, state: 'idle' }
|
|
108
|
-
: { ...initialPlacement, state: 'consumed' };
|
|
109
|
-
|
|
110
|
-
case 'EXPIRE':
|
|
111
|
-
// FR-034. Only a ready-and-waiting ad can go stale; one being shown is
|
|
112
|
-
// already in the user's hands.
|
|
113
|
-
return state.state === 'ready' ? { ...initialPlacement, state: 'idle' } : state;
|
|
114
|
-
|
|
115
|
-
case 'UNAVAILABLE':
|
|
116
|
-
return { ...initialPlacement, state: 'unavailable' };
|
|
117
|
-
|
|
118
|
-
case 'RESET':
|
|
119
|
-
return initialPlacement;
|
|
120
|
-
|
|
121
|
-
default:
|
|
122
|
-
return state;
|
|
123
|
-
}
|
|
124
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Per-placement lifecycle (FR-026 – FR-031, FR-034).
|
|
3
|
+
*
|
|
4
|
+
* Pure and synchronous, per data-model.md §3.2. Two variants share one reducer:
|
|
5
|
+
*
|
|
6
|
+
* full-screen — load, show once, become `consumed`, reload before the next show
|
|
7
|
+
* banner — continuous; it renders and may refresh, and never reaches
|
|
8
|
+
* `consumed`, because a banner is not a single-use resource
|
|
9
|
+
*
|
|
10
|
+
* Staleness is handled here rather than at the show site so that a stale ad can
|
|
11
|
+
* never be *reported* ready either — a consumer that renders a "Watch ad" button
|
|
12
|
+
* from `state === 'ready'` would otherwise offer a button that fails on tap.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { AdFormat, PlacementState, UnavailableReason } from '../config/types';
|
|
16
|
+
import type { AdsError } from '../diagnostics/errors';
|
|
17
|
+
|
|
18
|
+
export type PlacementEvent =
|
|
19
|
+
| { readonly type: 'LOAD' }
|
|
20
|
+
| { readonly type: 'LOADED'; readonly atMs: number }
|
|
21
|
+
| { readonly type: 'LOAD_FAILED'; readonly error: AdsError }
|
|
22
|
+
| { readonly type: 'SHOW' }
|
|
23
|
+
| { readonly type: 'SHOW_FAILED'; readonly error: AdsError }
|
|
24
|
+
| { readonly type: 'DISMISSED' }
|
|
25
|
+
| { readonly type: 'EXPIRE' }
|
|
26
|
+
| { readonly type: 'UNAVAILABLE'; readonly reason: UnavailableReason }
|
|
27
|
+
| { readonly type: 'RESET' };
|
|
28
|
+
|
|
29
|
+
export interface PlacementSnapshot {
|
|
30
|
+
readonly state: PlacementState;
|
|
31
|
+
readonly loadedAtMs: number | null;
|
|
32
|
+
readonly error: AdsError | null;
|
|
33
|
+
readonly correlationId: string | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const initialPlacement: PlacementSnapshot = {
|
|
37
|
+
state: 'idle',
|
|
38
|
+
loadedAtMs: null,
|
|
39
|
+
error: null,
|
|
40
|
+
correlationId: null,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A load is only legal from a settled state; `loading` is where FR-029 bites.
|
|
45
|
+
* `throttled` is settled too: it records that the last request was refused, and
|
|
46
|
+
* the throttle itself decides whether the next one may proceed.
|
|
47
|
+
*/
|
|
48
|
+
export function canLoad(state: PlacementState): boolean {
|
|
49
|
+
return (
|
|
50
|
+
state === 'idle' ||
|
|
51
|
+
state === 'failed' ||
|
|
52
|
+
state === 'consumed' ||
|
|
53
|
+
state === 'ready' ||
|
|
54
|
+
state === 'throttled'
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Only a ready ad may be shown. Everything else is a determinate refusal (FR-028). */
|
|
59
|
+
export function canShow(state: PlacementState): boolean {
|
|
60
|
+
return state === 'ready';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function placementReducer(
|
|
64
|
+
state: PlacementSnapshot,
|
|
65
|
+
event: PlacementEvent,
|
|
66
|
+
format: AdFormat,
|
|
67
|
+
): PlacementSnapshot {
|
|
68
|
+
switch (event.type) {
|
|
69
|
+
case 'LOAD':
|
|
70
|
+
// FR-029: a load while already loading is absorbed. The instance
|
|
71
|
+
// returns the in-flight promise; the state does not restart.
|
|
72
|
+
if (state.state === 'loading' || state.state === 'showing') {
|
|
73
|
+
return state;
|
|
74
|
+
}
|
|
75
|
+
if (state.state === 'unavailable') {
|
|
76
|
+
return state;
|
|
77
|
+
}
|
|
78
|
+
return { ...initialPlacement, state: 'loading' };
|
|
79
|
+
|
|
80
|
+
case 'LOADED':
|
|
81
|
+
return state.state === 'loading'
|
|
82
|
+
? { ...state, state: 'ready', loadedAtMs: event.atMs, error: null }
|
|
83
|
+
: state;
|
|
84
|
+
|
|
85
|
+
case 'LOAD_FAILED':
|
|
86
|
+
return state.state === 'loading'
|
|
87
|
+
? { ...initialPlacement, state: 'failed', error: event.error }
|
|
88
|
+
: state;
|
|
89
|
+
|
|
90
|
+
case 'SHOW':
|
|
91
|
+
return state.state === 'ready' ? { ...state, state: 'showing' } : state;
|
|
92
|
+
|
|
93
|
+
case 'SHOW_FAILED':
|
|
94
|
+
// A failed presentation consumes the ad regardless: the provider has
|
|
95
|
+
// taken it, and re-showing the same object is what FR-031 forbids.
|
|
96
|
+
return state.state === 'showing'
|
|
97
|
+
? { ...initialPlacement, state: 'failed', error: event.error }
|
|
98
|
+
: state;
|
|
99
|
+
|
|
100
|
+
case 'DISMISSED':
|
|
101
|
+
if (state.state !== 'showing') {
|
|
102
|
+
return state;
|
|
103
|
+
}
|
|
104
|
+
// A banner is continuous — dismissal is not part of its life. A
|
|
105
|
+
// full-screen ad is spent, and must be reloaded before the next show.
|
|
106
|
+
return format === 'banner'
|
|
107
|
+
? { ...initialPlacement, state: 'idle' }
|
|
108
|
+
: { ...initialPlacement, state: 'consumed' };
|
|
109
|
+
|
|
110
|
+
case 'EXPIRE':
|
|
111
|
+
// FR-034. Only a ready-and-waiting ad can go stale; one being shown is
|
|
112
|
+
// already in the user's hands.
|
|
113
|
+
return state.state === 'ready' ? { ...initialPlacement, state: 'idle' } : state;
|
|
114
|
+
|
|
115
|
+
case 'UNAVAILABLE':
|
|
116
|
+
return { ...initialPlacement, state: 'unavailable' };
|
|
117
|
+
|
|
118
|
+
case 'RESET':
|
|
119
|
+
return initialPlacement;
|
|
120
|
+
|
|
121
|
+
default:
|
|
122
|
+
return state;
|
|
123
|
+
}
|
|
124
|
+
}
|
package/src/core/privacyGate.ts
CHANGED
|
@@ -1,94 +1,93 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The privacy gate (FR-016 – FR-021).
|
|
3
|
-
*
|
|
4
|
-
* The package enforces ordering; the app owns policy. It renders no consent UI,
|
|
5
|
-
* writes no legal copy, and decides nothing about a jurisdiction — it accepts a
|
|
6
|
-
* gate outcome and refuses to let any request precede it.
|
|
7
|
-
*
|
|
8
|
-
* That refusal is the whole value. Consent ordering is easy to get wrong by
|
|
9
|
-
* accident, and the failure is invisible: an ad request that should not have
|
|
10
|
-
* happened looks exactly like one that should. A package that enforces the order
|
|
11
|
-
* is worth more than documentation that describes it.
|
|
12
|
-
*
|
|
13
|
-
* Pure — no provider, no React, no I/O. The engine adapter translates the
|
|
14
|
-
* outcome into provider configuration.
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
import type { PrivacyBlockReason, PrivacyOptions } from '../config/types';
|
|
18
|
-
import type { EnginePrivacy, EngineRequestOptions } from '../engine/port';
|
|
19
|
-
|
|
20
|
-
export type GateDecision =
|
|
21
|
-
| { readonly allowed:
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
)
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* The privacy gate (FR-016 – FR-021).
|
|
3
|
+
*
|
|
4
|
+
* The package enforces ordering; the app owns policy. It renders no consent UI,
|
|
5
|
+
* writes no legal copy, and decides nothing about a jurisdiction — it accepts a
|
|
6
|
+
* gate outcome and refuses to let any request precede it.
|
|
7
|
+
*
|
|
8
|
+
* That refusal is the whole value. Consent ordering is easy to get wrong by
|
|
9
|
+
* accident, and the failure is invisible: an ad request that should not have
|
|
10
|
+
* happened looks exactly like one that should. A package that enforces the order
|
|
11
|
+
* is worth more than documentation that describes it.
|
|
12
|
+
*
|
|
13
|
+
* Pure — no provider, no React, no I/O. The engine adapter translates the
|
|
14
|
+
* outcome into provider configuration.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { PrivacyBlockReason, PrivacyOptions } from '../config/types';
|
|
18
|
+
import type { EnginePrivacy, EngineRequestOptions } from '../engine/port';
|
|
19
|
+
|
|
20
|
+
export type GateDecision =
|
|
21
|
+
{ readonly allowed: true } | { readonly allowed: false; readonly reason: PrivacyBlockReason };
|
|
22
|
+
|
|
23
|
+
export function evaluateGate(privacy: PrivacyOptions | null): GateDecision {
|
|
24
|
+
if (!privacy) {
|
|
25
|
+
return { allowed: false, reason: 'consent_required' };
|
|
26
|
+
}
|
|
27
|
+
if (privacy.canRequestAds) {
|
|
28
|
+
return { allowed: true };
|
|
29
|
+
}
|
|
30
|
+
// Distinguishing "not asked yet" from "asked and refused" matters: the first
|
|
31
|
+
// is a prompt the app still owes the user, the second is a settled answer.
|
|
32
|
+
// Collapsing them would leave the app unable to tell which (FR-018).
|
|
33
|
+
return {
|
|
34
|
+
allowed: false,
|
|
35
|
+
reason: privacy.consentUndetermined ? 'consent_undetermined' : 'consent_required',
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Flags the provider only honors at initialization time.
|
|
41
|
+
*
|
|
42
|
+
* Changing one of these after `initialize()` cannot take effect, which is why
|
|
43
|
+
* `updatePrivacy` reports `requires_reinitialization` instead of accepting the
|
|
44
|
+
* change and leaving the package in a state that is half old and half new
|
|
45
|
+
* (FR-020).
|
|
46
|
+
*
|
|
47
|
+
* `canRequestAds` is absent on purpose: resolving the gate after being blocked
|
|
48
|
+
* is the normal, supported flow, not a reinitialization (FR-019).
|
|
49
|
+
*/
|
|
50
|
+
const INIT_TIME_ONLY = [
|
|
51
|
+
'childDirected',
|
|
52
|
+
'underAgeOfConsent',
|
|
53
|
+
'maxAdContentRating',
|
|
54
|
+
] as const satisfies readonly (keyof PrivacyOptions)[];
|
|
55
|
+
|
|
56
|
+
export function requiresReinitialization(
|
|
57
|
+
applied: PrivacyOptions | null,
|
|
58
|
+
next: PrivacyOptions,
|
|
59
|
+
): boolean {
|
|
60
|
+
if (!applied) {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
return INIT_TIME_ONLY.some(field => applied[field] !== next[field]);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Projects consumer privacy options onto what the engine needs.
|
|
68
|
+
*
|
|
69
|
+
* `personalizedAds` is carried through rather than inverted here; the adapter
|
|
70
|
+
* owns the translation to the provider's `requestNonPersonalizedAdsOnly`, so
|
|
71
|
+
* the polarity flip lives next to the API that requires it.
|
|
72
|
+
*/
|
|
73
|
+
export function toEnginePrivacy(
|
|
74
|
+
privacy: PrivacyOptions,
|
|
75
|
+
testDeviceIds: readonly string[],
|
|
76
|
+
): EnginePrivacy {
|
|
77
|
+
return {
|
|
78
|
+
childDirected: privacy.childDirected,
|
|
79
|
+
underAgeOfConsent: privacy.underAgeOfConsent,
|
|
80
|
+
maxAdContentRating: privacy.maxAdContentRating,
|
|
81
|
+
personalizedAds: privacy.personalizedAds,
|
|
82
|
+
testDeviceIds,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* What every ad request carries. Unset means personalized, matching the
|
|
88
|
+
* documented default of `PrivacyOptions.personalizedAds`; only an explicit
|
|
89
|
+
* `false` opts out.
|
|
90
|
+
*/
|
|
91
|
+
export function toEngineRequest(privacy: PrivacyOptions | null): EngineRequestOptions {
|
|
92
|
+
return { personalizedAds: privacy?.personalizedAds !== false };
|
|
93
|
+
}
|