feat(smart-app): implement complete mobile app MVP

- App.tsx: full navigation (Auth stack + Main tabs with 5 screens)
- Auth: LoginScreen, RegisterScreen, ForgotPasswordScreen
- HomeScreen: dashboard with IoT metrics, weather widget, alerts, quick actions, sensors
- MapScreen: interactive map with layer toggles (6 layers)
- MarketplaceScreen: categories (6), products (5), search
- ChatScreen: AI chat with quick prompts (4), bot responses
- ProfileScreen: user info, stats, menu (9 items), logout
- AlertsScreen: alert list with severity, acknowledge
- SensorsScreen: sensor list with type filters (6 types), search
- ZonesScreen: zone cards with stats
- SettingsScreen: language picker (FR/EN/ES/DE), privacy, about
- Stores: iotStore (sensors, zones, alerts), notificationStore, uiStore + i18n
- Hooks: useSensors, useAlerts, useNotifications, useLocation
- Components: Card, Button, LoadingSpinner, ErrorBoundary, Header
- Services: iotService, notificationService (with axios API client)
- Utils: formatters (temp, AQI, noise, dates), validators (email, password, IBAN)
- Theme: colors.ts with full design system (Blue Ocean palette)
- Ditto: fixed MongoDB connection, new JWT secrets, official gateway image
This commit is contained in:
Eric FELIXINE
2026-06-01 18:00:35 -04:00
parent 08ca495bde
commit e30ae8ed09
35578 changed files with 3703534 additions and 43 deletions

View File

@@ -0,0 +1,513 @@
import {
PermissionResponse,
PermissionStatus,
PermissionExpiration,
PermissionHookOptions,
} from 'expo-modules-core';
import { Ref } from 'react';
import type { ViewProps } from 'react-native';
export type CameraType = 'front' | 'back';
export type FlashMode = 'off' | 'on' | 'auto';
export type ImageType = 'png' | 'jpg';
export type CameraMode = 'picture' | 'video';
export type CameraRatio = '4:3' | '16:9' | '1:1';
/**
* This option specifies the mode of focus on the device.
* - `on` - Indicates that the device should autofocus once and then lock the focus.
* - `off` - Indicates that the device should automatically focus when needed.
* @default off
*/
export type FocusMode = 'on' | 'off';
/**
* This option specifies what codec to use when recording a video.
* @platform ios
*/
export type VideoCodec = 'avc1' | 'hvc1' | 'jpeg' | 'apcn' | 'ap4h';
/**
* This option specifies the stabilization mode to use when recording a video.
* @platform ios
*/
export type VideoStabilization = 'off' | 'standard' | 'cinematic' | 'auto';
// @docsMissing
export type VideoQuality = '2160p' | '1080p' | '720p' | '480p' | '4:3';
export type CameraOrientation =
| 'portrait'
| 'portraitUpsideDown'
| 'landscapeLeft'
| 'landscapeRight';
// @docsMissing
/**
* @hidden We do not expose related web methods in docs.
* @platform web
*/
export type ImageSize = {
width: number;
height: number;
};
// @docsMissing
/**
* @hidden We do not expose related web methods in docs.
* @platform web
*/
export type WebCameraSettings = {
autoFocus?: string;
flashMode?: string;
whiteBalance?: string;
exposureCompensation?: number;
colorTemperature?: number;
iso?: number;
brightness?: number;
contrast?: number;
saturation?: number;
sharpness?: number;
focusDistance?: number;
zoom?: number;
};
// @needsAudit
export type CameraCapturedPicture = {
/**
* Captured image width.
*/
width: number;
/**
* Captured image height.
*/
height: number;
/**
* On web, the value of `uri` is the same as `base64` because file system URLs are not supported in the browser.
*/
uri: string;
/**
* A Base64 representation of the image.
*/
base64?: string;
/**
* On Android and iOS this object may include various fields based on the device and operating system.
* On web, it is a partial representation of the [`MediaTrackSettings`](https://developer.mozilla.org/en-US/docs/Web/API/MediaTrackSettings) dictionary.
*/
exif?: Partial<MediaTrackSettings> | any;
};
// @needsAudit
export type CameraPictureOptions = {
/**
* Specify the compression quality from `0` to `1`. `0` means compress for small size, and `1` means compress for maximum quality.
*/
quality?: number;
/**
* Whether to also include the image data in Base64 format.
*/
base64?: boolean;
/**
* Whether to also include the EXIF data for the image.
*/
exif?: boolean;
/**
* Additional EXIF data to be included for the image. Only useful when `exif` option is set to `true`.
* @platform android
* @platform ios
*/
additionalExif?: Record<string, any>;
/**
* A callback invoked when picture is saved. If set, the promise of this method will resolve immediately with no data after picture is captured.
* The data that it should contain will be passed to this callback. If displaying or processing a captured photo right after taking it
* is not your case, this callback lets you skip waiting for it to be saved.
* @param picture
*/
onPictureSaved?: (picture: CameraCapturedPicture) => void;
// TODO(Bacon): Is it possible to implement this in the browser?
/**
* If set to `true`, camera skips orientation adjustment and returns an image straight from the device's camera.
* If enabled, `quality` option is discarded (processing pipeline is skipped as a whole).
* Although enabling this option reduces image delivery time significantly, it may cause the image to appear in a wrong orientation
* in the `Image` component (at the time of writing, it does not respect EXIF orientation of the images).
* > **Note**: Enabling `skipProcessing` would cause orientation uncertainty. `Image` component does not respect EXIF
* > stored orientation information, that means obtained image would be displayed wrongly (rotated by 90°, 180° or 270°).
* > Different devices provide different orientations. For example some Sony Xperia or Samsung devices don't provide
* > correctly oriented images by default. To always obtain correctly oriented image disable `skipProcessing` option.
*/
skipProcessing?: boolean;
/**
* @platform web
*/
scale?: number;
/**
* @platform web
*/
imageType?: ImageType;
/**
* @platform web
*/
isImageMirror?: boolean;
/**
* When set to `true`, the output image will be flipped along the vertical axis when using the front camera.
* @default false
* @platform ios
* @platform android
* @deprecated Use `mirror` prop on `CameraView` instead.
*/
mirror?: boolean;
/**
* @hidden
*/
id?: number;
/**
* @hidden
*/
fastMode?: boolean;
/**
* @hidden
*/
maxDownsampling?: number;
};
// @needsAudit
export type CameraRecordingOptions = {
/**
* Maximum video duration in seconds.
*/
maxDuration?: number;
/**
* Maximum video file size in bytes.
*/
maxFileSize?: number;
/**
* If `true`, the recorded video will be flipped along the vertical axis. iOS flips videos recorded with the front camera by default,
* but you can reverse that back by setting this to `true`. On Android, this is handled in the user's device settings.
* @deprecated Use `mirror` prop on `CameraView` instead.
*/
mirror?: boolean;
/**
* This option specifies what codec to use when recording the video. See [`VideoCodec`](#videocodec) for the possible values.
* @platform ios
*/
codec?: VideoCodec;
};
/**
* @hidden
*/
export type PictureSavedListener = (event: {
nativeEvent: { data: CameraCapturedPicture; id: number };
}) => void;
/**
* @hidden
*/
export type CameraReadyListener = () => void;
/**
* @hidden
*/
export type ResponsiveOrientationChangedListener = (event: {
nativeEvent: ResponsiveOrientationChanged;
}) => void;
export type ResponsiveOrientationChanged = { orientation: CameraOrientation };
/**
* @hidden
*/
export type MountErrorListener = (event: { nativeEvent: CameraMountError }) => void;
// @docsMissing
export type CameraMountError = { message: string };
// @docsMissing
export type Point = {
x: number;
y: number;
};
export type BarcodeSize = {
/**
* The height value.
*/
height: number;
/**
* The width value.
*/
width: number;
};
/**
* These coordinates are represented in the coordinate space of the camera source (e.g. when you
* are using the camera view, these values are adjusted to the dimensions of the view).
*/
export type BarcodePoint = Point;
export type BarcodeBounds = {
/**
* The origin point of the bounding box.
*/
origin: BarcodePoint;
/**
* The size of the bounding box.
*/
size: BarcodeSize;
};
// @needsAudit
export type BarcodeScanningResult = {
/**
* The barcode type.
*/
type: string;
/**
* The parsed information encoded in the barcode.
*/
data: string;
/**
* The raw information encoded in the barcode.
* May be different from `data` depending on the barcode type.
* @platform android
* @hidden
*/
raw?: string;
/**
* Corner points of the bounding box.
* `cornerPoints` is not always available and may be empty. On iOS, for `code39` and `pdf417`
* you don't get this value.
*/
cornerPoints: BarcodePoint[];
/**
* The [BarcodeBounds](#barcodebounds) object.
* `bounds` in some case will be representing an empty rectangle.
* Moreover, `bounds` doesn't have to bound the whole barcode.
* For some types, they will represent the area used by the scanner.
*/
bounds: BarcodeBounds;
};
export type ScanningResult = Omit<BarcodeScanningResult, 'bounds'>;
// @needsAudit
export type CameraProps = ViewProps & {
/**
* Camera facing. Use one of `CameraType`. When `front`, use the front-facing camera.
* When `back`, use the back-facing camera.
* @default 'back'
*/
facing?: CameraType;
/**
* Camera flash mode. Use one of `FlashMode` values. When `on`, the flash on your device will
* turn on when taking a picture. When `off`, it won't. Setting it to `auto` will fire flash if required.
* @default 'off'
*/
flash?: FlashMode;
/**
* A value between `0` and `1` being a percentage of device's max zoom. `0` - not zoomed, `1` - maximum zoom.
* @default 0
*/
zoom?: number;
/**
* Used to select image or video output
* @default 'picture'
*/
mode?: CameraMode;
/**
* If present, video will be recorded with no sound.
* @default false
*/
mute?: boolean;
/**
* A boolean that determines whether the camera should mirror the image when using the front camera.
* @default false
*/
mirror?: boolean;
/**
* Indicates the focus mode to use.
* @default off
* @platform ios
*/
autofocus?: FocusMode;
/**
* A boolean that determines whether the camera should be active.
* Useful in situations where the camera may not have unmounted but you still want to stop the camera session.
* @default true
* @platform ios
*/
active?: boolean;
/**
* Specify the quality of the recorded video. Use one of `VideoQuality` possible values:
* for 16:9 resolution `2160p`, `1080p`, `720p`, `480p` : `Android only` and for 4:3 `4:3` (the size is 640x480).
* If the chosen quality is not available for a device, the highest available is chosen.
*/
videoQuality?: VideoQuality;
/**
* A boolean that determines whether the camera shutter animation should be enabled.
* @default true
*/
animateShutter?: boolean;
/**
* A string representing the size of pictures [`takePictureAsync`](#takepictureasync) will take.
* Available sizes can be fetched with [`getAvailablePictureSizes`](#getavailablepicturesizes).
*/
pictureSize?: string;
/**
* A boolean to enable or disable the torch
* @default false
*/
enableTorch?: boolean;
/**
* The video stabilization mode used for a video recording. Use one of [`VideoStabilization.<value>`](#videostabilization).
* You can read more about each stabilization type in [Apple Documentation](https://developer.apple.com/documentation/avfoundation/avcapturevideostabilizationmode).
* @platform ios
*/
videoStabilizationMode?: VideoStabilization;
/**
* @example
* ```tsx
* <CameraView
* barcodeScannerSettings={{
* barcodeTypes: ["qr"],
* }}
* />
* ```
*/
barcodeScannerSettings?: BarcodeSettings;
/**
* A URL for an image to be shown while the camera is loading.
* @platform web
*/
poster?: string;
/**
* Whether to allow responsive orientation of the camera when the screen orientation is locked (i.e. when set to `true`
* landscape photos will be taken if the device is turned that way, even if the app or device orientation is locked to portrait)
* @platform ios
*/
responsiveOrientationWhenOrientationLocked?: boolean;
/**
* A string representing the aspect ratio of the preview. For example, `4:3` and `16:9`.
* Note: Setting the aspect ratio here will change the scaleType of the camera preview from `FILL` to `FIT`.
* Also, when using 1:1, devices only support certain sizes. If you specify an unsupported size, the closest supported ratio will be used.
* @platform android
*/
ratio?: CameraRatio;
/**
* Callback invoked when camera preview has been set.
*/
onCameraReady?: () => void;
/**
* Callback invoked when camera preview could not start.
* @param event Error object that contains a `message`.
*/
onMountError?: (event: CameraMountError) => void;
/**
* Callback that is invoked when a barcode has been successfully scanned. The callback is provided with
* an object of the [`BarcodeScanningResult`](#barcodescanningresult) shape, where the `type`
* refers to the barcode type that was scanned, and the `data` is the information encoded in the barcode
* (in this case of QR codes, this is often a URL). See [`BarcodeType`](#barcodetype) for supported values.
* for supported values.
* @param scanningResult
*/
onBarcodeScanned?: (scanningResult: BarcodeScanningResult) => void;
/**
* Callback invoked when responsive orientation changes. Only applicable if `responsiveOrientationWhenOrientationLocked` is `true`
* @param event result object that contains updated orientation of camera
* @platform ios
*/
onResponsiveOrientationChanged?: (event: ResponsiveOrientationChanged) => void;
};
/**
* @hidden
*/
export interface CameraViewRef {
readonly takePicture: (options: CameraPictureOptions) => Promise<CameraCapturedPicture>;
readonly getAvailablePictureSizes: () => Promise<string[]>;
readonly record: (options?: CameraRecordingOptions) => Promise<{ uri: string }>;
readonly stopRecording: () => Promise<void>;
readonly launchModernScanner: () => Promise<void>;
readonly resumePreview: () => Promise<void>;
readonly pausePreview: () => Promise<void>;
}
/**
* @hidden
*/
export type CameraNativeProps = {
pointerEvents?: any;
style?: any;
ref?: Ref<CameraViewRef>;
onCameraReady?: CameraReadyListener;
onMountError?: MountErrorListener;
onBarcodeScanned?: (event: { nativeEvent: BarcodeScanningResult }) => void;
onPictureSaved?: PictureSavedListener;
onResponsiveOrientationChanged?: ResponsiveOrientationChangedListener;
facing?: string;
flashMode?: string;
enableTorch?: boolean;
animateShutter?: boolean;
autoFocus?: FocusMode;
mute?: boolean;
zoom?: number;
ratio?: CameraRatio;
barcodeScannerSettings?: BarcodeSettings;
barcodeScannerEnabled?: boolean;
poster?: string;
responsiveOrientationWhenOrientationLocked?: boolean;
};
// @docsMissing
export type BarcodeSettings = {
barcodeTypes: BarcodeType[];
};
/**
* @platform ios
*/
export type ScanningOptions = {
/**
* The type of codes to scan for.
*/
barcodeTypes: BarcodeType[];
/**
* Indicates whether people can use a two-finger pinch-to-zoom gesture.
* @default true
*/
isPinchToZoomEnabled?: boolean;
/**
* Guidance text, such as “Slow Down,” appears over the live video.
* @default true
*/
isGuidanceEnabled?: boolean;
/**
* Indicates whether the scanner displays highlights around recognized items.
* @default false
*/
isHighlightingEnabled?: boolean;
};
/**
* The available barcode types that can be scanned.
*/
export type BarcodeType =
| 'aztec'
| 'ean13'
| 'ean8'
| 'qr'
| 'pdf417'
| 'upc_e'
| 'datamatrix'
| 'code39'
| 'code93'
| 'itf14'
| 'codabar'
| 'code128'
| 'upc_a';
export { PermissionResponse, PermissionStatus, PermissionExpiration, PermissionHookOptions };

