Skip to content

Foreground service

Matchelou38 edited this page Mar 12, 2026 · 1 revision

Architecture K3AudioType — Navigation audio, service de premier plan et permissions

Table des matières

  1. Vue d'ensemble
  2. Le canal clavier — K3AppState
  3. Le service d'accessibilité — K3AccessibilityService
  4. Demande de permission à l'utilisateur
  5. Le service de premier plan — TypingForegroundService
  6. Navigation pilotée par l'audio — flux complet
  7. Problèmes rencontrés et solutions

1. Vue d'ensemble

K3AudioType est une application d'entraînement à la frappe guidée par synthèse vocale (TTS). L'utilisateur écoute des phrases dictées et les retape. Toute la navigation est pilotable au clavier physique (Bluetooth ou USB-OTG), y compris lorsque l'écran est verrouillé.

Pour rendre cela possible, trois mécanismes travaillent ensemble :

  • K3AppState — un objet singleton qui centralise les événements clavier dans un Channel Kotlin, accessible depuis n'importe quel écran Compose.
  • K3AccessibilityService — un service d'accessibilité Android qui intercepte les touches physiques avant qu'elles n'atteignent le système de verrouillage.
  • TypingForegroundService — un service de premier plan qui maintient le processus de l'application vivant pendant qu'une session de frappe est en cours, même quand l'écran est éteint.
Clavier physique
      │
      ▼
K3AccessibilityService.onKeyEvent()
      │  émet dans
      ▼
K3AppState.keyChannel  ◄──── resetKeyChannel() à chaque navigation
      │
      │  collecté par
      ▼
LaunchedEffect de chaque écran Compose
(HomeScreen, CustomGameScreen, TextListScreen, TypingScreen)

2. Le canal clavier — K3AppState

Pourquoi un Channel et non un SharedFlow ?

Un SharedFlow avec replay > 0 rejoue les dernières valeurs à chaque nouveau collecteur. Lors d'une transition de navigation Compose, le nouvel écran aurait immédiatement reçu la touche ENTRÉE qui venait de déclencher la navigation — provoquant une double action non souhaitée.

Un Channel ne rejoue rien : chaque événement est consommé une seule fois par un seul collecteur. De plus, en recréant le channel à chaque navigation (resetKeyChannel()), on garantit que les collecteurs des écrans en cours de destruction ne recevront plus aucun événement destiné au nouvel écran.

object K3AppState {
@Volatile
var keyChannel: Channel<K3KeyInput> = Channel(Channel.UNLIMITED)
    private set

@Volatile var isInTypingMode: Boolean = false
@Volatile var isServiceConnected: Boolean = false

/**
 * À appeler juste avant toute navigation déclenchée par une touche.
 * L'ancien channel est abandonné — plus aucune touche ne lui sera envoyée.
 * Les LaunchedEffect des anciens écrans se suspendent indéfiniment sur
 * receive() puis sont annulés par Compose quand l'écran est détruit.
 */
fun resetKeyChannel() {
    keyChannel = Channel(Channel.UNLIMITED)
}

fun emitKey(keyCode: Int, unicodeChar: Int = 0) {
    keyChannel.trySend(K3KeyInput(keyCode, unicodeChar))
}

}

Pourquoi @Volatile ?

isServiceConnected et isInTypingMode sont lus depuis le thread principal et écrits depuis le thread du service d'accessibilité (un thread système). Sans @Volatile, la JVM pourrait lire une valeur mise en cache dans un registre CPU plutôt que la valeur réelle en mémoire. @Volatile force la lecture directe depuis la RAM à chaque accès.

Lecture depuis les écrans Compose

Chaque écran qui doit réagir aux touches utilise un LaunchedEffect avec une boucle for sur le channel :

LaunchedEffect("keys") {
    for (event in model.keyChannel) {
        when (event.keyCode) {
            KeyEvent.KEYCODE_ENTER -> { /* naviguer */ }
            KeyEvent.KEYCODE_S     -> { /* paramètres */ }
        }
    }
}

