@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.
@@ -1,134 +1,134 @@
1
- /**
2
- * Ad-originated return detection (FR-128).
3
- *
4
- * This is the reason app-open belongs in the package rather than in the app.
5
- * Every other app-open rule — when to show one, how often, on which screens — is
6
- * policy the app owns. This one is not policy; it is a fact only the package
7
- * holds. When a user dismisses a full-screen ad, or comes back from a browser an
8
- * ad opened, the app sees an ordinary return to the foreground and has no way to
9
- * tell it apart from any other. Showing an app-open ad on that return means
10
- * showing an ad immediately after an ad, which is both a poor experience and the
11
- * kind of thing store review notices.
12
- *
13
- * The package knows, because it holds the full-screen lock and it sees the click
14
- * events. So it refuses, with a reason distinct from `busy` and `not_ready` so
15
- * the app can tell what happened.
16
- *
17
- * **Bounded on both sides, deliberately.** Arming has to be tied to something
18
- * that actually happened, and it has to expire:
19
- *
20
- * - Armed when the provider reports a full-screen ad opened, or on a click —
21
- * that second case is a browser the ad opened, which is the only signal the
22
- * provider gives for it. Arming is deliberately not tied to the app
23
- * reporting that it went to the background; see `noteFullScreenShown`.
24
- * - Disarmed without firing if the app was away longer than
25
- * `AD_RETURN_MAX_AWAY_MS`. Someone who left, did something else, and came
26
- * back an hour later is not returning from an ad, and treating them as if
27
- * they were would refuse a legitimate show.
28
- * - Refusals last `AD_RETURN_GRACE_MS` after the return, then stop. The window
29
- * only needs to cover the moment right after the return, where an app-open
30
- * show would otherwise fire. A window that outlived that moment would start
31
- * refusing shows that have nothing to do with the ad.
32
- *
33
- * Three limits are worth stating plainly, because they are limits rather than
34
- * defects and the documentation must not imply otherwise:
35
- *
36
- * 1. It cannot survive process death. If the OS kills the app while an ad is
37
- * on screen, the return is a cold start and nothing links it to the ad.
38
- * 2. It depends on the app reporting `setAppActive` faithfully — an obligation
39
- * 0.1.0 already imposes for staleness re-evaluation, not a new one.
40
- * 3. A browser opened by an ad without a preceding click event is undetectable.
41
- *
42
- * Pure state. No listener, no timer — it is driven entirely by transitions the
43
- * package already observes, which is why `dispose()` has nothing extra to
44
- * release for it.
45
- */
46
-
47
- import { AD_CLICK_WINDOW_MS, AD_RETURN_GRACE_MS, AD_RETURN_MAX_AWAY_MS } from './timers';
48
-
49
- export interface AdOriginatedTracker {
50
- /** A full-screen ad is on screen, per the provider's own `opened` callback. */
51
- noteFullScreenShown(nowMs: number): void;
52
- /** Record an ad click — the only signal that an ad opened a browser. */
53
- noteClick(nowMs: number): void;
54
- /** The app went to the background. Kept for the click case's timing. */
55
- noteBackground(nowMs: number): void;
56
- /** The app came back. Decides whether this return was ad-originated. */
57
- noteForeground(nowMs: number): void;
58
- /** Whether an app-open show should be refused right now. */
59
- isRefusing(nowMs: number): boolean;
60
- /** Drops all state. For dispose, and for tests asserting a clean slate. */
61
- reset(): void;
62
- }
63
-
64
- export function createAdOriginatedTracker(): AdOriginatedTracker {
65
- let lastClickAtMs: number | null = null;
66
- let armedAtMs: number | null = null;
67
- let refusalUntilMs: number | null = null;
68
-
69
- return {
70
- /**
71
- * Arming happens here — when the provider says an ad is on screen — and
72
- * deliberately **not** when the app reports going to the background.
73
- *
74
- * That was the first design and it does not survive a real device. React
75
- * Native delivers app-state changes over the bridge, and while the app is
76
- * backgrounded its JS thread is throttled, so the `background` event is
77
- * routinely processed only once the app is already returning. By then the
78
- * ad has been dismissed and the full-screen lock released, so sampling the
79
- * lock at that moment reports "no ad was showing" and the return is let
80
- * through — the exact case FR-128 exists to catch. Arming on a fact the
81
- * provider reports, rather than on the timeliness of an event, removes the
82
- * race entirely.
83
- */
84
- noteFullScreenShown(nowMs) {
85
- armedAtMs = nowMs;
86
- },
87
-
88
- noteClick(nowMs) {
89
- lastClickAtMs = nowMs;
90
- // A click is itself grounds to arm: the ad may be opening a browser,
91
- // and if it is, no `opened` callback will follow.
92
- armedAtMs = nowMs;
93
- },
94
-
95
- noteBackground(nowMs) {
96
- // Only meaningful for the browser case, and only as a refinement: a
97
- // background that closely follows a click is that browser opening.
98
- if (lastClickAtMs !== null && nowMs - lastClickAtMs <= AD_CLICK_WINDOW_MS) {
99
- armedAtMs = nowMs;
100
- }
101
- },
102
-
103
- noteForeground(nowMs) {
104
- if (armedAtMs === null) {
105
- return;
106
- }
107
- const away = nowMs - armedAtMs;
108
- armedAtMs = null;
109
- lastClickAtMs = null;
110
- if (away > AD_RETURN_MAX_AWAY_MS) {
111
- // Too long to still be attributable to the ad. Not a refusal.
112
- return;
113
- }
114
- refusalUntilMs = nowMs + AD_RETURN_GRACE_MS;
115
- },
116
-
117
- isRefusing(nowMs) {
118
- if (refusalUntilMs === null) {
119
- return false;
120
- }
121
- if (nowMs >= refusalUntilMs) {
122
- refusalUntilMs = null;
123
- return false;
124
- }
125
- return true;
126
- },
127
-
128
- reset() {
129
- lastClickAtMs = null;
130
- armedAtMs = null;
131
- refusalUntilMs = null;
132
- },
133
- };
134
- }
1
+ /**
2
+ * Ad-originated return detection (FR-128).
3
+ *
4
+ * This is the reason app-open belongs in the package rather than in the app.
5
+ * Every other app-open rule — when to show one, how often, on which screens — is
6
+ * policy the app owns. This one is not policy; it is a fact only the package
7
+ * holds. When a user dismisses a full-screen ad, or comes back from a browser an
8
+ * ad opened, the app sees an ordinary return to the foreground and has no way to
9
+ * tell it apart from any other. Showing an app-open ad on that return means
10
+ * showing an ad immediately after an ad, which is both a poor experience and the
11
+ * kind of thing store review notices.
12
+ *
13
+ * The package knows, because it holds the full-screen lock and it sees the click
14
+ * events. So it refuses, with a reason distinct from `busy` and `not_ready` so
15
+ * the app can tell what happened.
16
+ *
17
+ * **Bounded on both sides, deliberately.** Arming has to be tied to something
18
+ * that actually happened, and it has to expire:
19
+ *
20
+ * - Armed when the provider reports a full-screen ad opened, or on a click —
21
+ * that second case is a browser the ad opened, which is the only signal the
22
+ * provider gives for it. Arming is deliberately not tied to the app
23
+ * reporting that it went to the background; see `noteFullScreenShown`.
24
+ * - Disarmed without firing if the app was away longer than
25
+ * `AD_RETURN_MAX_AWAY_MS`. Someone who left, did something else, and came
26
+ * back an hour later is not returning from an ad, and treating them as if
27
+ * they were would refuse a legitimate show.
28
+ * - Refusals last `AD_RETURN_GRACE_MS` after the return, then stop. The window
29
+ * only needs to cover the moment right after the return, where an app-open
30
+ * show would otherwise fire. A window that outlived that moment would start
31
+ * refusing shows that have nothing to do with the ad.
32
+ *
33
+ * Three limits are worth stating plainly, because they are limits rather than
34
+ * defects and the documentation must not imply otherwise:
35
+ *
36
+ * 1. It cannot survive process death. If the OS kills the app while an ad is
37
+ * on screen, the return is a cold start and nothing links it to the ad.
38
+ * 2. It depends on the app reporting `setAppActive` faithfully — an obligation
39
+ * 0.1.0 already imposes for staleness re-evaluation, not a new one.
40
+ * 3. A browser opened by an ad without a preceding click event is undetectable.
41
+ *
42
+ * Pure state. No listener, no timer — it is driven entirely by transitions the
43
+ * package already observes, which is why `dispose()` has nothing extra to
44
+ * release for it.
45
+ */
46
+
47
+ import { AD_CLICK_WINDOW_MS, AD_RETURN_GRACE_MS, AD_RETURN_MAX_AWAY_MS } from './timers';
48
+
49
+ export interface AdOriginatedTracker {
50
+ /** A full-screen ad is on screen, per the provider's own `opened` callback. */
51
+ noteFullScreenShown(nowMs: number): void;
52
+ /** Record an ad click — the only signal that an ad opened a browser. */
53
+ noteClick(nowMs: number): void;
54
+ /** The app went to the background. Kept for the click case's timing. */
55
+ noteBackground(nowMs: number): void;
56
+ /** The app came back. Decides whether this return was ad-originated. */
57
+ noteForeground(nowMs: number): void;
58
+ /** Whether an app-open show should be refused right now. */
59
+ isRefusing(nowMs: number): boolean;
60
+ /** Drops all state. For dispose, and for tests asserting a clean slate. */
61
+ reset(): void;
62
+ }
63
+
64
+ export function createAdOriginatedTracker(): AdOriginatedTracker {
65
+ let lastClickAtMs: number | null = null;
66
+ let armedAtMs: number | null = null;
67
+ let refusalUntilMs: number | null = null;
68
+
69
+ return {
70
+ /**
71
+ * Arming happens here — when the provider says an ad is on screen — and
72
+ * deliberately **not** when the app reports going to the background.
73
+ *
74
+ * That was the first design and it does not survive a real device. React
75
+ * Native delivers app-state changes over the bridge, and while the app is
76
+ * backgrounded its JS thread is throttled, so the `background` event is
77
+ * routinely processed only once the app is already returning. By then the
78
+ * ad has been dismissed and the full-screen lock released, so sampling the
79
+ * lock at that moment reports "no ad was showing" and the return is let
80
+ * through — the exact case FR-128 exists to catch. Arming on a fact the
81
+ * provider reports, rather than on the timeliness of an event, removes the
82
+ * race entirely.
83
+ */
84
+ noteFullScreenShown(nowMs) {
85
+ armedAtMs = nowMs;
86
+ },
87
+
88
+ noteClick(nowMs) {
89
+ lastClickAtMs = nowMs;
90
+ // A click is itself grounds to arm: the ad may be opening a browser,
91
+ // and if it is, no `opened` callback will follow.
92
+ armedAtMs = nowMs;
93
+ },
94
+
95
+ noteBackground(nowMs) {
96
+ // Only meaningful for the browser case, and only as a refinement: a
97
+ // background that closely follows a click is that browser opening.
98
+ if (lastClickAtMs !== null && nowMs - lastClickAtMs <= AD_CLICK_WINDOW_MS) {
99
+ armedAtMs = nowMs;
100
+ }
101
+ },
102
+
103
+ noteForeground(nowMs) {
104
+ if (armedAtMs === null) {
105
+ return;
106
+ }
107
+ const away = nowMs - armedAtMs;
108
+ armedAtMs = null;
109
+ lastClickAtMs = null;
110
+ if (away > AD_RETURN_MAX_AWAY_MS) {
111
+ // Too long to still be attributable to the ad. Not a refusal.
112
+ return;
113
+ }
114
+ refusalUntilMs = nowMs + AD_RETURN_GRACE_MS;
115
+ },
116
+
117
+ isRefusing(nowMs) {
118
+ if (refusalUntilMs === null) {
119
+ return false;
120
+ }
121
+ if (nowMs >= refusalUntilMs) {
122
+ refusalUntilMs = null;
123
+ return false;
124
+ }
125
+ return true;
126
+ },
127
+
128
+ reset() {
129
+ lastClickAtMs = null;
130
+ armedAtMs = null;
131
+ refusalUntilMs = null;
132
+ },
133
+ };
134
+ }
@@ -50,7 +50,12 @@ import {
50
50
  placementReducer,
51
51
  type PlacementSnapshot,
52
52
  } from './placementMachine';
