MFormations
Modern Mobile Engineering

Chapitre 21

21 - Corrections des exercices

21 - Corrections des exercices

21 - Corrections des exercices

Objectifs pédagogiques

À la fin de ce chapitre, vous serez capable de :

  1. Comparer votre solution à une solution de référence pour les 40 exercices
  2. Identifier les points de vigilance (null-safety, concurrence, fuites, sécurité)
  3. Comprendre le raisonnement derrière chaque corrigé
  4. Adopter les bonnes pratiques de production (tests, immutabilité, gestion d'erreurs)

Comment utiliser ce chapitre

  1. Tentez d'abord les exercices du chapitre 19 sans ouvrir ce fichier.
  2. Comparez ensuite, point par point, vos livrables avec les corrigés.
  3. Une différence n'est pas une faute : un corrigé est une référence, pas la vérité unique.
  4. Notez les points à améliorer et refaites l'exercice à J+7.

Section 1 — Kotlin & Coroutines (Exercices 1 à 4)

Corrigé de l'exercice 1 — Les bases du langage Kotlin

Points clés : data class immuable, enum avec propriété, extension function, null-safety sans !!.

enum class ParcelStatus(val label: String) {
    PENDING("En attente"), IN_TRANSIT("En transit"), DELIVERED("Livré")
}

data class Parcel(
    val id: String,
    val status: ParcelStatus,
    val destination: String,
    val estimatedDate: LocalDate
)

fun Parcel.isDelayed(today: LocalDate): Boolean =
    status != ParcelStatus.DELIVERED && estimatedDate.isBefore(today)

fun updateStatus(parcel: Parcel?, status: ParcelStatus): Parcel? =
    parcel?.copy(status = status)

Pourquoi c'est correct : les data classes sont immuables (copy plutôt que mutation), isDelayed est une extension pure (aucun effet de bord), et ?. chaîne l'appel uniquement si parcel n'est pas null — jamais d'exception NPE.

Tests attendus :

  • updateStatus(null, ...) retourne null
  • isDelayed est vrai si la date estimée est passée et le colis non livré
  • updateStatus préserve l'identifiant.

Pièges fréquents : utiliser !!, muter une data class (var), comparer des dates sans LocalDate.

Corrigé de l'exercice 2 — Coroutines : parallélisme contrôlé

Points clés : supervisorScope + async pour le parallélisme résilient, withTimeoutOrNull pour le délai.

suspend fun loadHome(): HomeUiState = supervisorScope {
    val profile = async { fetchProfile() }
    val notifications = async { fetchNotifications() }
    val weather = async { fetchWeather() }
    val weatherResult = withTimeoutOrNull(3_000) { weather.await() }
    HomeUiState(
        profile = profile.await(),
        notifications = notifications.await(),
        weather = weatherResult ?: Weather.Error
    )
}

Pourquoi c'est correct : dans supervisorScope, l'échec d'un enfant n'annule pas les autres — la météo peut échouer sans casser le profil ni les notifications. withTimeoutOrNull borne le temps d'attente : au-delà de 3 s, null et l'app continue.

Pièges fréquents : utiliser coroutineScope (échec d'un enfant = échec de tous), runBlocking en production, ou oublier withContext(Dispatchers.IO) pour les appels bloquants.

Corrigé de l'exercice 3 — Flow : flux de données réactifs

Points clés : debounce + flatMapLatest (recherche), catch qui réémet un état, StateFlow exposé en lecture seule.

val query = MutableStateFlow("")
val results: StateFlow<SearchState> = query
    .debounce(300)
    .distinctUntilChanged()
    .mapLatest { searchProduct(it) }
    .catch { emit(SearchState.Failure(it.message ?: "Erreur")) }
    .map<Result<List<Product>>, SearchState> { it.fold(...) }
    .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SearchState.Loading)

val uiState: StateFlow<SearchState> = results.asStateFlow()

Pourquoi c'est correct : mapLatest annule la recherche précédente dès qu'une nouvelle frappe arrive (les résultats obsolètes ne s'affichent pas), debounce évite un appel réseau par caractère, et stateIn partage le flux entre tous les collecteurs.

Pièges fréquents : collecter avec un Job non stocké (fuite), ne pas utiliser asStateFlow(), propager l'erreur au lieu de la capturer.

Corrigé de l'exercice 4 — Structure de données : tri et recherche

Points clés : merge sort O(n log n) stable, recherche binaire O(log n), mesure comparative.

fun <T : Comparable<T>> List<T>.mergeSort(): List<T> {
    if (size <= 1) return this
    val mid = size / 2
    return merge(left.subList(0, mid).mergeSort(), left.subList(mid, size).mergeSort())
}

fun <T : Comparable<T>> List<T>.binarySearch(target: T): Int? {
    var lo = 0; var hi = size - 1
    while (lo <= hi) {
        val mid = (lo + hi) / 2
        when { get(mid) < target -> lo = mid + 1
               get(mid) > target -> hi = mid - 1
               else -> return mid }
    }
    return null
}

Pourquoi c'est correct : merge sort est stable et garanti O(n log n). binarySearch de la stdlib est déjà optimisée (Collections.binarySearch) : l'implémentation sert à comprendre le mécanisme, pas à la remplacer en production.

Attendu du rapport : sur 10 000 éléments, la stdlib est typiquement 5 à 10× plus rapide (implémentation native, moins d'allocation). Le tri en O(n log n) vs O(n²) d'un tri naïf.


Section 2 — Jetpack Compose (Exercices 5 à 8)

Corrigé de l'exercice 5 — Écran de connexion

Points clés : état avec rememberSaveable, validation dérivée, OutlinedTextField avec isError, bouton désactivé.

@Composable
fun LoginScreen(onLogin: (String, String) -> Unit) {
    var email by rememberSaveable { mutableStateOf("") }
    var password by rememberSaveable { mutableStateOf("") }
    val emailValid = Patterns.EMAIL_ADDRESS.matcher(email).matches()
    val passwordValid = password.length >= 8
    val canSubmit = emailValid && passwordValid
    OutlinedTextField(
        value = email, onValueChange = { email = it },
        isError = email.isNotEmpty() && !emailValid,
        supportingText = { if (email.isNotEmpty() && !emailValid) Text("Email invalide") }
    )
    Button(onClick = { onLogin(email, password) }, enabled = canSubmit) { Text("Se connecter") }
}

Pourquoi c'est correct : rememberSaveable survit à la rotation (pas remember seul), la validation est dérivée de l'état (aucun état de plus), et isError + supportingText suivent le pattern Material 3.

Pièges fréquents : stocker isValid dans un état (source de désync), ne pas sauvegarder l'état lors de la rotation, ignorer onLogin en paramètre (stateless pour la logique).

Corrigé de l'exercice 6 — État et hoisting

Points clés : composable stateless ArticleRow, état remonté dans CartScreen, total dérivé.

@Composable
fun ArticleRow(article: Article, quantity: Int, onChange: (Int) -> Unit) {
    Card {
        Row {
            Text(article.name)
            IconButton(onClick = { onChange(quantity - 1) }) { Icon(Icons.Default.Remove) }
            Text("$quantity")
            IconButton(onClick = { onChange(quantity + 1) }) { Icon(Icons.Default.Add) }
        }
    }
}

@Composable
fun CartScreen() {
    var quantities by remember { mutableStateOf(mapOf<Int, Int>()) }
    val total = quantities.entries.sumOf { (id, qty) ->
        articles.first { it.id == id }.price * qty
    }
}

Pourquoi c'est correct : ArticleRow ne possède aucun état → testable et réutilisable. L'état vit dans CartScreen, le total est un dérivé recalculé à chaque changement. La signature onChange évite le couplage (le parent décide quoi faire).

Corrigé de l'exercice 7 — Liste + LazyColumn

Points clés : clés stables, état de scroll, animateScrollToItem, délégation pour la recherche.

@Composable
fun ContactsScreen() {
    val listState = rememberLazyListState()
    val filtered = remember(contacts, query) { contacts.filter { it.name.contains(query, true) } }
    val showFab by remember {
        derivedStateOf { listState.firstVisibleItemIndex > 200 }
    }
    LazyColumn(state = listState, contentType = { contacts[it].id }) {
        items(filtered, key = { it.id }) { ContactRow(it) }
    }
    AnimatedVisibility(visible = showFab) {
        FloatingActionButton(onClick = { scope.launch { listState.animateScrollToItem(0) } }) {
            Icon(Icons.Default.KeyboardArrowUp, contentDescription = "Haut de page")
        }
    }
}

Pourquoi c'est correct : derivedStateOf évite de recomposer tout l'écran à chaque frame de scroll, les clés stables (key) évitent les fausses re-créations, contentType permet à Compose de ne pas re-layout tout l'item lors d'un scroll mixte.

Corrigé de l'exercice 8 — Thème dynamique

Points clés : darkColorScheme/lightColorScheme, dynamicDarkColorScheme, thème à chaud.

@Composable
fun AppTheme(darkTheme: Boolean, dynamicColor: Boolean, content: @Composable () -> Unit) {
    val colorScheme = when {
        dynamicColor && Build.VERSION.SDK_INT >= 31 ->
            if (darkTheme) dynamicDarkColorScheme(LocalContext.current)
            else dynamicLightColorScheme(LocalContext.current)
        darkTheme -> DarkColors
        else -> LightColors
    }
    MaterialTheme(colorScheme = colorScheme, typography = Typography, content = content)
}

Pourquoi c'est correct : le thème est piloté par l'état (un StateFlow<Boolean> depuis les réglages), ce qui permet de changer de thème à chaud sans relancer l'app. dynamicColor applique la personnalisation Android 12+ (Material You) sur les appareils compatibles.


Section 3 — Swift & async/await (Exercices 9 à 11)

Corrigé de l'exercice 9 — Types et protocol

Points clés : enum à valeurs associées, protocol Processable, Decimal pour les montants.

enum PaymentMethod {
    case card(Card)
    case payPal(email: String)
    case wireTransfer(bic: String)
}

struct Payment: Processable {
    let amount: Decimal
    let currency: String
    let method: PaymentMethod

    func process() async throws -> PaymentReceipt {
        switch method {
        case .card(let card): return try await chargeCard(card)
        case .payPal(let email): return try await chargePayPal(email)
        case .wireTransfer(let bic): return try await transfer(bic)
        }
    }
}

Pourquoi c'est correct : l'enum à valeurs associées modélise proprement une union de cas sans héritage, Decimal évite les erreurs d'arrondi des Double pour l'argent, et le switch exhaustif force le traitement de tous les cas (le compilateur protège).

Corrigé de l'exercice 10 — async/await parallèle

Points clés : async let parallèle, withThrowingTaskGroup pour continuer malgré une erreur, actor pour la ressource partagée.

func loadDashboard() async throws -> Dashboard {
    async let balance = try loadBalance()
    async let transactions = try loadTransactions()
    async let budget = try loadBudget()
    async let goal = try loadGoal()
    return try await Dashboard(
        balance: balance, transactions: transactions,
        budget: budget, goal: goal
    )
}

Version résiliente avec TaskGroup :

func loadDashboardResilient() async -> Dashboard {
    await withTaskGroup(of: WidgetResult.self) { group in
        for source in allSources {
            group.addTask { await source.loadWithFallback() }
        }
        var widgets: [Widget] = []
        for await result in group { widgets.append(result.widget) }
        return Dashboard(widgets: widgets)
    }
}

Pourquoi c'est correct : async let lance les 4 appels concurremment puis attend tous. La TaskGroup permet de continuer même si une source échoue. L'actor protège le compteur de requêtes des courses.

Corrigé de l'exercice 11 — Actors et data race

Points clés : état mutable isolé dans un actor, éviction LRU, test de concurrence.

actor InMemoryCache {
    private var storage: [Key: Value] = [:]
    private var order: [Key] = []
    private let capacity: Int

    func value(for key: Key) -> Value? {
        guard let v = storage[key] else { return nil }
        order.removeAll { $0 == key }
        order.append(key)
        return v
    }

    func insert(_ value: Value, for key: Key) {
        if storage[key] == nil && storage.count >= capacity {
            let evicted = order.removeFirst()
            storage[evicted] = nil
        }
        storage[key] = value
        order.removeAll { $0 == key }
        order.append(key)
    }
}

Pourquoi c'est correct : l'actor sérialise l'accès à storage/order : plus aucune course de données possible même avec 1000 tâches concurrentes. Le test de concurrence lance les insertions dans une TaskGroup et vérifie ensuite l'intégrité.


Section 4 — SwiftUI (Exercices 12 à 14)

Corrigé de l'exercice 12 — Liste de tâches

Points clés : @State local, List + ForEach, onDelete/onMove, sheet.

struct TasksView: View {
    @State private var tasks: [Task] = []
    @State private var showAdd = false

    var body: some View {
        NavigationStack {
            List {
                ForEach($tasks) { $task in
                    TaskRow(task: $task)
                }
                .onDelete { tasks.remove(atOffsets: $0) }
                .onMove { tasks.move(fromOffsets: $0, toOffset: $1) }
            }
            .toolbar { EditButton() }
            .navigationTitle("Tâches (\(remainingCount))")
        }
    }
}

Pourquoi c'est correct : ForEach($tasks) lie chaque tâche à sa ligne (cocher une case modifie le modèle), remainingCount est un dérivé, le sheet reste un détail de présentation. Aucune persistance ici : l'exercice 26 la branchera.

Corrigé de l'exercice 13 — ViewModel ObservableObject

Points clés : @Observable (macro Swift), @MainActor, conversion testable.

@MainActor
@Observable
final class CurrencyViewModel {
    var amount: Decimal = 0 { didSet { convert() } }
    var from: Currency = .eur { didSet { convert() } }
    var to: Currency = .usd { didSet { convert() } }
    private(set) var result: Decimal = 0

    private func convert() {
        result = Currency.convert(amount, from: from, to: to)
    }
}

Pourquoi c'est correct : @Observable (Swift 5.9+) remplace ObservableObject/@Published avec moins de boilerplate et de meilleures performances. Le calcul est isolé dans une fonction pure Currency.convert → testable sans UI. @MainActor garantit la sécurité de thread de l'état UI.

Corrigé de l'exercice 14 — Animations

Points clés : matchedGeometryEffect, transitions, TimelineView, respect de reduceMotion.

@State private var expanded = false
@Namespace private var avatarNamespace

Image(systemName: "person.circle.fill")
    .matchedGeometryEffect(id: "avatar", in: avatarNamespace)
    .scaleEffect(expanded ? 1.4 : 1.0)
    .onTapGesture {
        withAnimation(expanded ? .snappy : .bouncy) { expanded.toggle() }
    }

Pourquoi c'est correct : matchedGeometryEffect anime le déplacement/redimensionnement entre deux positions partagées par le même Namespace. Les transitions .move + .opacity rendent le repli naturel. accessibilityReduceMotion est lu dans l'environnement pour désactiver les animations si l'utilisateur le demande.


Section 5 — React Native Hooks (Exercices 15 à 17)

Corrigé de l'exercice 15 — Formulaire contrôlé

Points clés : un seul état objet, validation dérivée, debounce via useEffect.

const [form, setForm] = useState({ email: "", password: "", confirm: "" });
const isValid = isEmail(form.email) && form.password.length >= 8 &&
  form.password === form.confirm;

useEffect(() => {
  const timer = setTimeout(() => setShowInactiveTip(true), 2000);
  return () => clearTimeout(timer);
}, [form.email, form.password]);

Pourquoi c'est correct : isValid est un dérivé (recalculé à chaque rendu, pas stocké), évitant toute désynchronisation. Le setTimeout est nettoyé dans la fonction de cleanup : chaque changement d'input annule le précédent. TypeScript strict interdit les valeurs undefined non gérées.

Corrigé de l'exercice 16 — Custom hooks

Points clés : hooks factorisés, AbortController pour annuler les requêtes obsolètes.

export function useDebounce<T>(value: T, delay: number): T {
  const [debounced, setDebounced] = useState(value);
  useEffect(() => {
    const timer = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(timer);
  }, [value, delay]);
  return debounced;
}

export function useFetch<T>(url: string | null) {
  const [state, setState] = useState<FetchState<T>>({ status: "idle" });
  useEffect(() => {
    if (!url) return;
    const controller = new AbortController();
    setState({ status: "loading" });
    fetch(url, { signal: controller.signal })
      .then(r => r.json())
      .then(data => setState({ status: "success", data }))
      .catch(e => {
        if (e.name !== "AbortError") setState({ status: "error", error: e });
      });
    return () => controller.abort();
  }, [url]);
  return { ...state, refetch: () => setReload(u => u + 1) };
}

Pourquoi c'est correct : chaque hook encapsule une responsabilité et est réutilisable. AbortController dans le cleanup annule la requête obsolète lors d'un changement d'URL (résultats incohérents évités). Les états sont typés par une union discriminée.

Corrigé de l'exercice 17 — Mémorisation

Points clés : React.memo, useCallback, useMemo, mesures avec Profiler.

const toggleFavorite = useCallback((id: string) => {
  setFavorites(prev => prev.has(id)
    ? new Set([...prev].filter(x => x !== id))
    : new Set([...prev, id]));
}, []);

const sorted = useMemo(
  () => [...items].sort((a, b) => b.score - a.score),
  [items]
);
const Row = React.memo(ItemRow);

Pourquoi c'est correct : React.memo sur une ligne stable évite le re-render des 1000 lignes lors d'un changement de thème. useCallback stabilise l'identité du handler (sinon React.memo ne sert à rien). useMemo évite de re-trier à chaque rendu. Le rapport de mesures doit montrer le gain réel : sans mémorisation le Profiler enregistre des centaines de milliers de renders ; avec, quelques unités.


Section 6 — Flutter Widgets (Exercices 18 à 20)

Corrigé de l'exercice 18 — Carte de profil

Points clés : Stack pour le badge, CircleAvatar avec fallback, constructeurs const.

class ProfileCard extends StatelessWidget {
  const ProfileCard({super.key, required this.user});
  final User user;

  @override
  Widget build(BuildContext context) {
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Row(
          children: [
            Stack(children: [
              CircleAvatar(
                radius: 32,
                backgroundImage: NetworkImage(user.photoUrl),
                child: user.photoUrl.isEmpty ? const Text("AB") : null,
              ),
              const Positioned(right: 0, bottom: 0, child: OnlineBadge()),
            ]),
            const SizedBox(width: 16),
            Expanded(child: Column(...)),
          ],
        ),
      ),
    );
  }
}

Pourquoi c'est correct : Stack + Positioned superpose le badge au coin de l'avatar. Le fallback initiales s'affiche si l'image est absente. Tous les widgets statiques sont const (créés une seule fois). Le composant reste stateless : le Switch est remonté.

Corrigé de l'exercice 19 — ListView + état

Points clés : ListView.builder, Dismissible, extraction de l'état dans un ValueNotifier.

class ChecklistController extends ChangeNotifier {
  final List<TodoItem> _items = [];
  void toggle(int index) { _items[index].done = !_items[index].done; notifyListeners(); }
  void remove(int index) { _items.removeAt(index); notifyListeners(); }
}

ListView.builder(
  itemCount: controller.items.length,
  itemBuilder: (context, index) {
    final item = controller.items[index];
    return Dismissible(
      key: ValueKey(item.id),
      direction: DismissDirection.endToStart,
      onDismissed: (_) => controller.remove(index),
      background: Container(color: Colors.red, alignment: Alignment.centerRight),
      child: CheckboxListTile(
        value: item.done,
        onChanged: (_) => controller.toggle(index),
        title: Text(item.title, style: TextStyle(
          decoration: item.done ? TextDecoration.lineThrough : null)),
      ),
    );
  },
)

Pourquoi c'est correct : l'état sort du widget (contrôleur) → testable et partageable. Dismissible exige une clé stable (ValueKey(item.id), pas l'index). Le builder ne construit que les lignes visibles : la liste de 200 éléments reste fluide.

Corrigé de l'exercice 20 — Thème et responsive

Points clés : ThemeData, LayoutBuilder/GridView adaptatif, persistance du thème.

final theme = ThemeData(colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal));
final darkTheme = ThemeData(colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal, brightness: Brightness.dark));

