@chipmobilesdk/rn-camera 0.2.1 → 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.
@@ -7,6 +7,8 @@
7
7
  * tested without a camera.
8
8
  */
9
9
 
10
+ import { appLifecycle, createListenerSet } from '@chipmobilesdk/rn-core';
11
+
10
12
  import {
11
13
  type CameraPermissionState,
12
14
  type CameraPosition,
@@ -18,10 +20,15 @@ import {
18
20
  type StartOptions,
19
21
  } from '../config/types';
20
22
  import { CameraError, isCameraError } from '../diagnostics/errors';
21
- import { silentLogger, type CameraLogger } from '../diagnostics/logger';
23
+ import { silentLogger, withSdkSinks } from '../diagnostics/logger';
22
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';
23
31
  import type { PermissionPort } from '../permissions/permissionPort';
24
- import type { ArtifactStore } from '../storage/artifactStore';
25
32
  import { EMPTY_CAPABILITIES, assertSupported, supportsTorch } from './capabilities';
26
33
  import {
27
34
  SingleFlight,
@@ -30,72 +37,29 @@ import {
30
37
  type CaptureContext,
31
38
  } from './captureController';
32
39
  import { canStart, initialSession, sessionReducer, type SessionSnapshot } from './sessionMachine';
40
+ import { createSessionRuntime } from './sessionRuntime';
33
41
 
34
42
  /** 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
- /**
51
- * Set false to skip the sweep that runs when the controller is first used
52
- * (tests). Defaults to true.
53
- */
54
- readonly sweepOnInit?: boolean;
55
- }
56
-
57
- export interface CameraController {
58
- readonly state: CameraState;
59
- subscribe(listener: (state: CameraState) => void): () => void;
60
-
61
- getPermission(): Promise<CameraPermissionState>;
62
- requestPermission(): Promise<CameraPermissionState>;
63
-
64
- start(options?: StartOptions): Promise<void>;
65
- pause(): Promise<void>;
66
- resume(): Promise<void>;
67
- stop(): Promise<void>;
68
-
69
- capture(request?: CaptureRequest): Promise<CapturedImage>;
70
- cleanup(image: CapturedImage | string): Promise<void>;
71
-
72
- getCapabilities(): Promise<CapabilityProfile>;
73
- setFlashMode(mode: FlashMode): void;
74
- setTorch(enabled: boolean): Promise<void>;
75
-
76
- /** Marks the preview surface mounted, enabling foreground auto-restart. */
77
- setSurfaceMounted(mounted: boolean): void;
78
- /**
79
- * Releases ports, listeners, and the camera. Safe to call twice. Afterwards
80
- * `start`, `resume`, `capture` and the permission and capability reads reject
81
- * with `notReady`; `stop`, `pause` and `cleanup` stay safe no-ops so teardown
82
- * code never has to check.
83
- */
84
- dispose(): Promise<void>;
85
- }
43
+ export {
44
+ DEFAULT_TEMPORARY_TTL_MS,
45
+ type AppStatus,
46
+ type CameraController,
47
+ type CameraControllerOptions,
48
+ } from './contract';
86
49
 
87
50
  function defaultAppStateSubscribe(listener: (status: AppStatus) => void): () => void {
88
- // Lazy require so importing the package outside React Native (for example in
89
- // a Node-based test runner) does not pull in the RN runtime.
90
- const { AppState } = require('react-native');
91
- const subscription = AppState.addEventListener('change', (next: string) => {
92
- listener(next === 'active' ? 'active' : 'background');
93
- });
94
- return () => subscription.remove();
51
+ // rn-core's one lifecycle subscription, shared with every other package; it
52
+ // requires react-native only on first subscribe (PERF-07).
53
+ return appLifecycle().subscribe(state => listener(state === 'active' ? 'active' : 'background'));
95
54
  }
96
55
 
97
- export function createCameraController(options: CameraControllerOptions = {}): CameraController {
98
- 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.
99
63
  throw new CameraError(
100
64
  'unsupportedCapability',
101
65
  'createCameraController requires `engine` and `permissions` ports. Use the ' +
@@ -103,13 +67,18 @@ export function createCameraController(options: CameraControllerOptions = {}): C
103
67
  );
104
68
  }
105
69
 
106
- // Bound after the guard so the closures below see non-optional ports.
107
70
  const engine: CameraEngine = options.engine;
108
71
  const permissions: PermissionPort = options.permissions;
109
72
  const store = options.store;
110
- const logger = options.logger ?? silentLogger;
73
+ const logger = withSdkSinks(options.logger ?? silentLogger);
111
74
  const ttlMs = options.temporaryTtlMs ?? DEFAULT_TEMPORARY_TTL_MS;
112
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
+
113
82
  let session: SessionSnapshot = initialSession;
114
83
  let permission: CameraPermissionState = 'undetermined';
115
84
  let capabilities: CapabilityProfile | null = null;
@@ -118,18 +87,40 @@ export function createCameraController(options: CameraControllerOptions = {}): C
118
87
  let torchEnabled = false;
119
88
  let surfaceMounted = false;
120
89
  let disposed = false;
121
- let appStatus: AppStatus = 'active';
122
- // Set only when backgrounding cut a running session short. Foregrounding
123
- // used to restart from any idle or stopped session, so a camera the user had
124
- // stopped — or never started, or no longer had permission for — came back on
125
- // with the app.
126
- let resumeOnForeground = false;
127
- let releasing: Promise<void> | null = null;
128
90
  let warnedUnmanaged = false;
129
91
  let detach: (() => void) | null = null;
130
92
 
131
93
  const capture = new SingleFlight();
132
- const listeners = new Set<(state: CameraState) => void>();
94
+ const listeners = createListenerSet<[CameraState]>({
95
+ onListenerError: () => logger.log({ type: 'session', code: 'listener-threw' }),
96
+ });
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
+ });
133
124
 
