@chipmobilesdk/rn-camera
v0.1.1
Published
Reusable camera infrastructure for ChipMobileSdk-family React Native apps: permission-gated preview, still capture with consistent metadata, a session lifecycle that reliably releases the camera, app-controlled artifact retention, capability-gated flash a
Downloads
276
Readme
@chipmobilesdk/rn-camera
Reusable camera infrastructure for ChipMobileSdk-family React Native apps: permission-gated preview, still capture with consistent metadata, a session lifecycle that reliably releases the camera, app-controlled artifact retention, capability-gated flash and torch, coordinate mapping for app-drawn overlays, and a test adapter.
The package is infrastructure, not a feature. It contains no business logic, does not know what your photos are for, sends nothing over the network, and writes nothing to the device gallery.
License:
UNLICENSED. Published publicly for use by the owner's applications; no open-source license is granted.
- Status:
0.1.0, public npm package - Entrypoint:
src/index.ts— the only public entrypoint - Native code authored here: none (see Native capability)
Compatibility
| | Supported |
|---|---|
| React | >= 19.2 |
| React Native | >= 0.85 |
| Android | minSdkVersion 24, targetSdkVersion 36 |
| iOS | 15.1+ |
| react-native-vision-camera | 5.1.x |
Android
minSdkVersionstays at 24. Secondary sources claim VisionCamera v5 requires 26. That is wrong for 5.1.1: itsgradle.propertiesdeclaresVisionCamera_minSdkVersion=23and itsbuild.gradleresolvesrootProject.ext.minSdkVersionfirst. Bumping to 26 would drop Android 7.0 and 7.1 devices for no reason.
Install (consuming app)
npm install @chipmobilesdk/rn-camera react-native-vision-camera react-native-nitro-modules react-native-nitro-image
cd ios && pod install && cd ..react-native-vision-camera is a peer dependency — your app installs and links it once, exactly as it does react-native-keychain for @chipmobilesdk/rn-auth.
VisionCamera 5.1.x in turn requires the Nitro modules and image packages shown
above; they remain app-owned native dependencies.
If your app also uses
react-native-unistyles(as the ChipMobileSdk demo app does), both it and VisionCamera depend onreact-native-nitro-modules, and only one Nitro version can exist in a build. After installing, verify your theme system still works on both platforms before going further.
iOS permissions
<key>NSCameraUsageDescription</key>
<string>Explain, in your own words, why your app needs the camera.</string>That is the only declaration the package requires. No microphone, no photo library, no location.
Android permissions
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera" android:required="false" />required="false" keeps your app installable on camera-less devices; the package reports hasCameraHardware: false there so you can render an unavailable state instead of failing.
Quick start
import React, { useCallback, useEffect, useState } from 'react';
import {
CameraPreview,
createVisionCameraEngine,
createVisionCameraPermissions,
useCameraController,
useCameraState,
type CapturedImage,
} from '@chipmobilesdk/rn-camera';
function CameraScreen() {
const [photo, setPhoto] = useState<CapturedImage | null>(null);
const controller = useCameraController({
engine: createVisionCameraEngine(),
permissions: createVisionCameraPermissions(),
});
const state = useCameraState(controller);
useEffect(() => {
void (async () => {
await controller.getCapabilities();
if (await controller.getPermission() === 'granted') {
await controller.start();
}
})();
}, [controller]);
const requestPermission = useCallback(async () => {
if (await controller.requestPermission() === 'granted') {
await controller.start();
}
}, [controller]);
if (state.permission !== 'granted') {
// Your copy, your layout — the package supplies state, not UI.
return <PermissionGate onRequest={requestPermission} state={state.permission} />;
}
return (
<CameraPreview controller={controller} accessibilityLabel="Camera preview">
<ShutterButton onPress={async () => setPhoto(await controller.capture())} />
</CameraPreview>
);
}CameraPreview mounts the native feed only after controller.start() reaches
the active state. getPermission() only reads state; it does not prompt or
start a session. The controller hook releases the session when the screen
unmounts.
Core concepts
Permission is a state, not a boolean
Seven distinguishable states, because each one needs different UI: undetermined, requesting, granted, denied, deniedPermanently, unavailable, unsupported.
getPermission() never prompts and never opens a session — safe to call on mount. Only requestPermission() prompts, and it refuses to re-prompt once the OS reports a permanent denial, reporting deniedPermanently instead so you can route the user to system settings. Revocations made in Settings while your app runs are pushed to subscribers without a restart.
Capture returns a file reference
capture() resolves to a file URI plus metadata — never a base64 string, never an in-memory buffer. Base64 inflates an image by roughly a third and holds the whole thing in JavaScript memory, and strings that size are exactly what ends up in crash logs. If you need bytes, read the file.
Metadata the platform cannot determine is explicitly marked unavailable rather than omitted:
const image = await controller.capture();
image.fileSize.available ? image.fileSize.value : 'not reported by this platform';A bare optional could not tell "the platform does not report this" from "nobody asked", which is why the wrapper exists.
You decide what is kept
The returned ownership metadata is decided at capture time by one thing —
whether you pass a destination:
| | No destination | With destination |
|---|---|---|
| Ownership | packageTemporary | appOwned |
| Default VisionCamera write | Native temporary file | Your path, directly |
| Automatic persistence | No | Controlled by your destination |
const temp = await controller.capture(); // package-owned
const kept = await controller.capture({ destination: '/my/app/x.jpg' }); // app-owned
// cleanup() only acts when the controller was given an ArtifactStore and the
// store reports that it owns this URI.
await controller.cleanup(temp);The default VisionCamera adapter does not automatically move its native
temporary file into the optional package store. With no store, cleanup() is
a no-op and no sweep runs. To enable bounded cleanup, create an
ArtifactStore with an app-supplied FileSystemBridge, pass it to the
controller, and place managed files under that store's temporary directory.
Initialization then sweeps only URIs the store owns, using a 24-hour default
TTL. cleanup() is idempotent and refuses paths outside the store root.
The camera is released, not held
Backgrounding stops and releases the camera rather than pausing it. Both operating systems can reclaim it anyway, and holding it risks leaving the OS camera indicator lit while your app is not in front of the user. Returning to the foreground restarts the preview if the surface is still mounted.
Each controller permits one active session; starting the same controller again
reports sessionConflict. The package does not implement a process-wide lock
across different controller instances, so an app should mount only one active
camera preview at a time.
Capability gating
Ask before you render:
const caps = await controller.getCapabilities();
if (caps.hasTorch) { /* render the torch control */ }Invoking something the device does not have returns unsupportedCapability rather than an unexpected failure. Flash mode (auto/on/off) applies at capture time; torch is continuous during preview and is forced off whenever the session pauses, stops, or the app backgrounds.
Overlays
The package draws no bounding boxes, guide frames, or reticles — those are where apps differ. It gives you the surface (children renders above the feed) and the geometry:
<CameraPreview controller={controller} onGeometryChange={setGeometry}>
<View style={myGuideFrame} pointerEvents="none" />
</CameraPreview>;
const inImageSpace = mapRectToImage(myGuideFrame, geometry);The pure mapping functions account for scale, letterbox or crop, orientation,
and front-camera mirroring using the geometry you supply. In the current
CameraPreview, onGeometryChange reports nominal image dimensions
(1920 × 1080), orientation 0, and mirroring from camera position; it does
not yet read the selected native capture format. Use capture metadata or
app-owned measured geometry when pixel-exact alignment is required.
Quality and dimensions
quality and maxDimension are normalized into effectiveQuality and
effectiveMaxDimension metadata. The current VisionCamera 5.1 adapter does
not yet resize or re-encode the saved image, so these fields are hints rather
than a guarantee about output bytes. Perform app-side image processing when an
exact upload size or quality is required.
Testing without hardware
createTestCamera() returns real implementations of the same published ports the production adapters implement — not a parallel mock, so it cannot drift from the contract without a compile error.
import { createCameraController, createTestCamera } from '@chipmobilesdk/rn-camera';
const camera = createTestCamera({ permission: 'granted' });
const controller = createCameraController({
engine: camera.engine,
permissions: camera.permissions,
store: camera.store,
subscribeToAppState: camera.subscribeToAppState,
sweepOnInit: false,
});
await controller.requestPermission();
await controller.getCapabilities();
await controller.start();
const image = await controller.capture();
// Drive any failure path:
camera.setCaptureError('captureFailed');
camera.setPermission('deniedPermanently');
camera.setAppState('background');
camera.emitInterruption();Add a module mapping so an accidental import of the native module resolves in Jest:
moduleNameMapper: {
'^react-native-vision-camera$': '<rootDir>/__tests__/__mocks__/react-native-vision-camera.ts',
}Errors
Every failure is a CameraError with a code and a recoverable flag, so you branch on the code rather than string-matching a message:
permissionDenied, cameraUnavailable, cameraBlocked, cameraInUse, sessionConflict, notReady, captureInProgress, captureFailed, storageFailure, unsupportedCapability, interrupted.
A double-tapped shutter produces captureInProgress on the second call. The package does not queue (which would give the user two photos they never asked for) and does not coalesce (which would hide a deliberate second capture behind an accidental one).
Diagnostics
Logging is off by default, and event bodies carry codes and states only — never file paths or image data:
createCameraController({ logger: { log: event => myLogger.debug(event) } });A redact() helper is exported for scrubbing paths and payload-shaped strings from anything else you log.
Data behaviour (for App Privacy and Data Safety)
Use this when completing your store declarations:
| Question | Answer for this package | |---|---| | What data is involved? | Camera imagery generated on-device by explicit user action, plus technical metadata (dimensions, mime type, size, orientation, timestamp, camera position) | | Transmitted off device? | No. The package makes no network requests of any kind | | Shared with third parties? | No | | Stored persistently? | A capture is a file: the default adapter uses VisionCamera's native temporary location unless the app supplies a destination. The package has no history database and does not write to the gallery | | Location attached? | No. No location data is read and no location permission is requested | | Written to the photo library? | No | | Tracking, ads, identifiers? | None | | On-device AI or inference? | None in this phase |
If your app uploads captured images off-device, that collection is yours to declare — the package cannot know you do it. The same applies to moderation and reporting obligations if captures become user-generated content.
Native capability
This package authors zero native source. Camera capability arrives entirely through the react-native-vision-camera peer dependency, mirroring how @chipmobilesdk/rn-auth obtains Keychain and OIDC capability.
The ChipMobileSdk constitution prohibits native code in shared libraries by default. A camera cannot be implemented in TypeScript, so this is the exception in its narrowest available form: a maintained MIT library the consuming app installs, wrapped behind port interfaces this package owns.
Reserved: local frame processing (not implemented)
Phase one runs no inference, emits no AI-derived labels, and sends no frame anywhere. The extension point is documented now so the later local-AI phase can arrive as an additive minor release without disturbing anything above.
The reserved contract:
// NOT EXPORTED. Reserved shape for the local-AI phase.
interface FrameProcessorOptions {
maxFps: number; // frame-rate cap, to protect the UI thread and battery
inferenceMaxDimension: number; // lower than preview resolution
onFrame: (frame: ReservedFrame) => void;
backpressure: 'dropLatest' | 'dropOldest';
// Automatically paused when the app backgrounds. Frames never leave the device.
}Still capture and real-time frame processing stay strictly separate.
Public API
Everything is exported from @chipmobilesdk/rn-camera. Modules under core/, native/, permissions/, storage/, geometry/, diagnostics/, and testing/ are private — do not deep-import them.
- Controller and state:
createCameraController,CameraController,CameraControllerOptions,useCameraController,useCameraState,CameraState,AppStatus,DEFAULT_TEMPORARY_TTL_MS - Preview and capture:
CameraPreview,CameraPreviewProps,CaptureRequest,CapturedImage,StartOptionsand the camera/session/capability types - Ports and adapters:
CameraEngine,EngineOptions,createVisionCameraEngine,PermissionPort,createVisionCameraPermissions,ArtifactStore,FileSystemBridge,StoreOptions,createCacheArtifactStore,createArtifactStore - Mapping:
mapPointToImage,mapPointToPreview,mapRectToImage,mapRectToPreview, plusPoint,Rect, andPreviewGeometry - Diagnostics:
CameraError,CameraErrorCode,isCameraError,redact, and the logger event/type contracts - Testing:
createTestCamera,createMemoryFileSystem,DEFAULT_TEST_CAPABILITIES, and their configuration/handle types - Metadata helpers:
known,unavailable,Known,Unavailable,Maybe
Stable for semver: types, controller, preview, mapping, errors. Experimental and may change in a minor: the diagnostics logger shape and the test adapter configuration.
License
UNLICENSED. The npm tarball is publicly downloadable, but no open-source
license or permission to copy, modify, or redistribute is granted.
Demo and validation
The demo integration lives in src/screens/CameraDemoScreen.tsx. Run the
package commands from the repository root (camera currently has no root-level
shortcut):
npm run typecheck --workspace @chipmobilesdk/rn-camera
npm run pack:dry-run --workspace @chipmobilesdk/rn-camera