Widget build(BuildContext context) {
  final width = MediaQuery.sizeOf(context).width;
  final columns = width >= 900 ? 4 : (width >= 600 ? 3 : 2);
  return GridView.builder(
    gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: columns),
    ...
  );
}

Pourquoi c'est correct : MediaQuery.sizeOf (remplace MediaQuery.of) réagit aux changements de taille (rotation, fenêtrage). Le nombre de colonnes est un dérivé de la largeur. Le choix du thème est persisté via shared_preferences et appliqué en racine de l'app par MaterialApp(theme:, darkTheme:).


Section 7 — Architecture MVVM (Exercices 21 à 24)

Corrigé de l'exercice 21 — MVVM Android

Points clés : état StateFlow immuable, méthodes d'intention, rotation sûre.

class CartViewModel : ViewModel() {
    private val _state = MutableStateFlow(CartUiState())
    val state: StateFlow<CartUiState> = _state.asStateFlow()

    fun changeQuantity(articleId: Int, delta: Int) {
        _state.update { s ->
            val newQty = (s.quantities[articleId] ?: 0) + delta
            s.copy(quantities = if (newQty <= 0) s.quantities - articleId
                                else s.quantities + (articleId to newQty))
        }
    }
}

Pourquoi c'est correct : l'état est une data class immuable mise à jour par copy + update (atomic). Le composable appelle viewModel.changeQuantity(...) : l'UI ne contient plus aucune logique. Le ViewModel survit à la rotation → l'état du panier est préservé sans persistance.