View File

@@ -0,0 +1,304 @@
import { Platform, UnavailabilityError, EventEmitter, Subscription } from 'expo-modules-core';
import * as React from 'react';
import { Ref } from 'react';
import {
CameraCapturedPicture,
CameraOrientation,
CameraPictureOptions,
CameraProps,
CameraRecordingOptions,
CameraViewRef,
ScanningOptions,
ScanningResult,
VideoCodec,
} from './Camera.types';
import ExpoCamera from './ExpoCamera';
import CameraManager from './ExpoCameraManager';
import { ConversionTables, ensureNativeProps } from './utils/props';
const emitter = new EventEmitter(CameraManager);
const EventThrottleMs = 500;
const _PICTURE_SAVED_CALLBACKS = {};
let _GLOBAL_PICTURE_ID = 1;
function ensurePictureOptions(options?: CameraPictureOptions): CameraPictureOptions {
if (!options || typeof options !== 'object') {
return {};
}
if (!options.quality) {
options.quality = 1;
}
if (options.mirror) {
console.warn(
'The `mirror` option is deprecated. Please use the `mirror` prop on the `CameraView` instead.'
);
}
if (options.onPictureSaved) {
const id = _GLOBAL_PICTURE_ID++;
_PICTURE_SAVED_CALLBACKS[id] = options.onPictureSaved;
options.id = id;
options.fastMode = true;
}
return options;
}
function ensureRecordingOptions(options?: CameraRecordingOptions): CameraRecordingOptions {
if (!options || typeof options !== 'object') {
return {};
}
if (options.mirror) {
console.warn(
'The `mirror` option is deprecated. Please use the `mirror` prop on the `CameraView` instead.'
);
}
return options;
}
function _onPictureSaved({
nativeEvent,
}: {
nativeEvent: { data: CameraCapturedPicture; id: number };
}) {
const { id, data } = nativeEvent;
const callback = _PICTURE_SAVED_CALLBACKS[id];
if (callback) {
callback(data);
delete _PICTURE_SAVED_CALLBACKS[id];
}
}
export default class CameraView extends React.Component<CameraProps> {
/**
* Property that determines if the current device has the ability to use `DataScannerViewController` (iOS 16+).
*/
static isModernBarcodeScannerAvailable: boolean = CameraManager.isModernBarcodeScannerAvailable;
/**
* Check whether the current device has a camera. This is useful for web and simulators cases.
* This isn't influenced by the Permissions API (all platforms), or HTTP usage (in the browser).
* You will still need to check if the native permission has been accepted.
* @platform web
*/
static async isAvailableAsync(): Promise<boolean> {
if (!CameraManager.isAvailableAsync) {
throw new UnavailabilityError('expo-camera', 'isAvailableAsync');
}
return await CameraManager.isAvailableAsync();
}
// @needsAudit
/**
* Queries the device for the available video codecs that can be used in video recording.
* @return A promise that resolves to a list of strings that represents available codecs.
* @platform ios
*/
static async getAvailableVideoCodecsAsync(): Promise<VideoCodec[]> {
if (!CameraManager.getAvailableVideoCodecsAsync) {
throw new UnavailabilityError('Camera', 'getAvailableVideoCodecsAsync');
}
return await CameraManager.getAvailableVideoCodecsAsync();
}
/**
* Get picture sizes that are supported by the device.
* @return Returns a Promise that resolves to an array of strings representing picture sizes that can be passed to `pictureSize` prop.
* The list varies across Android devices but is the same for every iOS.
*/
async getAvailablePictureSizesAsync(): Promise<string[]> {
return (await this._cameraRef.current?.getAvailablePictureSizes()) ?? [];
}
/**
* Resumes the camera preview.
*/
async resumePreview(): Promise<void> {
return this._cameraRef.current?.resumePreview();
}
/**
* Pauses the camera preview. It is not recommended to use `takePictureAsync` when preview is paused.
*/
async pausePreview(): Promise<void> {
return this._cameraRef.current?.pausePreview();
}
// Values under keys from this object will be transformed to native options
static ConversionTables = ConversionTables;
static defaultProps: CameraProps = {
zoom: 0,
facing: 'back',
enableTorch: false,
mode: 'picture',
flash: 'off',
};
_cameraHandle?: number | null;
_cameraRef = React.createRef<CameraViewRef>();
_lastEvents: { [eventName: string]: string } = {};
_lastEventsTimes: { [eventName: string]: Date } = {};
// @needsAudit
/**
* Takes a picture and saves it to app's cache directory. Photos are rotated to match device's orientation
* (if `options.skipProcessing` flag is not enabled) and scaled to match the preview. Anyway on Android it is essential
* to set ratio prop to get a picture with correct dimensions.
* > **Note**: Make sure to wait for the [`onCameraReady`](#oncameraready) callback before calling this method.
* @param options An object in form of `CameraPictureOptions` type.
* @return Returns a Promise that resolves to `CameraCapturedPicture` object, where `uri` is a URI to the local image file on iOS,
* Android, and a base64 string on web (usable as the source for an `Image` element). The `width` and `height` properties specify
* the dimensions of the image. `base64` is included if the `base64` option was truthy, and is a string containing the JPEG data
* of the image in Base64--prepend that with `'data:image/jpg;base64,'` to get a data URI, which you can use as the source
* for an `Image` element for example. `exif` is included if the `exif` option was truthy, and is an object containing EXIF
* data for the image--the names of its properties are EXIF tags and their values are the values for those tags.
*
* > On native platforms, the local image URI is temporary. Use [`FileSystem.copyAsync`](filesystem/#filesystemcopyasyncoptions)
* > to make a permanent copy of the image.
*
* **Note** Avoid calling this method while the preview is paused. On iOS, this will take a picture of the last frame that is currently on screen, on Android, this will throw an error.
*/
async takePictureAsync(
options?: CameraPictureOptions
): Promise<CameraCapturedPicture | undefined> {
const pictureOptions = ensurePictureOptions(options);
return await this._cameraRef.current?.takePicture(pictureOptions);
}
/**
* Presents a modal view controller that uses the [`DataScannerViewController`](https://developer.apple.com/documentation/visionkit/scanning_data_with_the_camera) available on iOS 16+.
* @platform ios
*/
static async launchScanner(options?: ScanningOptions): Promise<void> {
if (!options) {
options = { barcodeTypes: [] };
}
if (Platform.OS === 'ios' && CameraView.isModernBarcodeScannerAvailable) {
await CameraManager.launchScanner(options);
}
}
/**
* Dimiss the scanner presented by `launchScanner`.
* @platform ios
*/
static async dismissScanner(): Promise<void> {
if (Platform.OS === 'ios' && CameraView.isModernBarcodeScannerAvailable) {
await CameraManager.dismissScanner();
}
}
/**
* Invokes the `listener` function when a bar code has been successfully scanned. The callback is provided with
* an object of the `ScanningResult` shape, where the `type` refers to the bar code type that was scanned and the `data` is the information encoded in the bar code
* (in this case of QR codes, this is often a URL). See [`BarcodeType`](#barcodetype) for supported values.
* @param listener Invoked with the [ScanningResult](#scanningresult) when a bar code has been successfully scanned.
*
* @platform ios
*/
static onModernBarcodeScanned(listener: (event: ScanningResult) => void): Subscription {
return emitter.addListener<ScanningResult>('onModernBarcodeScanned', listener);
}
/**
* Starts recording a video that will be saved to cache directory. Videos are rotated to match device's orientation.
* Flipping camera during a recording results in stopping it.
* @param options A map of `CameraRecordingOptions` type.
* @return Returns a Promise that resolves to an object containing video file `uri` property and a `codec` property on iOS.
* The Promise is returned if `stopRecording` was invoked, one of `maxDuration` and `maxFileSize` is reached or camera preview is stopped.
* @platform android
* @platform ios
*/
async recordAsync(options?: CameraRecordingOptions): Promise<{ uri: string } | undefined> {
const recordingOptions = ensureRecordingOptions(options);
return await this._cameraRef.current?.record(recordingOptions);
}
/**
* Stops recording if any is in progress.
*/
stopRecording() {
this._cameraRef.current?.stopRecording();
}
_onCameraReady = () => {
if (this.props.onCameraReady) {
this.props.onCameraReady();
}
};
_onMountError = ({ nativeEvent }: { nativeEvent: { message: string } }) => {
if (this.props.onMountError) {
this.props.onMountError(nativeEvent);
}
};
_onResponsiveOrientationChanged = ({
nativeEvent,
}: {
nativeEvent: { orientation: CameraOrientation };
}) => {
if (this.props.onResponsiveOrientationChanged) {
this.props.onResponsiveOrientationChanged(nativeEvent);
}
};
_onObjectDetected =
(callback?: Function) =>
({ nativeEvent }: { nativeEvent: any }) => {
const { type } = nativeEvent;
if (
this._lastEvents[type] &&
this._lastEventsTimes[type] &&
JSON.stringify(nativeEvent) === this._lastEvents[type] &&
new Date().getTime() - this._lastEventsTimes[type].getTime() < EventThrottleMs
) {
return;
}
if (callback) {
callback(nativeEvent);
this._lastEventsTimes[type] = new Date();
this._lastEvents[type] = JSON.stringify(nativeEvent);
}
};
_setReference = (ref: Ref<CameraViewRef>) => {
if (ref) {
// TODO(Bacon): Unify these - perhaps with hooks?
if (Platform.OS === 'web') {
this._cameraHandle = ref as any;
}
}
};
render() {
const nativeProps = ensureNativeProps(this.props);
const onBarcodeScanned = this.props.onBarcodeScanned
? this._onObjectDetected(this.props.onBarcodeScanned)
: undefined;
return (
<ExpoCamera
{...nativeProps}
ref={this._cameraRef}
onCameraReady={this._onCameraReady}
onMountError={this._onMountError}
onBarcodeScanned={onBarcodeScanned}
onPictureSaved={_onPictureSaved}
onResponsiveOrientationChanged={this._onResponsiveOrientationChanged}
/>
);
}
}

View File

@@ -0,0 +1,8 @@
import { requireNativeViewManager } from 'expo-modules-core';
import * as React from 'react';
import { CameraNativeProps } from './Camera.types';
const ExpoCamera: React.ComponentType<CameraNativeProps> = requireNativeViewManager('ExpoCamera');
export default ExpoCamera;

View File

@@ -0,0 +1,164 @@
import { CodedError } from 'expo-modules-core';
import * as React from 'react';
import { StyleProp, StyleSheet, View, ViewStyle } from 'react-native';
import createElement from 'react-native-web/dist/exports/createElement';
import CameraManager from './ExpoCameraManager.web';
import {
CameraCapturedPicture,
CameraNativeProps,
CameraPictureOptions,
CameraType,
} from './legacy/Camera.types';
import { capture } from './web/WebCameraUtils';
import { PictureSizes } from './web/WebConstants';
import { useWebCameraStream } from './web/useWebCameraStream';
import { useWebQRScanner } from './web/useWebQRScanner';
export interface ExponentCameraRef {
getAvailablePictureSizes: (ratio: string) => Promise<string[]>;
takePicture: (options: CameraPictureOptions) => Promise<CameraCapturedPicture>;
resumePreview: () => Promise<void>;
pausePreview: () => Promise<void>;
}
const ExponentCamera = React.forwardRef(
(
{ type, poster, ...props }: CameraNativeProps & { children?: React.ReactNode },
ref: React.Ref<ExponentCameraRef>
) => {
const video = React.useRef<HTMLVideoElement | null>(null);
const native = useWebCameraStream(video, type as CameraType, props, {
onCameraReady() {
if (props.onCameraReady) {
props.onCameraReady();
}
},
onMountError: props.onMountError,
});
const isQRScannerEnabled = React.useMemo<boolean>(() => {
return !!(
props.barCodeScannerSettings?.barCodeTypes?.includes('qr') && !!props.onBarCodeScanned
);
}, [props.barCodeScannerSettings?.barCodeTypes, props.onBarCodeScanned]);
useWebQRScanner(video, {
interval: props.barCodeScannerSettings?.interval,
isEnabled: isQRScannerEnabled,
captureOptions: { scale: 1, isImageMirror: native.type === CameraType.front },
onScanned(event) {
if (props.onBarCodeScanned) {
props.onBarCodeScanned(event);
}
},
// onError: props.onMountError,
});
// const [pause, setPaused]
React.useImperativeHandle(
ref,
() => ({
async getAvailablePictureSizes(ratio: string): Promise<string[]> {
return PictureSizes;
},
async takePicture(options: CameraPictureOptions): Promise<CameraCapturedPicture> {
if (!video.current || video.current?.readyState !== video.current?.HAVE_ENOUGH_DATA) {
throw new CodedError(
'ERR_CAMERA_NOT_READY',
'HTMLVideoElement does not have enough camera data to construct an image yet.'
);
}
const settings = native.mediaTrackSettings;
if (!settings) {
throw new CodedError('ERR_CAMERA_NOT_READY', 'MediaStream is not ready yet.');
}
return capture(video.current, settings, {
...options,
// This will always be defined, the option gets added to a queue in the upper-level. We should replace the original so it isn't called twice.
onPictureSaved(picture) {
if (options.onPictureSaved) {
options.onPictureSaved(picture);
}
if (props.onPictureSaved) {
props.onPictureSaved({ nativeEvent: { data: picture, id: -1 } });
}
},
});
},
async resumePreview(): Promise<void> {
if (video.current) {
video.current.play();
}
},
async pausePreview(): Promise<void> {
if (video.current) {
video.current.pause();
}
},
}),
[native.mediaTrackSettings, props.onPictureSaved]
);
// TODO(Bacon): Create a universal prop, on native the microphone is only used when recording videos.
// Because we don't support recording video in the browser we don't need the user to give microphone permissions.
const isMuted = true;
const style = React.useMemo<StyleProp<ViewStyle>>(() => {
const isFrontFacingCamera = native.type === CameraManager.Type.front;
return [
StyleSheet.absoluteFill,
styles.video,
{
// Flip the camera
transform: isFrontFacingCamera ? [{ scaleX: -1 }] : undefined,
},
];
}, [native.type]);
return (
<View pointerEvents="box-none" style={[styles.videoWrapper, props.style]}>
<Video
autoPlay
playsInline
muted={isMuted}
poster={poster}
// webkitPlaysinline
pointerEvents={props.pointerEvents}
ref={video}
style={style}
/>
{props.children}
</View>
);
}
);
export default ExponentCamera;
const Video = React.forwardRef(
(
props: React.ComponentProps<typeof View> & {
autoPlay?: boolean;
playsInline?: boolean;
muted?: boolean;
poster?: string;
},
ref: React.Ref<HTMLVideoElement>
) => createElement('video', { ...props, ref })
);
const styles = StyleSheet.create({
videoWrapper: {
flex: 1,
alignItems: 'stretch',
},
video: {
width: '100%',
height: '100%',
objectFit: 'cover',
},
});