La boucle for se suspend automatiquement quand le channel est vide et reprend dès qu'un événement arrive. Quand resetKeyChannel() est appelé, le channel courant n'est plus alimenté : la boucle se suspend définitivement, puis la coroutine est annulée proprement par Compose lors de la destruction de l'écran.


3. Le service d'accessibilité — K3AccessibilityService

Pourquoi un service d'accessibilité ?

Sur Android, quand l'écran est verrouillé, les événements clavier HID (Bluetooth, USB-OTG) ne sont pas transmis aux activités normales. Seul un service d'accessibilité avec le flag FLAG_REQUEST_FILTER_KEY_EVENTS peut les intercepter dans ce contexte.

Implémentation

class K3AccessibilityService : AccessibilityService() {
override fun onServiceConnected() {
    serviceInfo = serviceInfo.apply {
        eventTypes = 0  // on ne s'intéresse à aucun événement d'accessibilité
        flags = flags or AccessibilityServiceInfo.FLAG_REQUEST_FILTER_KEY_EVENTS
    }
    K3AppState.isServiceConnected = true
}

override fun onKeyEvent(event: KeyEvent): Boolean {
    if (event.action != KeyEvent.ACTION_DOWN) return false
    if (event.repeatCount > 0) return false  // ignorer les appuis longs répétés

    val unicode = event.getUnicodeChar(event.metaState)
    K3AppState.emitKey(event.keyCode, unicode)
    return true  // toujours consommé — obligatoire pour fonctionner sur écran verrouillé
}

override fun onUnbind(intent: Intent?): Boolean {
    K3AppState.isServiceConnected = false
    return super.onUnbind(intent)
}

}

Points importants :

  • return true est impératif. Si on retourne false, Android considère que la touche n'a pas été gérée et la transmet au système de verrouillage, ce qui peut interférer avec la navigation.
  • event.getUnicodeChar(event.metaState) tient compte des modificateurs (Shift, CapsLock) pour produire le bon caractère. Sans metaState, getUnicodeChar() retourne toujours le caractère en minuscule.
  • Le flag isServiceConnected permet à MainActivity.onKeyDown() de savoir s'il doit émettre lui-même ou non (voir section 7).

Déclaration dans le manifeste

<service
    android:name=".main.K3AccessibilityService"
    android:exported="true"
    android:label="K3AudioType"
    android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">
    <intent-filter>
        <action android:name="android.accessibilityservice.AccessibilityService" />
    </intent-filter>
    <meta-data
        android:name="android.accessibilityservice"
        android:resource="@xml/k3_accessibility_service" />
</service>

4. Demande de permission à l'utilisateur

Un service d'accessibilité ne peut pas être activé par le code : c'est l'utilisateur qui doit l'activer manuellement dans les paramètres système. L'application détecte si le service est actif au démarrage, et affiche une boîte de dialogue non-annulable si ce n'est pas le cas.

Détection de l'état du service

private fun isAccessibilityServiceEnabled(context: Context): Boolean {
    val am = context.getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager
    return am.getEnabledAccessibilityServiceList(
        AccessibilityServiceInfo.FEEDBACK_ALL_MASK
    ).any { info ->
        info.resolveInfo.serviceInfo.packageName == context.packageName &&
        info.resolveInfo.serviceInfo.name == K3AccessibilityService::class.java.name
    }
}

On vérifie à la fois le packageName et le nom de la classe pour ne pas confondre notre service avec celui d'une autre application.

Ré-vérification à chaque reprise de l'activité

L'utilisateur peut activer le service depuis les paramètres système et revenir dans l'application. L'état est donc re-vérifié à chaque événement ON_RESUME du cycle de vie :