53
- import { evaluateGate, requiresReinitialization, toEnginePrivacy } from './privacyGate';
53
+ import {
54
+ evaluateGate,
55
+ requiresReinitialization,
56
+ toEnginePrivacy,
57
+ toEngineRequest,
58
+ } from './privacyGate';
54
59
  import { evaluateGates, type ConsentGateInput, type GateDecision } from './gates';
55
60
  import { createRewardedSession, type RewardedSession } from './rewardedSession';
56
61
  import { createTimerSet, isStale, type Scheduler } from './timers';
@@ -82,7 +87,13 @@ export type PrivacyUpdateResult =
82
87
  | { readonly status: 'requires_reinitialization' }
83
88
  | { readonly status: 'disabled' }
84
89
  | { readonly status: 'undecided' }
85
- | { readonly status: 'unavailable'; readonly reason: UnavailableReason };
90
+ | { readonly status: 'unavailable'; readonly reason: UnavailableReason }
91
+ /**
92
+ * The initialization this update triggered failed (added in 0.5.0). Before,
93
+ * it was reported as `applied`, so the app never retried and no ad ever
94
+ * showed, with nothing to say why.
95
+ */
96
+ | { readonly status: 'failed'; readonly error: AdsError };
86
97
 
87
98
  export type LoadResult =