View File

@@ -0,0 +1,3 @@
import { requireNativeModule } from 'expo-modules-core';
export default requireNativeModule('ExpoCamera');

View File

@@ -0,0 +1,236 @@
import { UnavailabilityError } from 'expo-modules-core';
import {
CameraCapturedPicture,
CameraPictureOptions,
CameraType,
PermissionResponse,
PermissionStatus,
} from './legacy/Camera.types';
import { ExponentCameraRef } from './legacy/ExpoCamera.web';
import {
canGetUserMedia,
isBackCameraAvailableAsync,
isFrontCameraAvailableAsync,
} from './web/WebUserMediaManager';
function getUserMedia(constraints: MediaStreamConstraints): Promise<MediaStream> {
if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
return navigator.mediaDevices.getUserMedia(constraints);
}
// Some browsers partially implement mediaDevices. We can't just assign an object
// with getUserMedia as it would overwrite existing properties.
// Here, we will just add the getUserMedia property if it's missing.
// First get ahold of the legacy getUserMedia, if present
const getUserMedia =
// TODO: this method is deprecated, migrate to https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia
navigator.getUserMedia ||
navigator.webkitGetUserMedia ||
navigator.mozGetUserMedia ||
function () {
const error: any = new Error('Permission unimplemented');
error.code = 0;
error.name = 'NotAllowedError';
throw error;
};
return new Promise((resolve, reject) => {
getUserMedia.call(navigator, constraints, resolve, reject);
});
}
function handleGetUserMediaError({ message }: { message: string }): PermissionResponse {
// name: NotAllowedError
// code: 0
if (message === 'Permission dismissed') {
return {
status: PermissionStatus.UNDETERMINED,
expires: 'never',
canAskAgain: true,
granted: false,
};
} else {
// TODO: Bacon: [OSX] The system could deny access to chrome.
// TODO: Bacon: add: { status: 'unimplemented' }
return {
status: PermissionStatus.DENIED,
expires: 'never',
canAskAgain: true,
granted: false,
};
}
}
async function handleRequestPermissionsAsync(): Promise<PermissionResponse> {
try {
await getUserMedia({
video: true,
});
return {
status: PermissionStatus.GRANTED,
expires: 'never',
canAskAgain: true,
granted: true,
};
} catch ({ message }) {
return handleGetUserMediaError({ message });
}
}
async function handlePermissionsQueryAsync(
query: 'camera' | 'microphone'
): Promise<PermissionResponse> {
if (!navigator?.permissions?.query) {
throw new UnavailabilityError('expo-camera', 'navigator.permissions API is not available');
}
try {
const { state } = await navigator.permissions.query({ name: query });
switch (state) {
case 'prompt':
return {
status: PermissionStatus.UNDETERMINED,
expires: 'never',
canAskAgain: true,
granted: false,
};
case 'granted':
return {
status: PermissionStatus.GRANTED,
expires: 'never',
canAskAgain: true,
granted: true,
};
case 'denied':
return {
status: PermissionStatus.DENIED,
expires: 'never',
canAskAgain: true,
granted: false,
};
}
} catch (e) {
// Firefox doesn't support querying for the camera permission, so return undetermined status
if (e instanceof TypeError) {
return {
status: PermissionStatus.UNDETERMINED,
expires: 'never',
canAskAgain: true,
granted: false,
};
}
throw e;
}
}
export default {
get Type() {
return {
back: 'back',
front: 'front',
};
},
get FlashMode() {
return {
on: 'on',
off: 'off',
auto: 'auto',
torch: 'torch',
};
},
get AutoFocus() {
return {
on: 'on',
off: 'off',
auto: 'auto',
singleShot: 'singleShot',
};
},
get WhiteBalance() {
return {
auto: 'auto',
continuous: 'continuous',
manual: 'manual',
};
},
get VideoQuality() {
return {};
},
get VideoStabilization() {
return {};
},
async isAvailableAsync(): Promise<boolean> {
return canGetUserMedia();
},
async takePicture(
options: CameraPictureOptions,
camera: ExponentCameraRef
): Promise<CameraCapturedPicture> {
return await camera.takePicture(options);
},
async pausePreview(camera: ExponentCameraRef): Promise<void> {
await camera.pausePreview();
},
async resumePreview(camera: ExponentCameraRef): Promise<void> {
return await camera.resumePreview();
},
async getAvailableCameraTypesAsync(): Promise<string[]> {
if (!canGetUserMedia() || !navigator.mediaDevices.enumerateDevices) return [];
const devices = await navigator.mediaDevices.enumerateDevices();
const types: (string | null)[] = await Promise.all([
(await isFrontCameraAvailableAsync(devices)) && CameraType.front,
(await isBackCameraAvailableAsync()) && CameraType.back,
]);
return types.filter(Boolean) as string[];
},
async getAvailablePictureSizes(ratio: string, camera: ExponentCameraRef): Promise<string[]> {
return await camera.getAvailablePictureSizes(ratio);
},
/* async getSupportedRatios(camera: ExponentCameraRef): Promise<string[]> {
// TODO: Support on web
},
async record(
options?: CameraRecordingOptions,
camera: ExponentCameraRef
): Promise<{ uri: string }> {
// TODO: Support on web
},
async stopRecording(camera: ExponentCameraRef): Promise<void> {
// TODO: Support on web
}, */
async getPermissionsAsync(): Promise<PermissionResponse> {
return handlePermissionsQueryAsync('camera');
},
async requestPermissionsAsync(): Promise<PermissionResponse> {
return handleRequestPermissionsAsync();
},
async getCameraPermissionsAsync(): Promise<PermissionResponse> {
return handlePermissionsQueryAsync('camera');
},
async requestCameraPermissionsAsync(): Promise<PermissionResponse> {
return handleRequestPermissionsAsync();
},
async getMicrophonePermissionsAsync(): Promise<PermissionResponse> {
return handlePermissionsQueryAsync('microphone');
},
async requestMicrophonePermissionsAsync(): Promise<PermissionResponse> {
try {
await getUserMedia({
audio: true,
});
return {
status: PermissionStatus.GRANTED,
expires: 'never',
canAskAgain: true,
granted: true,
};
} catch ({ message }) {
return handleGetUserMediaError({ message });
}
},
};

View File

@@ -0,0 +1,105 @@
import { createPermissionHook } from 'expo-modules-core';
import { BarcodeScanningResult, BarcodeType } from './Camera.types';
import CameraManager from './ExpoCameraManager';
import { PermissionResponse } from './legacy/Camera.types';
export { default as CameraView } from './CameraView';
// @needsAudit
/**
* Checks user's permissions for accessing camera.
* @return A promise that resolves to an object of type [PermissionResponse](#permissionresponse).
*/
async function getCameraPermissionsAsync(): Promise<PermissionResponse> {
return CameraManager.getCameraPermissionsAsync();
}
// @needsAudit
/**
* Asks the user to grant permissions for accessing camera.
* On iOS this will require apps to specify an `NSCameraUsageDescription` entry in the **Info.plist**.
* @return A promise that resolves to an object of type [PermissionResponse](#permissionresponse).
*/
async function requestCameraPermissionsAsync(): Promise<PermissionResponse> {
return CameraManager.requestCameraPermissionsAsync();
}
// @needsAudit
/**
* Check or request permissions to access the camera.
* This uses both `requestCameraPermissionsAsync` and `getCameraPermissionsAsync` to interact with the permissions.
*
* @example
* ```ts
* const [status, requestPermission] = useCameraPermissions();
* ```
*/
export const useCameraPermissions = createPermissionHook({
getMethod: getCameraPermissionsAsync,
requestMethod: requestCameraPermissionsAsync,
});
// @needsAudit
/**
* Checks user's permissions for accessing microphone.
* @return A promise that resolves to an object of type [PermissionResponse](#permissionresponse).
*/
async function getMicrophonePermissionsAsync(): Promise<PermissionResponse> {
return CameraManager.getMicrophonePermissionsAsync();
}
// @needsAudit
/**
* Asks the user to grant permissions for accessing the microphone.
* On iOS this will require apps to specify an `NSMicrophoneUsageDescription` entry in the **Info.plist**.
* @return A promise that resolves to an object of type [PermissionResponse](#permissionresponse).
*/
async function requestMicrophonePermissionsAsync(): Promise<PermissionResponse> {
return CameraManager.requestMicrophonePermissionsAsync();
}
// @needsAudit
/**
* Check or request permissions to access the microphone.
* This uses both `requestMicrophonePermissionsAsync` and `getMicrophonePermissionsAsync` to interact with the permissions.
*
* @example
* ```ts
* const [status, requestPermission] = Camera.useMicrophonePermissions();
* ```
*/
export const useMicrophonePermissions = createPermissionHook({
getMethod: getMicrophonePermissionsAsync,
requestMethod: requestMicrophonePermissionsAsync,
});
/**
* Scan bar codes from the image at the given URL.
* @param url URL to get the image from.
* @param barcodeTypes An array of bar code types. Defaults to all supported bar code types on
* the platform.
* > __Note:__ Only QR codes are supported on iOS.
* On android, the barcode should take up the majority of the image for best results.
* @return A possibly empty array of objects of the `BarcodeScanningResult` shape, where the type
* refers to the barcode type that was scanned and the data is the information encoded in the barcode.
*/
export async function scanFromURLAsync(
url: string,
barcodeTypes: BarcodeType[] = ['qr']
): Promise<BarcodeScanningResult[]> {
return CameraManager.scanFromURLAsync(url, barcodeTypes);
}
export * from './Camera.types';
/**
* @hidden
*/
export const Camera = {
getCameraPermissionsAsync,
requestCameraPermissionsAsync,
getMicrophonePermissionsAsync,
requestMicrophonePermissionsAsync,
scanFromURLAsync,
};

View File

