@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.
- package/CHANGELOG.md +61 -2
- package/README.md +154 -131
- package/package.json +10 -9
- package/skills/sdk-rn-camera/SKILL.md +75 -0
- package/src/core/captureController.ts +11 -9
- package/src/core/controller.ts +186 -53
- package/src/react/CameraPreview.tsx +7 -2
- package/src/react/VisionCameraFeed.tsx +178 -155
- package/src/react/hooks.ts +24 -10
- package/src/storage/artifactStore.ts +37 -4
- package/src/testing/testCameraAdapter.ts +7 -0
package/src/core/controller.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
230
|
-
|
|
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
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
-
|
|
378
|
-
|
|
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
|
-
|
|
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 () => {
|