@chipmobilesdk/rn-ads 0.4.0 → 0.5.1
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 +224 -196
- package/README.md +6 -1
- package/package.json +2 -5
- package/skills/sdk-rn-ads/SKILL.md +1 -2
- package/src/config/resolveAdUnit.ts +85 -85
- package/src/config/types.ts +318 -321
- package/src/config/validate.ts +1 -4
- package/src/core/adOriginatedReturn.ts +134 -134
- package/src/core/adsInstance.ts +76 -37
- package/src/core/consentFlow.ts +182 -180
- package/src/core/enablement.ts +76 -76
- package/src/core/fullScreenLock.ts +54 -54
- package/src/core/gates.ts +94 -91
- package/src/core/initMachine.ts +187 -187
- package/src/core/placementMachine.ts +124 -114
- package/src/core/privacyGate.ts +93 -85
- package/src/core/retryPolicy.ts +127 -127
- package/src/core/rewardedSession.ts +84 -84
- package/src/core/timers.ts +216 -208
- package/src/diagnostics/correlation.ts +31 -31
- package/src/diagnostics/errors.ts +230 -234
- package/src/diagnostics/events.ts +238 -232
- package/src/engine/admobEngine.ts +399 -399
- package/src/engine/port.ts +144 -125
- package/src/engine/testAdUnits.ts +61 -61
- package/src/react/AdsBanner.tsx +17 -17
- package/src/react/AdsProvider.tsx +57 -57
- package/src/react/hooks.ts +75 -75
- package/src/testing/index.ts +463 -460
package/src/core/consentFlow.ts
CHANGED
|
@@ -1,180 +1,182 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Consent orchestration and normalization (FR-101 – FR-110).
|
|
3
|
-
*
|
|
4
|
-
* This closes the most serious gap in 0.1.0. The package *consumed* a consent
|
|
5
|
-
* decision but nothing *produced* one, and the flow that produces it lives
|
|
6
|
-
* inside the very provider library the package promised an app would never have
|
|
7
|
-
* to import. So any app shipping to a consent-required region had to import the
|
|
8
|
-
* provider directly — breaking the package's central promise at exactly the
|
|
9
|
-
* point where it mattered most — and hand-write the same order-sensitive
|
|
10
|
-
* orchestration every other such app was also writing.
|
|
11
|
-
*
|
|
12
|
-
* What this module does **not** do is as load-bearing as what it does:
|
|
13
|
-
*
|
|
14
|
-
* - It renders no consent UI. The form is the provider's (FR-103).
|
|
15
|
-
* - It decides nothing about jurisdictions. Which regions require consent is
|
|
16
|
-
* the provider's answer and the publisher's console configuration; the
|
|
17
|
-
* package forwards it (FR-107).
|
|
18
|
-
* - It never infers consent. An error, a cancellation, a dismissed form, a
|
|
19
|
-
* network failure — all of them land on `error`, and `error` never carries
|
|
20
|
-
* permission (FR-106). This is the invariant the whole module exists to
|
|
21
|
-
* protect, because the failure it prevents is invisible: a request that
|
|
22
|
-
* should not have happened looks exactly like one that should.
|
|
23
|
-
*
|
|
24
|
-
* Pure apart from the port calls it awaits. No React, no provider type crossing
|
|
25
|
-
* outward.
|
|
26
|
-
*/
|
|
27
|
-
|
|
28
|
-
import type { ConsentSnapshot, ConsentState, ConsentTestingOptions } from '../config/types';
|
|
29
|
-
import { AdsError } from '../diagnostics/errors';
|
|
30
|
-
import type { ConsentPort, EngineConsentInfo, EngineConsentOptions } from '../engine/port';
|
|
31
|
-
import type { ConsentGateInput } from './gates';
|
|
32
|
-
|
|
33
|
-
export const INITIAL_CONSENT: ConsentSnapshot = {
|
|
34
|
-
state: 'undetermined',
|
|
35
|
-
canRequestAds: false,
|
|
36
|
-
isFormAvailable: false,
|
|
37
|
-
privacyOptionsRequirement: 'unknown',
|
|
38
|
-
error: null,
|
|
39
|
-
};
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* Maps the engine's status onto the published one.
|
|
43
|
-
*
|
|
44
|
-
* `canRequestAds` is taken from the provider rather than derived from the
|
|
45
|
-
* status. The two are not the same question — a region can be `not_required`
|
|
46
|
-
* while the provider still withholds permission — and inventing the second
|
|
47
|
-
* answer from the first is how a package ends up requesting ads it should not.
|
|
48
|
-
*/
|
|
49
|
-
export function normalizeConsent(info: EngineConsentInfo): ConsentSnapshot {
|
|
50
|
-
const state: ConsentState =
|
|
51
|
-
info.status === 'obtained'
|
|
52
|
-
? 'obtained'
|
|
53
|
-
: info.status === 'required'
|
|
54
|
-
? 'required'
|
|
55
|
-
: info.status === 'not_required'
|
|
56
|
-
? 'not_required'
|
|
57
|
-
: 'undetermined';
|
|
58
|
-
|
|
59
|
-
return {
|
|
60
|
-
state,
|
|
61
|
-
canRequestAds: info.canRequestAds === true,
|
|
62
|
-
isFormAvailable: info.isFormAvailable === true,
|
|
63
|
-
privacyOptionsRequirement: info.privacyOptions,
|
|
64
|
-
error: null,
|
|
65
|
-
};
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* The snapshot for a failed flow.
|
|
70
|
-
*
|
|
71
|
-
* `canRequestAds` is hard-coded false rather than carried through from anywhere.
|
|
72
|
-
* There is no failure mode in which "the flow broke" should mean "go ahead", and
|
|
73
|
-
* making that structural rather than conditional means no future edit can
|
|
74
|
-
* accidentally reintroduce the inference.
|
|
75
|
-
*
|
|
76
|
-
* The privacy-options requirement is `unknown` for the same reason: a broken
|
|
77
|
-
* flow has no provider verdict to forward, and reporting `not_required` would be
|
|
78
|
-
* the package deciding the user has no right to change their mind (FR-150).
|
|
79
|
-
*/
|
|
80
|
-
export function consentFailure(error: AdsError): ConsentSnapshot {
|
|
81
|
-
return {
|
|
82
|
-
state: 'error',
|
|
83
|
-
canRequestAds: false,
|
|
84
|
-
isFormAvailable: false,
|
|
85
|
-
privacyOptionsRequirement: 'unknown',
|
|
86
|
-
error,
|
|
87
|
-
};
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
export function toConsentError(cause: unknown, message: string): AdsError {
|
|
91
|
-
return new AdsError('consent_flow_failed', {
|
|
92
|
-
message,
|
|
93
|
-
operation: 'configure',
|
|
94
|
-
cause,
|
|
95
|
-
});
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/**
|
|
99
|
-
* Projects a snapshot onto the consent gate's second input.
|
|
100
|
-
*
|
|
101
|
-
* Only `obtained` and `not_required` open it. `undetermined` and `required` are
|
|
102
|
-
* both "we do not have permission yet" and are reported as
|
|
103
|
-
* `consent_flow_required`; `error` gets its own reason so an app can tell a
|
|
104
|
-
* user who has not answered from a flow that broke.
|
|
105
|
-
*/
|
|
106
|
-
export function toGateInput(snapshot: ConsentSnapshot): ConsentGateInput {
|
|
107
|
-
if (
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
permitted:
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
//
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Consent orchestration and normalization (FR-101 – FR-110).
|
|
3
|
+
*
|
|
4
|
+
* This closes the most serious gap in 0.1.0. The package *consumed* a consent
|
|
5
|
+
* decision but nothing *produced* one, and the flow that produces it lives
|
|
6
|
+
* inside the very provider library the package promised an app would never have
|
|
7
|
+
* to import. So any app shipping to a consent-required region had to import the
|
|
8
|
+
* provider directly — breaking the package's central promise at exactly the
|
|
9
|
+
* point where it mattered most — and hand-write the same order-sensitive
|
|
10
|
+
* orchestration every other such app was also writing.
|
|
11
|
+
*
|
|
12
|
+
* What this module does **not** do is as load-bearing as what it does:
|
|
13
|
+
*
|
|
14
|
+
* - It renders no consent UI. The form is the provider's (FR-103).
|
|
15
|
+
* - It decides nothing about jurisdictions. Which regions require consent is
|
|
16
|
+
* the provider's answer and the publisher's console configuration; the
|
|
17
|
+
* package forwards it (FR-107).
|
|
18
|
+
* - It never infers consent. An error, a cancellation, a dismissed form, a
|
|
19
|
+
* network failure — all of them land on `error`, and `error` never carries
|
|
20
|
+
* permission (FR-106). This is the invariant the whole module exists to
|
|
21
|
+
* protect, because the failure it prevents is invisible: a request that
|
|
22
|
+
* should not have happened looks exactly like one that should.
|
|
23
|
+
*
|
|
24
|
+
* Pure apart from the port calls it awaits. No React, no provider type crossing
|
|
25
|
+
* outward.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import type { ConsentSnapshot, ConsentState, ConsentTestingOptions } from '../config/types';
|
|
29
|
+
import { AdsError } from '../diagnostics/errors';
|
|
30
|
+
import type { ConsentPort, EngineConsentInfo, EngineConsentOptions } from '../engine/port';
|
|
31
|
+
import type { ConsentGateInput } from './gates';
|
|
32
|
+
|
|
33
|
+
export const INITIAL_CONSENT: ConsentSnapshot = {
|
|
34
|
+
state: 'undetermined',
|
|
35
|
+
canRequestAds: false,
|
|
36
|
+
isFormAvailable: false,
|
|
37
|
+
privacyOptionsRequirement: 'unknown',
|
|
38
|
+
error: null,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Maps the engine's status onto the published one.
|
|
43
|
+
*
|
|
44
|
+
* `canRequestAds` is taken from the provider rather than derived from the
|
|
45
|
+
* status. The two are not the same question — a region can be `not_required`
|
|
46
|
+
* while the provider still withholds permission — and inventing the second
|
|
47
|
+
* answer from the first is how a package ends up requesting ads it should not.
|
|
48
|
+
*/
|
|
49
|
+
export function normalizeConsent(info: EngineConsentInfo): ConsentSnapshot {
|
|
50
|
+
const state: ConsentState =
|
|
51
|
+
info.status === 'obtained'
|
|
52
|
+
? 'obtained'
|
|
53
|
+
: info.status === 'required'
|
|
54
|
+
? 'required'
|
|
55
|
+
: info.status === 'not_required'
|
|
56
|
+
? 'not_required'
|
|
57
|
+
: 'undetermined';
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
state,
|
|
61
|
+
canRequestAds: info.canRequestAds === true,
|
|
62
|
+
isFormAvailable: info.isFormAvailable === true,
|
|
63
|
+
privacyOptionsRequirement: info.privacyOptions,
|
|
64
|
+
error: null,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The snapshot for a failed flow.
|
|
70
|
+
*
|
|
71
|
+
* `canRequestAds` is hard-coded false rather than carried through from anywhere.
|
|
72
|
+
* There is no failure mode in which "the flow broke" should mean "go ahead", and
|
|
73
|
+
* making that structural rather than conditional means no future edit can
|
|
74
|
+
* accidentally reintroduce the inference.
|
|
75
|
+
*
|
|
76
|
+
* The privacy-options requirement is `unknown` for the same reason: a broken
|
|
77
|
+
* flow has no provider verdict to forward, and reporting `not_required` would be
|
|
78
|
+
* the package deciding the user has no right to change their mind (FR-150).
|
|
79
|
+
*/
|
|
80
|
+
export function consentFailure(error: AdsError): ConsentSnapshot {
|
|
81
|
+
return {
|
|
82
|
+
state: 'error',
|
|
83
|
+
canRequestAds: false,
|
|
84
|
+
isFormAvailable: false,
|
|
85
|
+
privacyOptionsRequirement: 'unknown',
|
|
86
|
+
error,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function toConsentError(cause: unknown, message: string): AdsError {
|
|
91
|
+
return new AdsError('consent_flow_failed', {
|
|
92
|
+
message,
|
|
93
|
+
operation: 'configure',
|
|
94
|
+
cause,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Projects a snapshot onto the consent gate's second input.
|
|
100
|
+
*
|
|
101
|
+
* Only `obtained` and `not_required` open it. `undetermined` and `required` are
|
|
102
|
+
* both "we do not have permission yet" and are reported as
|
|
103
|
+
* `consent_flow_required`; `error` gets its own reason so an app can tell a
|
|
104
|
+
* user who has not answered from a flow that broke.
|
|
105
|
+
*/
|
|
106
|
+
export function toGateInput(snapshot: ConsentSnapshot): ConsentGateInput {
|
|
107
|
+
if (
|
|
108
|
+
snapshot.canRequestAds &&
|
|
109
|
+
(snapshot.state === 'obtained' || snapshot.state === 'not_required')
|
|
110
|
+
) {
|
|
111
|
+
return { permitted: true };
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
permitted: false,
|
|
115
|
+
reason: snapshot.state === 'error' ? 'consent_flow_error' : 'consent_flow_required',
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Builds the options for a consent information update.
|
|
121
|
+
*
|
|
122
|
+
* `testing` has already been stripped by config validation when the environment
|
|
123
|
+
* is production, so this function does not re-check. That is deliberate: the
|
|
124
|
+
* guarantee lives at one point rather than being re-asserted at each call site,
|
|
125
|
+
* which is the same reason the ad-unit substitution guarantee is provable
|
|
126
|
+
* (FR-113).
|
|
127
|
+
*/
|
|
128
|
+
export function buildConsentOptions(
|
|
129
|
+
testing: ConsentTestingOptions | undefined,
|
|
130
|
+
underAgeOfConsent: boolean | undefined,
|
|
131
|
+
testDeviceIds: readonly string[],
|
|
132
|
+
): EngineConsentOptions {
|
|
133
|
+
return {
|
|
134
|
+
debugGeography: testing?.debugGeography,
|
|
135
|
+
underAgeOfConsent,
|
|
136
|
+
testDeviceIds,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* One consent operation, with the never-infer rule applied to every exit.
|
|
142
|
+
*
|
|
143
|
+
* Every path out of here is either a normalized snapshot or a normalized error
|
|
144
|
+
* snapshot. Nothing throws, because a consent failure must not be able to block
|
|
145
|
+
* app startup or crash the host (FR-108).
|
|
146
|
+
*/
|
|
147
|
+
export async function runConsentStep(
|
|
148
|
+
step: () => Promise<EngineConsentInfo>,
|
|
149
|
+
failureMessage: string,
|
|
150
|
+
): Promise<ConsentSnapshot> {
|
|
151
|
+
try {
|
|
152
|
+
return normalizeConsent(await step());
|
|
153
|
+
} catch (cause) {
|
|
154
|
+
return consentFailure(toConsentError(cause, failureMessage));
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Whether a form should be presented, per the provider's own report. */
|
|
159
|
+
export function needsForm(snapshot: ConsentSnapshot): boolean {
|
|
160
|
+
return (
|
|
161
|
+
snapshot.isFormAvailable && (snapshot.state === 'required' || snapshot.state === 'undetermined')
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function createConsentPortGuard(port: ConsentPort | undefined): ConsentPort {
|
|
166
|
+
if (port) {
|
|
167
|
+
return port;
|
|
168
|
+
}
|
|
169
|
+
// An engine without a consent port cannot serve the capability. Reporting it
|
|
170
|
+
// as a normalized failure keeps FR-108 true — the app does not crash, it
|
|
171
|
+
// learns the flow is unavailable and can fall back to its own gate.
|
|
172
|
+
const unavailable = async (): Promise<EngineConsentInfo> => {
|
|
173
|
+
throw new Error('provider_missing');
|
|
174
|
+
};
|
|
175
|
+
return {
|
|
176
|
+
requestInfoUpdate: unavailable,
|
|
177
|
+
showFormIfRequired: unavailable,
|
|
178
|
+
showPrivacyOptionsForm: unavailable,
|
|
179
|
+
getInfo: unavailable,
|
|
180
|
+
reset: () => undefined,
|
|
181
|
+
};
|
|
182
|
+
}
|
package/src/core/enablement.ts
CHANGED
|
@@ -1,76 +1,76 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Three-state enablement and the typed result of changing it (FR-114 – FR-125).
|
|
3
|
-
*
|
|
4
|
-
* 0.1.0 had a boolean, which forced an app selling an ad-removal tier to lie at
|
|
5
|
-
* startup. It does not yet know whether this user has paid — that answer arrives
|
|
6
|
-
* after a store sync — and both available answers are wrong: `true` flashes ads
|
|
7
|
-
* at someone who paid to never see them, `false` delays ads for everyone else
|
|
8
|
-
* and then runs into the silent re-enable trap below. The missing third state
|
|
9
|
-
* was a hole in the contract, not an omission in the app.
|
|
10
|
-
*
|
|
11
|
-
* The package still knows nothing about payments, entitlements, or why the app
|
|
12
|
-
* decided anything (FR-120). It only stops insisting the decision already exists
|
|
13
|
-
* at construction time.
|
|
14
|
-
*
|
|
15
|
-
* Pure — no provider, no React, no I/O.
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
|
-
import type { Enablement, InitStatus, UnavailableReason } from '../config/types';
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Normalizes the configured value. `true`/`false` keep their exact 0.1.0
|
|
22
|
-
* meaning, which is what makes the widened config type additive (BC-003).
|
|
23
|
-
*/
|
|
24
|
-
export function toEnablement(value: boolean | 'undecided'): Enablement {
|
|
25
|
-
if (value === 'undecided') {
|
|
26
|
-
return 'undecided';
|
|
27
|
-
}
|
|
28
|
-
return value ? 'enabled' : 'disabled';
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/** Neither `disabled` nor `undecided` may reach the provider (FR-115). */
|
|
32
|
-
export function permitsRequests(enablement: Enablement): boolean {
|
|
33
|
-
return enablement === 'enabled';
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* The outcome of an enablement change.
|
|
38
|
-
*
|
|
39
|
-
* 0.1.0 returned `void`, which is the defect FR-122 supersedes. Re-enabling
|
|
40
|
-
* after a disable sent the init status back to `not_started`, so the app *had*
|
|
41
|
-
* to call `initialize()` again or every later load and show was refused — and
|
|
42
|
-
* nothing said so. Not the result, not the status, not the documentation. An app
|
|
43
|
-
* whose user's subscription lapsed would turn ads back on and silently show
|
|
44
|
-
* none.
|
|
45
|
-
*/
|
|
46
|
-
export type EnablementResult =
|
|
47
|
-
/**
|
|
48
|
-
* Returned to the status held before disabling, with zero provider calls.
|
|
49
|
-
* The common case: disabling never un-initializes the provider, so there is
|
|
50
|
-
* genuinely nothing to redo.
|
|
51
|
-
*/
|
|
52
|
-
| { readonly status: 'resumed'; readonly initStatus: InitStatus }
|
|
53
|
-
/**
|
|
54
|
-
* The instance never reached `ready`, privacy options changed, or the
|
|
55
|
-
* platform became unavailable. The caller must initialize; the package will
|
|
56
|
-
* not do it implicitly, because that would hide an asynchronous, billable
|
|
57
|
-
* operation behind a setter the caller may not be awaiting (FR-121).
|
|
58
|
-
*/
|
|
59
|
-
| { readonly status: 'requires_initialization' }
|
|
60
|
-
| { readonly status: 'disabled' }
|
|
61
|
-
| { readonly status: 'undecided' }
|
|
62
|
-
/** The requested state was already the current one. No work, no event. */
|
|
63
|
-
| { readonly status: 'unchanged' }
|
|
64
|
-
| { readonly status: 'unavailable'; readonly reason: UnavailableReason };
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* Whether re-enabling can resume, given what the instance was doing when it was
|
|
68
|
-
* turned off.
|
|
69
|
-
*
|
|
70
|
-
* Only `ready` resumes. Everything else — never started, still initializing,
|
|
71
|
-
* blocked, failed — has no completed initialization to return to, so claiming a
|
|
72
|
-
* resume would put the instance in a state it never actually reached.
|
|
73
|
-
*/
|
|
74
|
-
export function canResume(resumeTo: InitStatus | null): resumeTo is 'ready' {
|
|
75
|
-
return resumeTo === 'ready';
|
|
76
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Three-state enablement and the typed result of changing it (FR-114 – FR-125).
|
|
3
|
+
*
|
|
4
|
+
* 0.1.0 had a boolean, which forced an app selling an ad-removal tier to lie at
|
|
5
|
+
* startup. It does not yet know whether this user has paid — that answer arrives
|
|
6
|
+
* after a store sync — and both available answers are wrong: `true` flashes ads
|
|
7
|
+
* at someone who paid to never see them, `false` delays ads for everyone else
|
|
8
|
+
* and then runs into the silent re-enable trap below. The missing third state
|
|
9
|
+
* was a hole in the contract, not an omission in the app.
|
|
10
|
+
*
|
|
11
|
+
* The package still knows nothing about payments, entitlements, or why the app
|
|
12
|
+
* decided anything (FR-120). It only stops insisting the decision already exists
|
|
13
|
+
* at construction time.
|
|
14
|
+
*
|
|
15
|
+
* Pure — no provider, no React, no I/O.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { Enablement, InitStatus, UnavailableReason } from '../config/types';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Normalizes the configured value. `true`/`false` keep their exact 0.1.0
|
|
22
|
+
* meaning, which is what makes the widened config type additive (BC-003).
|
|
23
|
+
*/
|
|
24
|
+
export function toEnablement(value: boolean | 'undecided'): Enablement {
|
|
25
|
+
if (value === 'undecided') {
|
|
26
|
+
return 'undecided';
|
|
27
|
+
}
|
|
28
|
+
return value ? 'enabled' : 'disabled';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Neither `disabled` nor `undecided` may reach the provider (FR-115). */
|
|
32
|
+
export function permitsRequests(enablement: Enablement): boolean {
|
|
33
|
+
return enablement === 'enabled';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The outcome of an enablement change.
|
|
38
|
+
*
|
|
39
|
+
* 0.1.0 returned `void`, which is the defect FR-122 supersedes. Re-enabling
|
|
40
|
+
* after a disable sent the init status back to `not_started`, so the app *had*
|
|
41
|
+
* to call `initialize()` again or every later load and show was refused — and
|
|
42
|
+
* nothing said so. Not the result, not the status, not the documentation. An app
|
|
43
|
+
* whose user's subscription lapsed would turn ads back on and silently show
|
|
44
|
+
* none.
|
|
45
|
+
*/
|
|
46
|
+
export type EnablementResult =
|
|
47
|
+
/**
|
|
48
|
+
* Returned to the status held before disabling, with zero provider calls.
|
|
49
|
+
* The common case: disabling never un-initializes the provider, so there is
|
|
50
|
+
* genuinely nothing to redo.
|
|
51
|
+
*/
|
|
52
|
+
| { readonly status: 'resumed'; readonly initStatus: InitStatus }
|
|
53
|
+
/**
|
|
54
|
+
* The instance never reached `ready`, privacy options changed, or the
|
|
55
|
+
* platform became unavailable. The caller must initialize; the package will
|
|
56
|
+
* not do it implicitly, because that would hide an asynchronous, billable
|
|
57
|
+
* operation behind a setter the caller may not be awaiting (FR-121).
|
|
58
|
+
*/
|
|
59
|
+
| { readonly status: 'requires_initialization' }
|
|
60
|
+
| { readonly status: 'disabled' }
|
|
61
|
+
| { readonly status: 'undecided' }
|
|
62
|
+
/** The requested state was already the current one. No work, no event. */
|
|
63
|
+
| { readonly status: 'unchanged' }
|
|
64
|
+
| { readonly status: 'unavailable'; readonly reason: UnavailableReason };
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Whether re-enabling can resume, given what the instance was doing when it was
|
|
68
|
+
* turned off.
|
|
69
|
+
*
|
|
70
|
+
* Only `ready` resumes. Everything else — never started, still initializing,
|
|
71
|
+
* blocked, failed — has no completed initialization to return to, so claiming a
|
|
72
|
+
* resume would put the instance in a state it never actually reached.
|
|
73
|
+
*/
|
|
74
|
+
export function canResume(resumeTo: InitStatus | null): resumeTo is 'ready' {
|
|
75
|
+
return resumeTo === 'ready';
|
|
76
|
+
}
|
|
@@ -1,54 +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
|
-
}
|
|
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
|
+
}
|