@chipmobilesdk/rn-camera 0.1.0 → 0.2.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.
@@ -47,7 +47,10 @@ export interface CameraControllerOptions {
47
47
  * Injectable so lifecycle rules are testable without an app (FR-015).
48
48
  */
49
49
  readonly subscribeToAppState?: (listener: (status: AppStatus) => void) => () => void;
50
- /** Set false to skip the initialization sweep (tests). Defaults to true. */
50
+ /**
51
+ * Set false to skip the sweep that runs when the controller is first used
52
+ * (tests). Defaults to true.
53
+ */
51
54
  readonly sweepOnInit?: boolean;
52
55
  }
53
56
 
@@ -72,7 +75,12 @@ export interface CameraController {
72
75
 
73
76
  /** Marks the preview surface mounted, enabling foreground auto-restart. */
74
77
  setSurfaceMounted(mounted: boolean): void;
75
- /** Releases ports, listeners, and the camera. Safe to call twice. */
78
+ /**
79
+ * Releases ports, listeners, and the camera. Safe to call twice. Afterwards
80
+ * `start`, `resume`, `capture` and the permission and capability reads reject
81
+ * with `notReady`; `stop`, `pause` and `cleanup` stay safe no-ops so teardown
82
+ * code never has to check.
83
+ */
76
84
  dispose(): Promise<void>;
77
85
  }
78
86
 
