diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 1fa150c..dff6951 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -4,11 +4,13 @@ + + diff --git a/android/gradle.properties b/android/gradle.properties index bceb108..76f893d 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -67,5 +67,6 @@ EX_UPDATES_ANDROID_DELAY_LOAD_APP=false # Ensure we always pick the NDK r27 libc++_shared.so we vendor in jniLibs android.packagingOptions.pickFirsts=**/libc++_shared.so + # Whether the app is configured to use edge-to-edge via the app config or `react-native-edge-to-edge` plugin -expo.edgeToEdgeEnabled=false +expo.edgeToEdgeEnabled=false \ No newline at end of file diff --git a/app.config.js b/app.config.js index 09fc17a..236bb3b 100644 --- a/app.config.js +++ b/app.config.js @@ -60,10 +60,12 @@ let config = { "android.permission.ACCESS_COARSE_LOCATION", "android.permission.ACCESS_FINE_LOCATION", "android.permission.CALL_PHONE", + "android.permission.CAMERA", "android.permission.INTERNET", "android.permission.MODIFY_AUDIO_SETTINGS", "android.permission.READ_CONTACTS", "android.permission.READ_EXTERNAL_STORAGE", + "android.permission.READ_MEDIA_IMAGES", "android.permission.RECORD_AUDIO", "android.permission.SYSTEM_ALERT_WINDOW", "android.permission.VIBRATE", diff --git a/src/permissions/mediaPermissions.js b/src/permissions/mediaPermissions.js new file mode 100644 index 0000000..8042b43 --- /dev/null +++ b/src/permissions/mediaPermissions.js @@ -0,0 +1,122 @@ +import { Alert, Platform } from "react-native"; +import { + check, + request, + openSettings, + PERMISSIONS, + RESULTS, +} from "react-native-permissions"; + +/** + * Show an alert inviting the user to open the OS settings when permission is blocked. + */ +const promptOpenSettings = (title, message) => + new Promise((resolve) => { + Alert.alert(title, message, [ + { text: "Annuler", style: "cancel", onPress: () => resolve(false) }, + { + text: "Ouvrir les réglages", + onPress: () => { + openSettings().catch(() => {}); + resolve(false); + }, + }, + ]); + }); + +/** + * Generic helper to check/request a single permission and handle blocked state. + */ +const ensurePermission = async (permission, niceName) => { + try { + const status = await check(permission); + + if (status === RESULTS.GRANTED || status === RESULTS.LIMITED) { + return true; + } + + if (status === RESULTS.BLOCKED) { + await promptOpenSettings( + `Permission ${niceName} requise`, + `Veuillez autoriser l'accès ${niceName} dans les réglages de l'application.`, + ); + return false; + } + + const req = await request(permission); + + if (req === RESULTS.BLOCKED) { + await promptOpenSettings( + `Permission ${niceName} requise`, + `Veuillez autoriser l'accès ${niceName} dans les réglages de l'application.`, + ); + return false; + } + + return req === RESULTS.GRANTED || req === RESULTS.LIMITED; + } catch (e) { + console.warn(`Failed to request ${niceName} permission`, e); + return false; + } +}; + +/** + * Ensure camera permission. + */ +export const ensureCameraPermission = async () => { + const perm = + Platform.OS === "android" + ? PERMISSIONS.ANDROID.CAMERA + : PERMISSIONS.IOS.CAMERA; + + return ensurePermission(perm, "à la caméra"); +}; + +/** + * Ensure photo library / media images read permission. + * On Android: + * - API >= 33: READ_MEDIA_IMAGES + * - API < 33: READ_EXTERNAL_STORAGE + * On iOS: PHOTO_LIBRARY (LIMITED is accepted). + */ +export const ensurePhotoPermission = async () => { + if (Platform.OS === "android") { + // Coerce API level to number to avoid misclassification on some devices + const apiLevel = Number(Platform.Version); + const isApi33Plus = !Number.isNaN(apiLevel) && apiLevel >= 33; + + const primary = isApi33Plus + ? PERMISSIONS.ANDROID.READ_MEDIA_IMAGES + : PERMISSIONS.ANDROID.READ_EXTERNAL_STORAGE; + const secondary = isApi33Plus + ? PERMISSIONS.ANDROID.READ_EXTERNAL_STORAGE + : PERMISSIONS.ANDROID.READ_MEDIA_IMAGES; + + // Try primary permission first + const primaryGranted = await ensurePermission(primary, "à vos photos"); + if (primaryGranted) return true; + + // If primary failed and secondary is not explicitly blocked, try secondary as a fallback + try { + const statusSecondary = await check(secondary); + if (statusSecondary !== RESULTS.BLOCKED) { + const secondaryGranted = await ensurePermission( + secondary, + "à vos photos", + ); + if (secondaryGranted) return true; + } + } catch (e) { + // ignore and fall through + } + + return false; + } + + return ensurePermission(PERMISSIONS.IOS.PHOTO_LIBRARY, "à vos photos"); +}; + +export default { + ensureCameraPermission, + ensurePhotoPermission, +}; diff --git a/src/scenes/Profile/AvatarModalEdit.js b/src/scenes/Profile/AvatarModalEdit.js index 92a43f7..2ebcc12 100644 --- a/src/scenes/Profile/AvatarModalEdit.js +++ b/src/scenes/Profile/AvatarModalEdit.js @@ -6,6 +6,10 @@ import ImagePicker from "react-native-image-crop-picker"; import { createStyles, useTheme } from "~/theme"; import { useFormContext } from "react-hook-form"; import ImageResizer from "@bam.tech/react-native-image-resizer"; +import { + ensureCameraPermission, + ensurePhotoPermission, +} from "~/permissions/mediaPermissions"; import env from "~/env"; @@ -83,8 +87,12 @@ export default function AvatarModalEdit({ modalState, userId }) { try { let pickedImage; if (mode === "library") { + const granted = await ensurePhotoPermission(); + if (!granted) return; pickedImage = await ImagePicker.openPicker(options); } else if (mode === "camera") { + const granted = await ensureCameraPermission(); + if (!granted) return; pickedImage = await ImagePicker.openCamera({ ...options, useFrontCamera: true, diff --git a/src/scenes/Profile/AvatarUploader.js b/src/scenes/Profile/AvatarUploader.js index a101205..6284331 100644 --- a/src/scenes/Profile/AvatarUploader.js +++ b/src/scenes/Profile/AvatarUploader.js @@ -2,8 +2,7 @@ import React, { useCallback, useState } from "react"; import { View, Image, TouchableWithoutFeedback } from "react-native"; import { IconButton, Avatar } from "react-native-paper"; import { MaterialCommunityIcons } from "@expo/vector-icons"; -import ImagePicker from "react-native-image-crop-picker"; -import { createStyles, useTheme } from "~/theme"; +import { useTheme } from "~/theme"; import { useFormContext } from "react-hook-form"; // import Text from "~/components/Text"; import ImageResizer from "@bam.tech/react-native-image-resizer";