88
99
  | { readonly status: 'loaded'; readonly correlationId: string }
@@ -231,7 +242,13 @@ export interface AdsInstance {
231
242
  /** What `AdsBanner` needs. Deliberately not documented as consumer API. */
232
243
  export interface InstanceInternals {
233
244
  resolveBanner(placementKey: string):
234
- | { readonly ok: true; readonly adUnitId: string; readonly correlationId: string }
245
+ | {
246
+ readonly ok: true;
247
+ readonly adUnitId: string;
248
+ readonly correlationId: string;
249
+ /** Current at resolution time, so a banner request honors `updatePrivacy`. */
250
+ readonly personalizedAds: boolean;
251
+ }
235
252
  | { readonly ok: false; readonly result: LoadResult };
236
253
  /**
237
254
  * Takes the throttle slot for a banner cycle (FR-140).
@@ -247,10 +264,7 @@ export interface InstanceInternals {
247
264
  * Emits nothing and sets no state: it is called during render, so the host
248
265
  * reports the refusal from its effect instead.
249
266
  */
250
- commitBannerRequest(
251
- placementKey: string,
252
- correlationId: string,
253
- ): ThrottledResult | null;
267
+ commitBannerRequest(placementKey: string, correlationId: string): ThrottledResult | null;
254
268
  /** The configured floor for a placement, so the host can report the cause. */
255
269
  throttleFloorFor(placementKey: string): number;
256
270
  emit(event: AdEvent): void;
@@ -604,6 +618,10 @@ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): Ad
604
618
  * guard clause. On refusal it moves the placement to `throttled` and emits,
605
619
  * which is what makes the refusal observable rather than a silent absence of
606
620
  * ads — the app would otherwise have no way to tell this from no-fill.
621
+ *
622
+ * A placement still holding a `ready` ad keeps that state: the refusal is
623
+ * about asking again, and overwriting it would strand an ad that was already
624
+ * paid for and is still showable.
607
625
  */
608
626
  function reportThrottled(
609
627
  key: string,
@@ -612,7 +630,7 @@ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): Ad
612
630
  minRequestIntervalMs: number,
613
631
  ): LoadResult {
614
632
  const runtime = placements.get(key);
615
- if (runtime) {
633
+ if (runtime && runtime.snapshot.state !== 'ready') {
616
634
  setPlacement(key, { ...runtime.snapshot, state: 'throttled' });
617
635
  }
618
636
  events.emit({
@@ -701,9 +719,7 @@ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): Ad
701
719
 
702
720
  /** App-open expires on its own, shorter window (FR-130). */
703
721
  function validityFor(format: AdFormat): number {
704
- return format === 'app_open'
705
- ? resolved.timeouts.appOpenValidity
706
- : resolved.timeouts.adValidity;
722
+ return format === 'app_open' ? resolved.timeouts.appOpenValidity : resolved.timeouts.adValidity;
707
723
  }
708
724
 
709
725
  async function doInitialize(privacy: PrivacyOptions): Promise<InitResult> {
@@ -870,7 +886,7 @@ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): Ad
870
886
  });
871
887
 
872
888
  engine
873
- .createFullScreen(format, adUnitId)
889
+ .createFullScreen(format, adUnitId, toEngineRequest(currentPrivacy))
874
890
  .then(handle => {
875
891
  if (disposed || settled) {
876
892
  engine.dispose(handle);
@@ -984,7 +1000,9 @@ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): Ad
984
1000
  if (!error || !error.retryable) {
985
1001
  return result;
986
1002
  }
987
- if (!canRetry(policy, runtime.retryAttempts) || !permitsRequests(enablement)) {
1003
+ // The whole gate, not just enablement: a retry is a new provider request
1004
+ // and needs the same permission the first one had (FR-017).
1005
+ if (!canRetry(policy, runtime.retryAttempts) || !currentGate().allowed) {
988
1006
  return result;
989
1007
  }
990
1008
 
@@ -1017,7 +1035,7 @@ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): Ad
1017
1035
  };
1018
1036
  });
