@chipmobilesdk/rn-camera 0.2.0 → 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 +207 -93
- package/README.md +390 -364
- package/package.json +52 -47
- package/skills/sdk-rn-camera/SKILL.md +76 -75
- package/src/config/types.ts +143 -149
- package/src/core/capabilities.ts +41 -44
- package/src/core/captureController.ts +120 -120
- package/src/core/controller.ts +522 -518
- package/src/core/sessionMachine.ts +104 -111
- package/src/diagnostics/errors.ts +94 -92
- package/src/diagnostics/logger.ts +47 -31
- package/src/geometry/coordinateMapping.ts +129 -129
- package/src/index.ts +78 -78
- package/src/native/cameraEngine.ts +7 -2
- package/src/native/visionCameraPeer.ts +30 -0
- package/src/permissions/permissionPort.ts +24 -20
- package/src/react/CameraPreview.tsx +137 -125
- package/src/react/VisionCameraFeed.tsx +181 -178
- package/src/react/hooks.ts +56 -58
- package/src/storage/artifactStore.ts +141 -141
- package/src/testing/conformance.ts +140 -0
- package/src/testing/index.ts +27 -0
- package/src/testing/testCameraAdapter.ts +246 -256
package/src/core/controller.ts
CHANGED
|
@@ -1,518 +1,522 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Camera controller — the object consuming apps actually drive.
|
|
3
|
-
*
|
|
4
|
-
* It owns observable state and orchestrates the three ports (engine,
|
|
5
|
-
* permissions, artifact store). All I/O lives here; the decisions live in
|
|
6
|
-
* `sessionMachine` and `captureController`, which stay pure so they can be
|
|
7
|
-
* tested without a camera.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
type
|
|
14
|
-
type
|
|
15
|
-
type
|
|
16
|
-
type
|
|
17
|
-
type
|
|
18
|
-
type
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
import
|
|
23
|
-
import type
|
|
24
|
-
import type {
|
|
25
|
-
import {
|
|
26
|
-
import {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
export
|
|
38
|
-
|
|
39
|
-
export
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
readonly
|
|
43
|
-
readonly
|
|
44
|
-
readonly
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
*
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
):
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
const
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
let
|
|
119
|
-
let
|
|
120
|
-
let
|
|
121
|
-
let
|
|
122
|
-
let
|
|
123
|
-
let
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
//
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
let
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
//
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
//
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
*
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
permission
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
//
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
)
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
//
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
.
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
*
|
|
306
|
-
*
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
//
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
});
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
//
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
//
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
//
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
if (
|
|
486
|
-
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Camera controller — the object consuming apps actually drive.
|
|
3
|
+
*
|
|
4
|
+
* It owns observable state and orchestrates the three ports (engine,
|
|
5
|
+
* permissions, artifact store). All I/O lives here; the decisions live in
|
|
6
|
+
* `sessionMachine` and `captureController`, which stay pure so they can be
|
|
7
|
+
* tested without a camera.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { appLifecycle, createListenerSet } from '@chipmobilesdk/rn-core';
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
type CameraPermissionState,
|
|
14
|
+
type CameraPosition,
|
|
15
|
+
type CameraState,
|
|
16
|
+
type CapabilityProfile,
|
|
17
|
+
type CapturedImage,
|
|
18
|
+
type CaptureRequest,
|
|
19
|
+
type FlashMode,
|
|
20
|
+
type StartOptions,
|
|
21
|
+
} from '../config/types';
|
|
22
|
+
import { CameraError, isCameraError } from '../diagnostics/errors';
|
|
23
|
+
import { silentLogger, withSdkSinks, type CameraLogger } from '../diagnostics/logger';
|
|
24
|
+
import type { CameraEngine } from '../native/cameraEngine';
|
|
25
|
+
import type { PermissionPort } from '../permissions/permissionPort';
|
|
26
|
+
import type { ArtifactStore } from '../storage/artifactStore';
|
|
27
|
+
import { EMPTY_CAPABILITIES, assertSupported, supportsTorch } from './capabilities';
|
|
28
|
+
import {
|
|
29
|
+
SingleFlight,
|
|
30
|
+
assertCaptureAllowed,
|
|
31
|
+
finalizeResult,
|
|
32
|
+
type CaptureContext,
|
|
33
|
+
} from './captureController';
|
|
34
|
+
import { canStart, initialSession, sessionReducer, type SessionSnapshot } from './sessionMachine';
|
|
35
|
+
|
|
36
|
+
/** 24 hours (FR-029). */
|
|
37
|
+
export const DEFAULT_TEMPORARY_TTL_MS = 24 * 60 * 60 * 1000;
|
|
38
|
+
|
|
39
|
+
export type AppStatus = 'active' | 'background';
|
|
40
|
+
|
|
41
|
+
export interface CameraControllerOptions {
|
|
42
|
+
readonly engine?: CameraEngine;
|
|
43
|
+
readonly permissions?: PermissionPort;
|
|
44
|
+
readonly store?: ArtifactStore | undefined;
|
|
45
|
+
readonly logger?: CameraLogger | undefined;
|
|
46
|
+
readonly temporaryTtlMs?: number;
|
|
47
|
+
/**
|
|
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).
|
|
51
|
+
*/
|
|
52
|
+
readonly subscribeToAppState?: (listener: (status: AppStatus) => void) => () => void;
|
|
53
|
+
/**
|
|
54
|
+
* Set false to skip the sweep that runs when the controller is first used
|
|
55
|
+
* (tests). Defaults to true.
|
|
56
|
+
*/
|
|
57
|
+
readonly sweepOnInit?: boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface CameraController {
|
|
61
|
+
readonly state: CameraState;
|
|
62
|
+
subscribe(listener: (state: CameraState) => void): () => void;
|
|
63
|
+
|
|
64
|
+
getPermission(): Promise<CameraPermissionState>;
|
|
65
|
+
requestPermission(): Promise<CameraPermissionState>;
|
|
66
|
+
|
|
67
|
+
start(options?: StartOptions): Promise<void>;
|
|
68
|
+
pause(): Promise<void>;
|
|
69
|
+
resume(): Promise<void>;
|
|
70
|
+
stop(): Promise<void>;
|
|
71
|
+
|
|
72
|
+
capture(request?: CaptureRequest): Promise<CapturedImage>;
|
|
73
|
+
cleanup(image: CapturedImage | string): Promise<void>;
|
|
74
|
+
|
|
75
|
+
getCapabilities(): Promise<CapabilityProfile>;
|
|
76
|
+
setFlashMode(mode: FlashMode): void;
|
|
77
|
+
setTorch(enabled: boolean): Promise<void>;
|
|
78
|
+
|
|
79
|
+
/** Marks the preview surface mounted, enabling foreground auto-restart. */
|
|
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;
|
|
87
|
+
/**
|
|
88
|
+
* Releases ports, listeners, and the camera. Safe to call twice. Afterwards
|
|
89
|
+
* `start`, `resume`, `capture` and the permission and capability reads reject
|
|
90
|
+
* with `notReady`; `stop`, `pause` and `cleanup` stay safe no-ops so teardown
|
|
91
|
+
* code never has to check.
|
|
92
|
+
*/
|
|
93
|
+
dispose(): Promise<void>;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function defaultAppStateSubscribe(listener: (status: AppStatus) => void): () => void {
|
|
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'));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function createCameraController(options: CameraControllerOptions = {}): CameraController {
|
|
103
|
+
if (!options.engine || !options.permissions) {
|
|
104
|
+
throw new CameraError(
|
|
105
|
+
'unsupportedCapability',
|
|
106
|
+
'createCameraController requires `engine` and `permissions` ports. Use the ' +
|
|
107
|
+
'default adapters, or createTestCamera() in tests.',
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Bound after the guard so the closures below see non-optional ports.
|
|
112
|
+
const engine: CameraEngine = options.engine;
|
|
113
|
+
const permissions: PermissionPort = options.permissions;
|
|
114
|
+
const store = options.store;
|
|
115
|
+
const logger = withSdkSinks(options.logger ?? silentLogger);
|
|
116
|
+
const ttlMs = options.temporaryTtlMs ?? DEFAULT_TEMPORARY_TTL_MS;
|
|
117
|
+
|
|
118
|
+
let session: SessionSnapshot = initialSession;
|
|
119
|
+
let permission: CameraPermissionState = 'undetermined';
|
|
120
|
+
let capabilities: CapabilityProfile | null = null;
|
|
121
|
+
let cameraPosition: CameraPosition = 'back';
|
|
122
|
+
let flashMode: FlashMode = 'off';
|
|
123
|
+
let torchEnabled = false;
|
|
124
|
+
let surfaceMounted = false;
|
|
125
|
+
let disposed = false;
|
|
126
|
+
let appStatus: AppStatus = 'active';
|
|
127
|
+
// Set only when backgrounding cut a running session short. Foregrounding
|
|
128
|
+
// used to restart from any idle or stopped session, so a camera the user had
|
|
129
|
+
// stopped — or never started, or no longer had permission for — came back on
|
|
130
|
+
// with the app.
|
|
131
|
+
let resumeOnForeground = false;
|
|
132
|
+
let releasing: Promise<void> | null = null;
|
|
133
|
+
let warnedUnmanaged = false;
|
|
134
|
+
let detach: (() => void) | null = null;
|
|
135
|
+
|
|
136
|
+
const capture = new SingleFlight();
|
|
137
|
+
const listeners = createListenerSet<[CameraState]>({
|
|
138
|
+
onListenerError: () => logger.log({ type: 'session', code: 'listener-threw' }),
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
function snapshot(): CameraState {
|
|
142
|
+
return {
|
|
143
|
+
session: session.status,
|
|
144
|
+
permission,
|
|
145
|
+
capabilities,
|
|
146
|
+
cameraPosition,
|
|
147
|
+
flashMode,
|
|
148
|
+
torchEnabled,
|
|
149
|
+
capturing: capture.busy,
|
|
150
|
+
error: session.error
|
|
151
|
+
? {
|
|
152
|
+
code: session.error.code,
|
|
153
|
+
recoverable: session.error.recoverable,
|
|
154
|
+
message: session.error.message,
|
|
155
|
+
}
|
|
156
|
+
: null,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
let current = snapshot();
|
|
161
|
+
|
|
162
|
+
function emit(): void {
|
|
163
|
+
current = snapshot();
|
|
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);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function dispatch(event: Parameters<typeof sessionReducer>[1]): void {
|
|
170
|
+
const next = sessionReducer(session, event);
|
|
171
|
+
if (next !== session) {
|
|
172
|
+
session = next;
|
|
173
|
+
logger.log({
|
|
174
|
+
type: 'session',
|
|
175
|
+
code: session.status,
|
|
176
|
+
recoverable: session.error?.recoverable,
|
|
177
|
+
});
|
|
178
|
+
emit();
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Torch must never survive leaving the active state (FR-034b). */
|
|
183
|
+
async function forceTorchOff(): Promise<void> {
|
|
184
|
+
if (!torchEnabled) {
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
torchEnabled = false;
|
|
188
|
+
try {
|
|
189
|
+
await engine.setTorch(false);
|
|
190
|
+
} catch {
|
|
191
|
+
// Torch-off is best effort; the session is going away regardless.
|
|
192
|
+
}
|
|
193
|
+
emit();
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function releaseCamera(): Promise<void> {
|
|
197
|
+
await forceTorchOff();
|
|
198
|
+
// Nothing to release. Going through STOP/STOPPED here turned an idle
|
|
199
|
+
// controller into `stopped` on every preview unmount.
|
|
200
|
+
if (session.status === 'idle' || session.status === 'stopped') {
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
dispatch({ type: 'STOP' });
|
|
204
|
+
try {
|
|
205
|
+
await engine.stop();
|
|
206
|
+
dispatch({ type: 'STOPPED' });
|
|
207
|
+
} catch (error) {
|
|
208
|
+
dispatch({ type: 'FAIL', code: isCameraError(error) ? error.code : 'captureFailed' });
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function startEngine(startOptions: StartOptions = {}): Promise<void> {
|
|
213
|
+
try {
|
|
214
|
+
await engine.start({ cameraPosition: startOptions.cameraPosition ?? cameraPosition });
|
|
215
|
+
dispatch({ type: 'STARTED' });
|
|
216
|
+
} catch (error) {
|
|
217
|
+
dispatch({ type: 'FAIL', code: isCameraError(error) ? error.code : 'cameraUnavailable' });
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function resumeAfterForeground(): void {
|
|
222
|
+
if (disposed || appStatus !== 'active' || !resumeOnForeground) {
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
resumeOnForeground = false;
|
|
226
|
+
if (!surfaceMounted || permission !== 'granted') {
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
dispatch({ type: 'FOREGROUND', surfaceMounted });
|
|
230
|
+
if (session.status === 'starting') {
|
|
231
|
+
startEngine().catch(() => undefined);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function onAppState(status: AppStatus): void {
|
|
236
|
+
appStatus = status;
|
|
237
|
+
if (status === 'background') {
|
|
238
|
+
resumeOnForeground ||= session.status === 'active' || session.status === 'starting';
|
|
239
|
+
dispatch({ type: 'BACKGROUND' });
|
|
240
|
+
releasing = releaseCamera().catch(() => undefined);
|
|
241
|
+
} else {
|
|
242
|
+
// Wait for a release still in flight, or the restart lands on a
|
|
243
|
+
// `stopping` session and is dropped.
|
|
244
|
+
(releasing ?? Promise.resolve()).then(resumeAfterForeground).catch(() => undefined);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Subscribes to permission and app-state changes and runs the sweep, on first
|
|
250
|
+
* use rather than at construction. React may build a controller during a
|
|
251
|
+
* render it then throws away (StrictMode does so on purpose); construction
|
|
252
|
+
* that subscribed leaked a listener pair every time.
|
|
253
|
+
*/
|
|
254
|
+
function attach(): void {
|
|
255
|
+
if (detach || disposed) {
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
const unsubscribePermission = permissions.subscribe(next => {
|
|
259
|
+
permission = next;
|
|
260
|
+
logger.log({ type: 'permission', code: next });
|
|
261
|
+
emit();
|
|
262
|
+
// A revocation while running must tear the session down, not leave a
|
|
263
|
+
// frozen preview on screen (FR-007).
|
|
264
|
+
if (next !== 'granted') {
|
|
265
|
+
resumeOnForeground = false;
|
|
266
|
+
if (session.status !== 'idle' && session.status !== 'stopped') {
|
|
267
|
+
releaseCamera().catch(() => undefined);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
const unsubscribeAppState = (options.subscribeToAppState ?? defaultAppStateSubscribe)(
|
|
272
|
+
onAppState,
|
|
273
|
+
);
|
|
274
|
+
detach = () => {
|
|
275
|
+
unsubscribePermission();
|
|
276
|
+
unsubscribeAppState();
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
// FR-029: sweep our own temp directory once, asynchronously, so it can
|
|
280
|
+
// never add latency to the capture path or the preview start.
|
|
281
|
+
if (store && options.sweepOnInit !== false) {
|
|
282
|
+
store
|
|
283
|
+
.sweep(ttlMs)
|
|
284
|
+
.then(removed => {
|
|
285
|
+
if (removed > 0) {
|
|
286
|
+
logger.log({ type: 'cleanup', code: `swept:${removed}` });
|
|
287
|
+
}
|
|
288
|
+
})
|
|
289
|
+
.catch(() => {
|
|
290
|
+
// A failed sweep must never block startup.
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Entry guard for operations that need a live controller. */
|
|
296
|
+
function live(operation: string): void {
|
|
297
|
+
if (disposed) {
|
|
298
|
+
throw new CameraError('notReady', `${operation}() called on a disposed camera controller`);
|
|
299
|
+
}
|
|
300
|
+
attach();
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Puts a package-temporary capture under the store so `cleanup()` and the
|
|
305
|
+
* sweep can reach it. Never throws: the capture itself succeeded, and a file
|
|
306
|
+
* the store cannot manage is still a usable photo.
|
|
307
|
+
*/
|
|
308
|
+
async function manage(image: CapturedImage): Promise<CapturedImage> {
|
|
309
|
+
if (image.ownership !== 'packageTemporary') {
|
|
310
|
+
return image;
|
|
311
|
+
}
|
|
312
|
+
if (store?.adopt) {
|
|
313
|
+
try {
|
|
314
|
+
return { ...image, uri: await store.adopt(image.uri) };
|
|
315
|
+
} catch {
|
|
316
|
+
logger.log({ type: 'cleanup', code: 'adopt-failed' });
|
|
317
|
+
return image;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
if (!store?.owns(image.uri) && !warnedUnmanaged) {
|
|
321
|
+
warnedUnmanaged = true;
|
|
322
|
+
logger.log({
|
|
323
|
+
type: 'cleanup',
|
|
324
|
+
code: store ? 'unmanaged:store-cannot-adopt' : 'unmanaged:no-store',
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
return image;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const controller: CameraController = {
|
|
331
|
+
get state() {
|
|
332
|
+
return current;
|
|
333
|
+
},
|
|
334
|
+
|
|
335
|
+
subscribe(listener) {
|
|
336
|
+
if (disposed) {
|
|
337
|
+
return () => undefined;
|
|
338
|
+
}
|
|
339
|
+
attach();
|
|
340
|
+
return listeners.add(listener);
|
|
341
|
+
},
|
|
342
|
+
|
|
343
|
+
async getPermission() {
|
|
344
|
+
live('getPermission');
|
|
345
|
+
permission = await permissions.getStatus();
|
|
346
|
+
emit();
|
|
347
|
+
return permission;
|
|
348
|
+
},
|
|
349
|
+
|
|
350
|
+
async requestPermission() {
|
|
351
|
+
live('requestPermission');
|
|
352
|
+
permission = 'requesting';
|
|
353
|
+
emit();
|
|
354
|
+
permission = await permissions.request();
|
|
355
|
+
emit();
|
|
356
|
+
return permission;
|
|
357
|
+
},
|
|
358
|
+
|
|
359
|
+
async start(startOptions: StartOptions = {}) {
|
|
360
|
+
live('start');
|
|
361
|
+
if (permission !== 'granted') {
|
|
362
|
+
dispatch({ type: 'FAIL', code: 'permissionDenied' });
|
|
363
|
+
throw new CameraError('permissionDenied', 'Camera permission is not granted');
|
|
364
|
+
}
|
|
365
|
+
if (!canStart(session.status)) {
|
|
366
|
+
throw new CameraError('sessionConflict', `Session already ${session.status}`);
|
|
367
|
+
}
|
|
368
|
+
if (startOptions.cameraPosition) {
|
|
369
|
+
cameraPosition = startOptions.cameraPosition;
|
|
370
|
+
}
|
|
371
|
+
dispatch({ type: 'START' });
|
|
372
|
+
await startEngine(startOptions);
|
|
373
|
+
if (session.error) {
|
|
374
|
+
throw session.error;
|
|
375
|
+
}
|
|
376
|
+
},
|
|
377
|
+
|
|
378
|
+
async pause() {
|
|
379
|
+
if (disposed) {
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
await forceTorchOff();
|
|
383
|
+
dispatch({ type: 'PAUSE' });
|
|
384
|
+
},
|
|
385
|
+
|
|
386
|
+
async resume() {
|
|
387
|
+
live('resume');
|
|
388
|
+
dispatch({ type: 'RESUME' });
|
|
389
|
+
if (session.status === 'starting') {
|
|
390
|
+
await startEngine();
|
|
391
|
+
}
|
|
392
|
+
},
|
|
393
|
+
|
|
394
|
+
async stop() {
|
|
395
|
+
// An explicit stop is the user's decision; foregrounding must not undo it.
|
|
396
|
+
resumeOnForeground = false;
|
|
397
|
+
if (disposed) {
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
await releaseCamera();
|
|
401
|
+
},
|
|
402
|
+
|
|
403
|
+
async capture(request: CaptureRequest = {}) {
|
|
404
|
+
live('capture');
|
|
405
|
+
const context: CaptureContext = {
|
|
406
|
+
engine,
|
|
407
|
+
sessionStatus: session.status,
|
|
408
|
+
permissionGranted: permission === 'granted',
|
|
409
|
+
capabilities,
|
|
410
|
+
cameraPosition,
|
|
411
|
+
flashMode,
|
|
412
|
+
};
|
|
413
|
+
// Guards run before the single-flight slot is taken so a rejected
|
|
414
|
+
// request cannot block a subsequent legal one.
|
|
415
|
+
assertCaptureAllowed(context, request);
|
|
416
|
+
|
|
417
|
+
try {
|
|
418
|
+
const result = await capture.run(async () => {
|
|
419
|
+
emit();
|
|
420
|
+
const raw = await engine.capture({
|
|
421
|
+
...request,
|
|
422
|
+
flashMode: request.flashMode ?? flashMode,
|
|
423
|
+
cameraPosition: request.cameraPosition ?? cameraPosition,
|
|
424
|
+
});
|
|
425
|
+
return manage(finalizeResult(raw, request, context));
|
|
426
|
+
});
|
|
427
|
+
logger.log({ type: 'capture', code: 'succeeded' });
|
|
428
|
+
return result;
|
|
429
|
+
} catch (error) {
|
|
430
|
+
// The contract promises a CameraError; a raw native error carries
|
|
431
|
+
// no code to branch on and its message may hold a file path.
|
|
432
|
+
const failure = isCameraError(error)
|
|
433
|
+
? error
|
|
434
|
+
: new CameraError('captureFailed', 'The camera failed to capture a photo');
|
|
435
|
+
logger.log({ type: 'capture', code: failure.code, recoverable: failure.recoverable });
|
|
436
|
+
throw failure;
|
|
437
|
+
} finally {
|
|
438
|
+
// Without this a failed capture left `capturing: true` published
|
|
439
|
+
// until some unrelated change emitted again.
|
|
440
|
+
emit();
|
|
441
|
+
}
|
|
442
|
+
},
|
|
443
|
+
|
|
444
|
+
async cleanup(image: CapturedImage | string) {
|
|
445
|
+
const uri = typeof image === 'string' ? image : image.uri;
|
|
446
|
+
if (!store) {
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
// Refuse anything we did not create rather than deleting an arbitrary
|
|
450
|
+
// path, which also covers every appOwned artifact (FR-026b, FR-028).
|
|
451
|
+
if (!store.owns(uri)) {
|
|
452
|
+
logger.log({ type: 'cleanup', code: 'refused-not-owned' });
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
await store.remove(uri);
|
|
456
|
+
logger.log({ type: 'cleanup', code: 'removed' });
|
|
457
|
+
},
|
|
458
|
+
|
|
459
|
+
async getCapabilities() {
|
|
460
|
+
live('getCapabilities');
|
|
461
|
+
try {
|
|
462
|
+
capabilities = await engine.getCapabilities();
|
|
463
|
+
} catch {
|
|
464
|
+
capabilities = EMPTY_CAPABILITIES;
|
|
465
|
+
}
|
|
466
|
+
logger.log({
|
|
467
|
+
type: 'capability',
|
|
468
|
+
code: capabilities.hasCameraHardware ? 'available' : 'no-hardware',
|
|
469
|
+
});
|
|
470
|
+
emit();
|
|
471
|
+
return capabilities;
|
|
472
|
+
},
|
|
473
|
+
|
|
474
|
+
setFlashMode(mode: FlashMode) {
|
|
475
|
+
assertSupported(mode === 'off' || capabilities?.hasFlash === true, `flash mode "${mode}"`);
|
|
476
|
+
flashMode = mode;
|
|
477
|
+
emit();
|
|
478
|
+
},
|
|
479
|
+
|
|
480
|
+
async setTorch(enabled: boolean) {
|
|
481
|
+
if (disposed && !enabled) {
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
live('setTorch');
|
|
485
|
+
if (enabled) {
|
|
486
|
+
assertSupported(supportsTorch(capabilities), 'torch');
|
|
487
|
+
}
|
|
488
|
+
await engine.setTorch(enabled);
|
|
489
|
+
torchEnabled = enabled;
|
|
490
|
+
emit();
|
|
491
|
+
},
|
|
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
|
+
|
|
501
|
+
setSurfaceMounted(mounted: boolean) {
|
|
502
|
+
if (disposed) {
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
surfaceMounted = mounted;
|
|
506
|
+
},
|
|
507
|
+
|
|
508
|
+
async dispose() {
|
|
509
|
+
if (disposed) {
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
disposed = true;
|
|
513
|
+
resumeOnForeground = false;
|
|
514
|
+
detach?.();
|
|
515
|
+
detach = null;
|
|
516
|
+
await releaseCamera();
|
|
517
|
+
listeners.clear();
|
|
518
|
+
},
|
|
519
|
+
};
|
|
520
|
+
|
|
521
|
+
return controller;
|
|
522
|
+
}
|