@chipmobilesdk/rn-ads 0.5.1 → 0.7.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.
@@ -19,7 +19,14 @@ import type {
19
19
  UnavailableReason,
20
20
  Unsubscribe,
21
21
  } from '../config/types';
22
- import { AdsError, type AdsErrorCode, type AdsOperation } from './errors';
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';
23
30
 
24
31
  export type AdEventType =
25
32
  | 'initialization'
@@ -151,79 +158,83 @@ export interface AdEventEmitter {
151
158
  listenerCount(): number;
152
159
  }
153
160
 
154
- export function createEventEmitter(): AdEventEmitter {
155
- const listeners = new Set<AdEventListener>();
156
- // `${correlationId}:${type}` for everything already emitted in a cycle.
157
- const emittedOnce = new Set<string>();
158
- let disposed = false;
159
- // Guards against an internal_error thrown by a listener re-entering dispatch
160
- // and recursing forever.
161
- let reportingListenerFailure = false;
162
-
163
- function dispatch(event: AdEvent): void {
164
- for (const listener of Array.from(listeners)) {
165
- try {
166
- listener(event);
167
- } catch (error) {
168
- if (reportingListenerFailure) {
169
- continue;
170
- }
171
- reportingListenerFailure = true;
172
- try {
173
- dispatch({
174
- type: 'internal_error',
175
- timestamp: Date.now(),
176
- placementKey: event.placementKey,
177
- format: event.format,
178
- correlationId: event.correlationId,
179
- error: new AdsError('internal_error' as AdsErrorCode, {
180
- message: 'consumer event listener threw',
181
- cause: error,
182
- }),
183
- });
184
- } finally {
185
- reportingListenerFailure = false;
186
- }
187
- }
188
- }
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';
189
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
+ });
190
219
 
191
220
  return {
192
221
  emit(event) {
193
222
  // After dispose, a late provider callback updates nothing and emits
194
- // nothing (FR-035, SC-008).
195
- if (disposed) {
196
- return;
197
- }
198
- if (event.correlationId && ONCE_PER_CYCLE.has(event.type)) {
199
- const key = `${event.correlationId}:${event.type}`;
200
- if (emittedOnce.has(key)) {
201
- return;
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);
202
229
  }
203
- emittedOnce.add(key);
230
+ } finally {
231
+ dispatching = previous;
204
232
  }
205
- dispatch(event);
206
- },
207
- subscribe(listener) {
208
- listeners.add(listener);
209
- let active = true;
210
- return () => {
211
- // Idempotent: a second call is a no-op, not an error (FR-005).
212
- if (!active) {
213
- return;
214
- }
215
- active = false;
216
- listeners.delete(listener);
217
- };
218
- },
219
- dispose() {
220
- disposed = true;
221
- listeners.clear();
222
- emittedOnce.clear();
223
- },
224
- listenerCount() {
225
- return listeners.size;
226
233
  },
234
+ // Idempotent unsubscribe (FR-005).
235
+ subscribe: listener => emitter.subscribe(listener),
236
+ dispose: () => emitter.dispose(),
237
+ listenerCount: () => emitter.listenerCount(),
227
238
  };
228
239
  }
229
240
 