@@ -0,0 +1,420 @@
import { createPermissionHook, Platform, UnavailabilityError } from 'expo-modules-core';
import * as React from 'react';
import { findNodeHandle } from 'react-native';
import {
CameraCapturedPicture,
CameraOrientation,
CameraPictureOptions,
CameraProps,
CameraRecordingOptions,
CameraType,
ConstantsType,
PermissionResponse,
VideoCodec,
} from './Camera.types';
import ExpoCamera from './ExpoCamera';
import CameraManager from './ExpoCameraManager';
import { ConversionTables, ensureNativeProps } from './utils/props';
const EventThrottleMs = 500;
const _PICTURE_SAVED_CALLBACKS = {};
let _GLOBAL_PICTURE_ID = 1;
function ensurePictureOptions(options?: CameraPictureOptions): CameraPictureOptions {
const pictureOptions: CameraPictureOptions =
!options || typeof options !== 'object' ? {} : options;
if (!pictureOptions.quality) {
pictureOptions.quality = 1;
}
if (pictureOptions.onPictureSaved) {
const id = _GLOBAL_PICTURE_ID++;
_PICTURE_SAVED_CALLBACKS[id] = pictureOptions.onPictureSaved;
pictureOptions.id = id;
pictureOptions.fastMode = true;
}
return pictureOptions;
}
function ensureRecordingOptions(options?: CameraRecordingOptions): CameraRecordingOptions {
let recordingOptions = options || {};
if (!recordingOptions || typeof recordingOptions !== 'object') {
recordingOptions = {};
} else if (typeof recordingOptions.quality === 'string') {
recordingOptions.quality = Camera.Constants.VideoQuality[recordingOptions.quality];
}
return recordingOptions;
}
function _onPictureSaved({
nativeEvent,
}: {
nativeEvent: { data: CameraCapturedPicture; id: number };
}) {
const { id, data } = nativeEvent;
const callback = _PICTURE_SAVED_CALLBACKS[id];
if (callback) {
callback(data);
delete _PICTURE_SAVED_CALLBACKS[id];
}
}
export default class Camera extends React.Component<CameraProps> {
/**
* Check whether the current device has a camera. This is useful for web and simulators cases.
* This isn't influenced by the Permissions API (all platforms), or HTTP usage (in the browser).
* You will still need to check if the native permission has been accepted.
* @platform web
*/
static async isAvailableAsync(): Promise<boolean> {
if (!CameraManager.isAvailableAsync) {
throw new UnavailabilityError('expo-camera', 'isAvailableAsync');
}
return await CameraManager.isAvailableAsync();
}
/**
* Returns a list of camera types `['front', 'back']`. This is useful for desktop browsers which only have front-facing cameras.
* @platform web
*/
static async getAvailableCameraTypesAsync(): Promise<CameraType[]> {
if (!CameraManager.getAvailableCameraTypesAsync) {
throw new UnavailabilityError('expo-camera', 'getAvailableCameraTypesAsync');
}
return await CameraManager.getAvailableCameraTypesAsync();
}
// @needsAudit
/**
* Queries the device for the available video codecs that can be used in video recording.
* @return A promise that resolves to a list of strings that represents available codecs.
* @platform ios
*/
static async getAvailableVideoCodecsAsync(): Promise<VideoCodec[]> {
if (!CameraManager.getAvailableVideoCodecsAsync) {
throw new UnavailabilityError('Camera', 'getAvailableVideoCodecsAsync');
}
return await CameraManager.getAvailableVideoCodecsAsync();
}
static Constants: ConstantsType = {
Type: CameraManager.Type,
FlashMode: CameraManager.FlashMode,
AutoFocus: CameraManager.AutoFocus,
WhiteBalance: CameraManager.WhiteBalance,
VideoQuality: CameraManager.VideoQuality,
VideoStabilization: CameraManager.VideoStabilization || {},
VideoCodec: CameraManager.VideoCodec,
};
// Values under keys from this object will be transformed to native options
static ConversionTables = ConversionTables;
static defaultProps: CameraProps = {
zoom: 0,
ratio: '4:3',
focusDepth: 0,
faceDetectorSettings: {},
type: CameraManager.Type.back,
autoFocus: CameraManager.AutoFocus.on,
flashMode: CameraManager.FlashMode.off,
whiteBalance: CameraManager.WhiteBalance.auto,
};
// @needsAudit
/**
* @deprecated Use `getCameraPermissionsAsync` or `getMicrophonePermissionsAsync` instead.
* Checks user's permissions for accessing camera.
*/
static async getPermissionsAsync(): Promise<PermissionResponse> {
console.warn(
`"getPermissionsAsync()" is now deprecated. Please use "getCameraPermissionsAsync()" or "getMicrophonePermissionsAsync()" instead.`
);
return CameraManager.getPermissionsAsync();
}
// @needsAudit
/**
* Asks the user to grant permissions for accessing camera.
* On iOS this will require apps to specify both `NSCameraUsageDescription` and `NSMicrophoneUsageDescription` entries in the **Info.plist**.
* @return A promise that resolves to an object of type [PermissionResponse](#permissionresponse).
* @deprecated Use `requestCameraPermissionsAsync` or `requestMicrophonePermissionsAsync` instead.
*/
static async requestPermissionsAsync(): Promise<PermissionResponse> {
console.warn(
`"requestPermissionsAsync()" is now deprecated. Please use "requestCameraPermissionsAsync()" or "requestMicrophonePermissionsAsync()" instead.`
);
return CameraManager.requestPermissionsAsync();
}
// @needsAudit
/**
* Checks user's permissions for accessing camera.
* @return A promise that resolves to an object of type [PermissionResponse](#permissionresponse).
*/
static async getCameraPermissionsAsync(): Promise<PermissionResponse> {
return CameraManager.getCameraPermissionsAsync();
}
// @needsAudit
/**
* Asks the user to grant permissions for accessing camera.
* On iOS this will require apps to specify an `NSCameraUsageDescription` entry in the **Info.plist**.
* @return A promise that resolves to an object of type [PermissionResponse](#permissionresponse).
*/
static async requestCameraPermissionsAsync(): Promise<PermissionResponse> {
return CameraManager.requestCameraPermissionsAsync();
}
// @needsAudit
/**
* Check or request permissions to access the camera.
* This uses both `requestCameraPermissionsAsync` and `getCameraPermissionsAsync` to interact with the permissions.
*
* @example
* ```ts
* const [status, requestPermission] = Camera.useCameraPermissions();
* ```
*/
static useCameraPermissions = createPermissionHook({
getMethod: Camera.getCameraPermissionsAsync,
requestMethod: Camera.requestCameraPermissionsAsync,
});
// @needsAudit
/**
* Checks user's permissions for accessing microphone.
* @return A promise that resolves to an object of type [PermissionResponse](#permissionresponse).
*/
static async getMicrophonePermissionsAsync(): Promise<PermissionResponse> {
return CameraManager.getMicrophonePermissionsAsync();
}
// @needsAudit
/**
* Asks the user to grant permissions for accessing the microphone.
* On iOS this will require apps to specify an `NSMicrophoneUsageDescription` entry in the **Info.plist**.
* @return A promise that resolves to an object of type [PermissionResponse](#permissionresponse).
*/
static async requestMicrophonePermissionsAsync(): Promise<PermissionResponse> {
return CameraManager.requestMicrophonePermissionsAsync();
}
// @needsAudit
/**
* Check or request permissions to access the microphone.
* This uses both `requestMicrophonePermissionsAsync` and `getMicrophonePermissionsAsync` to interact with the permissions.
*
* @example
* ```ts
* const [status, requestPermission] = Camera.useMicrophonePermissions();
* ```
*/
static useMicrophonePermissions = createPermissionHook({
getMethod: Camera.getMicrophonePermissionsAsync,
requestMethod: Camera.requestMicrophonePermissionsAsync,
});
_cameraHandle?: number | null;
_cameraRef?: React.Component | null;
_lastEvents: { [eventName: string]: string } = {};
_lastEventsTimes: { [eventName: string]: Date } = {};
// @needsAudit
/**
* Takes a picture and saves it to app's cache directory. Photos are rotated to match device's orientation
* (if `options.skipProcessing` flag is not enabled) and scaled to match the preview. Anyway on Android it is essential
* to set ratio prop to get a picture with correct dimensions.
* > **Note**: Make sure to wait for the [`onCameraReady`](#oncameraready) callback before calling this method.
* @param options An object in form of `CameraPictureOptions` type.
* @return Returns a Promise that resolves to `CameraCapturedPicture` object, where `uri` is a URI to the local image file on iOS,
* Android, and a base64 string on web (usable as the source for an `Image` element). The `width` and `height` properties specify
* the dimensions of the image. `base64` is included if the `base64` option was truthy, and is a string containing the JPEG data
* of the image in Base64--prepend that with `'data:image/jpg;base64,'` to get a data URI, which you can use as the source
* for an `Image` element for example. `exif` is included if the `exif` option was truthy, and is an object containing EXIF
* data for the image--the names of its properties are EXIF tags and their values are the values for those tags.
*
* > On native platforms, the local image URI is temporary. Use [`FileSystem.copyAsync`](filesystem/#filesystemcachedirectory)
* > to make a permanent copy of the image.
*/
async takePictureAsync(options?: CameraPictureOptions): Promise<CameraCapturedPicture> {
const pictureOptions = ensurePictureOptions(options);
return await CameraManager.takePicture(pictureOptions, this._cameraHandle);
}
/**
* Get aspect ratios that are supported by the device and can be passed via `ratio` prop.
* @return Returns a Promise that resolves to an array of strings representing ratios, eg. `['4:3', '1:1']`.
* @platform android
*/
async getSupportedRatiosAsync(): Promise<string[]> {
if (!CameraManager.getSupportedRatios) {
throw new UnavailabilityError('Camera', 'getSupportedRatiosAsync');
}
return await CameraManager.getSupportedRatios(this._cameraHandle);
}
/**
* Get picture sizes that are supported by the device for given `ratio`.
* @param ratio A string representing aspect ratio of sizes to be returned.
* @return Returns a Promise that resolves to an array of strings representing picture sizes that can be passed to `pictureSize` prop.
* The list varies across Android devices but is the same for every iOS.
*/
async getAvailablePictureSizesAsync(ratio: string): Promise<string[]> {
if (!CameraManager.getAvailablePictureSizes) {
throw new UnavailabilityError('Camera', 'getAvailablePictureSizesAsync');
}
return await CameraManager.getAvailablePictureSizes(ratio, this._cameraHandle);
}
/**
* Starts recording a video that will be saved to cache directory. Videos are rotated to match device's orientation.
* Flipping camera during a recording results in stopping it.
* @param options A map of `CameraRecordingOptions` type.
* @return Returns a Promise that resolves to an object containing video file `uri` property and a `codec` property on iOS.
* The Promise is returned if `stopRecording` was invoked, one of `maxDuration` and `maxFileSize` is reached or camera preview is stopped.
* @platform android
* @platform ios
*/
async recordAsync(options?: CameraRecordingOptions): Promise<{ uri: string }> {
if (!CameraManager.record) {
throw new UnavailabilityError('Camera', 'recordAsync');
}
const recordingOptions = ensureRecordingOptions(options);
return await CameraManager.record(recordingOptions, this._cameraHandle);
}
/**
* Stops recording if any is in progress.
*/
async stopRecording(): Promise<void> {
if (!CameraManager.stopRecording) {
throw new UnavailabilityError('Camera', 'stopRecording');
}
return await CameraManager.stopRecording(this._cameraHandle);
}
/**
* Pauses the camera preview. It is not recommended to use `takePictureAsync` when preview is paused.
*/
async pausePreview(): Promise<void> {
if (!CameraManager.pausePreview) {
throw new UnavailabilityError('Camera', 'pausePreview');
}
return await CameraManager.pausePreview(this._cameraHandle);
}
/**
* Resumes the camera preview.
*/
async resumePreview(): Promise<void> {
if (!CameraManager.resumePreview) {
throw new UnavailabilityError('Camera', 'resumePreview');
}
return await CameraManager.resumePreview(this._cameraHandle);
}
_onCameraReady = () => {
if (this.props.onCameraReady) {
this.props.onCameraReady();
}
};
_onMountError = ({ nativeEvent }: { nativeEvent: { message: string } }) => {
if (this.props.onMountError) {
this.props.onMountError(nativeEvent);
}
};
_onResponsiveOrientationChanged = ({
nativeEvent,
}: {
nativeEvent: { orientation: CameraOrientation };
}) => {
if (this.props.onResponsiveOrientationChanged) {
this.props.onResponsiveOrientationChanged(nativeEvent);
}
};
_onObjectDetected =
(callback?: Function) =>
({ nativeEvent }: { nativeEvent: any }) => {
const { type } = nativeEvent;
if (
this._lastEvents[type] &&
this._lastEventsTimes[type] &&
JSON.stringify(nativeEvent) === this._lastEvents[type] &&
new Date().getTime() - this._lastEventsTimes[type].getTime() < EventThrottleMs
) {
return;
}
if (callback) {
callback(nativeEvent);
this._lastEventsTimes[type] = new Date();
this._lastEvents[type] = JSON.stringify(nativeEvent);
}
};
_setReference = (ref?: React.Component) => {
if (ref) {
this._cameraRef = ref;
// TODO(Bacon): Unify these - perhaps with hooks?
if (Platform.OS === 'web') {
this._cameraHandle = ref as any;
} else {
this._cameraHandle = findNodeHandle(ref);
}
} else {
this._cameraRef = null;
this._cameraHandle = null;
}
};
render() {
const nativeProps = ensureNativeProps(this.props);
const onBarCodeScanned = this.props.onBarCodeScanned
? this._onObjectDetected(this.props.onBarCodeScanned)
: undefined;
const onFacesDetected = this._onObjectDetected(this.props.onFacesDetected);
return (
<ExpoCamera
{...nativeProps}
ref={this._setReference}
onCameraReady={this._onCameraReady}
onMountError={this._onMountError}
onBarCodeScanned={onBarCodeScanned}
onFacesDetected={onFacesDetected}
onPictureSaved={_onPictureSaved}
onResponsiveOrientationChanged={this._onResponsiveOrientationChanged}
/>
);
}
}
export const {
Constants,
getPermissionsAsync,
requestPermissionsAsync,
getCameraPermissionsAsync,
requestCameraPermissionsAsync,
getMicrophonePermissionsAsync,
requestMicrophonePermissionsAsync,
} = Camera;

View File

