Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 163 additions & 0 deletions src/hooks/useNativeCamera.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import {useFocusEffect} from '@react-navigation/core';
import {useCallback, useRef, useState} from 'react';
import {AppState} from 'react-native';
import {Gesture} from 'react-native-gesture-handler';
import {RESULTS} from 'react-native-permissions';
import {useAnimatedStyle, useSharedValue, withDelay, withSequence, withSpring, withTiming} from 'react-native-reanimated';
import type {Camera, Point} from 'react-native-vision-camera';
import {useCameraDevice} from 'react-native-vision-camera';
import {scheduleOnRN} from 'react-native-worklets';
import {useFullScreenLoaderActions, useFullScreenLoaderState} from '@components/FullScreenLoaderContext';
import {showCameraPermissionsAlert} from '@libs/fileDownload/FileUtils';
import getPlatform from '@libs/getPlatform';
import type Platform from '@libs/getPlatform/types';
import Log from '@libs/Log';
import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan';
import CameraPermission from '@pages/iou/request/step/IOURequestStepScan/CameraPermission';
import ONYXKEYS from '@src/ONYXKEYS';
import {getEmptyObject} from '@src/types/utils/EmptyObject';
import useLocalize from './useLocalize';
import useOnyx from './useOnyx';

type UseNativeCameraOptions = {
/** Context name for telemetry reason attributes */
context: string;

/** Additional logic to run when the screen gains focus */
onFocusStart?: () => void;

/** Additional cleanup to run when focus effect cleans up */
onFocusCleanup?: () => void;
};

function useNativeCamera({context, onFocusStart, onFocusCleanup}: UseNativeCameraOptions) {
const {translate} = useLocalize();
const {isLoaderVisible} = useFullScreenLoaderState();
const {setIsLoaderVisible} = useFullScreenLoaderActions();
Comment thread
DylanDylann marked this conversation as resolved.

const device = useCameraDevice('back', {
physicalDevices: ['wide-angle-camera', 'ultra-wide-angle-camera'],
});

const platform = getPlatform(true);
const [mutedPlatforms = getEmptyObject<Partial<Record<Platform, true>>>()] = useOnyx(ONYXKEYS.NVP_MUTED_PLATFORMS);
const isPlatformMuted = mutedPlatforms[platform];

const [cameraPermissionStatus, setCameraPermissionStatus] = useState<string | null>(null);
const hasFlash = !!device?.hasFlash;
const [flash, setFlash] = useState(false);
const [didCapturePhoto, setDidCapturePhoto] = useState(false);
const [isAttachmentPickerActive, setIsAttachmentPickerActive] = useState(false);
const camera = useRef<Camera>(null);

const askForPermissions = useCallback(() => {
// There's no way we can check for the BLOCKED status without requesting the permission first
// https://github.com/zoontek/react-native-permissions/blob/a836e114ce3a180b2b23916292c79841a267d828/README.md?plain=1#L670
CameraPermission.requestCameraPermission?.()
.then((status: string) => {
setCameraPermissionStatus(status);

if (status === RESULTS.BLOCKED) {
showCameraPermissionsAlert(translate);
}
})
.catch(() => {
setCameraPermissionStatus(RESULTS.UNAVAILABLE);
});
}, [translate]);

// Focus indicator animations
const focusIndicatorOpacity = useSharedValue(0);
const focusIndicatorScale = useSharedValue(2);
const focusIndicatorPosition = useSharedValue({x: 0, y: 0});

const cameraFocusIndicatorAnimatedStyle = useAnimatedStyle(() => ({
opacity: focusIndicatorOpacity.get(),
transform: [{translateX: focusIndicatorPosition.get().x}, {translateY: focusIndicatorPosition.get().y}, {scale: focusIndicatorScale.get()}],
}));

const focusCamera = useCallback((point: Point) => {
if (!camera.current) {
return;
}

camera.current.focus(point).catch((error: Record<string, unknown>) => {
if (error.message === '[unknown/unknown] Cancelled by another startFocusAndMetering()') {
return;
}
Log.warn('Error focusing camera', error);
});
}, []);

const tapGesture = Gesture.Tap()
.enabled(device?.supportsFocus ?? false)
.onStart((ev: {x: number; y: number}) => {
const point = {x: ev.x, y: ev.y};

focusIndicatorOpacity.set(withSequence(withTiming(0.8, {duration: 250}), withDelay(1000, withTiming(0, {duration: 250}))));
focusIndicatorScale.set(2);
focusIndicatorScale.set(withSpring(1, {damping: 10, stiffness: 200}));
focusIndicatorPosition.set(point);

scheduleOnRN(focusCamera, point);
});

// Refresh camera permission on screen focus and app state changes
useFocusEffect(
useCallback(() => {
setDidCapturePhoto(false);
onFocusStart?.();
const refreshCameraPermissionStatus = () => {
CameraPermission?.getCameraPermissionStatus?.()
.then(setCameraPermissionStatus)
.catch(() => setCameraPermissionStatus(RESULTS.UNAVAILABLE));
};

refreshCameraPermissionStatus();

// Refresh permission status when app gains focus
const subscription = AppState.addEventListener('change', (appState) => {
if (appState !== 'active') {
return;
}

refreshCameraPermissionStatus();
});

return () => {
subscription.remove();
onFocusCleanup?.();

if (isLoaderVisible) {
setIsLoaderVisible(false);
}
};
}, [isLoaderVisible, setIsLoaderVisible, onFocusStart, onFocusCleanup]),
);

const cameraLoadingReasonAttributes: SkeletonSpanReasonAttributes = {
context,
cameraPermissionGranted: cameraPermissionStatus === RESULTS.GRANTED,
deviceAvailable: device != null,
};

return {
camera,
device,
cameraPermissionStatus,
flash,
setFlash,
hasFlash,
didCapturePhoto,
setDidCapturePhoto,
isAttachmentPickerActive,
setIsAttachmentPickerActive,
isPlatformMuted,
askForPermissions,
tapGesture,
cameraFocusIndicatorAnimatedStyle,
cameraLoadingReasonAttributes,
};
}

