@chipmobilesdk/rn-ads 0.3.0 → 0.5.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.
@@ -34,6 +34,7 @@ export const INITIAL_CONSENT: ConsentSnapshot = {
34
34
  state: 'undetermined',
35
35
  canRequestAds: false,
36
36
  isFormAvailable: false,
37
+ privacyOptionsRequirement: 'unknown',
37
38
  error: null,
38
39
  };
39
40
 
@@ -59,6 +60,7 @@ export function normalizeConsent(info: EngineConsentInfo): ConsentSnapshot {
59
60
  state,
60
61
  canRequestAds: info.canRequestAds === true,
61
62
  isFormAvailable: info.isFormAvailable === true,
63
+ privacyOptionsRequirement: info.privacyOptions,
62
64
  error: null,
63
65
  };
64
66
  }
@@ -70,9 +72,19 @@ export function normalizeConsent(info: EngineConsentInfo): ConsentSnapshot {
70
72
  * There is no failure mode in which "the flow broke" should mean "go ahead", and
71
73
  * making that structural rather than conditional means no future edit can
72
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).
73
79
  */
74
80
  export function consentFailure(error: AdsError): ConsentSnapshot {
75
- return { state: 'error', canRequestAds: false, isFormAvailable: false, error };
81
+ return {
82
+ state: 'error',
83
+ canRequestAds: false,
84
+ isFormAvailable: false,
85
+ privacyOptionsRequirement: 'unknown',
86
+ error,
87
+ };
76
88
  }
77
89
 
