@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,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional consumer-supplied logger (research R4).
|
|
3
|
+
*
|
|
4
|
+
* The default is no logging at all, so FR-038 holds whether or not a consumer
|
|
5
|
+
* opts in. Event bodies carry codes and states only — never file paths, never
|
|
6
|
+
* image data — so an opted-in logger still cannot leak an image path.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export type CameraLogEventType =
|
|
10
|
+
| 'session'
|
|
11
|
+
| 'permission'
|
|
12
|
+
| 'capture'
|
|
13
|
+
| 'capability'
|
|
14
|
+
| 'cleanup';
|
|
15
|
+
|
|
16
|
+
export interface CameraLogEvent {
|
|
17
|
+
readonly type: CameraLogEventType;
|
|
18
|
+
readonly code: string;
|
|
19
|
+
readonly recoverable?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface CameraLogger {
|
|
23
|
+
log(event: CameraLogEvent): void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** No-op logger used when the consumer supplies none. */
|
|
27
|
+
export const silentLogger: CameraLogger = {
|
|
28
|
+
log() {
|
|
29
|
+
// Intentionally empty: logging is opt-in.
|
|
30
|
+
},
|
|
31
|
+
};
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bidirectional coordinate mapping between preview space and captured-image
|
|
3
|
+
* space (FR-036, SC-012).
|
|
4
|
+
*
|
|
5
|
+
* Pure functions with no native involvement (research R5). Everything needed is
|
|
6
|
+
* already known in JavaScript, so keeping this pure is what makes the 2-pixel
|
|
7
|
+
* round-trip tolerance verifiable in CI rather than only on a device.
|
|
8
|
+
*
|
|
9
|
+
* Composition order, preview -> image: undo mirror, undo letterbox/crop offset,
|
|
10
|
+
* undo scale, undo orientation. Image -> preview reverses it exactly.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { Point, PreviewGeometry, Rect } from '../config/types';
|
|
14
|
+
|
|
15
|
+
/** Orientation rotates the image for display, swapping dimensions at 90/270. */
|
|
16
|
+
function isQuarterTurn(orientation: number): boolean {
|
|
17
|
+
const normalized = ((orientation % 360) + 360) % 360;
|
|
18
|
+
return normalized === 90 || normalized === 270;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function normalizeOrientation(orientation: number): 0 | 90 | 180 | 270 {
|
|
22
|
+
const normalized = ((Math.round(orientation / 90) * 90) % 360 + 360) % 360;
|
|
23
|
+
return normalized as 0 | 90 | 180 | 270;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface Projection {
|
|
27
|
+
readonly scale: number;
|
|
28
|
+
readonly offsetX: number;
|
|
29
|
+
readonly offsetY: number;
|
|
30
|
+
/** Image dimensions as displayed, after orientation. */
|
|
31
|
+
readonly displayWidth: number;
|
|
32
|
+
readonly displayHeight: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function projection(geometry: PreviewGeometry): Projection {
|
|
36
|
+
const swap = isQuarterTurn(geometry.orientation);
|
|
37
|
+
const displayWidth = swap ? geometry.imageHeight : geometry.imageWidth;
|
|
38
|
+
const displayHeight = swap ? geometry.imageWidth : geometry.imageHeight;
|
|
39
|
+
|
|
40
|
+
const scaleX = geometry.previewWidth / displayWidth;
|
|
41
|
+
const scaleY = geometry.previewHeight / displayHeight;
|
|
42
|
+
// `cover` fills and crops the overflow; `contain` fits and letterboxes.
|
|
43
|
+
const scale =
|
|
44
|
+
geometry.resizeMode === 'cover' ? Math.max(scaleX, scaleY) : Math.min(scaleX, scaleY);
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
scale,
|
|
48
|
+
// Negative under `cover` (cropped away), positive under `contain`
|
|
49
|
+
// (letterbox bands). The same arithmetic covers both.
|
|
50
|
+
offsetX: (geometry.previewWidth - displayWidth * scale) / 2,
|
|
51
|
+
offsetY: (geometry.previewHeight - displayHeight * scale) / 2,
|
|
52
|
+
displayWidth,
|
|
53
|
+
displayHeight,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Display-space point -> original image point, undoing the rotation. */
|
|
58
|
+
function unrotate(point: Point, geometry: PreviewGeometry): Point {
|
|
59
|
+
switch (normalizeOrientation(geometry.orientation)) {
|
|
60
|
+
case 90:
|
|
61
|
+
return { x: point.y, y: geometry.imageHeight - point.x };
|
|
62
|
+
case 180:
|
|
63
|
+
return { x: geometry.imageWidth - point.x, y: geometry.imageHeight - point.y };
|
|
64
|
+
case 270:
|
|
65
|
+
return { x: geometry.imageWidth - point.y, y: point.x };
|
|
66
|
+
default:
|
|
67
|
+
return point;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Original image point -> display-space point, applying the rotation. */
|
|
72
|
+
function rotate(point: Point, geometry: PreviewGeometry): Point {
|
|
73
|
+
switch (normalizeOrientation(geometry.orientation)) {
|
|
74
|
+
case 90:
|
|
75
|
+
return { x: geometry.imageHeight - point.y, y: point.x };
|
|
76
|
+
case 180:
|
|
77
|
+
return { x: geometry.imageWidth - point.x, y: geometry.imageHeight - point.y };
|
|
78
|
+
case 270:
|
|
79
|
+
return { x: point.y, y: geometry.imageWidth - point.x };
|
|
80
|
+
default:
|
|
81
|
+
return point;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Maps a preview-space point into captured-image space. Points outside the
|
|
87
|
+
* visible region stay representable (they fall outside the image bounds) rather
|
|
88
|
+
* than being clamped to an edge, so callers can detect an out-of-frame overlay.
|
|
89
|
+
*/
|
|
90
|
+
export function mapPointToImage(point: Point, geometry: PreviewGeometry): Point {
|
|
91
|
+
const { scale, offsetX, offsetY } = projection(geometry);
|
|
92
|
+
const x = geometry.isMirrored ? geometry.previewWidth - point.x : point.x;
|
|
93
|
+
return unrotate({ x: (x - offsetX) / scale, y: (point.y - offsetY) / scale }, geometry);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Maps a captured-image point into preview space. Exact inverse of the above. */
|
|
97
|
+
export function mapPointToPreview(point: Point, geometry: PreviewGeometry): Point {
|
|
98
|
+
const { scale, offsetX, offsetY } = projection(geometry);
|
|
99
|
+
const rotated = rotate(point, geometry);
|
|
100
|
+
const x = rotated.x * scale + offsetX;
|
|
101
|
+
return {
|
|
102
|
+
x: geometry.isMirrored ? geometry.previewWidth - x : x,
|
|
103
|
+
y: rotated.y * scale + offsetY,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Axis-aligned bounding box of the mapped corners — exact for quarter turns. */
|
|
108
|
+
function boundsOf(a: Point, b: Point): Rect {
|
|
109
|
+
return {
|
|
110
|
+
x: Math.min(a.x, b.x),
|
|
111
|
+
y: Math.min(a.y, b.y),
|
|
112
|
+
width: Math.abs(b.x - a.x),
|
|
113
|
+
height: Math.abs(b.y - a.y),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function mapRectToImage(rect: Rect, geometry: PreviewGeometry): Rect {
|
|
118
|
+
return boundsOf(
|
|
119
|
+
mapPointToImage({ x: rect.x, y: rect.y }, geometry),
|
|
120
|
+
mapPointToImage({ x: rect.x + rect.width, y: rect.y + rect.height }, geometry),
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function mapRectToPreview(rect: Rect, geometry: PreviewGeometry): Rect {
|
|
125
|
+
return boundsOf(
|
|
126
|
+
mapPointToPreview({ x: rect.x, y: rect.y }, geometry),
|
|
127
|
+
mapPointToPreview({ x: rect.x + rect.width, y: rect.y + rect.height }, geometry),
|
|
128
|
+
);
|
|
129
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @chipmobilesdk/rn-camera — reusable camera infrastructure for
|
|
3
|
+
* ChipMobileSdk-family React Native apps.
|
|
4
|
+
*
|
|
5
|
+
* This is the single public entrypoint (LIB-003). Modules under `core/`,
|
|
6
|
+
* `native/`, `permissions/`, `storage/`, `geometry/`, `diagnostics/`, and
|
|
7
|
+
* `testing/` are private; import them only through this file.
|
|
8
|
+
*
|
|
9
|
+
* The package contains no business logic, sends nothing over the network, and
|
|
10
|
+
* writes nothing to the device gallery. Phase one runs no on-device inference —
|
|
11
|
+
* see the reserved frame-processing extension point in the README.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// Public types
|
|
15
|
+
export type {
|
|
16
|
+
ArtifactOwnership,
|
|
17
|
+
CameraErrorLike,
|
|
18
|
+
CameraPermissionState,
|
|
19
|
+
CameraPosition,
|
|
20
|
+
CameraState,
|
|
21
|
+
CapabilityProfile,
|
|
22
|
+
CapturedImage,
|
|
23
|
+
CaptureRequest,
|
|
24
|
+
FlashMode,
|
|
25
|
+
Known,
|
|
26
|
+
Maybe,
|
|
27
|
+
Point,
|
|
28
|
+
PreviewGeometry,
|
|
29
|
+
Rect,
|
|
30
|
+
ResizeMode,
|
|
31
|
+
SessionStatus,
|
|
32
|
+
StartOptions,
|
|
33
|
+
Unavailable,
|
|
34
|
+
} from './config/types';
|
|
35
|
+
export { known, unavailable } from './config/types';
|
|
36
|
+
|
|
37
|
+
// Controller
|
|
38
|
+
export { createCameraController, DEFAULT_TEMPORARY_TTL_MS } from './core/controller';
|
|
39
|
+
export type {
|
|
40
|
+
AppStatus,
|
|
41
|
+
CameraController,
|
|
42
|
+
CameraControllerOptions,
|
|
43
|
+
} from './core/controller';
|
|
44
|
+
|
|
45
|
+
// Ports + default adapters
|
|
46
|
+
export type { CameraEngine, EngineOptions } from './native/cameraEngine';
|
|
47
|
+
export { createVisionCameraEngine } from './native/cameraEngine';
|
|
48
|
+
export type { PermissionPort } from './permissions/permissionPort';
|
|
49
|
+
export { createVisionCameraPermissions } from './permissions/permissionPort';
|
|
50
|
+
export type { ArtifactStore, FileSystemBridge, StoreOptions } from './storage/artifactStore';
|
|
51
|
+
export { createArtifactStore, createCacheArtifactStore } from './storage/artifactStore';
|
|
52
|
+
|
|
53
|
+
// React surface
|
|
54
|
+
export { CameraPreview } from './react/CameraPreview';
|
|
55
|
+
export type { CameraPreviewProps } from './react/CameraPreview';
|
|
56
|
+
export { useCameraController, useCameraState } from './react/hooks';
|
|
57
|
+
|
|
58
|
+
// Coordinate mapping (pure)
|
|
59
|
+
export {
|
|
60
|
+
mapPointToImage,
|
|
61
|
+
mapPointToPreview,
|
|
62
|
+
mapRectToImage,
|
|
63
|
+
mapRectToPreview,
|
|
64
|
+
} from './geometry/coordinateMapping';
|
|
65
|
+
|
|
66
|
+
// Diagnostics
|
|
67
|
+
export { CameraError, isCameraError, redact } from './diagnostics/errors';
|
|
68
|
+
export type { CameraErrorCode } from './diagnostics/errors';
|
|
69
|
+
export type { CameraLogEvent, CameraLogEventType, CameraLogger } from './diagnostics/logger';
|
|
70
|
+
|
|
71
|
+
// Test adapter (FR-041) — exported from the single entrypoint so consumer tests
|
|
72
|
+
// exercise the same published ports the real adapters implement.
|
|
73
|
+
export { createTestCamera, createMemoryFileSystem, DEFAULT_TEST_CAPABILITIES } from './testing/testCameraAdapter';
|
|
74
|
+
export type {
|
|
75
|
+
MemoryFileSystem,
|
|
76
|
+
TestCameraConfig,
|
|
77
|
+
TestCameraHandles,
|
|
78
|
+
} from './testing/testCameraAdapter';
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CameraEngine port + default react-native-vision-camera adapter.
|
|
3
|
+
*
|
|
4
|
+
* This is the only module that knows a native camera library exists. The
|
|
5
|
+
* default adapter lazily requires it, mirroring
|
|
6
|
+
* `packages/rn-auth/src/tokens/secureStore.ts`, so unit tests that inject a
|
|
7
|
+
* fake never load native code.
|
|
8
|
+
*
|
|
9
|
+
* Preview frames never cross this boundary: `capture` returns a file reference
|
|
10
|
+
* plus metadata and nothing else (PERF-002, FR-021a).
|
|
11
|
+
*
|
|
12
|
+
* Targets the VisionCamera v5 API, where devices come from an async
|
|
13
|
+
* `CameraDeviceFactory` and capture runs through the photo output owned by the
|
|
14
|
+
* mounted preview surface (see `visionSurface`).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type {
|
|
18
|
+
CapabilityProfile,
|
|
19
|
+
CapturedImage,
|
|
20
|
+
CaptureRequest,
|
|
21
|
+
StartOptions,
|
|
22
|
+
} from '../config/types';
|
|
23
|
+
import { CameraError } from '../diagnostics/errors';
|
|
24
|
+
import { getActiveVisionSurface } from './visionSurface';
|
|
25
|
+
|
|
26
|
+
export interface CameraEngine {
|
|
27
|
+
start(options: StartOptions): Promise<void>;
|
|
28
|
+
stop(): Promise<void>;
|
|
29
|
+
capture(request: CaptureRequest): Promise<CapturedImage>;
|
|
30
|
+
setTorch(enabled: boolean): Promise<void>;
|
|
31
|
+
getCapabilities(): Promise<CapabilityProfile>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface EngineOptions {
|
|
35
|
+
/** Overrides device discovery. Primarily an escape hatch for diagnostics. */
|
|
36
|
+
readonly preferredPosition?: 'front' | 'back';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The subset of VisionCamera's `CameraDevice` this adapter reads. */
|
|
40
|
+
interface DiscoveredDevice {
|
|
41
|
+
readonly position?: string;
|
|
42
|
+
readonly hasFlash?: boolean;
|
|
43
|
+
readonly hasTorch?: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Default engine. VisionCamera drives the preview through its own React
|
|
48
|
+
* component, so this adapter owns capability discovery while `CameraPreview`
|
|
49
|
+
* owns the rendered surface and the photo output that capture runs through.
|
|
50
|
+
*/
|
|
51
|
+
export function createVisionCameraEngine(_options: EngineOptions = {}): CameraEngine {
|
|
52
|
+
// Lazy require: only loaded when the default adapter is actually used.
|
|
53
|
+
const { VisionCamera } = require('react-native-vision-camera');
|
|
54
|
+
|
|
55
|
+
// The factory is created once and reused: it keeps its own device list up to
|
|
56
|
+
// date, so re-creating it per call would only add latency.
|
|
57
|
+
let factory: Promise<{ readonly cameraDevices: DiscoveredDevice[] }> | null = null;
|
|
58
|
+
|
|
59
|
+
async function devices(): Promise<readonly DiscoveredDevice[]> {
|
|
60
|
+
factory ??= VisionCamera.createDeviceFactory();
|
|
61
|
+
return (await factory)?.cameraDevices ?? [];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
async start() {
|
|
66
|
+
// The preview component owns session activation; the engine only needs
|
|
67
|
+
// to confirm a device exists before the surface mounts.
|
|
68
|
+
const available = await devices();
|
|
69
|
+
if (available.length === 0) {
|
|
70
|
+
throw new CameraError('cameraUnavailable', 'No camera device is available');
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
async stop() {
|
|
75
|
+
// Teardown is driven by unmounting the preview surface.
|
|
76
|
+
},
|
|
77
|
+
|
|
78
|
+
async capture(request: CaptureRequest) {
|
|
79
|
+
const surface = getActiveVisionSurface();
|
|
80
|
+
if (!surface) {
|
|
81
|
+
throw new CameraError(
|
|
82
|
+
'notReady',
|
|
83
|
+
'The default VisionCamera engine captures through the CameraPreview ' +
|
|
84
|
+
'surface. Mount <CameraPreview> before calling capture().',
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
return surface.capture(request);
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
async setTorch() {
|
|
91
|
+
// Torch is a preview-surface prop; the controller mirrors the state and
|
|
92
|
+
// CameraPreview applies it.
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
async getCapabilities(): Promise<CapabilityProfile> {
|
|
96
|
+
const available = await devices();
|
|
97
|
+
const positions = Array.from(
|
|
98
|
+
new Set(
|
|
99
|
+
available
|
|
100
|
+
.map(device => device.position)
|
|
101
|
+
.filter((p): p is 'front' | 'back' => p === 'front' || p === 'back'),
|
|
102
|
+
),
|
|
103
|
+
);
|
|
104
|
+
return {
|
|
105
|
+
availablePositions: positions,
|
|
106
|
+
hasFlash: available.some(device => device.hasFlash === true),
|
|
107
|
+
hasTorch: available.some(device => device.hasTorch === true),
|
|
108
|
+
supportsOrientationChange: true,
|
|
109
|
+
hasCameraHardware: available.length > 0,
|
|
110
|
+
};
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bridge between the mounted VisionCamera preview surface and the default
|
|
3
|
+
* engine adapter.
|
|
4
|
+
*
|
|
5
|
+
* VisionCamera v5 owns capture through the session that the `<Camera>` view
|
|
6
|
+
* configures, so the photo output only exists while a surface is mounted. The
|
|
7
|
+
* engine is created by the app before any surface exists, so the surface
|
|
8
|
+
* registers itself here on mount and the engine reads it back at capture time.
|
|
9
|
+
*
|
|
10
|
+
* A single registration slot is deliberate: one camera session can be active at
|
|
11
|
+
* a time, so a second surface replacing the first is the correct behaviour.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { CapturedImage, CaptureRequest } from '../config/types';
|
|
15
|
+
|
|
16
|
+
export interface VisionSurface {
|
|
17
|
+
capture(request: CaptureRequest): Promise<CapturedImage>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let active: VisionSurface | null = null;
|
|
21
|
+
|
|
22
|
+
/** Registers the mounted surface. Returns an unregister function. */
|
|
23
|
+
export function registerVisionSurface(surface: VisionSurface): () => void {
|
|
24
|
+
active = surface;
|
|
25
|
+
return () => {
|
|
26
|
+
if (active === surface) {
|
|
27
|
+
active = null;
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function getActiveVisionSurface(): VisionSurface | null {
|
|
33
|
+
return active;
|
|
34
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PermissionPort + default react-native-vision-camera adapter (FR-004..FR-008).
|
|
3
|
+
*
|
|
4
|
+
* `getStatus` never prompts and never opens a session (FR-005); only `request`
|
|
5
|
+
* may prompt, and it refuses to re-prompt once the OS reports a permanent
|
|
6
|
+
* denial (FR-006). The default adapter lazily requires the native module so
|
|
7
|
+
* unit tests that inject a fake never load native code — same approach as
|
|
8
|
+
* `packages/rn-auth/src/tokens/secureStore.ts`.
|
|
9
|
+
*
|
|
10
|
+
* The adapter targets the VisionCamera v5 API: permissions live on the
|
|
11
|
+
* `VisionCamera` hybrid object, not on the `Camera` component (which carries no
|
|
12
|
+
* statics since v5).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { CameraPermissionState } from '../config/types';
|
|
16
|
+
|
|
17
|
+
export interface PermissionPort {
|
|
18
|
+
/** Reads current status. MUST NOT prompt or start a session (FR-005). */
|
|
19
|
+
getStatus(): Promise<CameraPermissionState>;
|
|
20
|
+
/** Explicitly requests permission (FR-006). */
|
|
21
|
+
request(): Promise<CameraPermissionState>;
|
|
22
|
+
/** Notifies on external changes, e.g. revocation in Settings (FR-007). */
|
|
23
|
+
subscribe(listener: (state: CameraPermissionState) => void): () => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* VisionCamera `PermissionStatus` mapped onto our seven-state contract.
|
|
28
|
+
*
|
|
29
|
+
* `not-determined` also covers "denied once, but the app may ask again" on
|
|
30
|
+
* Android, which is exactly the state where re-prompting is allowed, so it maps
|
|
31
|
+
* to `undetermined` rather than `denied`.
|
|
32
|
+
*/
|
|
33
|
+
function mapStatus(raw: string): CameraPermissionState {
|
|
34
|
+
switch (raw) {
|
|
35
|
+
case 'authorized':
|
|
36
|
+
return 'granted';
|
|
37
|
+
case 'not-determined':
|
|
38
|
+
return 'undetermined';
|
|
39
|
+
case 'denied':
|
|
40
|
+
return 'deniedPermanently';
|
|
41
|
+
case 'restricted':
|
|
42
|
+
return 'deniedPermanently';
|
|
43
|
+
default:
|
|
44
|
+
return 'unsupported';
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function createVisionCameraPermissions(): PermissionPort {
|
|
49
|
+
// Lazy require: only loaded when the default adapter is actually used.
|
|
50
|
+
const { VisionCamera } = require('react-native-vision-camera');
|
|
51
|
+
const { AppState } = require('react-native');
|
|
52
|
+
const listeners = new Set<(state: CameraPermissionState) => void>();
|
|
53
|
+
let last: CameraPermissionState | null = null;
|
|
54
|
+
let appStateSubscription: { remove(): void } | null = null;
|
|
55
|
+
|
|
56
|
+
function read(): CameraPermissionState {
|
|
57
|
+
try {
|
|
58
|
+
return mapStatus(VisionCamera.cameraPermissionStatus);
|
|
59
|
+
} catch {
|
|
60
|
+
// The native module is unreachable; report it rather than pretending
|
|
61
|
+
// the permission is merely undetermined.
|
|
62
|
+
return 'unsupported';
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function emit(state: CameraPermissionState): CameraPermissionState {
|
|
67
|
+
if (state !== last) {
|
|
68
|
+
last = state;
|
|
69
|
+
for (const listener of listeners) {
|
|
70
|
+
listener(state);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return state;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
async getStatus() {
|
|
78
|
+
return emit(read());
|
|
79
|
+
},
|
|
80
|
+
|
|
81
|
+
async request() {
|
|
82
|
+
// Never re-prompt after a permanent denial (FR-006); report the
|
|
83
|
+
// settings-required state instead so the app can route the user.
|
|
84
|
+
const current = read();
|
|
85
|
+
if (current === 'deniedPermanently') {
|
|
86
|
+
return emit(current);
|
|
87
|
+
}
|
|
88
|
+
await VisionCamera.requestCameraPermission();
|
|
89
|
+
// Re-read rather than trusting the boolean result: the status carries
|
|
90
|
+
// the richer distinction the contract needs.
|
|
91
|
+
return emit(read());
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
subscribe(listener) {
|
|
95
|
+
listeners.add(listener);
|
|
96
|
+
// Grants and revocations made in system Settings send the app through
|
|
97
|
+
// background/foreground, so re-reading on foreground is what surfaces
|
|
98
|
+
// them (FR-007). Attached only while somebody is listening.
|
|
99
|
+
if (!appStateSubscription) {
|
|
100
|
+
appStateSubscription = AppState.addEventListener('change', (next: string) => {
|
|
101
|
+
if (next === 'active') {
|
|
102
|
+
emit(read());
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
return () => {
|
|
107
|
+
listeners.delete(listener);
|
|
108
|
+
if (listeners.size === 0 && appStateSubscription) {
|
|
109
|
+
appStateSubscription.remove();
|
|
110
|
+
appStateSubscription = null;
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Camera preview surface (FR-009..FR-013, UX-001, UX-003).
|
|
3
|
+
*
|
|
4
|
+
* Renders the camera feed and nothing else: no shutter button, no permission
|
|
5
|
+
* copy, no error text, and no theme dependency. Consuming apps draw their own
|
|
6
|
+
* controls and overlays through `children`, positioned with the geometry
|
|
7
|
+
* reported by `onGeometryChange`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
|
11
|
+
import {
|
|
12
|
+
View,
|
|
13
|
+
type AccessibilityRole,
|
|
14
|
+
type LayoutChangeEvent,
|
|
15
|
+
type StyleProp,
|
|
16
|
+
type ViewStyle,
|
|
17
|
+
} from 'react-native';
|
|
18
|
+
|
|
19
|
+
import type { PreviewGeometry, ResizeMode } from '../config/types';
|
|
20
|
+
import type { CameraController } from '../core/controller';
|
|
21
|
+
import type { VisionCameraFeedProps } from './VisionCameraFeed';
|
|
22
|
+
|
|
23
|
+
export interface CameraPreviewProps {
|
|
24
|
+
readonly controller: CameraController;
|
|
25
|
+
readonly resizeMode?: ResizeMode;
|
|
26
|
+
readonly style?: StyleProp<ViewStyle>;
|
|
27
|
+
readonly onGeometryChange?: (geometry: PreviewGeometry) => void;
|
|
28
|
+
/** Forwarded untouched; the package never overrides consumer a11y (UX-003). */
|
|
29
|
+
readonly accessibilityLabel?: string;
|
|
30
|
+
readonly accessibilityRole?: AccessibilityRole;
|
|
31
|
+
/** Overlay layer rendered above the feed. */
|
|
32
|
+
readonly children?: React.ReactNode;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Nominal capture dimensions until the engine reports the real format. */
|
|
36
|
+
const DEFAULT_IMAGE_WIDTH = 1920;
|
|
37
|
+
const DEFAULT_IMAGE_HEIGHT = 1080;
|
|
38
|
+
|
|
39
|
+
export const CameraPreview: React.FC<CameraPreviewProps> = ({
|
|
40
|
+
controller,
|
|
41
|
+
resizeMode = 'cover',
|
|
42
|
+
style,
|
|
43
|
+
onGeometryChange,
|
|
44
|
+
accessibilityLabel,
|
|
45
|
+
accessibilityRole,
|
|
46
|
+
children,
|
|
47
|
+
}) => {
|
|
48
|
+
const [layout, setLayout] = useState<{ width: number; height: number } | null>(null);
|
|
49
|
+
const state = controller.state;
|
|
50
|
+
|
|
51
|
+
// Tell the controller a surface exists so foregrounding can restart the
|
|
52
|
+
// session, and clear it on unmount so the camera is released (FR-016).
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
controller.setSurfaceMounted(true);
|
|
55
|
+
return () => {
|
|
56
|
+
controller.setSurfaceMounted(false);
|
|
57
|
+
controller.stop().catch(() => undefined);
|
|
58
|
+
};
|
|
59
|
+
}, [controller]);
|
|
60
|
+
|
|
61
|
+
const onLayout = useCallback((event: LayoutChangeEvent) => {
|
|
62
|
+
const { width, height } = event.nativeEvent.layout;
|
|
63
|
+
setLayout({ width, height });
|
|
64
|
+
}, []);
|
|
65
|
+
|
|
66
|
+
const geometry = useMemo<PreviewGeometry | null>(() => {
|
|
67
|
+
if (!layout || layout.width === 0 || layout.height === 0) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
imageWidth: DEFAULT_IMAGE_WIDTH,
|
|
72
|
+
imageHeight: DEFAULT_IMAGE_HEIGHT,
|
|
73
|
+
previewWidth: layout.width,
|
|
74
|
+
previewHeight: layout.height,
|
|
75
|
+
resizeMode,
|
|
76
|
+
orientation: 0,
|
|
77
|
+
isMirrored: state.cameraPosition === 'front',
|
|
78
|
+
};
|
|
79
|
+
}, [layout, resizeMode, state.cameraPosition]);
|
|
80
|
+
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
if (geometry && onGeometryChange) {
|
|
83
|
+
onGeometryChange(geometry);
|
|
84
|
+
}
|
|
85
|
+
}, [geometry, onGeometryChange]);
|
|
86
|
+
|
|
87
|
+
const feedRequested = state.session === 'active' && state.permission === 'granted';
|
|
88
|
+
|
|
89
|
+
// Lazy require so importing this package never pulls in the native module
|
|
90
|
+
// for consumers that only use the pure utilities or the test adapter.
|
|
91
|
+
const Feed = useMemo(() => {
|
|
92
|
+
if (!feedRequested) {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
return require('./VisionCameraFeed')
|
|
97
|
+
.VisionCameraFeed as React.ComponentType<VisionCameraFeedProps>;
|
|
98
|
+
} catch {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
}, [feedRequested]);
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
<View
|
|
105
|
+
style={style}
|
|
106
|
+
onLayout={onLayout}
|
|
107
|
+
accessibilityLabel={accessibilityLabel}
|
|
108
|
+
accessibilityRole={accessibilityRole}
|
|
109
|
+
testID="rn-camera-preview">
|
|
110
|
+
{Feed ? (
|
|
111
|
+
<Feed
|
|
112
|
+
cameraPosition={state.cameraPosition}
|
|
113
|
+
resizeMode={resizeMode}
|
|
114
|
+
torchEnabled={state.torchEnabled}
|
|
115
|
+
/>
|
|
116
|
+
) : null}
|
|
117
|
+
{children}
|
|
118
|
+
</View>
|
|
119
|
+
);
|
|
120
|
+
};
|