Aller au contenu
Réussir mon BTS SIO

Tape une notion, une commande ou le nom d'une épreuve.

Par exemple

Chapitre 818 minIntermédiaire

Caméra, photos et géolocalisation : les permissions

Par Josselyn C., prof de BTS SIO. Mis à jour le .

À la fin de ce chapitre, tu sauras

  • demander une permission au bon moment et traiter un refus ;
  • déclarer les permissions et leurs messages dans app.json ;
  • prendre une photo et l'envoyer à une API en multipart ;
  • obtenir la position du téléphone en respectant le RGPD.

4 exercices corrigés1 schéma

Une application mobile accède à l'appareil photo, à la position ou aux photos du téléphone par des modules natifs (expo-image-picker, expo-camera, expo-location), et seulement après une permission accordée par l'utilisateur, qu'il peut refuser ou retirer à tout moment. Ce chapitre explique le modèle des permissions d'Android et d'iOS, puis ajoute à AppliFrais la photo du justificatif, son envoi à l'API, la lecture d'un QR code et la commune où se trouve le visiteur, en respectant le RGPD.

Le modèle des permissions

Sur Android depuis la version 6 et sur iOS depuis toujours, une application ne peut pas utiliser un capteur sensible sans l'accord de l'utilisateur, demandé au moment de l'usage. Une permission passe par plusieurs états :

ÉtatSignificationQue faire
Pas encore demandée (undetermined)L'utilisateur n'a jamais été sollicitéDemander, au moment où la fonction sert
Accordée (granted)Accès autoriséUtiliser le capteur
Refusée, redemandable (denied, canAskAgain: true)Refus ponctuelExpliquer l'intérêt, redemander plus tard
Refusée définitivement (denied, canAskAgain: false)Le système n'affichera plus la demandeProposer d'ouvrir les réglages du téléphone
Limitée (iOS : certaines photos, position approximative)Accès partielFaire avec, ou expliquer pourquoi l'accès complet serait utile
pas encore demandéeundeterminedfenêtre du systèmemessage de app.jsonaccordéel'appareil photo s'ouvreau toucher de« Photographier »ouirefusrefuséecanAskAgain: truel'application pourraredemander, en expliquantnouveau refus (Android)ou premier refus (iOS)refusée définitivementcanAskAgain: falseplus aucune fenêtredu systèmeouvrir les réglagesopenSettings()l'utilisateurl'activeÀ tout moment, l'utilisateur peut retirer une permission dans les réglages : vérifie-la à chaque usage.
Une permission se demande au moment de l'usage ; après un refus définitif, seuls les réglages du téléphone peuvent la rendre

Les bonnes pratiques, que Google et Apple vérifient lors de la publication :

  • Demander au moment où la fonction sert, pas toutes les permissions au premier lancement : « Photographier le justificatif » justifie la caméra, un écran d'accueil ne justifie rien.
  • Expliquer pourquoi avec un message clair, déclaré dans la configuration.
  • Ne pas bloquer l'application en cas de refus : Lucas peut saisir un frais sans photo.
  • Demander le minimum : la position approximative plutôt que précise si elle suffit, la position pendant l'utilisation plutôt qu'en permanence.

Déclarer les permissions dans app.json

Chaque module qui utilise un capteur fournit un greffon de configuration (config plugin), déclaré dans la liste plugins d'app.json. Il ajoute les autorisations au manifeste Android et les messages d'explication (usage descriptions) que iOS affiche dans sa fenêtre :

json
{
  "expo": {
    "plugins": [
      "expo-router",
      "expo-secure-store",
      "expo-sqlite",
      [
        "expo-image-picker",
        {
          "cameraPermission": "AppliFrais utilise l'appareil photo pour photographier les justificatifs de tes frais.",
          "photosPermission": false,
          "microphonePermission": false
        }
      ],
      [
        "expo-location",
        {
          "locationWhenInUsePermission": "AppliFrais utilise ta position, à ta demande, pour indiquer la commune d'un frais."
        }
      ],
      [
        "expo-local-authentication",
        { "faceIDPermission": "AppliFrais utilise Face ID pour déverrouiller l'application." }
      ]
    ]
  }
}

