@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,232 +1,249 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Normalized event model and emitter (FR-051, FR-052, FR-054, FR-056).
|
|
3
|
-
*
|
|
4
|
-
* Events are the package's only observability surface. They carry no provider
|
|
5
|
-
* object, no advertising identifier, no consent payload, and nothing that
|
|
6
|
-
* identifies a user — a consuming app forwards them to whatever logging or
|
|
7
|
-
* analytics it already has, and the package depends on none (FR-055).
|
|
8
|
-
*
|
|
9
|
-
* Dispatch iterates a snapshot of the listener set so subscribing or
|
|
10
|
-
* unsubscribing during a dispatch neither skips a listener nor double-invokes
|
|
11
|
-
* one. A listener that throws is caught and reported once; it cannot corrupt a
|
|
12
|
-
* state machine or starve another listener (FR-056).
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import type {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
readonly
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
readonly
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
readonly
|
|
108
|
-
/**
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
'
|
|
137
|
-
'
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
},
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Normalized event model and emitter (FR-051, FR-052, FR-054, FR-056).
|
|
3
|
+
*
|
|
4
|
+
* Events are the package's only observability surface. They carry no provider
|
|
5
|
+
* object, no advertising identifier, no consent payload, and nothing that
|
|
6
|
+
* identifies a user — a consuming app forwards them to whatever logging or
|
|
7
|
+
* analytics it already has, and the package depends on none (FR-055).
|
|
8
|
+
*
|
|
9
|
+
* Dispatch iterates a snapshot of the listener set so subscribing or
|
|
10
|
+
* unsubscribing during a dispatch neither skips a listener nor double-invokes
|
|
11
|
+
* one. A listener that throws is caught and reported once; it cannot corrupt a
|
|
12
|
+
* state machine or starve another listener (FR-056).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type {
|
|
16
|
+
AdFormat,
|
|
17
|
+
PrivacyBlockReason,
|
|
18
|
+
Reward,
|
|
19
|
+
UnavailableReason,
|
|
20
|
+
Unsubscribe,
|
|
21
|
+
} from '../config/types';
|
|
22
|
+
import {
|
|
23
|
+
createDiagnosticChannel,
|
|
24
|
+
createEmitter,
|
|
25
|
+
type DiagnosticSeverity,
|
|
26
|
+
type Emitter,
|
|
27
|
+
} from '@chipmobilesdk/rn-core';
|
|
28
|
+
|
|
29
|
+
import { AdsError, type AdsOperation } from './errors';
|
|
30
|
+
|
|
31
|
+
export type AdEventType =
|
|
32
|
+
| 'initialization'
|
|
33
|
+
| 'load_started'
|
|
34
|
+
| 'loaded'
|
|
35
|
+
| 'load_failed'
|
|
36
|
+
| 'display_started'
|
|
37
|
+
| 'impression'
|
|
38
|
+
| 'click'
|
|
39
|
+
| 'display_failed'
|
|
40
|
+
| 'dismissed'
|
|
41
|
+
| 'reward_earned'
|
|
42
|
+
| 'disabled'
|
|
43
|
+
| 'unavailable'
|
|
44
|
+
| 'config_warning'
|
|
45
|
+
// Emitted once per retry attempt, carrying the correlation id of the load
|
|
46
|
+
// cycle that triggered it. Each attempt is a billable request, so it has to
|
|
47
|
+
// be reconcilable against a bill from the event stream alone (FR-138).
|
|
48
|
+
| 'retry_scheduled'
|
|
49
|
+
// Emitted when the per-placement throttle refuses a request (FR-140). Carries
|
|
50
|
+
// no correlation id because no cycle began: nothing was requested, so there is
|
|
51
|
+
// nothing to correlate. Present so a throttled placement is visible in
|
|
52
|
+
// whatever logging the app already has, rather than looking like a silent
|
|
53
|
+
// absence of ads.
|
|
54
|
+
| 'request_throttled'
|
|
55
|
+
| 'internal_error';
|
|
56
|
+
|
|
57
|
+
interface BaseEvent {
|
|
58
|
+
readonly timestamp: number;
|
|
59
|
+
readonly placementKey?: string;
|
|
60
|
+
readonly format?: AdFormat;
|
|
61
|
+
readonly correlationId?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* `source` records whether the provider reported a true impression or the
|
|
66
|
+
* package derived one. Banners get a real impression callback; full-screen
|
|
67
|
+
* formats have none in the provider's event enum, so theirs is derived from the
|
|
68
|
+
* ad opening. Flattening that away would make the model quietly wrong for anyone
|
|
69
|
+
* reconciling these events against revenue reporting.
|
|
70
|
+
*/
|
|
71
|
+
export type ImpressionSource = 'provider' | 'derived';
|
|
72
|
+
|
|
73
|
+
export type AdEvent = BaseEvent &
|
|
74
|
+
(
|
|
75
|
+
| { readonly type: 'initialization'; readonly status: string; readonly retryable?: boolean }
|
|
76
|
+
| { readonly type: 'load_started' }
|
|
77
|
+
| { readonly type: 'loaded'; readonly durationMs: number }
|
|
78
|
+
| { readonly type: 'load_failed'; readonly error: AdsError }
|
|
79
|
+
| { readonly type: 'display_started' }
|
|
80
|
+
| { readonly type: 'impression'; readonly source: ImpressionSource }
|
|
81
|
+
| { readonly type: 'click' }
|
|
82
|
+
| { readonly type: 'display_failed'; readonly error: AdsError }
|
|
83
|
+
| { readonly type: 'dismissed'; readonly rewardEarned?: boolean }
|
|
84
|
+
| { readonly type: 'reward_earned'; readonly reward: Reward }
|
|
85
|
+
| { readonly type: 'disabled'; readonly operation: AdsOperation }
|
|
86
|
+
| {
|
|
87
|
+
readonly type: 'unavailable';
|
|
88
|
+
readonly operation: AdsOperation;
|
|
89
|
+
readonly reason: UnavailableReason | PrivacyBlockReason;
|
|
90
|
+
}
|
|
91
|
+
| {
|
|
92
|
+
readonly type: 'retry_scheduled';
|
|
93
|
+
/** 1-based: the first retry is attempt 1, not 0. */
|
|
94
|
+
readonly attempt: number;
|
|
95
|
+
readonly delayMs: number;
|
|
96
|
+
readonly error: AdsError;
|
|
97
|
+
}
|
|
98
|
+
| {
|
|
99
|
+
readonly type: 'request_throttled';
|
|
100
|
+
/** How long until this placement may request again. */
|
|
101
|
+
readonly retryAfterMs: number;
|
|
102
|
+
/** The configured floor that refused it, so the cause is readable. */
|
|
103
|
+
readonly minRequestIntervalMs: number;
|
|
104
|
+
}
|
|
105
|
+
| {
|
|
106
|
+
readonly type: 'config_warning';
|
|
107
|
+
readonly warning: ConfigWarning;
|
|
108
|
+
/**
|
|
109
|
+
* Placements the warning concerns. Empty for warnings that are not
|
|
110
|
+
* placement-scoped.
|
|
111
|
+
*
|
|
112
|
+
* Present on every config warning rather than only on the one that
|
|
113
|
+
* uses it, so that 0.1.0 code which reads `placementKeys` after
|
|
114
|
+
* checking `type === 'config_warning'` keeps compiling. Splitting
|
|
115
|
+
* this into one variant per warning would have been tidier and
|
|
116
|
+
* would have broken every such consumer for no behavioral gain.
|
|
117
|
+
*/
|
|
118
|
+
readonly placementKeys: readonly string[];
|
|
119
|
+
/** Consent tools ignored, for `consent_tool_ignored_in_production`. */
|
|
120
|
+
readonly tools?: readonly string[];
|
|
121
|
+
/** How long the app left the state unresolved, for the undecided warning. */
|
|
122
|
+
readonly elapsedMs?: number;
|
|
123
|
+
}
|
|
124
|
+
| { readonly type: 'internal_error'; readonly error: AdsError }
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Configuration problems worth telling the app about, all under one event type.
|
|
129
|
+
*
|
|
130
|
+
* One type rather than three because FR-132 forbids a parallel vocabulary, and
|
|
131
|
+
* because a consumer already routing `config_warning` into its logging should
|
|
132
|
+
* not have to subscribe again to keep seeing everything.
|
|
133
|
+
*/
|
|
134
|
+
export type ConfigWarning =
|
|
135
|
+
/** A production ad unit was replaced with an official test unit (FR-011a). */
|
|
136
|
+
| 'test_ad_unit_substituted'
|
|
137
|
+
/** The app left `enabled: 'undecided'` unresolved past its window (FR-119). */
|
|
138
|
+
| 'enablement_undecided_timeout'
|
|
139
|
+
/** Consent testing tools were supplied in production and ignored (FR-113). */
|
|
140
|
+
| 'consent_tool_ignored_in_production';
|
|
141
|
+
|
|
142
|
+
export type AdEventListener = (event: AdEvent) => void;
|
|
143
|
+
|
|
144
|
+
/** Events that may occur at most once within one correlated cycle (FR-054). */
|
|
145
|
+
const ONCE_PER_CYCLE: ReadonlySet<AdEventType> = new Set<AdEventType>([
|
|
146
|
+
'loaded',
|
|
147
|
+
'display_started',
|
|
148
|
+
'impression',
|
|
149
|
+
'dismissed',
|
|
150
|
+
'reward_earned',
|
|
151
|
+
]);
|
|
152
|
+
|
|
153
|
+
export interface AdEventEmitter {
|
|
154
|
+
emit(event: AdEvent): void;
|
|
155
|
+
subscribe(listener: AdEventListener): Unsubscribe;
|
|
156
|
+
/** Releases every listener and the once-per-cycle bookkeeping (FR-035). */
|
|
157
|
+
dispose(): void;
|
|
158
|
+
listenerCount(): number;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* How many once-per-cycle keys the emitter remembers. A cycle's duplicates arrive
|
|
163
|
+
* within that cycle, so forgetting the oldest keys past this bound loses nothing,
|
|
164
|
+
* and memory stays flat however long the instance lives (CORE-12, ADS-05).
|
|
165
|
+
*/
|
|
166
|
+
const DEDUPE_CAPACITY = 256;
|
|
167
|
+
|
|
168
|
+
/** Every delivered event also goes to the app's SDK sinks (`installSdkSink`, CORE-08). */
|
|
169
|
+
const channel = createDiagnosticChannel<AdEventType>('rn-ads');
|
|
170
|
+
|
|
171
|
+
function severityOf(event: AdEvent): DiagnosticSeverity {
|
|
172
|
+
switch (event.type) {
|
|
173
|
+
case 'internal_error':
|
|
174
|
+
return 'error';
|
|
175
|
+
case 'load_failed':
|
|
176
|
+
case 'display_failed':
|
|
177
|
+
case 'config_warning':
|
|
178
|
+
return 'warn';
|
|
179
|
+
default:
|
|
180
|
+
return 'info';
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** What a sink may see: where and which cycle, and an error's code. Never a payload. */
|
|
185
|
+
function contextOf(event: AdEvent): Record<string, unknown> {
|
|
186
|
+
return {
|
|
187
|
+
...(event.placementKey === undefined ? {} : { placementKey: event.placementKey }),
|
|
188
|
+
...(event.format === undefined ? {} : { format: event.format }),
|
|
189
|
+
...('error' in event ? { code: event.error.code, retryable: event.error.retryable } : {}),
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function createEventEmitter(): AdEventEmitter {
|
|
194
|
+
// The event whose listeners are running, so a listener's failure is reported
|
|
195
|
+
// against the placement and cycle it broke (FR-056).
|
|
196
|
+
let dispatching: AdEvent | null = null;
|
|
197
|
+
const emitter: Emitter<AdEvent> = createEmitter<AdEvent>({
|
|
198
|
+
onListenerError(error) {
|
|
199
|
+
emitter.emit({
|
|
200
|
+
type: 'internal_error',
|
|
201
|
+
timestamp: Date.now(),
|
|
202
|
+
placementKey: dispatching?.placementKey,
|
|
203
|
+
format: dispatching?.format,
|
|
204
|
+
correlationId: dispatching?.correlationId,
|
|
205
|
+
error: new AdsError('internal_error', {
|
|
206
|
+
message: 'consumer event listener threw',
|
|
207
|
+
cause: error,
|
|
208
|
+
}),
|
|
209
|
+
});
|
|
210
|
+
},
|
|
211
|
+
dedupe: {
|
|
212
|
+
key: event =>
|
|
213
|
+
event.correlationId && ONCE_PER_CYCLE.has(event.type)
|
|
214
|
+
? `${event.correlationId}:${event.type}`
|
|
215
|
+
: undefined,
|
|
216
|
+
capacity: DEDUPE_CAPACITY,
|
|
217
|
+
},
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
emit(event) {
|
|
222
|
+
// After dispose, a late provider callback updates nothing and emits
|
|
223
|
+
// nothing (FR-035, SC-008); the rn-core emitter enforces both.
|
|
224
|
+
const previous = dispatching;
|
|
225
|
+
dispatching = event;
|
|
226
|
+
try {
|
|
227
|
+
if (emitter.emit(event)) {
|
|
228
|
+
channel.emit(event.type, severityOf(event), contextOf(event), event.correlationId);
|
|
229
|
+
}
|
|
230
|
+
} finally {
|
|
231
|
+
dispatching = previous;
|
|
232
|
+
}
|
|
233
|
+
},
|
|
234
|
+
// Idempotent unsubscribe (FR-005).
|
|
235
|
+
subscribe: listener => emitter.subscribe(listener),
|
|
236
|
+
dispose: () => emitter.dispose(),
|
|
237
|
+
listenerCount: () => emitter.listenerCount(),
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Normalizes a provider reward descriptor. No provider object crosses this (FR-047). */
|
|
242
|
+
export function normalizeReward(value: unknown): Reward {
|
|
243
|
+
const source = (value ?? {}) as { type?: unknown; amount?: unknown };
|
|
244
|
+
const amount = Number(source.amount);
|
|
245
|
+
return {
|
|
246
|
+
type: typeof source.type === 'string' ? source.type : 'reward',
|
|
247
|
+
amount: Number.isFinite(amount) ? amount : 0,
|
|
248
|
+
};
|
|
249
|
+
}
|