@@ -0,0 +1,547 @@
import {
PermissionResponse,
PermissionStatus,
PermissionExpiration,
PermissionHookOptions,
} from 'expo-modules-core';
import type { ViewProps } from 'react-native';
export enum CameraType {
front = 'front',
back = 'back',
}
export enum FlashMode {
on = 'on',
off = 'off',
auto = 'auto',
torch = 'torch',
}
export enum AutoFocus {
on = 'on',
off = 'off',
/**
* @platform web
*/
auto = 'auto',
/**
* @platform web
*/
singleShot = 'singleShot',
}
export enum WhiteBalance {
auto = 'auto',
/**
* @platform android
* @platform ios
*/
sunny = 'sunny',
/**
* @platform android
* @platform ios
*/
cloudy = 'cloudy',
/**
* @platform android
* @platform ios
*/
shadow = 'shadow',
/**
* @platform android
* @platform ios
*/
incandescent = 'incandescent',
/**
* @platform android
* @platform ios
*/
fluorescent = 'fluorescent',
/**
* @platform web
*/
continuous = 'continuous',
/**
* @platform web
*/
manual = 'manual',
}
export enum ImageType {
png = 'png',
jpg = 'jpg',
}
/**
* This option specifies what codec to use when recording a video.
* @platform ios
*/
export enum VideoCodec {
H264 = 'avc1',
HEVC = 'hvc1',
JPEG = 'jpeg',
AppleProRes422 = 'apcn',
AppleProRes4444 = 'ap4h',
}
/**
* This option specifies the stabilization mode to use when recording a video.
* @platform ios
*/
export enum VideoStabilization {
off = 'off',
standard = 'standard',
cinematic = 'cinematic',
auto = 'auto',
}
// @docsMissing
export enum VideoQuality {
'2160p' = '2160p',
'1080p' = '1080p',
'720p' = '720p',
'480p' = '480p',
'4:3' = '4:3',
}
export enum CameraOrientation {
portrait = 1,
portraitUpsideDown = 2,
landscapeLeft = 3,
landscapeRight = 4,
}
// @docsMissing
/**
* @hidden We do not expose related web methods in docs.
* @platform web
*/
export type ImageSize = {
width: number;
height: number;
};
// @docsMissing
/**
* @hidden We do not expose related web methods in docs.
* @platform web
*/
export type WebCameraSettings = {
autoFocus?: string;
flashMode?: string;
whiteBalance?: string;
exposureCompensation?: number;
colorTemperature?: number;
iso?: number;
brightness?: number;
contrast?: number;
saturation?: number;
sharpness?: number;
focusDistance?: number;
zoom?: number;
};
// @needsAudit
export type CameraCapturedPicture = {
/**
* Captured image width.
*/
width: number;
/**
* Captured image height.
*/
height: number;
/**
* On web, the value of `uri` is the same as `base64` because file system URLs are not supported in the browser.
*/
uri: string;
/**
* A Base64 representation of the image.
*/
base64?: string;
/**
* On Android and iOS this object may include various fields based on the device and operating system.
* On web, it is a partial representation of the [`MediaTrackSettings`](https://developer.mozilla.org/en-US/docs/Web/API/MediaTrackSettings) dictionary.
*/
exif?: Partial<MediaTrackSettings> | any;
};
// @needsAudit
export type CameraPictureOptions = {
/**
* Specify the compression quality from `0` to `1`. `0` means compress for small size, and `1` means compress for maximum quality.
*/
quality?: number;
/**
* Whether to also include the image data in Base64 format.
*/
base64?: boolean;
/**
* Whether to also include the EXIF data for the image.
*/
exif?: boolean;
/**
* Additional EXIF data to be included for the image. Only useful when `exif` option is set to `true`.
* @platform android
* @platform ios
*/
additionalExif?: Record<string, any>;
/**
* A callback invoked when picture is saved. If set, the promise of this method will resolve immediately with no data after picture is captured.
* The data that it should contain will be passed to this callback. If displaying or processing a captured photo right after taking it
* is not your case, this callback lets you skip waiting for it to be saved.
* @param picture
*/
onPictureSaved?: (picture: CameraCapturedPicture) => void;
// TODO(Bacon): Is it possible to implement this in the browser?
/**
* If set to `true`, camera skips orientation adjustment and returns an image straight from the device's camera.
* If enabled, `quality` option is discarded (processing pipeline is skipped as a whole).
* Although enabling this option reduces image delivery time significantly, it may cause the image to appear in a wrong orientation
* in the `Image` component (at the time of writing, it does not respect EXIF orientation of the images).
* > **Note**: Enabling `skipProcessing` would cause orientation uncertainty. `Image` component does not respect EXIF
* > stored orientation information, that means obtained image would be displayed wrongly (rotated by 90°, 180° or 270°).
* > Different devices provide different orientations. For example some Sony Xperia or Samsung devices don't provide
* > correctly oriented images by default. To always obtain correctly oriented image disable `skipProcessing` option.
*/
skipProcessing?: boolean;
/**
* @platform web
*/
scale?: number;
/**
* @platform web
*/
imageType?: ImageType;
/**
* @platform web
*/
isImageMirror?: boolean;
/**
* @hidden
*/
id?: number;
/**
* @hidden
*/
fastMode?: boolean;
/**
* @hidden
*/
maxDownsampling?: number;
};
// @needsAudit
export type CameraRecordingOptions = {
/**
* Maximum video duration in seconds.
*/
maxDuration?: number;
/**
* Maximum video file size in bytes.
*/
maxFileSize?: number;
/**
* Specify the quality of recorded video. Use one of [`VideoQuality.<value>`](#videoquality).
* Possible values: for 16:9 resolution `2160p`, `1080p`, `720p`, `480p` : `Android only` and for 4:3 `4:3` (the size is 640x480).
* If the chosen quality is not available for a device, the highest available is chosen.
*/
quality?: number | string;
/**
* If present, video will be recorded with no sound.
*/
mute?: boolean;
/**
* If `true`, the recorded video will be flipped along the vertical axis. iOS flips videos recorded with the front camera by default,
* but you can reverse that back by setting this to `true`. On Android, this is handled in the user's device settings.
* @platform ios
*/
mirror?: boolean;
/**
* Only works if `useCamera2Api` is set to `true`. This option specifies a desired video bitrate. For example, `5*1000*1000` would be 5Mbps.
* @platform android
*/
videoBitrate?: number;
/**
* This option specifies what codec to use when recording the video. See [`VideoCodec`](#videocodec) for the possible values.
* @platform ios
*/
codec?: VideoCodec;
};
/**
* @hidden
*/
export type PictureSavedListener = (event: {
nativeEvent: { data: CameraCapturedPicture; id: number };
}) => void;
/**
* @hidden
*/
export type CameraReadyListener = () => void;
/**
* @hidden
*/
export type ResponsiveOrientationChangedListener = (event: {
nativeEvent: ResponsiveOrientationChanged;
}) => void;
export type ResponsiveOrientationChanged = { orientation: CameraOrientation };
/**
* @hidden
*/
export type MountErrorListener = (event: { nativeEvent: CameraMountError }) => void;
// @docsMissing
export type CameraMountError = { message: string };
// @docsMissing
export type Point = {
x: number;
y: number;
};
export type BarCodeSize = {
/**
* The height value.
*/
height: number;
/**
* The width value.
*/
width: number;
};
/**
* These coordinates are represented in the coordinate space of the camera source (e.g. when you
* are using the camera view, these values are adjusted to the dimensions of the view).
*/
export type BarCodePoint = Point;
export type BarCodeBounds = {
/**
* The origin point of the bounding box.
*/
origin: BarCodePoint;
/**
* The size of the bounding box.
*/
size: BarCodeSize;
};
// @needsAudit
export type BarCodeScanningResult = {
/**
* The barcode type.
*/
type: string;
/**
* The parsed information encoded in the bar code.
*/
data: string;
/**
* The raw information encoded in the bar code.
* May be different from `data` depending on the barcode type.
* @platform android
* @hidden
*/
raw?: string;
/**
* Corner points of the bounding box.
* `cornerPoints` is not always available and may be empty. On iOS, for `code39` and `pdf417`
* you don't get this value.
*/
cornerPoints: BarCodePoint[];
/**
* The [BarCodeBounds](#barcodebounds) object.
* `bounds` in some case will be representing an empty rectangle.
* Moreover, `bounds` doesn't have to bound the whole barcode.
* For some types, they will represent the area used by the scanner.
*/
bounds: BarCodeBounds;
};
export type FaceDetectionResult = {
/**
* Array of objects representing results of face detection.
* See [`FaceFeature`](facedetector/#facefeature) in FaceDetector documentation for more details.
*/
faces: object[];
};
/**
* @hidden
*/
export type ConstantsType = {
Type: CameraType;
FlashMode: FlashMode;
AutoFocus: AutoFocus;
WhiteBalance: WhiteBalance;
VideoQuality: VideoQuality;
VideoStabilization: VideoStabilization;
VideoCodec: VideoCodec;
};
// @needsAudit
export type CameraProps = ViewProps & {
/**
* Camera facing. Use one of `CameraType`. When `CameraType.front`, use the front-facing camera.
* When `CameraType.back`, use the back-facing camera.
* @default CameraType.back
*/
type?: number | CameraType;
/**
* Camera flash mode. Use one of [`FlashMode.<value>`](#flashmode-1). When `FlashMode.on`, the flash on your device will
* turn on when taking a picture, when `FlashMode.off`, it won't. Setting to `FlashMode.auto` will fire flash if required,
* `FlashMode.torch` turns on flash during the preview.
* @default FlashMode.off
*/
flashMode?: number | FlashMode;
/**
* Camera white balance. Use one of [`WhiteBalance.<value>`](#whitebalance). If a device does not support any of these values previous one is used.
* @default WhiteBalance.auto
*/
whiteBalance?: number | WhiteBalance;
/**
* State of camera auto focus. Use one of [`AutoFocus.<value>`](#autofocus-1). When `AutoFocus.on`,
* auto focus will be enabled, when `AutoFocus.off`, it won't and focus will lock as it was in the moment of change,
* but it can be adjusted on some devices via `focusDepth` prop.
* @default AutoFocus.on
*/
autoFocus?: boolean | number | AutoFocus;
/**
* A value between `0` and `1` being a percentage of device's max zoom. `0` - not zoomed, `1` - maximum zoom.
* @default 0
*/
zoom?: number;
/**
* A string representing aspect ratio of the preview, eg. `4:3`, `16:9`, `1:1`. To check if a ratio is supported
* by the device use [`getSupportedRatiosAsync`](#getsupportedratiosasync).
* @default 4:3
* @platform android
*/
ratio?: string;
/**
* Distance to plane of the sharpest focus. A value between `0` and `1` where: `0` - infinity focus, `1` - focus as close as possible.
* For Android this is available only for some devices and when `useCamera2Api` is set to `true`.
* @default 0
*/
focusDepth?: number;
/**
* Callback invoked when camera preview has been set.
*/
onCameraReady?: () => void;
/**
* Whether to use Android's Camera2 API. See `Note` at the top of this page.
* @platform android
*/
useCamera2Api?: boolean;
/**
* A string representing the size of pictures [`takePictureAsync`](#takepictureasyncoptions) will take.
* Available sizes can be fetched with [`getAvailablePictureSizesAsync`](#getavailablepicturesizesasyncratio).
*/
pictureSize?: string;
/**
* The video stabilization mode used for a video recording. Use one of [`VideoStabilization.<value>`](#videostabilization).
* You can read more about each stabilization type in [Apple Documentation](https://developer.apple.com/documentation/avfoundation/avcapturevideostabilizationmode).
* @platform ios
*/
videoStabilizationMode?: VideoStabilization;
/**
* Callback invoked when camera preview could not been started.
* @param event Error object that contains a `message`.
*/
onMountError?: (event: CameraMountError) => void;
/**
* Settings exposed by [`BarCodeScanner`](bar-code-scanner) module. Supported settings: **barCodeTypes**.
* @example
* ```tsx
* <Camera
* barCodeScannerSettings={{
* barCodeTypes: [BarCodeScanner.Constants.BarCodeType.qr],
* }}
* />
* ```
*/
barCodeScannerSettings?: BarCodeSettings;
/**
* Callback that is invoked when a bar code has been successfully scanned. The callback is provided with
* an object of the [`BarCodeScanningResult`](#barcodescanningresult) shape, where the `type`
* refers to the bar code type that was scanned and the `data` is the information encoded in the bar code
* (in this case of QR codes, this is often a URL). See [`BarCodeScanner.Constants.BarCodeType`](bar-code-scanner#supported-formats)
* for supported values.
* @param scanningResult
*/
onBarCodeScanned?: (scanningResult: BarCodeScanningResult) => void;
/**
* A settings object passed directly to an underlying module providing face detection features.
* See [`DetectionOptions`](facedetector/#detectionoptions) in FaceDetector documentation for details.
*/
faceDetectorSettings?: object;
/**
* Callback invoked with results of face detection on the preview.
* See [`DetectionResult`](facedetector/#detectionresult) in FaceDetector documentation for more details.
* @param faces
*/
onFacesDetected?: (faces: FaceDetectionResult) => void;
/**
* A URL for an image to be shown while the camera is loading.
* @platform web
*/
poster?: string;
/**
* Whether to allow responsive orientation of the camera when the screen orientation is locked (i.e. when set to `true`
* landscape photos will be taken if the device is turned that way, even if the app or device orientation is locked to portrait)
* @platform ios
*/
responsiveOrientationWhenOrientationLocked?: boolean;
/**
* Callback invoked when responsive orientation changes. Only applicable if `responsiveOrientationWhenOrientationLocked` is `true`
* @param event result object that contains updated orientation of camera
* @platform ios
*/
onResponsiveOrientationChanged?: (event: ResponsiveOrientationChanged) => void;
};
/**
* @hidden
*/
export type CameraNativeProps = {
pointerEvents?: any;
style?: any;
ref?: Function;
onCameraReady?: CameraReadyListener;
onMountError?: MountErrorListener;
onBarCodeScanned?: (event: { nativeEvent: BarCodeScanningResult }) => void;
onFacesDetected?: (event: { nativeEvent: FaceDetectionResult }) => void;
onFaceDetectionError?: (event: { nativeEvent: Error }) => void;
onPictureSaved?: PictureSavedListener;
onResponsiveOrientationChanged?: ResponsiveOrientationChangedListener;
type?: number | string;
flashMode?: number | string;
autoFocus?: string | boolean | number;
focusDepth?: number;
zoom?: number;
whiteBalance?: number | string;
pictureSize?: string;
barCodeScannerSettings?: BarCodeSettings;
faceDetectorSettings?: object;
barCodeScannerEnabled?: boolean;
faceDetectorEnabled?: boolean;
ratio?: string;
useCamera2Api?: boolean;
poster?: string;
responsiveOrientationWhenOrientationLocked?: boolean;
};
// @docsMissing
export type BarCodeSettings = {
barCodeTypes: string[];
interval?: number;
};
export { PermissionResponse, PermissionStatus, PermissionExpiration, PermissionHookOptions };

View File

@@ -0,0 +1,9 @@
import { requireNativeViewManager } from 'expo-modules-core';
import * as React from 'react';
import { CameraNativeProps } from './Camera.types';
const ExponentCamera: React.ComponentType<CameraNativeProps> =
requireNativeViewManager('ExpoCameraLegacy');
export default ExponentCamera;

View File

@@ -0,0 +1,165 @@
import { CodedError } from 'expo-modules-core';
import * as React from 'react';
import { StyleProp, StyleSheet, View, ViewStyle } from 'react-native';
import createElement from 'react-native-web/dist/exports/createElement';
import {
CameraCapturedPicture,
CameraNativeProps,
CameraPictureOptions,
CameraType,
} from './Camera.types';
import CameraManager from './ExpoCameraManager.web';
import { capture } from '../web/WebCameraUtils';
import { PictureSizes } from '../web/WebConstants';
import { useWebCameraStream } from '../web/useWebCameraStream';
import { useWebQRScanner } from '../web/useWebQRScanner';
export interface ExponentCameraRef {
getAvailablePictureSizes: (ratio: string) => Promise<string[]>;
takePicture: (options: CameraPictureOptions) => Promise<CameraCapturedPicture>;
resumePreview: () => Promise<void>;
pausePreview: () => Promise<void>;
}
const ExponentCamera = React.forwardRef(
(
{ type, pictureSize, poster, ...props }: CameraNativeProps & { children?: React.ReactNode },
ref: React.Ref<ExponentCameraRef>
) => {
const video = React.useRef<HTMLVideoElement | null>(null);
const native = useWebCameraStream(video, type as CameraType, props, {
onCameraReady() {
if (props.onCameraReady) {
props.onCameraReady();
}
},
onMountError: props.onMountError,
});
const isQRScannerEnabled = React.useMemo<boolean>(() => {
return !!(
props.barCodeScannerSettings?.barCodeTypes?.includes('qr') && !!props.onBarCodeScanned
);
}, [props.barCodeScannerSettings?.barCodeTypes, props.onBarCodeScanned]);
useWebQRScanner(video, {
interval: props.barCodeScannerSettings?.interval,
isEnabled: isQRScannerEnabled,
captureOptions: { scale: 1, isImageMirror: native.type === CameraType.front },
onScanned(event) {
if (props.onBarCodeScanned) {
props.onBarCodeScanned(event);
}
},
// onError: props.onMountError,
});
// const [pause, setPaused]
React.useImperativeHandle(
ref,
() => ({
async getAvailablePictureSizes(ratio: string): Promise<string[]> {
return PictureSizes;
},
async takePicture(options: CameraPictureOptions): Promise<CameraCapturedPicture> {
if (!video.current || video.current?.readyState !== video.current?.HAVE_ENOUGH_DATA) {
throw new CodedError(
'ERR_CAMERA_NOT_READY',
'HTMLVideoElement does not have enough camera data to construct an image yet.'
);
}
const settings = native.mediaTrackSettings;
if (!settings) {
throw new CodedError('ERR_CAMERA_NOT_READY', 'MediaStream is not ready yet.');
}
return capture(video.current, settings, {
...options,
// This will always be defined, the option gets added to a queue in the upper-level. We should replace the original so it isn't called twice.
onPictureSaved(picture) {
if (options.onPictureSaved) {
options.onPictureSaved(picture);
}
if (props.onPictureSaved) {
props.onPictureSaved({ nativeEvent: { data: picture, id: -1 } });
}
},
});
},
async resumePreview(): Promise<void> {
if (video.current) {
video.current.play();
}
},
async pausePreview(): Promise<void> {
if (video.current) {
video.current.pause();
}
},
}),
[native.mediaTrackSettings, props.onPictureSaved]
);
// TODO(Bacon): Create a universal prop, on native the microphone is only used when recording videos.
// Because we don't support recording video in the browser we don't need the user to give microphone permissions.
const isMuted = true;
const style = React.useMemo<StyleProp<ViewStyle>>(() => {
const isFrontFacingCamera = native.type === CameraManager.Type.front;
return [
StyleSheet.absoluteFill,
styles.video,
{
pointerEvents: props.pointerEvents,
// Flip the camera
transform: isFrontFacingCamera ? [{ scaleX: -1 }] : undefined,
},
];
}, [props.pointerEvents, native.type]);
return (
<View style={[styles.videoWrapper, props.style]}>
<Video
autoPlay
playsInline
muted={isMuted}
poster={poster}
// webkitPlaysinline
ref={video}
style={style}
/>
{props.children}
</View>
);
}
);
export default ExponentCamera;
const Video = React.forwardRef(
(
props: React.ComponentProps<typeof View> & {
autoPlay?: boolean;
playsInline?: boolean;
muted?: boolean;
poster?: string;
},
ref: React.Ref<HTMLVideoElement>
) => createElement('video', { ...props, ref })
);
const styles = StyleSheet.create({
videoWrapper: {
flex: 1,
alignItems: 'stretch',
pointerEvents: 'box-none',
},
video: {
width: '100%',
height: '100%',
objectFit: 'cover',
},
});

