@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.
- package/CHANGELOG.md +51 -0
- package/README.md +4 -2
- package/package.json +1 -1
- package/skills/sdk-rn-ads/SKILL.md +2 -1
- package/src/core/adsInstance.ts +72 -1829
- package/src/core/consentFlow.ts +1 -20
- package/src/core/instance/consent.ts +162 -0
- package/src/core/instance/context.ts +328 -0
- package/src/core/instance/gate.ts +147 -0
- package/src/core/instance/init.ts +262 -0
- package/src/core/instance/internals.ts +138 -0
- package/src/core/instance/load.ts +426 -0
- package/src/core/instance/show.ts +202 -0
- package/src/core/instance/types.ts +198 -0
- package/src/core/requestThrottle.ts +11 -17
- package/src/diagnostics/errors.ts +24 -5
- package/src/engine/admobBanner.tsx +55 -0
- package/src/engine/admobEngine.ts +21 -12
- package/src/engine/admobErrors.ts +44 -0
- package/src/engine/port.ts +46 -1
- package/src/engine/testAdUnits.ts +0 -5
- package/src/react/AdsBanner.tsx +81 -80
- package/src/testing/clock.ts +75 -0
- package/src/testing/index.ts +29 -68
|
@@ -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
|
|
87
|
-
*
|
|
88
|
-
*
|
|
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
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
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
|
-
|
|
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.
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
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(
|
|
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 => ({
|
|
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
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
+
}
|
package/src/engine/port.ts
CHANGED
|
@@ -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
|
-
| {
|
|
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
|
-
}
|