"photosPermission": false retire l'accès à la photothèque, et "microphonePermission": false l'accès au micro, que le module déclare sinon pour filmer des vidéos : AppliFrais ne prend que des photos. Une permission déclarée mais inutile est une collecte excessive, et un motif de questions lors de la publication. Ces réglages s'appliquent quand l'application est compilée (development build ou version publiée) ; dans Expo Go, ce sont les messages génériques d'Expo Go qui s'affichent.

Photographier un justificatif

Pour une photo ponctuelle, expo-image-picker ouvre l'appareil photo du système et renvoie l'image prise : pas d'interface de prise de vue à coder.

bash
npx expo install expo-image-picker expo-file-system

Dans src/lib/appareil.ts :

ts
import { File } from 'expo-file-system';
import * as ImagePicker from 'expo-image-picker';
import * as Location from 'expo-location';
import { Alert, Linking } from 'react-native';

// Photo du justificatif : l'appareil photo du système, compressé (un ticket n'a pas besoin de 12 Mpx).
export async function photographierJustificatif(): Promise<string | null> {
  const permission = await ImagePicker.requestCameraPermissionsAsync();
  if (!permission.granted) {
    if (!permission.canAskAgain) proposerReglages("l'appareil photo");
    return null;
  }
  const resultat = await ImagePicker.launchCameraAsync({ mediaTypes: ['images'], quality: 0.6 });
  if (resultat.canceled) return null;
  return resultat.assets[0].uri;
}

function proposerReglages(ressource: string) {
  Alert.alert('Autorisation refusée', `Pour utiliser ${ressource}, autorise-le dans les réglages du téléphone.`, [
    { text: 'Plus tard', style: 'cancel' },
    { text: 'Ouvrir les réglages', onPress: () => Linking.openSettings() },
  ]);
}

launchCameraAsync renvoie { canceled: true } si Lucas annule, ou { canceled: false, assets: [...] } avec l'image : son uri (l'adresse du fichier dans le cache de l'application), sa largeur, sa hauteur et son type. quality: 0.6 compresse le JPEG : la photo d'un ticket passe de plusieurs mégaoctets à quelques centaines de kilo-octets, ce qui compte pour un envoi en 4G. Pour choisir une photo existante, launchImageLibraryAsync ouvre la photothèque avec les mêmes options.

Dans le formulaire « Nouveau frais », un bouton prend la photo, et un aperçu s'affiche avec expo-image :

tsx
const [photo, setPhoto] = useState<string | null>(null);

{photo && <Image source={{ uri: photo }} style={styles.apercu} contentFit="cover" accessibilityLabel="Photo du justificatif" />}
<Bouton
  titre={photo ? 'Reprendre la photo' : 'Photographier le justificatif'}
  variante="secondaire"
  onPress={async () => setPhoto((await photographierJustificatif()) ?? photo)}
/>

Si Lucas annule une nouvelle prise, ?? photo garde la photo précédente au lieu de l'effacer.

Envoyer la photo à l'API

Une photo s'envoie dans une requête multipart/form-data, le format des formulaires HTML avec fichier. Côté application, le File du module expo-file-system représente le fichier pris par l'appareil photo, et se range dans un FormData comme n'importe quel fichier du Web :

ts
export function formulaireJustificatif(uri: string): FormData {
  const formulaire = new FormData();
  formulaire.append('fichier', new File(uri)); // File d'expo-file-system est un Blob ; « fichier » : le champ qu'attend l'API
  return formulaire;
}

Le client d'API l'envoie avec PUT /v1/frais-hors-forfait/{id}/justificatif, sans en-tête Content-Type : fetch le calcule lui-même, avec la frontière (boundary) qui sépare les parties du formulaire. Écrire 'Content-Type': 'multipart/form-data' à la main casse la requête, car la frontière manque.

Côté serveur, la route est celle du cours Node.js (upload de fichier avec Multer) : la bibliothèque Multer (version 2) lit le champ fichier du formulaire, refuse un fichier de plus de 2 Mo (413) ou d'un autre type que PDF, JPEG ou PNG (415), vérifie que le frais appartient au visiteur connecté, puis range le fichier sous un nom aléatoire, hors du dossier public du serveur. Le nom du champ doit être le même des deux côtés : avec formulaire.append('photo', …), Multer répondrait 400. Vérifie aussi la taille des photos de ton téléphone : si elles dépassent 2 Mo, baisse quality.

