@chipmobilesdk/rn-camera 0.3.0 → 0.4.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 CHANGED
@@ -1,5 +1,27 @@
1
1
  # @chipmobilesdk/rn-camera
2
2
 
3
+ ## 0.4.0
4
+
5
+ ### Minor Changes
6
+
7
+ - `start()` resolves when the preview is producing frames. With the VisionCamera adapter the session belongs to the `<Camera>` view, so the controller now waits for that view to report instead of calling the session active as soon as the request was made — `capture()` straight after `start()` used to fail with `notReady`. A camera that never produces a frame fails the start after `startTimeoutMs` (8 s by default) rather than leaving the session in `starting` forever, and the native session errors the preview reports map to `cameraInUse`, `cameraBlocked`, `permissionDenied`, `cameraUnavailable` or `interrupted`. ([#19](https://github.com/operationd8979/chip-mobile-sdk/pull/19))
8
+
9
+ `pause()` keeps the preview surface mounted and stops the camera, so `resume()` no longer rebuilds the device, the session and the photo output — the whole first-frame cost was paid on every resume, with a black flash to go with it.
10
+
11
+ Geometry comes from the camera. `onGeometryChange` fires again with the selected format's pixel size, the device's orientation and the position of the device that actually mounted, as soon as the surface reports them; the same values are published as `state.surface`. Mapping a rectangle on a portrait phone was being done against a nominal landscape 1920×1080 image.
12
+
13
+ `capture({ cameraPosition })` must name the camera that is running, and is refused with `notReady` otherwise: the photo came from the device the preview had mounted while the metadata claimed the camera that was asked for. `start({ cameraPosition })` switches.
14
+
15
+ A failed `requestPermission()` leaves a state the app can retry from rather than `requesting` forever. An engine can report that the camera was taken away (`subscribeToInterruptions`), and the session fails with that code instead of showing a frozen preview; `createTestCamera()` simulates it with `emitInterruption(code?)` and a failing permission request with `emitPermissionRequestFailure(true)`.
16
+
17
+ The preview surface belongs to its engine (`attachSurface`, `createSurfaceRegistry`), not to a module-level slot two controllers shared — a capture from one screen could run through another screen's surface. `engine` and `permissions` are required by `CameraControllerOptions` (as they always were at runtime), the unused `EngineOptions` type is gone, and the artifact store reports a typed `CameraError` rather than a bare `Error`.
18
+
19
+ ### Patch Changes
20
+
21
+ - Internal: no module in these packages is over 500 lines any more. The camera controller's session I/O moved to `sessionRuntime` and its contract to `contract.ts`; the Play adapter's shape translation to `iapShapes`; the purchase flow's failure mapping and its receipt-validation hook to modules of their own; the AI client's quota request to `quotaRequest`; the in-memory adapter's attachments and the fake ads engine's clock likewise. Behaviour is unchanged — the splits are by responsibility, and every package's suite passes as it stood. ([#19](https://github.com/operationd8979/chip-mobile-sdk/pull/19))
22
+
23
+ The repository also measures what it promised: `npm run perf` prints the median of nine runs of the deterministic hot paths against written budgets (recurrence expansion, theme construction, translation lookup, chart geometry, filter compilation), the demo app has a Performance screen reporting startup phases, time to interactive and the same benchmarks on Hermes, and `npm run check:file-size` keeps the limit from drifting. The budgets and the measured figures are recorded in `docs/standards.md` §VI.
24
+
3
25
  ## 0.3.0
4
26
 
5
27
  ### Minor Changes
package/README.md CHANGED
@@ -213,11 +213,39 @@ const inImageSpace = mapRectToImage(myGuideFrame, geometry);
213
213
  ```
214
214
 
215
215
  The pure mapping functions account for scale, letterbox or crop, orientation,
216
- and front-camera mirroring using the geometry you supply. In the current
217
- `CameraPreview`, `onGeometryChange` reports nominal image dimensions
218
- (`1920 × 1080`), orientation `0`, and mirroring from camera position; it does
219
- not yet read the selected native capture format. Use capture metadata or
220
- app-owned measured geometry when pixel-exact alignment is required.
216
+ and front-camera mirroring using the geometry you supply.
217
+
218
+ `onGeometryChange` fires first with nominal dimensions (`1920 × 1080`,
219
+ orientation `0`) because the preview has been measured before the camera has,
220
+ and again with the real ones as soon as the surface reports: the selected
221
+ format's pixel size, the device's orientation, and the position of the device
222
+ that actually mounted. The same values are on `state.surface`, which is `null`
223
+ until then — map coordinates once it is set, or a rectangle will be mapped
224
+ against a landscape image on a portrait phone.
225
+
226
+ ### Starting, pausing, and what "active" means
227
+
228
+ `start()` resolves when the preview is producing frames, not when the request
229
+ was made: with the VisionCamera adapter the session belongs to the `<Camera>`
230
+ view, so the controller waits for it to report and `capture()` straight after
231
+ `start()` works. A camera that never produces a frame fails the start after
232
+ `startTimeoutMs` (8 s by default) instead of leaving the session in `starting`.
233
+
234
+ `pause()` keeps the surface mounted and stops the camera; `resume()` therefore
235
+ does not rebuild the device, the session and the photo output. The surface is
236
+ unmounted only when the session stops, fails, or loses permission.
237
+
238
+ An engine can report that the camera was taken away — another app, an incoming
239
+ call, a disconnected device — through `subscribeToInterruptions`. The session
240
+ fails with that code rather than showing a frozen preview; `createTestCamera()`
241
+ simulates it with `emitInterruption(code?)`.
242
+
243
+ ### Which camera a capture comes from
244
+
245
+ `capture({ cameraPosition })` must name the camera that is running: a photo
246
+ comes from the device the preview mounted, so a request for the other one is
247
+ refused with `notReady` rather than returning an image labelled with a camera
248
+ that did not take it. Call `start({ cameraPosition })` to switch first.
221
249
 
222
250
  ### Quality and dimensions
223
251
 
@@ -359,8 +387,9 @@ Everything is exported from `@chipmobilesdk/rn-camera`, test doubles and conform
359
387
  `CameraState`, `AppStatus`, `DEFAULT_TEMPORARY_TTL_MS`
360
388
  - Preview and capture: `CameraPreview`, `CameraPreviewProps`, `CaptureRequest`,
361
389
  `CapturedImage`, `StartOptions` and the camera/session/capability types
362
- - Ports and adapters: `CameraEngine`, `EngineOptions`,
363
- `createVisionCameraEngine`, `PermissionPort`,
390
+ - Ports and adapters: `CameraEngine`, `createVisionCameraEngine`,
391
+ `VisionSurface`, `SurfaceInfo`, `SurfaceRegistry`, `createSurfaceRegistry`,
392
+ `PermissionPort`,
364
393
  `createVisionCameraPermissions`, `ArtifactStore`, `FileSystemBridge`,
365
394
  `StoreOptions`, `createCacheArtifactStore`, `createArtifactStore`
366
395
  - Mapping: `mapPointToImage`, `mapPointToPreview`, `mapRectToImage`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chipmobilesdk/rn-camera",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "main": "src/index.ts",
@@ -56,9 +56,25 @@ Runtime:
56
56
  - Conformance (`@chipmobilesdk/rn-camera/testing`): `runPermissionPortConformance`,
57
57
  `runCameraEngineConformance` — run against any adapter you write or inject
58
58
 
59
- Types: `CameraEngine`, `EngineOptions`, `PermissionPort`, `ArtifactStore`,
60
- `FileSystemBridge`, `StoreOptions`, `CameraErrorCode`, `CameraLogEvent`,
61
- `CameraLogEventType`, `CameraLogger`
59
+ Types: `CameraEngine`, `PermissionPort`, `ArtifactStore`, `FileSystemBridge`,
60
+ `StoreOptions`, `VisionSurface`, `SurfaceInfo`, `SurfaceRegistry`,
61
+ `CameraErrorCode`, `CameraLogEvent`, `CameraLogEventType`, `CameraLogger`
62
+
63
+ ## Three things the lifecycle guarantees
64
+
65
+ - **`start()` resolves when frames arrive.** `capture()` straight after it works, and a
66
+ camera that never produces a frame fails the start (8 s by default) instead of leaving
67
+ the session in `starting`.
68
+ - **`pause()` keeps the surface mounted** and stops the camera, so `resume()` is cheap.
69
+ - **`capture({ cameraPosition })` must name the running camera.** A photo comes from the
70
+ device the preview mounted; asking for the other one is `notReady`. Switch with
71
+ `start({ cameraPosition })`.
72
+
73
+ ## Coordinate mapping waits for the surface
74
+
75
+ `state.surface` is `null` until the preview reports its real format, and
76
+ `onGeometryChange` fires again with those numbers. Map rectangles after it is set —
77
+ before that the geometry is a nominal landscape 1920×1080.
62
78
 
63
79
  Always pass camera log payloads through `redact` before logging.
64
80
 
@@ -21,6 +21,8 @@ export type FlashMode = 'auto' | 'on' | 'off';
21
21
  export type ResizeMode = 'cover' | 'contain';
22
22
  export type ArtifactOwnership = 'packageTemporary' | 'appOwned';
23
23
 
24
+ import type { SurfaceInfo } from '../native/visionSurface';
25
+
24
26
  export type SessionStatus =
25
27
  'idle' | 'starting' | 'active' | 'paused' | 'stopping' | 'stopped' | 'error';
26
28
 
@@ -129,6 +131,12 @@ export interface CameraState {
129
131
  readonly flashMode: FlashMode;
130
132
  readonly torchEnabled: boolean;
131
133
  readonly capturing: boolean;
134
+ /**
135
+ * What the mounted preview surface reported about itself: the capture
136
+ * dimensions of the selected format, its orientation, and the position of
137
+ * the device actually running. `null` until a surface reports (CAM-10).
138
+ */
139
+ readonly surface: SurfaceInfo | null;
132
140
  readonly error: CameraErrorLike | null;
133
141
  }
134
142
 
@@ -67,6 +67,15 @@ export function assertCaptureAllowed(context: CaptureContext, request: CaptureRe
67
67
 
68
68
  const position = request.cameraPosition ?? context.cameraPosition;
69
69
  assertSupported(supportsPosition(context.capabilities, position), `${position} camera`);
70
+ if (position !== context.cameraPosition) {
71
+ // The photo would come from the camera that is running, while the
72
+ // metadata said it came from the one that was asked for (CAM-06).
73
+ throw new CameraError(
74
+ 'notReady',
75
+ `The ${context.cameraPosition} camera is running. Call start({ cameraPosition: ` +
76
+ `'${position}' }) before capturing with the ${position} camera.`,
77
+ );
78
+ }
70
79
  }
71
80
 
72
81
  /**
@@ -83,7 +92,8 @@ export function finalizeResult(
83
92
  return {
84
93
  ...raw,
85
94
  ownership,
86
- cameraPosition: raw.cameraPosition ?? request.cameraPosition ?? context.cameraPosition,
95
+ // The camera that took it, which the guard above has made the running one.
96
+ cameraPosition: raw.cameraPosition ?? context.cameraPosition,
87
97
  timestamp: raw.timestamp ?? Date.now(),
88
98
  effectiveQuality: raw.effectiveQuality ?? clampQuality(request.quality),
89
99
  effectiveMaxDimension: raw.effectiveMaxDimension ?? clampMaxDimension(request.maxDimension),
@@ -0,0 +1,109 @@
1
+ /**
2
+ * What a camera controller is, for the app that drives it.
3
+ *
4
+ * The contract lives apart from the orchestration in `controller.ts`: it is
5
+ * what a consumer reads, and what a test double has to satisfy, while the file
6
+ * next to it is how this package happens to satisfy it.
7
+ */
8
+
9
+ import type {
10
+ CameraPermissionState,
11
+ CameraState,
12
+ CapabilityProfile,
13
+ CapturedImage,
14
+ CaptureRequest,
15
+ FlashMode,
16
+ StartOptions,
17
+ } from '../config/types';
18
+ import type { CameraError } from '../diagnostics/errors';
19
+ import type { CameraLogger } from '../diagnostics/logger';
20
+ import type { CameraEngine } from '../native/cameraEngine';
21
+ import type { SurfaceInfo, VisionSurface } from '../native/visionSurface';
22
+ import type { PermissionPort } from '../permissions/permissionPort';
23
+ import type { ArtifactStore } from '../storage/artifactStore';
24
+
25
+ export const DEFAULT_TEMPORARY_TTL_MS = 24 * 60 * 60 * 1000;
26
+
27
+ export type AppStatus = 'active' | 'background';
28
+
29
+ export interface CameraControllerOptions {
30
+ /** Required. Use the default adapters, or `createTestCamera()` in tests. */
31
+ readonly engine: CameraEngine;
32
+ readonly permissions: PermissionPort;
33
+ readonly store?: ArtifactStore | undefined;
34
+ readonly logger?: CameraLogger | undefined;
35
+ readonly temporaryTtlMs?: number;
36
+ /**
37
+ * App foreground/background source. Defaults to rn-core's shared `appLifecycle()`,
38
+ * where `inactive` counts as background. Injectable so lifecycle rules are
39
+ * testable without an app (FR-015).
40
+ */
41
+ readonly subscribeToAppState?: (listener: (status: AppStatus) => void) => () => void;
42
+ /**
43
+ * Set false to skip the sweep that runs when the controller is first used
44
+ * (tests). Defaults to true.
45
+ */
46
+ readonly sweepOnInit?: boolean;
47
+ /**
48
+ * How long to wait for a preview surface to report frames before failing the
49
+ * start (FR-014). Only applies to an engine whose session belongs to the
50
+ * surface (`awaitsSurfaceStart`). Defaults to 8 s.
51
+ */
52
+ readonly startTimeoutMs?: number;
53
+ /** Test seam for that timeout. */
54
+ readonly scheduler?: {
55
+ setTimeout(callback: () => void, ms: number): unknown;
56
+ clearTimeout(handle: unknown): void;
57
+ };
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
+ * Attaches the mounted preview surface to the engine, for an engine that
83
+ * captures through it. Returns the detach function; a no-op for engines that
84
+ * own their own session.
85
+ */
86
+ attachSurface(surface: VisionSurface): () => void;
87
+ /**
88
+ * The preview reported frames: the session is active for real, not merely
89
+ * requested (CAM-08). What the surface turned out to be — the format's pixel
90
+ * size, the orientation, the device's own position — is published as
91
+ * `state.surface` for coordinate mapping.
92
+ */
93
+ reportSurfaceStarted(info: SurfaceInfo): void;
94
+ /** The preview's own session failed, or the OS took the camera away. */
95
+ reportSurfaceFailed(code: CameraError['code']): void;
96
+ /**
97
+ * Called by `CameraPreview` when the native preview surface cannot load (the
98
+ * VisionCamera module is missing or unlinked). Fails the session with
99
+ * `cameraUnavailable` rather than leaving a black preview and no error.
100
+ */
101
+ reportSurfaceUnavailable(): void;
102
+ /**
103
+ * Releases ports, listeners, and the camera. Safe to call twice. Afterwards
104
+ * `start`, `resume`, `capture` and the permission and capability reads reject
105
+ * with `notReady`; `stop`, `pause` and `cleanup` stay safe no-ops so teardown
106
+ * code never has to check.
107
+ */
108
+ dispose(): Promise<void>;
109
+ }
@@ -20,10 +20,15 @@ import {
20
20
  type StartOptions,
21
21
  } from '../config/types';
22
22
  import { CameraError, isCameraError } from '../diagnostics/errors';
23
- import { silentLogger, withSdkSinks, type CameraLogger } from '../diagnostics/logger';
23
+ import { silentLogger, withSdkSinks } from '../diagnostics/logger';
24
24
  import type { CameraEngine } from '../native/cameraEngine';
25
+ import {
26
+ DEFAULT_TEMPORARY_TTL_MS,
27
+ type AppStatus,
28
+ type CameraController,
29
+ type CameraControllerOptions,
30
+ } from './contract';
25
31
  import type { PermissionPort } from '../permissions/permissionPort';
26
- import type { ArtifactStore } from '../storage/artifactStore';
27
32
  import { EMPTY_CAPABILITIES, assertSupported, supportsTorch } from './capabilities';
28
33
  import {
29
34
  SingleFlight,
@@ -32,66 +37,15 @@ import {
32
37
  type CaptureContext,
33
38
  } from './captureController';
34
39
  import { canStart, initialSession, sessionReducer, type SessionSnapshot } from './sessionMachine';
40
+ import { createSessionRuntime } from './sessionRuntime';
35
41
 
36
42
  /** 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
- }
43
+ export {
44
+ DEFAULT_TEMPORARY_TTL_MS,
45
+ type AppStatus,
46
+ type CameraController,
47
+ type CameraControllerOptions,
48
+ } from './contract';
95
49
 
96
50
  function defaultAppStateSubscribe(listener: (status: AppStatus) => void): () => void {
97
51
  // rn-core's one lifecycle subscription, shared with every other package; it
@@ -99,8 +53,13 @@ function defaultAppStateSubscribe(listener: (status: AppStatus) => void): () =>
99
53
  return appLifecycle().subscribe(state => listener(state === 'active' ? 'active' : 'background'));
100
54
  }
101
55
 
102
- export function createCameraController(options: CameraControllerOptions = {}): CameraController {
103
- if (!options.engine || !options.permissions) {
56
+ /** 8 s: a cold camera on a slow device, with room to spare (PERF-001 is 1.5 s). */
57
+ export const DEFAULT_START_TIMEOUT_MS = 8_000;
58
+
59
+ export function createCameraController(options: CameraControllerOptions): CameraController {
60
+ if (!options?.engine || !options?.permissions) {
61
+ // A configuration fault, named as one. The ports are required by the type;
62
+ // this is the answer for a JavaScript caller or a cast.
104
63
  throw new CameraError(
105
64
  'unsupportedCapability',
106
65
  'createCameraController requires `engine` and `permissions` ports. Use the ' +
@@ -108,13 +67,18 @@ export function createCameraController(options: CameraControllerOptions = {}): C
108
67
  );
109
68
  }
110
69
 
111
- // Bound after the guard so the closures below see non-optional ports.
112
70
  const engine: CameraEngine = options.engine;
113
71
  const permissions: PermissionPort = options.permissions;
114
72
  const store = options.store;
115
73
  const logger = withSdkSinks(options.logger ?? silentLogger);
116
74
  const ttlMs = options.temporaryTtlMs ?? DEFAULT_TEMPORARY_TTL_MS;
117
75
 
76
+ const startTimeoutMs = options.startTimeoutMs ?? DEFAULT_START_TIMEOUT_MS;
77
+ const scheduler = options.scheduler ?? {
78
+ setTimeout: (callback: () => void, ms: number) => setTimeout(callback, ms),
79
+ clearTimeout: (handle: unknown) => clearTimeout(handle as ReturnType<typeof setTimeout>),
80
+ };
81
+
118
82
  let session: SessionSnapshot = initialSession;
119
83
  let permission: CameraPermissionState = 'undetermined';
120
84
  let capabilities: CapabilityProfile | null = null;
@@ -123,13 +87,6 @@ export function createCameraController(options: CameraControllerOptions = {}): C
123
87
  let torchEnabled = false;
124
88
  let surfaceMounted = false;
125
89
  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
90
  let warnedUnmanaged = false;
134
91
  let detach: (() => void) | null = null;
135
92
 
@@ -138,6 +95,33 @@ export function createCameraController(options: CameraControllerOptions = {}): C
138
95
  onListenerError: () => logger.log({ type: 'session', code: 'listener-threw' }),
139
96
  });
140
97
 
98
+ /**
99
+ * The session's own I/O (`sessionRuntime`): starting it, waiting for frames,
100
+ * releasing it, following the app in and out of the foreground. It reads and
101
+ * writes the state above through this context, so one snapshot reaches
102
+ * subscribers whichever part changed it.
103
+ */
104
+ const runtime = createSessionRuntime({
105
+ engine,
106
+ logger,
107
+ startTimeoutMs,
108
+ scheduler,
109
+ session: () => session,
110
+ dispatch,
111
+ emit,
112
+ cameraPosition: () => cameraPosition,
113
+ setCameraPosition: position => {
114
+ cameraPosition = position;
115
+ },
116
+ torchEnabled: () => torchEnabled,
117
+ setTorchEnabled: enabled => {
118
+ torchEnabled = enabled;
119
+ },
120
+ surfaceMounted: () => surfaceMounted,
121
+ permissionGranted: () => permission === 'granted',
122
+ disposed: () => disposed,
123
+ });
124
+
141
125
  function snapshot(): CameraState {
142
126
  return {
143
127
  session: session.status,
@@ -147,6 +131,9 @@ export function createCameraController(options: CameraControllerOptions = {}): C
147
131
  flashMode,
148
132
  torchEnabled,
149
133
  capturing: capture.busy,
134
+ // What the mounted preview turned out to be, once it reported: the
135
+ // format's pixel size and the orientation coordinate mapping needs.
136
+ surface: runtime.surfaceInfo(),
150
137
  error: session.error
151
138
  ? {
152
139
  code: session.error.code,
@@ -179,72 +166,6 @@ export function createCameraController(options: CameraControllerOptions = {}): C
179
166
  }
180
167
  }
181
168
 
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
169
  /**
249
170
  * Subscribes to permission and app-state changes and runs the sweep, on first
250
171
  * use rather than at construction. React may build a controller during a
@@ -262,18 +183,23 @@ export function createCameraController(options: CameraControllerOptions = {}): C
262
183
  // A revocation while running must tear the session down, not leave a
263
184
  // frozen preview on screen (FR-007).
264
185
  if (next !== 'granted') {
265
- resumeOnForeground = false;
186
+ runtime.armResumeOnForeground();
266
187
  if (session.status !== 'idle' && session.status !== 'stopped') {
267
- releaseCamera().catch(() => undefined);
188
+ runtime.release().catch(() => undefined);
268
189
  }
269
190
  }
270
191
  });
271
- const unsubscribeAppState = (options.subscribeToAppState ?? defaultAppStateSubscribe)(
272
- onAppState,
192
+ const unsubscribeAppState = (options.subscribeToAppState ?? defaultAppStateSubscribe)(status =>
193
+ runtime.onAppState(status),
273
194
  );
195
+ // The OS can take the camera at any moment — another app, an incoming
196
+ // call, a disconnected device. The session has to go with it (CAM-19).
197
+ const unsubscribeInterruptions =
198
+ engine.subscribeToInterruptions?.(code => runtime.onInterruption(code)) ?? (() => undefined);
274
199
  detach = () => {
275
200
  unsubscribePermission();
276
201
  unsubscribeAppState();
202
+ unsubscribeInterruptions();
277
203
  };
278
204
 
279
205
  // FR-029: sweep our own temp directory once, asynchronously, so it can
@@ -351,7 +277,17 @@ export function createCameraController(options: CameraControllerOptions = {}): C
351
277
  live('requestPermission');
352
278
  permission = 'requesting';
353
279
  emit();
354
- permission = await permissions.request();
280
+ try {
281
+ permission = await permissions.request();
282
+ } catch (error) {
283
+ // A native call that failed used to leave the state at `requesting`
284
+ // forever, with no way for the app to ask again (CAM-07).
285
+ permission = await permissions.getStatus().catch(() => 'unavailable' as const);
286
+ emit();
287
+ throw isCameraError(error)
288
+ ? error
289
+ : new CameraError('permissionDenied', 'The permission request could not be completed');
290
+ }
355
291
  emit();
356
292
  return permission;
357
293
  },
@@ -369,7 +305,7 @@ export function createCameraController(options: CameraControllerOptions = {}): C
369
305
  cameraPosition = startOptions.cameraPosition;
370
306
  }
371
307
  dispatch({ type: 'START' });
372
- await startEngine(startOptions);
308
+ await runtime.start(startOptions);
373
309
  if (session.error) {
374
310
  throw session.error;
375
311
  }
@@ -379,7 +315,7 @@ export function createCameraController(options: CameraControllerOptions = {}): C
379
315
  if (disposed) {
380
316
  return;
381
317
  }
382
- await forceTorchOff();
318
+ await runtime.forceTorchOff();
383
319
  dispatch({ type: 'PAUSE' });
384
320
  },
385
321
 
@@ -387,17 +323,17 @@ export function createCameraController(options: CameraControllerOptions = {}): C
387
323
  live('resume');
388
324
  dispatch({ type: 'RESUME' });
389
325
  if (session.status === 'starting') {
390
- await startEngine();
326
+ await runtime.start();
391
327
  }
392
328
  },
393
329
 
394
330
  async stop() {
395
331
  // An explicit stop is the user's decision; foregrounding must not undo it.
396
- resumeOnForeground = false;
332
+ runtime.armResumeOnForeground();
397
333
  if (disposed) {
398
334
  return;
399
335
  }
400
- await releaseCamera();
336
+ await runtime.release();
401
337
  },
402
338
 
403
339
  async capture(request: CaptureRequest = {}) {
@@ -498,6 +434,18 @@ export function createCameraController(options: CameraControllerOptions = {}): C
498
434
  dispatch({ type: 'FAIL', code: 'cameraUnavailable' });
499
435
  },
500
436
 
437
+ attachSurface(surface) {
438
+ return engine.attachSurface?.(surface) ?? (() => undefined);
439
+ },
440
+
441
+ reportSurfaceStarted(info) {
442
+ runtime.reportSurfaceStarted(info);
443
+ },
444
+
445
+ reportSurfaceFailed(code) {
446
+ runtime.reportSurfaceFailed(code);
447
+ },
448
+
501
449
  setSurfaceMounted(mounted: boolean) {
502
450
  if (disposed) {
503
451
  return;
@@ -510,10 +458,10 @@ export function createCameraController(options: CameraControllerOptions = {}): C
510
458
  return;
511
459
  }
512
460
  disposed = true;
513
- resumeOnForeground = false;
461
+ runtime.armResumeOnForeground();
514
462
  detach?.();
515
463
  detach = null;
516
- await releaseCamera();
464
+ await runtime.release();
517
465
  listeners.clear();
518
466
  },
519
467
  };