@chipmobilesdk/rn-camera 0.1.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 +34 -0
- package/README.md +341 -0
- package/package.json +46 -0
- package/src/config/types.ts +149 -0
- package/src/core/capabilities.ts +44 -0
- package/src/core/captureController.ts +118 -0
- package/src/core/controller.ts +385 -0
- package/src/core/sessionMachine.ts +111 -0
- package/src/diagnostics/errors.ts +92 -0
- package/src/diagnostics/logger.ts +31 -0
- package/src/geometry/coordinateMapping.ts +129 -0
- package/src/index.ts +78 -0
- package/src/native/cameraEngine.ts +113 -0
- package/src/native/visionSurface.ts +34 -0
- package/src/permissions/permissionPort.ts +115 -0
- package/src/react/CameraPreview.tsx +120 -0
- package/src/react/VisionCameraFeed.tsx +155 -0
- package/src/react/hooks.ts +44 -0
- package/src/storage/artifactStore.ts +108 -0
- package/src/testing/testCameraAdapter.ts +249 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Capture guards, single-flight enforcement, and metadata assembly
|
|
3
|
+
* (FR-020..FR-026b).
|
|
4
|
+
*
|
|
5
|
+
* The single-flight guard rejects rather than queues or coalesces. A queue
|
|
6
|
+
* turns a double-tapped shutter into two photos the user never asked for, and
|
|
7
|
+
* coalescing hides a deliberate second capture behind an accidental one — so a
|
|
8
|
+
* fast, explicit `captureInProgress` error is the only option that lets the app
|
|
9
|
+
* decide (clarification session 2026-07-26).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
known,
|
|
14
|
+
unavailable,
|
|
15
|
+
type CameraPosition,
|
|
16
|
+
type CapabilityProfile,
|
|
17
|
+
type CapturedImage,
|
|
18
|
+
type CaptureRequest,
|
|
19
|
+
type FlashMode,
|
|
20
|
+
type Maybe,
|
|
21
|
+
type SessionStatus,
|
|
22
|
+
} from '../config/types';
|
|
23
|
+
import { CameraError } from '../diagnostics/errors';
|
|
24
|
+
import type { CameraEngine } from '../native/cameraEngine';
|
|
25
|
+
import { assertSupported, supportsFlashMode, supportsPosition } from './capabilities';
|
|
26
|
+
import { canCapture } from './sessionMachine';
|
|
27
|
+
|
|
28
|
+
export const DEFAULT_QUALITY = 0.9;
|
|
29
|
+
|
|
30
|
+
export function clampQuality(quality: number | undefined): number {
|
|
31
|
+
if (quality === undefined || Number.isNaN(quality)) {
|
|
32
|
+
return DEFAULT_QUALITY;
|
|
33
|
+
}
|
|
34
|
+
return Math.min(1, Math.max(0, quality));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function clampMaxDimension(maxDimension: number | undefined): Maybe<number> {
|
|
38
|
+
if (maxDimension === undefined || !Number.isFinite(maxDimension) || maxDimension <= 0) {
|
|
39
|
+
return unavailable();
|
|
40
|
+
}
|
|
41
|
+
return known(Math.floor(maxDimension));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface CaptureContext {
|
|
45
|
+
readonly engine: CameraEngine;
|
|
46
|
+
readonly sessionStatus: SessionStatus;
|
|
47
|
+
readonly permissionGranted: boolean;
|
|
48
|
+
readonly capabilities: CapabilityProfile | null;
|
|
49
|
+
readonly cameraPosition: CameraPosition;
|
|
50
|
+
readonly flashMode: FlashMode;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Validates a capture request against permission, readiness, and capability.
|
|
55
|
+
* Throws before any artifact could be created, so a rejected capture never
|
|
56
|
+
* leaves a file behind (FR-020).
|
|
57
|
+
*/
|
|
58
|
+
export function assertCaptureAllowed(context: CaptureContext, request: CaptureRequest): void {
|
|
59
|
+
if (!context.permissionGranted) {
|
|
60
|
+
throw new CameraError('permissionDenied', 'Camera permission is not granted');
|
|
61
|
+
}
|
|
62
|
+
if (!canCapture(context.sessionStatus)) {
|
|
63
|
+
throw new CameraError('notReady', `Camera is not ready (session: ${context.sessionStatus})`);
|
|
64
|
+
}
|
|
65
|
+
const flashMode = request.flashMode ?? context.flashMode;
|
|
66
|
+
assertSupported(supportsFlashMode(context.capabilities, flashMode), `flash mode "${flashMode}"`);
|
|
67
|
+
|
|
68
|
+
const position = request.cameraPosition ?? context.cameraPosition;
|
|
69
|
+
assertSupported(supportsPosition(context.capabilities, position), `${position} camera`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Normalizes whatever the engine returned into the published contract, filling
|
|
74
|
+
* in effective hint values and marking undeterminable fields explicitly
|
|
75
|
+
* unavailable rather than guessing them (FR-022, FR-023).
|
|
76
|
+
*/
|
|
77
|
+
export function finalizeResult(
|
|
78
|
+
raw: CapturedImage,
|
|
79
|
+
request: CaptureRequest,
|
|
80
|
+
context: CaptureContext,
|
|
81
|
+
): CapturedImage {
|
|
82
|
+
const ownership = request.destination ? 'appOwned' : 'packageTemporary';
|
|
83
|
+
return {
|
|
84
|
+
...raw,
|
|
85
|
+
ownership,
|
|
86
|
+
cameraPosition: raw.cameraPosition ?? request.cameraPosition ?? context.cameraPosition,
|
|
87
|
+
timestamp: raw.timestamp ?? Date.now(),
|
|
88
|
+
effectiveQuality: raw.effectiveQuality ?? clampQuality(request.quality),
|
|
89
|
+
effectiveMaxDimension: raw.effectiveMaxDimension ?? clampMaxDimension(request.maxDimension),
|
|
90
|
+
fileSize: raw.fileSize ?? unavailable(),
|
|
91
|
+
orientation: raw.orientation ?? unavailable(),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Serializes captures. Holding the in-flight promise rather than a boolean
|
|
97
|
+
* means the guard cannot be defeated by two synchronous calls in the same tick.
|
|
98
|
+
*/
|
|
99
|
+
export class SingleFlight {
|
|
100
|
+
private inFlight: Promise<CapturedImage> | null = null;
|
|
101
|
+
|
|
102
|
+
get busy(): boolean {
|
|
103
|
+
return this.inFlight !== null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async run(operation: () => Promise<CapturedImage>): Promise<CapturedImage> {
|
|
107
|
+
if (this.inFlight) {
|
|
108
|
+
throw new CameraError('captureInProgress', 'A capture is already in progress');
|
|
109
|
+
}
|
|
110
|
+
const promise = operation();
|
|
111
|
+
this.inFlight = promise;
|
|
112
|
+
try {
|
|
113
|
+
return await promise;
|
|
114
|
+
} finally {
|
|
115
|
+
this.inFlight = null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -0,0 +1,385 @@
|
|
|
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
|
+
type CameraPermissionState,
|
|
12
|
+
type CameraPosition,
|
|
13
|
+
type CameraState,
|
|
14
|
+
type CapabilityProfile,
|
|
15
|
+
type CapturedImage,
|
|
16
|
+
type CaptureRequest,
|
|
17
|
+
type FlashMode,
|
|
18
|
+
type StartOptions,
|
|
19
|
+
} from '../config/types';
|
|
20
|
+
import { CameraError, isCameraError } from '../diagnostics/errors';
|
|
21
|
+
import { silentLogger, type CameraLogger } from '../diagnostics/logger';
|
|
22
|
+
import type { CameraEngine } from '../native/cameraEngine';
|
|
23
|
+
import type { PermissionPort } from '../permissions/permissionPort';
|
|
24
|
+
import type { ArtifactStore } from '../storage/artifactStore';
|
|
25
|
+
import { EMPTY_CAPABILITIES, assertSupported, supportsTorch } from './capabilities';
|
|
26
|
+
import {
|
|
27
|
+
SingleFlight,
|
|
28
|
+
assertCaptureAllowed,
|
|
29
|
+
finalizeResult,
|
|
30
|
+
type CaptureContext,
|
|
31
|
+
} from './captureController';
|
|
32
|
+
import { canStart, initialSession, sessionReducer, type SessionSnapshot } from './sessionMachine';
|
|
33
|
+
|
|
34
|
+
/** 24 hours (FR-029). */
|
|
35
|
+
export const DEFAULT_TEMPORARY_TTL_MS = 24 * 60 * 60 * 1000;
|
|
36
|
+
|
|
37
|
+
export type AppStatus = 'active' | 'background';
|
|
38
|
+
|
|
39
|
+
export interface CameraControllerOptions {
|
|
40
|
+
readonly engine?: CameraEngine;
|
|
41
|
+
readonly permissions?: PermissionPort;
|
|
42
|
+
readonly store?: ArtifactStore;
|
|
43
|
+
readonly logger?: CameraLogger;
|
|
44
|
+
readonly temporaryTtlMs?: number;
|
|
45
|
+
/**
|
|
46
|
+
* App foreground/background source. Defaults to React Native `AppState`.
|
|
47
|
+
* Injectable so lifecycle rules are testable without an app (FR-015).
|
|
48
|
+
*/
|
|
49
|
+
readonly subscribeToAppState?: (listener: (status: AppStatus) => void) => () => void;
|
|
50
|
+
/** Set false to skip the initialization sweep (tests). Defaults to true. */
|
|
51
|
+
readonly sweepOnInit?: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface CameraController {
|
|
55
|
+
readonly state: CameraState;
|
|
56
|
+
subscribe(listener: (state: CameraState) => void): () => void;
|
|
57
|
+
|
|
58
|
+
getPermission(): Promise<CameraPermissionState>;
|
|
59
|
+
requestPermission(): Promise<CameraPermissionState>;
|
|
60
|
+
|
|
61
|
+
start(options?: StartOptions): Promise<void>;
|
|
62
|
+
pause(): Promise<void>;
|
|
63
|
+
resume(): Promise<void>;
|
|
64
|
+
stop(): Promise<void>;
|
|
65
|
+
|
|
66
|
+
capture(request?: CaptureRequest): Promise<CapturedImage>;
|
|
67
|
+
cleanup(image: CapturedImage | string): Promise<void>;
|
|
68
|
+
|
|
69
|
+
getCapabilities(): Promise<CapabilityProfile>;
|
|
70
|
+
setFlashMode(mode: FlashMode): void;
|
|
71
|
+
setTorch(enabled: boolean): Promise<void>;
|
|
72
|
+
|
|
73
|
+
/** Marks the preview surface mounted, enabling foreground auto-restart. */
|
|
74
|
+
setSurfaceMounted(mounted: boolean): void;
|
|
75
|
+
/** Releases ports, listeners, and the camera. Safe to call twice. */
|
|
76
|
+
dispose(): Promise<void>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function defaultAppStateSubscribe(listener: (status: AppStatus) => void): () => void {
|
|
80
|
+
// Lazy require so importing the package outside React Native (for example in
|
|
81
|
+
// a Node-based test runner) does not pull in the RN runtime.
|
|
82
|
+
const { AppState } = require('react-native');
|
|
83
|
+
const subscription = AppState.addEventListener('change', (next: string) => {
|
|
84
|
+
listener(next === 'active' ? 'active' : 'background');
|
|
85
|
+
});
|
|
86
|
+
return () => subscription.remove();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function createCameraController(
|
|
90
|
+
options: CameraControllerOptions = {},
|
|
91
|
+
): CameraController {
|
|
92
|
+
if (!options.engine || !options.permissions) {
|
|
93
|
+
throw new CameraError(
|
|
94
|
+
'unsupportedCapability',
|
|
95
|
+
'createCameraController requires `engine` and `permissions` ports. Use the ' +
|
|
96
|
+
'default adapters, or createTestCamera() in tests.',
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Bound after the guard so the closures below see non-optional ports.
|
|
101
|
+
const engine: CameraEngine = options.engine;
|
|
102
|
+
const permissions: PermissionPort = options.permissions;
|
|
103
|
+
const store = options.store;
|
|
104
|
+
const logger = options.logger ?? silentLogger;
|
|
105
|
+
const ttlMs = options.temporaryTtlMs ?? DEFAULT_TEMPORARY_TTL_MS;
|
|
106
|
+
|
|
107
|
+
let session: SessionSnapshot = initialSession;
|
|
108
|
+
let permission: CameraPermissionState = 'undetermined';
|
|
109
|
+
let capabilities: CapabilityProfile | null = null;
|
|
110
|
+
let cameraPosition: CameraPosition = 'back';
|
|
111
|
+
let flashMode: FlashMode = 'off';
|
|
112
|
+
let torchEnabled = false;
|
|
113
|
+
let surfaceMounted = false;
|
|
114
|
+
let disposed = false;
|
|
115
|
+
|
|
116
|
+
const capture = new SingleFlight();
|
|
117
|
+
const listeners = new Set<(state: CameraState) => void>();
|
|
118
|
+
|
|
119
|
+
function snapshot(): CameraState {
|
|
120
|
+
return {
|
|
121
|
+
session: session.status,
|
|
122
|
+
permission,
|
|
123
|
+
capabilities,
|
|
124
|
+
cameraPosition,
|
|
125
|
+
flashMode,
|
|
126
|
+
torchEnabled,
|
|
127
|
+
capturing: capture.busy,
|
|
128
|
+
error: session.error
|
|
129
|
+
? {
|
|
130
|
+
code: session.error.code,
|
|
131
|
+
recoverable: session.error.recoverable,
|
|
132
|
+
message: session.error.message,
|
|
133
|
+
}
|
|
134
|
+
: null,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
let current = snapshot();
|
|
139
|
+
|
|
140
|
+
function emit(): void {
|
|
141
|
+
current = snapshot();
|
|
142
|
+
for (const listener of listeners) {
|
|
143
|
+
listener(current);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function dispatch(event: Parameters<typeof sessionReducer>[1]): void {
|
|
148
|
+
const next = sessionReducer(session, event);
|
|
149
|
+
if (next !== session) {
|
|
150
|
+
session = next;
|
|
151
|
+
logger.log({
|
|
152
|
+
type: 'session',
|
|
153
|
+
code: session.status,
|
|
154
|
+
recoverable: session.error?.recoverable,
|
|
155
|
+
});
|
|
156
|
+
emit();
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Torch must never survive leaving the active state (FR-034b). */
|
|
161
|
+
async function forceTorchOff(): Promise<void> {
|
|
162
|
+
if (!torchEnabled) {
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
torchEnabled = false;
|
|
166
|
+
try {
|
|
167
|
+
await engine.setTorch(false);
|
|
168
|
+
} catch {
|
|
169
|
+
// Torch-off is best effort; the session is going away regardless.
|
|
170
|
+
}
|
|
171
|
+
emit();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function releaseCamera(): Promise<void> {
|
|
175
|
+
await forceTorchOff();
|
|
176
|
+
dispatch({ type: 'STOP' });
|
|
177
|
+
try {
|
|
178
|
+
await engine.stop();
|
|
179
|
+
dispatch({ type: 'STOPPED' });
|
|
180
|
+
} catch (error) {
|
|
181
|
+
dispatch({ type: 'FAIL', code: isCameraError(error) ? error.code : 'captureFailed' });
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
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
|
+
async function startEngine(startOptions: StartOptions = {}): Promise<void> {
|
|
211
|
+
try {
|
|
212
|
+
await engine.start({ cameraPosition: startOptions.cameraPosition ?? cameraPosition });
|
|
213
|
+
dispatch({ type: 'STARTED' });
|
|
214
|
+
} catch (error) {
|
|
215
|
+
dispatch({ type: 'FAIL', code: isCameraError(error) ? error.code : 'cameraUnavailable' });
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
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}` });
|
|
227
|
+
}
|
|
228
|
+
})
|
|
229
|
+
.catch(() => {
|
|
230
|
+
// A failed sweep must never block startup.
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const controller: CameraController = {
|
|
235
|
+
get state() {
|
|
236
|
+
return current;
|
|
237
|
+
},
|
|
238
|
+
|
|
239
|
+
subscribe(listener) {
|
|
240
|
+
listeners.add(listener);
|
|
241
|
+
return () => {
|
|
242
|
+
listeners.delete(listener);
|
|
243
|
+
};
|
|
244
|
+
},
|
|
245
|
+
|
|
246
|
+
async getPermission() {
|
|
247
|
+
permission = await permissions.getStatus();
|
|
248
|
+
emit();
|
|
249
|
+
return permission;
|
|
250
|
+
},
|
|
251
|
+
|
|
252
|
+
async requestPermission() {
|
|
253
|
+
permission = 'requesting';
|
|
254
|
+
emit();
|
|
255
|
+
permission = await permissions.request();
|
|
256
|
+
emit();
|
|
257
|
+
return permission;
|
|
258
|
+
},
|
|
259
|
+
|
|
260
|
+
async start(startOptions: StartOptions = {}) {
|
|
261
|
+
if (permission !== 'granted') {
|
|
262
|
+
dispatch({ type: 'FAIL', code: 'permissionDenied' });
|
|
263
|
+
throw new CameraError('permissionDenied', 'Camera permission is not granted');
|
|
264
|
+
}
|
|
265
|
+
if (!canStart(session.status)) {
|
|
266
|
+
throw new CameraError('sessionConflict', `Session already ${session.status}`);
|
|
267
|
+
}
|
|
268
|
+
if (startOptions.cameraPosition) {
|
|
269
|
+
cameraPosition = startOptions.cameraPosition;
|
|
270
|
+
}
|
|
271
|
+
dispatch({ type: 'START' });
|
|
272
|
+
await startEngine(startOptions);
|
|
273
|
+
if (session.error) {
|
|
274
|
+
throw session.error;
|
|
275
|
+
}
|
|
276
|
+
},
|
|
277
|
+
|
|
278
|
+
async pause() {
|
|
279
|
+
await forceTorchOff();
|
|
280
|
+
dispatch({ type: 'PAUSE' });
|
|
281
|
+
},
|
|
282
|
+
|
|
283
|
+
async resume() {
|
|
284
|
+
dispatch({ type: 'RESUME' });
|
|
285
|
+
if (session.status === 'starting') {
|
|
286
|
+
await startEngine();
|
|
287
|
+
}
|
|
288
|
+
},
|
|
289
|
+
|
|
290
|
+
async stop() {
|
|
291
|
+
await releaseCamera();
|
|
292
|
+
},
|
|
293
|
+
|
|
294
|
+
async capture(request: CaptureRequest = {}) {
|
|
295
|
+
const context: CaptureContext = {
|
|
296
|
+
engine,
|
|
297
|
+
sessionStatus: session.status,
|
|
298
|
+
permissionGranted: permission === 'granted',
|
|
299
|
+
capabilities,
|
|
300
|
+
cameraPosition,
|
|
301
|
+
flashMode,
|
|
302
|
+
};
|
|
303
|
+
// Guards run before the single-flight slot is taken so a rejected
|
|
304
|
+
// request cannot block a subsequent legal one.
|
|
305
|
+
assertCaptureAllowed(context, request);
|
|
306
|
+
|
|
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,
|
|
313
|
+
});
|
|
314
|
+
return finalizeResult(raw, request, context);
|
|
315
|
+
});
|
|
316
|
+
logger.log({ type: 'capture', code: 'succeeded' });
|
|
317
|
+
emit();
|
|
318
|
+
return result;
|
|
319
|
+
},
|
|
320
|
+
|
|
321
|
+
async cleanup(image: CapturedImage | string) {
|
|
322
|
+
const uri = typeof image === 'string' ? image : image.uri;
|
|
323
|
+
if (!store) {
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
// Refuse anything we did not create rather than deleting an arbitrary
|
|
327
|
+
// path, which also covers every appOwned artifact (FR-026b, FR-028).
|
|
328
|
+
if (!store.owns(uri)) {
|
|
329
|
+
logger.log({ type: 'cleanup', code: 'refused-not-owned' });
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
await store.remove(uri);
|
|
333
|
+
logger.log({ type: 'cleanup', code: 'removed' });
|
|
334
|
+
},
|
|
335
|
+
|
|
336
|
+
async getCapabilities() {
|
|
337
|
+
try {
|
|
338
|
+
capabilities = await engine.getCapabilities();
|
|
339
|
+
} catch {
|
|
340
|
+
capabilities = EMPTY_CAPABILITIES;
|
|
341
|
+
}
|
|
342
|
+
logger.log({
|
|
343
|
+
type: 'capability',
|
|
344
|
+
code: capabilities.hasCameraHardware ? 'available' : 'no-hardware',
|
|
345
|
+
});
|
|
346
|
+
emit();
|
|
347
|
+
return capabilities;
|
|
348
|
+
},
|
|
349
|
+
|
|
350
|
+
setFlashMode(mode: FlashMode) {
|
|
351
|
+
assertSupported(
|
|
352
|
+
mode === 'off' || capabilities?.hasFlash === true,
|
|
353
|
+
`flash mode "${mode}"`,
|
|
354
|
+
);
|
|
355
|
+
flashMode = mode;
|
|
356
|
+
emit();
|
|
357
|
+
},
|
|
358
|
+
|
|
359
|
+
async setTorch(enabled: boolean) {
|
|
360
|
+
if (enabled) {
|
|
361
|
+
assertSupported(supportsTorch(capabilities), 'torch');
|
|
362
|
+
}
|
|
363
|
+
await engine.setTorch(enabled);
|
|
364
|
+
torchEnabled = enabled;
|
|
365
|
+
emit();
|
|
366
|
+
},
|
|
367
|
+
|
|
368
|
+
setSurfaceMounted(mounted: boolean) {
|
|
369
|
+
surfaceMounted = mounted;
|
|
370
|
+
},
|
|
371
|
+
|
|
372
|
+
async dispose() {
|
|
373
|
+
if (disposed) {
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
disposed = true;
|
|
377
|
+
unsubscribePermission();
|
|
378
|
+
unsubscribeAppState();
|
|
379
|
+
await releaseCamera();
|
|
380
|
+
listeners.clear();
|
|
381
|
+
},
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
return controller;
|
|
385
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session lifecycle state machine (FR-014..FR-019).
|
|
3
|
+
*
|
|
4
|
+
* Pure and synchronous: it decides what a transition means, the controller
|
|
5
|
+
* performs the I/O. That split is what makes every lifecycle rule in
|
|
6
|
+
* `data-model.md` section 2 testable without a camera.
|
|
7
|
+
*
|
|
8
|
+
* Backgrounding STOPS rather than pauses. Both operating systems can reclaim
|
|
9
|
+
* the camera anyway, and holding it risks leaving the OS camera indicator lit
|
|
10
|
+
* after the app is no longer in front of the user (FR-015).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { SessionStatus } from '../config/types';
|
|
14
|
+
import { CameraError, type CameraErrorCode } from '../diagnostics/errors';
|
|
15
|
+
|
|
16
|
+
export type SessionEvent =
|
|
17
|
+
| { readonly type: 'START' }
|
|
18
|
+
| { readonly type: 'STARTED' }
|
|
19
|
+
| { readonly type: 'PAUSE' }
|
|
20
|
+
| { readonly type: 'RESUME' }
|
|
21
|
+
| { readonly type: 'STOP' }
|
|
22
|
+
| { readonly type: 'STOPPED' }
|
|
23
|
+
| { readonly type: 'BACKGROUND' }
|
|
24
|
+
| { readonly type: 'FOREGROUND'; readonly surfaceMounted: boolean }
|
|
25
|
+
| { readonly type: 'FAIL'; readonly code: CameraErrorCode }
|
|
26
|
+
| { readonly type: 'START_TIMEOUT' };
|
|
27
|
+
|
|
28
|
+
export interface SessionSnapshot {
|
|
29
|
+
readonly status: SessionStatus;
|
|
30
|
+
readonly error: CameraError | null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const initialSession: SessionSnapshot = { status: 'idle', error: null };
|
|
34
|
+
|
|
35
|
+
/** Statuses from which a fresh start is allowed (FR-019). */
|
|
36
|
+
const STARTABLE: ReadonlySet<SessionStatus> = new Set<SessionStatus>([
|
|
37
|
+
'idle',
|
|
38
|
+
'stopped',
|
|
39
|
+
'error',
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
export function canStart(status: SessionStatus): boolean {
|
|
43
|
+
return STARTABLE.has(status);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Capture is only ever legal from a fully active session (FR-020). */
|
|
47
|
+
export function canCapture(status: SessionStatus): boolean {
|
|
48
|
+
return status === 'active';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function fail(code: CameraErrorCode): SessionSnapshot {
|
|
52
|
+
return { status: 'error', error: new CameraError(code) };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function sessionReducer(
|
|
56
|
+
snapshot: SessionSnapshot,
|
|
57
|
+
event: SessionEvent,
|
|
58
|
+
): SessionSnapshot {
|
|
59
|
+
const { status } = snapshot;
|
|
60
|
+
|
|
61
|
+
switch (event.type) {
|
|
62
|
+
case 'START':
|
|
63
|
+
if (!canStart(status)) {
|
|
64
|
+
// Refuse rather than silently stealing the camera from the surface
|
|
65
|
+
// that already holds it (FR-019).
|
|
66
|
+
return { status, error: new CameraError('sessionConflict') };
|
|
67
|
+
}
|
|
68
|
+
return { status: 'starting', error: null };
|
|
69
|
+
|
|
70
|
+
case 'STARTED':
|
|
71
|
+
return status === 'starting' ? { status: 'active', error: null } : snapshot;
|
|
72
|
+
|
|
73
|
+
case 'START_TIMEOUT':
|
|
74
|
+
// Never sit in `starting` forever when frames stop arriving.
|
|
75
|
+
return status === 'starting' ? fail('captureFailed') : snapshot;
|
|
76
|
+
|
|
77
|
+
case 'PAUSE':
|
|
78
|
+
return status === 'active' ? { status: 'paused', error: null } : snapshot;
|
|
79
|
+
|
|
80
|
+
case 'RESUME':
|
|
81
|
+
return status === 'paused' ? { status: 'starting', error: null } : snapshot;
|
|
82
|
+
|
|
83
|
+
case 'STOP':
|
|
84
|
+
if (status === 'stopped' || status === 'idle') {
|
|
85
|
+
return snapshot;
|
|
86
|
+
}
|
|
87
|
+
return { status: 'stopping', error: snapshot.error };
|
|
88
|
+
|
|
89
|
+
case 'STOPPED':
|
|
90
|
+
return { status: 'stopped', error: null };
|
|
91
|
+
|
|
92
|
+
case 'BACKGROUND':
|
|
93
|
+
// Release, do not hold (FR-015).
|
|
94
|
+
if (status === 'active' || status === 'paused' || status === 'starting') {
|
|
95
|
+
return { status: 'stopping', error: null };
|
|
96
|
+
}
|
|
97
|
+
return snapshot;
|
|
98
|
+
|
|
99
|
+
case 'FOREGROUND':
|
|
100
|
+
if (event.surfaceMounted && (status === 'stopped' || status === 'idle')) {
|
|
101
|
+
return { status: 'starting', error: null };
|
|
102
|
+
}
|
|
103
|
+
return snapshot;
|
|
104
|
+
|
|
105
|
+
case 'FAIL':
|
|
106
|
+
return fail(event.code);
|
|
107
|
+
|
|
108
|
+
default:
|
|
109
|
+
return snapshot;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed error taxonomy (FR-044) and redaction helper (FR-038).
|
|
3
|
+
*
|
|
4
|
+
* The taxonomy exists so consuming apps can branch on `code` and `recoverable`
|
|
5
|
+
* instead of string-matching messages. Messages never carry file paths or image
|
|
6
|
+
* payloads — `redact` is applied before any value reaches a log sink.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export type CameraErrorCode =
|
|
10
|
+
| 'permissionDenied'
|
|
11
|
+
| 'cameraUnavailable'
|
|
12
|
+
| 'cameraBlocked'
|
|
13
|
+
| 'cameraInUse'
|
|
14
|
+
| 'sessionConflict'
|
|
15
|
+
| 'notReady'
|
|
16
|
+
| 'captureInProgress'
|
|
17
|
+
| 'captureFailed'
|
|
18
|
+
| 'storageFailure'
|
|
19
|
+
| 'unsupportedCapability'
|
|
20
|
+
| 'interrupted';
|
|
21
|
+
|
|
22
|
+
/** Codes the app may retry; everything else ends the session. */
|
|
23
|
+
const RECOVERABLE: ReadonlySet<CameraErrorCode> = new Set<CameraErrorCode>([
|
|
24
|
+
'permissionDenied',
|
|
25
|
+
'cameraInUse',
|
|
26
|
+
'sessionConflict',
|
|
27
|
+
'notReady',
|
|
28
|
+
'captureInProgress',
|
|
29
|
+
'captureFailed',
|
|
30
|
+
'storageFailure',
|
|
31
|
+
'unsupportedCapability',
|
|
32
|
+
'interrupted',
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
export class CameraError extends Error {
|
|
36
|
+
readonly code: CameraErrorCode;
|
|
37
|
+
readonly recoverable: boolean;
|
|
38
|
+
|
|
39
|
+
constructor(code: CameraErrorCode, message?: string) {
|
|
40
|
+
super(message ?? code);
|
|
41
|
+
this.name = 'CameraError';
|
|
42
|
+
this.code = code;
|
|
43
|
+
this.recoverable = RECOVERABLE.has(code);
|
|
44
|
+
Object.setPrototypeOf(this, CameraError.prototype);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function isCameraError(value: unknown): value is CameraError {
|
|
49
|
+
return value instanceof CameraError;
|
|
50
|
+
}
|
|
51
|
+
|
|
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;
|
|
57
|
+
|
|
58
|
+
const REDACTED = '[redacted]';
|
|
59
|
+
const SENSITIVE_KEYS = new Set(['uri', 'path', 'destination', 'file', 'data', 'base64']);
|
|
60
|
+
|
|
61
|
+
function redactString(value: string): string {
|
|
62
|
+
return value.replace(PATH_PATTERN, REDACTED).replace(PAYLOAD_PATTERN, REDACTED);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Strips paths and payload-shaped strings from a value before logging (FR-038).
|
|
67
|
+
* Recurses through plain objects and arrays; known-sensitive keys are dropped
|
|
68
|
+
* wholesale rather than pattern-matched, since a bare filename has no separator
|
|
69
|
+
* for the path pattern to catch.
|
|
70
|
+
*/
|
|
71
|
+
export function redact(value: unknown): unknown {
|
|
72
|
+
if (typeof value === 'string') {
|
|
73
|
+
return redactString(value);
|
|
74
|
+
}
|
|
75
|
+
if (Array.isArray(value)) {
|
|
76
|
+
return value.map(redact);
|
|
77
|
+
}
|
|
78
|
+
if (value instanceof CameraError) {
|
|
79
|
+
return { code: value.code, recoverable: value.recoverable };
|
|
80
|
+
}
|
|
81
|
+
if (value instanceof Error) {
|
|
82
|
+
return { name: value.name, message: redactString(value.message) };
|
|
83
|
+
}
|
|
84
|
+
if (value && typeof value === 'object') {
|
|
85
|
+
const out: Record<string, unknown> = {};
|
|
86
|
+
for (const [key, item] of Object.entries(value)) {
|
|
87
|
+
out[key] = SENSITIVE_KEYS.has(key) ? REDACTED : redact(item);
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
return value;
|
|
92
|
+
}
|