DisposableEffect(lifecycleOwner) {
    val observer = LifecycleEventObserver { _, event ->
        if (event == Lifecycle.Event.ON_RESUME) {
            serviceEnabled = isAccessibilityServiceEnabled(context)
        }
    }
    lifecycleOwner.lifecycle.addObserver(observer)
    onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}

Boîte de dialogue non-annulable

if (!serviceEnabled) {
    AlertDialog(
        onDismissRequest = { /* non-annulable : l'utilisateur doit activer le service */ },
        title = { Text("Autorisation requise") },
        text  = {
            Text(
                "K3AudioType a besoin d'être activé en tant que service " +
                "d'accessibilité pour recevoir les touches clavier même " +
                "lorsque l'écran est verrouillé.\n\n" +
                "Dans l'écran qui va s'ouvrir :\n" +
                "1. Trouvez « K3AudioType » dans la liste\n" +
                "2. Activez-le\n" +
                "3. Confirmez la permission"
            )
        },
        confirmButton = {
            Button(onClick = {
                context.startActivity(
                    Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS).apply {
                        flags = Intent.FLAG_ACTIVITY_NEW_TASK
                    }
                )
            }) {
                Text("Ouvrir les paramètres")
            }
        }
    )
}

Le onDismissRequest vide empêche la fermeture du dialogue en appuyant en dehors ou sur le bouton système Retour. L'utilisateur n'a pas d'autre choix que d'ouvrir les paramètres.


5. Le service de premier plan — TypingForegroundService

Problème résolu

Sur Android, une application dont l'écran est éteint peut être suspendue ou tuée par le système afin d'économiser de la batterie. Pendant une session de frappe, cela détruirait la session en cours et couperait le TTS. Un service de premier plan (foreground service) affiche une notification persistante et indique au système que l'application est activement utilisée — elle ne doit pas être suspendue.

Cycle de vie

Utilisateur appuie sur "Confirmer" dans TextListScreen
        │
        ▼
MainActivity lance le service (ContextCompat.startForegroundService)
        │
        ▼
TypingScreen actif — session en cours
        │
        ├── Utilisateur appuie sur "Quitter" → onBack()
        │         └── context.stopService(...)
        │
        └── Session terminée → onFinished()
                  └── context.stopService(...)

Notification persistante

class TypingForegroundService : Service() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
    val status = intent?.getStringExtra(EXTRA_STATUS) ?: STATUS_TYPING
    startForeground(NOTIFICATION_ID, buildNotification(status))
    return START_STICKY  // le service redémarre automatiquement si le système le tue
}

private fun buildNotification(status: String): Notification {
    return NotificationCompat.Builder(this, CHANNEL_ID)
        .setContentTitle("K3AudioType")
        .setContentText(status)
        .setSmallIcon(android.R.drawable.ic_btn_speak_now)
        .setOngoing(true)   // l'utilisateur ne peut pas swiper pour fermer
        .setSilent(true)    // pas de son à chaque mise à jour du statut
        .setPriority(NotificationCompat.PRIORITY_LOW)
        .build()
}

}

START_STICKY signifie que si le système tue le service (mémoire insuffisante), Android le redémarrera automatiquement. La notification réapparaîtra avec le dernier statut connu.

setOngoing(true) empêche l'utilisateur de faire glisser la notification pour la fermer — la session reste active jusqu'à ce que l'application décide d'arrêter le service.

Mise à jour du statut en cours de session

Le statut de la notification évolue au fil de la session : "Préparez-vous", "Session en cours", etc. Pour mettre à jour le texte sans créer un nouveau service, on rappelle startService() avec un nouvel Intent :

// Depuis TypingScreen, après le compte à rebours :
context.startService(
    Intent(context, TypingForegroundService::class.java).apply {
        putExtra(TypingForegroundService.EXTRA_STATUS, TypingForegroundService.STATUS_TYPING)
    }
)

onStartCommand() est rappelé avec le nouvel Intent et met à jour la notification via startForeground().

Canal de notification

