@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 ADDED
@@ -0,0 +1,34 @@
1
+ # Changelog — @chipmobilesdk/rn-camera
2
+
3
+ All notable changes to this package are documented here. The package follows semantic versioning.
4
+
5
+ ## 0.1.0 — 2026-07-26
6
+
7
+ Initial release. New package, no existing consumers, **no migration required**.
8
+
9
+ ### Added
10
+
11
+ - **Permission handling** as a seven-state contract (`undetermined`, `requesting`, `granted`, `denied`, `deniedPermanently`, `unavailable`, `unsupported`). Reading status never prompts; requesting never re-prompts after a permanent denial; revocations made in system settings are pushed to subscribers without an app restart.
12
+ - **`CameraPreview`** surface with `cover`/`contain` resize modes, an overlay slot via `children`, and pass-through of consumer accessibility properties. Ships no styled UI and takes no theme dependency.
13
+ - **Still capture** returning a file reference plus metadata. Undeterminable fields (`fileSize`, `orientation`) are explicitly marked unavailable rather than guessed or omitted. Quality and max-dimension hints are applied within device limits and reported back as effective values.
14
+ - **Session lifecycle** — start, pause, resume, stop — that releases the camera on background, unmount, and stop, restarts on foreground when a surface is still mounted, and reports a single active session per process.
15
+ - **Artifact ownership and cleanup**: an optional `destination` on the capture request switches the result to app-owned and writes there directly; everything else lands in the package cache directory. `cleanup()` is idempotent and refuses anything the package does not own. Orphans are swept at initialization under a 24-hour time-to-live.
16
+ - **Capability reporting** for camera positions, flash, torch, orientation, and camera-less devices, with capture-time flash modes and a continuous torch that is forced off on pause, stop, and background.
17
+ - **Coordinate mapping** — pure, bidirectional, accounting for scale, letterbox or crop, orientation, and front-camera mirroring, accurate within 2 device-independent pixels on a round trip.
18
+ - **Typed error taxonomy** of 11 codes, each carrying a `recoverable` flag, plus a `redact()` helper.
19
+ - **Test adapter** (`createTestCamera`) implementing the same published ports as the production adapters, so consuming apps can drive every permission, capability, and capture path with no hardware and no permission prompts.
20
+ - **Opt-in diagnostics logger**, defaulting to no logging.
21
+
22
+ ### Guarantees
23
+
24
+ - No network egress of any kind.
25
+ - No location read, no location permission requested.
26
+ - Nothing written to the device photo library or gallery.
27
+ - No capture history or index retained.
28
+ - No on-device inference, no frame processor, no AI-derived labels.
29
+
30
+ ### Notes
31
+
32
+ - `react-native-vision-camera` (>= 5.1.1) is a **peer dependency**; the consuming app installs and links it. This package authors no native source.
33
+ - Android `minSdkVersion` remains **24** — VisionCamera 5.1.1 declares 23 and resolves the root project value first.
34
+ - The local frame-processing extension point is documented in the README but deliberately not exported. It is designed to arrive as an additive minor release.
package/README.md ADDED
@@ -0,0 +1,341 @@
1
+ # @chipmobilesdk/rn-camera
2
+
3
+ Reusable camera infrastructure for ChipMobileSdk-family React Native apps: permission-gated preview, still capture with consistent metadata, a session lifecycle that reliably releases the camera, app-controlled artifact retention, capability-gated flash and torch, coordinate mapping for app-drawn overlays, and a test adapter.
4
+
5
+ The package is **infrastructure, not a feature**. It contains no business logic, does not know what your photos are for, sends nothing over the network, and writes nothing to the device gallery.
6
+
7
+ > **License:** `UNLICENSED`. Published publicly for use by the owner's
8
+ > applications; no open-source license is granted.
9
+
10
+ - **Status**: `0.1.0`, public npm package
11
+ - **Entrypoint**: `src/index.ts` — the only public entrypoint
12
+ - **Native code authored here**: none (see [Native capability](#native-capability))
13
+
14
+ ## Compatibility
15
+
16
+ | | Supported |
17
+ |---|---|
18
+ | React | >= 19.2 |
19
+ | React Native | >= 0.85 |
20
+ | Android | `minSdkVersion` **24**, `targetSdkVersion` 36 |
21
+ | iOS | 15.1+ |
22
+ | `react-native-vision-camera` | 5.1.x |
23
+
24
+ > **Android `minSdkVersion` stays at 24.** Secondary sources claim VisionCamera v5 requires 26. That is wrong for 5.1.1: its `gradle.properties` declares `VisionCamera_minSdkVersion=23` and its `build.gradle` resolves `rootProject.ext.minSdkVersion` first. Bumping to 26 would drop Android 7.0 and 7.1 devices for no reason.
25
+
26
+ ## Install (consuming app)
27
+
28
+ ```bash
29
+ npm install @chipmobilesdk/rn-camera react-native-vision-camera react-native-nitro-modules react-native-nitro-image
30
+ cd ios && pod install && cd ..
31
+ ```
32
+
33
+ `react-native-vision-camera` is a **peer dependency** — your app installs and links it once, exactly as it does `react-native-keychain` for `@chipmobilesdk/rn-auth`.
34
+ VisionCamera 5.1.x in turn requires the Nitro modules and image packages shown
35
+ above; they remain app-owned native dependencies.
36
+
37
+ > **If your app also uses `react-native-unistyles`** (as the ChipMobileSdk demo app does), both it and VisionCamera depend on `react-native-nitro-modules`, and only one Nitro version can exist in a build. After installing, verify your theme system still works on both platforms before going further.
38
+
39
+ ### iOS permissions
40
+
41
+ ```xml
42
+ <key>NSCameraUsageDescription</key>
43
+ <string>Explain, in your own words, why your app needs the camera.</string>
44
+ ```
45
+
46
+ That is the only declaration the package requires. No microphone, no photo library, no location.
47
+
48
+ ### Android permissions
49
+
50
+ ```xml
51
+ <uses-permission android:name="android.permission.CAMERA" />
52
+ <uses-feature android:name="android.hardware.camera" android:required="false" />
53
+ ```
54
+
55
+ `required="false"` keeps your app installable on camera-less devices; the package reports `hasCameraHardware: false` there so you can render an unavailable state instead of failing.
56
+
57
+ ## Quick start
58
+
59
+ ```tsx
60
+ import React, { useCallback, useEffect, useState } from 'react';
61
+ import {
62
+ CameraPreview,
63
+ createVisionCameraEngine,
64
+ createVisionCameraPermissions,
65
+ useCameraController,
66
+ useCameraState,
67
+ type CapturedImage,
68
+ } from '@chipmobilesdk/rn-camera';
69
+
70
+ function CameraScreen() {
71
+ const [photo, setPhoto] = useState<CapturedImage | null>(null);
72
+ const controller = useCameraController({
73
+ engine: createVisionCameraEngine(),
74
+ permissions: createVisionCameraPermissions(),
75
+ });
76
+ const state = useCameraState(controller);
77
+
78
+ useEffect(() => {
79
+ void (async () => {
80
+ await controller.getCapabilities();
81
+ if (await controller.getPermission() === 'granted') {
82
+ await controller.start();
83
+ }
84
+ })();
85
+ }, [controller]);
86
+
87
+ const requestPermission = useCallback(async () => {
88
+ if (await controller.requestPermission() === 'granted') {
89
+ await controller.start();
90
+ }
91
+ }, [controller]);
92
+
93
+ if (state.permission !== 'granted') {
94
+ // Your copy, your layout — the package supplies state, not UI.
95
+ return <PermissionGate onRequest={requestPermission} state={state.permission} />;
96
+ }
97
+
98
+ return (
99
+ <CameraPreview controller={controller} accessibilityLabel="Camera preview">
100
+ <ShutterButton onPress={async () => setPhoto(await controller.capture())} />
101
+ </CameraPreview>
102
+ );
103
+ }
104
+ ```
105
+
106
+ `CameraPreview` mounts the native feed only after `controller.start()` reaches
107
+ the `active` state. `getPermission()` only reads state; it does not prompt or
108
+ start a session. The controller hook releases the session when the screen
109
+ unmounts.
110
+
111
+ ## Core concepts
112
+
113
+ ### Permission is a state, not a boolean
114
+
115
+ Seven distinguishable states, because each one needs different UI: `undetermined`, `requesting`, `granted`, `denied`, `deniedPermanently`, `unavailable`, `unsupported`.
116
+
117
+ `getPermission()` never prompts and never opens a session — safe to call on mount. Only `requestPermission()` prompts, and it refuses to re-prompt once the OS reports a permanent denial, reporting `deniedPermanently` instead so you can route the user to system settings. Revocations made in Settings while your app runs are pushed to subscribers without a restart.
118
+
119
+ ### Capture returns a file reference
120
+
121
+ `capture()` resolves to a **file URI plus metadata** — never a base64 string, never an in-memory buffer. Base64 inflates an image by roughly a third and holds the whole thing in JavaScript memory, and strings that size are exactly what ends up in crash logs. If you need bytes, read the file.
122
+
123
+ Metadata the platform cannot determine is **explicitly marked unavailable** rather than omitted:
124
+
125
+ ```ts
126
+ const image = await controller.capture();
127
+ image.fileSize.available ? image.fileSize.value : 'not reported by this platform';
128
+ ```
129
+
130
+ A bare optional could not tell "the platform does not report this" from "nobody asked", which is why the wrapper exists.
131
+
132
+ ### You decide what is kept
133
+
134
+ The returned ownership metadata is decided at capture time by one thing —
135
+ whether you pass a `destination`:
136
+
137
+ | | No `destination` | With `destination` |
138
+ |---|---|---|
139
+ | Ownership | `packageTemporary` | `appOwned` |
140
+ | Default VisionCamera write | Native temporary file | Your path, directly |
141
+ | Automatic persistence | No | Controlled by your destination |
142
+
143
+ ```ts
144
+ const temp = await controller.capture(); // package-owned
145
+ const kept = await controller.capture({ destination: '/my/app/x.jpg' }); // app-owned
146
+
147
+ // cleanup() only acts when the controller was given an ArtifactStore and the
148
+ // store reports that it owns this URI.
149
+ await controller.cleanup(temp);
150
+ ```
151
+
152
+ The default VisionCamera adapter does not automatically move its native
153
+ temporary file into the optional package store. With no `store`, `cleanup()` is
154
+ a no-op and no sweep runs. To enable bounded cleanup, create an
155
+ `ArtifactStore` with an app-supplied `FileSystemBridge`, pass it to the
156
+ controller, and place managed files under that store's temporary directory.
157
+ Initialization then sweeps only URIs the store owns, using a 24-hour default
158
+ TTL. `cleanup()` is idempotent and refuses paths outside the store root.
159
+
160
+ ### The camera is released, not held
161
+
162
+ Backgrounding **stops and releases** the camera rather than pausing it. Both operating systems can reclaim it anyway, and holding it risks leaving the OS camera indicator lit while your app is not in front of the user. Returning to the foreground restarts the preview if the surface is still mounted.
163
+
164
+ Each controller permits one active session; starting the same controller again
165
+ reports `sessionConflict`. The package does not implement a process-wide lock
166
+ across different controller instances, so an app should mount only one active
167
+ camera preview at a time.
168
+
169
+ ### Capability gating
170
+
171
+ Ask before you render:
172
+
173
+ ```ts
174
+ const caps = await controller.getCapabilities();
175
+ if (caps.hasTorch) { /* render the torch control */ }
176
+ ```
177
+
178
+ Invoking something the device does not have returns `unsupportedCapability` rather than an unexpected failure. Flash mode (`auto`/`on`/`off`) applies at capture time; torch is continuous during preview and is forced off whenever the session pauses, stops, or the app backgrounds.
179
+
180
+ ### Overlays
181
+
182
+ The package draws no bounding boxes, guide frames, or reticles — those are where apps differ. It gives you the surface (`children` renders above the feed) and the geometry:
183
+
184
+ ```tsx
185
+ <CameraPreview controller={controller} onGeometryChange={setGeometry}>
186
+ <View style={myGuideFrame} pointerEvents="none" />
187
+ </CameraPreview>;
188
+
189
+ const inImageSpace = mapRectToImage(myGuideFrame, geometry);
190
+ ```
191
+
192
+ The pure mapping functions account for scale, letterbox or crop, orientation,
193
+ and front-camera mirroring using the geometry you supply. In the current
194
+ `CameraPreview`, `onGeometryChange` reports nominal image dimensions
195
+ (`1920 × 1080`), orientation `0`, and mirroring from camera position; it does
196
+ not yet read the selected native capture format. Use capture metadata or
197
+ app-owned measured geometry when pixel-exact alignment is required.
198
+
199
+ ### Quality and dimensions
200
+
201
+ `quality` and `maxDimension` are normalized into `effectiveQuality` and
202
+ `effectiveMaxDimension` metadata. The current VisionCamera 5.1 adapter does
203
+ not yet resize or re-encode the saved image, so these fields are hints rather
204
+ than a guarantee about output bytes. Perform app-side image processing when an
205
+ exact upload size or quality is required.
206
+
207
+ ## Testing without hardware
208
+
209
+ `createTestCamera()` returns real implementations of the same published ports the production adapters implement — not a parallel mock, so it cannot drift from the contract without a compile error.
210
+
211
+ ```ts
212
+ import { createCameraController, createTestCamera } from '@chipmobilesdk/rn-camera';
213
+
214
+ const camera = createTestCamera({ permission: 'granted' });
215
+ const controller = createCameraController({
216
+ engine: camera.engine,
217
+ permissions: camera.permissions,
218
+ store: camera.store,
219
+ subscribeToAppState: camera.subscribeToAppState,
220
+ sweepOnInit: false,
221
+ });
222
+
223
+ await controller.requestPermission();
224
+ await controller.getCapabilities();
225
+ await controller.start();
226
+
227
+ const image = await controller.capture();
228
+
229
+ // Drive any failure path:
230
+ camera.setCaptureError('captureFailed');
231
+ camera.setPermission('deniedPermanently');
232
+ camera.setAppState('background');
233
+ camera.emitInterruption();
234
+ ```
235
+
236
+ Add a module mapping so an accidental import of the native module resolves in Jest:
237
+
238
+ ```js
239
+ moduleNameMapper: {
240
+ '^react-native-vision-camera$': '<rootDir>/__tests__/__mocks__/react-native-vision-camera.ts',
241
+ }
242
+ ```
243
+
244
+ ## Errors
245
+
246
+ Every failure is a `CameraError` with a `code` and a `recoverable` flag, so you branch on the code rather than string-matching a message:
247
+
248
+ `permissionDenied`, `cameraUnavailable`, `cameraBlocked`, `cameraInUse`, `sessionConflict`, `notReady`, `captureInProgress`, `captureFailed`, `storageFailure`, `unsupportedCapability`, `interrupted`.
249
+
250
+ A double-tapped shutter produces `captureInProgress` on the second call. The package does not queue (which would give the user two photos they never asked for) and does not coalesce (which would hide a deliberate second capture behind an accidental one).
251
+
252
+ ## Diagnostics
253
+
254
+ Logging is **off by default**, and event bodies carry codes and states only — never file paths or image data:
255
+
256
+ ```ts
257
+ createCameraController({ logger: { log: event => myLogger.debug(event) } });
258
+ ```
259
+
260
+ A `redact()` helper is exported for scrubbing paths and payload-shaped strings from anything else you log.
261
+
262
+ ## Data behaviour (for App Privacy and Data Safety)
263
+
264
+ Use this when completing your store declarations:
265
+
266
+ | Question | Answer for this package |
267
+ |---|---|
268
+ | What data is involved? | Camera imagery generated on-device by explicit user action, plus technical metadata (dimensions, mime type, size, orientation, timestamp, camera position) |
269
+ | Transmitted off device? | **No.** The package makes no network requests of any kind |
270
+ | Shared with third parties? | **No** |
271
+ | Stored persistently? | A capture is a file: the default adapter uses VisionCamera's native temporary location unless the app supplies a destination. The package has no history database and does not write to the gallery |
272
+ | Location attached? | **No.** No location data is read and no location permission is requested |
273
+ | Written to the photo library? | **No** |
274
+ | Tracking, ads, identifiers? | **None** |
275
+ | On-device AI or inference? | **None in this phase** |
276
+
277
+ **If your app uploads captured images off-device, that collection is yours to declare** — the package cannot know you do it. The same applies to moderation and reporting obligations if captures become user-generated content.
278
+
279
+ ## Native capability
280
+
281
+ This package authors **zero native source**. Camera capability arrives entirely through the `react-native-vision-camera` peer dependency, mirroring how `@chipmobilesdk/rn-auth` obtains Keychain and OIDC capability.
282
+
283
+ The ChipMobileSdk constitution prohibits native code in shared libraries by default. A camera cannot be implemented in TypeScript, so this is the exception in its narrowest available form: a maintained MIT library the consuming app installs, wrapped behind port interfaces this package owns.
284
+
285
+ ## Reserved: local frame processing (not implemented)
286
+
287
+ Phase one runs **no inference, emits no AI-derived labels, and sends no frame anywhere**. The extension point is documented now so the later local-AI phase can arrive as an additive minor release without disturbing anything above.
288
+
289
+ The reserved contract:
290
+
291
+ ```ts
292
+ // NOT EXPORTED. Reserved shape for the local-AI phase.
293
+ interface FrameProcessorOptions {
294
+ maxFps: number; // frame-rate cap, to protect the UI thread and battery
295
+ inferenceMaxDimension: number; // lower than preview resolution
296
+ onFrame: (frame: ReservedFrame) => void;
297
+ backpressure: 'dropLatest' | 'dropOldest';
298
+ // Automatically paused when the app backgrounds. Frames never leave the device.
299
+ }
300
+ ```
301
+
302
+ Still capture and real-time frame processing stay strictly separate.
303
+
304
+ ## Public API
305
+
306
+ Everything is exported from `@chipmobilesdk/rn-camera`. Modules under `core/`, `native/`, `permissions/`, `storage/`, `geometry/`, `diagnostics/`, and `testing/` are private — do not deep-import them.
307
+
308
+ - Controller and state: `createCameraController`, `CameraController`,
309
+ `CameraControllerOptions`, `useCameraController`, `useCameraState`,
310
+ `CameraState`, `AppStatus`, `DEFAULT_TEMPORARY_TTL_MS`
311
+ - Preview and capture: `CameraPreview`, `CameraPreviewProps`, `CaptureRequest`,
312
+ `CapturedImage`, `StartOptions` and the camera/session/capability types
313
+ - Ports and adapters: `CameraEngine`, `EngineOptions`,
314
+ `createVisionCameraEngine`, `PermissionPort`,
315
+ `createVisionCameraPermissions`, `ArtifactStore`, `FileSystemBridge`,
316
+ `StoreOptions`, `createCacheArtifactStore`, `createArtifactStore`
317
+ - Mapping: `mapPointToImage`, `mapPointToPreview`, `mapRectToImage`,
318
+ `mapRectToPreview`, plus `Point`, `Rect`, and `PreviewGeometry`
319
+ - Diagnostics: `CameraError`, `CameraErrorCode`, `isCameraError`, `redact`, and
320
+ the logger event/type contracts
321
+ - Testing: `createTestCamera`, `createMemoryFileSystem`,
322
+ `DEFAULT_TEST_CAPABILITIES`, and their configuration/handle types
323
+ - Metadata helpers: `known`, `unavailable`, `Known`, `Unavailable`, `Maybe`
324
+
325
+ Stable for semver: types, controller, preview, mapping, errors. Experimental and may change in a minor: the diagnostics logger shape and the test adapter configuration.
326
+
327
+ ## License
328
+
329
+ `UNLICENSED`. The npm tarball is publicly downloadable, but no open-source
330
+ license or permission to copy, modify, or redistribute is granted.
331
+
332
+ ## Demo and validation
333
+
334
+ The demo integration lives in `src/screens/CameraDemoScreen.tsx`. Run the
335
+ package commands from the repository root (camera currently has no root-level
336
+ shortcut):
337
+
338
+ ```bash
339
+ npm run typecheck --workspace @chipmobilesdk/rn-camera
340
+ npm run pack:dry-run --workspace @chipmobilesdk/rn-camera
341
+ ```
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@chipmobilesdk/rn-camera",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "license": "UNLICENSED",
6
+ "main": "src/index.ts",
7
+ "types": "src/index.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./src/index.ts",
11
+ "react-native": "./src/index.ts",
12
+ "default": "./src/index.ts"
13
+ }
14
+ },
15
+ "files": [
16
+ "src/index.ts",
17
+ "src/config",
18
+ "src/core",
19
+ "src/diagnostics",
20
+ "src/geometry",
21
+ "src/native",
22
+ "src/permissions",
23
+ "src/react",
24
+ "src/storage",
25
+ "src/testing",
26
+ "README.md",
27
+ "CHANGELOG.md"
28
+ ],
29
+ "sideEffects": false,
30
+ "scripts": {
31
+ "typecheck": "tsc -p tsconfig.json --noEmit",
32
+ "pack:dry-run": "npm pack --dry-run"
33
+ },
34
+ "peerDependencies": {
35
+ "react": ">=19",
36
+ "react-native": ">=0.85",
37
+ "react-native-vision-camera": ">=5.1.1"
38
+ },
39
+ "engines": {
40
+ "node": ">=22.11.0"
41
+ },
42
+ "publishConfig": {
43
+ "access": "public",
44
+ "registry": "https://registry.npmjs.org/"
45
+ }
46
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Public type contract for @chipmobilesdk/rn-camera.
3
+ *
4
+ * Every type here is re-exported from `src/index.ts` (LIB-002/LIB-003). See
5
+ * `specs/006-rn-camera-package/contracts/public-api.md` for the authoritative
6
+ * contract and `data-model.md` for the rules behind each field.
7
+ */
8
+
9
+ /** Camera authorization status. Seven distinguishable outcomes (FR-004). */
10
+ export type CameraPermissionState =
11
+ | 'undetermined'
12
+ | 'requesting'
13
+ | 'granted'
14
+ | 'denied'
15
+ | 'deniedPermanently'
16
+ | 'unavailable'
17
+ | 'unsupported';
18
+
19
+ export type CameraPosition = 'front' | 'back';
20
+ export type FlashMode = 'auto' | 'on' | 'off';
21
+ export type ResizeMode = 'cover' | 'contain';
22
+ export type ArtifactOwnership = 'packageTemporary' | 'appOwned';
23
+
24
+ export type SessionStatus =
25
+ | 'idle'
26
+ | 'starting'
27
+ | 'active'
28
+ | 'paused'
29
+ | 'stopping'
30
+ | 'stopped'
31
+ | 'error';
32
+
33
+ /**
34
+ * A metadata field the platform could not determine (FR-022). Distinct from an
35
+ * absent property so consumers can tell "not reported" from "not asked for".
36
+ */
37
+ export interface Unavailable {
38
+ readonly available: false;
39
+ }
40
+
41
+ export interface Known<T> {
42
+ readonly available: true;
43
+ readonly value: T;
44
+ }
45
+
46
+ export type Maybe<T> = Known<T> | Unavailable;
47
+
48
+ export function known<T>(value: T): Known<T> {
49
+ return { available: true, value };
50
+ }
51
+
52
+ export function unavailable(): Unavailable {
53
+ return { available: false };
54
+ }
55
+
56
+ /** What this device and platform actually support (FR-033). */
57
+ export interface CapabilityProfile {
58
+ readonly availablePositions: readonly CameraPosition[];
59
+ readonly hasFlash: boolean;
60
+ readonly hasTorch: boolean;
61
+ readonly supportsOrientationChange: boolean;
62
+ readonly hasCameraHardware: boolean;
63
+ }
64
+
65
+ /** Instruction to take one still photo. Carries no business context (FR-002). */
66
+ export interface CaptureRequest {
67
+ /** 0..1, clamped to what the device supports. */
68
+ readonly quality?: number;
69
+ /** Longest edge in pixels, applied within device limits. */
70
+ readonly maxDimension?: number;
71
+ readonly cameraPosition?: CameraPosition;
72
+ readonly flashMode?: FlashMode;
73
+ /**
74
+ * Supplying a destination makes the result `appOwned` and the package writes
75
+ * directly there. Omitting it makes the result `packageTemporary` (FR-026a).
76
+ * This is the only way ownership is decided.
77
+ */
78
+ readonly destination?: string;
79
+ }
80
+
81
+ /** Result of one successful capture. Always a file reference (FR-021/FR-021a). */
82
+ export interface CapturedImage {
83
+ /** File URI or handle. Never a base64 string, never an in-memory buffer. */
84
+ readonly uri: string;
85
+ readonly mimeType: string;
86
+ readonly width: number;
87
+ readonly height: number;
88
+ readonly timestamp: number;
89
+ readonly cameraPosition: CameraPosition;
90
+ readonly ownership: ArtifactOwnership;
91
+ readonly effectiveQuality: number;
92
+ readonly effectiveMaxDimension: Maybe<number>;
93
+ readonly fileSize: Maybe<number>;
94
+ readonly orientation: Maybe<number>;
95
+ }
96
+
97
+ /** Options accepted when starting a session. */
98
+ export interface StartOptions {
99
+ readonly cameraPosition?: CameraPosition;
100
+ }
101
+
102
+ /**
103
+ * Relationship between captured-image space and on-screen preview space.
104
+ * Consumed by the pure mapping functions in `geometry/coordinateMapping`.
105
+ */
106
+ export interface PreviewGeometry {
107
+ readonly imageWidth: number;
108
+ readonly imageHeight: number;
109
+ readonly previewWidth: number;
110
+ readonly previewHeight: number;
111
+ readonly resizeMode: ResizeMode;
112
+ /** Clockwise degrees: 0, 90, 180, or 270. */
113
+ readonly orientation: number;
114
+ readonly isMirrored: boolean;
115
+ }
116
+
117
+ export interface Point {
118
+ readonly x: number;
119
+ readonly y: number;
120
+ }
121
+
122
+ export interface Rect {
123
+ readonly x: number;
124
+ readonly y: number;
125
+ readonly width: number;
126
+ readonly height: number;
127
+ }
128
+
129
+ /** Aggregate observable state exposed by the controller. */
130
+ export interface CameraState {
131
+ readonly session: SessionStatus;
132
+ readonly permission: CameraPermissionState;
133
+ readonly capabilities: CapabilityProfile | null;
134
+ readonly cameraPosition: CameraPosition;
135
+ readonly flashMode: FlashMode;
136
+ readonly torchEnabled: boolean;
137
+ readonly capturing: boolean;
138
+ readonly error: CameraErrorLike | null;
139
+ }
140
+
141
+ /**
142
+ * Structural view of a CameraError, kept here so `CameraState` does not create
143
+ * a circular import with the diagnostics module.
144
+ */
145
+ export interface CameraErrorLike {
146
+ readonly code: string;
147
+ readonly recoverable: boolean;
148
+ readonly message: string;
149
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Capability gating (FR-033, FR-034).
3
+ *
4
+ * An unsupported capability must produce an `unsupportedCapability` result, not
5
+ * an unexpected failure — that is what lets an app hide a control instead of
6
+ * discovering at tap time that the device has no flash.
7
+ */
8
+
9
+ import type { CameraPosition, CapabilityProfile, FlashMode } from '../config/types';
10
+ import { CameraError } from '../diagnostics/errors';
11
+
12
+ export const EMPTY_CAPABILITIES: CapabilityProfile = {
13
+ availablePositions: [],
14
+ hasFlash: false,
15
+ hasTorch: false,
16
+ supportsOrientationChange: false,
17
+ hasCameraHardware: false,
18
+ };
19
+
20
+ export function supportsPosition(
21
+ profile: CapabilityProfile | null,
22
+ position: CameraPosition,
23
+ ): boolean {
24
+ return profile?.availablePositions.includes(position) ?? false;
25
+ }
26
+
27
+ /** `off` is always legal — it is the absence of flash, not a use of it. */
28
+ export function supportsFlashMode(
29
+ profile: CapabilityProfile | null,
30
+ mode: FlashMode,
31
+ ): boolean {
32
+ return mode === 'off' ? true : profile?.hasFlash === true;
33
+ }
34
+
35
+ export function supportsTorch(profile: CapabilityProfile | null): boolean {
36
+ return profile?.hasTorch === true;
37
+ }
38
+
39
+ /** Throws the uniform unsupported error so every gate reads the same way. */
40
+ export function assertSupported(supported: boolean, what: string): void {
41
+ if (!supported) {
42
+ throw new CameraError('unsupportedCapability', `Not supported on this device: ${what}`);
43
+ }
44
+ }