@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.
@@ -1,404 +1,399 @@
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 {
23
- AdsEngine,
24
- ConsentPort,
25
- EngineAdEvent,
26
- EngineAdHandle,
27
- EngineConsentInfo,
28
- EngineConsentOptions,
29
- EnginePrivacy,
30
- EngineRequestOptions,
31
- } from './port';
32
-
33
- /** The slice of the bridge this adapter uses. Kept local so nothing else sees it. */
34
- interface ProviderFullScreenAd {
35
- load(): void;
36
- show(): Promise<unknown>;
37
- addAdEventListener(type: string, listener: (payload?: unknown) => void): () => void;
38
- }
39
-
40
- interface ProviderModule {
41
- MobileAds: () => {
42
- initialize(): Promise<unknown>;
43
- setRequestConfiguration(config: Record<string, unknown>): Promise<unknown>;
44
- };
45
- InterstitialAd: {
46
- createForAdRequest(adUnitId: string, options?: Record<string, unknown>): ProviderFullScreenAd;
47
- };
48
- RewardedAd: {
49
- createForAdRequest(adUnitId: string, options?: Record<string, unknown>): ProviderFullScreenAd;
50
- };
51
- AppOpenAd: {
52
- createForAdRequest(adUnitId: string, options?: Record<string, unknown>): ProviderFullScreenAd;
53
- };
54
- AdEventType: Record<string, string>;
55
- RewardedAdEventType: Record<string, string>;
56
- MaxAdContentRating: Record<string, string>;
57
- AdsConsent: ProviderConsent;
58
- AdsConsentDebugGeography: Record<string, number>;
59
- }
60
-
61
- /** The slice of the bridge's consent module this adapter uses. */
62
- interface ProviderConsent {
63
- requestInfoUpdate(options?: Record<string, unknown>): Promise<ProviderConsentInfo>;
64
- loadAndShowConsentFormIfRequired(): Promise<ProviderConsentInfo>;
65
- showPrivacyOptionsForm(): Promise<ProviderConsentInfo>;
66
- getConsentInfo(): Promise<ProviderConsentInfo>;
67
- reset(): void;
68
- }
69
-
70
- interface ProviderConsentInfo {
71
- status?: string;
72
- canRequestAds?: boolean;
73
- isConsentFormAvailable?: boolean;
74
- privacyOptionsRequirementStatus?: string;
75
- }
76
-
77
- /**
78
- * The provider's geography enum is numeric and its spelling is the provider's.
79
- * Translating here rather than in `core/` is the same rule the rest of this file
80
- * follows: the one module that knows a provider exists is the one that speaks
81
- * its vocabulary.
82
- */
83
- function toProviderGeography(
84
- geography: EngineConsentOptions['debugGeography'],
85
- values: Record<string, number>,
86
- ): number | undefined {
87
- switch (geography) {
88
- case 'eea':
89
- return values.EEA;
90
- case 'not_eea':
91
- return values.NOT_EEA;
92
- case 'regulated_us_state':
93
- return values.REGULATED_US_STATE;
94
- case 'other':
95
- return values.OTHER;
96
- case 'disabled':
97
- return values.DISABLED;
98
- default:
99
- return undefined;
100
- }
101
- }
102
-
103
- /**
104
- * Normalizes the provider's consent shape into the engine's.
105
- *
106
- * `canRequestAds` is taken as reported and never inferred from the status: the
107
- * provider is the authority on whether a request is permitted, and second-
108
- * guessing it here is how a package ends up requesting ads it should not
109
- * (FR-106, FR-107).
110
- */
111
- function toEngineConsentInfo(info: ProviderConsentInfo): EngineConsentInfo {
112
- const status = String(info.status ?? 'UNKNOWN').toUpperCase();
113
- const normalized: EngineConsentInfo['status'] =
114
- status === 'OBTAINED'
115
- ? 'obtained'
116
- : status === 'REQUIRED'
117
- ? 'required'
118
- : status === 'NOT_REQUIRED'
119
- ? 'not_required'
120
- : 'unknown';
121
- return {
122
- status: normalized,
123
- canRequestAds: info.canRequestAds === true,
124
- isFormAvailable: info.isConsentFormAvailable === true,
125
- privacyOptions: toEnginePrivacyOptions(info.privacyOptionsRequirementStatus),
126
- };
127
- }
128
-
129
- /**
130
- * Same rule as the status: an unrecognized value is `unknown`, never a guess in
131
- * either direction (FR-150).
132
- */
133
- function toEnginePrivacyOptions(value: string | undefined): EngineConsentInfo['privacyOptions'] {
134
- switch (String(value ?? 'UNKNOWN').toUpperCase()) {
135
- case 'REQUIRED':
136
- return 'required';
137
- case 'NOT_REQUIRED':
138
- return 'not_required';
139
- default:
140
- return 'unknown';
141
- }
142
- }
143
-
144
- function loadProvider(): ProviderModule | null {
145
- try {
146
- return require('react-native-google-mobile-ads') as ProviderModule;
147
- } catch {
148
- // The bridge is a required peer, but a consumer can still have it missing
149
- // mid-install or excluded from a build. Reporting `provider_missing`
150
- // beats crashing the host app's launch (FR-025, NFR-005).
151
- return null;
152
- }
153
- }
154
-
155
- function toProviderRating(
156
- rating: AdContentRating | undefined,
157
- ratings: Record<string, string>,
158
- ): string | undefined {
159
- return rating ? ratings[rating] : undefined;
160
- }
161
-
162
- /** One unsubscribe covering every listener that was actually registered. */
163
- function makeUnsubscribe(removers: readonly (() => void)[]): Unsubscribe {
164
- let active = true;
165
- return () => {
166
- if (!active) {
167
- return;
168
- }
169
- active = false;
170
- for (const remove of removers) {
171
- try {
172
- remove();
173
- } catch {
174
- // A provider that throws on removal must not prevent the rest of
175
- // cleanup from running (FR-035).
176
- }
177
- }
178
- };
179
- }
180
-
181
- export function createAdMobEngine(): AdsEngine {
182
- // v1 is Android-only. On any other platform this stays null and every
183
- // operation resolves to `unsupported_platform` (SC-004, scenario 12).
184
- const supported = Platform.OS === 'android';
185
- let provider: ProviderModule | null = null;
186
- let providerResolved = false;
187
-
188
- function getProvider(): ProviderModule | null {
189
- if (!supported) {
190
- return null;
191
- }
192
- if (!providerResolved) {
193
- providerResolved = true;
194
- provider = loadProvider();
195
- }
196
- return provider;
197
- }
198
-
199
- // The format is kept alongside the ad because the provider validates which
200
- // event types a given ad object accepts, and rejects the rewarded ones on an
201
- // interstitial by throwing (see `subscribe`).
202
- const ads = new Map<string, { ad: ProviderFullScreenAd; format: FullScreenFormat }>();
203
- let counter = 0;
204
-
205
- return {
206
- isAvailable() {
207
- return supported && getProvider() !== null;
208
- },
209
-
210
- async initialize(privacy: EnginePrivacy) {
211
- const module = getProvider();
212
- if (!module) {
213
- throw new Error('provider_missing');
214
- }
215
- const mobileAds = module.MobileAds();
216
-
217
- // Before initialize(), deliberately. See the ordering note above.
218
- await mobileAds.setRequestConfiguration({
219
- tagForChildDirectedTreatment: privacy.childDirected,
220
- tagForUnderAgeOfConsent: privacy.underAgeOfConsent,
221
- maxAdContentRating: toProviderRating(
222
- privacy.maxAdContentRating,
223
- module.MaxAdContentRating,
224
- ),
225
- testDeviceIdentifiers: privacy.testDeviceIds ? [...privacy.testDeviceIds] : undefined,
226
- });
227
-
228
- await mobileAds.initialize();
229
- },
230
-
231
- async createFullScreen(
232
- format: FullScreenFormat,
233
- adUnitId: string,
234
- request?: EngineRequestOptions,
235
- ) {
236
- const module = getProvider();
237
- if (!module) {
238
- throw new Error('provider_missing');
239
- }
240
-
241
- const factory =
242
- format === 'rewarded'
243
- ? module.RewardedAd
244
- : format === 'app_open'
245
- ? module.AppOpenAd
246
- : module.InterstitialAd;
247
- const ad = factory.createForAdRequest(adUnitId, {
248
- // The consumer-facing polarity is `personalizedAds`; the provider's
249
- // is the inverse. The flip lives here, next to the API needing it.
250
- requestNonPersonalizedAdsOnly: request?.personalizedAds === false,
251
- });
252
-
253
- counter += 1;
254
- const id = `admob-${counter}`;
255
- ads.set(id, { ad, format });
256
- // The provider loads asynchronously; callbacks arrive through subscribe.
257
- ad.load();
258
- return { id } satisfies EngineAdHandle;
259
- },
260
-
261
- async show(handle) {
262
- const entry = ads.get(handle.id);
263
- if (!entry) {
264
- throw new Error('ad handle disposed');
265
- }
266
- await entry.ad.show();
267
- },
268
-
269
- subscribe(handle, listener): Unsubscribe {
270
- const module = getProvider();
271
- const entry = ads.get(handle.id);
272
- if (!module || !entry) {
273
- return () => undefined;
274
- }
275
-
276
- const { ad, format } = entry;
277
- const { AdEventType, RewardedAdEventType } = module;
278
- const removers: Array<() => void> = [];
279
-
280
- const on = (type: string | undefined, map: (payload?: unknown) => EngineAdEvent | null) => {
281
- if (!type) {
282
- return;
283
- }
284
- try {
285
- removers.push(
286
- ad.addAdEventListener(type, payload => {
287
- const event = map(payload);
288
- if (event) {
289
- listener(event);
290
- }
291
- }),
292
- );
293
- } catch {
294
- // The provider validates event types per ad object and throws on
295
- // one it does not accept. Skipping the listener keeps a single
296
- // rejected type from tearing down the whole subscription — which
297
- // would fail the load outright while the ad went on to load fine,
298
- // reporting a `provider_error` for an ad that was never broken.
299
- }
300
- };
301
-
302
- on(AdEventType.LOADED, () => ({ type: 'loaded' }));
303
- on(AdEventType.ERROR, cause => ({ type: 'failed', cause }));
304
- on(AdEventType.OPENED, () => ({ type: 'opened' }));
305
- on(AdEventType.CLICKED, () => ({ type: 'clicked' }));
306
- on(AdEventType.CLOSED, () => ({ type: 'dismissed' }));
307
-
308
- // Rewarded-only, and gated on the format because the provider REJECTS
309
- // these types on an interstitial. The rewarded loaded event is distinct
310
- // from AdEventType.LOADED, and EARNED_REWARD is distinct from CLOSED —
311
- // which is precisely what makes FR-046 and FR-048 enforceable rather
312
- // than a guess about ordering.
313
- if (format !== 'rewarded') {
314
- return makeUnsubscribe(removers);
315
- }
316
-
317
- on(RewardedAdEventType.LOADED, () => ({ type: 'loaded' }));
318
- on(RewardedAdEventType.EARNED_REWARD, payload => {
319
- const source = (payload ?? {}) as { type?: unknown; amount?: unknown };
320
- const amount = Number(source.amount);
321
- return {
322
- type: 'rewarded',
323
- reward: {
324
- type: typeof source.type === 'string' ? source.type : 'reward',
325
- amount: Number.isFinite(amount) ? amount : 0,
326
- },
327
- };
328
- });
329
-
330
- return makeUnsubscribe(removers);
331
- },
332
-
333
- dispose(handle) {
334
- ads.delete(handle.id);
335
- },
336
-
337
- consent: createAdMobConsent(getProvider),
338
- };
339
- }
340
-
341
- /**
342
- * The consent platform adapter.
343
- *
344
- * It is deliberately thin. Every decision that could be made here — whether a
345
- * region requires consent, whether an error means "no", whether a dismissed form
346
- * counts — is made elsewhere or not at all: the provider owns the first, and
347
- * `core/consentFlow.ts` owns the second and third. This file's only job is to
348
- * call the right method and hand back a shape that names nothing Google.
349
- */
350
- function createAdMobConsent(getProvider: () => ProviderModule | null): ConsentPort {
351
- function requireConsent(): ProviderConsent {
352
- const module = getProvider();
353
- if (!module?.AdsConsent) {
354
- throw new Error('provider_missing');
355
- }
356
- return module.AdsConsent;
357
- }
358
-
359
- return {
360
- async requestInfoUpdate(options: EngineConsentOptions) {
361
- const module = getProvider();
362
- if (!module?.AdsConsent) {
363
- throw new Error('provider_missing');
364
- }
365
- const debugGeography = toProviderGeography(
366
- options.debugGeography,
367
- module.AdsConsentDebugGeography ?? {},
368
- );
369
- const info = await module.AdsConsent.requestInfoUpdate({
370
- // Passing `undefined` rather than omitting keeps the call shape
371
- // stable; the bridge treats an absent value as "no override".
372
- debugGeography,
373
- tagForUnderAgeOfConsent: options.underAgeOfConsent,
374
- testDeviceIdentifiers: options.testDeviceIds
375
- ? [...options.testDeviceIds]
376
- : undefined,
377
- });
378
- return toEngineConsentInfo(info);
379
- },
380
-
381
- async showFormIfRequired() {
382
- // The provider decides whether a form is needed and renders it. The
383
- // package never draws consent UI of its own (FR-103).
384
- const info = await requireConsent().loadAndShowConsentFormIfRequired();
385
- return toEngineConsentInfo(info);
386
- },
387
-
388
- async showPrivacyOptionsForm() {
389
- // Present in the pinned bridge 16.0.3, so no range change (FR-155).
390
- // Resolves with the consent information as it stands once the form
391
- // closes, whether the user changed anything or not.
392
- const info = await requireConsent().showPrivacyOptionsForm();
393
- return toEngineConsentInfo(info);
394
- },
395
-
396
- async getInfo() {
397
- return toEngineConsentInfo(await requireConsent().getConsentInfo());
398
- },
399
-
400
- reset() {
401
- requireConsent().reset();
402
- },
403
- };
404
- }
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 {
23
+ AdsEngine,
24
+ ConsentPort,
25
+ EngineAdEvent,
26
+ EngineAdHandle,
27
+ EngineConsentInfo,
28
+ EngineConsentOptions,
29
+ EnginePrivacy,
30
+ EngineRequestOptions,
31
+ } from './port';
32
+
33
+ /** The slice of the bridge this adapter uses. Kept local so nothing else sees it. */
34
+ interface ProviderFullScreenAd {
35
+ load(): void;
36
+ show(): Promise<unknown>;
37
+ addAdEventListener(type: string, listener: (payload?: unknown) => void): () => void;
38
+ }
39
+
40
+ interface ProviderModule {
41
+ MobileAds: () => {
42
+ initialize(): Promise<unknown>;
43
+ setRequestConfiguration(config: Record<string, unknown>): Promise<unknown>;
44
+ };
45
+ InterstitialAd: {
46
+ createForAdRequest(adUnitId: string, options?: Record<string, unknown>): ProviderFullScreenAd;
47
+ };
48
+ RewardedAd: {
49
+ createForAdRequest(adUnitId: string, options?: Record<string, unknown>): ProviderFullScreenAd;
50
+ };
51
+ AppOpenAd: {
52
+ createForAdRequest(adUnitId: string, options?: Record<string, unknown>): ProviderFullScreenAd;
53
+ };
54
+ AdEventType: Record<string, string>;
55
+ RewardedAdEventType: Record<string, string>;
56
+ MaxAdContentRating: Record<string, string>;
57
+ AdsConsent: ProviderConsent;
58
+ AdsConsentDebugGeography: Record<string, number>;
59
+ }
60
+
61
+ /** The slice of the bridge's consent module this adapter uses. */
62
+ interface ProviderConsent {
63
+ requestInfoUpdate(options?: Record<string, unknown>): Promise<ProviderConsentInfo>;
64
+ loadAndShowConsentFormIfRequired(): Promise<ProviderConsentInfo>;
65
+ showPrivacyOptionsForm(): Promise<ProviderConsentInfo>;
66
+ getConsentInfo(): Promise<ProviderConsentInfo>;
67
+ reset(): void;
68
+ }
69
+
70
+ interface ProviderConsentInfo {
71
+ status?: string;
72
+ canRequestAds?: boolean;
73
+ isConsentFormAvailable?: boolean;
74
+ privacyOptionsRequirementStatus?: string;
75
+ }
76
+
77
+ /**
78
+ * The provider's geography enum is numeric and its spelling is the provider's.
79
+ * Translating here rather than in `core/` is the same rule the rest of this file
80
+ * follows: the one module that knows a provider exists is the one that speaks
81
+ * its vocabulary.
82
+ */
83
+ function toProviderGeography(
84
+ geography: EngineConsentOptions['debugGeography'],
85
+ values: Record<string, number>,
86
+ ): number | undefined {
87
+ switch (geography) {
88
+ case 'eea':
89
+ return values.EEA;
90
+ case 'not_eea':
91
+ return values.NOT_EEA;
92
+ case 'regulated_us_state':
93
+ return values.REGULATED_US_STATE;
94
+ case 'other':
95
+ return values.OTHER;
96
+ case 'disabled':
97
+ return values.DISABLED;
98
+ default:
99
+ return undefined;
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Normalizes the provider's consent shape into the engine's.
105
+ *
106
+ * `canRequestAds` is taken as reported and never inferred from the status: the
107
+ * provider is the authority on whether a request is permitted, and second-
108
+ * guessing it here is how a package ends up requesting ads it should not
109
+ * (FR-106, FR-107).
110
+ */
111
+ function toEngineConsentInfo(info: ProviderConsentInfo): EngineConsentInfo {
112
+ const status = String(info.status ?? 'UNKNOWN').toUpperCase();
113
+ const normalized: EngineConsentInfo['status'] =
114
+ status === 'OBTAINED'
115
+ ? 'obtained'
116
+ : status === 'REQUIRED'
117
+ ? 'required'
118
+ : status === 'NOT_REQUIRED'
119
+ ? 'not_required'
120
+ : 'unknown';
121
+ return {
122
+ status: normalized,
123
+ canRequestAds: info.canRequestAds === true,
124
+ isFormAvailable: info.isConsentFormAvailable === true,
125
+ privacyOptions: toEnginePrivacyOptions(info.privacyOptionsRequirementStatus),
126
+ };
127
+ }
128
+
129
+ /**
130
+ * Same rule as the status: an unrecognized value is `unknown`, never a guess in
131
+ * either direction (FR-150).
132
+ */
133
+ function toEnginePrivacyOptions(value: string | undefined): EngineConsentInfo['privacyOptions'] {
134
+ switch (String(value ?? 'UNKNOWN').toUpperCase()) {
135
+ case 'REQUIRED':
136
+ return 'required';
137
+ case 'NOT_REQUIRED':
138
+ return 'not_required';
139
+ default:
140
+ return 'unknown';
141
+ }
142
+ }
143
+
144
+ function loadProvider(): ProviderModule | null {
145
+ try {
146
+ return require('react-native-google-mobile-ads') as ProviderModule;
147
+ } catch {
148
+ // The bridge is a required peer, but a consumer can still have it missing
149
+ // mid-install or excluded from a build. Reporting `provider_missing`
150
+ // beats crashing the host app's launch (FR-025, NFR-005).
151
+ return null;
152
+ }
153
+ }
154
+
155
+ function toProviderRating(
156
+ rating: AdContentRating | undefined,
157
+ ratings: Record<string, string>,
158
+ ): string | undefined {
159
+ return rating ? ratings[rating] : undefined;
160
+ }
161
+
162
+ /** One unsubscribe covering every listener that was actually registered. */
163
+ function makeUnsubscribe(removers: readonly (() => void)[]): Unsubscribe {
164
+ let active = true;
165
+ return () => {
166
+ if (!active) {
167
+ return;
168
+ }
169
+ active = false;
170
+ for (const remove of removers) {
171
+ try {
172
+ remove();
173
+ } catch {
174
+ // A provider that throws on removal must not prevent the rest of
175
+ // cleanup from running (FR-035).
176
+ }
177
+ }
178
+ };
179
+ }
180
+
181
+ export function createAdMobEngine(): AdsEngine {
182
+ // v1 is Android-only. On any other platform this stays null and every
183
+ // operation resolves to `unsupported_platform` (SC-004, scenario 12).
184
+ const supported = Platform.OS === 'android';
185
+ let provider: ProviderModule | null = null;
186
+ let providerResolved = false;
187
+
188
+ function getProvider(): ProviderModule | null {
189
+ if (!supported) {
190
+ return null;
191
+ }
192
+ if (!providerResolved) {
193
+ providerResolved = true;
194
+ provider = loadProvider();
195
+ }
196
+ return provider;
197
+ }
198
+
199
+ // The format is kept alongside the ad because the provider validates which
200
+ // event types a given ad object accepts, and rejects the rewarded ones on an
201
+ // interstitial by throwing (see `subscribe`).
202
+ const ads = new Map<string, { ad: ProviderFullScreenAd; format: FullScreenFormat }>();
203
+ let counter = 0;
204
+
205
+ return {
206
+ isAvailable() {
207
+ return supported && getProvider() !== null;
208
+ },
209
+
210
+ async initialize(privacy: EnginePrivacy) {
211
+ const module = getProvider();
212
+ if (!module) {
213
+ throw new Error('provider_missing');
214
+ }
215
+ const mobileAds = module.MobileAds();
216
+
217
+ // Before initialize(), deliberately. See the ordering note above.
218
+ await mobileAds.setRequestConfiguration({
219
+ tagForChildDirectedTreatment: privacy.childDirected,
220
+ tagForUnderAgeOfConsent: privacy.underAgeOfConsent,
221
+ maxAdContentRating: toProviderRating(privacy.maxAdContentRating, module.MaxAdContentRating),
222
+ testDeviceIdentifiers: privacy.testDeviceIds ? [...privacy.testDeviceIds] : undefined,
223
+ });
224
+
225
+ await mobileAds.initialize();
226
+ },
227
+
228
+ async createFullScreen(
229
+ format: FullScreenFormat,
230
+ adUnitId: string,
231
+ request?: EngineRequestOptions,
232
+ ) {
233
+ const module = getProvider();
234
+ if (!module) {
235
+ throw new Error('provider_missing');
236
+ }
237
+
238
+ const factory =
239
+ format === 'rewarded'
240
+ ? module.RewardedAd
241
+ : format === 'app_open'
242
+ ? module.AppOpenAd
243
+ : module.InterstitialAd;
244
+ const ad = factory.createForAdRequest(adUnitId, {
245
+ // The consumer-facing polarity is `personalizedAds`; the provider's
246
+ // is the inverse. The flip lives here, next to the API needing it.
247
+ requestNonPersonalizedAdsOnly: request?.personalizedAds === false,
248
+ });
249
+
250
+ counter += 1;
251
+ const id = `admob-${counter}`;
252
+ ads.set(id, { ad, format });
253
+ // The provider loads asynchronously; callbacks arrive through subscribe.
254
+ ad.load();
255
+ return { id } satisfies EngineAdHandle;
256
+ },
257
+
258
+ async show(handle) {
259
+ const entry = ads.get(handle.id);
260
+ if (!entry) {
261
+ throw new Error('ad handle disposed');
262
+ }
263
+ await entry.ad.show();
264
+ },
265
+
266
+ subscribe(handle, listener): Unsubscribe {
267
+ const module = getProvider();
268
+ const entry = ads.get(handle.id);
269
+ if (!module || !entry) {
270
+ return () => undefined;
271
+ }
272
+
273
+ const { ad, format } = entry;
274
+ const { AdEventType, RewardedAdEventType } = module;
275
+ const removers: Array<() => void> = [];
276
+
277
+ const on = (type: string | undefined, map: (payload?: unknown) => EngineAdEvent | null) => {
278
+ if (!type) {
279
+ return;
280
+ }
281
+ try {
282
+ removers.push(
283
+ ad.addAdEventListener(type, payload => {
284
+ const event = map(payload);
285
+ if (event) {
286
+ listener(event);
287
+ }
288
+ }),
289
+ );
290
+ } catch {
291
+ // The provider validates event types per ad object and throws on
292
+ // one it does not accept. Skipping the listener keeps a single
293
+ // rejected type from tearing down the whole subscription — which
294
+ // would fail the load outright while the ad went on to load fine,
295
+ // reporting a `provider_error` for an ad that was never broken.
296
+ }
297
+ };
298
+
299
+ on(AdEventType.LOADED, () => ({ type: 'loaded' }));
300
+ on(AdEventType.ERROR, cause => ({ type: 'failed', cause }));
301
+ on(AdEventType.OPENED, () => ({ type: 'opened' }));
302
+ on(AdEventType.CLICKED, () => ({ type: 'clicked' }));
303
+ on(AdEventType.CLOSED, () => ({ type: 'dismissed' }));
304
+
305
+ // Rewarded-only, and gated on the format because the provider REJECTS
306
+ // these types on an interstitial. The rewarded loaded event is distinct
307
+ // from AdEventType.LOADED, and EARNED_REWARD is distinct from CLOSED —
308
+ // which is precisely what makes FR-046 and FR-048 enforceable rather
309
+ // than a guess about ordering.
310
+ if (format !== 'rewarded') {
311
+ return makeUnsubscribe(removers);
312
+ }
313
+
314
+ on(RewardedAdEventType.LOADED, () => ({ type: 'loaded' }));
315
+ on(RewardedAdEventType.EARNED_REWARD, payload => {
316
+ const source = (payload ?? {}) as { type?: unknown; amount?: unknown };
317
+ const amount = Number(source.amount);
318
+ return {
319
+ type: 'rewarded',
320
+ reward: {
321
+ type: typeof source.type === 'string' ? source.type : 'reward',
322
+ amount: Number.isFinite(amount) ? amount : 0,
323
+ },
324
+ };
325
+ });
326
+
327
+ return makeUnsubscribe(removers);
328
+ },
329
+
330
+ dispose(handle) {
331
+ ads.delete(handle.id);
332
+ },
333
+
334
+ consent: createAdMobConsent(getProvider),
335
+ };
336
+ }
337
+
338
+ /**
339
+ * The consent platform adapter.
340
+ *
341
+ * It is deliberately thin. Every decision that could be made here — whether a
342
+ * region requires consent, whether an error means "no", whether a dismissed form
343
+ * counts — is made elsewhere or not at all: the provider owns the first, and
344
+ * `core/consentFlow.ts` owns the second and third. This file's only job is to
345
+ * call the right method and hand back a shape that names nothing Google.
346
+ */
347
+ function createAdMobConsent(getProvider: () => ProviderModule | null): ConsentPort {
348
+ function requireConsent(): ProviderConsent {
349
+ const module = getProvider();
350
+ if (!module?.AdsConsent) {
351
+ throw new Error('provider_missing');
352
+ }
353
+ return module.AdsConsent;
354
+ }
355
+
356
+ return {
357
+ async requestInfoUpdate(options: EngineConsentOptions) {
358
+ const module = getProvider();
359
+ if (!module?.AdsConsent) {
360
+ throw new Error('provider_missing');
361
+ }
362
+ const debugGeography = toProviderGeography(
363
+ options.debugGeography,
364
+ module.AdsConsentDebugGeography ?? {},
365
+ );
366
+ const info = await module.AdsConsent.requestInfoUpdate({
367
+ // Passing `undefined` rather than omitting keeps the call shape
368
+ // stable; the bridge treats an absent value as "no override".
369
+ debugGeography,
370
+ tagForUnderAgeOfConsent: options.underAgeOfConsent,
371
+ testDeviceIdentifiers: options.testDeviceIds ? [...options.testDeviceIds] : undefined,
372
+ });
373
+ return toEngineConsentInfo(info);
374
+ },
375
+
376
+ async showFormIfRequired() {
377
+ // The provider decides whether a form is needed and renders it. The
378
+ // package never draws consent UI of its own (FR-103).
379
+ const info = await requireConsent().loadAndShowConsentFormIfRequired();
380
+ return toEngineConsentInfo(info);
381
+ },
382
+
383
+ async showPrivacyOptionsForm() {
384
+ // Present in the pinned bridge 16.0.3, so no range change (FR-155).
385
+ // Resolves with the consent information as it stands once the form
386
+ // closes, whether the user changed anything or not.
387
+ const info = await requireConsent().showPrivacyOptionsForm();
388
+ return toEngineConsentInfo(info);
389
+ },
390
+
391
+ async getInfo() {
392
+ return toEngineConsentInfo(await requireConsent().getConsentInfo());
393
+ },
394
+
395
+ reset() {
396
+ requireConsent().reset();
397
+ },
398
+ };
399
+ }