Corrigé de l'exercice 22 — Clean Architecture

Points clés : 3 couches, dépendances inversées, DI.

app/
  domain/
    model/Quote.kt          (entité pure)
    repository/QuoteRepository.kt   (interface)
    usecase/GetQuoteOfTheDay.kt     (orchestration)
  data/
    remote/QuoteRemoteDataSource.kt (Retrofit)
    local/QuoteLocalDataSource.kt   (Room)
    repository/QuoteRepositoryImpl.kt (combine les deux)
  presentation/
    viewmodel/QuoteViewModel.kt

Pourquoi c'est correct : le domain ne connaît ni Retrofit ni Room (dépendances inversées via l'interface QuoteRepository). Le data implémente l'interface. Le use case GetQuoteOfTheDay exprime la règle : « va chercher la citation du jour, mets-la en cache ». Tests : on injecte un FakeQuoteRepository dans le use case.

Corrigé de l'exercice 23 — MVI

Points clés : intentions scellées, réduction d'état, effets uniques.

sealed interface PaymentIntent {
    data object ChooseAmount : PaymentIntent
    data class SetAmount(val amount: Decimal) : PaymentIntent
    data class Pay(val method: PaymentMethod) : PaymentIntent
    data object Retry : PaymentIntent
}

data class PaymentState(
    val step: Step = Step.Amount,
    val amount: Decimal = 0.0.toDecimal(),
    val processing: Boolean = false,
    val error: String? = null,
    val receipt: PaymentReceipt? = null
)