export default useNativeCamera;
225 changes: 225 additions & 0 deletions src/hooks/useWebCamera.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
import {useIsFocused} from '@react-navigation/native';
import {useEffect, useReducer, useRef, useState} from 'react';
import type {LayoutRectangle} from 'react-native';
import type Webcam from 'react-webcam';
import {isMobile, isMobileWebKit} from '@libs/Browser';
import CONST from '@src/CONST';

/**
* Preload camera permission state at module load so first render can use a cached value.
*/
let cachedPermissionState: PermissionState | undefined;

if (typeof navigator !== 'undefined' && navigator.permissions) {
navigator.permissions
.query({name: 'camera'})
.then((status) => {
cachedPermissionState = status.state;
if ('addEventListener' in status) {
status.addEventListener('change', () => {
cachedPermissionState = status.state;
});
}
})
.catch(() => {
cachedPermissionState = 'denied';
});
}

function queryCameraPermission(): Promise<PermissionState> {
if (cachedPermissionState !== undefined) {
return Promise.resolve(cachedPermissionState);
}

if (typeof navigator === 'undefined' || !navigator.permissions) {
return Promise.resolve('denied');
}

return navigator.permissions
.query({name: 'camera'})
.then((status) => status.state)
.catch(() => 'denied' as const);
}

type UseWebCameraOptions = {
/** Additional cleanup to run on unmount */
onUnmount?: () => void;
};

