@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,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The live VisionCamera surface rendered inside `CameraPreview`.
|
|
3
|
+
*
|
|
4
|
+
* Split into its own module so `CameraPreview` can require it lazily: consumers
|
|
5
|
+
* that only use the pure utilities or the test adapter never load the native
|
|
6
|
+
* module, while the hooks this component needs (`useCameraDevice`,
|
|
7
|
+
* `usePhotoOutput`) can still be called normally.
|
|
8
|
+
*
|
|
9
|
+
* It also owns capture. In VisionCamera v5 the photo output belongs to the
|
|
10
|
+
* session the `<Camera>` view configures, so the output only exists while this
|
|
11
|
+
* surface is mounted; it is published to the engine through `visionSurface`.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import React, { useEffect, useMemo } from 'react';
|
|
15
|
+
import { StyleSheet } from 'react-native';
|
|
16
|
+
import {
|
|
17
|
+
Camera,
|
|
18
|
+
useCameraDevice,
|
|
19
|
+
usePhotoOutput,
|
|
20
|
+
type CameraPhotoOutput,
|
|
21
|
+
} from 'react-native-vision-camera';
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
known,
|
|
25
|
+
unavailable,
|
|
26
|
+
type CameraPosition,
|
|
27
|
+
type CapturedImage,
|
|
28
|
+
type CaptureRequest,
|
|
29
|
+
type ResizeMode,
|
|
30
|
+
} from '../config/types';
|
|
31
|
+
import { clampMaxDimension, clampQuality } from '../core/captureController';
|
|
32
|
+
import { CameraError } from '../diagnostics/errors';
|
|
33
|
+
import { registerVisionSurface } from '../native/visionSurface';
|
|
34
|
+
|
|
35
|
+
export interface VisionCameraFeedProps {
|
|
36
|
+
readonly cameraPosition: CameraPosition;
|
|
37
|
+
readonly resizeMode: ResizeMode;
|
|
38
|
+
readonly torchEnabled: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const styles = StyleSheet.create({
|
|
42
|
+
feed: { flex: 1 },
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const MIME_TYPES: Record<string, string> = {
|
|
46
|
+
jpeg: 'image/jpeg',
|
|
47
|
+
heic: 'image/heic',
|
|
48
|
+
dng: 'image/x-adobe-dng',
|
|
49
|
+
tiff: 'image/tiff',
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** VisionCamera reports orientation as a compass value; the contract wants degrees. */
|
|
53
|
+
const ORIENTATION_DEGREES: Record<string, number> = {
|
|
54
|
+
up: 0,
|
|
55
|
+
right: 90,
|
|
56
|
+
down: 180,
|
|
57
|
+
left: 270,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
function toFileUri(path: string): string {
|
|
61
|
+
return path.startsWith('file://') ? path : `file://${path}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function toFilePath(uri: string): string {
|
|
65
|
+
return uri.startsWith('file://') ? uri.slice('file://'.length) : uri;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function capturePhoto(
|
|
69
|
+
photoOutput: CameraPhotoOutput,
|
|
70
|
+
request: CaptureRequest,
|
|
71
|
+
cameraPosition: CameraPosition,
|
|
72
|
+
): Promise<CapturedImage> {
|
|
73
|
+
const photo = await photoOutput.capturePhoto(
|
|
74
|
+
{ flashMode: request.flashMode ?? 'off' },
|
|
75
|
+
{},
|
|
76
|
+
);
|
|
77
|
+
try {
|
|
78
|
+
// A destination makes the artifact app-owned, so it is written straight
|
|
79
|
+
// there; otherwise it goes to a temporary file (FR-026a).
|
|
80
|
+
let path: string;
|
|
81
|
+
if (request.destination) {
|
|
82
|
+
try {
|
|
83
|
+
await photo.saveToFileAsync(toFilePath(request.destination));
|
|
84
|
+
} catch (error) {
|
|
85
|
+
throw new CameraError(
|
|
86
|
+
'storageFailure',
|
|
87
|
+
`Could not write the capture to the requested destination: ${
|
|
88
|
+
error instanceof Error ? error.message : 'unknown error'
|
|
89
|
+
}`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
path = request.destination;
|
|
93
|
+
} else {
|
|
94
|
+
path = await photo.saveToTemporaryFileAsync();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
uri: toFileUri(path),
|
|
99
|
+
mimeType: MIME_TYPES[photo.containerFormat] ?? 'application/octet-stream',
|
|
100
|
+
width: photo.width,
|
|
101
|
+
height: photo.height,
|
|
102
|
+
timestamp: Date.now(),
|
|
103
|
+
cameraPosition: request.cameraPosition ?? cameraPosition,
|
|
104
|
+
// Ownership is decided by the controller from the original request;
|
|
105
|
+
// this value is overwritten there and is only a safe default.
|
|
106
|
+
ownership: request.destination ? 'appOwned' : 'packageTemporary',
|
|
107
|
+
effectiveQuality: clampQuality(request.quality),
|
|
108
|
+
effectiveMaxDimension: clampMaxDimension(request.maxDimension),
|
|
109
|
+
// The native layer reports neither back after a save, and guessing them
|
|
110
|
+
// would be worse than saying so (FR-022).
|
|
111
|
+
fileSize: unavailable(),
|
|
112
|
+
orientation:
|
|
113
|
+
photo.orientation in ORIENTATION_DEGREES
|
|
114
|
+
? known(ORIENTATION_DEGREES[photo.orientation])
|
|
115
|
+
: unavailable(),
|
|
116
|
+
};
|
|
117
|
+
} finally {
|
|
118
|
+
// Large native buffers must not wait for the JS GC.
|
|
119
|
+
photo.dispose();
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export const VisionCameraFeed: React.FC<VisionCameraFeedProps> = ({
|
|
124
|
+
cameraPosition,
|
|
125
|
+
resizeMode,
|
|
126
|
+
torchEnabled,
|
|
127
|
+
}) => {
|
|
128
|
+
const device = useCameraDevice(cameraPosition);
|
|
129
|
+
const photoOutput = usePhotoOutput();
|
|
130
|
+
|
|
131
|
+
const surface = useMemo(
|
|
132
|
+
() => ({
|
|
133
|
+
capture: (request: CaptureRequest) =>
|
|
134
|
+
capturePhoto(photoOutput, request, cameraPosition),
|
|
135
|
+
}),
|
|
136
|
+
[photoOutput, cameraPosition],
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
useEffect(() => registerVisionSurface(surface), [surface]);
|
|
140
|
+
|
|
141
|
+
if (!device) {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return (
|
|
146
|
+
<Camera
|
|
147
|
+
style={styles.feed}
|
|
148
|
+
device={device}
|
|
149
|
+
outputs={[photoOutput]}
|
|
150
|
+
isActive={true}
|
|
151
|
+
resizeMode={resizeMode}
|
|
152
|
+
torchMode={torchEnabled ? 'on' : 'off'}
|
|
153
|
+
/>
|
|
154
|
+
);
|
|
155
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React bindings for the camera controller.
|
|
3
|
+
*
|
|
4
|
+
* `useCameraController` owns the controller lifetime and disposes it on
|
|
5
|
+
* unmount, which is what guarantees the camera is released when a screen goes
|
|
6
|
+
* away (FR-016).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
10
|
+
|
|
11
|
+
import type { CameraState } from '../config/types';
|
|
12
|
+
import {
|
|
13
|
+
createCameraController,
|
|
14
|
+
type CameraController,
|
|
15
|
+
type CameraControllerOptions,
|
|
16
|
+
} from '../core/controller';
|
|
17
|
+
|
|
18
|
+
export function useCameraController(
|
|
19
|
+
options: CameraControllerOptions = {},
|
|
20
|
+
): CameraController {
|
|
21
|
+
// Options usually arrive as a fresh object literal each render; capturing
|
|
22
|
+
// them in a ref keeps the controller stable for the component's lifetime.
|
|
23
|
+
const optionsRef = useRef(options);
|
|
24
|
+
const controller = useMemo(() => createCameraController(optionsRef.current), []);
|
|
25
|
+
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
return () => {
|
|
28
|
+
controller.dispose().catch(() => undefined);
|
|
29
|
+
};
|
|
30
|
+
}, [controller]);
|
|
31
|
+
|
|
32
|
+
return controller;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function useCameraState(controller: CameraController): CameraState {
|
|
36
|
+
const [state, setState] = useState<CameraState>(controller.state);
|
|
37
|
+
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
setState(controller.state);
|
|
40
|
+
return controller.subscribe(setState);
|
|
41
|
+
}, [controller]);
|
|
42
|
+
|
|
43
|
+
return state;
|
|
44
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ArtifactStore port + default cache-directory adapter (FR-026..FR-029).
|
|
3
|
+
*
|
|
4
|
+
* Package-owned artifacts live in a dedicated `rn-camera` subdirectory of the
|
|
5
|
+
* platform cache directory (research R6). That dedicated prefix is what makes
|
|
6
|
+
* ownership checkable rather than advisory: `owns()` is a path-prefix test, so
|
|
7
|
+
* the package can refuse to delete anything it did not create (FR-028).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface ArtifactStore {
|
|
11
|
+
/** Absolute path of the package-owned temporary directory. */
|
|
12
|
+
temporaryDirectory(): string;
|
|
13
|
+
/** True only for paths the package created (FR-028). */
|
|
14
|
+
owns(uri: string): boolean;
|
|
15
|
+
/** Deletes an artifact. Resolves even if already gone (FR-027). */
|
|
16
|
+
remove(uri: string): Promise<void>;
|
|
17
|
+
/** Deletes owned artifacts older than `olderThanMs`; returns the count. */
|
|
18
|
+
sweep(olderThanMs: number): Promise<number>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Minimal filesystem surface the adapter needs. Injectable for tests. */
|
|
22
|
+
export interface FileSystemBridge {
|
|
23
|
+
cacheDirectory(): string;
|
|
24
|
+
exists(path: string): Promise<boolean>;
|
|
25
|
+
unlink(path: string): Promise<void>;
|
|
26
|
+
list(directory: string): Promise<readonly string[]>;
|
|
27
|
+
createdAt(path: string): Promise<number>;
|
|
28
|
+
ensureDirectory(directory: string): Promise<void>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface StoreOptions {
|
|
32
|
+
readonly fs?: FileSystemBridge;
|
|
33
|
+
/** Directory name under the cache directory. Defaults to `rn-camera`. */
|
|
34
|
+
readonly directoryName?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Normalizes separators so prefix comparison works on both platforms. */
|
|
38
|
+
function normalize(path: string): string {
|
|
39
|
+
return path.replace(/\\/g, '/').replace(/^file:\/\//, '');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function createArtifactStore(
|
|
43
|
+
fs: FileSystemBridge,
|
|
44
|
+
directoryName = 'rn-camera',
|
|
45
|
+
): ArtifactStore {
|
|
46
|
+
const root = `${fs.cacheDirectory().replace(/\/$/, '')}/${directoryName}`;
|
|
47
|
+
const normalizedRoot = normalize(root);
|
|
48
|
+
// Artifacts created by this process are never swept, no matter their age.
|
|
49
|
+
const createdThisProcess = new Set<string>();
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
temporaryDirectory() {
|
|
53
|
+
return root;
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
owns(uri) {
|
|
57
|
+
return normalize(uri).startsWith(`${normalizedRoot}/`);
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
async remove(uri) {
|
|
61
|
+
if (!this.owns(uri)) {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const path = normalize(uri);
|
|
65
|
+
// Idempotent: a missing artifact is a successful cleanup (FR-027).
|
|
66
|
+
if (await fs.exists(path)) {
|
|
67
|
+
await fs.unlink(path);
|
|
68
|
+
}
|
|
69
|
+
createdThisProcess.delete(path);
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
async sweep(olderThanMs) {
|
|
73
|
+
await fs.ensureDirectory(root);
|
|
74
|
+
const entries = await fs.list(root);
|
|
75
|
+
const cutoff = Date.now() - olderThanMs;
|
|
76
|
+
let removed = 0;
|
|
77
|
+
|
|
78
|
+
for (const entry of entries) {
|
|
79
|
+
const path = normalize(entry);
|
|
80
|
+
if (createdThisProcess.has(path)) {
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
const created = await fs.createdAt(path);
|
|
84
|
+
if (created < cutoff) {
|
|
85
|
+
await fs.unlink(path);
|
|
86
|
+
removed += 1;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return removed;
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Default adapter. The concrete filesystem bridge is supplied by the native
|
|
96
|
+
* layer at runtime; it is a parameter rather than a hard import so the package
|
|
97
|
+
* takes no filesystem dependency of its own.
|
|
98
|
+
*/
|
|
99
|
+
export function createCacheArtifactStore(options: StoreOptions = {}): ArtifactStore {
|
|
100
|
+
const fs = options.fs;
|
|
101
|
+
if (!fs) {
|
|
102
|
+
throw new Error(
|
|
103
|
+
'createCacheArtifactStore requires a FileSystemBridge. Pass `fs`, or use ' +
|
|
104
|
+
'createTestCamera() in tests.',
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
return createArtifactStore(fs, options.directoryName);
|
|
108
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic in-memory camera for tests (FR-041, SC-010).
|
|
3
|
+
*
|
|
4
|
+
* These are real implementations of the same published ports the production
|
|
5
|
+
* adapters implement, not a parallel mock — so a contract change breaks them at
|
|
6
|
+
* compile time and they cannot drift. Nothing here touches a native module, the
|
|
7
|
+
* filesystem, or a permission API.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
known,
|
|
12
|
+
unavailable,
|
|
13
|
+
type CameraPermissionState,
|
|
14
|
+
type CameraPosition,
|
|
15
|
+
type CapabilityProfile,
|
|
16
|
+
type CapturedImage,
|
|
17
|
+
type CaptureRequest,
|
|
18
|
+
} from '../config/types';
|
|
19
|
+
import { CameraError, type CameraErrorCode } from '../diagnostics/errors';
|
|
20
|
+
import type { CameraEngine } from '../native/cameraEngine';
|
|
21
|
+
import type { PermissionPort } from '../permissions/permissionPort';
|
|
22
|
+
import {
|
|
23
|
+
createArtifactStore,
|
|
24
|
+
type ArtifactStore,
|
|
25
|
+
type FileSystemBridge,
|
|
26
|
+
} from '../storage/artifactStore';
|
|
27
|
+
import type { AppStatus } from '../core/controller';
|
|
28
|
+
|
|
29
|
+
export interface MemoryFileSystem extends FileSystemBridge {
|
|
30
|
+
/** Seeds a file with an explicit creation time, for time-to-live tests. */
|
|
31
|
+
seed(path: string, createdAtMs: number): void;
|
|
32
|
+
/** Every path currently present. */
|
|
33
|
+
paths(): readonly string[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function createMemoryFileSystem(root = '/cache'): MemoryFileSystem {
|
|
37
|
+
const files = new Map<string, number>();
|
|
38
|
+
const directories = new Set<string>();
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
cacheDirectory: () => root,
|
|
42
|
+
async exists(path) {
|
|
43
|
+
return files.has(path);
|
|
44
|
+
},
|
|
45
|
+
async unlink(path) {
|
|
46
|
+
files.delete(path);
|
|
47
|
+
},
|
|
48
|
+
async list(directory) {
|
|
49
|
+
const prefix = `${directory.replace(/\/$/, '')}/`;
|
|
50
|
+
return Array.from(files.keys()).filter(path => path.startsWith(prefix));
|
|
51
|
+
},
|
|
52
|
+
async createdAt(path) {
|
|
53
|
+
return files.get(path) ?? Date.now();
|
|
54
|
+
},
|
|
55
|
+
async ensureDirectory(directory) {
|
|
56
|
+
directories.add(directory);
|
|
57
|
+
},
|
|
58
|
+
seed(path, createdAtMs) {
|
|
59
|
+
files.set(path, createdAtMs);
|
|
60
|
+
},
|
|
61
|
+
paths: () => Array.from(files.keys()),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const DEFAULT_TEST_CAPABILITIES: CapabilityProfile = {
|
|
66
|
+
availablePositions: ['back', 'front'],
|
|
67
|
+
hasFlash: true,
|
|
68
|
+
hasTorch: true,
|
|
69
|
+
supportsOrientationChange: true,
|
|
70
|
+
hasCameraHardware: true,
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export interface TestCameraConfig {
|
|
74
|
+
readonly permission?: CameraPermissionState;
|
|
75
|
+
readonly capabilities?: Partial<CapabilityProfile>;
|
|
76
|
+
readonly captureResult?: Partial<CapturedImage>;
|
|
77
|
+
readonly captureError?: CameraErrorCode;
|
|
78
|
+
readonly startError?: CameraErrorCode;
|
|
79
|
+
readonly cacheRoot?: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface TestCameraHandles {
|
|
83
|
+
readonly engine: CameraEngine;
|
|
84
|
+
readonly permissions: PermissionPort;
|
|
85
|
+
readonly store: ArtifactStore;
|
|
86
|
+
readonly fs: MemoryFileSystem;
|
|
87
|
+
/** Drives the controller's app foreground/background handling. */
|
|
88
|
+
subscribeToAppState(listener: (status: AppStatus) => void): () => void;
|
|
89
|
+
setAppState(status: AppStatus): void;
|
|
90
|
+
/** Simulates a Settings-level permission change while running (FR-007). */
|
|
91
|
+
setPermission(state: CameraPermissionState): void;
|
|
92
|
+
/** Simulates an OS preemption such as an incoming call. */
|
|
93
|
+
emitInterruption(): void;
|
|
94
|
+
setCaptureError(code: CameraErrorCode | null): void;
|
|
95
|
+
setStartError(code: CameraErrorCode | null): void;
|
|
96
|
+
/** Every capture the engine produced, in order. */
|
|
97
|
+
captures(): readonly CapturedImage[];
|
|
98
|
+
torchState(): boolean;
|
|
99
|
+
startCount(): number;
|
|
100
|
+
stopCount(): number;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function createTestCamera(config: TestCameraConfig = {}): TestCameraHandles {
|
|
104
|
+
const fs = createMemoryFileSystem(config.cacheRoot ?? '/cache');
|
|
105
|
+
const store = createArtifactStore(fs);
|
|
106
|
+
const capabilities: CapabilityProfile = {
|
|
107
|
+
...DEFAULT_TEST_CAPABILITIES,
|
|
108
|
+
...config.capabilities,
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
let permission: CameraPermissionState = config.permission ?? 'undetermined';
|
|
112
|
+
let captureError: CameraErrorCode | null = config.captureError ?? null;
|
|
113
|
+
let startError: CameraErrorCode | null = config.startError ?? null;
|
|
114
|
+
let torch = false;
|
|
115
|
+
let starts = 0;
|
|
116
|
+
let stops = 0;
|
|
117
|
+
let sequence = 0;
|
|
118
|
+
|
|
119
|
+
const produced: CapturedImage[] = [];
|
|
120
|
+
const permissionListeners = new Set<(state: CameraPermissionState) => void>();
|
|
121
|
+
const appStateListeners = new Set<(status: AppStatus) => void>();
|
|
122
|
+
const interruptionListeners = new Set<() => void>();
|
|
123
|
+
|
|
124
|
+
const permissions: PermissionPort = {
|
|
125
|
+
async getStatus() {
|
|
126
|
+
return permission;
|
|
127
|
+
},
|
|
128
|
+
async request() {
|
|
129
|
+
// Mirrors the real adapter: never re-prompt after a permanent denial.
|
|
130
|
+
if (permission === 'deniedPermanently') {
|
|
131
|
+
return permission;
|
|
132
|
+
}
|
|
133
|
+
permission = permission === 'undetermined' || permission === 'denied' ? 'granted' : permission;
|
|
134
|
+
for (const listener of permissionListeners) {
|
|
135
|
+
listener(permission);
|
|
136
|
+
}
|
|
137
|
+
return permission;
|
|
138
|
+
},
|
|
139
|
+
subscribe(listener) {
|
|
140
|
+
permissionListeners.add(listener);
|
|
141
|
+
return () => {
|
|
142
|
+
permissionListeners.delete(listener);
|
|
143
|
+
};
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const engine: CameraEngine = {
|
|
148
|
+
async start() {
|
|
149
|
+
starts += 1;
|
|
150
|
+
if (startError) {
|
|
151
|
+
throw new CameraError(startError);
|
|
152
|
+
}
|
|
153
|
+
if (!capabilities.hasCameraHardware) {
|
|
154
|
+
throw new CameraError('cameraUnavailable');
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
|
|
158
|
+
async stop() {
|
|
159
|
+
stops += 1;
|
|
160
|
+
torch = false;
|
|
161
|
+
},
|
|
162
|
+
|
|
163
|
+
async capture(request: CaptureRequest) {
|
|
164
|
+
if (captureError) {
|
|
165
|
+
throw new CameraError(captureError);
|
|
166
|
+
}
|
|
167
|
+
sequence += 1;
|
|
168
|
+
const position: CameraPosition = request.cameraPosition ?? 'back';
|
|
169
|
+
const uri =
|
|
170
|
+
request.destination ?? `${store.temporaryDirectory()}/capture-${sequence}.jpg`;
|
|
171
|
+
fs.seed(uri, Date.now());
|
|
172
|
+
|
|
173
|
+
const image: CapturedImage = {
|
|
174
|
+
uri,
|
|
175
|
+
mimeType: 'image/jpeg',
|
|
176
|
+
width: 1920,
|
|
177
|
+
height: 1080,
|
|
178
|
+
timestamp: Date.now(),
|
|
179
|
+
cameraPosition: position,
|
|
180
|
+
ownership: request.destination ? 'appOwned' : 'packageTemporary',
|
|
181
|
+
effectiveQuality: request.quality ?? 0.9,
|
|
182
|
+
effectiveMaxDimension:
|
|
183
|
+
request.maxDimension === undefined ? unavailable() : known(request.maxDimension),
|
|
184
|
+
fileSize: known(1024),
|
|
185
|
+
orientation: known(0),
|
|
186
|
+
...config.captureResult,
|
|
187
|
+
};
|
|
188
|
+
produced.push(image);
|
|
189
|
+
return image;
|
|
190
|
+
},
|
|
191
|
+
|
|
192
|
+
async setTorch(enabled: boolean) {
|
|
193
|
+
torch = enabled;
|
|
194
|
+
},
|
|
195
|
+
|
|
196
|
+
async getCapabilities() {
|
|
197
|
+
return capabilities;
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
engine,
|
|
203
|
+
permissions,
|
|
204
|
+
store,
|
|
205
|
+
fs,
|
|
206
|
+
|
|
207
|
+
subscribeToAppState(listener) {
|
|
208
|
+
appStateListeners.add(listener);
|
|
209
|
+
return () => {
|
|
210
|
+
appStateListeners.delete(listener);
|
|
211
|
+
};
|
|
212
|
+
},
|
|
213
|
+
|
|
214
|
+
setAppState(status) {
|
|
215
|
+
for (const listener of appStateListeners) {
|
|
216
|
+
listener(status);
|
|
217
|
+
}
|
|
218
|
+
},
|
|
219
|
+
|
|
220
|
+
setPermission(state) {
|
|
221
|
+
permission = state;
|
|
222
|
+
for (const listener of permissionListeners) {
|
|
223
|
+
listener(state);
|
|
224
|
+
}
|
|
225
|
+
},
|
|
226
|
+
|
|
227
|
+
emitInterruption() {
|
|
228
|
+
for (const listener of interruptionListeners) {
|
|
229
|
+
listener();
|
|
230
|
+
}
|
|
231
|
+
// An interruption behaves like the OS taking the camera away: the next
|
|
232
|
+
// start attempt fails until the app clears it.
|
|
233
|
+
startError = 'interrupted';
|
|
234
|
+
},
|
|
235
|
+
|
|
236
|
+
setCaptureError(code) {
|
|
237
|
+
captureError = code;
|
|
238
|
+
},
|
|
239
|
+
|
|
240
|
+
setStartError(code) {
|
|
241
|
+
startError = code;
|
|
242
|
+
},
|
|
243
|
+
|
|
244
|
+
captures: () => produced,
|
|
245
|
+
torchState: () => torch,
|
|
246
|
+
startCount: () => starts,
|
|
247
|
+
stopCount: () => stops,
|
|
248
|
+
};
|
|
249
|
+
}
|