View File

@@ -0,0 +1,5 @@
import { requireNativeModule } from 'expo-modules-core';
const CameraManager: Record<string, any> = requireNativeModule('ExpoCameraLegacy');
export default CameraManager;

View File

@@ -0,0 +1,236 @@
import { UnavailabilityError } from 'expo-modules-core';
import {
CameraCapturedPicture,
CameraPictureOptions,
CameraType,
PermissionResponse,
PermissionStatus,
} from './Camera.types';
import { ExponentCameraRef } from './ExpoCamera.web';
import {
canGetUserMedia,
isBackCameraAvailableAsync,
isFrontCameraAvailableAsync,
} from '../web/WebUserMediaManager';
function getUserMedia(constraints: MediaStreamConstraints): Promise<MediaStream> {
if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
return navigator.mediaDevices.getUserMedia(constraints);
}
// Some browsers partially implement mediaDevices. We can't just assign an object
// with getUserMedia as it would overwrite existing properties.
// Here, we will just add the getUserMedia property if it's missing.
// First get ahold of the legacy getUserMedia, if present
const getUserMedia =
// TODO: this method is deprecated, migrate to https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia
navigator.getUserMedia ||
navigator.webkitGetUserMedia ||
navigator.mozGetUserMedia ||
function () {
const error: any = new Error('Permission unimplemented');
error.code = 0;
error.name = 'NotAllowedError';
throw error;
};
return new Promise((resolve, reject) => {
getUserMedia.call(navigator, constraints, resolve, reject);
});
}
function handleGetUserMediaError({ message }: { message: string }): PermissionResponse {
// name: NotAllowedError
// code: 0
if (message === 'Permission dismissed') {
return {
status: PermissionStatus.UNDETERMINED,
expires: 'never',
canAskAgain: true,
granted: false,
};
} else {
// TODO: Bacon: [OSX] The system could deny access to chrome.
// TODO: Bacon: add: { status: 'unimplemented' }
return {
status: PermissionStatus.DENIED,
expires: 'never',
canAskAgain: true,
granted: false,
};
}
}
async function handleRequestPermissionsAsync(): Promise<PermissionResponse> {
try {
await getUserMedia({
video: true,
});
return {
status: PermissionStatus.GRANTED,
expires: 'never',
canAskAgain: true,
granted: true,
};
} catch ({ message }) {
return handleGetUserMediaError({ message });
}
}
async function handlePermissionsQueryAsync(
query: 'camera' | 'microphone'
): Promise<PermissionResponse> {
if (!navigator?.permissions?.query) {
throw new UnavailabilityError('expo-camera', 'navigator.permissions API is not available');
}
try {
const { state } = await navigator.permissions.query({ name: query });
switch (state) {
case 'prompt':
return {
status: PermissionStatus.UNDETERMINED,
expires: 'never',
canAskAgain: true,
granted: false,
};
case 'granted':
return {
status: PermissionStatus.GRANTED,
expires: 'never',
canAskAgain: true,
granted: true,
};
case 'denied':
return {
status: PermissionStatus.DENIED,
expires: 'never',
canAskAgain: true,
granted: false,
};
}
} catch (e) {
// Firefox doesn't support querying for the camera permission, so return undetermined status
if (e instanceof TypeError) {
return {
status: PermissionStatus.UNDETERMINED,
expires: 'never',
canAskAgain: true,
granted: false,
};
}
throw e;
}
}
export default {
get Type() {
return {
back: 'back',
front: 'front',
};
},
get FlashMode() {
return {
on: 'on',
off: 'off',
auto: 'auto',
torch: 'torch',
};
},
get AutoFocus() {
return {
on: 'on',
off: 'off',
auto: 'auto',
singleShot: 'singleShot',
};
},
get WhiteBalance() {
return {
auto: 'auto',
continuous: 'continuous',
manual: 'manual',
};
},
get VideoQuality() {
return {};
},
get VideoStabilization() {
return {};
},
async isAvailableAsync(): Promise<boolean> {
return canGetUserMedia();
},
async takePicture(
options: CameraPictureOptions,
camera: ExponentCameraRef
): Promise<CameraCapturedPicture> {
return await camera.takePicture(options);
},
async pausePreview(camera: ExponentCameraRef): Promise<void> {
await camera.pausePreview();
},
async resumePreview(camera: ExponentCameraRef): Promise<void> {
return await camera.resumePreview();
},
async getAvailableCameraTypesAsync(): Promise<string[]> {
if (!canGetUserMedia() || !navigator.mediaDevices.enumerateDevices) return [];
const devices = await navigator.mediaDevices.enumerateDevices();
const types: (string | null)[] = await Promise.all([
(await isFrontCameraAvailableAsync(devices)) && CameraType.front,
(await isBackCameraAvailableAsync()) && CameraType.back,
]);
return types.filter(Boolean) as string[];
},
async getAvailablePictureSizes(ratio: string, camera: ExponentCameraRef): Promise<string[]> {
return await camera.getAvailablePictureSizes(ratio);
},
/* async getSupportedRatios(camera: ExponentCameraRef): Promise<string[]> {
// TODO: Support on web
},
async record(
options?: CameraRecordingOptions,
camera: ExponentCameraRef
): Promise<{ uri: string }> {
// TODO: Support on web
},
async stopRecording(camera: ExponentCameraRef): Promise<void> {
// TODO: Support on web
}, */
async getPermissionsAsync(): Promise<PermissionResponse> {
return handlePermissionsQueryAsync('camera');
},
async requestPermissionsAsync(): Promise<PermissionResponse> {
return handleRequestPermissionsAsync();
},
async getCameraPermissionsAsync(): Promise<PermissionResponse> {
return handlePermissionsQueryAsync('camera');
},
async requestCameraPermissionsAsync(): Promise<PermissionResponse> {
return handleRequestPermissionsAsync();
},
async getMicrophonePermissionsAsync(): Promise<PermissionResponse> {
return handlePermissionsQueryAsync('microphone');
},
async requestMicrophonePermissionsAsync(): Promise<PermissionResponse> {
try {
await getUserMedia({
audio: true,
});
return {
status: PermissionStatus.GRANTED,
expires: 'never',
canAskAgain: true,
granted: true,
};
} catch ({ message }) {
return handleGetUserMediaError({ message });
}
},
};

View File

@@ -0,0 +1,12 @@
export { default as Camera } from './Camera';
export {
Constants,
getPermissionsAsync,
requestPermissionsAsync,
getCameraPermissionsAsync,
requestCameraPermissionsAsync,
getMicrophonePermissionsAsync,
requestMicrophonePermissionsAsync,
} from './Camera';
export * from './Camera.types';

View File

@@ -0,0 +1,65 @@
import { Platform } from 'expo-modules-core';
import {
CameraNativeProps,
CameraType,
FlashMode,
AutoFocus,
WhiteBalance,
CameraProps,
} from '../Camera.types';
import CameraManager from '../ExpoCameraManager';
// Values under keys from this object will be transformed to native options
export const ConversionTables: {
type: Record<keyof typeof CameraType, CameraNativeProps['type']>;
flashMode: Record<keyof typeof FlashMode, CameraNativeProps['flashMode']>;
autoFocus: Record<keyof typeof AutoFocus, CameraNativeProps['autoFocus']>;
whiteBalance: Record<keyof typeof WhiteBalance, CameraNativeProps['whiteBalance']>;
} = {
type: CameraManager.Type,
flashMode: CameraManager.FlashMode,
autoFocus: CameraManager.AutoFocus,
whiteBalance: CameraManager.WhiteBalance,
};
export function convertNativeProps(props?: CameraProps): CameraNativeProps {
if (!props || typeof props !== 'object') {
return {};
}
const nativeProps: CameraNativeProps = {};
for (const [key, value] of Object.entries(props)) {
if (typeof value === 'string' && ConversionTables[key]) {
nativeProps[key] = ConversionTables[key][value];
} else {
nativeProps[key] = value;
}
}
return nativeProps;
}
export function ensureNativeProps(props?: CameraProps): CameraNativeProps {
const newProps = convertNativeProps(props);
if (newProps.onBarCodeScanned) {
newProps.barCodeScannerEnabled = true;
}
if (newProps.onFacesDetected) {
newProps.faceDetectorEnabled = true;
}
if (Platform.OS !== 'android') {
delete newProps.ratio;
delete newProps.useCamera2Api;
}
if (Platform.OS !== 'web') {
delete newProps.poster;
}
return newProps;
}

View File

@@ -0,0 +1,139 @@
// Type definitions for non-npm package W3C Image Capture 1.0
// Project: https://www.w3.org/TR/image-capture/
// Definitions by: Cosium <https://github.com/cosium>
// Definitions: https://github.com/DefinitelyTyped/DefinitelyTyped
// TypeScript Version: 3.0
declare module 'image-capture';
declare class ImageCapture {
constructor(videoTrack: MediaStreamTrack);
takePhoto(photoSettings?: PhotoSettings): Promise<Blob>;
getPhotoCapabilities(): Promise<PhotoCapabilities>;
getPhotoSettings(): Promise<PhotoSettings>;
grabFrame(): Promise<ImageBitmap>;
readonly track: MediaStreamTrack;
}
interface PhotoCapabilities {
readonly redEyeReduction: RedEyeReduction;
readonly imageHeight: MediaSettingsRange;
readonly imageWidth: MediaSettingsRange;
readonly fillLightMode: FillLightMode[];
}
interface PhotoSettings {
fillLightMode?: FillLightMode;
imageHeight?: number;
imageWidth?: number;
redEyeReduction?: boolean;
}
interface MediaSettingsRange {
readonly max: number;
readonly min: number;
readonly step: number;
}
type RedEyeReduction = 'never' | 'always' | 'controllable';
type FillLightMode = 'auto' | 'off' | 'flash';
interface MediaTrackCapabilities {
whiteBalanceMode: MeteringMode[];
exposureMode: MeteringMode[];
focusMode: MeteringMode[];
exposureCompensation: MediaSettingsRange;
colorTemperature: MediaSettingsRange;
iso: MediaSettingsRange;
brightness: MediaSettingsRange;
contrast: MediaSettingsRange;
saturation: MediaSettingsRange;
sharpness: MediaSettingsRange;
focusDistance: MediaSettingsRange;
zoom: MediaSettingsRange;
torch: boolean;
}
declare namespace W3C {
type ConstrainPoint2D = Point2D[] | ConstrainPoint2DParameters;
}
interface MediaTrackConstraintSet {
whiteBalanceMode?: W3C.ConstrainString;
exposureMode?: W3C.ConstrainString;
focusMode?: W3C.ConstrainString;
pointsOfInterest?: W3C.ConstrainPoint2D;
exposureCompensation?: W3C.ConstrainDouble;
colorTemperature?: W3C.ConstrainDouble;
iso?: W3C.ConstrainDouble;
brightness?: W3C.ConstrainDouble;
contrast?: W3C.ConstrainDouble;
saturation?: W3C.ConstrainDouble;
sharpness?: W3C.ConstrainDouble;
focusDistance?: W3C.ConstrainDouble;
zoom?: W3C.ConstrainDouble;
torch?: W3C.ConstrainBoolean;
}
interface MediaTrackSettings {
whiteBalanceMode: MeteringMode;
exposureMode: MeteringMode;
focusMode: MeteringMode;
pointsOfInterest: Point2D[];
exposureCompensation: number;
colorTemperature: number;
iso: number;
brightness: number;
contrast: number;
saturation: number;
sharpness: number;
focusDistance: number;
zoom: number;
torch: boolean;
}
interface MediaTrackSupportedConstraints {
whiteBalanceMode: boolean;
exposureMode: boolean;
focusMode: boolean;
pointsOfInterest: boolean;
exposureCompensation: boolean;
colorTemperature: boolean;
iso: boolean;
brightness: boolean;
contrast: boolean;
saturation: boolean;
sharpness: boolean;
focusDistance: boolean;
zoom: boolean;
torch: boolean;
}
interface ConstrainPoint2DParameters {
exact: Point2D[];
ideal: Point2D[];
}
type MeteringMode = 'none' | 'manual' | 'single-shot' | 'continuous';
interface Point2D {
x: number;
y: number;
}

View File

@@ -0,0 +1,34 @@
// Expose this file as a module (see https://stackoverflow.com/a/59499895/4337317)
export {};
/**
* Handle deprecations and missing typings that not available in the main lib.dom.d.ts file.
*/
declare global {
type GetUserMediaFunctionType = (
constraints: MediaStreamConstraints,
successCallback: () => MediaStream,
failureCallback: () => DOMException
) => undefined;
interface Navigator {
/**
* This method has been deprecated: https://developer.mozilla.org/en-US/docs/Web/API/Navigator/getUserMedia
* TODO: migrate to https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia
*/
getUserMedia?: GetUserMediaFunctionType;
webkitGetUserMedia?: GetUserMediaFunctionType;
mozGetUserMedia?: GetUserMediaFunctionType;
}
type PermissionNameWithAdditionalValues = PermissionName | 'camera' | 'microphone';
// TODO: remove once "microphone" name is added to the PermissionName union type exposed by the main lib.dom.d.ts file.
interface Permissions {
// Replace original PermissionDescriptor with our own that includes missing permission names (e.g. "microphone")
query(permissionDesc: PermissionDescriptorWithAdditionalValues): Promise<PermissionStatus>;
}
interface PermissionDescriptorWithAdditionalValues {
name: PermissionNameWithAdditionalValues;
}
}

View File

@@ -0,0 +1,46 @@
import { Platform } from 'expo-modules-core';
import { CameraNativeProps, CameraType, FlashMode, CameraProps } from '../Camera.types';
import CameraManager from '../ExpoCameraManager';
// Values under keys from this object will be transformed to native options
export const ConversionTables: {
type: Record<keyof CameraType, CameraNativeProps['facing']>;
flash: Record<keyof FlashMode, CameraNativeProps['flashMode']>;
} = {
type: CameraManager.Type,
flash: CameraManager.FlashMode,
};
export function convertNativeProps(props?: CameraProps): CameraNativeProps {
if (!props || typeof props !== 'object') {
return {};
}
const nativeProps: CameraNativeProps = {};
for (const [key, value] of Object.entries(props)) {
if (typeof value === 'string' && ConversionTables[key]) {
nativeProps[key] = ConversionTables[key][value];
} else {
nativeProps[key] = value;
}
}
return nativeProps;
}
export function ensureNativeProps(props?: CameraProps): CameraNativeProps {
const newProps = convertNativeProps(props);
newProps.barcodeScannerEnabled = !!props?.onBarcodeScanned;
newProps.flashMode = props?.flash ?? 'off';
newProps.mute = props?.mute ?? false;
newProps.autoFocus = props?.autofocus ?? 'off';
if (Platform.OS !== 'web') {
delete newProps.poster;
}
return newProps;
}