Le type annoncé (mimetype) vient du client et se falsifie : une API sérieuse vérifie en plus la signature du fichier (les premiers octets d'un JPEG valent FF D8 FF), pour qu'un script déguisé en image ne soit jamais accepté.

Scanner un QR code avec expo-camera

Quand l'application a besoin de l'image en direct plutôt que d'une photo (lire un QR code, cadrer un document), le composant CameraView d'expo-camera affiche l'aperçu de la caméra dans l'écran. Exemple : lire le QR code imprimé sur la carte d'un professionnel de santé visité.

tsx
import { CameraView, useCameraPermissions } from 'expo-camera';

export function ScannerQr({ surCode }: { surCode: (valeur: string) => void }) {
  const [permission, demanderPermission] = useCameraPermissions();
  const dejaLu = useRef(false);

  if (!permission) return null; // chargement de l'état de la permission
  if (!permission.granted) {
    return <Bouton titre="Autoriser la caméra" onPress={demanderPermission} />;
  }
  return (
    <CameraView
      style={{ flex: 1 }}
      facing="back"
      barcodeScannerSettings={{ barcodeTypes: ['qr'] }}
      onBarcodeScanned={({ data }) => {
        if (dejaLu.current) return; // l'événement arrive plusieurs fois par seconde
        dejaLu.current = true;
        surCode(data);
      }}
    />
  );
}

useCameraPermissions renvoie l'état de la permission et la fonction qui la demande. Le contenu d'un QR code est une entrée externe : n'importe qui peut en imprimer un. Valide-le (format attendu, longueur) avant de l'utiliser, et n'ouvre jamais aveuglément une adresse qu'il contient.

La géolocalisation avec expo-location

AppliFrais propose d'ajouter au libellé d'un frais la commune où se trouve Lucas (« Repas client à Villeurbanne »), à sa demande. expo-location donne la position, puis la convertit en adresse (géocodage inverse) :

ts
// La commune où se trouve le visiteur, pour compléter le libellé d'un frais.
export async function communeActuelle(): Promise<string | null> {
  const { granted } = await Location.requestForegroundPermissionsAsync();
  if (!granted) return null;
  const position = await Location.getCurrentPositionAsync({ accuracy: Location.Accuracy.Balanced });
  const [adresse] = await Location.reverseGeocodeAsync(position.coords);
  return adresse?.city ?? null;
}
ChoixValeur dans AppliFraisPourquoi
PermissionAu premier plan seulement (requestForegroundPermissionsAsync)La position ne sert que pendant la saisie
PrécisionAccuracy.Balanced, de l'ordre de la centaine de mètresUne commune n'a pas besoin du GPS au mètre, et la réponse est plus rapide et moins coûteuse en batterie
DéclenchementUn bouton, « Ajouter ma commune »L'utilisateur décide ; aucune position n'est prise à son insu
ConservationSeulement le nom de la commune, dans le libelléPas de coordonnées stockées ni envoyées

La position en arrière-plan (requestBackgroundPermissionsAsync) est une autre affaire : une permission distincte, que Google et Apple n'acceptent que pour des usages justifiés (navigation, suivi de livraison), avec une déclaration spéciale lors de la publication.

Tester les capteurs sur un émulateur

Pas besoin d'aller à Villeurbanne pour tester la commune. L'émulateur Android, dans ses Extended controls (les trois points de sa barre latérale), permet de fixer une position GPS ou de rejouer un trajet. Sa caméra montre une scène virtuelle, ou la webcam de l'ordinateur si tu la configures dans Device Manager. Le simulateur iOS propose une position simulée dans son menu, mais pas de caméra : pour la photo, teste sur un téléphone.

Teste systématiquement les refus : refuse la permission, puis refuse-la définitivement, et vérifie que l'application reste utilisable et propose d'ouvrir les réglages. Pour revenir à l'état « pas encore demandée », désinstalle l'application ou réinitialise ses autorisations dans les réglages du téléphone.

Exercice 1 sur 4 Quand demander ? Réussi À revoir

Un développeur demande, au premier lancement d'AppliFrais, toutes les permissions d'un coup : caméra, position précise en arrière-plan, contacts, notifications. Relève les problèmes et propose une meilleure façon de faire.

Voir la réponseMasquer la réponse de l'exercice 1
  • Tout au premier lancement : l'utilisateur ne comprend pas à quoi servent ces demandes, et refuse souvent tout. Chaque permission se demande au moment où la fonction sert : la caméra au toucher de « Photographier le justificatif », la position au toucher de « Ajouter ma commune », les notifications quand Lucas active le rappel.
  • Position précise en arrière-plan : disproportionnée. La commune se détermine avec la position approximative, au premier plan, à la demande. L'arrière-plan serait de plus refusé par les magasins sans justification.
  • Contacts : aucune fonction d'AppliFrais n'en a besoin. Une permission inutile est un motif de refus de publication et une collecte excessive au sens du RGPD (minimisation).
  • Notifications : à demander quand Lucas active le rappel, avec une explication.

Tu avais trouvé ?

Exercice 2 sur 4 Traiter le refus Réussi À revoir

Complète cette fonction pour qu'elle renvoie la position quand la permission est accordée, null sinon, et propose d'ouvrir les réglages si la permission a été refusée définitivement.

ts
export async function positionActuelle(): Promise<Location.LocationObject | null> {
  const permission = await Location.requestForegroundPermissionsAsync();
  // à compléter
}
Voir la réponseMasquer la réponse de l'exercice 2
ts
export async function positionActuelle(): Promise<Location.LocationObject | null> {
  const permission = await Location.requestForegroundPermissionsAsync();
  if (!permission.granted) {
    if (!permission.canAskAgain) {
      Alert.alert('Position refusée', 'Autorise la position dans les réglages pour utiliser cette fonction.', [
        { text: 'Plus tard', style: 'cancel' },
        { text: 'Ouvrir les réglages', onPress: () => Linking.openSettings() },
      ]);
    }
    return null;
  }
  return Location.getCurrentPositionAsync({ accuracy: Location.Accuracy.Balanced });
}

Quand canAskAgain est faux, requestForegroundPermissionsAsync répond tout de suite « refusé » sans rien afficher : seuls les réglages du téléphone permettent de rendre l'accès.

Tu avais trouvé ?

Exercice 3 sur 4 Le message de permission Réussi À revoir

Lequel de ces messages d'explication de la caméra est acceptable pour l'App Store ? Justifie.

  1. « Cette application a besoin de la caméra. »
  2. « AppliFrais utilise l'appareil photo pour photographier les justificatifs de tes frais. »
  3. « Autoriser pour continuer. »
Voir la réponseMasquer la réponse de l'exercice 3

Seul le 2 est acceptable : il dit qui utilise la caméra et pour quoi faire. Le 1 et le 3 ne disent pas à quoi sert l'accès, ce qu'Apple exige ; le 3 laisse même croire que l'application est inutilisable sans, ce qui est faux (un frais peut être saisi sans photo).

Tu avais trouvé ?

Exercice 4 sur 4 Envoyer la photo Réussi À revoir

Ce code d'envoi du justificatif reçoit une erreur 500, et le journal de l'API indique « Error: Multipart: Boundary not found ». Trouve la cause.

ts
const formulaire = new FormData();
formulaire.append('fichier', new File(uri));
await fetch(`${URL_API}/frais-hors-forfait/${id}/justificatif`, {
  method: 'PUT',
  headers: { 'Content-Type': 'multipart/form-data', Authorization: `Bearer ${jeton}` },
  body: formulaire,
});
Voir la réponseMasquer la réponse de l'exercice 4

L'en-tête Content-Type écrit à la main n'a pas de frontière (boundary=…) : le serveur ne sait pas où commence et où finit chaque partie du formulaire, et multer lève une exception, que le gestionnaire d'erreurs par défaut d'Express transforme en 500. Il faut retirer 'Content-Type' et laisser fetch le calculer à partir du FormData, ce qu'il fait automatiquement (multipart/form-data; boundary=…). L'en-tête Authorization reste, lui, nécessaire.

Tu avais trouvé ?

Chapitre terminé !

Fais les 4 exercices avant de passer à la suite : c'est en t'entraînant que tout se fixe.

chapitres
–
de série
–
du cours
–
Chapitre suivant : Notifications push et authentification biométriqueRevoir le sommaire du cours