sealed interface PaymentEffect {
    data object NavigateToReceipt : PaymentEffect
    data class ShowError(val message: String) : PaymentEffect
}

fun reduce(intent: PaymentIntent) {
    when (intent) {
        PaymentIntent.Pay -> { _state.update { it.copy(processing = true) }; pay() }
        PaymentIntent.Retry -> { retry() }
    }
}

Pourquoi c'est correct : chaque interaction est une intention ; l'état ne se modifie que dans le when (flux unidirectionnel). Les Effect (navigation, toast) sont émis une seule fois — contrairement à l'état, ils ne doivent pas survivre à une rotation. Tests : on envoie des intentions et on vérifie les états/effets produits.

Corrigé de l'exercice 24 — Multi-module Gradle

Points clés : version catalog, modules par feature, visibilité, configuration cache.

settings.gradle.kts  → include(":app", ":core:ui", ":core:network", ":feature:cart", ":feature:checkout")
libs.versions.toml   → [versions] kotlin=2.0.x ; [libraries] androidx-compose = ...
:feature:cart → depends on :core:network, :core:ui
:core:ui      → depends on nothing (pure UI kit)

Pourquoi c'est correct : les modules feature dépendent des modules core, jamais l'inverse (pas de cycle). Les symboles partagés sont internal au module ou public seulement si nécessaires (API surface minimale). Le configuration cache (org.gradle.configuration-cache=true) réduit le temps de build de 30 à 50 % sur les gros projets.


