@chipmobilesdk/rn-camera 0.2.1 → 0.3.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 CHANGED
@@ -1,5 +1,105 @@
1
1
  # @chipmobilesdk/rn-camera
2
2
 
3
+ ## 0.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - One diagnostic stream: install a sink with `installSdkSink` from
8
+ `@chipmobilesdk/rn-core` and it receives what every package observes (events,
9
+ failures, warnings) as `SdkDiagnostic`s, redacted. Listeners are notified through
10
+ rn-core, so a listener that throws no longer stops the others nor fails the call that
11
+ notified, and its error is reported rather than swallowed. ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17))
12
+
13
+ - rn-ads: a banner's auto-refresh is a new cycle, so its `loaded` and `impression`
14
+ events are no longer deduplicated away; the dedupe memory is bounded. A throwing
15
+ status, consent or placement listener is reported as an `internal_error` event.
16
+ - rn-i18n: new `onDiagnostic` config receives every diagnostic (`I18nDiagnostic`,
17
+ with a stable `code`); without it, warnings go to `console.warn` in development
18
+ builds only. The warn-once memory is bounded.
19
+ - rn-chart: now depends on rn-core for its diagnostics.
20
+ - rn-payment: status, entitlement and pending listeners report a throw to the logger
21
+ instead of swallowing it.
22
+
23
+ - The package's errors extend `SdkError` from the new `@chipmobilesdk/rn-core`
24
+ dependency: `isSdkError` recognises them alongside every other package's, and each
25
+ carries `pkg`, `code` and `retryable`. Names and codes are unchanged. ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17))
26
+
27
+ - rn-ai and rn-camera: `retryable` is the new name of `recoverable`, which stays as a
28
+ deprecated alias.
29
+ - rn-auth: `retryable` is true for `network-unavailable`. `redact` also removes URLs,
30
+ e-mail addresses and UUIDs.
31
+ - rn-ads: `redact` also removes JWTs and e-mail addresses, and reduces an `AdsError`
32
+ to its code.
33
+ - rn-ai: an error message quoted from the gateway is pattern-redacted.
34
+ - rn-camera: `redact` also removes JWTs, e-mail addresses and UUIDs.
35
+ - rn-theme: `ThemeConfigError` has `code: 'invalid_config'` and `ThemeContrastError`
36
+ `code: 'contrast'` (new `ThemeErrorCode` type).
37
+ - rn-i18n: `I18nConfigError` has `code: 'invalid_config'` and `UnitConversionError`
38
+ `code: 'unit_conversion'` (new `I18nErrorCode` type).
39
+
40
+ - Native peers load through `lazyPeer` from `@chipmobilesdk/rn-core`, on first use,
41
+ and a missing one is each package's own typed error. ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17))
42
+
43
+ - rn-auth: creating `auth` no longer loads react-native-app-auth or
44
+ react-native-keychain; each loads on first use. New entrypoints
45
+ `@chipmobilesdk/rn-auth/app-auth` and `/keychain`. The default platform comes from
46
+ rn-core.
47
+ - rn-camera: a preview whose VisionCamera feed cannot load fails the session with
48
+ `cameraUnavailable` (new `controller.reportSurfaceUnavailable()`), instead of
49
+ showing black with no error. A missing VisionCamera module is `cameraUnavailable`.
50
+ - rn-notification: `createMmkvStore` reports a fallback to memory as
51
+ `STATE_STORE_DEGRADED` (to the SDK sinks and a new `onDegraded` option) and
52
+ exposes `durable`.
53
+ - rn-payment: an iOS app validates its catalog against the iOS product ids; the
54
+ default platform used to be Android everywhere. A missing react-native-iap is
55
+ `PAYMENT_UNAVAILABLE`.
56
+ - rn-theme: `buildThemes` runs without React Native (new `hairlineWidth` option) and
57
+ importing the package no longer loads Unistyles; `applyThemeMode` loads it on
58
+ first use.
59
+ - rn-ads, rn-local-db: the same loading, with the same error codes as before.
60
+
61
+ - Port conformance suites: one list of checks that a package's fake and its real adapter
62
+ both pass, so a test written against the fake proves something about the device. ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17))
63
+
64
+ - rn-core: `@chipmobilesdk/rn-core/testing` adds `runChecks`, `expectConformance`,
65
+ `ensure` and `createFaultRegistry` (deterministic fault injection by named point).
66
+ - rn-camera: a `./testing` entrypoint with `runPermissionPortConformance`,
67
+ `runCameraEngineConformance` and the test adapter, which stays on the main
68
+ entrypoint too until the next major. `createTestCamera(...).prompts()` counts system
69
+ prompts.
70
+ - rn-payment: `runProviderPortConformance` in `./testing`, passed by the fake store and
71
+ the Play adapter.
72
+ - rn-ads: `runAdsEngineConformance` in `./testing`, passed by the fake engine and the
73
+ AdMob adapter.
74
+ - rn-auth: a `./testing` entrypoint with `createMemorySecureStore`,
75
+ `runSecureTokenStoreConformance` and `runOidcAuthClientConformance`, passed by the
76
+ in-memory doubles and the keychain and app-auth adapters.
77
+
78
+ - One app-lifecycle source, and a startup that needs no extra frame. ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17))
79
+
80
+ - rn-core: `appLifecycle()`, a shared, reference-counted `AppState` subscription that
81
+ reports changes only (`active`, `inactive`, `background`), with `onForeground` for a
82
+ return from the background and `createManualLifecycle` in `./testing`.
83
+ - rn-camera, rn-ads and rn-notification hear the lifecycle through it instead of
84
+ subscribing to `AppState` each, so an app holds one native listener. The camera
85
+ controller's core layer no longer imports `react-native`.
86
+ - rn-payment: `syncOnForeground(engine)` and `<PaymentProvider syncOnForeground>` call
87
+ `handleForeground()` on each return from the background. Off by default; calling
88
+ `handleForeground()` yourself works as before.
89
+ - rn-i18n: `i18n.initSync()` resolves the locale before the first render when the
90
+ storage adapter has the new optional `getItemSync`, and returns false otherwise.
91
+ `localeStorageFromKeyValue(port)` builds such an adapter from rn-core's
92
+ `KeyValuePort` (MMKV).
93
+
94
+ ### Patch Changes
95
+
96
+ - `createCameraController` accepts an explicit `undefined` for `store` and `logger`, so
97
+ apps compiling with `exactOptionalPropertyTypes` type-check against the package.
98
+ `VisionCameraFeed` leaves `torchMode` unset until the app first touches the torch,
99
+ instead of passing `undefined`. No other change. ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17))
100
+ - Updated dependencies ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17), [#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17), [#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17)):
101
+ - @chipmobilesdk/rn-core@0.1.0
102
+
3
103
  ## 0.2.1
4
104
 
5
105
  ### Patch Changes
package/README.md CHANGED
@@ -256,6 +256,31 @@ camera.setAppState('background');
256
256
  camera.emitInterruption();
257
257
  ```
258
258
 
259
+ ### Conformance
260
+
261
+ `@chipmobilesdk/rn-camera/testing` also ships the checks every `PermissionPort` and
262
+ `CameraEngine` must pass. The package runs them against the test adapter and against the
263
+ VisionCamera adapters; run them against your own adapter, or from a demo screen on a
264
+ device, the same way:
265
+
266
+ ```ts
267
+ import { expectConformance } from '@chipmobilesdk/rn-core/testing';
268
+ import { runPermissionPortConformance } from '@chipmobilesdk/rn-camera/testing';
269
+
270
+ expectConformance(
271
+ await runPermissionPortConformance({
272
+ create(initial) {
273
+ // a port over a system whose permission starts in `initial`
274
+ return { port, setSystemState, prompts };
275
+ },
276
+ }),
277
+ );
278
+ ```
279
+
280
+ `createTestCamera` and its helpers are exported from `./testing` too; the main
281
+ entrypoint keeps them until the next major release. `camera.prompts()` counts how often
282
+ `request()` showed the system prompt.
283
+
259
284
  Add a module mapping so an accidental import of the native module resolves in Jest:
260
285
 
261
286
  ```js
@@ -266,7 +291,8 @@ moduleNameMapper: {
266
291
 
267
292
  ## Errors
268
293
 
269
- Every failure is a `CameraError` with a `code` and a `recoverable` flag, so you branch on the code rather than string-matching a message:
294
+ Every failure is a `CameraError` with a `code` and a `retryable` flag (`recoverable` is kept as its deprecated alias), so you branch on the code rather than string-matching a message. `CameraError` extends `SdkError` from `@chipmobilesdk/rn-core`, so `isSdkError(error)` recognises it alongside every other package's errors, `error.pkg` names this package, and `error.redactedCause` (when present) is the underlying failure reduced to log-safe text.
295
+
270
296
 
271
297
  `permissionDenied`, `cameraUnavailable`, `cameraBlocked`, `cameraInUse`, `sessionConflict`, `notReady`, `captureInProgress`, `captureFailed`, `storageFailure`, `unsupportedCapability`, `interrupted`.
272
298
 
@@ -326,7 +352,7 @@ Still capture and real-time frame processing stay strictly separate.
326
352
 
327
353
  ## Public API
328
354
 
329
- Everything is exported from `@chipmobilesdk/rn-camera`. Modules under `core/`, `native/`, `permissions/`, `storage/`, `geometry/`, `diagnostics/`, and `testing/` are private — do not deep-import them.
355
+ Everything is exported from `@chipmobilesdk/rn-camera`, test doubles and conformance suites also from `@chipmobilesdk/rn-camera/testing`. Modules under `core/`, `native/`, `permissions/`, `storage/`, `geometry/`, `diagnostics/`, and `testing/` are private — do not deep-import them.
330
356
 
331
357
  - Controller and state: `createCameraController`, `CameraController`,
332
358
  `CameraControllerOptions`, `useCameraController`, `useCameraState`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chipmobilesdk/rn-camera",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "main": "src/index.ts",
@@ -10,6 +10,11 @@
10
10
  "types": "./src/index.ts",
11
11
  "react-native": "./src/index.ts",
12
12
  "default": "./src/index.ts"
13
+ },
14
+ "./testing": {
15
+ "types": "./src/testing/index.ts",
16
+ "react-native": "./src/testing/index.ts",
17
+ "default": "./src/testing/index.ts"
13
18
  }
14
19
  },
15
20
  "files": [
@@ -29,9 +34,12 @@
29
34
  ],
30
35
  "sideEffects": false,
31
36
  "scripts": {
32
- "typecheck": "tsc -p tsconfig.json --noEmit",
37
+ "typecheck": "tsc -p tsconfig.json && tsc -p tsconfig.test.json",
33
38
  "pack:check": "npm pack --dry-run"
34
39
  },
40
+ "dependencies": {
41
+ "@chipmobilesdk/rn-core": "^0.1.0"
42
+ },
35
43
  "peerDependencies": {
36
44
  "react": ">=19",
37
45
  "react-native": ">=0.85",
@@ -53,6 +53,8 @@ Runtime:
53
53
  - `CameraPreview` (+ `CameraPreviewProps`)
54
54
  - `CameraError`, `isCameraError`, `redact` — `redact` strips sensitive paths before logging
55
55
  - Testing: `createTestCamera`, `createMemoryFileSystem`, `DEFAULT_TEST_CAPABILITIES`
56
+ - Conformance (`@chipmobilesdk/rn-camera/testing`): `runPermissionPortConformance`,
57
+ `runCameraEngineConformance` — run against any adapter you write or inject
56
58
 
57
59
  Types: `CameraEngine`, `EngineOptions`, `PermissionPort`, `ArtifactStore`,
58
60
  `FileSystemBridge`, `StoreOptions`, `CameraErrorCode`, `CameraLogEvent`,
@@ -7,6 +7,8 @@
7
7
  * tested without a camera.
8
8
  */
9
9
 
10
+ import { appLifecycle, createListenerSet } from '@chipmobilesdk/rn-core';
11
+
10
12
  import {
11
13
  type CameraPermissionState,
12
14
  type CameraPosition,
@@ -18,7 +20,7 @@ import {
18
20
  type StartOptions,
19
21
  } from '../config/types';
20
22
  import { CameraError, isCameraError } from '../diagnostics/errors';
21
- import { silentLogger, type CameraLogger } from '../diagnostics/logger';
23
+ import { silentLogger, withSdkSinks, type CameraLogger } from '../diagnostics/logger';
22
24
  import type { CameraEngine } from '../native/cameraEngine';
23
25
  import type { PermissionPort } from '../permissions/permissionPort';
24
26
  import type { ArtifactStore } from '../storage/artifactStore';
@@ -39,12 +41,13 @@ export type AppStatus = 'active' | 'background';
39
41
  export interface CameraControllerOptions {
40
42
  readonly engine?: CameraEngine;
41
43
  readonly permissions?: PermissionPort;
42
- readonly store?: ArtifactStore;
43
- readonly logger?: CameraLogger;
44
+ readonly store?: ArtifactStore | undefined;
45
+ readonly logger?: CameraLogger | undefined;
44
46
  readonly temporaryTtlMs?: number;
45
47
  /**
46
- * App foreground/background source. Defaults to React Native `AppState`.
47
- * Injectable so lifecycle rules are testable without an app (FR-015).
48
+ * App foreground/background source. Defaults to rn-core's shared `appLifecycle()`,
49
+ * where `inactive` counts as background. Injectable so lifecycle rules are
50
+ * testable without an app (FR-015).
48
51
  */
49
52
  readonly subscribeToAppState?: (listener: (status: AppStatus) => void) => () => void;
50
53
  /**
@@ -75,6 +78,12 @@ export interface CameraController {
75
78
 
76
79
  /** Marks the preview surface mounted, enabling foreground auto-restart. */
77
80
  setSurfaceMounted(mounted: boolean): void;
81
+ /**
82
+ * Called by `CameraPreview` when the native preview surface cannot load (the
83
+ * VisionCamera module is missing or unlinked). Fails the session with
84
+ * `cameraUnavailable` rather than leaving a black preview and no error.
85
+ */
86
+ reportSurfaceUnavailable(): void;
78
87
  /**
79
88
  * Releases ports, listeners, and the camera. Safe to call twice. Afterwards
80
89
  * `start`, `resume`, `capture` and the permission and capability reads reject
@@ -85,13 +94,9 @@ export interface CameraController {
85
94
  }
86
95
 
87
96
  function defaultAppStateSubscribe(listener: (status: AppStatus) => void): () => void {
88
- // Lazy require so importing the package outside React Native (for example in
89
- // a Node-based test runner) does not pull in the RN runtime.
90
- const { AppState } = require('react-native');
91
- const subscription = AppState.addEventListener('change', (next: string) => {
92
- listener(next === 'active' ? 'active' : 'background');
93
- });
94
- return () => subscription.remove();
97
+ // rn-core's one lifecycle subscription, shared with every other package; it
98
+ // requires react-native only on first subscribe (PERF-07).
99
+ return appLifecycle().subscribe(state => listener(state === 'active' ? 'active' : 'background'));
95
100
  }
96
101
 
97
102
  export function createCameraController(options: CameraControllerOptions = {}): CameraController {
@@ -107,7 +112,7 @@ export function createCameraController(options: CameraControllerOptions = {}): C
107
112
  const engine: CameraEngine = options.engine;
108
113
  const permissions: PermissionPort = options.permissions;
109
114
  const store = options.store;
110
- const logger = options.logger ?? silentLogger;
115
+ const logger = withSdkSinks(options.logger ?? silentLogger);
111
116
  const ttlMs = options.temporaryTtlMs ?? DEFAULT_TEMPORARY_TTL_MS;
112
117
 
113
118
  let session: SessionSnapshot = initialSession;
@@ -129,7 +134,9 @@ export function createCameraController(options: CameraControllerOptions = {}): C
129
134
  let detach: (() => void) | null = null;
130
135
 
131
136
  const capture = new SingleFlight();
132
- const listeners = new Set<(state: CameraState) => void>();
137
+ const listeners = createListenerSet<[CameraState]>({
138
+ onListenerError: () => logger.log({ type: 'session', code: 'listener-threw' }),
139
+ });
133
140
 
134
141
  function snapshot(): CameraState {
135
142
  return {
@@ -154,15 +161,9 @@ export function createCameraController(options: CameraControllerOptions = {}): C
154
161
 
155
162
  function emit(): void {
156
163
  current = snapshot();
157
- for (const listener of listeners) {
158
- // One throwing subscriber must not starve the others, nor unwind into
159
- // the transition that emitted — that left the controller half-updated.
160
- try {
161
- listener(current);
162
- } catch {
163
- logger.log({ type: 'session', code: 'listener-threw' });
164
- }
165
- }
164
+ // Isolated: one throwing subscriber neither starves the others nor unwinds
165
+ // into the transition that emitted, which left the controller half-updated.
166
+ listeners.notify(current);
166
167
  }
167
168
 
168
169
  function dispatch(event: Parameters<typeof sessionReducer>[1]): void {
@@ -336,10 +337,7 @@ export function createCameraController(options: CameraControllerOptions = {}): C
336
337
  return () => undefined;
337
338
  }
338
339
  attach();
339
- listeners.add(listener);
340
- return () => {
341
- listeners.delete(listener);
342
- };
340
+ return listeners.add(listener);
343
341
  },
344
342
 
345
343
  async getPermission() {
@@ -492,6 +490,14 @@ export function createCameraController(options: CameraControllerOptions = {}): C
492
490
  emit();
493
491
  },
494
492
 
493
+ reportSurfaceUnavailable() {
494
+ if (disposed) {
495
+ return;
496
+ }
497
+ logger.log({ type: 'session', code: 'surface-unavailable', recoverable: false });
498
+ dispatch({ type: 'FAIL', code: 'cameraUnavailable' });
499
+ },
500
+
495
501
  setSurfaceMounted(mounted: boolean) {
496
502
  if (disposed) {
497
503
  return;
@@ -6,6 +6,8 @@
6
6
  * payloads — `redact` is applied before any value reaches a log sink.
7
7
  */
8
8
 
9
+ import { COMMON_PATTERNS, PATH_PATTERN, SdkError, redactPatterns } from '@chipmobilesdk/rn-core';
10
+
9
11
  export type CameraErrorCode =
10
12
  | 'permissionDenied'
11
13
  | 'cameraUnavailable'
@@ -32,16 +34,16 @@ const RECOVERABLE: ReadonlySet<CameraErrorCode> = new Set<CameraErrorCode>([
32
34
  'interrupted',
33
35
  ]);
34
36
 
35
- export class CameraError extends Error {
36
- readonly code: CameraErrorCode;
37
- readonly recoverable: boolean;
38
-
37
+ /** The package's error; an `SdkError` (`@chipmobilesdk/rn-core`) with `pkg: 'rn-camera'`. */
38
+ export class CameraError extends SdkError<CameraErrorCode> {
39
39
  constructor(code: CameraErrorCode, message?: string) {
40
- super(message ?? code);
40
+ super('rn-camera', code, { message, retryable: RECOVERABLE.has(code) });
41
41
  this.name = 'CameraError';
42
- this.code = code;
43
- this.recoverable = RECOVERABLE.has(code);
44
- Object.setPrototypeOf(this, CameraError.prototype);
42
+ }
43
+
44
+ /** @deprecated Use `retryable`, the name every SDK package shares. */
45
+ get recoverable(): boolean {
46
+ return this.retryable;
45
47
  }
46
48
  }
47
49
 
@@ -49,17 +51,17 @@ export function isCameraError(value: unknown): value is CameraError {
49
51
  return value instanceof CameraError;
50
52
  }
51
53
 
52
- /** Matches anything path-like: file URIs, POSIX paths, and Windows paths. */
53
- const PATH_PATTERN =
54
- /(?:[a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^\s"']+)|(?:[a-zA-Z]:[\\/][^\s"']+)|(?:\/[^\s"']*\/[^\s"']*)/g;
55
- /** Matches base64-ish runs long enough to be an encoded image payload. */
56
- const PAYLOAD_PATTERN = /[A-Za-z0-9+/=]{64,}/g;
54
+ /**
55
+ * rn-core's shared patterns (URLs, including file URIs; JWTs; e-mail addresses;
56
+ * UUIDs; base64-ish payload runs) plus POSIX and Windows paths.
57
+ */
58
+ const PATTERNS = [...COMMON_PATTERNS, PATH_PATTERN];
57
59
 
58
60
  const REDACTED = '[redacted]';
59
61
  const SENSITIVE_KEYS = new Set(['uri', 'path', 'destination', 'file', 'data', 'base64']);
60
62
 
61
63
  function redactString(value: string): string {
62
- return value.replace(PATH_PATTERN, REDACTED).replace(PAYLOAD_PATTERN, REDACTED);
64
+ return redactPatterns(value, PATTERNS);
63
65
  }
64
66
 
65
67
  /**
@@ -76,7 +78,7 @@ export function redact(value: unknown): unknown {
76
78
  return value.map(redact);
77
79
  }
78
80
  if (value instanceof CameraError) {
79
- return { code: value.code, recoverable: value.recoverable };
81
+ return { code: value.code, recoverable: value.retryable };
80
82
  }
81
83
  if (value instanceof Error) {
82
84
  return { name: value.name, message: redactString(value.message) };
@@ -6,18 +6,39 @@
6
6
  * image data — so an opted-in logger still cannot leak an image path.
7
7
  */
8
8
 
9
+ import { createDiagnosticChannel } from '@chipmobilesdk/rn-core';
10
+
9
11
  export type CameraLogEventType = 'session' | 'permission' | 'capture' | 'capability' | 'cleanup';
10
12
 
11
13
  export interface CameraLogEvent {
12
14
  readonly type: CameraLogEventType;
13
15
  readonly code: string;
14
- readonly recoverable?: boolean;
16
+ readonly recoverable?: boolean | undefined;
15
17
  }
16
18
 
17
19
  export interface CameraLogger {
18
20
  log(event: CameraLogEvent): void;
19
21
  }
20
22
 
23
+ const channel = createDiagnosticChannel('rn-camera');
24
+
25
+ /**
26
+ * Every event also reaches the app's SDK sinks (`installSdkSink`, CORE-08), as
27
+ * `<type>.<code>`: the same code-and-state body, so nothing new can leak.
28
+ */
29
+ export function withSdkSinks(logger: CameraLogger): CameraLogger {
30
+ return {
31
+ log(event) {
32
+ channel.emit(
33
+ `${event.type}.${event.code}`,
34
+ event.code === 'listener-threw' ? 'error' : event.recoverable === false ? 'warn' : 'info',
35
+ event.recoverable === undefined ? undefined : { retryable: event.recoverable },
36
+ );
37
+ logger.log(event);
38
+ },
39
+ };
40
+ }
41
+
21
42
  /** No-op logger used when the consumer supplies none. */
22
43
  export const silentLogger: CameraLogger = {
23
44
  log() {
@@ -14,6 +14,7 @@
14
14
  * mounted preview surface (see `visionSurface`).
15
15
  */
16
16
 
17
+ import { loadVisionCamera } from './visionCameraPeer';
17
18
  import type {
18
19
  CapabilityProfile,
19
20
  CapturedImage,
@@ -49,8 +50,12 @@ interface DiscoveredDevice {
49
50
  * owns the rendered surface and the photo output that capture runs through.
50
51
  */
51
52
  export function createVisionCameraEngine(_options: EngineOptions = {}): CameraEngine {
52
- // Lazy require: only loaded when the default adapter is actually used.
53
- const { VisionCamera } = require('react-native-vision-camera');
53
+ // Loaded when the default adapter is created; a missing peer is `cameraUnavailable`.
54
+ const { VisionCamera } = loadVisionCamera<{
55
+ VisionCamera: {
56
+ createDeviceFactory(): Promise<{ readonly cameraDevices: DiscoveredDevice[] }>;
57
+ };
58
+ }>();
54
59
 
55
60
  // The factory is created once and reused: it keeps its own device list up to
56
61
  // date, so re-creating it per call would only add latency.
@@ -0,0 +1,30 @@
1
+ /**
2
+ * react-native-vision-camera, loaded on first use (rn-core's lazy peer). A missing
3
+ * or unlinked module is a `cameraUnavailable` CameraError, the code the controller
4
+ * already reports when the camera cannot be reached.
5
+ */
6
+ import { lazyPeer } from '@chipmobilesdk/rn-core';
7
+
8
+ import { CameraError } from '../diagnostics/errors';
9
+
10
+ // The engine and the permission port each describe the slice they use.
11
+ type VisionCameraModule = { VisionCamera: Record<string, (...args: never[]) => unknown> };
12
+
13
+ const visionCamera = lazyPeer('react-native-vision-camera', () => {
14
+ // Inside `try`, so Metro bundles an app that leaves this optional peer out.
15
+ try {
16
+ return require('react-native-vision-camera') as VisionCameraModule;
17
+ } catch (cause) {
18
+ throw cause;
19
+ }
20
+ });
21
+
22
+ export function loadVisionCamera<T>(): T {
23
+ return visionCamera.require(
24
+ peer =>
25
+ new CameraError(
26
+ 'cameraUnavailable',
27
+ `${peer} is not installed or its native module is not linked`,
28
+ ),
29
+ ) as unknown as T;
30
+ }
@@ -12,6 +12,9 @@
12
12
  * statics since v5).
13
13
  */
14
14
 
15
+ import { loadVisionCamera } from '../native/visionCameraPeer';
16
+ import { appLifecycle, createListenerSet } from '@chipmobilesdk/rn-core';
17
+
15
18
  import type { CameraPermissionState } from '../config/types';
16
19
 
17
20
  export interface PermissionPort {
@@ -46,12 +49,16 @@ function mapStatus(raw: string): CameraPermissionState {
46
49
  }
47
50
 
48
51
  export function createVisionCameraPermissions(): PermissionPort {
49
- // Lazy require: only loaded when the default adapter is actually used.
50
- const { VisionCamera } = require('react-native-vision-camera');
51
- const { AppState } = require('react-native');
52
- const listeners = new Set<(state: CameraPermissionState) => void>();
52
+ // Loaded when the default adapter is created; a missing peer is `cameraUnavailable`.
53
+ const { VisionCamera } = loadVisionCamera<{
54
+ VisionCamera: {
55
+ readonly cameraPermissionStatus: string;
56
+ requestCameraPermission(): Promise<unknown>;
57
+ };
58
+ }>();
59
+ const listeners = createListenerSet<[CameraPermissionState]>();
53
60
  let last: CameraPermissionState | null = null;
54
- let appStateSubscription: { remove(): void } | null = null;
61
+ let stopWatchingLifecycle: (() => void) | null = null;
55
62
 
56
63
  function read(): CameraPermissionState {
57
64
  try {
@@ -66,9 +73,7 @@ export function createVisionCameraPermissions(): PermissionPort {
66
73
  function emit(state: CameraPermissionState): CameraPermissionState {
67
74
  if (state !== last) {
68
75
  last = state;
69
- for (const listener of listeners) {
70
- listener(state);
71
- }
76
+ listeners.notify(state);
72
77
  }
73
78
  return state;
74
79
  }
@@ -92,22 +97,21 @@ export function createVisionCameraPermissions(): PermissionPort {
92
97
  },
93
98
 
94
99
  subscribe(listener) {
95
- listeners.add(listener);
100
+ const remove = listeners.add(listener);
96
101
  // Grants and revocations made in system Settings send the app through
97
102
  // background/foreground, so re-reading on foreground is what surfaces
98
103
  // them (FR-007). Attached only while somebody is listening.
99
- if (!appStateSubscription) {
100
- appStateSubscription = AppState.addEventListener('change', (next: string) => {
101
- if (next === 'active') {
102
- emit(read());
103
- }
104
- });
105
- }
104
+ // rn-core's shared lifecycle port, so this adds no native subscription of its own.
105
+ stopWatchingLifecycle ??= appLifecycle().subscribe(state => {
106
+ if (state === 'active') {
107
+ emit(read());
108
+ }
109
+ });
106
110
  return () => {
107
- listeners.delete(listener);
108
- if (listeners.size === 0 && appStateSubscription) {
109
- appStateSubscription.remove();
110
- appStateSubscription = null;
111
+ remove();
112
+ if (listeners.size === 0 && stopWatchingLifecycle) {
113
+ stopWatchingLifecycle();
114
+ stopWatchingLifecycle = null;
111
115
  }
112
116
  };
113
117
  },
@@ -33,6 +33,9 @@ export interface CameraPreviewProps {
33
33
  readonly children?: React.ReactNode;
34
34
  }
35
35
 
36
+ /** The feed module failed to load; distinct from `null`, which means not requested. */
37
+ const FEED_UNAVAILABLE = 'unavailable' as const;
38
+
36
39
  /** Nominal capture dimensions until the engine reports the real format. */
37
40
  const DEFAULT_IMAGE_WIDTH = 1920;
38
41
  const DEFAULT_IMAGE_HEIGHT = 1080;
@@ -101,10 +104,18 @@ export const CameraPreview: React.FC<CameraPreviewProps> = ({
101
104
  return require('./VisionCameraFeed')
102
105
  .VisionCameraFeed as React.ComponentType<VisionCameraFeedProps>;
103
106
  } catch {
104
- return null;
107
+ // Reported, not swallowed (CAM-21): the session fails with
108
+ // `cameraUnavailable` instead of showing a black preview.
109
+ return FEED_UNAVAILABLE;
105
110
  }
106
111
  }, [feedRequested]);
107
112
 
113
+ useEffect(() => {
114
+ if (Feed === FEED_UNAVAILABLE) {
115
+ controller.reportSurfaceUnavailable();
116
+ }
117
+ }, [Feed, controller]);
118
+
108
119
  return (
109
120
  <View
110
121
  style={style}
@@ -113,7 +124,7 @@ export const CameraPreview: React.FC<CameraPreviewProps> = ({
113
124
  accessibilityRole={accessibilityRole}
114
125
  testID="rn-camera-preview"
115
126
  >
116
- {Feed ? (
127
+ {Feed && Feed !== FEED_UNAVAILABLE ? (
117
128
  <Feed
118
129
  cameraPosition={state.cameraPosition}
119
130
  resizeMode={resizeMode}
@@ -26,6 +26,7 @@ import {
26
26
  type CameraPosition,
27
27
  type CapturedImage,
28
28
  type CaptureRequest,
29
+ type Maybe,
29
30
  type ResizeMode,
30
31
  } from '../config/types';
31
32
  import { DEFAULT_QUALITY } from '../core/captureController';
@@ -57,6 +58,11 @@ const ORIENTATION_DEGREES: Record<string, number> = {
57
58
  left: 270,
58
59
  };
59
60
 
61
+ function orientationOf(orientation: string): Maybe<number> {
62
+ const degrees = ORIENTATION_DEGREES[orientation];
63
+ return degrees === undefined ? unavailable() : known(degrees);
64
+ }
65
+
60
66
  function toFileUri(path: string): string {
61
67
  return path.startsWith('file://') ? path : `file://${path}`;
62
68
  }
@@ -122,10 +128,7 @@ async function capturePhoto(
122
128
  // The native layer reports neither back after a save, and guessing them
123
129
  // would be worse than saying so (FR-022).
124
130
  fileSize: unavailable(),
125
- orientation:
126
- photo.orientation in ORIENTATION_DEGREES
127
- ? known(ORIENTATION_DEGREES[photo.orientation])
128
- : unavailable(),
131
+ orientation: orientationOf(photo.orientation),
129
132
  };
130
133
  } finally {
131
134
  // Large native buffers must not wait for the JS GC.
@@ -171,7 +174,8 @@ export const VisionCameraFeed: React.FC<VisionCameraFeedProps> = ({
171
174
  outputs={[photoOutput]}
172
175
  isActive={true}
173
176
  resizeMode={resizeMode}
174
- torchMode={torchMode}
177
+ // Left unset until the app first touches the torch, so the device's own default stands.
178
+ {...(torchMode === undefined ? {} : { torchMode })}
175
179
  />
176
180
  );
177
181
  };
@@ -0,0 +1,140 @@
1
+ /**
2
+ * What any `PermissionPort` and `CameraEngine` must do (IOS-12, CAM-13).
3
+ *
4
+ * The same checks run against the in-memory test adapter and against the real
5
+ * VisionCamera adapters (native module mocked in Jest, or for real on a device),
6
+ * so the fake the app's tests rely on cannot drift from the adapter it stands in
7
+ * for. Each check builds its own port from a harness, under the conditions it
8
+ * needs.
9
+ */
10
+ import {
11
+ ensure,
12
+ runChecks,
13
+ type Check,
14
+ type ConformanceReport,
15
+ } from '@chipmobilesdk/rn-core/testing';
16
+
17
+ import type { CameraPermissionState } from '../config/types';
18
+ import type { CameraEngine } from '../native/cameraEngine';
19
+ import type { PermissionPort } from '../permissions/permissionPort';
20
+
21
+ export type SystemPermission = 'undetermined' | 'granted' | 'deniedPermanently';
22
+
23
+ export interface PermissionHarness {
24
+ /** A port over a system whose permission starts in `initial`. */
25
+ create(initial: SystemPermission): {
26
+ readonly port: PermissionPort;
27
+ /** Changes the system permission, as Settings would. */
28
+ setSystemState(state: SystemPermission): void;
29
+ /** How many times the system prompt was shown. */
30
+ prompts(): number;
31
+ };
32
+ }
33
+
34
+ export interface CameraDeviceSpec {
35
+ readonly position: 'front' | 'back';
36
+ readonly hasFlash?: boolean;
37
+ readonly hasTorch?: boolean;
38
+ }
39
+
40
+ export interface EngineHarness {
41
+ /** An engine on a device with these cameras. */
42
+ create(devices: readonly CameraDeviceSpec[]): CameraEngine;
43
+ }
44
+
45
+ export const permissionPortChecks: readonly Check<PermissionHarness>[] = [
46
+ {
47
+ name: 'reads the status without prompting',
48
+ async run(harness) {
49
+ const system = harness.create('undetermined');
50
+ ensure((await system.port.getStatus()) === 'undetermined', 'expected undetermined');
51
+ ensure(system.prompts() === 0, 'getStatus prompted');
52
+ },
53
+ },
54
+ {
55
+ name: 'reports a grant after prompting once',
56
+ async run(harness) {
57
+ const system = harness.create('undetermined');
58
+ const state = await system.port.request();
59
+ ensure(state === 'granted', `expected granted, got ${state}`);
60
+ ensure(system.prompts() === 1, `expected one prompt, got ${system.prompts()}`);
61
+ },
62
+ },
63
+ {
64
+ name: 'never prompts again after a permanent denial',
65
+ async run(harness) {
66
+ const system = harness.create('deniedPermanently');
67
+ const state = await system.port.request();
68
+ ensure(state === 'deniedPermanently', `expected deniedPermanently, got ${state}`);
69
+ ensure(system.prompts() === 0, 'prompted after a permanent denial');
70
+ },
71
+ },
72
+ {
73
+ name: 'tells subscribers about a change, and nothing after unsubscribing',
74
+ async run(harness) {
75
+ const system = harness.create('undetermined');
76
+ const seen: CameraPermissionState[] = [];
77
+ const unsubscribe = system.port.subscribe(state => seen.push(state));
78
+ await system.port.request();
79
+ ensure(seen.includes('granted'), `subscriber saw ${JSON.stringify(seen)}`);
80
+ unsubscribe();
81
+ unsubscribe();
82
+ const before = seen.length;
83
+ system.setSystemState('deniedPermanently');
84
+ await system.port.getStatus();
85
+ ensure(seen.length === before, 'notified after unsubscribing');
86
+ },
87
+ },
88
+ ];
89
+
90
+ export const cameraEngineChecks: readonly Check<EngineHarness>[] = [
91
+ {
92
+ name: 'refuses to start without a camera, as cameraUnavailable',
93
+ async run(harness) {
94
+ const engine = harness.create([]);
95
+ let code: unknown;
96
+ try {
97
+ await engine.start({});
98
+ } catch (error) {
99
+ code = (error as { code?: unknown }).code;
100
+ }
101
+ ensure(code === 'cameraUnavailable', `expected cameraUnavailable, got ${String(code)}`);
102
+ },
103
+ },
104
+ {
105
+ name: 'starts when a camera exists',
106
+ async run(harness) {
107
+ await harness.create([{ position: 'back' }]).start({});
108
+ },
109
+ },
110
+ {
111
+ name: 'reports capabilities from the cameras present',
112
+ async run(harness) {
113
+ const capabilities = await harness
114
+ .create([{ position: 'back', hasTorch: true, hasFlash: true }, { position: 'front' }])
115
+ .getCapabilities();
116
+ ensure(capabilities.hasCameraHardware, 'no camera hardware reported');
117
+ ensure(capabilities.hasTorch && capabilities.hasFlash, 'torch or flash not reported');
118
+ const positions = [...capabilities.availablePositions].sort();
119
+ ensure(positions.join(',') === 'back,front', `positions ${positions.join(',')}`);
120
+ },
121
+ },
122
+ {
123
+ name: 'stops safely, even without a start and twice',
124
+ async run(harness) {
125
+ const engine = harness.create([{ position: 'back' }]);
126
+ await engine.stop();
127
+ await engine.stop();
128
+ },
129
+ },
130
+ ];
131
+
132
+ export function runPermissionPortConformance(
133
+ harness: PermissionHarness,
134
+ ): Promise<ConformanceReport> {
135
+ return runChecks(() => harness, permissionPortChecks);
136
+ }
137
+
138
+ export function runCameraEngineConformance(harness: EngineHarness): Promise<ConformanceReport> {
139
+ return runChecks(() => harness, cameraEngineChecks);
140
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * `@chipmobilesdk/rn-camera/testing`: test doubles and the port conformance suites.
3
+ *
4
+ * A separate entrypoint so the suites cannot reach a production bundle. The test
5
+ * adapter is also exported from the main entrypoint for compatibility; it leaves
6
+ * there only in a major release.
7
+ */
8
+
9
+ export {
10
+ createTestCamera,
11
+ createMemoryFileSystem,
12
+ DEFAULT_TEST_CAPABILITIES,
13
+ } from './testCameraAdapter';
14
+ export type { MemoryFileSystem, TestCameraConfig, TestCameraHandles } from './testCameraAdapter';
15
+
16
+ export {
17
+ cameraEngineChecks,
18
+ permissionPortChecks,
19
+ runCameraEngineConformance,
20
+ runPermissionPortConformance,
21
+ } from './conformance';
22
+ export type {
23
+ CameraDeviceSpec,
24
+ EngineHarness,
25
+ PermissionHarness,
26
+ SystemPermission,
27
+ } from './conformance';
@@ -7,6 +7,8 @@
7
7
  * filesystem, or a permission API.
8
8
  */
9
9
 
10
+ import { createListenerSet } from '@chipmobilesdk/rn-core';
11
+
10
12
  import {
11
13
  known,
12
14
  unavailable,
@@ -100,6 +102,8 @@ export interface TestCameraHandles {
100
102
  emitInterruption(): void;
101
103
  setCaptureError(code: CameraErrorCode | null): void;
102
104
  setStartError(code: CameraErrorCode | null): void;
105
+ /** How many times `request` showed the system prompt. */
106
+ prompts(): number;
103
107
  /** Every capture the engine produced, in order. */
104
108
  captures(): readonly CapturedImage[];
105
109
  torchState(): boolean;
@@ -122,11 +126,13 @@ export function createTestCamera(config: TestCameraConfig = {}): TestCameraHandl
122
126
  let starts = 0;
123
127
  let stops = 0;
124
128
  let sequence = 0;
129
+ let promptCount = 0;
125
130
 
126
131
  const produced: CapturedImage[] = [];
127
- const permissionListeners = new Set<(state: CameraPermissionState) => void>();
128
- const appStateListeners = new Set<(status: AppStatus) => void>();
129
- const interruptionListeners = new Set<() => void>();
132
+ // The same listener sets as the real adapters, so a throwing subscriber behaves
133
+ // identically here and on a device.
134
+ const permissionListeners = createListenerSet<[CameraPermissionState]>();
135
+ const appStateListeners = createListenerSet<[AppStatus]>();
130
136
 
131
137
  const permissions: PermissionPort = {
132
138
  async getStatus() {
@@ -137,19 +143,14 @@ export function createTestCamera(config: TestCameraConfig = {}): TestCameraHandl
137
143
  if (permission === 'deniedPermanently') {
138
144
  return permission;
139
145
  }
140
- permission =
141
- permission === 'undetermined' || permission === 'denied' ? 'granted' : permission;
142
- for (const listener of permissionListeners) {
143
- listener(permission);
146
+ if (permission === 'undetermined' || permission === 'denied') {
147
+ promptCount += 1;
148
+ permission = 'granted';
144
149
  }
150
+ permissionListeners.notify(permission);
145
151
  return permission;
146
152
  },
147
- subscribe(listener) {
148
- permissionListeners.add(listener);
149
- return () => {
150
- permissionListeners.delete(listener);
151
- };
152
- },
153
+ subscribe: listener => permissionListeners.add(listener),
153
154
  };
154
155
 
155
156
  const engine: CameraEngine = {
@@ -211,30 +212,18 @@ export function createTestCamera(config: TestCameraConfig = {}): TestCameraHandl
211
212
  store,
212
213
  fs,
213
214
 
214
- subscribeToAppState(listener) {
215
- appStateListeners.add(listener);
216
- return () => {
217
- appStateListeners.delete(listener);
218
- };
219
- },
215
+ subscribeToAppState: listener => appStateListeners.add(listener),
220
216
 
221
217
  setAppState(status) {
222
- for (const listener of appStateListeners) {
223
- listener(status);
224
- }
218
+ appStateListeners.notify(status);
225
219
  },
226
220
 
227
221
  setPermission(state) {
228
222
  permission = state;
229
- for (const listener of permissionListeners) {
230
- listener(state);
231
- }
223
+ permissionListeners.notify(state);
232
224
  },
233
225
 
234
226
  emitInterruption() {
235
- for (const listener of interruptionListeners) {
236
- listener();
237
- }
238
227
  // An interruption behaves like the OS taking the camera away: the next
239
228
  // start attempt fails until the app clears it.
240
229
  startError = 'interrupted';
@@ -248,6 +237,7 @@ export function createTestCamera(config: TestCameraConfig = {}): TestCameraHandl
248
237
  startError = code;
249
238
  },
250
239
 
240
+ prompts: () => promptCount,
251
241
  captures: () => produced,
252
242
  torchState: () => torch,
253
243
  startCount: () => starts,