1019
1037
 
1020
- if (!waited || disposed || !permitsRequests(enablement)) {
1038
+ if (!waited || disposed || !currentGate().allowed) {
1021
1039
  return result;
1022
1040
  }
1023
1041
 
@@ -1163,7 +1181,8 @@ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): Ad
1163
1181
 
1164
1182
  async updatePrivacy(privacy) {
1165
1183
  if (disposed) {
1166
- return { status: 'disabled' };
1184
+ // The same answer initialize and load give a disposed instance.
1185
+ return { status: 'unavailable', reason: 'provider_missing' };
1167
1186
  }
1168
1187
  if (enablement === 'undecided') {
1169
1188
  return { status: 'undecided' };
@@ -1184,6 +1203,11 @@ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): Ad
1184
1203
  currentPrivacy = privacy;
1185
1204
  const gate = evaluateGate(privacy);
1186
1205
  if (!gate.allowed) {
1206
+ // FR-017, FR-137, FR-153: the same teardown as a consent outcome that
1207
+ // closes the gate. Without it a retry already waiting on its backoff
1208
+ // would still reach the provider after the app withdrew consent.
1209
+ cancelAllRetries();
1210
+ haltPlacements({ status: 'blocked', reason: gate.reason });
1187
1211
  setInit(initReducer(init, { type: 'BLOCK', reason: gate.reason }));
1188
1212
  return { status: 'blocked', reason: gate.reason };
1189
1213
  }
@@ -1193,12 +1217,24 @@ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): Ad
1193
1217
  if (init.status === 'ready') {
1194
1218
  return { status: 'applied' };
1195
1219
  }
