@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.
- package/CHANGELOG.md +178 -0
- package/README.md +12 -3
- package/package.json +5 -2
- package/skills/sdk-rn-ads/SKILL.md +3 -2
- package/src/config/validate.ts +3 -1
- package/src/core/adsInstance.ts +85 -1872
- 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/core/retryPolicy.ts +12 -2
- package/src/core/timers.ts +17 -69
- package/src/diagnostics/errors.ts +52 -63
- package/src/diagnostics/events.ts +77 -66
- package/src/engine/admobBanner.tsx +55 -0
- package/src/engine/admobEngine.ts +38 -18
- package/src/engine/admobErrors.ts +44 -0
- package/src/engine/port.ts +46 -1
- package/src/engine/testAdUnits.ts +0 -5
- package/src/index.ts +19 -1
- package/src/react/AdsBanner.tsx +94 -79
- package/src/react/AdsProvider.tsx +6 -7
- package/src/react/hooks.ts +26 -31
- package/src/testing/clock.ts +75 -0
- package/src/testing/conformance.ts +112 -0
- package/src/testing/index.ts +43 -70
|
@@ -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
|
}
|
package/src/core/retryPolicy.ts
CHANGED
|
@@ -21,6 +21,8 @@
|
|
|
21
21
|
* TimerSet so `dispose()` can prove it released everything.
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
+
import { computeBackoff } from '@chipmobilesdk/rn-core';
|
|
25
|
+
|
|
24
26
|
import { AdsConfigError } from '../diagnostics/errors';
|
|
25
27
|
import { checkBand, DEFAULT_RETRY_POLICY, RETRY_BOUNDS } from './timers';
|
|
26
28
|
import type { RetryPolicy } from '../config/types';
|
|
@@ -47,16 +49,20 @@ export function resolveRetryPolicy(policy: RetryPolicy, label: string): Resolved
|
|
|
47
49
|
`${label}.maxRetries`,
|
|
48
50
|
policy.maxRetries ?? DEFAULT_RETRY_POLICY.maxRetries,
|
|
49
51
|
RETRY_BOUNDS.maxRetries,
|
|
52
|
+
'retry_out_of_bounds',
|
|
53
|
+
'',
|
|
50
54
|
);
|
|
51
55
|
const initialDelayMs = checkBand(
|
|
52
56
|
`${label}.initialDelayMs`,
|
|
53
57
|
policy.initialDelayMs ?? DEFAULT_RETRY_POLICY.initialDelayMs,
|
|
54
58
|
RETRY_BOUNDS.initialDelayMs,
|
|
59
|
+
'retry_out_of_bounds',
|
|
55
60
|
);
|
|
56
61
|
const maxDelayMs = checkBand(
|
|
57
62
|
`${label}.maxDelayMs`,
|
|
58
63
|
policy.maxDelayMs ?? DEFAULT_RETRY_POLICY.maxDelayMs,
|
|
59
64
|
RETRY_BOUNDS.maxDelayMs,
|
|
65
|
+
'retry_out_of_bounds',
|
|
60
66
|
);
|
|
61
67
|
|
|
62
68
|
if (!Number.isInteger(maxRetries)) {
|
|
@@ -117,8 +123,12 @@ export function resolveEffectiveRetry(
|
|
|
117
123
|
* than a property that does not apply.
|
|
118
124
|
*/
|
|
119
125
|
export function delayForAttempt(policy: ResolvedRetryPolicy, attempt: number): number {
|
|
120
|
-
|
|
121
|
-
|
|
126
|
+
return computeBackoff(attempt, {
|
|
127
|
+
initialDelayMs: policy.initialDelayMs,
|
|
128
|
+
maxDelayMs: policy.maxDelayMs,
|
|
129
|
+
factor: 2,
|
|
130
|
+
jitter: 'none',
|
|
131
|
+
});
|
|
122
132
|
}
|
|
123
133
|
|
|
124
134
|
/** Whether another attempt is permitted. `attemptsMade` counts retries only. */
|
package/src/core/timers.ts
CHANGED
|
@@ -8,8 +8,10 @@
|
|
|
8
8
|
* something it did not (FR-008).
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import {
|
|
12
|
-
|
|
11
|
+
import { checkRange, type Range } from '@chipmobilesdk/rn-core';
|
|
12
|
+
|
|
13
|
+
import { AdsConfigError, type AdsErrorCode } from '../diagnostics/errors';
|
|
14
|
+
import type { TimeoutConfig } from '../config/types';
|
|
13
15
|
|
|
14
16
|
/** Comfortably longer than a normal fill on a slow mobile network, still bounded. */
|
|
15
17
|
export const DEFAULT_LOAD_TIMEOUT_MS = 30_000;
|
|
@@ -96,17 +98,19 @@ export interface ResolvedTimeouts {
|
|
|
96
98
|
readonly undecidedWarning: number;
|
|
97
99
|
}
|
|
98
100
|
|
|
101
|
+
/**
|
|
102
|
+
* `value` when it is inside `bounds`, else an `AdsConfigError` with `code`: each
|
|
103
|
+
* field reports the code its documentation names, so a retry policy out of bounds
|
|
104
|
+
* is `retry_out_of_bounds`, not a timeout's code (CORE-04, ADS-16).
|
|
105
|
+
*/
|
|
99
106
|
export function checkBand(
|
|
100
107
|
label: string,
|
|
101
108
|
value: number,
|
|
102
|
-
bounds:
|
|
109
|
+
bounds: Range,
|
|
110
|
+
code: AdsErrorCode = 'timeout_out_of_bounds',
|
|
111
|
+
unit = 'ms',
|
|
103
112
|
): number {
|
|
104
|
-
|
|
105
|
-
throw new AdsConfigError('timeout_out_of_bounds', {
|
|
106
|
-
message: `${label} must be between ${bounds.min}ms and ${bounds.max}ms; received ${value}`,
|
|
107
|
-
});
|
|
108
|
-
}
|
|
109
|
-
return value;
|
|
113
|
+
return checkRange(label, value, bounds, message => new AdsConfigError(code, { message }), unit);
|
|
110
114
|
}
|
|
111
115
|
|
|
112
116
|
export interface TimeoutInput {
|
|
@@ -148,67 +152,11 @@ export function resolveTimeouts(input: TimeoutInput): ResolvedTimeouts {
|
|
|
148
152
|
}
|
|
149
153
|
|
|
150
154
|
/**
|
|
151
|
-
* How timers are
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
* undecided warning by moving a clock rather than by waiting. The production
|
|
155
|
-
* default is `setTimeout`; the fake engine supplies a controllable one. Without
|
|
156
|
-
* this, every timing assertion in the suite would either sleep for real or
|
|
157
|
-
* depend on the test runner's timer mocking, and NFR-103's "no device, no
|
|
158
|
-
* network" would quietly become "no device, no network, but several seconds".
|
|
155
|
+
* How timers are scheduled, and the set that tracks them so `dispose()` can prove
|
|
156
|
+
* it released every one (SC-008): rn-core's, injectable so a test drives retry
|
|
157
|
+
* backoff, app-open expiry and the undecided warning by moving a clock.
|
|
159
158
|
*/
|
|
160
|
-
export
|
|
161
|
-
setTimeout(callback: () => void, ms: number): unknown;
|
|
162
|
-
clearTimeout(handle: unknown): void;
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
const realScheduler: Scheduler = {
|
|
166
|
-
setTimeout: (callback, ms) => setTimeout(callback, ms),
|
|
167
|
-
clearTimeout: handle => clearTimeout(handle as ReturnType<typeof setTimeout>),
|
|
168
|
-
};
|
|
169
|
-
|
|
170
|
-
/**
|
|
171
|
-
* Tracks every timer the instance owns so `dispose()` can prove it released
|
|
172
|
-
* them. Counting is not incidental — SC-008 asserts the count reaches zero, and
|
|
173
|
-
* a leaked timer is otherwise invisible until it fires into a disposed consumer.
|
|
174
|
-
*/
|
|
175
|
-
export interface TimerSet {
|
|
176
|
-
after(ms: number, callback: () => void): Unsubscribe;
|
|
177
|
-
clearAll(): void;
|
|
178
|
-
size(): number;
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
export function createTimerSet(scheduler: Scheduler = realScheduler): TimerSet {
|
|
182
|
-
const handles = new Set<unknown>();
|
|
183
|
-
|
|
184
|
-
return {
|
|
185
|
-
after(ms, callback) {
|
|
186
|
-
const handle = scheduler.setTimeout(() => {
|
|
187
|
-
handles.delete(handle);
|
|
188
|
-
callback();
|
|
189
|
-
}, ms);
|
|
190
|
-
handles.add(handle);
|
|
191
|
-
let cleared = false;
|
|
192
|
-
return () => {
|
|
193
|
-
if (cleared) {
|
|
194
|
-
return;
|
|
195
|
-
}
|
|
196
|
-
cleared = true;
|
|
197
|
-
scheduler.clearTimeout(handle);
|
|
198
|
-
handles.delete(handle);
|
|
199
|
-
};
|
|
200
|
-
},
|
|
201
|
-
clearAll() {
|
|
202
|
-
for (const handle of handles) {
|
|
203
|
-
scheduler.clearTimeout(handle);
|
|
204
|
-
}
|
|
205
|
-
handles.clear();
|
|
206
|
-
},
|
|
207
|
-
size() {
|
|
208
|
-
return handles.size;
|
|
209
|
-
},
|
|
210
|
-
};
|
|
211
|
-
}
|
|
159
|
+
export { createTimerSet, type Scheduler, type TimerSet } from '@chipmobilesdk/rn-core';
|
|
212
160
|
|
|
213
161
|
/** A loaded ad past its validity window must never be reported ready (FR-034). */
|
|
214
162
|
export function isStale(loadedAtMs: number, nowMs: number, adValidityMs: number): boolean {
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
* carrier for identifiers the package promises never to emit (FR-021).
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
+
import { SdkError, createRedactor } from '@chipmobilesdk/rn-core';
|
|
14
|
+
|
|
13
15
|
import type { AdFormat } from '../config/types';
|
|
14
16
|
|
|
15
17
|
export type AdsOperation = 'initialize' | 'load' | 'show' | 'configure';
|
|
@@ -58,6 +60,10 @@ export type AdsErrorCode =
|
|
|
58
60
|
// is a show refusal that resolves with time rather than with another attempt.
|
|
59
61
|
| 'ad_originated_return'
|
|
60
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'
|
|
61
67
|
| 'internal_error'
|
|
62
68
|
// Configuration — a retry policy outside its bounds. Rejected rather than
|
|
63
69
|
// clamped, matching `timeout_out_of_bounds`: silently clamping tells the app
|
|
@@ -94,9 +100,8 @@ export interface AdsErrorOptions {
|
|
|
94
100
|
readonly cause?: unknown;
|
|
95
101
|
}
|
|
96
102
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
readonly retryable: boolean;
|
|
103
|
+
/** The package's error; an `SdkError` (`@chipmobilesdk/rn-core`) with `pkg: 'rn-ads'`. */
|
|
104
|
+
export class AdsError extends SdkError<AdsErrorCode> {
|
|
100
105
|
readonly operation?: AdsOperation;
|
|
101
106
|
readonly placementKey?: string;
|
|
102
107
|
readonly format?: AdFormat;
|
|
@@ -104,15 +109,17 @@ export class AdsError extends Error {
|
|
|
104
109
|
readonly cause?: string;
|
|
105
110
|
|
|
106
111
|
constructor(code: AdsErrorCode, options: AdsErrorOptions = {}) {
|
|
107
|
-
|
|
112
|
+
const cause = options.cause === undefined ? undefined : redact(options.cause);
|
|
113
|
+
super('rn-ads', code, {
|
|
114
|
+
message: options.message,
|
|
115
|
+
retryable: RETRYABLE.has(code),
|
|
116
|
+
redactedCause: cause,
|
|
117
|
+
});
|
|
108
118
|
this.name = 'AdsError';
|
|
109
|
-
this.code = code;
|
|
110
|
-
this.retryable = RETRYABLE.has(code);
|
|
111
119
|
this.operation = options.operation;
|
|
112
120
|
this.placementKey = options.placementKey;
|
|
113
121
|
this.format = options.format;
|
|
114
|
-
this.cause =
|
|
115
|
-
Object.setPrototypeOf(this, AdsError.prototype);
|
|
122
|
+
this.cause = cause;
|
|
116
123
|
}
|
|
117
124
|
}
|
|
118
125
|
|
|
@@ -125,7 +132,6 @@ export class AdsConfigError extends AdsError {
|
|
|
125
132
|
constructor(code: AdsErrorCode, options: AdsErrorOptions = {}) {
|
|
126
133
|
super(code, { ...options, operation: options.operation ?? 'configure' });
|
|
127
134
|
this.name = 'AdsConfigError';
|
|
128
|
-
Object.setPrototypeOf(this, AdsConfigError.prototype);
|
|
129
135
|
}
|
|
130
136
|
}
|
|
131
137
|
|
|
@@ -133,23 +139,15 @@ export function isAdsError(value: unknown): value is AdsError {
|
|
|
133
139
|
return value instanceof AdsError;
|
|
134
140
|
}
|
|
135
141
|
|
|
136
|
-
const REDACTED = '[redacted]';
|
|
137
|
-
|
|
138
142
|
/** An AdMob unit or app id. Redacted so a log never carries a live identifier. */
|
|
139
143
|
const AD_UNIT_PATTERN = /ca-app-pub-\d+[~/]\d+/g;
|
|
140
|
-
/** GAID and any other dashed UUID an SDK might put in a message. */
|
|
141
|
-
const UUID_PATTERN = /[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/g;
|
|
142
|
-
/** Base64-ish runs long enough to be an encoded payload such as a consent string. */
|
|
143
|
-
const PAYLOAD_PATTERN = /[A-Za-z0-9+/=_-]{40,}/g;
|
|
144
|
-
/** Anything path- or URL-like, which can carry query parameters with identifiers. */
|
|
145
|
-
const URL_PATTERN = /[a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^\s"']+/g;
|
|
146
144
|
|
|
147
145
|
/**
|
|
148
146
|
* Keys dropped wholesale rather than pattern-matched, because their values are
|
|
149
147
|
* sensitive regardless of shape — a bare advertising id has no separator for a
|
|
150
148
|
* pattern to catch.
|
|
151
149
|
*/
|
|
152
|
-
const SENSITIVE_KEYS =
|
|
150
|
+
const SENSITIVE_KEYS = [
|
|
153
151
|
'adid',
|
|
154
152
|
'adUnitId',
|
|
155
153
|
'advertisingId',
|
|
@@ -160,63 +158,54 @@ const SENSITIVE_KEYS = new Set([
|
|
|
160
158
|
'tcString',
|
|
161
159
|
'token',
|
|
162
160
|
'userId',
|
|
163
|
-
]
|
|
161
|
+
];
|
|
164
162
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
163
|
+
/**
|
|
164
|
+
* rn-core's pattern redaction (URLs, JWTs, e-mail addresses, UUIDs, payload-sized
|
|
165
|
+
* runs) plus ad unit ids, with the keys above dropped. Provider messages are kept
|
|
166
|
+
* readable: they describe the failure, and every identifier in them is shaped.
|
|
167
|
+
*/
|
|
168
|
+
const redactor = createRedactor({
|
|
169
|
+
text: 'patterns',
|
|
170
|
+
patterns: [AD_UNIT_PATTERN],
|
|
171
|
+
dropKeys: SENSITIVE_KEYS,
|
|
172
|
+
maxDepth: 2,
|
|
173
|
+
});
|
|
172
174
|
|
|
173
175
|
/**
|
|
174
176
|
* Reduces an arbitrary provider value to a safe string (FR-053, NFR-006).
|
|
175
177
|
*
|
|
176
178
|
* Returns a string rather than a structure on purpose: a redacted object still
|
|
177
179
|
* hands the consumer a reference into provider-owned memory, and the contract
|
|
178
|
-
* promises no provider object crosses the boundary.
|
|
180
|
+
* promises no provider object crosses the boundary. An `AdsError` reduces to its
|
|
181
|
+
* code.
|
|
179
182
|
*/
|
|
180
|
-
export function redact(value: unknown
|
|
181
|
-
|
|
182
|
-
return '';
|
|
183
|
-
}
|
|
184
|
-
if (typeof value === 'string') {
|
|
185
|
-
return redactString(value);
|
|
186
|
-
}
|
|
187
|
-
if (typeof value === 'number' || typeof value === 'boolean') {
|
|
188
|
-
return String(value);
|
|
189
|
-
}
|
|
190
|
-
if (value instanceof Error) {
|
|
191
|
-
return redactString(value.message);
|
|
192
|
-
}
|
|
193
|
-
if (depth >= 2) {
|
|
194
|
-
return REDACTED;
|
|
195
|
-
}
|
|
196
|
-
if (Array.isArray(value)) {
|
|
197
|
-
return value.map(item => redact(item, depth + 1)).join(', ');
|
|
198
|
-
}
|
|
199
|
-
if (typeof value === 'object') {
|
|
200
|
-
const parts: string[] = [];
|
|
201
|
-
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
|
|
202
|
-
if (SENSITIVE_KEYS.has(key)) {
|
|
203
|
-
parts.push(`${key}=${REDACTED}`);
|
|
204
|
-
continue;
|
|
205
|
-
}
|
|
206
|
-
parts.push(`${key}=${redact(entry, depth + 1)}`);
|
|
207
|
-
}
|
|
208
|
-
return parts.join(' ');
|
|
209
|
-
}
|
|
210
|
-
return REDACTED;
|
|
183
|
+
export function redact(value: unknown): string {
|
|
184
|
+
return redactor.text(value);
|
|
211
185
|
}
|
|
212
186
|
|
|
213
187
|
/**
|
|
214
|
-
* Maps a provider failure onto the normalized taxonomy.
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
*
|
|
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.
|
|
218
194
|
*/
|
|
219
|
-
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
|
+
}
|
|
220
209
|
const text = value instanceof Error ? value.message : String(value ?? '');
|
|
221
210
|
const lowered = text.toLowerCase();
|
|
222
211
|
|