@@ -112,6 +120,15 @@ export function createCameraController(
112
120
  let torchEnabled = false;
113
121
  let surfaceMounted = false;
114
122
  let disposed = false;
123
+ let appStatus: AppStatus = 'active';
124
+ // Set only when backgrounding cut a running session short. Foregrounding
125
+ // used to restart from any idle or stopped session, so a camera the user had
126
+ // stopped — or never started, or no longer had permission for — came back on
127
+ // with the app.
128
+ let resumeOnForeground = false;
129
+ let releasing: Promise<void> | null = null;
130
+ let warnedUnmanaged = false;
131
+ let detach: (() => void) | null = null;
115
132
 
116
133
  const capture = new SingleFlight();
117
134
  const listeners = new Set<(state: CameraState) => void>();
@@ -140,7 +157,13 @@ export function createCameraController(
140
157
  function emit(): void {
141
158
  current = snapshot();
142
159
  for (const listener of listeners) {
143
- listener(current);
160
+ // One throwing subscriber must not starve the others, nor unwind into
161
+ // the transition that emitted — that left the controller half-updated.
162
+ try {
163
+ listener(current);
164
+ } catch {
165
+ logger.log({ type: 'session', code: 'listener-threw' });
166
+ }
144
167
  }
145
168
  }
146
169
 
@@ -173,6 +196,11 @@ export function createCameraController(
173
196
 
174
197
  async function releaseCamera(): Promise<void> {
175
198
  await forceTorchOff();
199
+ // Nothing to release. Going through STOP/STOPPED here turned an idle
200
+ // controller into `stopped` on every preview unmount.
201
+ if (session.status === 'idle' || session.status === 'stopped') {
202
+ return;
203
+ }
176
204
  dispatch({ type: 'STOP' });
177
205
  try {
178
206
  await engine.stop();
@@ -182,31 +210,6 @@ export function createCameraController(
182
210
  }
183
211
  }
184
212
 
185
- const unsubscribePermission = permissions.subscribe(next => {
186
- permission = next;
187
- logger.log({ type: 'permission', code: next });
188
- emit();
189
- // A revocation while running must tear the session down, not leave a
190
- // frozen preview on screen (FR-007).
191
- if (next !== 'granted' && session.status !== 'idle' && session.status !== 'stopped') {
192
- releaseCamera().catch(() => undefined);
193
- }
194
- });
195
-
196
- const unsubscribeAppState = (options.subscribeToAppState ?? defaultAppStateSubscribe)(
197
- status => {
198
- if (status === 'background') {
199
- dispatch({ type: 'BACKGROUND' });
200
- releaseCamera().catch(() => undefined);
201
- } else {
202
- dispatch({ type: 'FOREGROUND', surfaceMounted });
203
- if (surfaceMounted && session.status === 'starting') {
204
- startEngine().catch(() => undefined);
205
- }
206
- }
207
- },
208
- );
209
-
210
213
  async function startEngine(startOptions: StartOptions = {}): Promise<void> {
211
214
  try {
212
215
  await engine.start({ cameraPosition: startOptions.cameraPosition ?? cameraPosition });
@@ -216,19 +219,110 @@ export function createCameraController(
216
219
  }
217
220
  }
218
221
 
219
- // FR-029: sweep our own temp directory once per process, asynchronously, so
220
- // it can never add latency to the capture path or the preview start.
221
- if (store && options.sweepOnInit !== false) {
222
- store
223
- .sweep(ttlMs)
224
- .then(removed => {
225
- if (removed > 0) {
226
- logger.log({ type: 'cleanup', code: `swept:${removed}` });
222
+ function resumeAfterForeground(): void {
223
+ if (disposed || appStatus !== 'active' || !resumeOnForeground) {
224
+ return;
225
+ }
226
+ resumeOnForeground = false;
227
+ if (!surfaceMounted || permission !== 'granted') {
228
+ return;
229
+ }
230
+ dispatch({ type: 'FOREGROUND', surfaceMounted });
231
+ if (session.status === 'starting') {
232
+ startEngine().catch(() => undefined);
233
+ }
234
+ }
235
+
236
+ function onAppState(status: AppStatus): void {
237
+ appStatus = status;
238
+ if (status === 'background') {
239
+ resumeOnForeground ||= session.status === 'active' || session.status === 'starting';
240
+ dispatch({ type: 'BACKGROUND' });
241
+ releasing = releaseCamera().catch(() => undefined);
242
+ } else {
243
+ // Wait for a release still in flight, or the restart lands on a
244
+ // `stopping` session and is dropped.
245
+ (releasing ?? Promise.resolve()).then(resumeAfterForeground).catch(() => undefined);
246
+ }
247
+ }
248
+
249
+ /**
250
+ * Subscribes to permission and app-state changes and runs the sweep, on first
251
+ * use rather than at construction. React may build a controller during a
252
+ * render it then throws away (StrictMode does so on purpose); construction
253
+ * that subscribed leaked a listener pair every time.
254
+ */
255
+ function attach(): void {
256
+ if (detach || disposed) {
257
+ return;
258
+ }
259
+ const unsubscribePermission = permissions.subscribe(next => {
260
+ permission = next;
261
+ logger.log({ type: 'permission', code: next });
262
+ emit();
263
+ // A revocation while running must tear the session down, not leave a
264
+ // frozen preview on screen (FR-007).
265
+ if (next !== 'granted') {
266
+ resumeOnForeground = false;
267
+ if (session.status !== 'idle' && session.status !== 'stopped') {
268
+ releaseCamera().catch(() => undefined);
227
269
  }
228
- })
229
- .catch(() => {
230
- // A failed sweep must never block startup.
231
- });
270
+ }
271
+ });
272
+ const unsubscribeAppState = (options.subscribeToAppState ?? defaultAppStateSubscribe)(
273
+ onAppState,
274
+ );
275
+ detach = () => {
276
+ unsubscribePermission();
277
+ unsubscribeAppState();
278
+ };
279
+
280
+ // FR-029: sweep our own temp directory once, asynchronously, so it can
281
+ // never add latency to the capture path or the preview start.
282
+ if (store && options.sweepOnInit !== false) {
283
+ store
284
+ .sweep(ttlMs)
285
+ .then(removed => {
286
+ if (removed > 0) {
287
+ logger.log({ type: 'cleanup', code: `swept:${removed}` });
288
+ }
289
+ })
290
+ .catch(() => {
291
+ // A failed sweep must never block startup.
292
+ });
293
+ }
294
+ }
295
+
296
+ /** Entry guard for operations that need a live controller. */
297
+ function live(operation: string): void {
298
+ if (disposed) {
299
+ throw new CameraError('notReady', `${operation}() called on a disposed camera controller`);
300
+ }
301
+ attach();
302
+ }
303
+
304
+ /**
305
+ * Puts a package-temporary capture under the store so `cleanup()` and the
306
+ * sweep can reach it. Never throws: the capture itself succeeded, and a file
307
+ * the store cannot manage is still a usable photo.
308
+ */
309
+ async function manage(image: CapturedImage): Promise<CapturedImage> {
310
+ if (image.ownership !== 'packageTemporary') {
311
+ return image;
312
+ }
313
+ if (store?.adopt) {
314
+ try {
315
+ return { ...image, uri: await store.adopt(image.uri) };
316
+ } catch {
317
+ logger.log({ type: 'cleanup', code: 'adopt-failed' });
318
+ return image;
319
+ }
320
+ }
321
+ if (!store?.owns(image.uri) && !warnedUnmanaged) {
322
+ warnedUnmanaged = true;
323
+ logger.log({ type: 'cleanup', code: store ? 'unmanaged:store-cannot-adopt' : 'unmanaged:no-store' });
324
+ }
325
+ return image;
232
326
  }
233
327
 
234
328
  const controller: CameraController = {
@@ -237,6 +331,10 @@ export function createCameraController(
237
331
  },
238
332
 
239
333
  subscribe(listener) {
334
+ if (disposed) {
335
+ return () => undefined;
336
+ }
337
+ attach();
240
338
  listeners.add(listener);
241
339
  return () => {
242
340
  listeners.delete(listener);
@@ -244,12 +342,14 @@ export function createCameraController(
244
342
  },
245
343
 
246
344
  async getPermission() {
345
+ live('getPermission');
247
346
  permission = await permissions.getStatus();
248
347
  emit();
249
348
  return permission;
250
349
  },
251
350
 
252
351
  async requestPermission() {
352
+ live('requestPermission');
253
353
  permission = 'requesting';
254
354
  emit();
255
355
  permission = await permissions.request();
@@ -258,6 +358,7 @@ export function createCameraController(
258
358
  },
259
359
 
260
360
  async start(startOptions: StartOptions = {}) {
361
+ live('start');
261
362
  if (permission !== 'granted') {
262
363
  dispatch({ type: 'FAIL', code: 'permissionDenied' });
263
364
  throw new CameraError('permissionDenied', 'Camera permission is not granted');
@@ -276,11 +377,15 @@ export function createCameraController(
276
377
  },
277
378
 
278
379
  async pause() {
380
+ if (disposed) {
381
+ return;
382
+ }
279
383
  await forceTorchOff();
280
384
  dispatch({ type: 'PAUSE' });
281
385
  },
282
386
 
283
387
  async resume() {
388
+ live('resume');
284
389
  dispatch({ type: 'RESUME' });
285
390
  if (session.status === 'starting') {
286
391
  await startEngine();
@@ -288,10 +393,16 @@ export function createCameraController(
288
393
  },
289
394
 
290
395
  async stop() {
396
+ // An explicit stop is the user's decision; foregrounding must not undo it.
397
+ resumeOnForeground = false;
398
+ if (disposed) {
399
+ return;
400
+ }
291
401
  await releaseCamera();
292
402
  },
293
403
 
294
404
  async capture(request: CaptureRequest = {}) {
405
+ live('capture');
295
406
  const context: CaptureContext = {
296
407
  engine,
297
408
  sessionStatus: session.status,
@@ -304,18 +415,31 @@ export function createCameraController(
304
415
  // request cannot block a subsequent legal one.
305
416
  assertCaptureAllowed(context, request);
306
417
 
307
- const result = await capture.run(async () => {
308
- emit();
309
- const raw = await engine.capture({
310
- ...request,
311
- flashMode: request.flashMode ?? flashMode,
312
- cameraPosition: request.cameraPosition ?? cameraPosition,
418
+ try {
419
+ const result = await capture.run(async () => {
420
+ emit();
421
+ const raw = await engine.capture({
422
+ ...request,
423
+ flashMode: request.flashMode ?? flashMode,
424
+ cameraPosition: request.cameraPosition ?? cameraPosition,
425
+ });
426
+ return manage(finalizeResult(raw, request, context));
313
427
  });
314
- return finalizeResult(raw, request, context);
315
- });
316
- logger.log({ type: 'capture', code: 'succeeded' });
317
- emit();
318
- return result;
428
+ logger.log({ type: 'capture', code: 'succeeded' });
429
+ return result;
430
+ } catch (error) {
431
+ // The contract promises a CameraError; a raw native error carries
432
+ // no code to branch on and its message may hold a file path.
433
+ const failure = isCameraError(error)
434
+ ? error
435
+ : new CameraError('captureFailed', 'The camera failed to capture a photo');
436
+ logger.log({ type: 'capture', code: failure.code, recoverable: failure.recoverable });
437
+ throw failure;
438
+ } finally {
439
+ // Without this a failed capture left `capturing: true` published
440
+ // until some unrelated change emitted again.
441
+ emit();
442
+ }
319
443
  },
320
444
 
321
445
  async cleanup(image: CapturedImage | string) {
@@ -334,6 +458,7 @@ export function createCameraController(
334
458
  },
335
459
 
336
460
  async getCapabilities() {
461
+ live('getCapabilities');
337
462
  try {
338
463
  capabilities = await engine.getCapabilities();
339
464
  } catch {
@@ -357,6 +482,10 @@ export function createCameraController(
357
482
  },
358
483
 
359
484
  async setTorch(enabled: boolean) {
485
+ if (disposed && !enabled) {
486
+ return;
487
+ }
488
+ live('setTorch');
360
489
  if (enabled) {
361
490
  assertSupported(supportsTorch(capabilities), 'torch');
362
491
  }
@@ -366,6 +495,9 @@ export function createCameraController(
366
495
  },
367
496
 
368
497
  setSurfaceMounted(mounted: boolean) {
498
+ if (disposed) {
499
+ return;
500
+ }
369
501
  surfaceMounted = mounted;
370
502
  },
371
503
 
@@ -374,8 +506,9 @@ export function createCameraController(
374
506
  return;
375
507
  }
376
508
  disposed = true;
377
- unsubscribePermission();
378
- unsubscribeAppState();
509
+ resumeOnForeground = false;
510
+ detach?.();
511
+ detach = null;
379
512
  await releaseCamera();
380
513
  listeners.clear();
381
514
  },
@@ -18,6 +18,7 @@ import {
18
18
 
19
19
  import type { PreviewGeometry, ResizeMode } from '../config/types';
20
20
  import type { CameraController } from '../core/controller';
21
+ import { useCameraState } from './hooks';
21
22
  import type { VisionCameraFeedProps } from './VisionCameraFeed';
22
23
 
23
24
  export interface CameraPreviewProps {
@@ -46,10 +47,14 @@ export const CameraPreview: React.FC<CameraPreviewProps> = ({
46
47
  children,
47
48
  }) => {
48
49
  const [layout, setLayout] = useState<{ width: number; height: number } | null>(null);
49
- const state = controller.state;
50
+ // Subscribed, not read once: a plain `controller.state` read only refreshed
51
+ // when the parent happened to re-render, so the feed could stay unmounted
52
+ // after the session became active (or stay mounted after it stopped).
53
+ const state = useCameraState(controller);
50
54
 
51
55
  // Tell the controller a surface exists so foregrounding can restart the
52
- // session, and clear it on unmount so the camera is released (FR-016).
56
+ // session, and clear it on unmount so the camera is released (FR-016). Both
57
+ // calls are no-ops on an idle or disposed controller.
53
58
  useEffect(() => {
54
59
  controller.setSurfaceMounted(true);
55
60
  return () => {