78
90
  export function toConsentError(cause: unknown, message: string): AdsError {
@@ -161,6 +173,7 @@ export function createConsentPortGuard(port: ConsentPort | undefined): ConsentPo
161
173
  return {
162
174
  requestInfoUpdate: unavailable,
163
175
  showFormIfRequired: unavailable,
176
+ showPrivacyOptionsForm: unavailable,
164
177
  getInfo: unavailable,
165
178
  reset: () => undefined,
166
179
  };
@@ -23,6 +23,13 @@ export type InitEvent =
23
23
  | { readonly type: 'UNDECIDE' }
24
24
  | { readonly type: 'ENABLE' }
25
25
  | { readonly type: 'SUCCEED' }
26
+ /**
27
+ * Back to `ready` with no native call, because the provider was initialized
28
+ * earlier and nothing has torn it down since (FR-154). The instance decides
29
+ * whether that holds; this machine only refuses to resume from a state a
30
+ * fresh start could not leave either.
31
+ */
32
+ | { readonly type: 'RESUME' }
26
33
  | { readonly type: 'FAIL'; readonly error: AdsError };
27
34
 
28
35
  export interface InitSnapshot {
@@ -165,6 +172,12 @@ export function initReducer(state: InitSnapshot, event: InitEvent): InitSnapshot
165
172
  // resurrect a state the app deliberately left.
166
173
  return state.status === 'initializing' ? at('ready') : state;
167
174
 
175
+ case 'RESUME':
176
+ // Same guard as START, for the same reason: `disabled`, `undecided` and
177
+ // `unavailable` are not left by a gate reopening. `canStart` excludes
178
+ // them, and `initializing`, whose own outcome is still on its way.
179
+ return canStart(state.status) ? at('ready') : state;
180
+
168
181
  case 'FAIL':
169
182
  return state.status === 'initializing' ? at('failed', { error: event.error }) : state;
170
183
 
@@ -40,9 +40,19 @@ export const initialPlacement: PlacementSnapshot = {
40
40
  correlationId: null,
41
41
  };
42
42
 
43
- /** A load is only legal from a settled state; `loading` is where FR-029 bites. */
43
+ /**
44
+ * A load is only legal from a settled state; `loading` is where FR-029 bites.
45
+ * `throttled` is settled too: it records that the last request was refused, and
46
+ * the throttle itself decides whether the next one may proceed.
47
+ */
44
48
  export function canLoad(state: PlacementState): boolean {
45
- return state === 'idle' || state === 'failed' || state === 'consumed' || state === 'ready';
49
+ return (
50
+ state === 'idle' ||
51
+ state === 'failed' ||
52
+ state === 'consumed' ||
53
+ state === 'ready' ||
54
+ state === 'throttled'
55
+ );
46
56
  }
47
57
 
48
58
  /** Only a ready ad may be shown. Everything else is a determinate refusal (FR-028). */
@@ -15,7 +15,7 @@
15
15
  */
16
16
 
17
17
  import type { PrivacyBlockReason, PrivacyOptions } from '../config/types';
18
- import type { EnginePrivacy } from '../engine/port';
18
+ import type { EnginePrivacy, EngineRequestOptions } from '../engine/port';
19
19
 
20
20
  export type GateDecision =
21
21
  | { readonly allowed: true }
@@ -83,3 +83,12 @@ export function toEnginePrivacy(
83
83
  testDeviceIds,
84
84
  };
85
85
  }
86
+
87
+ /**
88
+ * What every ad request carries. Unset means personalized, matching the
89
+ * documented default of `PrivacyOptions.personalizedAds`; only an explicit
90
+ * `false` opts out.
91
+ */
92
+ export function toEngineRequest(privacy: PrivacyOptions | null): EngineRequestOptions {
93
+ return { personalizedAds: privacy?.personalizedAds !== false };
94
+ }
@@ -27,6 +27,7 @@ import type {
27
27
  EngineConsentInfo,
28
28
  EngineConsentOptions,
29
29
  EnginePrivacy,
30
+ EngineRequestOptions,
30
31
  } from './port';
31
32
 
32
33
  /** The slice of the bridge this adapter uses. Kept local so nothing else sees it. */
@@ -61,6 +62,7 @@ interface ProviderModule {
61
62
  interface ProviderConsent {
62
63
  requestInfoUpdate(options?: Record<string, unknown>): Promise<ProviderConsentInfo>;
63
64
  loadAndShowConsentFormIfRequired(): Promise<ProviderConsentInfo>;
65
+ showPrivacyOptionsForm(): Promise<ProviderConsentInfo>;
64
66
  getConsentInfo(): Promise<ProviderConsentInfo>;
65
67
  reset(): void;
66
68
  }
@@ -69,6 +71,7 @@ interface ProviderConsentInfo {
69
71
  status?: string;
70
72
  canRequestAds?: boolean;
71
73
  isConsentFormAvailable?: boolean;
74
+ privacyOptionsRequirementStatus?: string;
72
75
  }
73
76
 
74
77
  /**
@@ -119,9 +122,25 @@ function toEngineConsentInfo(info: ProviderConsentInfo): EngineConsentInfo {
119
122
  status: normalized,
120
123
  canRequestAds: info.canRequestAds === true,
121
124
  isFormAvailable: info.isConsentFormAvailable === true,
125
+ privacyOptions: toEnginePrivacyOptions(info.privacyOptionsRequirementStatus),
122
126
  };
123
127
  }
124
128
 
129
+ /**
130
+ * Same rule as the status: an unrecognized value is `unknown`, never a guess in
131
+ * either direction (FR-150).
132
+ */
133
+ function toEnginePrivacyOptions(value: string | undefined): EngineConsentInfo['privacyOptions'] {
134
+ switch (String(value ?? 'UNKNOWN').toUpperCase()) {
135
+ case 'REQUIRED':
136
+ return 'required';
137
+ case 'NOT_REQUIRED':
138
+ return 'not_required';
139
+ default:
140
+ return 'unknown';
141
+ }
142
+ }
143
+
125
144
  function loadProvider(): ProviderModule | null {
126
145
  try {
127
146
  return require('react-native-google-mobile-ads') as ProviderModule;
@@ -209,7 +228,11 @@ export function createAdMobEngine(): AdsEngine {
209
228
  await mobileAds.initialize();
210
229
  },
211
230
 
212
- async createFullScreen(format: FullScreenFormat, adUnitId: string) {
231
+ async createFullScreen(
232
+ format: FullScreenFormat,
233
+ adUnitId: string,
234
+ request?: EngineRequestOptions,
235
+ ) {
213
236
  const module = getProvider();
214
237
  if (!module) {
215
238
  throw new Error('provider_missing');
@@ -224,7 +247,7 @@ export function createAdMobEngine(): AdsEngine {
224
247
  const ad = factory.createForAdRequest(adUnitId, {
225
248
  // The consumer-facing polarity is `personalizedAds`; the provider's
226
249
  // is the inverse. The flip lives here, next to the API needing it.
227
- requestNonPersonalizedAdsOnly: false,
250
+ requestNonPersonalizedAdsOnly: request?.personalizedAds === false,
228
251
  });
229
252
 
230
253
  counter += 1;
@@ -362,6 +385,14 @@ function createAdMobConsent(getProvider: () => ProviderModule | null): ConsentPo
362
385
  return toEngineConsentInfo(info);
363
386
  },
364
387
 
388
+ async showPrivacyOptionsForm() {
389
+ // Present in the pinned bridge 16.0.3, so no range change (FR-155).
390
+ // Resolves with the consent information as it stands once the form
391
+ // closes, whether the user changed anything or not.
392
+ const info = await requireConsent().showPrivacyOptionsForm();
393
+ return toEngineConsentInfo(info);
394
+ },
395
+
365
396
  async getInfo() {
366
397
  return toEngineConsentInfo(await requireConsent().getConsentInfo());
367
398
  },
@@ -23,6 +23,17 @@ export interface EnginePrivacy {
23
23
  readonly testDeviceIds?: readonly string[];
24
24
  }
25
25
 
26
+ /**
27
+ * Privacy that applies to each ad request rather than to initialization.
28
+ *
29
+ * `personalizedAds` is here, not only in `EnginePrivacy`, because the provider
30
+ * reads it per request and `updatePrivacy` may change it without
31
+ * reinitializing: a request must carry the value current at request time.
32
+ */
33
+ export interface EngineRequestOptions {
34
+ readonly personalizedAds: boolean;
35
+ }
36
+
26
37
  /**
27
38
  * Provider callbacks, normalized to the vocabulary the core state machines use.
28
39
  * `rewarded` and `dismissed` are separate members because they are separate
@@ -52,6 +63,8 @@ export interface EngineConsentInfo {
52
63
  readonly status: 'unknown' | 'required' | 'not_required' | 'obtained';
53
64
  readonly canRequestAds: boolean;
54
65
  readonly isFormAvailable: boolean;
66
+ /** Whether the provider requires a "change my choice" entry point (FR-150). */
67
+ readonly privacyOptions: 'unknown' | 'required' | 'not_required';
55
68
  }
56
69
 
57
70
  export interface EngineConsentOptions {
@@ -76,6 +89,13 @@ export interface ConsentPort {
76
89
  requestInfoUpdate(options: EngineConsentOptions): Promise<EngineConsentInfo>;
77
90
  /** Presents the provider's form if one is required; a no-op otherwise. */
78
91
  showFormIfRequired(): Promise<EngineConsentInfo>;
92
+ /**
93
+ * Presents the provider's privacy options form, through which a user changes
94
+ * an answer already given (FR-151). Resolves when the form closes, whether or
95
+ * not anything changed; rejects if it could not be presented. The caller has
96
+ * already checked the requirement, so the adapter does not re-check.
97
+ */
98
+ showPrivacyOptionsForm(): Promise<EngineConsentInfo>;
79
99
  /** Last known information without contacting the network. */
80
100
  getInfo(): Promise<EngineConsentInfo>;
81
101
  /** Clears the stored decision. Inert in production by configuration. */
@@ -99,8 +119,16 @@ export interface AdsEngine {
99
119
  */
100
120
  initialize(privacy: EnginePrivacy): Promise<void>;
101
121
 
102
- /** Creates and begins loading one full-screen ad. Resolves on request, not on load. */
103
- createFullScreen(format: FullScreenFormat, adUnitId: string): Promise<EngineAdHandle>;
122
+ /**
123
+ * Creates and begins loading one full-screen ad. Resolves on request, not on
124
+ * load. `request` is optional so engines written against 0.4.0 still satisfy
125
+ * the port; an engine that ignores it requests personalized ads.
126
+ */
127
+ createFullScreen(
128
+ format: FullScreenFormat,
129
+ adUnitId: string,
130
+ request?: EngineRequestOptions,
131
+ ): Promise<EngineAdHandle>;
104
132
 
105
133
  /** Presents a loaded ad. Rejects if the provider refuses to present. */
106
134
  show(handle: EngineAdHandle): Promise<void>;
package/src/index.ts CHANGED
@@ -1,101 +1,103 @@
1
- /**
2
- * @chipmobilesdk/rn-ads — reusable advertising capability for
3
- * ChipMobileSdk-family React Native apps.
4
- *
5
- * This is the public entrypoint (LIB-003). Modules under `config/`, `core/`,
6
- * `engine/`, `react/`, and `diagnostics/` are private; import them only through
7
- * this file. The fake engine lives behind `@chipmobilesdk/rn-ads/testing` so
8
- * test doubles cannot reach a production bundle.
9
- *
10
- * Nothing exported here names Google, AdMob, or any provider type (FR-002).
11
- * `src/__tests__/public-api.test.ts` asserts that mechanically.
12
- *
13
- * The package owns capability: a safe, consistent way to request and present
14
- * ads. The consuming app owns policy — where ads appear, when, how often, under
15
- * what consent, and whether they appear at all. There is no placement rule, no
16
- * frequency cap, and no screen name in this package (FR-045).
17
- */
18
-
19
- // Configuration and state
20
- export type {
21
- AdContentRating,
22
- AdFormat,
23
- AdsConfig,
24
- ConsentSnapshot,
25
- ConsentState,
26
- ConsentTestingOptions,
27
- Enablement,
28
- Environment,
29
- FullScreenFormat,
30
- InitStatus,
31
- PlacementDefinition,
32
- PlacementState,
33
- PrivacyBlockReason,
34
- PrivacyOptions,
35
- RetryPolicy,
36
- Reward,
37
- TimeoutConfig,
38
- UnavailableReason,
39
- Unsubscribe,
40
- } from './config/types';
41
- export { isFullScreenFormat } from './config/types';
42
-
43
- // Timeout policy — exported so a consumer can read the defaults it is bound by
44
- // rather than discovering them from a timeout in production (FR-032).
45
- export {
46
- AD_VALIDITY_BOUNDS,
47
- APP_OPEN_VALIDITY_BOUNDS,
48
- DEFAULT_AD_VALIDITY_MS,
49
- DEFAULT_APP_OPEN_VALIDITY_MS,
50
- DEFAULT_LOAD_TIMEOUT_MS,
51
- DEFAULT_RETRY_POLICY,
52
- DEFAULT_SHOW_TIMEOUT_MS,
53
- DEFAULT_UNDECIDED_WARNING_MS,
54
- LOAD_TIMEOUT_BOUNDS,
55
- RETRY_BOUNDS,
56
- SHOW_TIMEOUT_BOUNDS,
57
- UNDECIDED_WARNING_BOUNDS,
58
- } from './core/timers';
59
-
60
- // Request throttle — account safety, added in 0.3.0 (FR-140). Exported so an app
61
- // can read the floor it is bound by, and so the format-aware default is legible
62
- // rather than something to discover from a `throttled` state in production.
63
- export {
64
- DEFAULT_BANNER_MIN_REQUEST_INTERVAL_MS,
65
- DEFAULT_FULL_SCREEN_MIN_REQUEST_INTERVAL_MS,
66
- MIN_REQUEST_INTERVAL_BOUNDS,
67
- defaultMinRequestIntervalMs,
68
- } from './core/requestThrottle';
69
-
70
- // Enablement. `EnablementResult` is what `setEnabled` returns in place of
71
- // 0.1.0's `void` — the defect that let a re-enable fail in silence (FR-122).
72
- export type { EnablementResult } from './core/enablement';
73
-
74
- // Instance, lifecycle, and results
75
- export { createAds } from './core/adsInstance';
76
- export type {
77
- AdsInstance,
78
- ConsentApi,
79
- InitResult,
80
- LoadResult,
81
- PrivacyUpdateResult,
82
- ShowResult,
83
- } from './core/adsInstance';
84
-
85
- // React surface
86
- export { AdsBanner } from './react/AdsBanner';
87
- export type { AdsBannerProps } from './react/AdsBanner';
88
- export { AdsProvider, useAds } from './react/AdsProvider';
89
- export type { AdsProviderProps } from './react/AdsProvider';
90
- export { useAdsEvents, useAdsStatus, usePlacementState } from './react/hooks';
91
-
92
- // Diagnostics
93
- export { AdsConfigError, AdsError, isAdsError } from './diagnostics/errors';
94
- export type { AdsErrorCode, AdsOperation } from './diagnostics/errors';
95
- export type {
96
- AdEvent,
97
- AdEventListener,
98
- AdEventType,
99
- ConfigWarning,
100
- ImpressionSource,
101
- } from './diagnostics/events';
1
+ /**
2
+ * @chipmobilesdk/rn-ads — reusable advertising capability for
3
+ * ChipMobileSdk-family React Native apps.
4
+ *
5
+ * This is the public entrypoint (LIB-003). Modules under `config/`, `core/`,
6
+ * `engine/`, `react/`, and `diagnostics/` are private; import them only through
7
+ * this file. The fake engine lives behind `@chipmobilesdk/rn-ads/testing` so
8
+ * test doubles cannot reach a production bundle.
9
+ *
10
+ * Nothing exported here names Google, AdMob, or any provider type (FR-002).
11
+ * `src/__tests__/public-api.test.ts` asserts that mechanically.
12
+ *
13
+ * The package owns capability: a safe, consistent way to request and present
14
+ * ads. The consuming app owns policy — where ads appear, when, how often, under
15
+ * what consent, and whether they appear at all. There is no placement rule, no
16
+ * frequency cap, and no screen name in this package (FR-045).
17
+ */
18
+
19
+ // Configuration and state
20
+ export type {
21
+ AdContentRating,
22
+ AdFormat,
23
+ AdsConfig,
24
+ ConsentSnapshot,
25
+ ConsentState,
26
+ ConsentTestingOptions,
27
+ Enablement,
28
+ Environment,
29
+ FullScreenFormat,
30
+ InitStatus,
31
+ PlacementDefinition,
32
+ PlacementState,
33
+ PrivacyBlockReason,
34
+ PrivacyOptions,
35
+ PrivacyOptionsRequirement,
36
+ RetryPolicy,
37
+ Reward,
38
+ TimeoutConfig,
39
+ UnavailableReason,
40
+ Unsubscribe,
41
+ } from './config/types';
42
+ export { isFullScreenFormat } from './config/types';
43
+
44
+ // Timeout policy — exported so a consumer can read the defaults it is bound by
45
+ // rather than discovering them from a timeout in production (FR-032).
46
+ export {
47
+ AD_VALIDITY_BOUNDS,
48
+ APP_OPEN_VALIDITY_BOUNDS,
49
+ DEFAULT_AD_VALIDITY_MS,
50
+ DEFAULT_APP_OPEN_VALIDITY_MS,
51
+ DEFAULT_LOAD_TIMEOUT_MS,
52
+ DEFAULT_RETRY_POLICY,
53
+ DEFAULT_SHOW_TIMEOUT_MS,
54
+ DEFAULT_UNDECIDED_WARNING_MS,
55
+ LOAD_TIMEOUT_BOUNDS,
56
+ RETRY_BOUNDS,
57
+ SHOW_TIMEOUT_BOUNDS,
58
+ UNDECIDED_WARNING_BOUNDS,
59
+ } from './core/timers';
60
+
61
+ // Request throttle — account safety, added in 0.3.0 (FR-140). Exported so an app
62
+ // can read the floor it is bound by, and so the format-aware default is legible
63
+ // rather than something to discover from a `throttled` state in production.
64
+ export {
65
+ DEFAULT_BANNER_MIN_REQUEST_INTERVAL_MS,
66
+ DEFAULT_FULL_SCREEN_MIN_REQUEST_INTERVAL_MS,
67
+ MIN_REQUEST_INTERVAL_BOUNDS,
68
+ defaultMinRequestIntervalMs,
69
+ } from './core/requestThrottle';
70
+
71
+ // Enablement. `EnablementResult` is what `setEnabled` returns in place of
72
+ // 0.1.0's `void` — the defect that let a re-enable fail in silence (FR-122).
73
+ export type { EnablementResult } from './core/enablement';
74
+
75
+ // Instance, lifecycle, and results
76
+ export { createAds } from './core/adsInstance';
77
+ export type {
78
+ AdsInstance,
79
+ ConsentApi,
80
+ InitResult,
81
+ LoadResult,
82
+ PrivacyOptionsFormResult,
83
+ PrivacyUpdateResult,
84
+ ShowResult,
85
+ } from './core/adsInstance';
86
+
87
+ // React surface
88
+ export { AdsBanner } from './react/AdsBanner';
89
+ export type { AdsBannerProps } from './react/AdsBanner';
90
+ export { AdsProvider, useAds } from './react/AdsProvider';
91
+ export type { AdsProviderProps } from './react/AdsProvider';
92
+ export { useAdsEvents, useAdsStatus, usePlacementState } from './react/hooks';
93
+
94
+ // Diagnostics
95
+ export { AdsConfigError, AdsError, isAdsError } from './diagnostics/errors';
96
+ export type { AdsErrorCode, AdsOperation } from './diagnostics/errors';
97
+ export type {
98
+ AdEvent,
99
+ AdEventListener,
100
+ AdEventType,
101
+ ConfigWarning,
102
+ ImpressionSource,
103
+ } from './diagnostics/events';