Section 8 — Room & CoreData (Exercices 25 à 27)

Corrigé de l'exercice 25 — Room

Points clés : entités relationnelles, @Relation, Flow, migration versionnée.

@Entity(tableName = "categories")
data class CategoryEntity(@PrimaryKey val id: Long, val name: String)

@Entity(tableName = "notes",
  foreignKeys = [ForeignKey(entity = CategoryEntity::class,
    parentColumns = ["id"], childColumns = ["categoryId"], onDelete = CASCADE)],
  indices = [Index("categoryId")])
data class NoteEntity(@PrimaryKey(autoGenerate = true) val id: Long,
                      val title: String, val categoryId: Long)

data class NoteWithCategory(
    @Embedded val note: NoteEntity,
    @Relation(parentColumn = "categoryId", entityColumn = "id")
    val category: CategoryEntity
)

@Dao interface NoteDao {
    @Query("SELECT * FROM notes") fun observeAll(): Flow<List<NoteWithCategory>>
    @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(note: NoteEntity)
}

Pourquoi c'est correct : la FK + index garantit l'intégrité et la rapidité des jointures. Flow rend les données réactives (l'UI se met à jour seule). La migration (version 1 → 2, ajout de colonne) se teste avec MigrationTestHelper : le schéma migré doit être identique au schéma déclaré.

Corrigé de l'exercice 26 — CoreData

Points clés : container, contexte de vue, @FetchRequest, sauvegarde gérée.

final class TaskStore {
    static let shared = TaskStore()
    private let container: NSPersistentContainer

    init() {
        container = NSPersistentContainer(name: "TaskModel")
        container.loadPersistentStores { _, error in
            precondition(error == nil, "Échec CoreData: \(error!)")
        }
    }

    @MainActor
    func addTask(title: String, priority: Priority) throws {
        let context = container.viewContext
        let task = TaskEntity(context: context)
        task.id = UUID()
        task.title = title
        task.priorityRaw = priority.rawValue
        task.done = false
        try context.save()
    }
}

Pourquoi c'est correct : le container charge le modèle et le store, le contexte de vue est utilisé sur @MainActor (CoreData n'est pas thread-safe), et chaque opération est une fonction async/throwing que l'UI peut appeler avec try. @FetchRequest dans la vue met la liste à jour automatiquement.