private fun createNotificationChannel() {
    val channel = NotificationChannel(
        CHANNEL_ID,
        "K3AudioType — Session active",
        NotificationManager.IMPORTANCE_LOW  // pas de son, pas de vibration
    ).apply {
        description = "Maintient la session de frappe active lorsque l'écran est éteint"
        setShowBadge(false)
    }
    notificationManager.createNotificationChannel(channel)
}

IMPORTANCE_LOW est le niveau minimal pour qu'une notification soit visible dans la barre de statut sans déranger l'utilisateur avec des sons ou des vibrations.

Permissions requises dans le manifeste

<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.WAKE_LOCK" />

<service android:name=".main.TypingForegroundService" android:foregroundServiceType="mediaPlayback" android:exported="false" />

foregroundServiceType="mediaPlayback" est requis depuis Android 10 pour les services qui produisent du son (ici, le TTS). Sans ce type déclaré, startForeground() lèverait une exception sur les versions récentes d'Android.


6. Navigation pilotée par l'audio — flux complet

Architecture du ViewModel

MainViewModel expose le channel clavier et les fonctions TTS. Il ne stocke jamais directement une référence au channel (celle-ci peut changer lors d'un resetKeyChannel()), mais utilise une propriété calculée :

class MainViewModel(...) : AndroidViewModel(...) {
// Propriété calculée : lit toujours le channel courant de K3AppState.
// Un val stockerait la référence initiale et deviendrait obsolète après reset.
val keyChannel get() = K3AppState.keyChannel

fun resetKeyChannel() = K3AppState.resetKeyChannel()

fun speak(text: String) { /* TTS QUEUE_FLUSH */ }
fun speakQueued(text: String) { /* TTS QUEUE_ADD */ }

/**
 * Parle toutes les phrases en séquence puis exécute onDone sur le main thread.
 * Le callback onDone est indispensable pour la navigation en fin de session :
 * UtteranceProgressListener déclenche ses callbacks sur un thread background,
 * et Compose ne supporte pas les appels de navigation depuis un thread background.
 */
fun speakThenDo(phrases: List&lt;String&gt;, onDone: () -&gt; Unit) {
    // ...
    tts?.setOnUtteranceProgressListener(object : UtteranceProgressListener() {
        override fun onDone(utteranceId: String?) {
            if (utteranceId == lastId) {
                mainHandler.post(onDone)  // retour sur le main thread
            }
        }
    })
}

}

Exemple : HomeScreen → CustomGameScreen

// Dans MainActivity, la navigation est déclenchée par touche :
composable("home") {
    HomeScreen(
        model = viewModel,
        onPartiePersonnalisee = {
            viewModel.resetKeyChannel()          // 1. vider le channel
            navController.navigate("custom_game") // 2. naviguer
        }
    )
}
// Dans HomeScreen, le collecteur réagit à ENTRÉE :
LaunchedEffect(Unit) {
    for (event in model.keyChannel) {
        when (event.keyCode) {
            KeyEvent.KEYCODE_ENTER -> {
                model.stopSpeaking()
                onPartiePersonnalisee()  // appelle le bloc ci-dessus
            }
        }
    }
}

L'ordre est important : resetKeyChannel() est appelé avant navigate(). Si c'était l'inverse, le nouvel écran pourrait démarrer sa boucle for sur l'ancien channel et recevoir des événements résiduels.

Exemple : TypingScreen — fin de session avec TTS

Quand toutes les phrases ont été tapées, l'application annonce les résultats puis navigue vers l'accueil. La navigation doit attendre la fin du TTS :

model.speakThenDo(
    phrases = listOf(
        "Bravo, exercice terminé.",
        "Durée : $durationText.",
        "Vitesse : $wpmRounded mots par minute.",
        "Précision : $accRounded pourcent.",
        "Retour au menu principal."
    ),
    onDone = { onFinished() }  // appelé sur le main thread quand le TTS a fini
)

onFinished() est défini dans MainActivity :

onFinished = {
    context.stopService(Intent(context, TypingForegroundService::class.java))
    viewModel.resetKeyChannel()
    navController.navigate("home") { popUpTo("home") { inclusive = false } }
}

Récapitulatif des appels resetKeyChannel()

Transition
Home → CustomGame onPartiePersonnalisee
Home → Settings onSettings
CustomGame → TextList onConfirmer
CustomGame → Home (annuler) onAnnuler
TextList → Typing onTextSelected
TextList → CustomGame (retour) onBack
Typing → Home (fin) onFinished
Typing → TextList (quitter) onBack

7. Problèmes rencontrés et solutions

Double émission sur écran verrouillé

Symptôme : chaque appui sur ENTRÉE déclenchait deux navigations — par exemple, la liste des textes était sélectionnée mais repartait immédiatement en demandant une nouvelle sélection.

Cause : sur MIUI (Xiaomi), l'interaction avec l'écran de verrouillage génère un second ACTION_DOWN pour certaines touches. Le service d'accessibilité recevait et émettait les deux. De plus, MainActivity.onKeyDown() émettait lui aussi un événement, multipliant encore le problème.

Solution en deux parties :

1. MainActivity.onKeyDown() consomme la touche sans émettre quand le service est actif :

override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
    if (event.repeatCount > 0) return super.onKeyDown(keyCode, event)
if (K3AppState.isServiceConnected) {
    // Le service a déjà émis. On consomme la touche (return true)
    // pour éviter qu'Android/MIUI génère un second événement via
    // l'interaction avec l'écran de verrouillage.
    return true
}

// Fallback si le service est inactif
if (!sharedViewModel.isInTypingMode) {
    sharedViewModel.emitKeyEvent(keyCode)
    return true
}
return super.onKeyDown(keyCode, event)

}

