# Stratégie de Permissions Géolocalisation **Date** : 2026-01-31 **Auteur** : Architecture Mobile RoadWave **Statut** : Approuvé **Version** : 1.0 --- ## Contexte La géolocalisation est **critique** pour RoadWave, mais les permissions arrière-plan sont le **#1 motif de rejet** sur iOS App Store et Android Play Store. ### Problématiques Identifiées #### iOS App Store - **Taux de rejet ~70%** si permission "Always Location" mal justifiée - Apple exige que l'app soit **pleinement utilisable** sans "Always Location" - Textes `Info.plist` scrutés manuellement par reviewers humains - Rejection si suspicion de tracking publicitaire ou vente de données #### Android Play Store - Depuis Android 10 : `ACCESS_BACKGROUND_LOCATION` nécessite **déclaration justifiée** - Vidéo démo **obligatoire** montrant le flow de demande (< 30s) - Google vérifie que la permission est **réellement optionnelle** - Foreground service notification **obligatoire** en arrière-plan (Android 12+) #### RGPD (Règle 02) - Permissions doivent être **optionnelles** - Utilisateur doit pouvoir **refuser sans pénalité** - App doit fonctionner en **mode dégradé acceptable** --- ## Stratégie Progressive (2 Étapes) ### Vue d'Ensemble ``` ┌─────────────────────────────────────────────────────────┐ │ ONBOARDING │ │ ┌─────────────────────────────────────────────────────┐ │ │ │ Étape 1: Permission "When In Use" │ │ │ │ → Mode voiture complet ✅ │ │ │ └─────────────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────┘ │ │ User utilise l'app normalement │ ▼ ┌─────────────────────────────────────────────────────────┐ │ SETTINGS (Plus tard, si besoin) │ │ ┌─────────────────────────────────────────────────────┐ │ │ │ Étape 2: Permission "Always" (optionnelle) │ │ │ │ → Mode piéton avec notifications push ✅ │ │ │ └─────────────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────┘ ``` --- ## Étape 1 : Permission de Base (Onboarding) ### Quand - **Premier lancement** de l'app - Avant de pouvoir utiliser les fonctionnalités principales ### Permission Demandée | Platform | Permission | Nom Utilisateur | |----------|-----------|----------------| | **iOS** | `NSLocationWhenInUseUsageDescription` | "Allow While Using App" | | **Android** | `ACCESS_FINE_LOCATION` | "Autorisez uniquement lorsque l'application est en cours d'utilisation" | ### Flow UI **Écran pré-permission** (recommandé pour taux d'acceptation) : ``` ┌────────────────────────────────────────┐ │ 🗺️ Bienvenue sur RoadWave │ ├────────────────────────────────────────┤ │ │ │ RoadWave vous propose du contenu audio│ │ adapté à votre position en temps réel.│ │ │ │ Nous avons besoin de votre localisation│ │ pour : │ │ │ │ ✅ Recommander du contenu proche │ │ ✅ Détecter votre mode (voiture/piéton)│ │ ✅ Synchroniser avec vos trajets │ │ │ │ [Continuer] │ │ │ │ Votre vie privée est protégée │ └────────────────────────────────────────┘ ``` **Puis demande système iOS/Android** ### Si Permission Acceptée - Mode voiture **complet** ✅ - Détection POI quand app **ouverte** - Recommandations géolocalisées temps réel - **Pas de demande supplémentaire** sauf si user veut mode piéton ### Si Permission Refusée **Mode dégradé (IP2Location)** : - Détection pays/ville via adresse IP (IP2Location Lite, voir [ADR-019](../adr/019-geolocalisation-ip.md)) - Contenus nationaux et régionaux disponibles - Pas de contenus hyperlocaux (< 10km) **UI** : ``` ┌────────────────────────────────────────┐ │ ⚠️ Géolocalisation désactivée │ ├────────────────────────────────────────┤ │ Vous écoutez des contenus de votre │ │ région (détection approximative). │ │ │ │ Pour débloquer les contenus proches : │ │ [Activer la géolocalisation] │ └────────────────────────────────────────┘ ``` **Tap "Activer"** → `openAppSettings()` (réglages système) ### Code d'Implémentation ```dart // lib/presentation/onboarding/location_onboarding_screen.dart class LocationOnboardingScreen extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( body: SafeArea( child: Padding( padding: EdgeInsets.all(24), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(Icons.map, size: 80, color: Colors.blue), SizedBox(height: 32), Text( 'Bienvenue sur RoadWave', style: Theme.of(context).textTheme.headlineMedium, ), SizedBox(height: 16), Text( 'RoadWave vous propose du contenu audio ' 'adapté à votre position en temps réel.', textAlign: TextAlign.center, ), SizedBox(height: 32), _buildFeatureList(), SizedBox(height: 48), ElevatedButton( onPressed: () => _requestLocationPermission(context), child: Text('Continuer'), ), SizedBox(height: 16), Text( 'Votre vie privée est protégée', style: Theme.of(context).textTheme.bodySmall, ), ], ), ), ), ); } Widget _buildFeatureList() { return Column( children: [ _buildFeature('Recommander du contenu proche'), _buildFeature('Détecter votre mode (voiture/piéton)'), _buildFeature('Synchroniser avec vos trajets'), ], ); } Widget _buildFeature(String text) { return Padding( padding: EdgeInsets.symmetric(vertical: 8), child: Row( children: [ Icon(Icons.check_circle, color: Colors.green), SizedBox(width: 16), Expanded(child: Text(text)), ], ), ); } Future _requestLocationPermission(BuildContext context) async { final service = context.read(); final granted = await service.requestBasicPermission(); if (granted) { // Navigation vers écran principal Navigator.pushReplacementNamed(context, '/home'); } else { // Afficher mode dégradé disponible _showDegradedModeDialog(context); } } void _showDegradedModeDialog(BuildContext context) { showDialog( context: context, builder: (context) => AlertDialog( title: Text('Géolocalisation désactivée'), content: Text( 'Vous pouvez toujours utiliser RoadWave avec des contenus ' 'de votre région (détection approximative).', ), actions: [ TextButton( onPressed: () { Navigator.pop(context); Navigator.pushReplacementNamed(context, '/home'); }, child: Text('Continuer sans GPS'), ), TextButton( onPressed: () async { Navigator.pop(context); await openAppSettings(); }, child: Text('Ouvrir réglages'), ), ], ), ); } } ``` --- ## Étape 2 : Permission Arrière-Plan (Optionnelle) ### Quand - User **active explicitement** "Notifications audio-guides piéton" dans Settings - **Jamais au premier lancement** ### Permission Demandée | Platform | Permission | Nom Utilisateur | |----------|-----------|----------------| | **iOS** | `NSLocationAlwaysAndWhenInUseUsageDescription` | "Allow Always" | | **Android** | `ACCESS_BACKGROUND_LOCATION` | "Toujours autoriser" | ### Flow UI (Critique pour Validation Stores) **1. Toggle dans Settings** ``` Settings > Notifications ┌────────────────────────────────────────┐ │ 🔔 Notifications │ ├────────────────────────────────────────┤ │ Recommendations de contenu │ │ ├─ En conduite [ON] │ │ └─ Au volant [ON] │ │ │ │ Audio-guides piéton [OFF] │ │ ⓘ Nécessite localisation arrière-plan │ │ │ │ Live de créateurs suivis [ON] │ └────────────────────────────────────────┘ ``` **2. Écran d'éducation (OBLIGATOIRE avant demande OS)** ``` ┌────────────────────────────────────────┐ │ 📍 Notifications audio-guides piéton │ ├────────────────────────────────────────┤ │ Pour vous alerter d'audio-guides à │ │ proximité même quand vous marchez avec │ │ l'app fermée, RoadWave a besoin de │ │ votre position en arrière-plan. │ │ │ │ 🔍 Votre position sera utilisée pour : │ │ ✅ Détecter monuments à 200m │ │ ✅ Vous envoyer une notification │ │ │ │ 🔒 Votre position ne sera jamais : │ │ ❌ Vendue à des tiers │ │ ❌ Utilisée pour de la publicité │ │ ❌ Partagée sans votre consentement │ │ │ │ Cette fonctionnalité est optionnelle. │ │ Vous pouvez utiliser RoadWave sans │ │ cette permission. │ │ │ │ [Continuer] [Non merci] │ │ │ │ Plus d'infos : Politique confidentialité│ └────────────────────────────────────────┘ ``` **3. Demande système iOS/Android** **4. Si permission accordée** ``` ✅ Mode piéton activé ! Vous recevrez une notification lorsque vous passez près d'un audio-guide. ``` **5. Si permission refusée** ``` ⚠️ Mode piéton non disponible Sans permission "Toujours autoriser", nous ne pouvons pas détecter les audio-guides en arrière-plan. Vous pouvez toujours : ✅ Utiliser le mode voiture ✅ Lancer manuellement les audio-guides [Ouvrir réglages] [Fermer] ``` ### Code d'Implémentation ```dart // lib/presentation/settings/notifications_settings_screen.dart class NotificationsSettingsScreen extends StatefulWidget { @override _NotificationsSettingsScreenState createState() => _NotificationsSettingsScreenState(); } class _NotificationsSettingsScreenState extends State { bool _pedestrianModeEnabled = false; @override void initState() { super.initState(); _loadPermissionStatus(); } Future _loadPermissionStatus() async { final service = context.read(); final level = await service.getCurrentLevel(); setState(() { _pedestrianModeEnabled = (level == LocationPermissionLevel.always); }); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text('Notifications')), body: ListView( children: [ SwitchListTile( title: Text('Recommendations de contenu'), subtitle: Text('En conduite'), value: true, onChanged: (value) { /* ... */ }, ), SwitchListTile( title: Text('Audio-guides piéton'), subtitle: Text('Nécessite localisation arrière-plan'), value: _pedestrianModeEnabled, onChanged: _handlePedestrianModeToggle, ), ], ), ); } Future _handlePedestrianModeToggle(bool enabled) async { if (enabled) { // User veut activer → demander permission final granted = await _requestBackgroundPermission(); setState(() { _pedestrianModeEnabled = granted; }); } else { // User veut désactiver → juste disable service setState(() { _pedestrianModeEnabled = false; }); // Arrêter geofencing service context.read().stop(); } } Future _requestBackgroundPermission() async { // Étape 1: Afficher écran d'éducation final userWantsToContinue = await _showEducationDialog(); if (!userWantsToContinue) return false; // Étape 2: Demander permission OS final service = context.read(); final granted = await service.requestBackgroundPermission(context: context); if (granted) { _showSuccessDialog(); // Démarrer geofencing service context.read().start(); } else { _showDeniedDialog(); } return granted; } Future _showEducationDialog() async { return await showDialog( context: context, barrierDismissible: false, builder: (context) => AlertDialog( title: Text('📍 Notifications audio-guides piéton'), content: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Pour vous alerter d\'audio-guides à proximité ' 'même quand vous marchez avec l\'app fermée, ' 'RoadWave a besoin de votre position en arrière-plan.', ), SizedBox(height: 16), Text('🔍 Votre position sera utilisée pour :', style: TextStyle(fontWeight: FontWeight.bold)), _buildListItem('Détecter monuments à 200m'), _buildListItem('Vous envoyer une notification'), SizedBox(height: 16), Text('🔒 Votre position ne sera jamais :', style: TextStyle(fontWeight: FontWeight.bold)), _buildListItem('Vendue à des tiers', isNegative: true), _buildListItem('Utilisée pour de la publicité', isNegative: true), _buildListItem('Partagée sans votre consentement', isNegative: true), SizedBox(height: 16), Text( 'Cette fonctionnalité est optionnelle. ' 'Vous pouvez utiliser RoadWave sans cette permission.', style: TextStyle(fontStyle: FontStyle.italic, fontSize: 12), ), ], ), ), actions: [ TextButton( onPressed: () => Navigator.pop(context, false), child: Text('Non merci'), ), ElevatedButton( onPressed: () => Navigator.pop(context, true), child: Text('Continuer'), ), ], ), ) ?? false; } Widget _buildListItem(String text, {bool isNegative = false}) { return Padding( padding: EdgeInsets.symmetric(vertical: 4), child: Row( children: [ Icon( isNegative ? Icons.cancel : Icons.check_circle, color: isNegative ? Colors.red : Colors.green, size: 20, ), SizedBox(width: 8), Expanded(child: Text(text)), ], ), ); } void _showSuccessDialog() { showDialog( context: context, builder: (context) => AlertDialog( title: Text('✅ Mode piéton activé !'), content: Text( 'Vous recevrez une notification lorsque vous passez près d\'un audio-guide.', ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: Text('OK'), ), ], ), ); } void _showDeniedDialog() { showDialog( context: context, builder: (context) => AlertDialog( title: Text('⚠️ Mode piéton non disponible'), content: Text( 'Sans permission "Toujours autoriser", nous ne pouvons pas ' 'détecter les audio-guides en arrière-plan.\n\n' 'Vous pouvez toujours :\n' '✅ Utiliser le mode voiture\n' '✅ Lancer manuellement les audio-guides', ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: Text('Fermer'), ), TextButton( onPressed: () { Navigator.pop(context); openAppSettings(); }, child: Text('Ouvrir réglages'), ), ], ), ); } } ``` --- ## Tableau de Dégradation Gracieuse | Niveau Permission | Mode Voiture | Mode Piéton | Contenus Hyperlocaux | Notifications | |-------------------|--------------|-------------|---------------------|--------------| | **Always** | ✅ Complet | ✅ Complet | ✅ Tous | Push en arrière-plan | | **When In Use** | ✅ Complet | ❌ Désactivé | ✅ Si app ouverte | Sonores (app ouverte) | | **Denied** | ⚠️ IP2Location (ville) | ❌ Désactivé | ❌ Aucun | Aucune | **Garanties** : - App **utilisable** à tous niveaux de permission ✅ - Pas de fonctionnalité **bloquante** sans permission ✅ - Mode dégradé **acceptable** (contenus régionaux) ✅ --- ## Configuration Plateformes ### iOS (`ios/Runner/Info.plist`) ```xml NSLocationWhenInUseUsageDescription RoadWave utilise votre position pour vous proposer des contenus audio géolocalisés adaptés à votre trajet en temps réel. NSLocationAlwaysAndWhenInUseUsageDescription Si vous activez les notifications audio-guides piéton, RoadWave peut vous alerter lorsque vous passez près d'un monument ou musée, même quand l'app est en arrière-plan. Cette fonctionnalité est optionnelle et peut être désactivée à tout moment dans les réglages. UIBackgroundModes location remote-notification NSLocationAlwaysUsageDescription Si vous activez les notifications audio-guides piéton, RoadWave peut vous alerter lorsque vous passez près d'un monument ou musée, même quand l'app est en arrière-plan. ``` ### Android (`android/app/src/main/AndroidManifest.xml`) ```xml ``` --- ## Checklist Validation Stores ### iOS App Store - [ ] Permission "Always" demandée **uniquement** après activation explicite mode piéton - [ ] Écran d'éducation **avant** demande OS (avec raisons claires) - [ ] Texte `NSLocationAlwaysAndWhenInUseUsageDescription` mentionne : - [ ] Fonctionnalité précise ("audio-guides piéton") - [ ] **Optionnalité** ("Cette fonctionnalité est optionnelle") - [ ] Pas de mention tracking/publicité - [ ] App fonctionne **complètement** avec permission "When In Use" uniquement - [ ] App fonctionne en **mode dégradé** sans aucune permission (IP2Location) - [ ] Screenshots montrant app fonctionnelle sans permission "Always" - [ ] Video demo flow de permissions (< 1 min, optionnel mais recommandé) ### Android Play Store - [ ] Déclaration `ACCESS_BACKGROUND_LOCATION` avec justification dans Play Console : - [ ] "Notifications géolocalisées pour audio-guides touristiques en arrière-plan" - [ ] "Permet aux utilisateurs de recevoir des alertes lorsqu'ils passent près de monuments" - [ ] **Vidéo démo obligatoire** (< 30s) montrant : - [ ] Activation toggle "Mode piéton" dans Settings - [ ] Écran d'éducation pré-permission - [ ] Demande permission système Android - [ ] App fonctionnelle si permission refusée - [ ] Foreground service notification visible en mode piéton (Android 12+) - [ ] App fonctionne **complètement** avec `ACCESS_FINE_LOCATION` uniquement - [ ] App fonctionne en **mode dégradé** sans permissions - [ ] Screenshots montrant app fonctionnelle sans permission background --- ## Tests Requis ### Tests Unitaires ```dart // test/core/services/location_permission_service_test.dart void main() { group('LocationPermissionService', () { test('getCurrentLevel returns denied when no permission', () async { // ... }); test('getCurrentLevel returns whenInUse with basic permission', () async { // ... }); test('getCurrentLevel returns always with background permission', () async { // ... }); test('requestBasicPermission shows system dialog', () async { // ... }); test('requestBackgroundPermission requires education dialog first', () async { // ... }); }); } ``` ### Tests d'Intégration ```dart // integration_test/permissions_flow_test.dart void main() { testWidgets('Onboarding flow with permission acceptance', (tester) async { app.main(); await tester.pumpAndSettle(); // Voir écran onboarding expect(find.text('Bienvenue sur RoadWave'), findsOneWidget); // Tap continuer await tester.tap(find.text('Continuer')); await tester.pumpAndSettle(); // Permission acceptée (mock) → navigation home expect(find.byType(HomeScreen), findsOneWidget); }); testWidgets('Settings pedestrian mode activation flow', (tester) async { // ... await tester.tap(find.byType(SwitchListTile).last); await tester.pumpAndSettle(); // Voir écran d'éducation expect(find.text('Notifications audio-guides piéton'), findsOneWidget); expect(find.text('Votre position sera utilisée pour'), findsOneWidget); // Tap continuer await tester.tap(find.text('Continuer')); await tester.pumpAndSettle(); // Vérifier demande système (mock) // ... }); } ``` ### Tests Manuels (Devices Réels) **iOS** : - [ ] iPhone avec iOS 14, 15, 16, 17, 18 - [ ] Tester flow onboarding permission "When In Use" - [ ] Tester activation mode piéton avec permission "Always" - [ ] Tester refus permission "Always" → app reste fonctionnelle - [ ] Tester changement permission dans Settings iOS → app réagit correctement **Android** : - [ ] Android 10, 11, 12, 13, 14, 15 - [ ] Tester flow onboarding permission `FINE_LOCATION` - [ ] Tester activation mode piéton avec `BACKGROUND_LOCATION` - [ ] Tester refus permission background → app reste fonctionnelle - [ ] Vérifier foreground notification visible en arrière-plan (Android 12+) --- ## Validation TestFlight / Internal Testing ### Phase 1 : TestFlight Beta (iOS) **Objectif** : Valider que Apple accepte notre stratégie de permissions **Participants** : 10-20 beta testers externes **Durée** : 2 semaines **Checklist** : - [ ] Upload build vers TestFlight - [ ] Compléter questionnaire App Store Connect : - [ ] "Why does your app use background location?" → "To send push notifications when users walk near tourist audio-guides, even when app is closed. This feature is optional and can be disabled in settings." - [ ] Screenshots montrant app fonctionnelle sans permission "Always" - [ ] Attendre review Apple (24-48h) - [ ] Si rejet : analyser feedback, ajuster textes/flow, re-soumettre - [ ] Si accepté : lancer beta test avec testeurs **Scénarios de test beta** : 1. Installation fresh → onboarding → accepter "When In Use" 2. Utiliser mode voiture pendant 1 semaine 3. Activer mode piéton dans settings → accepter "Always" 4. Vérifier réception notifications push en arrière-plan 5. Désactiver mode piéton → vérifier app toujours fonctionnelle **Métriques collectées** : - Taux acceptation permission "When In Use" : cible >85% - Taux acceptation permission "Always" : cible >40% - Taux rejet App Review : cible 0% ### Phase 2 : Internal Testing (Android) **Objectif** : Valider conformité Play Store + foreground service **Participants** : 5-10 beta testers internes **Durée** : 1 semaine **Checklist** : - [ ] Upload build vers Play Console (Internal Testing) - [ ] Compléter déclaration permissions : - [ ] `ACCESS_BACKGROUND_LOCATION` justification - [ ] Upload vidéo démo (< 30s) - [ ] Tester sur Android 10, 11, 12, 13, 14, 15 - [ ] Vérifier foreground notification visible (Android 12+) **Scénarios de test** : 1. Installation → onboarding → accepter `FINE_LOCATION` 2. Utiliser app mode voiture 3. Activer mode piéton → voir écran éducation → accepter `BACKGROUND_LOCATION` 4. App en arrière-plan → marcher près d'un POI → vérifier notification push 5. Vérifier notification foreground service visible dans panneau notifications **Métriques collectées** : - Consommation batterie mode piéton : cible <5% par heure - Taux crash background service : cible <0.1% --- ## Vidéo Démo Play Store (Script) **Durée** : 25 secondes **Format** : MP4 1080p, portrait **Voix off** : Optionnel **Storyboard** : | Seconde | Écran | Action | Texte Overlay | |---------|-------|--------|---------------| | 0-5 | Settings > Notifications | Scroll vers "Audio-guides piéton" | "Utilisateur active mode piéton" | | 5-8 | Toggle OFF → ON | Tap toggle | | | 8-15 | Écran d'éducation | Scroll, lire texte | "Écran explicatif affiché" | | 15-18 | Tap "Continuer" | Demande permission Android | "Permission arrière-plan demandée" | | 18-22 | Dialog Android | Tap "Toujours autoriser" | "Utilisateur accepte (optionnel)" | | 22-25 | Retour Settings | Toggle ON | "Mode piéton activé" | **Fichier** : `android/play-store-assets/background-location-demo.mp4` --- ## FAQ ### Q1 : Pourquoi ne pas demander "Always" dès le début ? **R** : Taux d'acceptation ~15% vs ~85% pour "When In Use". Strategy progressive maximise utilisateurs avec permissions. ### Q2 : Que se passe-t-il si user change permission dans Settings OS ? **R** : App détecte changement via `AppLifecycleState` et `permission_handler`. Si downgrade "Always" → "When In Use", mode piéton désactivé automatiquement avec notification in-app. ### Q3 : Est-ce que IP2Location suffit pour le MVP ? **R** : Non. Mode voiture nécessite GPS précis pour ETA et notifications géolocalisées (règle métier 05). IP2Location = fallback uniquement. ### Q4 : Combien de temps pour validation TestFlight/Play Store ? **R** : - TestFlight : 24-48h (review Apple) - Play Console Internal Testing : Immédiat (pas de review) - Play Console Production : 3-7 jours (review Google) --- ## Références - **ADR-010** : [Frontend Mobile](../adr/010-frontend-mobile.md) - **Règle 05** : [Mode Piéton](../regles-metier/05-interactions-navigation.md#512-mode-piéton-audio-guides) - **Règle 02** : [Conformité RGPD](../regles-metier/02-conformite-rgpd.md) - **Apple Guidelines** : [Location Best Practices](https://developer.apple.com/design/human-interface-guidelines/location) - **Android Guidelines** : [Request Background Location](https://developer.android.com/training/location/permissions#request-background-location) --- **Dernière mise à jour** : 2026-01-31 **Prochaine revue** : Après validation TestFlight (Sprint 3)