Corrigé de l'exercice 27 — Synchronisation offline-first

Points clés : file d'opérations, rejeu ordonné, conflit par horodatage.

1. Chaque mutation locale → table pending_operations (type, params, timestamp).
2. WorkManager/BackgroundTask déclenche la synchronisation au retour du réseau.
3. Rejeu des opérations dans l'ordre du timestamp.
4. Conflit : on compare lastModified local vs serveur → la plus récente gagne.
5. État exposé : syncing / pending(n) / upToDate via StateFlow.

Pourquoi c'est correct : l'utilisateur peut travailler hors-ligne sans perte : rien n'est appliqué au serveur avant la reprise du réseau. La file garantit l'ordre. Le timestamp évite d'écraser une modification serveur plus récente. Le test clé : modifier hors-ligne → reconnecter → vérifier la convergence des deux côtés.


Section 9 — Networking (Exercices 28 à 30)

Corrigé de l'exercice 28 — Retrofit

Points clés : API typée, intercepteurs, erreurs mappées, Resource<T>.

interface GithubApi {
    @GET("users/{user}/repos")
    suspend fun listRepos(@Path("user") user: String): List<Repo>
}

class AuthInterceptor(private val token: String) : Interceptor {
    override fun intercept(chain: Interceptor.Chain): Response =
        chain.proceed(chain.request().newBuilder()
            .addHeader("Authorization", "token $token").build())
}

sealed interface ApiError {
    data object Network : ApiError
    data class Server(val code: Int) : ApiError
    data object Auth : ApiError
    data object NotFound : ApiError
}

Pourquoi c'est correct : Retrofit génère l'implémentation à partir de l'interface ; les intercepteurs (auth, logging) sont transparents pour la couche appelante ; les erreurs HTTP sont converties en un type scellé exhaustif (le when du ViewModel est exhaustif). MockWebServer permet de tester chaque statut sans réseau.

Corrigé de l'exercice 29 — URLSession

Points clés : API async native, Decodable, erreurs typées.

struct Article: Decodable, Identifiable {
    let id: Int
    let title: String
    let body: String
}

enum APIError: Error {
    case invalidResponse, badStatus(Int), decoding(Error)
}

func fetchArticles() async throws -> [Article] {
    var request = URLRequest(url: articlesURL)
    request.httpMethod = "GET"
    let (data, response) = try await URLSession.shared.data(for: request)
    guard let http = response as? HTTPURLResponse else { throw APIError.invalidResponse }
    guard (200..<300).contains(http.statusCode) else { throw APIError.badStatus(http.statusCode) }
    return try JSONDecoder().decode([Article].self, from: data)
}

Pourquoi c'est correct : data(for:) suspend la tâche sans bloquer le thread. Le décodage est typé (Decodable). On vérifie explicitement le statut HTTP (URLSession n'échoue pas sur un 404). L'erreur typée permet à l'UI d'afficher un message précis.

Corrigé de l'exercice 30 — WebSocket

Points clés : reconnexion exponentielle avec jitter, AsyncStream/Flow.