function useWebCamera({onUnmount}: UseWebCameraOptions = {}) {
const isTabActive = useIsFocused();
const [cameraPermissionState, setCameraPermissionState] = useState<PermissionState | undefined>(() => cachedPermissionState ?? 'prompt');
const [isFlashLightOn, toggleFlashlight] = useReducer((state: boolean) => !state, false);
const [isTorchAvailable, setIsTorchAvailable] = useState(false);
const [isQueriedPermissionState, setIsQueriedPermissionState] = useState(() => cachedPermissionState !== undefined);
const [deviceConstraints, setDeviceConstraints] = useState<MediaTrackConstraints>();
const videoConstraints = isTabActive ? deviceConstraints : undefined;
const cameraRef = useRef<Webcam>(null);
const trackRef = useRef<MediaStreamTrack | null>(null);
const viewfinderLayout = useRef<LayoutRectangle>(null);
const getScreenshotTimeoutRef = useRef<NodeJS.Timeout | null>(null);

/**
* On phones that have ultra-wide lens, react-webcam uses ultra-wide by default.
* The last deviceId is of regular lens camera.
*/
const requestCameraPermission = () => {
if (!isMobile()) {
return;
}
const defaultConstraints = {facingMode: {exact: 'environment'}};
navigator.mediaDevices
.getUserMedia({video: {facingMode: {exact: 'environment'}, zoom: {ideal: 1}}})
.then((stream) => {
setCameraPermissionState('granted');
Comment thread
DylanDylann marked this conversation as resolved.
for (const track of stream.getTracks()) {
track.stop();
}
// Only Safari 17+ supports zoom constraint
if (isMobileWebKit() && stream.getTracks().length > 0) {
let deviceId;
for (const track of stream.getTracks()) {
const setting = track.getSettings();
if (setting.zoom === 1) {
deviceId = setting.deviceId;
break;
}
}
if (deviceId) {
setDeviceConstraints({deviceId});
return;
}
}
if (!navigator.mediaDevices.enumerateDevices) {
setDeviceConstraints(defaultConstraints);
return;
}
navigator.mediaDevices
.enumerateDevices()
.then((devices) => {
let lastBackDeviceId = '';
for (let i = devices.length - 1; i >= 0; i--) {
const device = devices.at(i);
if (device?.kind === 'videoinput') {
lastBackDeviceId = device.deviceId;
break;
}
}
if (!lastBackDeviceId) {
setDeviceConstraints(defaultConstraints);
return;
}
setDeviceConstraints({deviceId: lastBackDeviceId});
})
.catch(() => {
setDeviceConstraints(defaultConstraints);
});
})
.catch(() => {
setDeviceConstraints(defaultConstraints);
setCameraPermissionState('denied');
});
};

useEffect(() => {
if (!isTabActive) {
return;
}
let ignore = false;
queryCameraPermission()
.then((state) => {
if (ignore) {
return;
}
if (state === 'granted') {
requestCameraPermission();
Comment thread
DylanDylann marked this conversation as resolved.
} else {
setCameraPermissionState(state);
}
})
.catch(() => {
if (ignore) {
return;
}
setCameraPermissionState('denied');
})
.finally(() => {
if (ignore) {
return;
}
setIsQueriedPermissionState(true);
});
return () => {
ignore = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- requestCameraPermission is defined inline so including it would cause an infinite loop
}, [isTabActive]);
Comment thread
DylanDylann marked this conversation as resolved.

useEffect(
() => () => {
onUnmount?.();
setDeviceConstraints(undefined);
if (!getScreenshotTimeoutRef.current) {
return;
}
clearTimeout(getScreenshotTimeoutRef.current);
},
Comment thread
DylanDylann marked this conversation as resolved.
[onUnmount],
);

const setupCameraPermissionsAndCapabilities = (stream: MediaStream) => {
setCameraPermissionState('granted');

const [track] = stream.getVideoTracks();
const capabilities = track.getCapabilities();

if ('torch' in capabilities && capabilities.torch) {
trackRef.current = track;
}
setIsTorchAvailable('torch' in capabilities && !!capabilities.torch);
};

const clearTorchConstraints = () => {
if (!trackRef.current) {
return;
}
trackRef.current.applyConstraints({
advanced: [{torch: false}],
});
};

const capturePhotoWithFlash = (getScreenshot: () => void) => {
if (trackRef.current && isFlashLightOn) {
trackRef.current
.applyConstraints({
advanced: [{torch: true}],
})
.then(() => {
getScreenshotTimeoutRef.current = setTimeout(() => {
getScreenshot();
clearTorchConstraints();
}, CONST.RECEIPT.FLASH_DELAY_MS);
});
return;
}

getScreenshot();
};

return {
cameraRef,
viewfinderLayout,
cameraPermissionState,
setCameraPermissionState,
isFlashLightOn,
toggleFlashlight,
isTorchAvailable,
isQueriedPermissionState,
videoConstraints,
requestCameraPermission,
setupCameraPermissionsAndCapabilities,
capturePhotoWithFlash,
};
}
Comment thread
DylanDylann marked this conversation as resolved.

export default useWebCamera;
Loading
Loading