2. TextListScreen pose un flag hasNavigated avant d'appeler la navigation :

keyCode == KeyEvent.KEYCODE_ENTER -> {
    val textId = model.pendingTextId
    if (textId != null && !hasNavigated) {
        hasNavigated = true          // posé AVANT navigate() —
        model.pendingTextId = null   // si un second ENTER arrive pendant
        model.stopSpeaking()         // la transition, il est ignoré ici
        onTextSelected(textId)
    }
}

Sélection perdue sur écran verrouillé

Symptôme : l'utilisateur sélectionnait un texte (ex. touche 2), puis appuyait sur ENTRÉE — mais l'application demandait de nouveau de sélectionner un texte.

Cause : sur écran verrouillé, appuyer sur ENTRÉE peut provoquer un bref cycle onPause/onResume de l'Activity. Compose recrée alors l'état local (remember) de TextListScreen, remettant selectedIndex à null avant même que l'ENTRÉE soit traitée.

Solution : stocker le textId sélectionné dans le ViewModel (qui survit à tout cycle de vie), pas dans un état Compose local :

// Dans TextListScreen, lors de la sélection par touche numérique :
selectedIndex       = digitIndex          // uniquement pour l'affichage visuel
model.pendingTextId = texts[digitIndex].idText  // survit à onPause/onResume

// Lors de la validation par ENTRÉE : val textId = model.pendingTextId // toujours disponible même après un cycle de vie if (textId != null && !hasNavigated) { onTextSelected(textId) }

Navigation Compose avec callback TTS sur thread background

Symptôme : à la fin d'une session, onFinished() (qui appelle navController.navigate()) était appelé depuis UtteranceProgressListener.onDone() — un thread background. Compose plante silencieusement dans ce cas, en particulier sur écran verrouillé.

Solution : speakThenDo() utilise un Handler sur le main thread pour invoquer le callback :

override fun onDone(utteranceId: String?) {
    if (utteranceId == lastId) {
        tts?.setOnUtteranceProgressListener(null)
        mainHandler.post(onDone)  // retour sur le main thread avant de naviguer
    }
}

Clone this wiki locally