@chipmobilesdk/rn-ads 0.1.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 +46 -0
- package/README.md +372 -0
- package/package.json +49 -0
- package/skills/sdk-rn-ads/SKILL.md +98 -0
- package/src/config/resolveAdUnit.ts +85 -0
- package/src/config/types.ts +119 -0
- package/src/config/validate.ts +112 -0
- package/src/core/adsInstance.ts +1031 -0
- package/src/core/fullScreenLock.ts +54 -0
- package/src/core/initMachine.ts +108 -0
- package/src/core/placementMachine.ts +114 -0
- package/src/core/privacyGate.ts +85 -0
- package/src/core/rewardedSession.ts +84 -0
- package/src/core/timers.ts +109 -0
- package/src/diagnostics/correlation.ts +31 -0
- package/src/diagnostics/errors.ts +210 -0
- package/src/diagnostics/events.ts +179 -0
- package/src/engine/admobEngine.ts +231 -0
- package/src/engine/port.ts +72 -0
- package/src/engine/testAdUnits.ts +58 -0
- package/src/index.ts +69 -0
- package/src/react/AdsBanner.tsx +270 -0
- package/src/react/AdsProvider.tsx +57 -0
- package/src/react/hooks.ts +75 -0
- package/src/testing/index.ts +206 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single full-screen lock (FR-030).
|
|
3
|
+
*
|
|
4
|
+
* One token per instance, not per placement, because the constraint is about the
|
|
5
|
+
* screen rather than about any particular ad: two full-screen ads stacked on top
|
|
6
|
+
* of each other is a broken app regardless of which placements produced them.
|
|
7
|
+
*
|
|
8
|
+
* **There is no queue.** A refused show is refused, and the caller is told so
|
|
9
|
+
* immediately. Queueing would mean an ad opening at a moment the app never
|
|
10
|
+
* chose — seconds later, on a different screen, after the user moved on — which
|
|
11
|
+
* is exactly the behavior FR-045 forbids the package from deciding on its own.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export interface FullScreenLock {
|
|
15
|
+
/** Takes the lock, or returns false if another full-screen ad holds it. */
|
|
16
|
+
acquire(placementKey: string): boolean;
|
|
17
|
+
/** Releases only if this placement holds it; a stale release is a no-op. */
|
|
18
|
+
release(placementKey: string): void;
|
|
19
|
+
/** Whichever placement currently holds it, if any. */
|
|
20
|
+
holder(): string | null;
|
|
21
|
+
isHeld(): boolean;
|
|
22
|
+
/** Drops the lock unconditionally. For dispose only. */
|
|
23
|
+
reset(): void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function createFullScreenLock(): FullScreenLock {
|
|
27
|
+
let held: string | null = null;
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
acquire(placementKey) {
|
|
31
|
+
if (held !== null) {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
held = placementKey;
|
|
35
|
+
return true;
|
|
36
|
+
},
|
|
37
|
+
release(placementKey) {
|
|
38
|
+
// Guarded so a late dismissal from an earlier ad cannot release the
|
|
39
|
+
// lock out from under the ad currently on screen.
|
|
40
|
+
if (held === placementKey) {
|
|
41
|
+
held = null;
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
holder() {
|
|
45
|
+
return held;
|
|
46
|
+
},
|
|
47
|
+
isHeld() {
|
|
48
|
+
return held !== null;
|
|
49
|
+
},
|
|
50
|
+
reset() {
|
|
51
|
+
held = null;
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Initialization state machine (FR-022 – FR-025).
|
|
3
|
+
*
|
|
4
|
+
* Pure and synchronous: it decides what a transition means; the instance
|
|
5
|
+
* performs the I/O. That split is what makes every rule in data-model.md §3.1
|
|
6
|
+
* testable without a provider.
|
|
7
|
+
*
|
|
8
|
+
* The load-bearing invariant is idempotence. Three consumers calling
|
|
9
|
+
* `initialize()` at once must produce exactly one native initialization and
|
|
10
|
+
* three identical results (SC-005) — the instance holds one in-flight promise,
|
|
11
|
+
* and this machine refuses to leave `initializing` for anything but a terminal
|
|
12
|
+
* outcome.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { InitStatus, PrivacyBlockReason, UnavailableReason } from '../config/types';
|
|
16
|
+
import type { AdsError } from '../diagnostics/errors';
|
|
17
|
+
|
|
18
|
+
export type InitEvent =
|
|
19
|
+
| { readonly type: 'START' }
|
|
20
|
+
| { readonly type: 'BLOCK'; readonly reason: PrivacyBlockReason }
|
|
21
|
+
| { readonly type: 'UNAVAILABLE'; readonly reason: UnavailableReason }
|
|
22
|
+
| { readonly type: 'DISABLE' }
|
|
23
|
+
| { readonly type: 'ENABLE' }
|
|
24
|
+
| { readonly type: 'SUCCEED' }
|
|
25
|
+
| { readonly type: 'FAIL'; readonly error: AdsError };
|
|
26
|
+
|
|
27
|
+
export interface InitSnapshot {
|
|
28
|
+
readonly status: InitStatus;
|
|
29
|
+
readonly blockReason: PrivacyBlockReason | null;
|
|
30
|
+
readonly unavailableReason: UnavailableReason | null;
|
|
31
|
+
readonly error: AdsError | null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const initialInit: InitSnapshot = {
|
|
35
|
+
status: 'not_started',
|
|
36
|
+
blockReason: null,
|
|
37
|
+
unavailableReason: null,
|
|
38
|
+
error: null,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
function at(status: InitStatus, patch: Partial<InitSnapshot> = {}): InitSnapshot {
|
|
42
|
+
return { ...initialInit, status, ...patch };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* States a fresh start may leave from.
|
|
47
|
+
*
|
|
48
|
+
* `blocked` is included because resolving consent and trying again is the
|
|
49
|
+
* documented, expected flow (FR-019). `initializing` is excluded because that is
|
|
50
|
+
* exactly the race idempotence exists to prevent. `ready` is excluded because
|
|
51
|
+
* re-initializing an initialized provider is what FR-022 forbids.
|
|
52
|
+
*/
|
|
53
|
+
const STARTABLE: ReadonlySet<InitStatus> = new Set<InitStatus>([
|
|
54
|
+
'not_started',
|
|
55
|
+
'blocked',
|
|
56
|
+
'failed',
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
export function canStart(status: InitStatus): boolean {
|
|
60
|
+
return STARTABLE.has(status);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Only `ready` may issue requests. Everything else is a refusal with a reason. */
|
|
64
|
+
export function canRequest(status: InitStatus): boolean {
|
|
65
|
+
return status === 'ready';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function initReducer(state: InitSnapshot, event: InitEvent): InitSnapshot {
|
|
69
|
+
switch (event.type) {
|
|
70
|
+
case 'START':
|
|
71
|
+
// `disabled` and `unavailable` are terminal for the instance: a config
|
|
72
|
+
// change means a new instance, not a resurrected one.
|
|
73
|
+
if (state.status === 'disabled' || state.status === 'unavailable') {
|
|
74
|
+
return state;
|
|
75
|
+
}
|
|
76
|
+
return canStart(state.status) ? at('initializing') : state;
|
|
77
|
+
|
|
78
|
+
case 'BLOCK':
|
|
79
|
+
if (state.status === 'disabled' || state.status === 'unavailable') {
|
|
80
|
+
return state;
|
|
81
|
+
}
|
|
82
|
+
// A gate withdrawn after initialization does not un-initialize the
|
|
83
|
+
// provider — it stops requests. Reporting `blocked` is what the app
|
|
84
|
+
// acts on; the native SDK stays as it is.
|
|
85
|
+
return at('blocked', { blockReason: event.reason });
|
|
86
|
+
|
|
87
|
+
case 'UNAVAILABLE':
|
|
88
|
+
return at('unavailable', { unavailableReason: event.reason });
|
|
89
|
+
|
|
90
|
+
case 'DISABLE':
|
|
91
|
+
return at('disabled');
|
|
92
|
+
|
|
93
|
+
case 'ENABLE':
|
|
94
|
+
return state.status === 'disabled' ? at('not_started') : state;
|
|
95
|
+
|
|
96
|
+
case 'SUCCEED':
|
|
97
|
+
// Only a transition out of `initializing` counts. A `SUCCEED` arriving
|
|
98
|
+
// after a disable or a block is a late callback, and honoring it would
|
|
99
|
+
// resurrect a state the app deliberately left.
|
|
100
|
+
return state.status === 'initializing' ? at('ready') : state;
|
|
101
|
+
|
|
102
|
+
case 'FAIL':
|
|
103
|
+
return state.status === 'initializing' ? at('failed', { error: event.error }) : state;
|
|
104
|
+
|
|
105
|
+
default:
|
|
106
|
+
return state;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-placement lifecycle (FR-026 – FR-031, FR-034).
|
|
3
|
+
*
|
|
4
|
+
* Pure and synchronous, per data-model.md §3.2. Two variants share one reducer:
|
|
5
|
+
*
|
|
6
|
+
* full-screen — load, show once, become `consumed`, reload before the next show
|
|
7
|
+
* banner — continuous; it renders and may refresh, and never reaches
|
|
8
|
+
* `consumed`, because a banner is not a single-use resource
|
|
9
|
+
*
|
|
10
|
+
* Staleness is handled here rather than at the show site so that a stale ad can
|
|
11
|
+
* never be *reported* ready either — a consumer that renders a "Watch ad" button
|
|
12
|
+
* from `state === 'ready'` would otherwise offer a button that fails on tap.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { AdFormat, PlacementState, UnavailableReason } from '../config/types';
|
|
16
|
+
import type { AdsError } from '../diagnostics/errors';
|
|
17
|
+
|
|
18
|
+
export type PlacementEvent =
|
|
19
|
+
| { readonly type: 'LOAD' }
|
|
20
|
+
| { readonly type: 'LOADED'; readonly atMs: number }
|
|
21
|
+
| { readonly type: 'LOAD_FAILED'; readonly error: AdsError }
|
|
22
|
+
| { readonly type: 'SHOW' }
|
|
23
|
+
| { readonly type: 'SHOW_FAILED'; readonly error: AdsError }
|
|
24
|
+
| { readonly type: 'DISMISSED' }
|
|
25
|
+
| { readonly type: 'EXPIRE' }
|
|
26
|
+
| { readonly type: 'UNAVAILABLE'; readonly reason: UnavailableReason }
|
|
27
|
+
| { readonly type: 'RESET' };
|
|
28
|
+
|
|
29
|
+
export interface PlacementSnapshot {
|
|
30
|
+
readonly state: PlacementState;
|
|
31
|
+
readonly loadedAtMs: number | null;
|
|
32
|
+
readonly error: AdsError | null;
|
|
33
|
+
readonly correlationId: string | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const initialPlacement: PlacementSnapshot = {
|
|
37
|
+
state: 'idle',
|
|
38
|
+
loadedAtMs: null,
|
|
39
|
+
error: null,
|
|
40
|
+
correlationId: null,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** A load is only legal from a settled state; `loading` is where FR-029 bites. */
|
|
44
|
+
export function canLoad(state: PlacementState): boolean {
|
|
45
|
+
return state === 'idle' || state === 'failed' || state === 'consumed' || state === 'ready';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Only a ready ad may be shown. Everything else is a determinate refusal (FR-028). */
|
|
49
|
+
export function canShow(state: PlacementState): boolean {
|
|
50
|
+
return state === 'ready';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function placementReducer(
|
|
54
|
+
state: PlacementSnapshot,
|
|
55
|
+
event: PlacementEvent,
|
|
56
|
+
format: AdFormat,
|
|
57
|
+
): PlacementSnapshot {
|
|
58
|
+
switch (event.type) {
|
|
59
|
+
case 'LOAD':
|
|
60
|
+
// FR-029: a load while already loading is absorbed. The instance
|
|
61
|
+
// returns the in-flight promise; the state does not restart.
|
|
62
|
+
if (state.state === 'loading' || state.state === 'showing') {
|
|
63
|
+
return state;
|
|
64
|
+
}
|
|
65
|
+
if (state.state === 'unavailable') {
|
|
66
|
+
return state;
|
|
67
|
+
}
|
|
68
|
+
return { ...initialPlacement, state: 'loading' };
|
|
69
|
+
|
|
70
|
+
case 'LOADED':
|
|
71
|
+
return state.state === 'loading'
|
|
72
|
+
? { ...state, state: 'ready', loadedAtMs: event.atMs, error: null }
|
|
73
|
+
: state;
|
|
74
|
+
|
|
75
|
+
case 'LOAD_FAILED':
|
|
76
|
+
return state.state === 'loading'
|
|
77
|
+
? { ...initialPlacement, state: 'failed', error: event.error }
|
|
78
|
+
: state;
|
|
79
|
+
|
|
80
|
+
case 'SHOW':
|
|
81
|
+
return state.state === 'ready' ? { ...state, state: 'showing' } : state;
|
|
82
|
+
|
|
83
|
+
case 'SHOW_FAILED':
|
|
84
|
+
// A failed presentation consumes the ad regardless: the provider has
|
|
85
|
+
// taken it, and re-showing the same object is what FR-031 forbids.
|
|
86
|
+
return state.state === 'showing'
|
|
87
|
+
? { ...initialPlacement, state: 'failed', error: event.error }
|
|
88
|
+
: state;
|
|
89
|
+
|
|
90
|
+
case 'DISMISSED':
|
|
91
|
+
if (state.state !== 'showing') {
|
|
92
|
+
return state;
|
|
93
|
+
}
|
|
94
|
+
// A banner is continuous — dismissal is not part of its life. A
|
|
95
|
+
// full-screen ad is spent, and must be reloaded before the next show.
|
|
96
|
+
return format === 'banner'
|
|
97
|
+
? { ...initialPlacement, state: 'idle' }
|
|
98
|
+
: { ...initialPlacement, state: 'consumed' };
|
|
99
|
+
|
|
100
|
+
case 'EXPIRE':
|
|
101
|
+
// FR-034. Only a ready-and-waiting ad can go stale; one being shown is
|
|
102
|
+
// already in the user's hands.
|
|
103
|
+
return state.state === 'ready' ? { ...initialPlacement, state: 'idle' } : state;
|
|
104
|
+
|
|
105
|
+
case 'UNAVAILABLE':
|
|
106
|
+
return { ...initialPlacement, state: 'unavailable' };
|
|
107
|
+
|
|
108
|
+
case 'RESET':
|
|
109
|
+
return initialPlacement;
|
|
110
|
+
|
|
111
|
+
default:
|
|
112
|
+
return state;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The privacy gate (FR-016 – FR-021).
|
|
3
|
+
*
|
|
4
|
+
* The package enforces ordering; the app owns policy. It renders no consent UI,
|
|
5
|
+
* writes no legal copy, and decides nothing about a jurisdiction — it accepts a
|
|
6
|
+
* gate outcome and refuses to let any request precede it.
|
|
7
|
+
*
|
|
8
|
+
* That refusal is the whole value. Consent ordering is easy to get wrong by
|
|
9
|
+
* accident, and the failure is invisible: an ad request that should not have
|
|
10
|
+
* happened looks exactly like one that should. A package that enforces the order
|
|
11
|
+
* is worth more than documentation that describes it.
|
|
12
|
+
*
|
|
13
|
+
* Pure — no provider, no React, no I/O. The engine adapter translates the
|
|
14
|
+
* outcome into provider configuration.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { PrivacyBlockReason, PrivacyOptions } from '../config/types';
|
|
18
|
+
import type { EnginePrivacy } from '../engine/port';
|
|
19
|
+
|
|
20
|
+
export type GateDecision =
|
|
21
|
+
| { readonly allowed: true }
|
|
22
|
+
| { readonly allowed: false; readonly reason: PrivacyBlockReason };
|
|
23
|
+
|
|
24
|
+
export function evaluateGate(privacy: PrivacyOptions | null): GateDecision {
|
|
25
|
+
if (!privacy) {
|
|
26
|
+
return { allowed: false, reason: 'consent_required' };
|
|
27
|
+
}
|
|
28
|
+
if (privacy.canRequestAds) {
|
|
29
|
+
return { allowed: true };
|
|
30
|
+
}
|
|
31
|
+
// Distinguishing "not asked yet" from "asked and refused" matters: the first
|
|
32
|
+
// is a prompt the app still owes the user, the second is a settled answer.
|
|
33
|
+
// Collapsing them would leave the app unable to tell which (FR-018).
|
|
34
|
+
return {
|
|
35
|
+
allowed: false,
|
|
36
|
+
reason: privacy.consentUndetermined ? 'consent_undetermined' : 'consent_required',
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Flags the provider only honors at initialization time.
|
|
42
|
+
*
|
|
43
|
+
* Changing one of these after `initialize()` cannot take effect, which is why
|
|
44
|
+
* `updatePrivacy` reports `requires_reinitialization` instead of accepting the
|
|
45
|
+
* change and leaving the package in a state that is half old and half new
|
|
46
|
+
* (FR-020).
|
|
47
|
+
*
|
|
48
|
+
* `canRequestAds` is absent on purpose: resolving the gate after being blocked
|
|
49
|
+
* is the normal, supported flow, not a reinitialization (FR-019).
|
|
50
|
+
*/
|
|
51
|
+
const INIT_TIME_ONLY = [
|
|
52
|
+
'childDirected',
|
|
53
|
+
'underAgeOfConsent',
|
|
54
|
+
'maxAdContentRating',
|
|
55
|
+
] as const satisfies readonly (keyof PrivacyOptions)[];
|
|
56
|
+
|
|
57
|
+
export function requiresReinitialization(
|
|
58
|
+
applied: PrivacyOptions | null,
|
|
59
|
+
next: PrivacyOptions,
|
|
60
|
+
): boolean {
|
|
61
|
+
if (!applied) {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
return INIT_TIME_ONLY.some(field => applied[field] !== next[field]);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Projects consumer privacy options onto what the engine needs.
|
|
69
|
+
*
|
|
70
|
+
* `personalizedAds` is carried through rather than inverted here; the adapter
|
|
71
|
+
* owns the translation to the provider's `requestNonPersonalizedAdsOnly`, so
|
|
72
|
+
* the polarity flip lives next to the API that requires it.
|
|
73
|
+
*/
|
|
74
|
+
export function toEnginePrivacy(
|
|
75
|
+
privacy: PrivacyOptions,
|
|
76
|
+
testDeviceIds: readonly string[],
|
|
77
|
+
): EnginePrivacy {
|
|
78
|
+
return {
|
|
79
|
+
childDirected: privacy.childDirected,
|
|
80
|
+
underAgeOfConsent: privacy.underAgeOfConsent,
|
|
81
|
+
maxAdContentRating: privacy.maxAdContentRating,
|
|
82
|
+
personalizedAds: privacy.personalizedAds,
|
|
83
|
+
testDeviceIds,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rewarded session bookkeeping (FR-046 – FR-049).
|
|
3
|
+
*
|
|
4
|
+
* Two independent booleans, never derived from one another:
|
|
5
|
+
*
|
|
6
|
+
* rewardConfirmed=false rewardConfirmed=true
|
|
7
|
+
* dismissed=false in progress reward emitted, awaiting dismissal
|
|
8
|
+
* dismissed=true dismissed_no_reward earned
|
|
9
|
+
*
|
|
10
|
+
* The defect this exists to prevent is inferring "earned" from dismissal. It is
|
|
11
|
+
* the most common way rewarded integrations go wrong, and it is a direct revenue
|
|
12
|
+
* and fairness fault: a user who closed the ad after two seconds gets the prize,
|
|
13
|
+
* and the publisher is billed for nothing.
|
|
14
|
+
*
|
|
15
|
+
* Tracking two flags and deriving the outcome makes both callback orderings
|
|
16
|
+
* produce the same result *by construction* rather than by the provider
|
|
17
|
+
* happening to fire in a convenient order (SC-007).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { Reward } from '../config/types';
|
|
21
|
+
|
|
22
|
+
export type RewardedOutcome = 'in_progress' | 'earned' | 'dismissed_without_reward';
|
|
23
|
+
|
|
24
|
+
export interface RewardedSession {
|
|
25
|
+
/**
|
|
26
|
+
* Records a provider reward confirmation. Returns the reward the first time
|
|
27
|
+
* and `null` on every repeat, which is what collapses a duplicate provider
|
|
28
|
+
* emission into exactly one public event (FR-054).
|
|
29
|
+
*/
|
|
30
|
+
confirmReward(reward: Reward): Reward | null;
|
|
31
|
+
/** Records dismissal. Returns true the first time only. */
|
|
32
|
+
dismiss(): boolean;
|
|
33
|
+
outcome(): RewardedOutcome;
|
|
34
|
+
/** True only once the provider actually confirmed. Never inferred (FR-046). */
|
|
35
|
+
get rewardEarned(): boolean;
|
|
36
|
+
get dismissed(): boolean;
|
|
37
|
+
reward(): Reward | null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function createRewardedSession(): RewardedSession {
|
|
41
|
+
let confirmed = false;
|
|
42
|
+
let dismissed = false;
|
|
43
|
+
let earnedReward: Reward | null = null;
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
confirmReward(reward) {
|
|
47
|
+
if (confirmed) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
confirmed = true;
|
|
51
|
+
earnedReward = reward;
|
|
52
|
+
return reward;
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
dismiss() {
|
|
56
|
+
if (dismissed) {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
dismissed = true;
|
|
60
|
+
return true;
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
outcome() {
|
|
64
|
+
if (!dismissed) {
|
|
65
|
+
return 'in_progress';
|
|
66
|
+
}
|
|
67
|
+
// Read at dismissal time, so a reward that arrived first is honored and
|
|
68
|
+
// one that never arrives is not invented.
|
|
69
|
+
return confirmed ? 'earned' : 'dismissed_without_reward';
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
get rewardEarned() {
|
|
73
|
+
return confirmed;
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
get dismissed() {
|
|
77
|
+
return dismissed;
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
reward() {
|
|
81
|
+
return earnedReward;
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Timeout and staleness policy (FR-032, FR-034).
|
|
3
|
+
*
|
|
4
|
+
* Defaults and bounds live here rather than at each call site so there is one
|
|
5
|
+
* place to read what the package will wait for. Values outside a band are a
|
|
6
|
+
* configuration error rather than being silently clamped: silently clamping a
|
|
7
|
+
* one-second load timeout to five would mean the app is told it configured
|
|
8
|
+
* something it did not (FR-008).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { AdsConfigError } from '../diagnostics/errors';
|
|
12
|
+
import type { TimeoutConfig, Unsubscribe } from '../config/types';
|
|
13
|
+
|
|
14
|
+
/** Comfortably longer than a normal fill on a slow mobile network, still bounded. */
|
|
15
|
+
export const DEFAULT_LOAD_TIMEOUT_MS = 30_000;
|
|
16
|
+
/** Generous for what is essentially a local presentation. */
|
|
17
|
+
export const DEFAULT_SHOW_TIMEOUT_MS = 10_000;
|
|
18
|
+
/**
|
|
19
|
+
* Google documents that a loaded ad object expires about an hour after load.
|
|
20
|
+
* Sixty minutes is the provider's own number; choosing anything else would be
|
|
21
|
+
* inventing a constraint (R7).
|
|
22
|
+
*/
|
|
23
|
+
export const DEFAULT_AD_VALIDITY_MS = 3_600_000;
|
|
24
|
+
|
|
25
|
+
export const LOAD_TIMEOUT_BOUNDS = { min: 5_000, max: 120_000 } as const;
|
|
26
|
+
export const SHOW_TIMEOUT_BOUNDS = { min: 5_000, max: 30_000 } as const;
|
|
27
|
+
export const AD_VALIDITY_BOUNDS = { min: 60_000, max: 3_600_000 } as const;
|
|
28
|
+
|
|
29
|
+
export interface ResolvedTimeouts {
|
|
30
|
+
readonly load: number;
|
|
31
|
+
readonly show: number;
|
|
32
|
+
readonly adValidity: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function checkBand(
|
|
36
|
+
label: string,
|
|
37
|
+
value: number,
|
|
38
|
+
bounds: { readonly min: number; readonly max: number },
|
|
39
|
+
): number {
|
|
40
|
+
if (!Number.isFinite(value) || value < bounds.min || value > bounds.max) {
|
|
41
|
+
throw new AdsConfigError('timeout_out_of_bounds', {
|
|
42
|
+
message: `${label} must be between ${bounds.min}ms and ${bounds.max}ms; received ${value}`,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
return value;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function resolveTimeouts(
|
|
49
|
+
timeouts: TimeoutConfig | undefined,
|
|
50
|
+
adValidityMs: number | undefined,
|
|
51
|
+
): ResolvedTimeouts {
|
|
52
|
+
return {
|
|
53
|
+
load: checkBand('timeouts.load', timeouts?.load ?? DEFAULT_LOAD_TIMEOUT_MS, LOAD_TIMEOUT_BOUNDS),
|
|
54
|
+
show: checkBand('timeouts.show', timeouts?.show ?? DEFAULT_SHOW_TIMEOUT_MS, SHOW_TIMEOUT_BOUNDS),
|
|
55
|
+
adValidity: checkBand(
|
|
56
|
+
'adValidityMs',
|
|
57
|
+
adValidityMs ?? DEFAULT_AD_VALIDITY_MS,
|
|
58
|
+
AD_VALIDITY_BOUNDS,
|
|
59
|
+
),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Tracks every timer the instance owns so `dispose()` can prove it released
|
|
65
|
+
* them. Counting is not incidental — SC-008 asserts the count reaches zero, and
|
|
66
|
+
* a leaked timer is otherwise invisible until it fires into a disposed consumer.
|
|
67
|
+
*/
|
|
68
|
+
export interface TimerSet {
|
|
69
|
+
after(ms: number, callback: () => void): Unsubscribe;
|
|
70
|
+
clearAll(): void;
|
|
71
|
+
size(): number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function createTimerSet(): TimerSet {
|
|
75
|
+
const handles = new Set<ReturnType<typeof setTimeout>>();
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
after(ms, callback) {
|
|
79
|
+
const handle = setTimeout(() => {
|
|
80
|
+
handles.delete(handle);
|
|
81
|
+
callback();
|
|
82
|
+
}, ms);
|
|
83
|
+
handles.add(handle);
|
|
84
|
+
let cleared = false;
|
|
85
|
+
return () => {
|
|
86
|
+
if (cleared) {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
cleared = true;
|
|
90
|
+
clearTimeout(handle);
|
|
91
|
+
handles.delete(handle);
|
|
92
|
+
};
|
|
93
|
+
},
|
|
94
|
+
clearAll() {
|
|
95
|
+
for (const handle of handles) {
|
|
96
|
+
clearTimeout(handle);
|
|
97
|
+
}
|
|
98
|
+
handles.clear();
|
|
99
|
+
},
|
|
100
|
+
size() {
|
|
101
|
+
return handles.size;
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** A loaded ad past its validity window must never be reported ready (FR-034). */
|
|
107
|
+
export function isStale(loadedAtMs: number, nowMs: number, adValidityMs: number): boolean {
|
|
108
|
+
return nowMs - loadedAtMs >= adValidityMs;
|
|
109
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Correlation identifiers (FR-052).
|
|
3
|
+
*
|
|
4
|
+
* One value is minted when a load begins and carried by every event of that
|
|
5
|
+
* load/show cycle through its terminal event. It is what lets a consumer
|
|
6
|
+
* reconstruct a single ad's life from an event stream alone (SC-009) without the
|
|
7
|
+
* package having to store any history of its own.
|
|
8
|
+
*
|
|
9
|
+
* Opaque and monotonic per instance. Not a UUID, and deliberately not derived
|
|
10
|
+
* from anything about the device or the user — it exists to group events, and
|
|
11
|
+
* anything with more meaning than that would be a payload the package has
|
|
12
|
+
* promised not to carry (FR-021).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export interface CorrelationIssuer {
|
|
16
|
+
next(): string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function createCorrelationIssuer(prefix = 'ad'): CorrelationIssuer {
|
|
20
|
+
let counter = 0;
|
|
21
|
+
// Distinguishes cycles from two instances in one process, which otherwise
|
|
22
|
+
// both start at 1 and would collide in a merged log.
|
|
23
|
+
const instance = Math.random().toString(36).slice(2, 8);
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
next() {
|
|
27
|
+
counter += 1;
|
|
28
|
+
return `${prefix}-${instance}-${counter}`;
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
}
|