134
125
  function snapshot(): CameraState {
135
126
  return {
@@ -140,6 +131,9 @@ export function createCameraController(options: CameraControllerOptions = {}): C
140
131
  flashMode,
141
132
  torchEnabled,
142
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(),
143
137
  error: session.error
144
138
  ? {
145
139
  code: session.error.code,
@@ -154,15 +148,9 @@ export function createCameraController(options: CameraControllerOptions = {}): C
154
148
 
155
149
  function emit(): void {
156
150
  current = snapshot();
157
- for (const listener of listeners) {
158
- // One throwing subscriber must not starve the others, nor unwind into
159
- // the transition that emitted — that left the controller half-updated.
160
- try {
161
- listener(current);
162
- } catch {
163
- logger.log({ type: 'session', code: 'listener-threw' });
164
- }
165
- }
151
+ // Isolated: one throwing subscriber neither starves the others nor unwinds
152
+ // into the transition that emitted, which left the controller half-updated.
153
+ listeners.notify(current);
166
154
  }
167
155
 
168
156
  function dispatch(event: Parameters<typeof sessionReducer>[1]): void {
@@ -178,72 +166,6 @@ export function createCameraController(options: CameraControllerOptions = {}): C
178
166
  }
179
167
  }
180
168
 
181
- /** Torch must never survive leaving the active state (FR-034b). */
182
- async function forceTorchOff(): Promise<void> {
183
- if (!torchEnabled) {
184
- return;
185
- }
186
- torchEnabled = false;
187
- try {
188
- await engine.setTorch(false);
189
- } catch {
190
- // Torch-off is best effort; the session is going away regardless.
191
- }
192
- emit();
193
- }
194
-
195
- async function releaseCamera(): Promise<void> {
196
- await forceTorchOff();
197
- // Nothing to release. Going through STOP/STOPPED here turned an idle
198
- // controller into `stopped` on every preview unmount.
199
- if (session.status === 'idle' || session.status === 'stopped') {
200
- return;
201
- }
202
- dispatch({ type: 'STOP' });
203
- try {
204
- await engine.stop();
205
- dispatch({ type: 'STOPPED' });
206
- } catch (error) {
207
- dispatch({ type: 'FAIL', code: isCameraError(error) ? error.code : 'captureFailed' });
208
- }
209
- }
210
-
211
- async function startEngine(startOptions: StartOptions = {}): Promise<void> {
212
- try {
213
- await engine.start({ cameraPosition: startOptions.cameraPosition ?? cameraPosition });
214
- dispatch({ type: 'STARTED' });
215
- } catch (error) {
216
- dispatch({ type: 'FAIL', code: isCameraError(error) ? error.code : 'cameraUnavailable' });
217
- }
218
- }
219
-
220
- function resumeAfterForeground(): void {
221
- if (disposed || appStatus !== 'active' || !resumeOnForeground) {
222
- return;
223
- }
224
- resumeOnForeground = false;
225
- if (!surfaceMounted || permission !== 'granted') {
226
- return;
227
- }
228
- dispatch({ type: 'FOREGROUND', surfaceMounted });
229
- if (session.status === 'starting') {
230
- startEngine().catch(() => undefined);
231
- }
232
- }
233
-
234
- function onAppState(status: AppStatus): void {
235
- appStatus = status;
236
- if (status === 'background') {
237
- resumeOnForeground ||= session.status === 'active' || session.status === 'starting';
238
- dispatch({ type: 'BACKGROUND' });
239
- releasing = releaseCamera().catch(() => undefined);
240
- } else {
241
- // Wait for a release still in flight, or the restart lands on a
242
- // `stopping` session and is dropped.
243
- (releasing ?? Promise.resolve()).then(resumeAfterForeground).catch(() => undefined);
244
- }
245
- }
246
-
247
169
  /**
248
170
  * Subscribes to permission and app-state changes and runs the sweep, on first
249
171
  * use rather than at construction. React may build a controller during a
@@ -261,18 +183,23 @@ export function createCameraController(options: CameraControllerOptions = {}): C
261
183
  // A revocation while running must tear the session down, not leave a
262
184
  // frozen preview on screen (FR-007).
263
185
  if (next !== 'granted') {
264
- resumeOnForeground = false;
186
+ runtime.armResumeOnForeground();
265
187
  if (session.status !== 'idle' && session.status !== 'stopped') {
266
- releaseCamera().catch(() => undefined);
188
+ runtime.release().catch(() => undefined);
267
189
  }
268
190
  }
269
191
  });
270
- const unsubscribeAppState = (options.subscribeToAppState ?? defaultAppStateSubscribe)(
271
- onAppState,
192
+ const unsubscribeAppState = (options.subscribeToAppState ?? defaultAppStateSubscribe)(status =>
193
+ runtime.onAppState(status),
272
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);
273
199
  detach = () => {
274
200
  unsubscribePermission();
275
201
  unsubscribeAppState();
202
+ unsubscribeInterruptions();
276
203
  };
277
204
 
278
205
  // FR-029: sweep our own temp directory once, asynchronously, so it can
@@ -336,10 +263,7 @@ export function createCameraController(options: CameraControllerOptions = {}): C
336
263
  return () => undefined;
337
264
  }
338
265
  attach();
339
- listeners.add(listener);
340
- return () => {
341
- listeners.delete(listener);
342
- };
266
+ return listeners.add(listener);
343
267
  },
344
268
 
345
269
  async getPermission() {
@@ -353,7 +277,17 @@ export function createCameraController(options: CameraControllerOptions = {}): C
353
277
  live('requestPermission');
354
278
  permission = 'requesting';
355
279
  emit();
356
- 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
+ }
357
291
  emit();
358
292
  return permission;
359
293
  },
@@ -371,7 +305,7 @@ export function createCameraController(options: CameraControllerOptions = {}): C
371
305
  cameraPosition = startOptions.cameraPosition;
372
306
  }
373
307
  dispatch({ type: 'START' });
374
- await startEngine(startOptions);
308
+ await runtime.start(startOptions);
375
309
  if (session.error) {
376
310
  throw session.error;
377
311
  }
@@ -381,7 +315,7 @@ export function createCameraController(options: CameraControllerOptions = {}): C
381
315
  if (disposed) {
382
316
  return;
383
317
  }
384
- await forceTorchOff();
318
+ await runtime.forceTorchOff();
385
319
  dispatch({ type: 'PAUSE' });
386
320
  },
387
321
 
@@ -389,17 +323,17 @@ export function createCameraController(options: CameraControllerOptions = {}): C
389
323
  live('resume');
390
324
  dispatch({ type: 'RESUME' });
391
325
  if (session.status === 'starting') {
392
- await startEngine();
326
+ await runtime.start();
393
327
  }
394
328
  },
395
329
 
396
330
  async stop() {
397
331
  // An explicit stop is the user's decision; foregrounding must not undo it.
398
- resumeOnForeground = false;
332
+ runtime.armResumeOnForeground();
399
333
  if (disposed) {
400
334
  return;
401
335
  }
402
- await releaseCamera();
336
+ await runtime.release();
403
337
  },
404
338
 
405
339
  async capture(request: CaptureRequest = {}) {
@@ -492,6 +426,26 @@ export function createCameraController(options: CameraControllerOptions = {}): C
492
426
  emit();
493
427
  },
494
428
 
429
+ reportSurfaceUnavailable() {
430
+ if (disposed) {
431
+ return;
432
+ }
433
+ logger.log({ type: 'session', code: 'surface-unavailable', recoverable: false });
434
+ dispatch({ type: 'FAIL', code: 'cameraUnavailable' });
435
+ },
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
+
495
449
  setSurfaceMounted(mounted: boolean) {
496
450
  if (disposed) {
497
451
  return;
@@ -504,10 +458,10 @@ export function createCameraController(options: CameraControllerOptions = {}): C
504
458
  return;
505
459
  }
506
460
  disposed = true;
507
- resumeOnForeground = false;
461
+ runtime.armResumeOnForeground();
508
462
  detach?.();
509
463
  detach = null;
510
- await releaseCamera();
464
+ await runtime.release();
511
465
  listeners.clear();
512
466
  },
513
467
  };
@@ -0,0 +1,242 @@
1
+ /**
2
+ * The session's own I/O: starting it, waiting for frames, releasing it, and
3
+ * following the app in and out of the foreground.
4
+ *
5
+ * Split out of `controller.ts`, which had grown to hold three jobs at once:
6
+ * publishing state, running the session, and serving the public API. The
7
+ * decisions still live in `sessionMachine` (pure); this is the part that
8
+ * actually awaits the engine, and the controller composes it.
9
+ *
10
+ * It reads and writes the controller's state through `SessionContext` rather
11
+ * than owning it: one snapshot reaches subscribers, whichever part of the
12
+ * controller changed it.
13
+ */
14
+
15
+ import type { StartOptions } from '../config/types';
16
+ import { isCameraError, type CameraErrorCode } from '../diagnostics/errors';
17
+ import type { CameraLogger } from '../diagnostics/logger';
18
+ import type { CameraEngine } from '../native/cameraEngine';
19
+ import type { SurfaceInfo } from '../native/visionSurface';
20
+ import type { SessionEvent, SessionSnapshot } from './sessionMachine';
21
+
22
+ /** What the runtime needs from the controller that owns the state. */
23
+ export interface SessionContext {
24
+ readonly engine: CameraEngine;
25
+ readonly logger: CameraLogger;
26
+ /** How long to wait for a preview surface to report frames. */
27
+ readonly startTimeoutMs: number;
28
+ readonly scheduler: {
29
+ setTimeout(callback: () => void, ms: number): unknown;
30
+ clearTimeout(handle: unknown): void;
31
+ };
32
+ session(): SessionSnapshot;
33
+ dispatch(event: SessionEvent): void;
34
+ emit(): void;
35
+ cameraPosition(): 'front' | 'back';
36
+ setCameraPosition(position: 'front' | 'back'): void;
37
+ torchEnabled(): boolean;
38
+ setTorchEnabled(enabled: boolean): void;
39
+ surfaceMounted(): boolean;
40
+ permissionGranted(): boolean;
41
+ disposed(): boolean;
42
+ }
43
+
44
+ export interface SessionRuntime {
45
+ /** Starts the engine and, where the surface owns the session, waits for frames. */
46
+ start(options?: StartOptions): Promise<void>;
47
+ /** Turns the torch off and releases the camera. Safe from any state. */
48
+ release(): Promise<void>;
49
+ /** Torch off, whatever the session is doing. */
50
+ forceTorchOff(): Promise<void>;
51
+ /** The app went to the background or came back. */
52
+ onAppState(status: 'active' | 'background'): void;
53
+ /** The preview reported frames; carries what the mounted device turned out to be. */
54
+ reportSurfaceStarted(info: SurfaceInfo): void;
55
+ /** The preview's own session failed, or the OS took the camera. */
56
+ reportSurfaceFailed(code: CameraErrorCode): void;
57
+ /** The engine lost the camera: another app, a call, a disconnected device. */
58
+ onInterruption(code: CameraErrorCode): void;
59
+ /** What the surface last reported, for the published state. */
60
+ surfaceInfo(): SurfaceInfo | null;
61
+ /** Set when the app foregrounds with a surface mounted, so the session restarts. */
62
+ armResumeOnForeground(): void;
63
+ /** A release still in flight, so a restart can wait for it. */
64
+ pending(): Promise<void> | undefined;
65
+ }
66
+
67
+ export function createSessionRuntime(context: SessionContext): SessionRuntime {
68
+ let surfaceInfo: SurfaceInfo | null = null;
69
+ /** Resolved by the surface's own report, or by the start timeout. */
70
+ let pendingStart: { settle(): void; fail(code: CameraErrorCode): void } | null = null;
71
+ let appStatus: 'active' | 'background' = 'active';
72
+ let resumeOnForeground = false;
73
+ let releasing: Promise<void> | undefined;
74
+
75
+ /** Torch must never survive leaving the active state (FR-034b). */
76
+ async function forceTorchOff(): Promise<void> {
77
+ if (!context.torchEnabled()) {
78
+ return;
79
+ }
80
+ context.setTorchEnabled(false);
81
+ try {
82
+ await context.engine.setTorch(false);
83
+ } catch {
84
+ // Torch-off is best effort; the session is going away regardless.
85
+ }
86
+ context.emit();
87
+ }
88
+
89
+ async function release(): Promise<void> {
90
+ await forceTorchOff();
91
+ // Nothing to release. Going through STOP/STOPPED here turned an idle
92
+ // controller into `stopped` on every preview unmount.
93
+ const status = context.session().status;
94
+ if (status === 'idle' || status === 'stopped') {
95
+ return;
96
+ }
97
+ context.dispatch({ type: 'STOP' });
98
+ try {
99
+ await context.engine.stop();
100
+ context.dispatch({ type: 'STOPPED' });
101
+ } catch (error) {
102
+ context.dispatch({
103
+ type: 'FAIL',
104
+ code: isCameraError(error) ? error.code : 'captureFailed',
105
+ });
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Waits for the mounted preview to report frames.
111
+ *
112
+ * `engine.start()` only confirms a device exists for the VisionCamera
113
+ * adapter: the session belongs to the `<Camera>` view. Calling the session
114
+ * active there made `capture()` right after `start()` fail with `notReady`,
115
+ * and a camera that never produced a frame left the session in `starting`
116
+ * forever (CAM-08).
117
+ */
118
+ function awaitSurfaceStart(): Promise<void> {
119
+ return new Promise<void>(resolve => {
120
+ const timer = context.scheduler.setTimeout(() => {
121
+ if (pendingStart) {
122
+ pendingStart = null;
123
+ context.dispatch({ type: 'START_TIMEOUT' });
124
+ resolve();
125
+ }
126
+ }, context.startTimeoutMs);
127
+ const finish = () => {
128
+ context.scheduler.clearTimeout(timer);
129
+ pendingStart = null;
130
+ resolve();
131
+ };
132
+ pendingStart = {
133
+ settle: () => {
134
+ context.dispatch({ type: 'STARTED' });
135
+ finish();
136
+ },
137
+ fail: code => {
138
+ context.dispatch({ type: 'FAIL', code });
139
+ finish();
140
+ },
141
+ };
142
+ });
143
+ }
144
+
145
+ async function start(options: StartOptions = {}): Promise<void> {
146
+ try {
147
+ await context.engine.start({
148
+ cameraPosition: options.cameraPosition ?? context.cameraPosition(),
149
+ });
150
+ } catch (error) {
151
+ context.dispatch({
152
+ type: 'FAIL',
153
+ code: isCameraError(error) ? error.code : 'cameraUnavailable',
154
+ });
155
+ return;
156
+ }
157
+ if (context.engine.awaitsSurfaceStart !== true || !context.surfaceMounted()) {
158
+ // The engine owns its session, or there is no preview to wait for.
159
+ context.dispatch({ type: 'STARTED' });
160
+ return;
161
+ }
162
+ await awaitSurfaceStart();
163
+ }
164
+
165
+ function resumeAfterForeground(): void {
166
+ if (context.disposed() || appStatus !== 'active' || !resumeOnForeground) {
167
+ return;
168
+ }
169
+ resumeOnForeground = false;
170
+ if (!context.surfaceMounted() || !context.permissionGranted()) {
171
+ return;
172
+ }
173
+ context.dispatch({ type: 'FOREGROUND', surfaceMounted: true });
174
+ if (context.session().status === 'starting') {
175
+ start().catch(() => undefined);
176
+ }
177
+ }
178
+
179
+ return {
180
+ start,
181
+ release,
182
+ forceTorchOff,
183
+
184
+ onAppState(status) {
185
+ appStatus = status;
186
+ if (status === 'background') {
187
+ const running = context.session().status;
188
+ resumeOnForeground ||= running === 'active' || running === 'starting';
189
+ context.dispatch({ type: 'BACKGROUND' });
190
+ releasing = release().catch(() => undefined);
191
+ } else {
192
+ // Wait for a release still in flight, or the restart lands on a
193
+ // `stopping` session and is dropped.
194
+ (releasing ?? Promise.resolve()).then(resumeAfterForeground).catch(() => undefined);
195
+ }
196
+ },
197
+
198
+ reportSurfaceStarted(info) {
199
+ surfaceInfo = info;
200
+ if (info.cameraPosition !== context.cameraPosition()) {
201
+ // The device that mounted is the one taking the photos.
202
+ context.setCameraPosition(info.cameraPosition);
203
+ }
204
+ if (pendingStart) {
205
+ pendingStart.settle();
206
+ } else if (context.session().status === 'starting') {
207
+ context.dispatch({ type: 'STARTED' });
208
+ } else {
209
+ context.emit();
210
+ }
211
+ },
212
+
213
+ reportSurfaceFailed(code) {
214
+ if (pendingStart) {
215
+ pendingStart.fail(code);
216
+ return;
217
+ }
218
+ context.dispatch({ type: 'FAIL', code });
219
+ },
220
+
221
+ onInterruption(code) {
222
+ context.logger.log({ type: 'session', code });
223
+ if (pendingStart) {
224
+ pendingStart.fail(code);
225
+ return;
226
+ }
227
+ const status = context.session().status;
228
+ if (status !== 'idle' && status !== 'stopped') {
229
+ context.dispatch({ type: 'FAIL', code });
230
+ release().catch(() => undefined);
231
+ }
232
+ },
233
+
234
+ surfaceInfo: () => surfaceInfo,
235
+
236
+ armResumeOnForeground() {
237
+ resumeOnForeground = false;
238
+ },
239
+
240
+ pending: () => releasing,
241
+ };
242
+ }