@chipmobilesdk/rn-ads 0.1.0 → 0.2.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 +57 -0
- package/README.md +310 -11
- package/package.json +1 -1
- package/skills/sdk-rn-ads/SKILL.md +98 -98
- package/src/config/types.ts +170 -8
- package/src/config/validate.ts +83 -7
- package/src/core/adOriginatedReturn.ts +134 -0
- package/src/core/adsInstance.ts +565 -42
- package/src/core/consentFlow.ts +167 -0
- package/src/core/enablement.ts +76 -0
- package/src/core/gates.ts +91 -0
- package/src/core/initMachine.ts +73 -7
- package/src/core/retryPolicy.ts +127 -0
- package/src/core/timers.ts +109 -10
- package/src/diagnostics/errors.ts +27 -3
- package/src/diagnostics/events.ts +41 -1
- package/src/engine/admobEngine.ts +144 -2
- package/src/engine/port.ts +44 -0
- package/src/engine/testAdUnits.ts +3 -0
- package/src/index.ts +23 -1
- package/src/react/AdsBanner.tsx +23 -1
- package/src/testing/index.ts +155 -0
package/src/core/timers.ts
CHANGED
|
@@ -22,17 +22,81 @@ export const DEFAULT_SHOW_TIMEOUT_MS = 10_000;
|
|
|
22
22
|
*/
|
|
23
23
|
export const DEFAULT_AD_VALIDITY_MS = 3_600_000;
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* App-open's own window, deliberately shorter than the hour the other
|
|
27
|
+
* full-screen formats get (FR-130).
|
|
28
|
+
*
|
|
29
|
+
* Worth being explicit about why: Google expires app-open ads after **four
|
|
30
|
+
* hours**, which is *longer* than DEFAULT_AD_VALIDITY_MS, so the provider does
|
|
31
|
+
* not constrain this at all. The shorter window is a product decision. An
|
|
32
|
+
* app-open ad shows at the most prominent moment the app has, and a creative
|
|
33
|
+
* cached for an hour is stale by then; thirty minutes still spares a returning
|
|
34
|
+
* user the spinner of a cold load, which is both the worst app-open experience
|
|
35
|
+
* and the specific behavior store policy penalizes.
|
|
36
|
+
*/
|
|
37
|
+
export const DEFAULT_APP_OPEN_VALIDITY_MS = 1_800_000;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* How long `enabled: 'undecided'` may go unresolved before one warning fires.
|
|
41
|
+
*
|
|
42
|
+
* Ten seconds is several times a realistic worst case for the round trip this
|
|
43
|
+
* state exists to cover — asking a store whether the user owns an ad-removal
|
|
44
|
+
* entitlement — so an app doing the right thing essentially never trips it,
|
|
45
|
+
* while an app that forgot to resolve trips it inside a single QA session,
|
|
46
|
+
* which is where it needs to be caught (FR-119).
|
|
47
|
+
*/
|
|
48
|
+
export const DEFAULT_UNDECIDED_WARNING_MS = 10_000;
|
|
49
|
+
|
|
25
50
|
export const LOAD_TIMEOUT_BOUNDS = { min: 5_000, max: 120_000 } as const;
|
|
26
51
|
export const SHOW_TIMEOUT_BOUNDS = { min: 5_000, max: 30_000 } as const;
|
|
27
52
|
export const AD_VALIDITY_BOUNDS = { min: 60_000, max: 3_600_000 } as const;
|
|
53
|
+
/** Ceiling is one hour so app-open can never outlive the general window. */
|
|
54
|
+
export const APP_OPEN_VALIDITY_BOUNDS = { min: 60_000, max: 3_600_000 } as const;
|
|
55
|
+
export const UNDECIDED_WARNING_BOUNDS = { min: 1_000, max: 120_000 } as const;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Retry defaults (FR-136).
|
|
59
|
+
*
|
|
60
|
+
* Two retries rather than more: each one is a billable request, and two covers
|
|
61
|
+
* the transient network failure and the momentary no-fill — the cases where
|
|
62
|
+
* trying again plausibly helps. A third mostly pays for a failure that was not
|
|
63
|
+
* transient. 2s then 4s keeps both inside a window the user still associates
|
|
64
|
+
* with the action that triggered the load.
|
|
65
|
+
*/
|
|
66
|
+
export const DEFAULT_RETRY_POLICY = {
|
|
67
|
+
maxRetries: 2,
|
|
68
|
+
initialDelayMs: 2_000,
|
|
69
|
+
maxDelayMs: 30_000,
|
|
70
|
+
} as const;
|
|
71
|
+
|
|
72
|
+
export const RETRY_BOUNDS = {
|
|
73
|
+
maxRetries: { min: 1, max: 5 },
|
|
74
|
+
initialDelayMs: { min: 500, max: 30_000 },
|
|
75
|
+
maxDelayMs: { min: 1_000, max: 60_000 },
|
|
76
|
+
} as const;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Ad-originated return detection (FR-128).
|
|
80
|
+
*
|
|
81
|
+
* A background transition within CLICK_WINDOW of a click is attributed to a
|
|
82
|
+
* browser the ad opened. An absence longer than MAX_AWAY is no longer
|
|
83
|
+
* attributable to the ad — the user did something else in between. GRACE covers
|
|
84
|
+
* the moment right after the return, where an app-open show would otherwise
|
|
85
|
+
* fire, without outliving it and refusing a genuinely later show.
|
|
86
|
+
*/
|
|
87
|
+
export const AD_CLICK_WINDOW_MS = 2_000;
|
|
88
|
+
export const AD_RETURN_MAX_AWAY_MS = 300_000;
|
|
89
|
+
export const AD_RETURN_GRACE_MS = 10_000;
|
|
28
90
|
|
|
29
91
|
export interface ResolvedTimeouts {
|
|
30
92
|
readonly load: number;
|
|
31
93
|
readonly show: number;
|
|
32
94
|
readonly adValidity: number;
|
|
95
|
+
readonly appOpenValidity: number;
|
|
96
|
+
readonly undecidedWarning: number;
|
|
33
97
|
}
|
|
34
98
|
|
|
35
|
-
function checkBand(
|
|
99
|
+
export function checkBand(
|
|
36
100
|
label: string,
|
|
37
101
|
value: number,
|
|
38
102
|
bounds: { readonly min: number; readonly max: number },
|
|
@@ -45,10 +109,15 @@ function checkBand(
|
|
|
45
109
|
return value;
|
|
46
110
|
}
|
|
47
111
|
|
|
48
|
-
export
|
|
49
|
-
timeouts
|
|
50
|
-
adValidityMs
|
|
51
|
-
|
|
112
|
+
export interface TimeoutInput {
|
|
113
|
+
readonly timeouts?: TimeoutConfig;
|
|
114
|
+
readonly adValidityMs?: number;
|
|
115
|
+
readonly appOpenValidityMs?: number;
|
|
116
|
+
readonly undecidedWarningMs?: number;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function resolveTimeouts(input: TimeoutInput): ResolvedTimeouts {
|
|
120
|
+
const { timeouts, adValidityMs, appOpenValidityMs, undecidedWarningMs } = input;
|
|
52
121
|
return {
|
|
53
122
|
load: checkBand('timeouts.load', timeouts?.load ?? DEFAULT_LOAD_TIMEOUT_MS, LOAD_TIMEOUT_BOUNDS),
|
|
54
123
|
show: checkBand('timeouts.show', timeouts?.show ?? DEFAULT_SHOW_TIMEOUT_MS, SHOW_TIMEOUT_BOUNDS),
|
|
@@ -57,9 +126,39 @@ export function resolveTimeouts(
|
|
|
57
126
|
adValidityMs ?? DEFAULT_AD_VALIDITY_MS,
|
|
58
127
|
AD_VALIDITY_BOUNDS,
|
|
59
128
|
),
|
|
129
|
+
appOpenValidity: checkBand(
|
|
130
|
+
'appOpenValidityMs',
|
|
131
|
+
appOpenValidityMs ?? DEFAULT_APP_OPEN_VALIDITY_MS,
|
|
132
|
+
APP_OPEN_VALIDITY_BOUNDS,
|
|
133
|
+
),
|
|
134
|
+
undecidedWarning: checkBand(
|
|
135
|
+
'undecidedWarningMs',
|
|
136
|
+
undecidedWarningMs ?? DEFAULT_UNDECIDED_WARNING_MS,
|
|
137
|
+
UNDECIDED_WARNING_BOUNDS,
|
|
138
|
+
),
|
|
60
139
|
};
|
|
61
140
|
}
|
|
62
141
|
|
|
142
|
+
/**
|
|
143
|
+
* How timers are actually scheduled.
|
|
144
|
+
*
|
|
145
|
+
* Injectable so a test can drive retry backoff, app-open expiry, and the
|
|
146
|
+
* undecided warning by moving a clock rather than by waiting. The production
|
|
147
|
+
* default is `setTimeout`; the fake engine supplies a controllable one. Without
|
|
148
|
+
* this, every timing assertion in the suite would either sleep for real or
|
|
149
|
+
* depend on the test runner's timer mocking, and NFR-103's "no device, no
|
|
150
|
+
* network" would quietly become "no device, no network, but several seconds".
|
|
151
|
+
*/
|
|
152
|
+
export interface Scheduler {
|
|
153
|
+
setTimeout(callback: () => void, ms: number): unknown;
|
|
154
|
+
clearTimeout(handle: unknown): void;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const realScheduler: Scheduler = {
|
|
158
|
+
setTimeout: (callback, ms) => setTimeout(callback, ms),
|
|
159
|
+
clearTimeout: handle => clearTimeout(handle as ReturnType<typeof setTimeout>),
|
|
160
|
+
};
|
|
161
|
+
|
|
63
162
|
/**
|
|
64
163
|
* Tracks every timer the instance owns so `dispose()` can prove it released
|
|
65
164
|
* them. Counting is not incidental — SC-008 asserts the count reaches zero, and
|
|
@@ -71,12 +170,12 @@ export interface TimerSet {
|
|
|
71
170
|
size(): number;
|
|
72
171
|
}
|
|
73
172
|
|
|
74
|
-
export function createTimerSet(): TimerSet {
|
|
75
|
-
const handles = new Set<
|
|
173
|
+
export function createTimerSet(scheduler: Scheduler = realScheduler): TimerSet {
|
|
174
|
+
const handles = new Set<unknown>();
|
|
76
175
|
|
|
77
176
|
return {
|
|
78
177
|
after(ms, callback) {
|
|
79
|
-
const handle = setTimeout(() => {
|
|
178
|
+
const handle = scheduler.setTimeout(() => {
|
|
80
179
|
handles.delete(handle);
|
|
81
180
|
callback();
|
|
82
181
|
}, ms);
|
|
@@ -87,13 +186,13 @@ export function createTimerSet(): TimerSet {
|
|
|
87
186
|
return;
|
|
88
187
|
}
|
|
89
188
|
cleared = true;
|
|
90
|
-
clearTimeout(handle);
|
|
189
|
+
scheduler.clearTimeout(handle);
|
|
91
190
|
handles.delete(handle);
|
|
92
191
|
};
|
|
93
192
|
},
|
|
94
193
|
clearAll() {
|
|
95
194
|
for (const handle of handles) {
|
|
96
|
-
clearTimeout(handle);
|
|
195
|
+
scheduler.clearTimeout(handle);
|
|
97
196
|
}
|
|
98
197
|
handles.clear();
|
|
99
198
|
},
|
|
@@ -27,10 +27,21 @@ export type AdsErrorCode =
|
|
|
27
27
|
| 'unsupported_platform'
|
|
28
28
|
| 'provider_missing'
|
|
29
29
|
| 'disabled'
|
|
30
|
+
// The app has not decided whether ads are on. Sibling of `disabled`, and
|
|
31
|
+
// non-retryable for the same reason: calling again changes nothing until the
|
|
32
|
+
// app resolves it (FR-115).
|
|
33
|
+
| 'undecided'
|
|
30
34
|
// Privacy — resolved by consent changing, not by calling again.
|
|
31
35
|
| 'consent_required'
|
|
32
36
|
| 'consent_undetermined'
|
|
33
37
|
| 'requires_reinitialization'
|
|
38
|
+
// The package's own consent flow has not produced permission. Distinct from
|
|
39
|
+
// `consent_required`, which means the app's own gate is closed — a refusal
|
|
40
|
+
// has to name which of the two inputs is blocking (FR-105a).
|
|
41
|
+
| 'consent_flow_required'
|
|
42
|
+
// The consent flow failed. Never retried by the package: FR-135 excludes
|
|
43
|
+
// consent errors, and re-running the flow is the app's call, not a backoff's.
|
|
44
|
+
| 'consent_flow_failed'
|
|
34
45
|
// Runtime.
|
|
35
46
|
| 'no_fill'
|
|
36
47
|
| 'network_error'
|
|
@@ -41,13 +52,26 @@ export type AdsErrorCode =
|
|
|
41
52
|
| 'busy'
|
|
42
53
|
| 'consumed'
|
|
43
54
|
| 'app_inactive'
|
|
55
|
+
// The user came back to the app from an ad, or from a browser an ad opened.
|
|
56
|
+
// Only the package can know this, which is why refusing it is a capability
|
|
57
|
+
// rather than a policy (FR-128). Not retryable: retry covers load, and this
|
|
58
|
+
// is a show refusal that resolves with time rather than with another attempt.
|
|
59
|
+
| 'ad_originated_return'
|
|
44
60
|
| 'provider_error'
|
|
45
|
-
| 'internal_error'
|
|
61
|
+
| 'internal_error'
|
|
62
|
+
// Configuration — a retry policy outside its bounds. Rejected rather than
|
|
63
|
+
// clamped, matching `timeout_out_of_bounds`: silently clamping tells the app
|
|
64
|
+
// it configured something it did not.
|
|
65
|
+
| 'retry_out_of_bounds';
|
|
46
66
|
|
|
47
67
|
/**
|
|
48
68
|
* "Retrying this operation could plausibly succeed" — not "retry immediately".
|
|
49
|
-
*
|
|
50
|
-
*
|
|
69
|
+
*
|
|
70
|
+
* 0.2.0 added an opt-in retry policy, and this set is what it is allowed to act
|
|
71
|
+
* on. Everything outside it is excluded deliberately: configuration and consent
|
|
72
|
+
* errors, an unsupported platform, and a disabled or undecided instance are all
|
|
73
|
+
* states another attempt cannot change, so retrying them would spend a billable
|
|
74
|
+
* request on a certainty (FR-135).
|
|
51
75
|
*/
|
|
52
76
|
const RETRYABLE: ReadonlySet<AdsErrorCode> = new Set<AdsErrorCode>([
|
|
53
77
|
'no_fill',
|
|
@@ -29,6 +29,10 @@ export type AdEventType =
|
|
|
29
29
|
| 'disabled'
|
|
30
30
|
| 'unavailable'
|
|
31
31
|
| 'config_warning'
|
|
32
|
+
// Emitted once per retry attempt, carrying the correlation id of the load
|
|
33
|
+
// cycle that triggered it. Each attempt is a billable request, so it has to
|
|
34
|
+
// be reconcilable against a bill from the event stream alone (FR-138).
|
|
35
|
+
| 'retry_scheduled'
|
|
32
36
|
| 'internal_error';
|
|
33
37
|
|
|
34
38
|
interface BaseEvent {
|
|
@@ -65,14 +69,50 @@ export type AdEvent = BaseEvent &
|
|
|
65
69
|
readonly operation: AdsOperation;
|
|
66
70
|
readonly reason: UnavailableReason | PrivacyBlockReason;
|
|
67
71
|
}
|
|
72
|
+
| {
|
|
73
|
+
readonly type: 'retry_scheduled';
|
|
74
|
+
/** 1-based: the first retry is attempt 1, not 0. */
|
|
75
|
+
readonly attempt: number;
|
|
76
|
+
readonly delayMs: number;
|
|
77
|
+
readonly error: AdsError;
|
|
78
|
+
}
|
|
68
79
|
| {
|
|
69
80
|
readonly type: 'config_warning';
|
|
70
|
-
readonly warning:
|
|
81
|
+
readonly warning: ConfigWarning;
|
|
82
|
+
/**
|
|
83
|
+
* Placements the warning concerns. Empty for warnings that are not
|
|
84
|
+
* placement-scoped.
|
|
85
|
+
*
|
|
86
|
+
* Present on every config warning rather than only on the one that
|
|
87
|
+
* uses it, so that 0.1.0 code which reads `placementKeys` after
|
|
88
|
+
* checking `type === 'config_warning'` keeps compiling. Splitting
|
|
89
|
+
* this into one variant per warning would have been tidier and
|
|
90
|
+
* would have broken every such consumer for no behavioral gain.
|
|
91
|
+
*/
|
|
71
92
|
readonly placementKeys: readonly string[];
|
|
93
|
+
/** Consent tools ignored, for `consent_tool_ignored_in_production`. */
|
|
94
|
+
readonly tools?: readonly string[];
|
|
95
|
+
/** How long the app left the state unresolved, for the undecided warning. */
|
|
96
|
+
readonly elapsedMs?: number;
|
|
72
97
|
}
|
|
73
98
|
| { readonly type: 'internal_error'; readonly error: AdsError }
|
|
74
99
|
);
|
|
75
100
|
|
|
101
|
+
/**
|
|
102
|
+
* Configuration problems worth telling the app about, all under one event type.
|
|
103
|
+
*
|
|
104
|
+
* One type rather than three because FR-132 forbids a parallel vocabulary, and
|
|
105
|
+
* because a consumer already routing `config_warning` into its logging should
|
|
106
|
+
* not have to subscribe again to keep seeing everything.
|
|
107
|
+
*/
|
|
108
|
+
export type ConfigWarning =
|
|
109
|
+
/** A production ad unit was replaced with an official test unit (FR-011a). */
|
|
110
|
+
| 'test_ad_unit_substituted'
|
|
111
|
+
/** The app left `enabled: 'undecided'` unresolved past its window (FR-119). */
|
|
112
|
+
| 'enablement_undecided_timeout'
|
|
113
|
+
/** Consent testing tools were supplied in production and ignored (FR-113). */
|
|
114
|
+
| 'consent_tool_ignored_in_production';
|
|
115
|
+
|
|
76
116
|
export type AdEventListener = (event: AdEvent) => void;
|
|
77
117
|
|
|
78
118
|
/** Events that may occur at most once within one correlated cycle (FR-054). */
|
|
@@ -19,7 +19,15 @@
|
|
|
19
19
|
import { Platform } from 'react-native';
|
|
20
20
|
|
|
21
21
|
import type { AdContentRating, FullScreenFormat, Unsubscribe } from '../config/types';
|
|
22
|
-
import type {
|
|
22
|
+
import type {
|
|
23
|
+
AdsEngine,
|
|
24
|
+
ConsentPort,
|
|
25
|
+
EngineAdEvent,
|
|
26
|
+
EngineAdHandle,
|
|
27
|
+
EngineConsentInfo,
|
|
28
|
+
EngineConsentOptions,
|
|
29
|
+
EnginePrivacy,
|
|
30
|
+
} from './port';
|
|
23
31
|
|
|
24
32
|
/** The slice of the bridge this adapter uses. Kept local so nothing else sees it. */
|
|
25
33
|
interface ProviderFullScreenAd {
|
|
@@ -39,9 +47,79 @@ interface ProviderModule {
|
|
|
39
47
|
RewardedAd: {
|
|
40
48
|
createForAdRequest(adUnitId: string, options?: Record<string, unknown>): ProviderFullScreenAd;
|
|
41
49
|
};
|
|
50
|
+
AppOpenAd: {
|
|
51
|
+
createForAdRequest(adUnitId: string, options?: Record<string, unknown>): ProviderFullScreenAd;
|
|
52
|
+
};
|
|
42
53
|
AdEventType: Record<string, string>;
|
|
43
54
|
RewardedAdEventType: Record<string, string>;
|
|
44
55
|
MaxAdContentRating: Record<string, string>;
|
|
56
|
+
AdsConsent: ProviderConsent;
|
|
57
|
+
AdsConsentDebugGeography: Record<string, number>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The slice of the bridge's consent module this adapter uses. */
|
|
61
|
+
interface ProviderConsent {
|
|
62
|
+
requestInfoUpdate(options?: Record<string, unknown>): Promise<ProviderConsentInfo>;
|
|
63
|
+
loadAndShowConsentFormIfRequired(): Promise<ProviderConsentInfo>;
|
|
64
|
+
getConsentInfo(): Promise<ProviderConsentInfo>;
|
|
65
|
+
reset(): void;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface ProviderConsentInfo {
|
|
69
|
+
status?: string;
|
|
70
|
+
canRequestAds?: boolean;
|
|
71
|
+
isConsentFormAvailable?: boolean;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The provider's geography enum is numeric and its spelling is the provider's.
|
|
76
|
+
* Translating here rather than in `core/` is the same rule the rest of this file
|
|
77
|
+
* follows: the one module that knows a provider exists is the one that speaks
|
|
78
|
+
* its vocabulary.
|
|
79
|
+
*/
|
|
80
|
+
function toProviderGeography(
|
|
81
|
+
geography: EngineConsentOptions['debugGeography'],
|
|
82
|
+
values: Record<string, number>,
|
|
83
|
+
): number | undefined {
|
|
84
|
+
switch (geography) {
|
|
85
|
+
case 'eea':
|
|
86
|
+
return values.EEA;
|
|
87
|
+
case 'not_eea':
|
|
88
|
+
return values.NOT_EEA;
|
|
89
|
+
case 'regulated_us_state':
|
|
90
|
+
return values.REGULATED_US_STATE;
|
|
91
|
+
case 'other':
|
|
92
|
+
return values.OTHER;
|
|
93
|
+
case 'disabled':
|
|
94
|
+
return values.DISABLED;
|
|
95
|
+
default:
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Normalizes the provider's consent shape into the engine's.
|
|
102
|
+
*
|
|
103
|
+
* `canRequestAds` is taken as reported and never inferred from the status: the
|
|
104
|
+
* provider is the authority on whether a request is permitted, and second-
|
|
105
|
+
* guessing it here is how a package ends up requesting ads it should not
|
|
106
|
+
* (FR-106, FR-107).
|
|
107
|
+
*/
|
|
108
|
+
function toEngineConsentInfo(info: ProviderConsentInfo): EngineConsentInfo {
|
|
109
|
+
const status = String(info.status ?? 'UNKNOWN').toUpperCase();
|
|
110
|
+
const normalized: EngineConsentInfo['status'] =
|
|
111
|
+
status === 'OBTAINED'
|
|
112
|
+
? 'obtained'
|
|
113
|
+
: status === 'REQUIRED'
|
|
114
|
+
? 'required'
|
|
115
|
+
: status === 'NOT_REQUIRED'
|
|
116
|
+
? 'not_required'
|
|
117
|
+
: 'unknown';
|
|
118
|
+
return {
|
|
119
|
+
status: normalized,
|
|
120
|
+
canRequestAds: info.canRequestAds === true,
|
|
121
|
+
isFormAvailable: info.isConsentFormAvailable === true,
|
|
122
|
+
};
|
|
45
123
|
}
|
|
46
124
|
|
|
47
125
|
function loadProvider(): ProviderModule | null {
|
|
@@ -137,7 +215,12 @@ export function createAdMobEngine(): AdsEngine {
|
|
|
137
215
|
throw new Error('provider_missing');
|
|
138
216
|
}
|
|
139
217
|
|
|
140
|
-
const factory =
|
|
218
|
+
const factory =
|
|
219
|
+
format === 'rewarded'
|
|
220
|
+
? module.RewardedAd
|
|
221
|
+
: format === 'app_open'
|
|
222
|
+
? module.AppOpenAd
|
|
223
|
+
: module.InterstitialAd;
|
|
141
224
|
const ad = factory.createForAdRequest(adUnitId, {
|
|
142
225
|
// The consumer-facing polarity is `personalizedAds`; the provider's
|
|
143
226
|
// is the inverse. The flip lives here, next to the API needing it.
|
|
@@ -227,5 +310,64 @@ export function createAdMobEngine(): AdsEngine {
|
|
|
227
310
|
dispose(handle) {
|
|
228
311
|
ads.delete(handle.id);
|
|
229
312
|
},
|
|
313
|
+
|
|
314
|
+
consent: createAdMobConsent(getProvider),
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* The consent platform adapter.
|
|
320
|
+
*
|
|
321
|
+
* It is deliberately thin. Every decision that could be made here — whether a
|
|
322
|
+
* region requires consent, whether an error means "no", whether a dismissed form
|
|
323
|
+
* counts — is made elsewhere or not at all: the provider owns the first, and
|
|
324
|
+
* `core/consentFlow.ts` owns the second and third. This file's only job is to
|
|
325
|
+
* call the right method and hand back a shape that names nothing Google.
|
|
326
|
+
*/
|
|
327
|
+
function createAdMobConsent(getProvider: () => ProviderModule | null): ConsentPort {
|
|
328
|
+
function requireConsent(): ProviderConsent {
|
|
329
|
+
const module = getProvider();
|
|
330
|
+
if (!module?.AdsConsent) {
|
|
331
|
+
throw new Error('provider_missing');
|
|
332
|
+
}
|
|
333
|
+
return module.AdsConsent;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return {
|
|
337
|
+
async requestInfoUpdate(options: EngineConsentOptions) {
|
|
338
|
+
const module = getProvider();
|
|
339
|
+
if (!module?.AdsConsent) {
|
|
340
|
+
throw new Error('provider_missing');
|
|
341
|
+
}
|
|
342
|
+
const debugGeography = toProviderGeography(
|
|
343
|
+
options.debugGeography,
|
|
344
|
+
module.AdsConsentDebugGeography ?? {},
|
|
345
|
+
);
|
|
346
|
+
const info = await module.AdsConsent.requestInfoUpdate({
|
|
347
|
+
// Passing `undefined` rather than omitting keeps the call shape
|
|
348
|
+
// stable; the bridge treats an absent value as "no override".
|
|
349
|
+
debugGeography,
|
|
350
|
+
tagForUnderAgeOfConsent: options.underAgeOfConsent,
|
|
351
|
+
testDeviceIdentifiers: options.testDeviceIds
|
|
352
|
+
? [...options.testDeviceIds]
|
|
353
|
+
: undefined,
|
|
354
|
+
});
|
|
355
|
+
return toEngineConsentInfo(info);
|
|
356
|
+
},
|
|
357
|
+
|
|
358
|
+
async showFormIfRequired() {
|
|
359
|
+
// The provider decides whether a form is needed and renders it. The
|
|
360
|
+
// package never draws consent UI of its own (FR-103).
|
|
361
|
+
const info = await requireConsent().loadAndShowConsentFormIfRequired();
|
|
362
|
+
return toEngineConsentInfo(info);
|
|
363
|
+
},
|
|
364
|
+
|
|
365
|
+
async getInfo() {
|
|
366
|
+
return toEngineConsentInfo(await requireConsent().getConsentInfo());
|
|
367
|
+
},
|
|
368
|
+
|
|
369
|
+
reset() {
|
|
370
|
+
requireConsent().reset();
|
|
371
|
+
},
|
|
230
372
|
};
|
|
231
373
|
}
|
package/src/engine/port.ts
CHANGED
|
@@ -41,6 +41,47 @@ export interface EngineAdHandle {
|
|
|
41
41
|
readonly id: string;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Consent status as the engine reports it — the provider's own vocabulary,
|
|
46
|
+
* lowercased, with nothing provider-shaped attached. `core/consentFlow.ts`
|
|
47
|
+
* normalizes this into the public `ConsentState`; keeping the two separate is
|
|
48
|
+
* what lets an iOS adapter map a different mechanism onto the same public shape
|
|
49
|
+
* without either side changing.
|
|
50
|
+
*/
|
|
51
|
+
export interface EngineConsentInfo {
|
|
52
|
+
readonly status: 'unknown' | 'required' | 'not_required' | 'obtained';
|
|
53
|
+
readonly canRequestAds: boolean;
|
|
54
|
+
readonly isFormAvailable: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface EngineConsentOptions {
|
|
58
|
+
/** Already stripped by config validation when the environment is production. */
|
|
59
|
+
readonly debugGeography?: 'disabled' | 'eea' | 'not_eea' | 'regulated_us_state' | 'other';
|
|
60
|
+
readonly underAgeOfConsent?: boolean;
|
|
61
|
+
readonly testDeviceIds?: readonly string[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The provider's consent platform, behind the same boundary as everything else.
|
|
66
|
+
*
|
|
67
|
+
* It is a separate member of `AdsEngine` rather than more methods on it because
|
|
68
|
+
* it is a genuinely separate native module - the ads SDK and the consent SDK
|
|
69
|
+
* initialize independently and neither requires the other.
|
|
70
|
+
*
|
|
71
|
+
* The package orchestrates; it never renders. `showFormIfRequired` asks the
|
|
72
|
+
* provider to present its own form and reports what came back (FR-103).
|
|
73
|
+
*/
|
|
74
|
+
export interface ConsentPort {
|
|
75
|
+
/** Refreshes consent information, applying any testing options. */
|
|
76
|
+
requestInfoUpdate(options: EngineConsentOptions): Promise<EngineConsentInfo>;
|
|
77
|
+
/** Presents the provider's form if one is required; a no-op otherwise. */
|
|
78
|
+
showFormIfRequired(): Promise<EngineConsentInfo>;
|
|
79
|
+
/** Last known information without contacting the network. */
|
|
80
|
+
getInfo(): Promise<EngineConsentInfo>;
|
|
81
|
+
/** Clears the stored decision. Inert in production by configuration. */
|
|
82
|
+
reset(): void;
|
|
83
|
+
}
|
|
84
|
+
|
|
44
85
|
export interface AdsEngine {
|
|
45
86
|
/**
|
|
46
87
|
* True when this platform has a working adapter. Checked before anything
|
|
@@ -69,4 +110,7 @@ export interface AdsEngine {
|
|
|
69
110
|
|
|
70
111
|
/** Releases the handle's provider resources. Safe to call more than once. */
|
|
71
112
|
dispose(handle: EngineAdHandle): void;
|
|
113
|
+
|
|
114
|
+
/** The provider's consent platform. Only touched if the app opts in (FR-102). */
|
|
115
|
+
readonly consent: ConsentPort;
|
|
72
116
|
}
|
|
@@ -28,6 +28,9 @@ const ANDROID_TEST_AD_UNITS: Readonly<Record<AdFormat, string>> = {
|
|
|
28
28
|
banner: 'ca-app-pub-3940256099942544/9214589741',
|
|
29
29
|
interstitial: 'ca-app-pub-3940256099942544/1033173712',
|
|
30
30
|
rewarded: 'ca-app-pub-3940256099942544/5224354917',
|
|
31
|
+
// Added in 0.2.0 alongside the app-open format. Cross-checked against the
|
|
32
|
+
// bridge's own `TestIds.APP_OPEN` at 16.0.3.
|
|
33
|
+
app_open: 'ca-app-pub-3940256099942544/9257395921',
|
|
31
34
|
};
|
|
32
35
|
|
|
33
36
|
export function testAdUnitFor(format: AdFormat): string {
|
package/src/index.ts
CHANGED
|
@@ -21,6 +21,10 @@ export type {
|
|
|
21
21
|
AdContentRating,
|
|
22
22
|
AdFormat,
|
|
23
23
|
AdsConfig,
|
|
24
|
+
ConsentSnapshot,
|
|
25
|
+
ConsentState,
|
|
26
|
+
ConsentTestingOptions,
|
|
27
|
+
Enablement,
|
|
24
28
|
Environment,
|
|
25
29
|
FullScreenFormat,
|
|
26
30
|
InitStatus,
|
|
@@ -28,6 +32,7 @@ export type {
|
|
|
28
32
|
PlacementState,
|
|
29
33
|
PrivacyBlockReason,
|
|
30
34
|
PrivacyOptions,
|
|
35
|
+
RetryPolicy,
|
|
31
36
|
Reward,
|
|
32
37
|
TimeoutConfig,
|
|
33
38
|
UnavailableReason,
|
|
@@ -39,17 +44,28 @@ export { isFullScreenFormat } from './config/types';
|
|
|
39
44
|
// rather than discovering them from a timeout in production (FR-032).
|
|
40
45
|
export {
|
|
41
46
|
AD_VALIDITY_BOUNDS,
|
|
47
|
+
APP_OPEN_VALIDITY_BOUNDS,
|
|
42
48
|
DEFAULT_AD_VALIDITY_MS,
|
|
49
|
+
DEFAULT_APP_OPEN_VALIDITY_MS,
|
|
43
50
|
DEFAULT_LOAD_TIMEOUT_MS,
|
|
51
|
+
DEFAULT_RETRY_POLICY,
|
|
44
52
|
DEFAULT_SHOW_TIMEOUT_MS,
|
|
53
|
+
DEFAULT_UNDECIDED_WARNING_MS,
|
|
45
54
|
LOAD_TIMEOUT_BOUNDS,
|
|
55
|
+
RETRY_BOUNDS,
|
|
46
56
|
SHOW_TIMEOUT_BOUNDS,
|
|
57
|
+
UNDECIDED_WARNING_BOUNDS,
|
|
47
58
|
} from './core/timers';
|
|
48
59
|
|
|
60
|
+
// Enablement. `EnablementResult` is what `setEnabled` returns in place of
|
|
61
|
+
// 0.1.0's `void` — the defect that let a re-enable fail in silence (FR-122).
|
|
62
|
+
export type { EnablementResult } from './core/enablement';
|
|
63
|
+
|
|
49
64
|
// Instance, lifecycle, and results
|
|
50
65
|
export { createAds } from './core/adsInstance';
|
|
51
66
|
export type {
|
|
52
67
|
AdsInstance,
|
|
68
|
+
ConsentApi,
|
|
53
69
|
InitResult,
|
|
54
70
|
LoadResult,
|
|
55
71
|
PrivacyUpdateResult,
|
|
@@ -66,4 +82,10 @@ export { useAdsEvents, useAdsStatus, usePlacementState } from './react/hooks';
|
|
|
66
82
|
// Diagnostics
|
|
67
83
|
export { AdsConfigError, AdsError, isAdsError } from './diagnostics/errors';
|
|
68
84
|
export type { AdsErrorCode, AdsOperation } from './diagnostics/errors';
|
|
69
|
-
export type {
|
|
85
|
+
export type {
|
|
86
|
+
AdEvent,
|
|
87
|
+
AdEventListener,
|
|
88
|
+
AdEventType,
|
|
89
|
+
ConfigWarning,
|
|
90
|
+
ImpressionSource,
|
|
91
|
+
} from './diagnostics/events';
|
package/src/react/AdsBanner.tsx
CHANGED
|
@@ -102,8 +102,22 @@ export function AdsBanner({ placementKey, onStateChange, style }: AdsBannerProps
|
|
|
102
102
|
// `unavailable` and let the app keep its layout decision open. Every other
|
|
103
103
|
// non-ready status — disabled, blocked, failed, unsupported — means there
|
|
104
104
|
// will be no ad, and the app should reclaim the space (FR-039, FR-042).
|
|
105
|
+
//
|
|
106
|
+
// `undecided` is the exception added in 0.2.0, and it is the entire reason
|
|
107
|
+
// the third state exists. It is reported as itself rather than folded into
|
|
108
|
+
// either neighbour: `idle` would say "an ad is coming", which is not known
|
|
109
|
+
// yet, and `unavailable` would say "there will be none", which is also not
|
|
110
|
+
// known. Only by reporting it honestly can the app decide whether to hold
|
|
111
|
+
// the space — the choice that keeps a paying user from watching an ad
|
|
112
|
+
// flash in and out (FR-116).
|
|
105
113
|
if (status !== 'ready') {
|
|
106
|
-
move(
|
|
114
|
+
move(
|
|
115
|
+
status === 'undecided'
|
|
116
|
+
? 'undecided'
|
|
117
|
+
: status === 'not_started' || status === 'initializing'
|
|
118
|
+
? 'idle'
|
|
119
|
+
: 'unavailable',
|
|
120
|
+
);
|
|
107
121
|
return;
|
|
108
122
|
}
|
|
109
123
|
if (!resolution?.ok) {
|
|
@@ -229,12 +243,20 @@ export function AdsBanner({ placementKey, onStateChange, style }: AdsBannerProps
|
|
|
229
243
|
// tree rather than render an empty host. `not_started` and `initializing` are
|
|
230
244
|
// excluded on purpose: the host must stay mounted through them, because it is
|
|
231
245
|
// what measures the container and starts the load once the instance is ready.
|
|
246
|
+
//
|
|
247
|
+
// `undecided` is excluded for the same reason: the app may still resolve it to
|
|
248
|
+
// enabled, and a host that unmounted would have to re-measure the container
|
|
249
|
+
// from scratch when it did. It renders no ad meanwhile — it renders the same
|
|
250
|
+
// empty measuring host it renders while initializing — and it reports
|
|
251
|
+
// `undecided` so the app can size the space it decided to keep.
|
|
232
252
|
if (status === 'disabled' || status === 'unavailable' || status === 'failed') {
|
|
233
253
|
return null;
|
|
234
254
|
}
|
|
235
255
|
|
|
236
256
|
if (resolution && !resolution.ok) {
|
|
237
257
|
const refusal = resolution.result;
|
|
258
|
+
// `undecided` deliberately absent: unlike `disabled`, it is not a settled
|
|
259
|
+
// no, so the host stays mounted (FR-116).
|
|
238
260
|
if (refusal.status === 'disabled' || refusal.status === 'unavailable') {
|
|
239
261
|
return null;
|
|
240
262
|
}
|