suspend fun connect() = withContext(Dispatchers.IO) {
    var attempt = 0
    while (isActive) {
        try {
            client.newWebSocket(request, listener)
            attempt = 0
            return@withContext
        } catch (e: Exception) {
            val delayMs = minOf(60_000L, 1_000L * (1L shl attempt))
            delay(delayMs + Random.nextLong(0, 500))
            attempt++
        }
    }
}

Pourquoi c'est correct : la reconnexion est exponentielle (1 s, 2 s, 4 s…) bornée à 60 s avec un jitter aléatoire (évite le « thundering herd » de plusieurs devices se reconnectant ensemble). Le bouclage est annulable (isActive). Les messages sont exposés via Flow/AsyncStream pour que l'UI soit réactive.


Section 10 — Testing (Exercices 31 à 34)

Corrigé de l'exercice 31 — Stratégie et edge cases

Points clés : table de cas, naming convention, couverture.

Cas à couvrir :
- coupon = null        → 0 %
- coupon = "SAVE10"    → 10 %
- coupon = "VIP20"     → 20 %
- coupon inconnu       → 0 %
- prix = 0             → 0 € de remise
- prix = MAX           → remise plafonnée (pas de dépassement)
- prix négatif         → géré (0 ou exception documentée)

Nommage : calculateDiscount_noCoupon_returnsZero
         calculateDiscount_vip20_returnsTwentyPercent

Pourquoi c'est correct : la table de cas couvre les valeurs limites (0, max, négatif) et les branches. La convention method_condition_expected rend l'échec d'un test immédiatement compréhensible. JaCoCo/XcodeCoverage ≥ 90 % sur cette fonction pure est atteignable et significatif.

Corrigé de l'exercice 32 — Tests d'UI

Compose :

@Test
fun loginScreen_invalidEmail_showsError() {
    setContent { LoginScreen(onLogin = { _, _ -> }) }
    onNodeWithTag("email").performTextInput("bad")
    onNodeWithTag("errorEmail").assertIsDisplayed()
    onNodeWithTag("submit").assertIsNotEnabled()
}

XCUITest :

func testInvalidEmailShowsError() {
    let email = app.textFields["emailField"]
    email.tap()
    email.typeText("bad")
    XCTAssertTrue(app.staticTexts["Email invalide"].waitForExistence(timeout: 2))
    XCTAssertFalse(app.buttons["Se connecter"].isEnabled)
}

Pourquoi c'est correct : les testTag/accessibility identifiers rendent les tests robustes (indépendants du texte affiché). On vérifie l'état observable de l'UI (message, désactivation du bouton), pas des détails d'implémentation.

Corrigé de l'exercice 33 — Fakes et data layer

Points clés : fakes configurés, Room in-memory, temps de test borné.

class FakeQuoteRepository : QuoteRepository {
    var shouldFail = false
    var quotes: List<Quote> = emptyList()
    override suspend fun getQuoteOfTheDay(): Quote {
        if (shouldFail) throw IOException("network")
        return quotes.random()
    }
}

// Room in-memory :
val db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java).build()

Pourquoi c'est correct : le fake est configurable (on force l'échec ou le succès) → on teste le ViewModel dans tous les scénarios sans réseau ni disk I/O. La base in-memory est plus rapide et détruite automatiquement. En bornant le temps (< 30 s) on garde des feedbacks rapides.

Corrigé de l'exercice 34 — E2E Maestro

appId: com.example.app
---
- launchApp
- tapOn: "Se connecter"
- inputText: "demo@test.com"
- tapOn: "Mot de passe"
- inputText: "password123"
- tapOn: "Connexion"
- assertVisible: "Tableau de bord"
- waitForAnimationToEnd
- takeScreenshot: "home"
- tapOn: "Profil"
- assertVisible: "Déconnexion"

Pourquoi c'est correct : Maestro orchestre de vrais taps sur le device (comme un utilisateur). En CI (GitHub Actions), le flow s'exécute sur un émulateur dans le pipeline : régression fonctionnelle à chaque commit. waitForAnimationToEnd évite les flakiness liées aux animations.


Section 11 — Performance (Exercices 35 à 37)

Corrigé de l'exercice 35 — Profilage et jank

Méthode :

  1. Reproduire le ralentissement (liste avec chargement synchrone dans onDraw).
  2. Mesurer avec GPU Rendering (Android) / Core Animation (Instruments) : frames > 16 ms visibles.
  3. Corriger : déplacer le décodage hors du main thread (Coil/Glide le font déjà), mémoriser les items (remember), réduire les allouations dans onDraw.
  4. Re-mesurer : toutes les frames doivent passer sous 16 ms (60 fps).

Pourquoi c'est correct : le profiling sert d'abord à localiser le gouffre (données, layout, dessin) avant de corriger — optimiser au hasard est contre-productif. Le rapport doit montrer la courbe avant/après.

Corrigé de l'exercice 36 — Démarrage à froid