View File

@@ -0,0 +1,457 @@
/* eslint-env browser */
import invariant from 'invariant';
import * as CapabilityUtils from './WebCapabilityUtils';
import { CameraTypeToFacingMode, ImageTypeFormat, MinimumConstraints } from './WebConstants';
import { requestUserMediaAsync } from './WebUserMediaManager';
import {
CameraType,
CameraCapturedPicture,
ImageSize,
ImageType,
WebCameraSettings,
CameraPictureOptions,
} from '../legacy/Camera.types';
interface ConstrainLongRange {
max?: number;
min?: number;
exact?: number;
ideal?: number;
}
export function getImageSize(videoWidth: number, videoHeight: number, scale: number): ImageSize {
const width = videoWidth * scale;
const ratio = videoWidth / width;
const height = videoHeight / ratio;
return {
width,
height,
};
}
export function toDataURL(
canvas: HTMLCanvasElement,
imageType: ImageType,
quality: number
): string {
invariant(
Object.values(ImageType).includes(imageType),
`expo-camera: ${imageType} is not a valid ImageType. Expected a string from: ${Object.values(
ImageType
).join(', ')}`
);
const format = ImageTypeFormat[imageType];
if (imageType === ImageType.jpg) {
invariant(
quality <= 1 && quality >= 0,
`expo-camera: ${quality} is not a valid image quality. Expected a number from 0...1`
);
return canvas.toDataURL(format, quality);
} else {
return canvas.toDataURL(format);
}
}
export function hasValidConstraints(
preferredCameraType?: CameraType,
width?: number | ConstrainLongRange,
height?: number | ConstrainLongRange
): boolean {
return preferredCameraType !== undefined && width !== undefined && height !== undefined;
}
function ensureCameraPictureOptions(config: CameraPictureOptions): CameraPictureOptions {
const captureOptions = {
scale: 1,
imageType: ImageType.png,
isImageMirror: false,
};
for (const key in config) {
if (key in config && config[key] !== undefined && key in captureOptions) {
captureOptions[key] = config[key];
}
}
return captureOptions;
}
const DEFAULT_QUALITY = 0.92;
export function captureImageData(
video: HTMLVideoElement | null,
pictureOptions: Pick<CameraPictureOptions, 'scale' | 'isImageMirror'> = {}
): ImageData | null {
if (!video || video.readyState !== video.HAVE_ENOUGH_DATA) {
return null;
}
const canvas = captureImageContext(video, pictureOptions);
const context = canvas.getContext('2d', { alpha: false });
if (!context || !canvas.width || !canvas.height) {
return null;
}
const imageData = context.getImageData(0, 0, canvas.width, canvas.height);
return imageData;
}
export function captureImageContext(
video: HTMLVideoElement,
{ scale = 1, isImageMirror = false }: Pick<CameraPictureOptions, 'scale' | 'isImageMirror'>
): HTMLCanvasElement {
const { videoWidth, videoHeight } = video;
const { width, height } = getImageSize(videoWidth, videoHeight, scale!);
// Build the canvas size and draw the camera image to the context from video
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const context = canvas.getContext('2d', { alpha: false });
if (!context) {
// Should never be called
throw new Error('Context is not defined');
}
// sharp image details
// context.imageSmoothingEnabled = false;
// Flip horizontally (as css transform: rotateY(180deg))
if (isImageMirror) {
context.setTransform(-1, 0, 0, 1, canvas.width, 0);
}
context.drawImage(video, 0, 0, width, height);
return canvas;
}
export function captureImage(
video: HTMLVideoElement,
pictureOptions: CameraPictureOptions
): string {
const config = ensureCameraPictureOptions(pictureOptions);
const canvas = captureImageContext(video, config);
const { imageType, quality = DEFAULT_QUALITY } = config;
return toDataURL(canvas, imageType!, quality);
}
function getSupportedConstraints(): MediaTrackSupportedConstraints | null {
if (navigator.mediaDevices && navigator.mediaDevices.getSupportedConstraints) {
return navigator.mediaDevices.getSupportedConstraints();
}
return null;
}
export function getIdealConstraints(
preferredCameraType: CameraType,
width?: number | ConstrainLongRange,
height?: number | ConstrainLongRange
): MediaStreamConstraints {
const preferredConstraints: MediaStreamConstraints = {
audio: false,
video: {},
};
if (hasValidConstraints(preferredCameraType, width, height)) {
return MinimumConstraints;
}
const supports = getSupportedConstraints();
// TODO(Bacon): Test this
if (!supports || !supports.facingMode || !supports.width || !supports.height) {
return MinimumConstraints;
}
if (preferredCameraType && Object.values(CameraType).includes(preferredCameraType)) {
const facingMode = CameraTypeToFacingMode[preferredCameraType];
if (isWebKit()) {
const key = facingMode === 'user' ? 'exact' : 'ideal';
(preferredConstraints.video as MediaTrackConstraints).facingMode = {
[key]: facingMode,
};
} else {
(preferredConstraints.video as MediaTrackConstraints).facingMode = {
ideal: CameraTypeToFacingMode[preferredCameraType],
};
}
}
if (isMediaTrackConstraints(preferredConstraints.video)) {
preferredConstraints.video.width = width;
preferredConstraints.video.height = height;
}
return preferredConstraints;
}
function isMediaTrackConstraints(input: any): input is MediaTrackConstraints {
return input && typeof input.video !== 'boolean';
}
/**
* Invoke getStreamDevice a second time with the opposing camera type if the preferred type cannot be retrieved.
*
* @param preferredCameraType
* @param preferredWidth
* @param preferredHeight
*/
export async function getPreferredStreamDevice(
preferredCameraType: CameraType,
preferredWidth?: number | ConstrainLongRange,
preferredHeight?: number | ConstrainLongRange
): Promise<MediaStream> {
try {
return await getStreamDevice(preferredCameraType, preferredWidth, preferredHeight);
} catch (error) {
// A hack on desktop browsers to ensure any camera is used.
// eslint-disable-next-line no-undef
if (error instanceof OverconstrainedError && error.constraint === 'facingMode') {
const nextCameraType =
preferredCameraType === CameraType.back ? CameraType.front : CameraType.back;
return await getStreamDevice(nextCameraType, preferredWidth, preferredHeight);
}
throw error;
}
}
export async function getStreamDevice(
preferredCameraType: CameraType,
preferredWidth?: number | ConstrainLongRange,
preferredHeight?: number | ConstrainLongRange
): Promise<MediaStream> {
const constraints: MediaStreamConstraints = getIdealConstraints(
preferredCameraType,
preferredWidth,
preferredHeight
);
const stream: MediaStream = await requestUserMediaAsync(constraints);
return stream;
}
export function isWebKit(): boolean {
return /WebKit/.test(navigator.userAgent) && !/Edg/.test(navigator.userAgent);
}
export function compareStreams(a: MediaStream | null, b: MediaStream | null): boolean {
if (!a || !b) {
return false;
}
const settingsA = a.getTracks()[0].getSettings();
const settingsB = b.getTracks()[0].getSettings();
return settingsA.deviceId === settingsB.deviceId;
}
export function capture(
video: HTMLVideoElement,
settings: MediaTrackSettings,
config: CameraPictureOptions
): CameraCapturedPicture {
const base64 = captureImage(video, config);
const capturedPicture: CameraCapturedPicture = {
uri: base64,
base64,
width: 0,
height: 0,
};
if (settings) {
const { width = 0, height = 0 } = settings;
capturedPicture.width = width;
capturedPicture.height = height;
capturedPicture.exif = settings;
}
if (config.onPictureSaved) {
config.onPictureSaved(capturedPicture);
}
return capturedPicture;
}
export async function syncTrackCapabilities(
cameraType: CameraType,
stream: MediaStream | null,
settings: WebCameraSettings = {}
): Promise<void> {
if (stream?.getVideoTracks) {
await Promise.all(
stream.getVideoTracks().map((track) => onCapabilitiesReady(cameraType, track, settings))
);
}
}
// https://developer.mozilla.org/en-US/docs/Web/API/MediaTrackConstraints
async function onCapabilitiesReady(
cameraType: CameraType,
track: MediaStreamTrack,
settings: WebCameraSettings = {}
): Promise<void> {
if (typeof track.getCapabilities !== 'function') {
return;
}
const capabilities = track.getCapabilities();
// Create an empty object because if you set a constraint that isn't available an error will be thrown.
const constraints: MediaTrackConstraintSet = {};
// TODO(Bacon): Add `pointsOfInterest` support
const clampedValues = [
'exposureCompensation',
'colorTemperature',
'iso',
'brightness',
'contrast',
'saturation',
'sharpness',
'focusDistance',
'zoom',
];
for (const property of clampedValues) {
if (capabilities[property]) {
constraints[property] = convertNormalizedSetting(capabilities[property], settings[property]);
}
}
function validatedInternalConstrainedValue<IConvertedType>(
constraintKey: string,
settingsKey: string,
converter: (settingValue: any) => IConvertedType
) {
const convertedSetting = converter(settings[settingsKey]);
return validatedConstrainedValue({
constraintKey,
settingsKey,
convertedSetting,
capabilities,
settings,
cameraType,
});
}
if (capabilities.focusMode && settings.autoFocus !== undefined) {
constraints.focusMode = validatedInternalConstrainedValue<MediaTrackConstraintSet['focusMode']>(
'focusMode',
'autoFocus',
CapabilityUtils.convertAutoFocusJSONToNative
);
}
if (capabilities.torch && settings.flashMode !== undefined) {
constraints.torch = validatedInternalConstrainedValue<MediaTrackConstraintSet['torch']>(
'torch',
'flashMode',
CapabilityUtils.convertFlashModeJSONToNative
);
}
if (capabilities.whiteBalanceMode && settings.whiteBalance !== undefined) {
constraints.whiteBalanceMode = validatedInternalConstrainedValue<
MediaTrackConstraintSet['whiteBalanceMode']
>('whiteBalanceMode', 'whiteBalance', CapabilityUtils.convertWhiteBalanceJSONToNative);
}
try {
await track.applyConstraints({ advanced: [constraints] });
} catch (error) {
if (__DEV__) console.warn('Failed to apply constraints', error);
}
}
export function stopMediaStream(stream: MediaStream | null) {
if (!stream) {
return;
}
if (stream.getAudioTracks) {
stream.getAudioTracks().forEach((track) => track.stop());
}
if (stream.getVideoTracks) {
stream.getVideoTracks().forEach((track) => track.stop());
}
if (isMediaStreamTrack(stream)) {
stream.stop();
}
}
export function setVideoSource(
video: HTMLVideoElement,
stream: MediaStream | MediaSource | Blob | null
): void {
const createObjectURL = window.URL.createObjectURL ?? window.webkitURL.createObjectURL;
if (typeof video.srcObject !== 'undefined') {
video.srcObject = stream;
} else if (typeof (video as any).mozSrcObject !== 'undefined') {
(video as any).mozSrcObject = stream;
} else if (stream && createObjectURL) {
video.src = createObjectURL(stream as MediaSource | Blob);
}
if (!stream) {
const revokeObjectURL = window.URL.revokeObjectURL ?? window.webkitURL.revokeObjectURL;
const source = video.src ?? video.srcObject ?? (video as any).mozSrcObject;
if (revokeObjectURL && typeof source === 'string') {
revokeObjectURL(source);
}
}
}
export function isCapabilityAvailable(video: HTMLVideoElement, keyName: string): boolean {
const stream = video.srcObject;
if (stream instanceof MediaStream) {
const videoTrack = stream.getVideoTracks()[0];
return videoTrack.getCapabilities?.()?.[keyName];
}
return false;
}
function isMediaStreamTrack(input: any): input is MediaStreamTrack {
return typeof input.stop === 'function';
}
function convertNormalizedSetting(range: MediaSettingsRange, value?: number): number | undefined {
if (!value) {
return;
}
// convert the normalized incoming setting to the native camera zoom range
const converted = convertRange(value, [range.min, range.max]);
// clamp value so we don't get an error
return Math.min(range.max, Math.max(range.min, converted));
}
function convertRange(value: number, r2: number[], r1: number[] = [0, 1]): number {
return ((value - r1[0]) * (r2[1] - r2[0])) / (r1[1] - r1[0]) + r2[0];
}
function validatedConstrainedValue<T>(props: {
constraintKey: string;
settingsKey: string;
convertedSetting: T;
capabilities: MediaTrackCapabilities;
settings: WebCameraSettings;
cameraType: string;
}): T | undefined {
const { constraintKey, settingsKey, convertedSetting, capabilities, settings, cameraType } =
props;
const setting = settings[settingsKey];
if (
Array.isArray(capabilities[constraintKey]) &&
convertedSetting &&
!capabilities[constraintKey].includes(convertedSetting)
) {
if (__DEV__) {
// Only warn in dev mode.
console.warn(
` { ${settingsKey}: "${setting}" } (converted to "${convertedSetting}" in the browser) is not supported for camera type "${cameraType}" in your browser. Using the default value instead.`
);
}
return undefined;
}
return convertedSetting;
}

View File

@@ -0,0 +1,42 @@
/*
* Native web camera (Android) has a torch: boolean
*/
export function convertFlashModeJSONToNative(input: string): boolean {
switch (input) {
case 'torch':
return true;
case 'on':
case 'off':
case 'auto':
default:
return false;
}
}
export function convertWhiteBalanceJSONToNative(input: string): MeteringMode | undefined {
switch (input) {
case 'on':
case 'auto':
return 'continuous';
case 'off':
return 'none';
case 'singleShot':
return 'single-shot';
default:
return undefined;
}
}
export function convertAutoFocusJSONToNative(input: string): MeteringMode | undefined {
switch (input) {
case 'on':
case 'auto':
return 'continuous';
case 'off':
return 'manual';
case 'singleShot':
return 'single-shot';
default:
return undefined;
}
}

View File

