@chipmobilesdk/rn-ads 0.6.0 → 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.
@@ -0,0 +1,198 @@
1
+ /**
2
+ * The ads instance's public contract: what `createAds` returns and what each
3
+ * operation reports. Implementation lives beside this file, one module per
4
+ * responsibility (ADS-10).
5
+ */
6
+ import type { AdsError } from '../../diagnostics/errors';
7
+ import type {
8
+ ConsentSnapshot,
9
+ Enablement,
10
+ InitStatus,
11
+ PlacementState,
12
+ PrivacyBlockReason,
13
+ PrivacyOptions,
14
+ UnavailableReason,
15
+ Unsubscribe,
16
+ } from '../../config/types';
17
+ import type { AdEventListener } from '../../diagnostics/events';
18
+ import type { AdsEngine } from '../../engine/port';
19
+ import type { EnablementResult } from '../enablement';
20
+ import type { Scheduler } from '../timers';
21
+
22
+ export type InitResult =
23
+ | { readonly status: 'ready' }
24
+ | { readonly status: 'blocked'; readonly reason: PrivacyBlockReason }
25
+ | { readonly status: 'disabled' }
26
+ /** The app has not decided whether ads are on. Nothing was requested (FR-115). */
27
+ | { readonly status: 'undecided' }
28
+ | { readonly status: 'unavailable'; readonly reason: UnavailableReason }
29
+ | { readonly status: 'failed'; readonly error: AdsError };
30
+
31
+ export type PrivacyUpdateResult =
32
+ | { readonly status: 'applied' }
33
+ | { readonly status: 'blocked'; readonly reason: PrivacyBlockReason }
34
+ | { readonly status: 'requires_reinitialization' }
35
+ | { readonly status: 'disabled' }
36
+ | { readonly status: 'undecided' }
37
+ | { readonly status: 'unavailable'; readonly reason: UnavailableReason }
38
+ /**
39
+ * The initialization this update triggered failed (added in 0.5.0). Before,
40
+ * it was reported as `applied`, so the app never retried and no ad ever
41
+ * showed, with nothing to say why.
42
+ */
43
+ | { readonly status: 'failed'; readonly error: AdsError };
44
+
45
+ export type LoadResult =
46
+ | { readonly status: 'loaded'; readonly correlationId: string }
47
+ | { readonly status: 'disabled' }
48
+ | { readonly status: 'undecided' }
49
+ | { readonly status: 'blocked'; readonly reason: PrivacyBlockReason }
50
+ | { readonly status: 'unavailable'; readonly reason: UnavailableReason }
51
+ | { readonly status: 'no_fill' }
52
+ /**
53
+ * Refused by the per-placement request throttle (FR-140), added in 0.3.0.
54
+ *
55
+ * Nothing was requested and nothing is broken — this placement asked too
56
+ * recently. Distinct from `no_fill` on purpose: no-fill means the network had
57
+ * nothing to give, this means we did not ask. An app that treats them the same
58
+ * will misdiagnose the first time a banner goes quiet.
59
+ */
60
+ | ThrottledResult
61
+ | { readonly status: 'timeout' }
62
+ | { readonly status: 'failed'; readonly error: AdsError };
63
+
64
+ export interface ThrottledResult {
65
+ readonly status: 'throttled';
66
+ readonly retryAfterMs: number;
67
+ }
68
+
69
+ /** A gate refusal: what a load gets when it may not ask at all. */
70
+ export type LoadRefusal = Extract<
71
+ LoadResult,
72
+ { status: 'disabled' | 'undecided' | 'blocked' | 'unavailable' }
73
+ >;
74
+
75
+ export type ShowResult =
76
+ | { readonly status: 'shown'; readonly correlationId: string }
77
+ | { readonly status: 'not_ready' }
78
+ | { readonly status: 'busy' }
79
+ | { readonly status: 'consumed' }
80
+ | { readonly status: 'app_inactive' }
81
+ /**
82
+ * The user came back to the app from an ad, or from a browser an ad opened,
83
+ * so an app-open ad would be an ad immediately after an ad. Distinct from
84
+ * `busy` and `not_ready` because the app cannot detect this itself and
85
+ * therefore cannot infer it from either of those (FR-128).
86
+ */
87
+ | { readonly status: 'ad_originated_return' }
88
+ | { readonly status: 'disabled' }
89
+ | { readonly status: 'undecided' }
90
+ | { readonly status: 'blocked'; readonly reason: PrivacyBlockReason }
91
+ | { readonly status: 'unavailable'; readonly reason: UnavailableReason }
92
+ | { readonly status: 'timeout' }
93
+ | { readonly status: 'failed'; readonly error: AdsError };
94
+
95
+ /**
96
+ * What `consent.showPrivacyOptionsForm()` reports (FR-151), added in 0.4.0.
97
+ *
98
+ * Every refusal is its own member, so an app can tell "there is nothing to
99
+ * offer here" from "you asked too early" from "it broke" without parsing an
100
+ * error. None of them throws.
101
+ */
102
+ export type PrivacyOptionsFormResult =
103
+ /**
104
+ * The provider's form was presented and has closed. `consent` is the state it
105
+ * left behind, already applied to the gate. A user who closed the form without
106
+ * changing anything lands here too, with the state they already had.
107
+ */
108
+ | { readonly status: 'shown'; readonly consent: ConsentSnapshot }
109
+ /** The provider does not require privacy options here. Nothing was presented. */
110
+ | { readonly status: 'not_required' }
111
+ /**
112
+ * The requirement is not known yet: no successful `requestInfoUpdate()` since
113
+ * the instance was created or reset, or the last one failed. Nothing was
114
+ * presented.
115
+ */
116
+ | { readonly status: 'info_update_required' }
117
+ /** A consent form is already on screen. Nothing was presented. */
118
+ | { readonly status: 'busy' }
119
+ /**
120
+ * The form could not be presented or failed while open. The consent state is
121
+ * whatever the provider reports it still holds, and never a new grant.
122
+ */
123
+ | { readonly status: 'failed'; readonly error: AdsError }
124
+ | { readonly status: 'unavailable'; readonly reason: UnavailableReason };
125
+
126
+ /**
127
+ * The optional consent capability (FR-101).
128
+ *
129
+ * Grouped under its own namespace rather than flattened onto the instance so
130
+ * that FR-102's optionality stays legible: an app that never touches
131
+ * `ads.consent` never starts a flow. Five loose methods on the instance would
132
+ * invite the assumption that they are part of the normal path.
133
+ */
134
+ export interface ConsentApi {
135
+ /** Refreshes consent information from the provider. */
136
+ requestInfoUpdate(): Promise<ConsentSnapshot>;
137
+ /** Presents the provider's form if one is required. Renders nothing itself. */
138
+ showFormIfRequired(): Promise<ConsentSnapshot>;
139
+ /**
140
+ * Presents the provider's privacy options form, so a user can change an
141
+ * answer already given (FR-151). Added in 0.4.0.
142
+ *
143
+ * Offer it when `getState().privacyOptionsRequirement` is `required`. Where
144
+ * the entry point lives, and what it says, stay the app's. The outcome feeds
145
+ * the privacy gate by itself, exactly as `showFormIfRequired` does.
146
+ */
147
+ showPrivacyOptionsForm(): Promise<PrivacyOptionsFormResult>;
148
+ getState(): ConsentSnapshot;
149
+ subscribe(listener: (snapshot: ConsentSnapshot) => void): Unsubscribe;
150
+ /** Clears the stored decision. Inert in production (FR-113). */
151
+ reset(): Promise<void>;
152
+ }
153
+
154
+ export interface AdsInstance {
155
+ initialize(privacy: PrivacyOptions): Promise<InitResult>;
156
+ updatePrivacy(privacy: PrivacyOptions): Promise<PrivacyUpdateResult>;
157
+ /**
158
+ * Turns ads on or off. Returns a result rather than `void` (FR-122).
159
+ *
160
+ * 0.1.0 returned nothing, which meant an app re-enabling ads after a lapsed
161
+ * subscription had no way to learn that it also had to re-initialize — and
162
+ * every load and show after that was refused in silence.
163
+ */
164
+ setEnabled(enabled: boolean): EnablementResult;
165
+ /** The same, including the third state the app has not resolved yet. */
166
+ setEnablement(next: Enablement): EnablementResult;
167
+ setAppActive(active: boolean): void;
168
+ readonly consent: ConsentApi;
169
+ getStatus(): InitStatus;
170
+ subscribeStatus(listener: (status: InitStatus) => void): Unsubscribe;
171
+
172
+ load(placementKey: string): Promise<LoadResult>;
173
+ show(placementKey: string): Promise<ShowResult>;
174
+ getPlacementState(placementKey: string): PlacementState;
175
+ subscribePlacement(key: string, listener: (state: PlacementState) => void): Unsubscribe;
176
+
177
+ subscribeEvents(listener: AdEventListener): Unsubscribe;
178
+
179
+ /** FR-011a — substitution is observable state, not only a log line. */
180
+ getSubstitutedPlacements(): readonly string[];
181
+ /** FR-113 — the same treatment for consent tools dropped in production. */
182
+ getIgnoredConsentTools(): readonly string[];
183
+ dispose(): void;
184
+ }
185
+
186
+ export interface CreateAdsOptions {
187
+ /** Injected in tests; the AdMob adapter is the default in production. */
188
+ readonly engine?: AdsEngine;
189
+ /** Injected so the FR-012 rule is testable without touching a global. */
190
+ readonly isDevBuild?: boolean;
191
+ readonly now?: () => number;
192
+ /**
193
+ * Injected so retry backoff, app-open expiry, and the undecided warning can
194
+ * be driven by a test clock instead of by waiting. Production uses real
195
+ * timers.
196
+ */
197
+ readonly scheduler?: Scheduler;
198
+ }
@@ -83,24 +83,21 @@ export interface RequestThrottle {
83
83
  * banner path depends on it. React may run a `useMemo` or an effect more than
84
84
  * once for a single committed render — StrictMode does it deliberately — and
85
85
  * without a token the second run would consume a second slot and be refused,
86
- * so a banner would never appear in development. Passing the cycle's
87
- * correlation id means "this is the same request I already asked about", and
88
- * the previous decision is returned unchanged.
86
+ * so a banner would never appear in development. Passing the banner mount's
87
+ * token means "this is the same request I already asked about", and the
88
+ * previous decision is returned unchanged.
89
89
  *
90
90
  * Omit `token` for a genuinely new imperative request, where every call is a
91
91
  * distinct intent to ask the network.
92
92
  */
93
93
  tryAcquire(key: string, minIntervalMs: number, token?: string): ThrottleDecision;
94
94
  /**
95
- * Forgets a placement's last request.
96
- *
97
- * Used when a placement's resources are released, so a genuinely new cycle is
98
- * not charged for the previous one.
95
+ * How long until this placement may ask again, without taking a slot: 0 when
96
+ * it may ask now. A load retry waits at least this long, so a retry policy
97
+ * cannot request faster than the floor allows (ADS-07).
99
98
  */
100
- reset(key: string): void;
99
+ waitFor(key: string, minIntervalMs: number): number;
101
100
  clear(): void;
102
- /** Diagnostics only: when the placement last requested, or undefined. */
103
- lastRequestAt(key: string): number | undefined;
104
101
  }