Méthode :

  1. adb shell am start -W mesure le temps de démarrage ; MetricKit (iOS) agrège les métriques des users.
  2. Localiser les travaux bloquants : init de DI lourde, lecture de préférences, décodage d'images.
  3. Appliquer : initialisation paresseuse, WorkManager pour les tâches différées, placeholder pour l'UI, splash minimal.
  4. Cible : < 1,5 s sur device de référence.

Pourquoi c'est correct : le cold start est perçu par l'utilisateur à chaque lancement : toute milliseconde gagnée améliore la rétention. On retarde ce qui n'est pas nécessaire à l'affichage du premier écran.

Corrigé de l'exercice 37 — Mémoire

Méthode :

  1. Fuite volontaire : un listener enregistré mais jamais retiré (ex. observer du cycle de vie non déréférencé).
  2. Détection : LeakCanary (Android) affiche la chaîne de référence du leak ; Instruments → Leaks (iOS).
  3. Correction : retirer le listener dans onDestroy/deinit, ou utiliser des WeakReference/callbacks limités.
  4. Images : Coil/SDWebImage avec cache mémoire + disque et redimensionnement (size), comparer le heap.

Pourquoi c'est correct : une fuite = mémoire jamais libérée → crash OOM après des cycles de navigation. Le cache mémoire partagé (LRU) évite de décoder plusieurs fois la même image. Le redimensionnement avant décodage divise souvent l'empreinte par 10.


Section 12 — Sécurité (Exercices 38 à 40)

Corrigé de l'exercice 38 — Chiffrement

Points clés : Keystore/Keychain pour la clé, AES-GCM pour les données, secret hors fichiers clairs.

// Android : EncryptedSharedPreferences (clé dans Android Keystore)
val prefs = EncryptedSharedPreferences.create(
    "secure_prefs", masterKeyAlias, context, PrefKeyEncryptionScheme.AES256_SIV,
    PrefValueEncryptionScheme.AES256_GCM
)
prefs.edit().putString("api_token", token).apply()
// iOS : Keychain
let item: [String: Any] = [
    kSecClass as String: kSecClassGenericPassword,
    kSecAttrAccount as String: "api_token",
    kSecValueData as String: Data(token.utf8)
]
SecItemAdd(item as CFDictionary, nil)

Pourquoi c'est correct : la clé de chiffrement vit dans le Keystore/Keychain (matériel ou isolée par le système), jamais dans le code ni en clair dans les préférences. AES-GCM authentifie les données (intégrité + confidentialité). Le test de redémarrage vérifie que le secret survit au cycle de l'app.

Corrigé de l'exercice 39 — Durcir le trafic

Points clés : HTTPS par défaut, exceptions minimales, pinning sur les domaines critiques.

<!-- res/xml/network_security_config.xml -->
<network-security-config>
    <base-config cleartextTrafficPermitted="false" />
    <domain-config cleartextTrafficPermitted="true">
        <domain includeSubdomains="true">dev.local</domain>
    </domain-config>
</network-security-config>
<!-- iOS Info.plist : ATS strict -->
<key>NSAppTransportSecurity</key>
<dict><key>NSAllowsArbitraryLoads</key><false/></dict>

Pourquoi c'est correct : cleartextTrafficPermitted=false bloque tout HTTP en clair (fail-fast en dev). L'exception est nominale et temporaire (on la supprime ensuite). mitmproxy valide que l'interception échoue sans certificat racine approuvé. Le pinning (certificate pin) empêche même un CA compromis d'intercepter.

Corrigé de l'exercice 40 — Audit OWASP MASVS

Méthode :

  1. Choisir 8 exigences (ex. MASVS-STORAGE-1, MASVS-CRYPTO-1, MASVS-NETWORK-1, MASVS-PLATFORM-1…).
  2. Tester sur une app de démo : désassembler (jadx/MobSF), vérifier le stockage, intercepter le trafic.
  3. Rapport : pour chaque exigence → statut (conforme / partiel / non conforme), preuve, sévérité (CVSS-like), remédiation.
  4. Prioriser par la matrice impact × probabilité.

Pourquoi c'est correct : un audit n'est utile que s'il est traçable (preuves) et actionnable (remédiations priorisées). Le MASVS donne un langage commun pour prioriser : stockage des données, crypto, réseau, code et plateforme. Exemple : un token en clair dans SharedPreferences = risque élevé immédiat → remédiation Keystore.


Grille d'auto-évaluation finale

CritèreAtteint si…
Qualité du codeLisible, testé, sans duplication flagrante
Bonnes pratiquesPas de !!, pas de runBlocking prod, pas de secret en dur
TestsChaque exercice de test passe et est significatif
AutonomieVous avez réussi ≥ 30 exercices avant de lire ce chapitre
ProgressionVous pouvez expliquer chaque corrigé avec vos mots

Pour aller plus loin

  1. Comparez vos solutions avec les corrigés de vos pairs (code review croisée).
  2. Pour chaque exercice, notez la difficulté ressentie : si un niveau ⭐ a pris plus de 2 h, revoyez le chapitre correspondant.
  3. Reproduisez les exercices 35 à 40 en inversant la plateforme (Android → iOS) pour valider la maîtrise des deux écosystèmes.