On an iPhone 17 Pro device from our user (running iOS 26.0.1), our app crashes as soon as the user presses our app's shutter button. This behavior is not present on iPhone 12, iPhone 15, and various Android devices that we have in our development team (i.e. our own phones, we are a small outfit).
The iPhone 17 in question is not our device and we don't have an iPhone 17 to test with, so I cannot produce the logs and formats that you are asking for.
import { CameraRoll } from '@react-native-camera-roll/camera-roll'
import { useAppState } from '@react-native-community/hooks'
import { useIsFocused } from '@react-navigation/native'
import React, {
PropsWithChildren,
useCallback,
useEffect,
useRef,
useState,
} from 'react'
import {
ImageSourcePropType,
Platform,
Pressable,
Image as RNImage,
StyleProp,
StyleSheet,
View,
ViewStyle,
} from 'react-native'
import { Image } from 'react-native-compressor'
import { Gesture, GestureDetector } from 'react-native-gesture-handler'
import { launchImageLibrary } from 'react-native-image-picker'
import { ProgressBar } from 'react-native-paper'
import Animated, {
Extrapolation,
interpolate,
runOnJS,
useAnimatedProps,
useAnimatedStyle,
useSharedValue,
withSequence,
withSpring,
withTiming,
} from 'react-native-reanimated'
import {
Camera,
CameraPosition,
CameraProps,
Point,
useCameraDevice,
useCameraFormat,
} from 'react-native-vision-camera'
import LOG from 'utils/log'
import {
PermissionQueryState,
requestGalleryPermissions,
requestImageCapturePermissions,
} from 'utils/permissions'
import { BORDER_RADIUS, GUTTER, MARGIN, themeColors } from 'utils/trustyTheme'
import Icon from './Icon'
import MediaCapturePermissions from './MediaCapturePermissions'
const ANIMATION_DISABLED = Platform.OS === 'android'
const ANIMATION_DURATION = 250
const MAX_IMAGE_DIMENSIONS = 1920
const IMAGE_OUTPUT_QUALITY = 0.9
const MAX_ZOOM_FACTOR = 10
const SCALE_FULL_ZOOM = 3
Animated.addWhitelistedNativeProps({
zoom: true,
})
const AnimatedCamera = Animated.createAnimatedComponent(Camera)
const AnimatedPressable = Animated.createAnimatedComponent(Pressable)
type Props = {
left?: React.JSX.Element
onCaptureComplete: (photoPath: string) => Promise<void>
right: React.JSX.Element
aiEnhancementAvailable?: boolean
cameraPosition?: CameraPosition
displayGalleryImage?: boolean
shutterDisabled?: boolean
}
type Animation = 'fadeIn' | 'fadeOut'
const ImageCapture: React.FC<PropsWithChildren<Props>> = ({
children,
onCaptureComplete,
right,
aiEnhancementAvailable = false,
cameraPosition = 'back',
displayGalleryImage = false,
shutterDisabled = false,
}) => {
const appState = useAppState()
const isFocused = useIsFocused()
const [isActive, setIsActive] = useState(true)
const [isOverlayActive, setIsOverlayActive] = useState(true)
const [isStarted, setIsStarted] = useState(false)
const device = useCameraDevice(cameraPosition)
const camera = useRef<Camera>(null)
const [cameraStyle, setCameraStyle] = useState<StyleProp<ViewStyle>>({})
const zoom = useSharedValue(1)
const [imageSource, setImageSource] = useState<
ImageSourcePropType | undefined
>(undefined)
const [permissionQueryState, setPermissionQueryState] =
useState<PermissionQueryState>('unknown')
useEffect(() => {
const queryPermissions = async () => {
setPermissionQueryState('requested')
const granted = await requestImageCapturePermissions()
await requestGalleryPermissions()
if (granted) {
setPermissionQueryState('granted')
} else {
setPermissionQueryState('denied')
}
}
if (permissionQueryState !== 'unknown') {
return
}
queryPermissions()
}, [permissionQueryState])
useEffect(() => {
const loadFirstGalleryImage = async () => {
try {
const { edges } = await CameraRoll.getPhotos({
assetType: 'Photos',
first: 1,
})
if (edges.length > 0) {
const [{ node }] = edges
setImageSource({ uri: node.image.uri })
}
} catch (error) {
LOG.error(error)
}
}
if (permissionQueryState === 'granted') {
loadFirstGalleryImage()
}
}, [setImageSource, permissionQueryState])
const [animationQueue, setAnimationQueue] = useState<Animation[]>([])
const [isAnimating, setIsAnimating] = useState(false)
const buttonMargin = useSharedValue(2)
const overlayOpacity = useSharedValue(ANIMATION_DISABLED ? 0 : 1)
const overlayStyle = useAnimatedStyle(() => ({
backgroundColor: 'black',
opacity: overlayOpacity.value,
}))
const format = useCameraFormat(device, [
{ fps: 60 },
{ videoResolution: 'max' },
{ photoResolution: 'max' },
{ videoStabilizationMode: 'cinematic-extended' },
{ photoAspectRatio: 0.75 },
])
const onStarted = useCallback(() => {
setIsStarted(true)
}, [])
const onStopped = useCallback(() => {
setIsStarted(false)
}, [])
const runAnimation = useCallback(
(type: Animation) => {
const fadeOut = () => {
setIsAnimating(true)
overlayOpacity.value = withTiming(
0,
{ duration: ANIMATION_DURATION },
finished => {
runOnJS(setIsOverlayActive)(!finished)
runOnJS(setIsAnimating)(false)
},
)
}
const fadeIn = () => {
setIsAnimating(true)
setIsOverlayActive(true)
overlayOpacity.value = withTiming(
1,
{
duration: ANIMATION_DURATION * 2,
},
() => {
runOnJS(setIsAnimating)(false)
},
)
}
if (type === 'fadeOut') {
fadeOut()
} else if (type === 'fadeIn') {
fadeIn()
}
},
[overlayOpacity, setIsAnimating, setIsOverlayActive],
)
useEffect(() => {
if (isAnimating || animationQueue.length === 0) {
return
}
setAnimationQueue(prev => {
setIsAnimating(true)
const [animation, ...rest] = prev
runAnimation(animation)
return rest
})
}, [animationQueue, isAnimating, runAnimation, setIsAnimating])
useEffect(() => {
if (ANIMATION_DISABLED) {
return
}
if (isFocused && isStarted) {
setAnimationQueue(prev => [...prev, 'fadeOut'])
} else {
setAnimationQueue(prev => [...prev, 'fadeIn'])
}
}, [isFocused, isStarted, setAnimationQueue])
useEffect(() => {
if (ANIMATION_DISABLED) {
return
}
setAnimationQueue(prev => {
if (prev.length === 0) {
return ['fadeIn', 'fadeOut']
}
return prev
})
}, [cameraPosition, setAnimationQueue])
useEffect(() => {
// Reset zoom to it's default everytime the `device` changes.
zoom.value = device?.neutralZoom ?? 1
}, [zoom, device])
const capture = async () => {
try {
setIsActive(false)
setIsOverlayActive(true)
overlayOpacity.value = withSequence(
withTiming(0.5, { duration: 0 }),
withTiming(0, { duration: ANIMATION_DURATION }, finished => {
runOnJS(setIsOverlayActive)(!finished)
}),
)
buttonMargin.value = withSequence(
withSpring(4, { duration: ANIMATION_DURATION / 2 }),
withSpring(2, { duration: ANIMATION_DURATION }),
)
const photo = await camera.current?.takePhoto()
if (!photo) {
return
}
const result = await Image.compress(photo.path, {
compressionMethod: 'auto',
quality: IMAGE_OUTPUT_QUALITY,
maxHeight: MAX_IMAGE_DIMENSIONS,
maxWidth: MAX_IMAGE_DIMENSIONS,
})
await onCaptureComplete(result)
} catch (error) {
LOG.error(error)
} finally {
setIsActive(true)
setIsOverlayActive(false)
}
}
const onLaunchGalleryPressed = async () => {
try {
const { assets } = await launchImageLibrary({
mediaType: 'photo',
selectionLimit: 1,
})
const image = assets?.at(0)
if (!image?.uri) {
return
}
await onCaptureComplete(image.uri)
} catch (error) {
LOG.error(error)
}
}
const focus = useCallback(
(point: Point) => {
camera.current?.focus(point)
},
[camera],
)
const tapGesture = Gesture.Tap().onEnd(({ x, y }) => {
runOnJS(focus)({ x, y })
})
const minZoom = device?.minZoom ?? 1
const maxZoom = Math.min(device?.maxZoom ?? 1, MAX_ZOOM_FACTOR)
const zoomOffset = useSharedValue(0)
const pinchGesture = Gesture.Pinch()
.onBegin(() => {
zoomOffset.value = zoom.value
})
.onUpdate(event => {
const scale = interpolate(
event.scale,
[1 - 1 / SCALE_FULL_ZOOM, 1, SCALE_FULL_ZOOM],
[-1, 0, 1],
Extrapolation.CLAMP,
)
zoom.value = interpolate(
scale,
[-1, 0, 1],
[minZoom, zoomOffset.value, maxZoom],
Extrapolation.CLAMP,
)
})
const animatedProps = useAnimatedProps<CameraProps>(() => {
return {
zoom: zoom.value,
}
}, [zoom])
const isShutterDisabled = !isStarted || !isActive || shutterDisabled
return (
<>
<View style={styles.content}>
<View style={styles.cameraContainer}>
{permissionQueryState === 'granted' && device && (
<GestureDetector gesture={Gesture.Race(tapGesture, pinchGesture)}>
<AnimatedCamera
device={device}
ref={camera}
isActive={appState === 'active' && isFocused}
photo
onStarted={onStarted}
onStopped={onStopped}
style={cameraStyle}
format={format}
photoQualityBalance="speed"
outputOrientation="device"
fps={format?.maxFps}
// The following two lines fix a render issue on Android
// See: https://github.com/mrousavy/react-native-vision-camera/issues/3384#issuecomment-2630942302
androidPreviewViewType="texture-view"
onLayout={() => setCameraStyle(styles.camera)}
animatedProps={animatedProps}
/>
</GestureDetector>
)}
{isOverlayActive && (
<Animated.View style={[StyleSheet.absoluteFill, overlayStyle]} />
)}
</View>
{permissionQueryState !== 'granted' && (
<MediaCapturePermissions type="image" />
)}
{!aiEnhancementAvailable && !isActive && <ProgressBar indeterminate />}
<View style={styles.childrenContainer}>{children}</View>
</View>
<View style={styles.controlsContainer}>
<Pressable
onPress={onLaunchGalleryPressed}
style={styles.galleryImageContainer}>
{displayGalleryImage && imageSource ? (
<RNImage source={imageSource} style={styles.galleryImage} />
) : (
<Icon name="solar:gallery-add-outline" />
)}
</Pressable>
<View style={styles.shutterButtonContainer}>
<AnimatedPressable
style={[
styles.shutterButton,
{
backgroundColor: shutterDisabled
? themeColors.icon[500]
: themeColors.accent1[100],
margin: buttonMargin,
},
]}
onPress={capture}
disabled={isShutterDisabled}
/>
</View>
<View>{right}</View>
</View>
</>
)
}
const styles = StyleSheet.create({
layout: {
justifyContent: 'space-between',
},
content: {
flexGrow: 1,
gap: GUTTER,
position: 'relative',
},
camera: {
flexGrow: 1,
},
cameraContainer: {
flexGrow: 1,
},
controlsContainer: {
paddingHorizontal: MARGIN,
paddingBottom: GUTTER,
alignItems: 'center',
flexDirection: 'row',
justifyContent: 'space-between',
},
shutterButtonContainer: {
width: 62,
height: 62,
backgroundColor: 'transparent',
borderRadius: 31,
borderColor: themeColors.accent1[100],
borderWidth: 4,
},
shutterButton: {
flex: 1,
flexGrow: 1,
borderRadius: 50,
margin: 2,
},
childrenContainer: {
gap: GUTTER,
paddingBottom: MARGIN,
paddingTop: GUTTER,
},
galleryImageContainer: {
alignItems: 'center',
backgroundColor: themeColors.secondary[700],
borderColor: themeColors.secondary[700],
borderWidth: 1,
borderRadius: BORDER_RADIUS,
height: 62,
justifyContent: 'center',
width: 62,
},
galleryImage: {
width: 58,
height: 58,
borderRadius: BORDER_RADIUS,
overflow: 'hidden',
},
})
export default ImageCapture
What's happening?
On an iPhone 17 Pro device from our user (running iOS 26.0.1), our app crashes as soon as the user presses our app's shutter button. This behavior is not present on iPhone 12, iPhone 15, and various Android devices that we have in our development team (i.e. our own phones, we are a small outfit).
On #3644 there are comments about the app crashing on shutter which seems similar, but the issue itself talks about the app crashing in general, so thought it might be a separate issue.
The iPhone 17 in question is not our device and we don't have an iPhone 17 to test with, so I cannot produce the logs and formats that you are asking for.
Crashlytics Trace
com.loaded.app_issue_233c110e4bb971462bfde95beee323a3_crash_session_06ba8186fb764486960dadb338a830f1_DNE_8_v2_stacktrace.txt
Reproduceable Code
Relevant log output
We do NOT have an iPhone 17 accessible to provide the native logs requested, but I did share the crashlytics logs that we have access to.Camera Device
cannot provideDevice
iPhone 17 Pro (iOS 26.0.1
VisionCamera Version
4.7.2
Can you reproduce this issue in the VisionCamera Example app?
I didn't try (⚠️ your issue might get ignored & closed if you don't try this)
Additional information