105
102
 
106
103
  export function createRequestThrottle(now: () => number): RequestThrottle {
@@ -134,6 +131,10 @@ export function createRequestThrottle(now: () => number): RequestThrottle {
134
131
  };
135
132
 
136
133
  return {
134
+ waitFor(key, minIntervalMs) {
135
+ const decision = decide(key, minIntervalMs);
136
+ return decision.allowed ? 0 : decision.retryAfterMs;
137
+ },
137
138
  tryAcquire(key, minIntervalMs, token) {
138
139
  if (token !== undefined) {
139
140
  const previous = lastToken.get(key);
@@ -161,16 +162,9 @@ export function createRequestThrottle(now: () => number): RequestThrottle {
161
162
  }
162
163
  return decision;
163
164
  },
164
- reset(key) {
165
- lastAt.delete(key);
166
- lastToken.delete(key);
167
- },
168
165
  clear() {
169
166
  lastAt.clear();
170
167
  lastToken.clear();
171
168
  },
172
- lastRequestAt(key) {
173
- return lastAt.get(key);
174
- },
175
169
  };
176
170
  }
@@ -60,6 +60,10 @@ export type AdsErrorCode =
60
60
  // is a show refusal that resolves with time rather than with another attempt.
61
61
  | 'ad_originated_return'
62
62
  | 'provider_error'
63
+ // The provider refused the request itself: an invalid ad unit or request, a
64
+ // missing app id. Not retryable — the same request fails the same way, and
65
+ // retrying would spend billable requests on a certainty (FR-135, ADS-08).
66
+ | 'provider_rejected'
63
67
  | 'internal_error'
64
68
  // Configuration — a retry policy outside its bounds. Rejected rather than
65
69
  // clamped, matching `timeout_out_of_bounds`: silently clamping tells the app
@@ -181,12 +185,27 @@ export function redact(value: unknown): string {
181
185
  }
182
186
 
183
187
  /**
184
- * Maps a provider failure onto the normalized taxonomy. Provider codes vary by
185
- * SDK version, so the match is on substrings that have been stable across
186
- * releases, and anything unrecognized becomes a retryable `provider_error`
187
- * rather than being silently reshaped into a code that means something specific.
188
+ * Maps a provider failure onto the normalized taxonomy.
189
+ *
190
+ * The adapter's classification wins when it gave one (ADS-08). Otherwise the
191
+ * match is on substrings that have been stable across provider releases, and
192
+ * anything unrecognized becomes a retryable `provider_error` rather than being
193
+ * reshaped into a code that means something specific.
188
194
  */
189
- export function normalizeProviderError(value: unknown, options: AdsErrorOptions = {}): AdsError {
195
+ export function normalizeProviderError(
196
+ value: unknown,
197
+ options: AdsErrorOptions = {},
198
+ kind?: 'no_fill' | 'network' | 'rejected',
199
+ ): AdsError {
200
+ switch (kind) {
201
+ case 'no_fill':
202
+ return new AdsError('no_fill', { ...options, cause: value });
203
+ case 'network':
204
+ return new AdsError('network_error', { ...options, cause: value });
205
+ case 'rejected':
206
+ return new AdsError('provider_rejected', { ...options, cause: value });
207
+ default:
208
+ }
190
209
  const text = value instanceof Error ? value.message : String(value ?? '');
191
210
  const lowered = text.toLowerCase();
192
211
 
@@ -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
+ }
@@ -18,9 +18,13 @@
18
18
 
19
19
  import { lazyPeer } from '@chipmobilesdk/rn-core';
20
20
 
21
+ import type { ComponentType } from 'react';
21
22
  import { Platform } from 'react-native';
22
23
 
23
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';
24
28
  import type {
25
29
  AdsEngine,
26
30
  ConsentPort,
@@ -58,6 +62,8 @@ interface ProviderModule {
58
62
  MaxAdContentRating: Record<string, string>;
59
63
  AdsConsent: ProviderConsent;
60
64
  AdsConsentDebugGeography: Record<string, number>;
65
+ BannerAd?: ComponentType<Record<string, unknown>>;
66
+ BannerAdSize?: Record<string, string>;
61
67
  }
62
68
 
63
69
  /** The slice of the bridge's consent module this adapter uses. */
@@ -218,6 +224,10 @@ export function createAdMobEngine(): AdsEngine {
218
224
  return supported && getProvider() !== null;
219
225
  },
220
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
+
221
231
  async initialize(privacy: EnginePrivacy) {
222
232
  const module = getProvider();
223
233
  if (!module) {
@@ -308,7 +318,11 @@ export function createAdMobEngine(): AdsEngine {
308
318
  };
309
319
 
310
320
  on(AdEventType.LOADED, () => ({ type: 'loaded' }));
311
- on(AdEventType.ERROR, cause => ({ type: 'failed', cause }));
321
+ on(AdEventType.ERROR, cause => ({
322
+ type: 'failed',
323
+ cause,
324
+ kind: classifyProviderFailure(cause),
325
+ }));
312
326
  on(AdEventType.OPENED, () => ({ type: 'opened' }));
313
327
  on(AdEventType.CLICKED, () => ({ type: 'clicked' }));
314
328
  on(AdEventType.CLOSED, () => ({ type: 'dismissed' }));
@@ -323,17 +337,10 @@ export function createAdMobEngine(): AdsEngine {
323
337
  }
324
338
 
325
339
  on(RewardedAdEventType.LOADED, () => ({ type: 'loaded' }));
326
- on(RewardedAdEventType.EARNED_REWARD, payload => {
327
- const source = (payload ?? {}) as { type?: unknown; amount?: unknown };
328
- const amount = Number(source.amount);
329
- return {
330
- type: 'rewarded',
331
- reward: {
332
- type: typeof source.type === 'string' ? source.type : 'reward',
333
- amount: Number.isFinite(amount) ? amount : 0,
334
- },
335
- };
336
- });
340
+ on(RewardedAdEventType.EARNED_REWARD, payload => ({
341
+ type: 'rewarded',
342
+ reward: normalizeReward(payload),
343
+ }));
337
344
 
338
345
  return makeUnsubscribe(removers);
339
346
  },
@@ -408,3 +415,5 @@ function createAdMobConsent(getProvider: () => ProviderModule | null): ConsentPo
408
415
  },
409
416
  };
410
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
- }