1220
+ // Exhaustive on purpose (ADS-04): the old ternary mapped only `ready` and
1221
+ // `blocked`, and every other outcome — `failed` included — fell through to
1222
+ // `applied`. A new InitResult member now fails to compile here.
1196
1223
  const result = await instance.initialize(privacy);
1197
- return result.status === 'ready'
1198
- ? { status: 'applied' }
1199
- : result.status === 'blocked'
1200
- ? { status: 'blocked', reason: result.reason }
1201
- : { status: 'applied' };
1224
+ switch (result.status) {
1225
+ case 'ready':
1226
+ return { status: 'applied' };
1227
+ case 'blocked':
1228
+ case 'disabled':
1229
+ case 'undecided':
1230
+ case 'unavailable':
1231
+ case 'failed':
1232
+ return result;
1233
+ default: {
1234
+ const unreachable: never = result;
1235
+ return unreachable;
1236
+ }
1237
+ }
1202
1238
  },
1203
1239
 
1204
1240
  setEnabled(next) {
@@ -1362,18 +1398,11 @@ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): Ad
1362
1398
  return runtime.inFlightLoad;
1363
1399
  }
1364
1400
 
1365
- // FR-140. Checked after the in-flight join so a caller that merely
1366
- // arrived twice for one cycle is answered by the join, not charged a
1367
- // throttle slot — and before the correlation id and `load_started`,
1368
- // because a refused request is not a cycle and must not look like one
1369
- // in the event stream.
1370
- const throttled = checkThrottle(placementKey, definition.format);
1371
- if (throttled) {
1372
- return throttled;
1373
- }
1374
-
1375
1401
  if (runtime.snapshot.state === 'showing') {
1376
- return { status: 'failed', error: new AdsError('busy', { operation: 'load', placementKey }) };
1402
+ return {
1403
+ status: 'failed',
1404
+ error: new AdsError('busy', { operation: 'load', placementKey }),
1405
+ };
1377
1406
  }