@@ -0,0 +1,55 @@
1
+ /**
2
+ * The AdMob adaptive banner behind the engine port (ADS-12).
3
+ *
4
+ * `AdsBanner` renders whatever banner component the engine offers and passes
5
+ * neutral props; this file is where they become AdMob's. So the banner host no
6
+ * longer requires the bridge itself, a fake engine can render a banner in a test
7
+ * without mocking a native module, and an iOS or other-provider engine supplies
8
+ * its own component.
9
+ */
10
+ import React from 'react';
11
+ import type { ComponentType } from 'react';
12
+
13
+ import { classifyProviderFailure } from './admobErrors';
14
+ import type { EngineBanner, EngineBannerProps } from './port';
15
+
16
+ /** The slice of the bridge the banner uses. */
17
+ interface ProviderBannerModule {
18
+ readonly BannerAd?: ComponentType<Record<string, unknown>>;
19
+ readonly BannerAdSize?: Record<string, string>;
20
+ }
21
+
22
+ export function createAdMobBanner(getProvider: () => ProviderBannerModule | null): EngineBanner {
23
+ function AdMobBanner({
24
+ adUnitId,
25
+ personalizedAds,
26
+ onLoaded,
27
+ onFailed,
28
+ onImpression,
29
+ onClicked,
30
+ }: EngineBannerProps) {
31
+ const provider = getProvider();
32
+ const BannerAd = provider?.BannerAd;
33
+ if (!BannerAd) {
34
+ return null;
35
+ }
36
+ return (
37
+ <BannerAd
38
+ unitId={adUnitId}
39
+ // Adaptive, so the banner fills the container the app measured (FR-038).
40
+ size={provider.BannerAdSize?.ANCHORED_ADAPTIVE_BANNER}
41
+ // The provider's polarity is the inverse of the consumer-facing option.
42
+ requestOptions={{ requestNonPersonalizedAdsOnly: !personalizedAds }}
43
+ onAdLoaded={() => onLoaded()}
44
+ onAdFailedToLoad={(error: unknown) => onFailed(error, classifyProviderFailure(error))}
45
+ onAdImpression={() => onImpression()}
46
+ onAdClicked={() => onClicked()}
47
+ />
48
+ );
49
+ }
50
+
51
+ return {
52
+ isAvailable: () => Boolean(getProvider()?.BannerAd),
53
+ Component: AdMobBanner,
54
+ };
55
+ }
@@ -16,9 +16,15 @@
16
16
  * applied.
17
17
  */
18
18
 
19
+ import { lazyPeer } from '@chipmobilesdk/rn-core';
20
+
21
+ import type { ComponentType } from 'react';
19
22
  import { Platform } from 'react-native';
20
23
 
21
24
  import type { AdContentRating, FullScreenFormat, Unsubscribe } from '../config/types';
25
+ import { normalizeReward } from '../diagnostics/events';
26
+ import { createAdMobBanner } from './admobBanner';
27
+ import { classifyProviderFailure } from './admobErrors';
22
28
  import type {
23
29
  AdsEngine,
24
30
  ConsentPort,
@@ -56,6 +62,8 @@ interface ProviderModule {
56
62
  MaxAdContentRating: Record<string, string>;
57
63
  AdsConsent: ProviderConsent;
58
64
  AdsConsentDebugGeography: Record<string, number>;
65
+ BannerAd?: ComponentType<Record<string, unknown>>;
66
+ BannerAdSize?: Record<string, string>;
59
67
  }
60
68
 
61
69
  /** The slice of the bridge's consent module this adapter uses. */
@@ -141,15 +149,24 @@ function toEnginePrivacyOptions(value: string | undefined): EngineConsentInfo['p
141
149
  }
142
150
  }
143
151
 
144
- function loadProvider(): ProviderModule | null {
152
+ /**
153
+ * The provider bridge, loaded on first use (rn-core's lazy peer). It is a required
154
+ * peer, but a consumer can still have it missing mid-install or excluded from a
155
+ * build: reporting `provider_missing` beats crashing the host app's launch
156
+ * (FR-025, NFR-005). The banner component reaches it through the same loader.
157
+ */
158
+ export const admobProvider = lazyPeer('react-native-google-mobile-ads', () => {
159
+ // Inside `try`, so Metro bundles an app that leaves this optional peer out.
145
160
  try {
146
161
  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;
162
+ } catch (cause) {
163
+ throw cause;
152
164
  }
165
+ });
166
+
167
+ function loadProvider(): ProviderModule | null {
168
+ const result = admobProvider.get();
169
+ return result.ok ? result.module : null;
153
170
  }
154
171
 
155
172
  function toProviderRating(
@@ -207,6 +224,10 @@ export function createAdMobEngine(): AdsEngine {
207
224
  return supported && getProvider() !== null;
208
225
  },
209
226
 
227
+ // The adaptive banner renders through the engine like every other format
228
+ // (ADS-12), so the banner host never names the provider.
229
+ banner: createAdMobBanner(getProvider),
230
+
210
231
  async initialize(privacy: EnginePrivacy) {
211
232
  const module = getProvider();
212
233
  if (!module) {
@@ -297,7 +318,11 @@ export function createAdMobEngine(): AdsEngine {
297
318
  };
298
319
 
299
320
  on(AdEventType.LOADED, () => ({ type: 'loaded' }));
300
- on(AdEventType.ERROR, cause => ({ type: 'failed', cause }));
321
+ on(AdEventType.ERROR, cause => ({
322
+ type: 'failed',
323
+ cause,
324
+ kind: classifyProviderFailure(cause),
325
+ }));
301
326
  on(AdEventType.OPENED, () => ({ type: 'opened' }));
302
327
  on(AdEventType.CLICKED, () => ({ type: 'clicked' }));
303
328
  on(AdEventType.CLOSED, () => ({ type: 'dismissed' }));
@@ -312,17 +337,10 @@ export function createAdMobEngine(): AdsEngine {
312
337
  }
313
338
 
314
339
  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
- });
340
+ on(RewardedAdEventType.EARNED_REWARD, payload => ({
341
+ type: 'rewarded',
342
+ reward: normalizeReward(payload),
343
+ }));
326
344
 
327
345
  return makeUnsubscribe(removers);
328
346
  },
@@ -397,3 +415,5 @@ function createAdMobConsent(getProvider: () => ProviderModule | null): ConsentPo
397
415
  },