@@ -0,0 +1,32 @@
import { CameraType, ImageType } from '../legacy/Camera.types';
// https://developer.mozilla.org/en-US/docs/Web/API/MediaTrackConstraints/aspectRatio
export const VIDEO_ASPECT_RATIOS = {
'3840x2160': 3840 / 2160,
'1920x1080': 1920 / 1080,
'1280x720': 1280 / 720,
'640x480': 640 / 480,
'352x288': 352 / 288,
};
export const PictureSizes = Object.keys(VIDEO_ASPECT_RATIOS);
export const ImageTypeFormat = {
[ImageType.jpg]: 'image/jpeg',
[ImageType.png]: 'image/png',
};
export const MinimumConstraints: MediaStreamConstraints = {
audio: false,
video: true,
};
export const CameraTypeToFacingMode = {
[CameraType.front]: 'user',
[CameraType.back]: 'environment',
};
export const FacingModeToCameraType = {
user: CameraType.front,
environment: CameraType.back,
};

View File

@@ -0,0 +1,172 @@
/* eslint-env browser */
/**
* A web-only module for ponyfilling the UserMedia API.
*/
import { Platform } from 'expo-modules-core';
export const userMediaRequested: boolean = false;
export const mountedInstances: any[] = [];
async function requestLegacyUserMediaAsync(props): Promise<any[]> {
const optionalSource = (id) => ({ optional: [{ sourceId: id }] });
const constraintToSourceId = (constraint) => {
const { deviceId } = constraint;
if (typeof deviceId === 'string') {
return deviceId;
}
if (Array.isArray(deviceId) && deviceId.length > 0) {
return deviceId[0];
}
if (typeof deviceId === 'object' && deviceId.ideal) {
return deviceId.ideal;
}
return null;
};
const sources: any[] = await new Promise((resolve) =>
// @ts-ignore: https://caniuse.com/#search=getSources Chrome for Android (78) & Samsung Internet (10.1) use this
MediaStreamTrack.getSources((sources) => resolve(sources))
);
let audioSource = null;
let videoSource = null;
sources.forEach((source) => {
if (source.kind === 'audio') {
audioSource = source.id;
} else if (source.kind === 'video') {
videoSource = source.id;
}
});
const audioSourceId = constraintToSourceId(props.audioConstraints);
if (audioSourceId) {
audioSource = audioSourceId;
}
const videoSourceId = constraintToSourceId(props.videoConstraints);
if (videoSourceId) {
videoSource = videoSourceId;
}
return [optionalSource(audioSource), optionalSource(videoSource)];
}
async function sourceSelectedAsync(
isMuted: boolean,
audioConstraints?: MediaTrackConstraints | boolean,
videoConstraints?: MediaTrackConstraints | boolean
): Promise<MediaStream> {
const constraints: MediaStreamConstraints = {
video: typeof videoConstraints !== 'undefined' ? videoConstraints : true,
};
if (!isMuted) {
constraints.audio = typeof audioConstraints !== 'undefined' ? audioConstraints : true;
}
return await getAnyUserMediaAsync(constraints);
}
export async function requestUserMediaAsync(
props: { audio?: any; video?: any },
isMuted: boolean = true
): Promise<MediaStream> {
if (canGetUserMedia()) {
return await sourceSelectedAsync(isMuted, props.audio, props.video);
}
const [audio, video] = await requestLegacyUserMediaAsync(props);
return await sourceSelectedAsync(isMuted, audio, video);
}
export async function getAnyUserMediaAsync(
constraints: MediaStreamConstraints,
ignoreConstraints: boolean = false
): Promise<MediaStream> {
try {
return await getUserMediaAsync({
...constraints,
video: ignoreConstraints || constraints.video,
});
} catch (error) {
if (!ignoreConstraints && error.name === 'ConstraintNotSatisfiedError') {
return await getAnyUserMediaAsync(constraints, true);
}
throw error;
}
}
export async function getUserMediaAsync(constraints: MediaStreamConstraints): Promise<MediaStream> {
if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
return navigator.mediaDevices.getUserMedia(constraints);
}
const _getUserMedia =
navigator['mozGetUserMedia'] || navigator['webkitGetUserMedia'] || navigator['msGetUserMedia'];
return new Promise((resolve, reject) =>
_getUserMedia.call(navigator, constraints, resolve, reject)
);
}
export function canGetUserMedia(): boolean {
return (
// SSR
Platform.isDOMAvailable &&
// Has any form of media API
!!(
(navigator.mediaDevices && navigator.mediaDevices.getUserMedia) ||
navigator['mozGetUserMedia'] ||
navigator['webkitGetUserMedia'] ||
navigator['msGetUserMedia']
)
);
}
export async function isFrontCameraAvailableAsync(
devices?: MediaDeviceInfo[]
): Promise<null | string> {
return await supportsCameraType(['front', 'user', 'facetime'], 'user', devices);
}
export async function isBackCameraAvailableAsync(
devices?: MediaDeviceInfo[]
): Promise<null | string> {
return await supportsCameraType(['back', 'rear'], 'environment', devices);
}
async function supportsCameraType(
labels: string[],
type: string,
devices?: MediaDeviceInfo[]
): Promise<null | string> {
if (!devices) {
if (!navigator.mediaDevices.enumerateDevices) {
return null;
}
devices = await navigator.mediaDevices.enumerateDevices();
}
const cameras = devices.filter((t) => t.kind === 'videoinput');
const [hasCamera] = cameras.filter((camera) =>
labels.some((label) => camera.label.toLowerCase().includes(label))
);
const [isCapable] = cameras.filter((camera) => {
if (!('getCapabilities' in camera)) {
return null;
}
const capabilities = (camera as any).getCapabilities();
if (!capabilities.facingMode) {
return null;
}
return capabilities.facingMode.find((_: string) => type);
});
return isCapable?.deviceId || hasCamera?.deviceId || null;
}

View File

@@ -0,0 +1,202 @@
/* eslint-env browser */
import * as React from 'react';
import * as Utils from './WebCameraUtils';
import { FacingModeToCameraType } from './WebConstants';
import {
CameraReadyListener,
CameraType,
MountErrorListener,
WebCameraSettings,
} from '../legacy/Camera.types';
const VALID_SETTINGS_KEYS = [
'autoFocus',
'flashMode',
'exposureCompensation',
'colorTemperature',
'iso',
'brightness',
'contrast',
'saturation',
'sharpness',
'focusDistance',
'whiteBalance',
'zoom',
];
function useLoadedVideo(video: HTMLVideoElement | null, onLoaded: () => void) {
React.useEffect(() => {
if (video) {
video.addEventListener('loadedmetadata', () => {
// without this async block the constraints aren't properly applied to the camera,
// this means that if you were to turn on the torch and swap to the front camera,
// then swap back to the rear camera the torch setting wouldn't be applied.
requestAnimationFrame(() => {
onLoaded();
});
});
}
}, [video]);
}
export function useWebCameraStream(
video: React.MutableRefObject<HTMLVideoElement | null>,
preferredType: CameraType,
settings: Record<string, any>,
{
onCameraReady,
onMountError,
}: { onCameraReady?: CameraReadyListener; onMountError?: MountErrorListener }
): {
type: CameraType | null;
mediaTrackSettings: MediaTrackSettings | null;
} {
const isStartingCamera = React.useRef<boolean | null>(false);
const activeStreams = React.useRef<MediaStream[]>([]);
const capabilities = React.useRef<WebCameraSettings>({
autoFocus: 'continuous',
flashMode: 'off',
whiteBalance: 'continuous',
zoom: 1,
});
const [stream, setStream] = React.useState<MediaStream | null>(null);
const mediaTrackSettings = React.useMemo(() => {
return stream ? stream.getTracks()[0].getSettings() : null;
}, [stream]);
// The actual camera type - this can be different from the incoming camera type.
const type = React.useMemo(() => {
if (!mediaTrackSettings) {
return null;
}
// On desktop no value will be returned, in this case we should assume the cameraType is 'front'
const { facingMode = 'user' } = mediaTrackSettings;
return FacingModeToCameraType[facingMode];
}, [mediaTrackSettings]);
const getStreamDeviceAsync = React.useCallback(async (): Promise<MediaStream | null> => {
try {
return await Utils.getPreferredStreamDevice(preferredType);
} catch (nativeEvent) {
if (__DEV__) {
console.warn(`Error requesting UserMedia for type "${preferredType}":`, nativeEvent);
}
if (onMountError) {
onMountError({ nativeEvent });
}
return null;
}
}, [preferredType, onMountError]);
const resumeAsync = React.useCallback(async (): Promise<boolean> => {
const nextStream = await getStreamDeviceAsync();
if (Utils.compareStreams(nextStream, stream)) {
// Do nothing if the streams are the same.
// This happens when the device only supports one camera (i.e. desktop) and the mode was toggled between front/back while already active.
// Without this check there is a screen flash while the video switches.
return false;
}
// Save a history of all active streams (usually 2+) so we can close them later.
// Keeping them open makes swapping camera types much faster.
if (!activeStreams.current.some((value) => value.id === nextStream?.id)) {
activeStreams.current.push(nextStream!);
}
// Set the new stream -> update the video, settings, and actual camera type.
setStream(nextStream);
if (onCameraReady) {
onCameraReady();
}
return false;
}, [getStreamDeviceAsync, setStream, onCameraReady, stream, activeStreams.current]);
React.useEffect(() => {
// Restart the camera and guard concurrent actions.
if (isStartingCamera.current) {
return;
}
isStartingCamera.current = true;
resumeAsync()
.then((isStarting) => {
isStartingCamera.current = isStarting;
})
.catch(() => {
// ensure the camera can be started again.
isStartingCamera.current = false;
});
}, [preferredType]);
// Update the native camera with any custom capabilities.
React.useEffect(() => {
const changes: WebCameraSettings = {};
for (const key of Object.keys(settings)) {
if (!VALID_SETTINGS_KEYS.includes(key)) {
continue;
}
const nextValue = settings[key];
if (nextValue !== capabilities.current[key]) {
changes[key] = nextValue;
}
}
// Only update the native camera if changes were found
const hasChanges = !!Object.keys(changes).length;
const nextWebCameraSettings = { ...capabilities.current, ...changes };
if (hasChanges) {
Utils.syncTrackCapabilities(preferredType, stream, changes);
}
capabilities.current = nextWebCameraSettings;
}, [
settings.autoFocus,
settings.flashMode,
settings.exposureCompensation,
settings.colorTemperature,
settings.iso,
settings.brightness,
settings.contrast,
settings.saturation,
settings.sharpness,
settings.focusDistance,
settings.whiteBalance,
settings.zoom,
]);
React.useEffect(() => {
// set or unset the video source.
if (!video.current) {
return;
}
Utils.setVideoSource(video.current, stream);
}, [video.current, stream]);
React.useEffect(() => {
return () => {
// Clean up on dismount, this is important for making sure the camera light goes off when the component is removed.
for (const stream of activeStreams.current) {
// Close all open streams.
Utils.stopMediaStream(stream);
}
if (video.current) {
// Invalidate the video source.
Utils.setVideoSource(video.current, stream);
}
};
}, []);
// Update props when the video loads.
useLoadedVideo(video.current, () => {
Utils.syncTrackCapabilities(preferredType, stream, capabilities.current);
});
return {
type,
mediaTrackSettings,
};
}

View File

@@ -0,0 +1,156 @@
import * as React from 'react';
import { captureImageData } from './WebCameraUtils';
import {
BarCodeScanningResult,
CameraPictureOptions,
MountErrorListener,
} from '../legacy/Camera.types';
const qrWorkerMethod = ({ data, width, height }: ImageData): any => {
// eslint-disable-next-line no-undef
const decoded = (self as any).jsQR(data, width, height, {
inversionAttempts: 'attemptBoth',
});
let parsed;
try {
parsed = JSON.parse(decoded);
} catch {
parsed = decoded;
}
if (parsed?.data) {
const nativeEvent: BarCodeScanningResult = {
type: 'qr',
data: parsed.data,
cornerPoints: [],
bounds: { origin: { x: 0, y: 0 }, size: { width: 0, height: 0 } },
};
if (parsed.location) {
nativeEvent.cornerPoints = [
parsed.location.topLeftCorner,
parsed.location.bottomLeftCorner,
parsed.location.topRightCorner,
parsed.location.bottomRightCorner,
];
}
return nativeEvent;
}
return parsed;
};
const createWorkerAsyncFunction = <T extends (data: any) => any>(fn: T, deps: string[]) => {
if (typeof window === 'undefined') {
return async () => {
throw new Error('Cannot use createWorkerAsyncFunction in a non-browser environment');
};
}
const stringifiedFn = [
`self.func = ${fn.toString()};`,
'self.onmessage = (e) => {',
' const result = self.func(e.data);',
' self.postMessage(result);',
'};',
];
if (deps.length > 0) {
stringifiedFn.unshift(`importScripts(${deps.map((dep) => `'${dep}'`).join(', ')});`);
}
const blob = new Blob(stringifiedFn, { type: 'text/javascript' });
const worker = new Worker(URL.createObjectURL(blob));
// First-In First-Out queue of promises
const promises: {
resolve: (value: ReturnType<T>) => void;
reject: (reason?: any) => void;
}[] = [];
worker.onmessage = (e) => promises.shift()?.resolve(e.data);
return (data: Parameters<T>[0]) => {
return new Promise<ReturnType<T>>((resolve, reject) => {
promises.push({ resolve, reject });
worker.postMessage(data);
});
};
};
const decode = createWorkerAsyncFunction(qrWorkerMethod, [
'https://cdn.jsdelivr.net/npm/jsqr@1.2.0/dist/jsQR.min.js',
]);
export function useWebQRScanner(
video: React.MutableRefObject<HTMLVideoElement | null>,
{
isEnabled,
captureOptions,
interval,
onScanned,
onError,
}: {
isEnabled: boolean;
captureOptions: Pick<CameraPictureOptions, 'scale' | 'isImageMirror'>;
interval?: number;
onScanned?: (scanningResult: { nativeEvent: BarCodeScanningResult }) => void;
onError?: MountErrorListener;
}
) {
const isRunning = React.useRef<boolean>(false);
const timeout = React.useRef<number | undefined>(undefined);
async function scanAsync() {
// If interval is 0 then only scan once.
if (!isRunning.current || !onScanned) {
stop();
return;
}
try {
const data = captureImageData(video.current, captureOptions);
if (data) {
const nativeEvent: BarCodeScanningResult | any = await decode(data);
if (nativeEvent?.data) {
onScanned({
nativeEvent,
});
}
}
} catch (error) {
if (onError) {
onError({ nativeEvent: error });
}
} finally {
// If interval is 0 then only scan once.
if (interval === 0) {
stop();
return;
}
const intervalToUse = !interval || interval < 0 ? 16 : interval;
// @ts-ignore: Type 'Timeout' is not assignable to type 'number'
timeout.current = setTimeout(() => {
scanAsync();
}, intervalToUse);
}
}
function stop() {
isRunning.current = false;
clearTimeout(timeout.current);
}
React.useEffect(() => {
if (isEnabled) {
isRunning.current = true;
scanAsync();
}
return () => {
if (isEnabled) {
stop();
}
};
}, [isEnabled]);
}