1378
1407
 
1379
1408
  if (!canLoad(runtime.snapshot.state)) {
@@ -1383,6 +1412,17 @@ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): Ad
1383
1412
  };
1384
1413
  }
1385
1414
 
1415
+ // FR-140. Checked after the in-flight join so a caller that merely
1416
+ // arrived twice for one cycle is answered by the join, not charged a
1417
+ // throttle slot; after the busy and not-ready guards so a load that was
1418
+ // never going to request anything does not spend one either; and before
1419
+ // the correlation id and `load_started`, because a refused request is
1420
+ // not a cycle and must not look like one in the event stream.
1421
+ const throttled = checkThrottle(placementKey, definition.format);
1422
+ if (throttled) {
1423
+ return throttled;
1424
+ }
1425
+
1386
1426
  releasePlacementResources(placementKey);
1387
1427
 
1388
1428
  const correlationId = correlation.next();
@@ -1712,9 +1752,7 @@ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): Ad
1712
1752
  }
1713
1753
  setConsent(next);
1714
1754
  await reevaluateAfterConsent();
1715
- return failure
1716
- ? { status: 'failed', error: failure }
1717
- : { status: 'shown', consent: next };
1755
+ return failure ? { status: 'failed', error: failure } : { status: 'shown', consent: next };
1718
1756
  },
1719
1757
 
1720
1758
  getState() {
@@ -1800,14 +1838,14 @@ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): Ad
1800
1838
  ok: true,
1801
1839
  adUnitId: resolved.catalog.byKey.get(placementKey)!,
1802
1840
  correlationId: correlation.next(),
1841
+ personalizedAds: toEngineRequest(currentPrivacy).personalizedAds,
1803
1842
  };
1804
1843
  },
1805
1844
  commitBannerRequest(placementKey, correlationId) {
1806
1845
  if (!resolved.placements.has(placementKey)) {
1807
1846
  return null;
1808
1847
  }
1809
- const minRequestIntervalMs =
1810
- resolved.minRequestIntervalByKey.get(placementKey) ?? 0;
1848
+ const minRequestIntervalMs = resolved.minRequestIntervalByKey.get(placementKey) ?? 0;
1811
1849
  const decision = requestThrottle.tryAcquire(
1812
1850
  placementKey,
1813
1851
  minRequestIntervalMs,
@@ -1883,7 +1921,8 @@ export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): Ad
1883
1921
  function createDefaultEngine(): AdsEngine {
1884
1922
  // Deferred require keeps the provider off the import graph of every module
1885
1923
  // that merely mentions `createAds`.
1886
- const { createAdMobEngine } = require('../engine/admobEngine') as typeof import('../engine/admobEngine');
1924
+ const { createAdMobEngine } =
1925
+ require('../engine/admobEngine') as typeof import('../engine/admobEngine');
1887
1926
  return createAdMobEngine();
1888
1927
  }
1889
1928