398
416
  };
399
417
  }
418
+
419
+ export { classifyProviderFailure } from './admobErrors';
@@ -0,0 +1,44 @@
1
+ /**
2
+ * AdMob's error vocabulary, translated into the port's neutral failure kinds
3
+ * (ADS-08). Shared by the full-screen engine and the banner component, which
4
+ * are the only two places that see a provider error.
5
+ */
6
+ import type { EngineFailureKind } from './port';
7
+
8
+ /**
9
+ * The bridge's error codes that say the provider refused the request itself, so
10
+ * that asking again cannot help (ADS-08). Kept to codes the bridge has reported
11
+ * the same way across releases. `internal-error` is deliberately absent: AdMob
12
+ * uses it for an invalid server response too, which a later request can survive.
13
+ */
14
+ const REJECTED_CODES: ReadonlySet<string> = new Set([
15
+ 'invalid-request',
16
+ 'invalid-argument',
17
+ 'app-id-missing',
18
+ 'request-id-mismatch',
19
+ ]);
20
+
21
+ /** The bridge's code for an error: `error.code`, or the `[googleMobileAds/<code>]` prefix. */
22
+ function providerCodeOf(cause: unknown): string | null {
23
+ const code = (cause as { code?: unknown } | null)?.code;
24
+ if (typeof code === 'string') {
25
+ return code.replace(/^googleMobileAds\//, '');
26
+ }
27
+ const message = cause instanceof Error ? cause.message : typeof cause === 'string' ? cause : '';
28
+ return /\[googleMobileAds\/([a-z-]+)\]/.exec(message)?.[1] ?? null;
29
+ }
30
+
31
+ /** Classifies a provider failure in neutral terms; undefined when it cannot tell. */
32
+ export function classifyProviderFailure(cause: unknown): EngineFailureKind | undefined {
33
+ const code = providerCodeOf(cause);
34
+ if (code === null) {
35
+ return undefined;
36
+ }
37
+ if (code === 'no-fill' || code === 'mediation-no-fill') {
38
+ return 'no_fill';
39
+ }
40
+ if (code === 'network-error') {
41
+ return 'network';
42
+ }
43
+ return REJECTED_CODES.has(code) ? 'rejected' : undefined;
44
+ }
@@ -12,6 +12,8 @@
12
12
  * pretend a banner is a load-then-show resource.
13
13
  */
14
14
 
15
+ import type { ComponentType } from 'react';
16
+
15
17
  import type { AdContentRating, FullScreenFormat, Reward, Unsubscribe } from '../config/types';
16
18
 
17
19
  /** Privacy state as the engine needs it — no consumer-facing shape leaks in. */
@@ -39,9 +41,45 @@ export interface EngineRequestOptions {
39
41
  * `rewarded` and `dismissed` are separate members because they are separate
40
42
  * facts that can arrive in either order (FR-049).
41
43
  */
44
+ /**
45
+ * What kind of failure the provider reported, in words that name no provider
46
+ * (ADS-08). Only the adapter knows its provider's error vocabulary, so it
47
+ * classifies; core decides what each kind means for retrying.
48
+ *
49
+ * - `no_fill`: the network had nothing to give.
50
+ * - `network`: the request did not get through.
51
+ * - `rejected`: the provider refused the request itself — an invalid ad unit, a
52
+ * missing app id. The same request will fail the same way.
53
+ */
54
+ export type EngineFailureKind = 'no_fill' | 'network' | 'rejected';
55
+
56
+ /** What an engine's banner component receives. Names no provider (ADS-12). */
57
+ export interface EngineBannerProps {
58
+ readonly adUnitId: string;
59
+ /** False asks the provider for non-personalized ads only (FR-017). */
60
+ readonly personalizedAds: boolean;
61
+ readonly onLoaded: () => void;
62
+ readonly onFailed: (cause: unknown, kind?: EngineFailureKind) => void;
63
+ /** A real provider impression, as opposed to the derived full-screen one (R5). */
64
+ readonly onImpression: () => void;
65
+ readonly onClicked: () => void;
66
+ }
67
+
68
+ /** An engine's banner: one adaptive banner filling its container's width (FR-038). */
69
+ export interface EngineBanner {
70
+ /** Whether the provider's banner can render here, e.g. its native module is linked. */
71
+ isAvailable(): boolean;
72
+ readonly Component: ComponentType<EngineBannerProps>;
73
+ }
74
+
42
75
  export type EngineAdEvent =
43
76
  | { readonly type: 'loaded' }
44
- | { readonly type: 'failed'; readonly cause: unknown }
77
+ | {
78
+ readonly type: 'failed';
79
+ readonly cause: unknown;
80
+ /** Optional, so an engine that does not classify keeps working. */
81
+ readonly kind?: EngineFailureKind;
82
+ }
45
83
  | { readonly type: 'opened' }
46
84
  | { readonly type: 'clicked' }
47
85
  | { readonly type: 'dismissed' }
@@ -141,4 +179,11 @@ export interface AdsEngine {
141
179
 
142
180
  /** The provider's consent platform. Only touched if the app opts in (FR-102). */
143
181
  readonly consent: ConsentPort;
182
+
183
+ /**
184
+ * The adaptive banner, rendered by `AdsBanner` (ADS-12). Optional, so an
185
+ * engine written against 0.6.0 still satisfies the port; without it banners
186
+ * report `unavailable`.
187
+ */
188
+ readonly banner?: EngineBanner;
144
189
  }
@@ -54,8 +54,3 @@ export function isOfficialTestAdUnit(adUnitId: string): boolean {
54
54
  }
55
55
  return false;
56
56
  }
57
-
58
- /** Exposed for the package's own tests and for documentation generation. */
59
- export function allTestAdUnits(): Readonly<Record<AdFormat, string>> {
60
- return ANDROID_TEST_AD_UNITS;
61
- }
package/src/index.ts CHANGED
@@ -16,6 +16,14 @@
16
16
  * frequency cap, and no screen name in this package (FR-045).
17
17
  */
18
18
 
19
+ import type { AdsConfig } from './config/types';
20
+ import {
21
+ createAds as createInstance,
22
+ type AdsInstance,
23
+ type CreateAdsOptions,
24
+ } from './core/adsInstance';
25
+ import { createAdMobEngine } from './engine/admobEngine';
26
+
19
27
  // Configuration and state
20
28
  export type {
21
29
  AdContentRating,
@@ -73,7 +81,17 @@ export {
73
81
  export type { EnablementResult } from './core/enablement';
74
82
 
75
83
  // Instance, lifecycle, and results
76
- export { createAds } from './core/adsInstance';
84
+
85
+ /**
86
+ * Creates an ads instance. This is the composition root: core's instance with
87
+ * the AdMob adapter as its default engine, so core/ itself never loads an
88
+ * adapter (ADS-14). The adapter resolves the provider bridge only on first use,
89
+ * so importing the package where there is no adapter, or no bridge, costs
90
+ * nothing and crashes nothing (FR-025, SC-004).
91
+ */
92
+ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): AdsInstance {
93
+ return createInstance(config, options, createAdMobEngine);
94
+ }
77
95
  export type {
78
96
  AdsInstance,
79
97
  ConsentApi,