diff --git a/CHANGELOG.md b/CHANGELOG.md index bf4c6a0..2eed3df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,40 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [2.3.1] - 2026-04-27 + +### Fixed +- Improved session recording stability and reduced resource usage on both platforms. +- Fixed multiple crashes and ANRs during session recording and app termination. +- Fixed incorrect session engagement time calculations on Android. + +## [2.3.0] - 2026-02-24 + +### Added +- Feature configuration for screen capture, auto-start recording, plug chat theme, and remote config (fresh vs cached/lazy fetch). +- Support for React Native versions >= 0.79. + +### Changed +- Improved frame capture disabled session replays experience. +- [iOS] Session upload now enforces minimum visit duration before uploading. + +### Fixed +- Fixed a few memory leaks and crashes. + +## [2.2.6] - 2026-02-16 + +### Fixed +- Fixed an issue with session recordings on QR scan screens on Android. + +## [2.2.5] - 2026-02-02 + +### Changed +- Modernized legacy components and aligned for better performance. + +### Removed +- Removed deprecated and non-public iOS APIs. + ## [2.2.4] - 2026-01-29 ### Changed diff --git a/README.md b/README.md index 67e2222..d4fa550 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,9 @@ DevRev SDK, used for integrating DevRev services into your React Native and Expo - [Installation](#installation) - [Expo](#expo) - [Set up the DevRev SDK](#set-up-the-devrev-sdk) + - [Update the feature configuration](#update-the-feature-configuration) + - [Feature configuration reference](#feature-configuration-reference) + - [Support widget theme options](#support-widget-theme-options) - [Features](#features) - [Identification](#identification) - [Identify an unverified user](#identify-an-unverified-user) @@ -60,7 +63,6 @@ DevRev SDK, used for integrating DevRev services into your React Native and Expo - For Expo apps, Expo 50.0.0 or later. - Android: minimum API level 24. - iOS: minimum deployment target 15.1. -- (Recommended) An SSH key configured locally and registered with [GitHub](https://docs.github.com/en/github/authenticating-to-github/connecting-to-github-with-ssh). ### Installation @@ -100,12 +102,89 @@ npm install @devrev/sdk-react-native > [!WARNING] > The DevRev SDK must be configured before you can use any of its features. -The SDK becomes ready for use once the following configuration method is executed. +The SDK becomes ready for use once the configuration API is executed. ```typescript -DevRev.configure(appID: string) +DevRev.configure(appID) ``` +To provide a feature configuration during setup, call the overload that accepts it: + +```typescript +DevRev.configure(appID, featureConfiguration) +``` + +For default behavior, call the simpler form: + +```typescript +DevRev.configure('abcdefg12345') +``` + +To customize behavior such as frame capture, auto-start recording, or theme preferences, pass a full `FeatureConfiguration` object: + +```typescript +DevRev.configure('abcdefg12345', { + enableFrameCapture: false, + autoStartRecording: false, + prefersDialogMode: false, + alwaysUseRemoteConfig: true, + supportWidgetTheme: { + prefersSystemTheme: true, + }, +}); +``` + +#### Update the feature configuration + +You can adjust the feature configuration without reconfiguring the SDK. Pass a **full** `FeatureConfiguration` object (all properties are required): + +```typescript +DevRev.updateFeatureConfiguration({ + enableFrameCapture: true, + autoStartRecording: true, + prefersDialogMode: false, + alwaysUseRemoteConfig: true, + supportWidgetTheme: { + prefersSystemTheme: true, + }, +}); +``` + +#### Feature configuration reference + +`FeatureConfiguration` controls how the SDK behaves both during initial setup and when calling `DevRev.updateFeatureConfiguration(...)`. All properties are required when providing a feature configuration. + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `enableFrameCapture` | `boolean` | `true` | Enables the screen capture pipeline used by session replay. | +| `autoStartRecording` | `boolean` | `true` | Automatically starts recording after the SDK finishes remote configuration. | +| `prefersDialogMode` | `boolean` | `false` | Prefer dialog mode for the support UI (Android only). | +| `alwaysUseRemoteConfig` | `boolean` | `true` | Always use remote config. | +| `supportWidgetTheme` | `SupportWidgetTheme` | — | Controls the appearance of the in-app support widget, including dynamic theme behavior. | + +##### Support widget theme options + +`SupportWidgetTheme` lets you fine-tune the support UI. Use the `supportWidgetTheme` property inside your feature configuration. + +```typescript +const customTheme = { + prefersSystemTheme: false, + primaryTextColor: '#1F2933', + accentColor: '#F97316', + spacing: { + bottom: '20px', + side: '16px', + }, +}; +``` + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `prefersSystemTheme` | `boolean` | `true` | Follows the device appearance when `true`; otherwise uses your custom colors. | +| `primaryTextColor` | `string?` | — | Hex color string (e.g. `'#000000'`, `'#1F2933'`) for primary text in the support widget. | +| `accentColor` | `string?` | — | Hex color string (e.g. `'#F97316'`, `'#FF0000'`) applied to buttons and highlights. | +| `spacing` | `{ [key: string]: string }?` | — | CSS-like spacing overrides (`bottom` and `side` keys are recognized). | + ## Features ### Identification diff --git a/devrev-sdk-react-native-2.3.1.tgz b/devrev-sdk-react-native-2.3.1.tgz new file mode 100644 index 0000000..a9c8a5f Binary files /dev/null and b/devrev-sdk-react-native-2.3.1.tgz differ diff --git a/sample/expo/PushNotificationsService.tsx b/sample/expo/PushNotificationsService.tsx index ff1ba2a..06d0ae7 100644 --- a/sample/expo/PushNotificationsService.tsx +++ b/sample/expo/PushNotificationsService.tsx @@ -67,6 +67,8 @@ Notifications.setNotificationHandler({ shouldShowAlert: true, shouldPlaySound: true, shouldSetBadge: true, + shouldShowBanner: true, + shouldShowList: true, }; }, }); diff --git a/sample/expo/app.json b/sample/expo/app.json index d33b3cd..0f6e793 100644 --- a/sample/expo/app.json +++ b/sample/expo/app.json @@ -46,6 +46,19 @@ [ "../../app.plugin.js" ], + [ + "expo-image-picker", + { + "photosPermission": "App needs access to your photos." + } + ], + [ + "expo-camera", + { + "cameraPermission": "Allow $(PRODUCT_NAME) to access your camera", + "recordAudioAndroid": false + } + ], [ "expo-notifications", { @@ -63,7 +76,8 @@ "use_modular_headers!": true } } - ] + ], + "expo-asset" ], "extra": { "eas": { @@ -71,4 +85,4 @@ } } } -} +} \ No newline at end of file diff --git a/sample/expo/components/TouchableOpacityButton.tsx b/sample/expo/components/TouchableOpacityButton.tsx index ca02b39..6277fdc 100644 --- a/sample/expo/components/TouchableOpacityButton.tsx +++ b/sample/expo/components/TouchableOpacityButton.tsx @@ -5,13 +5,15 @@ import { StyleSheet, type TextStyle, type ViewStyle, + StyleProp, } from 'react-native'; interface TouchableOpacityProps { onPress: () => void; buttonText: string; - buttonStyle?: ViewStyle; - textStyle?: TextStyle; + buttonStyle?: StyleProp; + textStyle?: StyleProp; + disabled?: boolean; } const TouchableOpacityButton: React.FC = ({ @@ -19,9 +21,14 @@ const TouchableOpacityButton: React.FC = ({ buttonText, buttonStyle, textStyle, + disabled = false, }) => { return ( - + {buttonText} ); diff --git a/sample/expo/navigator/Navigator.tsx b/sample/expo/navigator/Navigator.tsx index 466764b..1298f32 100644 --- a/sample/expo/navigator/Navigator.tsx +++ b/sample/expo/navigator/Navigator.tsx @@ -9,6 +9,8 @@ import DelayedScreen from '../screens/DelayScreen'; import WebViewScreen from '../screens/WebViewScreen'; import FlatListScreen from '../screens/FlatListScreen'; import { TouchableOpacity, StyleSheet, Text } from 'react-native'; +import CameraScreen from '../screens/CameraScreen'; +import ImageUploadScreen from '../screens/ImageUpload'; export type RootStackParamList = { Home: undefined; @@ -19,6 +21,8 @@ export type RootStackParamList = { DelayScreen: undefined; WebViewScreen: undefined; FlatListScreen: undefined; + ImageUploadScreen: undefined; + CameraScreen: undefined; }; const Stack = createStackNavigator(); @@ -60,6 +64,16 @@ const screens = [ component: FlatListScreen, title: 'Large Scrollable List', }, + { + name: 'CameraScreen', + component: CameraScreen, + title: 'Camera', + }, + { + name: 'ImageUploadScreen', + component: ImageUploadScreen, + title: 'Gallery Upload', + }, ] as const; const createScreen = ( diff --git a/sample/expo/package.json b/sample/expo/package.json index 3bbe00b..f307c36 100644 --- a/sample/expo/package.json +++ b/sample/expo/package.json @@ -20,15 +20,19 @@ "@react-navigation/native-stack": "^7.3.14", "@react-navigation/stack": "^7.1.2", "expo": "^53.0.0", + "expo-asset": "~11.1.7", "expo-build-properties": "~0.14.8", + "expo-camera": "~16.1.11", + "expo-file-system": "~18.1.11", "expo-firebase-messaging": "^2.0.0", + "expo-image-picker": "~16.1.4", "expo-notifications": "~0.31.4", "expo-system-ui": "~5.0.11", "react": "19.0.0", "react-native": "0.79.5", "react-native-device-info": "^14.0.2", "react-native-gesture-handler": "~2.24.0", - "react-native-safe-area-context": "5.4.0", + "react-native-safe-area-context": "^5.6.2", "react-native-screens": "~4.11.1", "react-native-webview": "13.13.5" }, diff --git a/sample/expo/screens/CameraScreen.tsx b/sample/expo/screens/CameraScreen.tsx new file mode 100644 index 0000000..be3d942 --- /dev/null +++ b/sample/expo/screens/CameraScreen.tsx @@ -0,0 +1,190 @@ +import React from 'react'; +import { View, Text, Image, StyleSheet, Alert } from 'react-native'; +import { CameraView, useCameraPermissions } from 'expo-camera'; +import TouchableOpacityButton from '../components/TouchableOpacityButton'; +import { CameraType } from 'expo-image-picker'; + +const CameraScreen: React.FC = () => { + const [capturedImage, setCapturedImage] = React.useState(null); + const [permission, requestPermission] = useCameraPermissions(); + const cameraRef = React.useRef(null); + const [isCameraReady, setIsCameraReady] = React.useState(false); + + const takePicture = async () => { + if (cameraRef.current && isCameraReady) { + try { + const photo = await cameraRef.current.takePictureAsync({ + quality: 0.8, + base64: false, + exif: false, + }); + if (photo?.uri) { + setCapturedImage(photo.uri); + console.log('Image captured:', photo.uri); + } + } catch (error) { + Alert.alert('Error', 'Could not take photo'); + console.error(error); + } + } + }; + + if (!permission) { + return ( + + Checking permissions... + + ); + } + + return ( + + + {!permission.granted ? ( + + + Camera permission is required. + + + + ) : ( + + setIsCameraReady(true)} + style={styles.camera} + facing={CameraType.back} + ref={cameraRef} + /> + + + )} + + + + {capturedImage ? ( + <> + Captured Image Preview + + setCapturedImage(null)} + buttonStyle={[styles.button, styles.clearButton]} + textStyle={styles.buttonText} + /> + + ) : ( + + Your captured photo will appear here. + + )} + + + ); +}; + +export default CameraScreen; + +const styles = StyleSheet.create({ + button: { + backgroundColor: '#D3D3D3', + padding: 12, + borderRadius: 12, + marginVertical: 4, + alignItems: 'flex-start', + paddingBottom: 8, + }, + buttonText: { + margin: 'auto', + color: '#000000', + fontSize: 16, + paddingLeft: 8, + }, + container: { + flex: 1, + backgroundColor: '#fff', + paddingHorizontal: 10, + }, + topSection: { + flex: 1, + alignItems: 'center', + width: '100%', + marginTop: 20, + }, + cameraWrapper: { + width: '100%', + height: '80%', + overflow: 'hidden', + borderRadius: 15, + }, + camera: { + flex: 1, + height: '100%', + }, + permissionWarning: { + backgroundColor: '#FFF3CD', + padding: 8, + borderRadius: 8, + borderWidth: 1, + borderColor: '#FFE69C', + width: '100%', + }, + permissionWarningText: { + fontSize: 16, + fontWeight: '600', + color: '#856404', + textAlign: 'center', + marginBottom: 10, + }, + cameraButton: { + marginTop: 10, + backgroundColor: '#0091ff', + borderRadius: 10, + width: '100%', + }, + clearButton: { + backgroundColor: '#fc655d', + borderRadius: 10, + width: '100%', + }, + bottomSection: { + width: '100%', + flex: 1, + alignItems: 'center', + justifyContent: 'flex-start', + }, + label: { + fontSize: 20, + fontWeight: '600', + marginBottom: 10, + color: '#333', + textAlign: 'center', + }, + image: { + width: '100%', + height: 200, + objectFit: 'contain', + borderRadius: 10, + borderWidth: 2, + borderColor: '#007AFF', + }, + placeholderText: { + fontSize: 14, + color: '#666', + textAlign: 'center', + fontStyle: 'italic', + }, +}); diff --git a/sample/expo/screens/FlatListScreen.tsx b/sample/expo/screens/FlatListScreen.tsx index c6f810b..30bc72b 100644 --- a/sample/expo/screens/FlatListScreen.tsx +++ b/sample/expo/screens/FlatListScreen.tsx @@ -9,7 +9,7 @@ const DATA = Array.from({ length: 100 }, (_, i) => ({ const refs = DATA.map(() => React.createRef()); -const CardView = React.forwardRef(({ title }: { title: string }, ref) => ( +const CardView = React.forwardRef(({ title }, ref) => ( {title} diff --git a/sample/expo/screens/ImageUpload.tsx b/sample/expo/screens/ImageUpload.tsx new file mode 100644 index 0000000..d12d4af --- /dev/null +++ b/sample/expo/screens/ImageUpload.tsx @@ -0,0 +1,244 @@ +import React from 'react'; +import { + View, + Text, + Image, + StyleSheet, + Alert, + Dimensions, + FlatList, +} from 'react-native'; +import * as ImagePicker from 'expo-image-picker'; +import TouchableOpacityButton from '../components/TouchableOpacityButton'; + +const { width } = Dimensions.get('window'); +const imageSize = (width - 60) / 3; + +const RenderImageItem = ({ uri, index }: { uri: string; index: number }) => ( + + + {index + 1} + +); + +const EmptyState = () => ( + + No images selected yet. + +); + +const ImageUploadScreen: React.FC = () => { + const [selectedImages, setSelectedImages] = React.useState< + ImagePicker.ImagePickerAsset[] + >([]); + const [status, requestPermission] = ImagePicker.useMediaLibraryPermissions(); + const [pickerActive, setPickerActive] = React.useState(false); + + if (status === null) { + return ( + + Checking permissions... + + ); + } + + const permissionDenied = + status.status === ImagePicker.PermissionStatus.DENIED; + + const handleSelectImages = async () => { + setPickerActive(true); + if (status?.status !== ImagePicker.PermissionStatus.GRANTED) { + const permissionResponse = await requestPermission(); + if (!permissionResponse.granted) { + Alert.alert( + 'Permission Required', + 'Gallery permission is required to select images.' + ); + setPickerActive(false); + return; + } + } + + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: [], + allowsMultipleSelection: true, + quality: 0.8, + selectionLimit: 0, + }); + + if (result.canceled) { + console.log('User cancelled image picker'); + } else { + setSelectedImages(result.assets); + console.log(`Selected ${result.assets.length} images`); + } + + setPickerActive(false); + }; + + return ( + + + {permissionDenied && ( + + + Gallery permission is required to use this feature. + + + Please enable gallery permission in your device settings. + + + )} + + + + {selectedImages.length > 0 && ( + + {selectedImages.length} image + {selectedImages.length !== 1 ? 's' : ''} selected + + )} + + + ( + + )} + keyExtractor={(item, index) => `${item.uri}-${index}`} + style={styles.imageScrollView} + contentContainerStyle={[ + styles.imageScrollContent, + selectedImages.length === 0 && styles.contentCenter, + ]} + numColumns={3} + columnWrapperStyle={ + selectedImages.length > 0 ? styles.imagesGrid : null + } + ListEmptyComponent={EmptyState} + initialNumToRender={10} + maxToRenderPerBatch={10} + /> + + ); +}; + +export default ImageUploadScreen; + +const styles = StyleSheet.create({ + contentCenter: { + flex: 1, + justifyContent: 'center', + }, + container: { + flex: 1, + backgroundColor: '#fff', + }, + buttonText: { + margin: 'auto', + color: '#000000', + fontSize: 16, + paddingLeft: 8, + }, + topSection: { + paddingVertical: 20, + paddingHorizontal: 20, + alignItems: 'center', + borderBottomWidth: 1, + borderBottomColor: '#e0e0e0', + }, + permissionWarning: { + backgroundColor: '#FFF3CD', + padding: 16, + borderRadius: 8, + borderWidth: 1, + borderColor: '#FFE69C', + width: '100%', + }, + permissionWarningText: { + fontSize: 16, + fontWeight: '600', + color: '#856404', + textAlign: 'center', + marginBottom: 5, + }, + permissionWarningSubtext: { + fontSize: 14, + color: '#856404', + textAlign: 'center', + }, + selectButton: { + marginTop: 10, + backgroundColor: '#0091ff', + borderRadius: 10, + width: '100%', + }, + disabledButton: { + backgroundColor: '#CCCCCC', + opacity: 0.6, + }, + countText: { + marginTop: 15, + fontSize: 16, + fontWeight: '600', + color: '#007AFF', + }, + imageScrollView: { + flex: 1, + }, + imageScrollContent: { + padding: 15, + }, + imagesGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + justifyContent: 'flex-start', + }, + imageWrapper: { + margin: 5, + position: 'relative', + }, + image: { + width: imageSize, + height: imageSize, + borderRadius: 8, + borderWidth: 2, + borderColor: '#007AFF', + }, + imageNumber: { + position: 'absolute', + top: 5, + right: 5, + backgroundColor: 'rgba(0, 122, 255, 0.9)', + color: '#fff', + paddingHorizontal: 8, + paddingVertical: 4, + borderRadius: 12, + fontSize: 12, + fontWeight: 'bold', + }, + placeholderContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + paddingHorizontal: 40, + paddingTop: 60, + }, + placeholderText: { + fontSize: 16, + color: '#666', + textAlign: 'center', + lineHeight: 24, + }, +}); diff --git a/sample/expo/screens/PushNotificationsScreen.tsx b/sample/expo/screens/PushNotificationsScreen.tsx index 8f7939b..32cfe8f 100644 --- a/sample/expo/screens/PushNotificationsScreen.tsx +++ b/sample/expo/screens/PushNotificationsScreen.tsx @@ -9,40 +9,28 @@ const PushNotificationsScreen: React.FC = () => { message: string; } | null>(null); - const handleRegister = async () => { - const isRegistered = viewModel.registerDeviceToken(); - if (await isRegistered) { - setShowDialog({ - title: 'Success', - message: 'Successfully registered for push notifications.', - }); - } else { - setShowDialog({ - title: 'Error', - message: 'Could not register the device.', - }); - } + const registerPushNotifications = () => { + viewModel.registerPushNotifications(); + setShowDialog({ + title: 'Success', + message: 'Successfully registered for push notifications!', + }); }; - const handleUnregister = async () => { - const isUnregistered = viewModel.unregisterDevice(); - if (await isUnregistered) { - setShowDialog({ - title: 'Success', - message: 'Successfully unregistered for push notifications.', - }); - } else { - setShowDialog({ - title: 'Error', - message: 'Could not unregister the device.', - }); - return; - } + const unregisterDevice = () => { + viewModel.unregisterDevice(); + setShowDialog({ + title: 'Success', + message: 'Successfully unregistered the device!', + }); }; const buttons = [ - { text: 'Register for push notifications', onPress: handleRegister }, - { text: 'Unregister for push notifications', onPress: handleUnregister }, + { + text: 'Register for push notifications', + onPress: registerPushNotifications, + }, + { text: 'Unregister for push notifications', onPress: unregisterDevice }, ] as const; return ( diff --git a/sample/expo/screens/SessionAnalyticsScreen.tsx b/sample/expo/screens/SessionAnalyticsScreen.tsx index b9f2f67..ab44415 100644 --- a/sample/expo/screens/SessionAnalyticsScreen.tsx +++ b/sample/expo/screens/SessionAnalyticsScreen.tsx @@ -68,6 +68,16 @@ const ListViewButton = [ }, ] as const; +const MediaButtons = [ + { + text: 'Open Camera', + screenname: 'CameraScreen', + }, + { + text: 'Gallery Image Upload', + screenname: 'ImageUploadScreen', + }, +] as const; const SessionAnalyticsScreen = ({ navigation }: { navigation: any }) => { const sensitiveLabelRef = useRef(null); const unsensitiveLabelRef = useRef(null); @@ -130,6 +140,17 @@ const SessionAnalyticsScreen = ({ navigation }: { navigation: any }) => { /> ))} + Media + {MediaButtons.map((button, index) => ( + navigation.navigate(button.screenname)} + buttonText={button.text} + buttonStyle={styles.button} + textStyle={styles.buttonText} + /> + ))} + Timers {Timer.map((button, index) => ( + + + + + NSLocationWhenInUseUsageDescription + NSCameraUsageDescription + This app needs access to your camera to take photos + NSPhotoLibraryUsageDescription + This app needs access to your photo library to select photos + NSPhotoLibraryAddUsageDescription + This app needs permission to save photos to your library UIBackgroundModes remote-notification diff --git a/sample/react-native/ios/Sources/PrivacyInfo.xcprivacy b/sample/react-native/ios/Sources/PrivacyInfo.xcprivacy index 189b631..412881a 100644 --- a/sample/react-native/ios/Sources/PrivacyInfo.xcprivacy +++ b/sample/react-native/ios/Sources/PrivacyInfo.xcprivacy @@ -20,6 +20,7 @@ NSPrivacyAccessedAPITypeReasons C617.1 + 3B52.1 diff --git a/sample/react-native/package.json b/sample/react-native/package.json index fb31339..74c31b5 100644 --- a/sample/react-native/package.json +++ b/sample/react-native/package.json @@ -15,7 +15,9 @@ "@react-native-firebase/installations": "^21.0.0", "@react-native-firebase/messaging": "^21.0.0", "react-native-device-info": "^14.0.1", + "react-native-image-picker": "^8.2.1", "react-native-notifications": "^5.1.0", + "react-native-vision-camera": "^4.7.3", "react-native-webview": "^13.15.0" }, "devDependencies": { diff --git a/sample/react-native/src/App.tsx b/sample/react-native/src/App.tsx index c729d62..e0c476d 100644 --- a/sample/react-native/src/App.tsx +++ b/sample/react-native/src/App.tsx @@ -13,6 +13,8 @@ import HomeScreen from './screens/HomeScreen'; import DelayedScreen from './screens/DelayedScreen'; import WebViewScreen from './screens/WebViewScreen'; import FlatListScreen from './screens/FlatListScreen'; +import ImageUploadScreen from './screens/ImageUploadScreen'; +import CameraScreen from './screens/CameraScreen'; import { commonStyles } from './styles/styles'; export type RootStackParamList = { @@ -24,6 +26,8 @@ export type RootStackParamList = { DelayedScreen: undefined; WebViewScreen: undefined; FlatListScreen: undefined; + ImageUploadScreen: undefined; + CameraScreen: undefined; }; const Stack = createStackNavigator(); @@ -65,6 +69,16 @@ const screens = [ component: FlatListScreen, title: 'Large Scrollable List', }, + { + name: 'CameraScreen', + component: CameraScreen, + title: 'Camera', + }, + { + name: 'ImageUploadScreen', + component: ImageUploadScreen, + title: 'Gallery Upload', + }, ] as const; const createScreen = ( diff --git a/sample/react-native/src/components/TouchableOpacityButton.tsx b/sample/react-native/src/components/TouchableOpacityButton.tsx index edc2c41..172b78e 100644 --- a/sample/react-native/src/components/TouchableOpacityButton.tsx +++ b/sample/react-native/src/components/TouchableOpacityButton.tsx @@ -5,23 +5,26 @@ import { type TextStyle, type ViewStyle, View, + StyleProp, } from 'react-native'; import { commonStyles } from '../styles/styles'; interface TouchableOpacityProps { onPress: () => void; buttonText: string; - buttonStyle?: ViewStyle; - textStyle?: TextStyle; + buttonStyle?: StyleProp; + textStyle?: StyleProp; + disabled?: boolean; } const TouchableOpacityButton = forwardRef( - ({ onPress, buttonText, buttonStyle, textStyle }, ref) => { + ({ onPress, buttonText, buttonStyle, textStyle, disabled = false }, ref) => { return ( {buttonText} diff --git a/sample/react-native/src/screens/CameraScreen.tsx b/sample/react-native/src/screens/CameraScreen.tsx new file mode 100644 index 0000000..92a4a3c --- /dev/null +++ b/sample/react-native/src/screens/CameraScreen.tsx @@ -0,0 +1,162 @@ +import React from 'react'; +import { View, Text, Image, StyleSheet, Alert, Linking } from 'react-native'; +import { + Camera, + useCameraDevice, + useCameraPermission, +} from 'react-native-vision-camera'; +import TouchableOpacityButton from '../components/TouchableOpacityButton'; +import { commonStyles } from '../styles/styles'; + +const CameraScreen: React.FC = () => { + const cameraRef = React.useRef(null); + const device = useCameraDevice('back'); + const { hasPermission, requestPermission } = useCameraPermission(); + + const [capturedImage, setCapturedImage] = React.useState(null); + const [capturing, setCapturing] = React.useState(false); + + const handleCapture = async () => { + let granted = hasPermission; + + if (!granted) { + granted = await requestPermission(); + } + + if (!granted) { + Alert.alert('Permission Required', 'Camera permission is required.', [ + { text: 'Cancel', style: 'cancel' }, + { text: 'Open Settings', onPress: () => Linking.openSettings() }, + ]); + return; + } + + if (!cameraRef.current) return; + + try { + setCapturing(true); + const photo = await cameraRef.current.takePhoto(); + setCapturedImage(`file://${photo.path}`); + } catch (error) { + console.error(error); + Alert.alert('Camera Error', 'Failed to capture image'); + } finally { + setCapturing(false); + } + }; + + if (!device) { + return ( + + Loading camera... + + ); + } + + return ( + + + + + + + + + {capturedImage ? ( + <> + Captured Image + + setCapturedImage(null)} + buttonStyle={[commonStyles.button, styles.clearButton]} + textStyle={[commonStyles.buttonText]} + /> + + ) : ( + No image captured yet. + )} + + + ); +}; + +export default CameraScreen; + +const styles = StyleSheet.create({ + buttonText: { + fontWeight: '400', + margin: 'auto', + }, + container: { + flex: 1, + backgroundColor: '#fff', + }, + cameraSection: { + flex: 1, + backgroundColor: '#000', + }, + bottomSection: { + flex: 1, + padding: 16, + alignItems: 'center', + }, + captureButton: { + backgroundColor: '#0091ff', + borderRadius: 10, + width: '100%', + marginBottom: 12, + }, + clearButton: { + backgroundColor: '#fc655d', + borderRadius: 10, + width: '100%', + marginTop: 10, + }, + disabledButton: { + backgroundColor: '#CCCCCC', + }, + label: { + fontSize: 16, + fontWeight: '600', + marginVertical: 8, + }, + image: { + width: '100%', + height: 200, + borderRadius: 12, + borderWidth: 2, + borderColor: '#007AFF', + }, + placeholderText: { + fontSize: 14, + color: '#666', + fontStyle: 'italic', + marginTop: 20, + }, + center: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, +}); diff --git a/sample/react-native/src/screens/DelayedScreen.tsx b/sample/react-native/src/screens/DelayedScreen.tsx index 932f395..90e142f 100644 --- a/sample/react-native/src/screens/DelayedScreen.tsx +++ b/sample/react-native/src/screens/DelayedScreen.tsx @@ -1,5 +1,4 @@ -import * as React from 'react'; -import { useEffect } from 'react'; +import React, { useEffect } from 'react'; import { View, Text } from 'react-native'; import * as DevRev from '@devrev/sdk-react-native'; import { commonStyles } from '../styles/styles'; diff --git a/sample/react-native/src/screens/FlatListScreen.tsx b/sample/react-native/src/screens/FlatListScreen.tsx index c6f810b..30bc72b 100644 --- a/sample/react-native/src/screens/FlatListScreen.tsx +++ b/sample/react-native/src/screens/FlatListScreen.tsx @@ -9,7 +9,7 @@ const DATA = Array.from({ length: 100 }, (_, i) => ({ const refs = DATA.map(() => React.createRef()); -const CardView = React.forwardRef(({ title }: { title: string }, ref) => ( +const CardView = React.forwardRef(({ title }, ref) => ( {title} diff --git a/sample/react-native/src/screens/HomeScreen.tsx b/sample/react-native/src/screens/HomeScreen.tsx index 6bea2b6..601b941 100644 --- a/sample/react-native/src/screens/HomeScreen.tsx +++ b/sample/react-native/src/screens/HomeScreen.tsx @@ -1,4 +1,4 @@ -import * as React from 'react'; +import React from 'react'; import { View, Text, Platform, Animated } from 'react-native'; import TouchableOpacityButton from '../components/TouchableOpacityButton'; import { commonStyles } from '../styles/styles'; diff --git a/sample/react-native/src/screens/ImageUploadScreen.tsx b/sample/react-native/src/screens/ImageUploadScreen.tsx new file mode 100644 index 0000000..1a7becd --- /dev/null +++ b/sample/react-native/src/screens/ImageUploadScreen.tsx @@ -0,0 +1,300 @@ +import React from 'react'; +import { + View, + Text, + Image, + StyleSheet, + Alert, + Dimensions, + Platform, + PermissionsAndroid, + FlatList, +} from 'react-native'; +import { + launchImageLibrary, + ImagePickerResponse, + Asset, +} from 'react-native-image-picker'; +import TouchableOpacityButton from '../components/TouchableOpacityButton'; +import { commonStyles } from '../styles/styles'; + +const { width } = Dimensions.get('window'); +const imageSize = (width - 60) / 3; + +const RenderImageItem = ({ uri, index }: { uri: string; index: number }) => ( + + + {index + 1} + +); + +const EmptyState = () => ( + + No images selected yet. + +); + +const ImageUploadScreen: React.FC = () => { + const [selectedImages, setSelectedImages] = React.useState([]); + const [hasPermission, setHasPermission] = React.useState( + null + ); + const [permissionDenied, setPermissionDenied] = + React.useState(false); + const [pickerActive, setPickerActive] = React.useState(false); + + React.useEffect(() => { + checkMediaPermission(); + }, []); + + const checkMediaPermission = async () => { + if (Platform.OS === 'android') { + try { + const permission = + Platform.Version >= 33 + ? PermissionsAndroid.PERMISSIONS.READ_MEDIA_IMAGES + : PermissionsAndroid.PERMISSIONS.READ_EXTERNAL_STORAGE; + const granted = await PermissionsAndroid.check(permission); + setHasPermission(granted); + } catch (err) { + console.warn('Error checking media permission:', err); + setHasPermission(false); + } + } else { + setHasPermission(true); + } + }; + + const requestMediaPermission = async (): Promise => { + if (Platform.OS !== 'android') { + return true; + } + + try { + const permission = + Platform.Version >= 33 + ? PermissionsAndroid.PERMISSIONS.READ_MEDIA_IMAGES + : PermissionsAndroid.PERMISSIONS.READ_EXTERNAL_STORAGE; + const result = await PermissionsAndroid.request(permission, { + title: 'Storage Permission', + message: 'App needs access to your photos.', + buttonNeutral: 'Ask Me Later', + buttonNegative: 'Cancel', + buttonPositive: 'OK', + }); + + const isGranted = result === PermissionsAndroid.RESULTS.GRANTED; + setHasPermission(isGranted); + setPermissionDenied(!isGranted); + return isGranted; + } catch (err) { + console.warn('Error requesting storage permission:', err); + setPermissionDenied(true); + return false; + } + }; + + const handleSelectImages = async () => { + setPickerActive(true); + if (Platform.OS === 'android' && !hasPermission) { + const granted = await requestMediaPermission(); + if (!granted) { + Alert.alert( + 'Permission Required', + 'Gallery permission is required to select images.' + ); + setPickerActive(false); + return; + } + } + + launchImageLibrary( + { + mediaType: 'photo', + selectionLimit: 0, + quality: 0.8, + }, + (response: ImagePickerResponse) => { + if (response.didCancel) { + console.log('User cancelled image picker'); + } else if (response.errorCode) { + Alert.alert( + 'Gallery Error', + response.errorMessage || 'Failed to open gallery' + ); + console.error('Gallery error:', response.errorMessage); + } else if (response.assets && response.assets.length > 0) { + setSelectedImages(response.assets); + console.log(`Selected ${response.assets.length} images`); + } + } + ); + setPickerActive(false); + }; + + return ( + + + {Platform.OS === 'android' && permissionDenied && ( + + + Gallery permission is required to use this feature. + + + Please enable gallery permission in your device settings. + + + )} + + + {selectedImages.length > 0 && ( + + {selectedImages.length} image + {selectedImages.length !== 1 ? 's' : ''} selected + + )} + + + ( + + )} + keyExtractor={(item, index) => `${item.uri}-${index}`} + style={styles.imageScrollView} + contentContainerStyle={[ + styles.imageScrollContent, + selectedImages.length === 0 && styles.contentCenter, + ]} + numColumns={3} + columnWrapperStyle={ + selectedImages.length > 0 ? styles.imagesGrid : null + } + ListEmptyComponent={EmptyState} + initialNumToRender={10} + maxToRenderPerBatch={10} + /> + + ); +}; + +export default ImageUploadScreen; + +const styles = StyleSheet.create({ + contentCenter: { + flex: 1, + justifyContent: 'center', + }, + buttonText: { + fontWeight: '400', + margin: 'auto', + }, + container: { + flex: 1, + backgroundColor: '#fff', + }, + topSection: { + paddingVertical: 20, + paddingHorizontal: 20, + alignItems: 'center', + borderBottomWidth: 1, + borderBottomColor: '#e0e0e0', + }, + permissionWarning: { + backgroundColor: '#FFF3CD', + padding: 15, + borderRadius: 8, + marginTop: 10, + marginHorizontal: 20, + borderWidth: 1, + borderColor: '#FFE69C', + width: '100%', + }, + permissionWarningText: { + fontSize: 16, + fontWeight: '600', + color: '#856404', + textAlign: 'center', + marginBottom: 5, + }, + permissionWarningSubtext: { + fontSize: 14, + color: '#856404', + textAlign: 'center', + }, + selectButton: { + marginTop: 10, + backgroundColor: '#409cff', + width: '100%', + textAlign: 'center', + }, + disabledButton: { + backgroundColor: '#CCCCCC', + opacity: 0.6, + }, + countText: { + marginTop: 15, + fontSize: 16, + fontWeight: '600', + color: '#007AFF', + }, + imageScrollView: { + flex: 1, + }, + imageScrollContent: { + padding: 15, + }, + imagesGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + justifyContent: 'flex-start', + }, + imageWrapper: { + margin: 5, + position: 'relative', + }, + image: { + width: imageSize, + height: imageSize, + borderRadius: 8, + borderWidth: 2, + borderColor: '#007AFF', + }, + imageNumber: { + position: 'absolute', + top: 5, + right: 5, + backgroundColor: 'rgba(0, 122, 255, 0.9)', + color: '#fff', + paddingHorizontal: 8, + paddingVertical: 4, + borderRadius: 12, + fontSize: 12, + fontWeight: 'bold', + }, + placeholderContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + paddingHorizontal: 40, + paddingTop: 60, + }, + placeholderText: { + fontSize: 16, + color: '#666', + textAlign: 'center', + lineHeight: 24, + }, +}); diff --git a/sample/react-native/src/screens/SessionAnalyticsScreen.tsx b/sample/react-native/src/screens/SessionAnalyticsScreen.tsx index dc35835..453663a 100644 --- a/sample/react-native/src/screens/SessionAnalyticsScreen.tsx +++ b/sample/react-native/src/screens/SessionAnalyticsScreen.tsx @@ -43,6 +43,17 @@ const TimerButtons = [ }, ] as const; +const MediaButtons = [ + { + text: 'Open Camera', + screenname: 'CameraScreen', + }, + { + text: 'Gallery Image Upload', + screenname: 'ImageUploadScreen', + }, +] as const; + const OnDemandSessionButtons = [ { text: 'Process All On-Demand Sessions', @@ -150,6 +161,17 @@ const SessionAnalyticsScreen: React.FC<{ navigation: any }> = ({ /> ))} + Media + {MediaButtons.map((button, index) => ( + navigation.navigate(button.screenname)} + buttonText={button.text} + buttonStyle={commonStyles.button} + textStyle={commonStyles.buttonText} + /> + ))} + Manual Masking / Unmasking