@chipmobilesdk/rn-camera 0.2.1 → 0.4.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.
@@ -15,7 +15,7 @@ import {
15
15
  type CameraControllerOptions,
16
16
  } from '../core/controller';
17
17
 
18
- export function useCameraController(options: CameraControllerOptions = {}): CameraController {
18
+ export function useCameraController(options: CameraControllerOptions): CameraController {
19
19
  // Options usually arrive as a fresh object literal each render; capturing
20
20
  // them in a ref keeps the controller stable for the component's lifetime.
21
21
  const optionsRef = useRef(options);
@@ -7,6 +7,8 @@
7
7
  * the package can refuse to delete anything it did not create (FR-028).
8
8
  */
9
9
 
10
+ import { CameraError } from '../diagnostics/errors';
11
+
10
12
  export interface ArtifactStore {
11
13
  /** Absolute path of the package-owned temporary directory. */
12
14
  temporaryDirectory(): string;
@@ -112,7 +114,10 @@ export function createArtifactStore(
112
114
  return uri;
113
115
  }
114
116
  if (!fs.move) {
115
- throw new Error('This FileSystemBridge has no move(), so it cannot adopt a capture');
117
+ throw new CameraError(
118
+ 'storageFailure',
119
+ 'This FileSystemBridge has no move(), so it cannot adopt a capture',
120
+ );
116
121
  }
117
122
  await fs.ensureDirectory(root);
118
123
  const name = path.slice(path.lastIndexOf('/') + 1) || `capture-${Date.now()}.jpg`;
@@ -132,7 +137,9 @@ export function createArtifactStore(
132
137
  export function createCacheArtifactStore(options: StoreOptions = {}): ArtifactStore {
133
138
  const fs = options.fs;
134
139
  if (!fs) {
135
- throw new Error(
140
+ // A typed error, like every other failure this package reports.
141
+ throw new CameraError(
142
+ 'storageFailure',
136
143
  'createCacheArtifactStore requires a FileSystemBridge. Pass `fs`, or use ' +
137
144
  'createTestCamera() in tests.',
138
145
  );
@@ -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,
@@ -25,6 +27,7 @@ import {
25
27
  type FileSystemBridge,
26
28
  } from '../storage/artifactStore';
27
29
  import type { AppStatus } from '../core/controller';
30
+ import { clampQuality } from '../core/captureController';
28
31
 
29
32
  export interface MemoryFileSystem extends FileSystemBridge {
30
33
  /** Seeds a file with an explicit creation time, for time-to-live tests. */
@@ -96,10 +99,19 @@ export interface TestCameraHandles {
96
99
  setAppState(status: AppStatus): void;
97
100
  /** Simulates a Settings-level permission change while running (FR-007). */
98
101
  setPermission(state: CameraPermissionState): void;
99
- /** Simulates an OS preemption such as an incoming call. */
100
- emitInterruption(): void;
102
+ /**
103
+ * Simulates the OS taking the camera away: an incoming call, another app,
104
+ * a disconnected device. A live session tears down with that code, exactly
105
+ * as the device adapter reports it (CAM-19), and the next start fails until
106
+ * `setStartError(null)` clears it.
107
+ */
108
+ emitInterruption(code?: CameraErrorCode): void;
109
+ /** Simulates the preview failing to produce frames. */
110
+ emitPermissionRequestFailure(enabled: boolean): void;
101
111
  setCaptureError(code: CameraErrorCode | null): void;
102
112
  setStartError(code: CameraErrorCode | null): void;
113
+ /** How many times `request` showed the system prompt. */
114
+ prompts(): number;
103
115
  /** Every capture the engine produced, in order. */
104
116
  captures(): readonly CapturedImage[];
105
117
  torchState(): boolean;
@@ -122,37 +134,42 @@ export function createTestCamera(config: TestCameraConfig = {}): TestCameraHandl
122
134
  let starts = 0;
123
135
  let stops = 0;
124
136
  let sequence = 0;
137
+ let promptCount = 0;
125
138
 
126
139
  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>();
140
+ // The same listener sets as the real adapters, so a throwing subscriber behaves
141
+ // identically here and on a device.
142
+ const permissionListeners = createListenerSet<[CameraPermissionState]>();
143
+ const appStateListeners = createListenerSet<[AppStatus]>();
144
+ const interruptionListeners = createListenerSet<[CameraErrorCode]>();
145
+ let requestThrows = false;
130
146
 
131
147
  const permissions: PermissionPort = {
132
148
  async getStatus() {
133
149
  return permission;
134
150
  },
135
151
  async request() {
152
+ if (requestThrows) {
153
+ // What a native module that cannot be reached does.
154
+ throw new Error('permission request failed');
155
+ }
136
156
  // Mirrors the real adapter: never re-prompt after a permanent denial.
137
157
  if (permission === 'deniedPermanently') {
138
158
  return permission;
139
159
  }
140
- permission =
141
- permission === 'undetermined' || permission === 'denied' ? 'granted' : permission;
142
- for (const listener of permissionListeners) {
143
- listener(permission);
160
+ if (permission === 'undetermined' || permission === 'denied') {
161
+ promptCount += 1;
162
+ permission = 'granted';
144
163
  }
164
+ permissionListeners.notify(permission);
145
165
  return permission;
146
166
  },
147
- subscribe(listener) {
148
- permissionListeners.add(listener);
149
- return () => {
150
- permissionListeners.delete(listener);
151
- };
152
- },
167
+ subscribe: listener => permissionListeners.add(listener),
153
168
  };
154
169
 
155
170
  const engine: CameraEngine = {
171
+ subscribeToInterruptions: listener => interruptionListeners.add(listener),
172
+
156
173
  async start() {
157
174
  starts += 1;
158
175
  if (startError) {
@@ -185,7 +202,8 @@ export function createTestCamera(config: TestCameraConfig = {}): TestCameraHandl
185
202
  timestamp: Date.now(),
186
203
  cameraPosition: position,
187
204
  ownership: request.destination ? 'appOwned' : 'packageTemporary',
188
- effectiveQuality: request.quality ?? 0.9,
205
+ // Clamped the way the real path clamps it, not a second copy of 0.9.
206
+ effectiveQuality: clampQuality(request.quality),
189
207
  effectiveMaxDimension:
190
208
  request.maxDimension === undefined ? unavailable() : known(request.maxDimension),
191
209
  fileSize: known(1024),
@@ -211,33 +229,26 @@ export function createTestCamera(config: TestCameraConfig = {}): TestCameraHandl
211
229
  store,
212
230
  fs,
213
231
 
214
- subscribeToAppState(listener) {
215
- appStateListeners.add(listener);
216
- return () => {
217
- appStateListeners.delete(listener);
218
- };
219
- },
232
+ subscribeToAppState: listener => appStateListeners.add(listener),
220
233
 
221
234
  setAppState(status) {
222
- for (const listener of appStateListeners) {
223
- listener(status);
224
- }
235
+ appStateListeners.notify(status);
225
236
  },
226
237
 
227
238
  setPermission(state) {
228
239
  permission = state;
229
- for (const listener of permissionListeners) {
230
- listener(state);
231
- }
240
+ permissionListeners.notify(state);
232
241
  },
233
242
 
234
- emitInterruption() {
235
- for (const listener of interruptionListeners) {
236
- listener();
237
- }
238
- // An interruption behaves like the OS taking the camera away: the next
239
- // start attempt fails until the app clears it.
240
- startError = 'interrupted';
243
+ emitInterruption(code: CameraErrorCode = 'interrupted') {
244
+ // The OS took the camera away. A live session hears about it, and the
245
+ // next start attempt fails until the app clears it.
246
+ startError = code;
247
+ interruptionListeners.notify(code);
248
+ },
249
+
250
+ emitPermissionRequestFailure(enabled) {
251
+ requestThrows = enabled;
241
252
  },
242
253
 
243
254
  setCaptureError(code) {
@@ -248,6 +259,7 @@ export function createTestCamera(config: TestCameraConfig = {}): TestCameraHandl
248
259
  startError = code;
249
260
  },
250
261
 
262
+ prompts: () => promptCount,
251
263
  captures: () => produced,
252
264
  torchState: () => torch,
253
265
  startCount: () => starts,