@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,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalized error model (FR-053) and redaction (NFR-006).
|
|
3
|
+
*
|
|
4
|
+
* Codes are stable public API: renaming one is a breaking change under LIB-008.
|
|
5
|
+
* They exist so a consuming app branches on `code` and `retryable` rather than
|
|
6
|
+
* string-matching a provider message that may change under it.
|
|
7
|
+
*
|
|
8
|
+
* `cause` is a redacted string, never the provider's error object. A provider
|
|
9
|
+
* object reaching a consumer would be a contract leak (FR-002) and a potential
|
|
10
|
+
* carrier for identifiers the package promises never to emit (FR-021).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { AdFormat } from '../config/types';
|
|
14
|
+
|
|
15
|
+
export type AdsOperation = 'initialize' | 'load' | 'show' | 'configure';
|
|
16
|
+
|
|
17
|
+
export type AdsErrorCode =
|
|
18
|
+
// Configuration — none retryable; the configuration has to change.
|
|
19
|
+
| 'invalid_config'
|
|
20
|
+
| 'duplicate_placement_key'
|
|
21
|
+
| 'missing_ad_unit_id'
|
|
22
|
+
| 'format_mismatch'
|
|
23
|
+
| 'placement_not_found'
|
|
24
|
+
| 'production_in_dev_build'
|
|
25
|
+
| 'timeout_out_of_bounds'
|
|
26
|
+
// Availability — none retryable within this process.
|
|
27
|
+
| 'unsupported_platform'
|
|
28
|
+
| 'provider_missing'
|
|
29
|
+
| 'disabled'
|
|
30
|
+
// Privacy — resolved by consent changing, not by calling again.
|
|
31
|
+
| 'consent_required'
|
|
32
|
+
| 'consent_undetermined'
|
|
33
|
+
| 'requires_reinitialization'
|
|
34
|
+
// Runtime.
|
|
35
|
+
| 'no_fill'
|
|
36
|
+
| 'network_error'
|
|
37
|
+
| 'load_timeout'
|
|
38
|
+
| 'show_timeout'
|
|
39
|
+
| 'ad_expired'
|
|
40
|
+
| 'not_ready'
|
|
41
|
+
| 'busy'
|
|
42
|
+
| 'consumed'
|
|
43
|
+
| 'app_inactive'
|
|
44
|
+
| 'provider_error'
|
|
45
|
+
| 'internal_error';
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* "Retrying this operation could plausibly succeed" — not "retry immediately".
|
|
49
|
+
* The package ships no automatic retry in v1; pacing belongs to the app, which
|
|
50
|
+
* is the only party that knows what a retry costs its users (FR-036).
|
|
51
|
+
*/
|
|
52
|
+
const RETRYABLE: ReadonlySet<AdsErrorCode> = new Set<AdsErrorCode>([
|
|
53
|
+
'no_fill',
|
|
54
|
+
'network_error',
|
|
55
|
+
'load_timeout',
|
|
56
|
+
'show_timeout',
|
|
57
|
+
'ad_expired',
|
|
58
|
+
'not_ready',
|
|
59
|
+
'busy',
|
|
60
|
+
'consumed',
|
|
61
|
+
'app_inactive',
|
|
62
|
+
'provider_error',
|
|
63
|
+
]);
|
|
64
|
+
|
|
65
|
+
export interface AdsErrorOptions {
|
|
66
|
+
readonly message?: string;
|
|
67
|
+
readonly operation?: AdsOperation;
|
|
68
|
+
readonly placementKey?: string;
|
|
69
|
+
readonly format?: AdFormat;
|
|
70
|
+
readonly cause?: unknown;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export class AdsError extends Error {
|
|
74
|
+
readonly code: AdsErrorCode;
|
|
75
|
+
readonly retryable: boolean;
|
|
76
|
+
readonly operation?: AdsOperation;
|
|
77
|
+
readonly placementKey?: string;
|
|
78
|
+
readonly format?: AdFormat;
|
|
79
|
+
/** Redacted provider detail. Never a provider object. */
|
|
80
|
+
readonly cause?: string;
|
|
81
|
+
|
|
82
|
+
constructor(code: AdsErrorCode, options: AdsErrorOptions = {}) {
|
|
83
|
+
super(options.message ?? code);
|
|
84
|
+
this.name = 'AdsError';
|
|
85
|
+
this.code = code;
|
|
86
|
+
this.retryable = RETRYABLE.has(code);
|
|
87
|
+
this.operation = options.operation;
|
|
88
|
+
this.placementKey = options.placementKey;
|
|
89
|
+
this.format = options.format;
|
|
90
|
+
this.cause = options.cause === undefined ? undefined : redact(options.cause);
|
|
91
|
+
Object.setPrototypeOf(this, AdsError.prototype);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Configuration faults are thrown rather than returned, because they mean the
|
|
97
|
+
* app is wired wrong and no runtime result could describe a usable outcome.
|
|
98
|
+
* Every other failure in the package is a typed result (FR-004).
|
|
99
|
+
*/
|
|
100
|
+
export class AdsConfigError extends AdsError {
|
|
101
|
+
constructor(code: AdsErrorCode, options: AdsErrorOptions = {}) {
|
|
102
|
+
super(code, { ...options, operation: options.operation ?? 'configure' });
|
|
103
|
+
this.name = 'AdsConfigError';
|
|
104
|
+
Object.setPrototypeOf(this, AdsConfigError.prototype);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function isAdsError(value: unknown): value is AdsError {
|
|
109
|
+
return value instanceof AdsError;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const REDACTED = '[redacted]';
|
|
113
|
+
|
|
114
|
+
/** An AdMob unit or app id. Redacted so a log never carries a live identifier. */
|
|
115
|
+
const AD_UNIT_PATTERN = /ca-app-pub-\d+[~/]\d+/g;
|
|
116
|
+
/** GAID and any other dashed UUID an SDK might put in a message. */
|
|
117
|
+
const UUID_PATTERN =
|
|
118
|
+
/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/g;
|
|
119
|
+
/** Base64-ish runs long enough to be an encoded payload such as a consent string. */
|
|
120
|
+
const PAYLOAD_PATTERN = /[A-Za-z0-9+/=_-]{40,}/g;
|
|
121
|
+
/** Anything path- or URL-like, which can carry query parameters with identifiers. */
|
|
122
|
+
const URL_PATTERN = /[a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^\s"']+/g;
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Keys dropped wholesale rather than pattern-matched, because their values are
|
|
126
|
+
* sensitive regardless of shape — a bare advertising id has no separator for a
|
|
127
|
+
* pattern to catch.
|
|
128
|
+
*/
|
|
129
|
+
const SENSITIVE_KEYS = new Set([
|
|
130
|
+
'adid',
|
|
131
|
+
'adUnitId',
|
|
132
|
+
'advertisingId',
|
|
133
|
+
'consent',
|
|
134
|
+
'consentString',
|
|
135
|
+
'gaid',
|
|
136
|
+
'idfa',
|
|
137
|
+
'tcString',
|
|
138
|
+
'token',
|
|
139
|
+
'userId',
|
|
140
|
+
]);
|
|
141
|
+
|
|
142
|
+
function redactString(value: string): string {
|
|
143
|
+
return value
|
|
144
|
+
.replace(URL_PATTERN, REDACTED)
|
|
145
|
+
.replace(AD_UNIT_PATTERN, REDACTED)
|
|
146
|
+
.replace(UUID_PATTERN, REDACTED)
|
|
147
|
+
.replace(PAYLOAD_PATTERN, REDACTED);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Reduces an arbitrary provider value to a safe string (FR-053, NFR-006).
|
|
152
|
+
*
|
|
153
|
+
* Returns a string rather than a structure on purpose: a redacted object still
|
|
154
|
+
* hands the consumer a reference into provider-owned memory, and the contract
|
|
155
|
+
* promises no provider object crosses the boundary.
|
|
156
|
+
*/
|
|
157
|
+
export function redact(value: unknown, depth = 0): string {
|
|
158
|
+
if (value === null || value === undefined) {
|
|
159
|
+
return '';
|
|
160
|
+
}
|
|
161
|
+
if (typeof value === 'string') {
|
|
162
|
+
return redactString(value);
|
|
163
|
+
}
|
|
164
|
+
if (typeof value === 'number' || typeof value === 'boolean') {
|
|
165
|
+
return String(value);
|
|
166
|
+
}
|
|
167
|
+
if (value instanceof Error) {
|
|
168
|
+
return redactString(value.message);
|
|
169
|
+
}
|
|
170
|
+
if (depth >= 2) {
|
|
171
|
+
return REDACTED;
|
|
172
|
+
}
|
|
173
|
+
if (Array.isArray(value)) {
|
|
174
|
+
return value.map(item => redact(item, depth + 1)).join(', ');
|
|
175
|
+
}
|
|
176
|
+
if (typeof value === 'object') {
|
|
177
|
+
const parts: string[] = [];
|
|
178
|
+
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
|
|
179
|
+
if (SENSITIVE_KEYS.has(key)) {
|
|
180
|
+
parts.push(`${key}=${REDACTED}`);
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
parts.push(`${key}=${redact(entry, depth + 1)}`);
|
|
184
|
+
}
|
|
185
|
+
return parts.join(' ');
|
|
186
|
+
}
|
|
187
|
+
return REDACTED;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Maps a provider failure onto the normalized taxonomy. Provider codes vary by
|
|
192
|
+
* SDK version, so the match is on substrings that have been stable across
|
|
193
|
+
* releases, and anything unrecognized becomes a retryable `provider_error`
|
|
194
|
+
* rather than being silently reshaped into a code that means something specific.
|
|
195
|
+
*/
|
|
196
|
+
export function normalizeProviderError(
|
|
197
|
+
value: unknown,
|
|
198
|
+
options: AdsErrorOptions = {},
|
|
199
|
+
): AdsError {
|
|
200
|
+
const text = value instanceof Error ? value.message : String(value ?? '');
|
|
201
|
+
const lowered = text.toLowerCase();
|
|
202
|
+
|
|
203
|
+
if (lowered.includes('no-fill') || lowered.includes('no fill')) {
|
|
204
|
+
return new AdsError('no_fill', { ...options, cause: value });
|
|
205
|
+
}
|
|
206
|
+
if (lowered.includes('network')) {
|
|
207
|
+
return new AdsError('network_error', { ...options, cause: value });
|
|
208
|
+
}
|
|
209
|
+
return new AdsError('provider_error', { ...options, cause: value });
|
|
210
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
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 { AdFormat, PrivacyBlockReason, Reward, UnavailableReason, Unsubscribe } from '../config/types';
|
|
16
|
+
import { AdsError, type AdsErrorCode, type AdsOperation } from './errors';
|
|
17
|
+
|
|
18
|
+
export type AdEventType =
|
|
19
|
+
| 'initialization'
|
|
20
|
+
| 'load_started'
|
|
21
|
+
| 'loaded'
|
|
22
|
+
| 'load_failed'
|
|
23
|
+
| 'display_started'
|
|
24
|
+
| 'impression'
|
|
25
|
+
| 'click'
|
|
26
|
+
| 'display_failed'
|
|
27
|
+
| 'dismissed'
|
|
28
|
+
| 'reward_earned'
|
|
29
|
+
| 'disabled'
|
|
30
|
+
| 'unavailable'
|
|
31
|
+
| 'config_warning'
|
|
32
|
+
| 'internal_error';
|
|
33
|
+
|
|
34
|
+
interface BaseEvent {
|
|
35
|
+
readonly timestamp: number;
|
|
36
|
+
readonly placementKey?: string;
|
|
37
|
+
readonly format?: AdFormat;
|
|
38
|
+
readonly correlationId?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* `source` records whether the provider reported a true impression or the
|
|
43
|
+
* package derived one. Banners get a real impression callback; full-screen
|
|
44
|
+
* formats have none in the provider's event enum, so theirs is derived from the
|
|
45
|
+
* ad opening. Flattening that away would make the model quietly wrong for anyone
|
|
46
|
+
* reconciling these events against revenue reporting.
|
|
47
|
+
*/
|
|
48
|
+
export type ImpressionSource = 'provider' | 'derived';
|
|
49
|
+
|
|
50
|
+
export type AdEvent = BaseEvent &
|
|
51
|
+
(
|
|
52
|
+
| { readonly type: 'initialization'; readonly status: string; readonly retryable?: boolean }
|
|
53
|
+
| { readonly type: 'load_started' }
|
|
54
|
+
| { readonly type: 'loaded'; readonly durationMs: number }
|
|
55
|
+
| { readonly type: 'load_failed'; readonly error: AdsError }
|
|
56
|
+
| { readonly type: 'display_started' }
|
|
57
|
+
| { readonly type: 'impression'; readonly source: ImpressionSource }
|
|
58
|
+
| { readonly type: 'click' }
|
|
59
|
+
| { readonly type: 'display_failed'; readonly error: AdsError }
|
|
60
|
+
| { readonly type: 'dismissed'; readonly rewardEarned?: boolean }
|
|
61
|
+
| { readonly type: 'reward_earned'; readonly reward: Reward }
|
|
62
|
+
| { readonly type: 'disabled'; readonly operation: AdsOperation }
|
|
63
|
+
| {
|
|
64
|
+
readonly type: 'unavailable';
|
|
65
|
+
readonly operation: AdsOperation;
|
|
66
|
+
readonly reason: UnavailableReason | PrivacyBlockReason;
|
|
67
|
+
}
|
|
68
|
+
| {
|
|
69
|
+
readonly type: 'config_warning';
|
|
70
|
+
readonly warning: 'test_ad_unit_substituted';
|
|
71
|
+
readonly placementKeys: readonly string[];
|
|
72
|
+
}
|
|
73
|
+
| { readonly type: 'internal_error'; readonly error: AdsError }
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
export type AdEventListener = (event: AdEvent) => void;
|
|
77
|
+
|
|
78
|
+
/** Events that may occur at most once within one correlated cycle (FR-054). */
|
|
79
|
+
const ONCE_PER_CYCLE: ReadonlySet<AdEventType> = new Set<AdEventType>([
|
|
80
|
+
'loaded',
|
|
81
|
+
'display_started',
|
|
82
|
+
'impression',
|
|
83
|
+
'dismissed',
|
|
84
|
+
'reward_earned',
|
|
85
|
+
]);
|
|
86
|
+
|
|
87
|
+
export interface AdEventEmitter {
|
|
88
|
+
emit(event: AdEvent): void;
|
|
89
|
+
subscribe(listener: AdEventListener): Unsubscribe;
|
|
90
|
+
/** Releases every listener and the once-per-cycle bookkeeping (FR-035). */
|
|
91
|
+
dispose(): void;
|
|
92
|
+
listenerCount(): number;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function createEventEmitter(): AdEventEmitter {
|
|
96
|
+
const listeners = new Set<AdEventListener>();
|
|
97
|
+
// `${correlationId}:${type}` for everything already emitted in a cycle.
|
|
98
|
+
const emittedOnce = new Set<string>();
|
|
99
|
+
let disposed = false;
|
|
100
|
+
// Guards against an internal_error thrown by a listener re-entering dispatch
|
|
101
|
+
// and recursing forever.
|
|
102
|
+
let reportingListenerFailure = false;
|
|
103
|
+
|
|
104
|
+
function dispatch(event: AdEvent): void {
|
|
105
|
+
for (const listener of Array.from(listeners)) {
|
|
106
|
+
try {
|
|
107
|
+
listener(event);
|
|
108
|
+
} catch (error) {
|
|
109
|
+
if (reportingListenerFailure) {
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
reportingListenerFailure = true;
|
|
113
|
+
try {
|
|
114
|
+
dispatch({
|
|
115
|
+
type: 'internal_error',
|
|
116
|
+
timestamp: Date.now(),
|
|
117
|
+
placementKey: event.placementKey,
|
|
118
|
+
format: event.format,
|
|
119
|
+
correlationId: event.correlationId,
|
|
120
|
+
error: new AdsError('internal_error' as AdsErrorCode, {
|
|
121
|
+
message: 'consumer event listener threw',
|
|
122
|
+
cause: error,
|
|
123
|
+
}),
|
|
124
|
+
});
|
|
125
|
+
} finally {
|
|
126
|
+
reportingListenerFailure = false;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
emit(event) {
|
|
134
|
+
// After dispose, a late provider callback updates nothing and emits
|
|
135
|
+
// nothing (FR-035, SC-008).
|
|
136
|
+
if (disposed) {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (event.correlationId && ONCE_PER_CYCLE.has(event.type)) {
|
|
140
|
+
const key = `${event.correlationId}:${event.type}`;
|
|
141
|
+
if (emittedOnce.has(key)) {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
emittedOnce.add(key);
|
|
145
|
+
}
|
|
146
|
+
dispatch(event);
|
|
147
|
+
},
|
|
148
|
+
subscribe(listener) {
|
|
149
|
+
listeners.add(listener);
|
|
150
|
+
let active = true;
|
|
151
|
+
return () => {
|
|
152
|
+
// Idempotent: a second call is a no-op, not an error (FR-005).
|
|
153
|
+
if (!active) {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
active = false;
|
|
157
|
+
listeners.delete(listener);
|
|
158
|
+
};
|
|
159
|
+
},
|
|
160
|
+
dispose() {
|
|
161
|
+
disposed = true;
|
|
162
|
+
listeners.clear();
|
|
163
|
+
emittedOnce.clear();
|
|
164
|
+
},
|
|
165
|
+
listenerCount() {
|
|
166
|
+
return listeners.size;
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Normalizes a provider reward descriptor. No provider object crosses this (FR-047). */
|
|
172
|
+
export function normalizeReward(value: unknown): Reward {
|
|
173
|
+
const source = (value ?? {}) as { type?: unknown; amount?: unknown };
|
|
174
|
+
const amount = Number(source.amount);
|
|
175
|
+
return {
|
|
176
|
+
type: typeof source.type === 'string' ? source.type : 'reward',
|
|
177
|
+
amount: Number.isFinite(amount) ? amount : 0,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default AdsEngine adapter for Google AdMob on Android.
|
|
3
|
+
*
|
|
4
|
+
* **This is the only module in the package that knows an ad provider exists.**
|
|
5
|
+
* It lazily requires `react-native-google-mobile-ads`, mirroring
|
|
6
|
+
* `packages/rn-camera/src/native/cameraEngine.ts`, so unit tests that inject the
|
|
7
|
+
* fake never load native code and an unsupported platform never loads it at all.
|
|
8
|
+
*
|
|
9
|
+
* Targets bridge 16.0.3, whose ads SDK (24.9.0) predates the Kotlin 2.3 metadata
|
|
10
|
+
* bump that makes newer releases uncompilable under React Native 0.85's pinned
|
|
11
|
+
* Kotlin compiler — see specs/011-rn-advertising-sdk/research.md (R2).
|
|
12
|
+
*
|
|
13
|
+
* Ordering note: `setRequestConfiguration` must run *before* `initialize`, or
|
|
14
|
+
* the child-directed and under-age flags do not take effect. That is why FR-020
|
|
15
|
+
* treats a later change as requiring re-initialization rather than pretending it
|
|
16
|
+
* applied.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { Platform } from 'react-native';
|
|
20
|
+
|
|
21
|
+
import type { AdContentRating, FullScreenFormat, Unsubscribe } from '../config/types';
|
|
22
|
+
import type { AdsEngine, EngineAdEvent, EngineAdHandle, EnginePrivacy } from './port';
|
|
23
|
+
|
|
24
|
+
/** The slice of the bridge this adapter uses. Kept local so nothing else sees it. */
|
|
25
|
+
interface ProviderFullScreenAd {
|
|
26
|
+
load(): void;
|
|
27
|
+
show(): Promise<unknown>;
|
|
28
|
+
addAdEventListener(type: string, listener: (payload?: unknown) => void): () => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface ProviderModule {
|
|
32
|
+
MobileAds: () => {
|
|
33
|
+
initialize(): Promise<unknown>;
|
|
34
|
+
setRequestConfiguration(config: Record<string, unknown>): Promise<unknown>;
|
|
35
|
+
};
|
|
36
|
+
InterstitialAd: {
|
|
37
|
+
createForAdRequest(adUnitId: string, options?: Record<string, unknown>): ProviderFullScreenAd;
|
|
38
|
+
};
|
|
39
|
+
RewardedAd: {
|
|
40
|
+
createForAdRequest(adUnitId: string, options?: Record<string, unknown>): ProviderFullScreenAd;
|
|
41
|
+
};
|
|
42
|
+
AdEventType: Record<string, string>;
|
|
43
|
+
RewardedAdEventType: Record<string, string>;
|
|
44
|
+
MaxAdContentRating: Record<string, string>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function loadProvider(): ProviderModule | null {
|
|
48
|
+
try {
|
|
49
|
+
return require('react-native-google-mobile-ads') as ProviderModule;
|
|
50
|
+
} catch {
|
|
51
|
+
// The bridge is a required peer, but a consumer can still have it missing
|
|
52
|
+
// mid-install or excluded from a build. Reporting `provider_missing`
|
|
53
|
+
// beats crashing the host app's launch (FR-025, NFR-005).
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function toProviderRating(
|
|
59
|
+
rating: AdContentRating | undefined,
|
|
60
|
+
ratings: Record<string, string>,
|
|
61
|
+
): string | undefined {
|
|
62
|
+
return rating ? ratings[rating] : undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** One unsubscribe covering every listener that was actually registered. */
|
|
66
|
+
function makeUnsubscribe(removers: readonly (() => void)[]): Unsubscribe {
|
|
67
|
+
let active = true;
|
|
68
|
+
return () => {
|
|
69
|
+
if (!active) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
active = false;
|
|
73
|
+
for (const remove of removers) {
|
|
74
|
+
try {
|
|
75
|
+
remove();
|
|
76
|
+
} catch {
|
|
77
|
+
// A provider that throws on removal must not prevent the rest of
|
|
78
|
+
// cleanup from running (FR-035).
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function createAdMobEngine(): AdsEngine {
|
|
85
|
+
// v1 is Android-only. On any other platform this stays null and every
|
|
86
|
+
// operation resolves to `unsupported_platform` (SC-004, scenario 12).
|
|
87
|
+
const supported = Platform.OS === 'android';
|
|
88
|
+
let provider: ProviderModule | null = null;
|
|
89
|
+
let providerResolved = false;
|
|
90
|
+
|
|
91
|
+
function getProvider(): ProviderModule | null {
|
|
92
|
+
if (!supported) {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
if (!providerResolved) {
|
|
96
|
+
providerResolved = true;
|
|
97
|
+
provider = loadProvider();
|
|
98
|
+
}
|
|
99
|
+
return provider;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// The format is kept alongside the ad because the provider validates which
|
|
103
|
+
// event types a given ad object accepts, and rejects the rewarded ones on an
|
|
104
|
+
// interstitial by throwing (see `subscribe`).
|
|
105
|
+
const ads = new Map<string, { ad: ProviderFullScreenAd; format: FullScreenFormat }>();
|
|
106
|
+
let counter = 0;
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
isAvailable() {
|
|
110
|
+
return supported && getProvider() !== null;
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
async initialize(privacy: EnginePrivacy) {
|
|
114
|
+
const module = getProvider();
|
|
115
|
+
if (!module) {
|
|
116
|
+
throw new Error('provider_missing');
|
|
117
|
+
}
|
|
118
|
+
const mobileAds = module.MobileAds();
|
|
119
|
+
|
|
120
|
+
// Before initialize(), deliberately. See the ordering note above.
|
|
121
|
+
await mobileAds.setRequestConfiguration({
|
|
122
|
+
tagForChildDirectedTreatment: privacy.childDirected,
|
|
123
|
+
tagForUnderAgeOfConsent: privacy.underAgeOfConsent,
|
|
124
|
+
maxAdContentRating: toProviderRating(
|
|
125
|
+
privacy.maxAdContentRating,
|
|
126
|
+
module.MaxAdContentRating,
|
|
127
|
+
),
|
|
128
|
+
testDeviceIdentifiers: privacy.testDeviceIds ? [...privacy.testDeviceIds] : undefined,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
await mobileAds.initialize();
|
|
132
|
+
},
|
|
133
|
+
|
|
134
|
+
async createFullScreen(format: FullScreenFormat, adUnitId: string) {
|
|
135
|
+
const module = getProvider();
|
|
136
|
+
if (!module) {
|
|
137
|
+
throw new Error('provider_missing');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const factory = format === 'rewarded' ? module.RewardedAd : module.InterstitialAd;
|
|
141
|
+
const ad = factory.createForAdRequest(adUnitId, {
|
|
142
|
+
// The consumer-facing polarity is `personalizedAds`; the provider's
|
|
143
|
+
// is the inverse. The flip lives here, next to the API needing it.
|
|
144
|
+
requestNonPersonalizedAdsOnly: false,
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
counter += 1;
|
|
148
|
+
const id = `admob-${counter}`;
|
|
149
|
+
ads.set(id, { ad, format });
|
|
150
|
+
// The provider loads asynchronously; callbacks arrive through subscribe.
|
|
151
|
+
ad.load();
|
|
152
|
+
return { id } satisfies EngineAdHandle;
|
|
153
|
+
},
|
|
154
|
+
|
|
155
|
+
async show(handle) {
|
|
156
|
+
const entry = ads.get(handle.id);
|
|
157
|
+
if (!entry) {
|
|
158
|
+
throw new Error('ad handle disposed');
|
|
159
|
+
}
|
|
160
|
+
await entry.ad.show();
|
|
161
|
+
},
|
|
162
|
+
|
|
163
|
+
subscribe(handle, listener): Unsubscribe {
|
|
164
|
+
const module = getProvider();
|
|
165
|
+
const entry = ads.get(handle.id);
|
|
166
|
+
if (!module || !entry) {
|
|
167
|
+
return () => undefined;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const { ad, format } = entry;
|
|
171
|
+
const { AdEventType, RewardedAdEventType } = module;
|
|
172
|
+
const removers: Array<() => void> = [];
|
|
173
|
+
|
|
174
|
+
const on = (type: string | undefined, map: (payload?: unknown) => EngineAdEvent | null) => {
|
|
175
|
+
if (!type) {
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
try {
|
|
179
|
+
removers.push(
|
|
180
|
+
ad.addAdEventListener(type, payload => {
|
|
181
|
+
const event = map(payload);
|
|
182
|
+
if (event) {
|
|
183
|
+
listener(event);
|
|
184
|
+
}
|
|
185
|
+
}),
|
|
186
|
+
);
|
|
187
|
+
} catch {
|
|
188
|
+
// The provider validates event types per ad object and throws on
|
|
189
|
+
// one it does not accept. Skipping the listener keeps a single
|
|
190
|
+
// rejected type from tearing down the whole subscription — which
|
|
191
|
+
// would fail the load outright while the ad went on to load fine,
|
|
192
|
+
// reporting a `provider_error` for an ad that was never broken.
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
on(AdEventType.LOADED, () => ({ type: 'loaded' }));
|
|
197
|
+
on(AdEventType.ERROR, cause => ({ type: 'failed', cause }));
|
|
198
|
+
on(AdEventType.OPENED, () => ({ type: 'opened' }));
|
|
199
|
+
on(AdEventType.CLICKED, () => ({ type: 'clicked' }));
|
|
200
|
+
on(AdEventType.CLOSED, () => ({ type: 'dismissed' }));
|
|
201
|
+
|
|
202
|
+
// Rewarded-only, and gated on the format because the provider REJECTS
|
|
203
|
+
// these types on an interstitial. The rewarded loaded event is distinct
|
|
204
|
+
// from AdEventType.LOADED, and EARNED_REWARD is distinct from CLOSED —
|
|
205
|
+
// which is precisely what makes FR-046 and FR-048 enforceable rather
|
|
206
|
+
// than a guess about ordering.
|
|
207
|
+
if (format !== 'rewarded') {
|
|
208
|
+
return makeUnsubscribe(removers);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
on(RewardedAdEventType.LOADED, () => ({ type: 'loaded' }));
|
|
212
|
+
on(RewardedAdEventType.EARNED_REWARD, payload => {
|
|
213
|
+
const source = (payload ?? {}) as { type?: unknown; amount?: unknown };
|
|
214
|
+
const amount = Number(source.amount);
|
|
215
|
+
return {
|
|
216
|
+
type: 'rewarded',
|
|
217
|
+
reward: {
|
|
218
|
+
type: typeof source.type === 'string' ? source.type : 'reward',
|
|
219
|
+
amount: Number.isFinite(amount) ? amount : 0,
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
return makeUnsubscribe(removers);
|
|
225
|
+
},
|
|
226
|
+
|
|
227
|
+
dispose(handle) {
|
|
228
|
+
ads.delete(handle.id);
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The internal engine port (NFR-008, FR-006).
|
|
3
|
+
*
|
|
4
|
+
* This interface names no platform and no ad provider. That is what makes an
|
|
5
|
+
* iOS adapter an additive change: it implements this, and nothing in the public
|
|
6
|
+
* contract moves. `core/` talks only to this shape; the AdMob bridge exists
|
|
7
|
+
* behind exactly one implementation of it, in `admobEngine.ts`.
|
|
8
|
+
*
|
|
9
|
+
* Banners are absent on purpose. The provider owns a banner's whole lifecycle
|
|
10
|
+
* through a React component, so `react/AdsBanner` adapts those callbacks
|
|
11
|
+
* directly rather than routing them through a handle model that would have to
|
|
12
|
+
* pretend a banner is a load-then-show resource.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { AdContentRating, FullScreenFormat, Reward, Unsubscribe } from '../config/types';
|
|
16
|
+
|
|
17
|
+
/** Privacy state as the engine needs it — no consumer-facing shape leaks in. */
|
|
18
|
+
export interface EnginePrivacy {
|
|
19
|
+
readonly childDirected?: boolean;
|
|
20
|
+
readonly underAgeOfConsent?: boolean;
|
|
21
|
+
readonly maxAdContentRating?: AdContentRating;
|
|
22
|
+
readonly personalizedAds?: boolean;
|
|
23
|
+
readonly testDeviceIds?: readonly string[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Provider callbacks, normalized to the vocabulary the core state machines use.
|
|
28
|
+
* `rewarded` and `dismissed` are separate members because they are separate
|
|
29
|
+
* facts that can arrive in either order (FR-049).
|
|
30
|
+
*/
|
|
31
|
+
export type EngineAdEvent =
|
|
32
|
+
| { readonly type: 'loaded' }
|
|
33
|
+
| { readonly type: 'failed'; readonly cause: unknown }
|
|
34
|
+
| { readonly type: 'opened' }
|
|
35
|
+
| { readonly type: 'clicked' }
|
|
36
|
+
| { readonly type: 'dismissed' }
|
|
37
|
+
| { readonly type: 'rewarded'; readonly reward: Reward };
|
|
38
|
+
|
|
39
|
+
/** An opaque reference to one loaded ad. Never exposed publicly. */
|
|
40
|
+
export interface EngineAdHandle {
|
|
41
|
+
readonly id: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface AdsEngine {
|
|
45
|
+
/**
|
|
46
|
+
* True when this platform has a working adapter. Checked before anything
|
|
47
|
+
* else so an unsupported platform never loads the provider at all (FR-025).
|
|
48
|
+
*/
|
|
49
|
+
isAvailable(): boolean;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Applies privacy configuration and then initializes the provider. The order
|
|
53
|
+
* matters: child-directed and under-age flags only take effect if they reach
|
|
54
|
+
* the provider before it initializes (FR-017, R9).
|
|
55
|
+
*
|
|
56
|
+
* The caller guarantees this is invoked at most once per instance; the
|
|
57
|
+
* engine does not have to be idempotent itself.
|
|
58
|
+
*/
|
|
59
|
+
initialize(privacy: EnginePrivacy): Promise<void>;
|
|
60
|
+
|
|
61
|
+
/** Creates and begins loading one full-screen ad. Resolves on request, not on load. */
|
|
62
|
+
createFullScreen(format: FullScreenFormat, adUnitId: string): Promise<EngineAdHandle>;
|
|
63
|
+
|
|
64
|
+
/** Presents a loaded ad. Rejects if the provider refuses to present. */
|
|
65
|
+
show(handle: EngineAdHandle): Promise<void>;
|
|
66
|
+
|
|
67
|
+
/** Provider callbacks for one handle. Returns an unsubscribe (FR-005). */
|
|
68
|
+
subscribe(handle: EngineAdHandle, listener: (event: EngineAdEvent) => void): Unsubscribe;
|
|
69
|
+
|
|
70
|
+
/** Releases the handle's provider resources. Safe to call more than once. */
|
|
71
|
+
dispose(handle: EngineAdHandle): void;
|
|
72
|
+
}
|