Skip to content

🐛 iPhone 17 Pro crashes app when pressing shutter button #3662

@thegrandpoobah

Description

@thegrandpoobah

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

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

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 provide

Device

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    🐛 bugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions