From c0133d26f14c8fa35931caa010ac943342e26664 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Sun, 6 Sep 2026 22:22:53 +0200 Subject: [PATCH 1/4] =?UTF-8?q?feat(lecteur):=20r=C3=A9gler=20la=20vitesse?= =?UTF-8?q?,=20et=20la=20retrouver=20au=20lancement=20suivant?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La vitesse vit dans les préférences, et c'est le service qui les observe pour l'appliquer au lecteur — comme il écoute déjà les expirations de la minuterie. Ni l'écran ni le PlaybackController n'y touchent. Le détour paraît long ; il tient à ceci qu'une lecture démarrée sans écran ouvert — Android Auto, la notification — doit partir à la bonne vitesse. Un réglage posé par l'interface retomberait à ×1 précisément là où l'on ne peut pas le corriger. L'affichage suit le même chemin. PlayerUiState prend sa vitesse du flux des préférences et non de PlaybackState, lequel ne se reconstruit qu'aux tics de position — donc plus du tout en pause, où l'on règle pourtant volontiers sa vitesse. Le bouton de l'en-tête porte le chiffre plutôt qu'une icône : aucune icône ne dit ×1,5, et une vitesse active mais invisible est un défaut qu'on cherche longtemps. Le bornage est double. Celui de la relecture protège l'application d'un fichier écrit par une version future aux bornes plus larges ; celui de l'écriture protège le fichier lui-même. Ils s'éprouvent séparément : le second se lit sous la clé brute, sans quoi le premier rendrait le test vert quoi qu'on ait écrit. Le formatage n'emprunte pas String.format, qui suivrait la locale de l'appareil pour le séparateur décimal comme pour le jeu de chiffres, dans une interface en français. Claude-Session: https://claude.ai/code/session_01YXSdDq15CsKFvy1WXWGK6i --- .../main/java/app/waveflow/MainActivity.kt | 1 + .../app/waveflow/data/PreferencesStore.kt | 37 +++++- .../java/app/waveflow/model/AppPreferences.kt | 9 +- .../java/app/waveflow/model/PlaybackSpeed.kt | 80 ++++++++++++ .../app/waveflow/playback/PlaybackService.kt | 26 ++++ .../waveflow/ui/player/NowPlayingScreen.kt | 56 ++++++++ .../waveflow/ui/player/PlaybackSpeedSheet.kt | 104 +++++++++++++++ .../app/waveflow/ui/player/PlayerUiState.kt | 6 + .../app/waveflow/ui/player/PlayerViewModel.kt | 69 +++++++--- .../app/waveflow/data/PreferencesStoreTest.kt | 75 +++++++++++ .../app/waveflow/model/PlaybackSpeedTest.kt | 68 ++++++++++ .../playback/PlaybackServiceSpeedTest.kt | 120 ++++++++++++++++++ .../test/java/app/waveflow/testing/Fakes.kt | 33 +++++ .../ui/player/PlaybackSpeedSheetTest.kt | 55 ++++++++ .../waveflow/ui/player/PlayerViewModelTest.kt | 58 ++++++++- .../ui/settings/SettingsViewModelTest.kt | 15 +-- docs/PASSATION.md | 74 +++++++---- 17 files changed, 822 insertions(+), 64 deletions(-) create mode 100644 app/src/main/java/app/waveflow/model/PlaybackSpeed.kt create mode 100644 app/src/main/java/app/waveflow/ui/player/PlaybackSpeedSheet.kt create mode 100644 app/src/test/java/app/waveflow/model/PlaybackSpeedTest.kt create mode 100644 app/src/test/java/app/waveflow/playback/PlaybackServiceSpeedTest.kt create mode 100644 app/src/test/java/app/waveflow/ui/player/PlaybackSpeedSheetTest.kt diff --git a/app/src/main/java/app/waveflow/MainActivity.kt b/app/src/main/java/app/waveflow/MainActivity.kt index 0ab4917..3b580ef 100644 --- a/app/src/main/java/app/waveflow/MainActivity.kt +++ b/app/src/main/java/app/waveflow/MainActivity.kt @@ -653,6 +653,7 @@ private fun WaveFlowRoot( onStartSleepTimer = playerViewModel::startSleepTimer, onCancelSleepTimer = playerViewModel::cancelSleepTimer, onSleepTimerRemainingMs = playerViewModel::sleepTimerRemainingMs, + onSetPlaybackSpeed = playerViewModel::setPlaybackSpeed, ) } } diff --git a/app/src/main/java/app/waveflow/data/PreferencesStore.kt b/app/src/main/java/app/waveflow/data/PreferencesStore.kt index 86895e3..e5b2de3 100644 --- a/app/src/main/java/app/waveflow/data/PreferencesStore.kt +++ b/app/src/main/java/app/waveflow/data/PreferencesStore.kt @@ -6,9 +6,11 @@ import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.floatPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore import app.waveflow.model.AppPreferences +import app.waveflow.model.PlaybackSpeed import app.waveflow.model.ThemeChoice import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow @@ -32,6 +34,16 @@ interface PreferencesStore { val preferences: Flow suspend fun setTheme(choice: ThemeChoice) + + /** + * Change la vitesse de lecture. + * + * Écrite ici et non posée sur le lecteur : c'est le service qui observe la + * préférence et l'applique, si bien que le réglage tient même quand aucun + * écran n'est ouvert — la lecture démarrée depuis Android Auto ou la + * notification part à la bonne vitesse. + */ + suspend fun setPlaybackSpeed(speed: Float) } /** @@ -92,22 +104,39 @@ class DataStorePreferencesStore( } /** - * Un nom de thème inconnu vaut le défaut. + * La vitesse est bornée à l'écriture **et** à la relecture. + * + * Aux deux bouts et non à un seul : borner en écrivant protège le fichier + * de ce que l'appelant apporte, borner en lisant protège l'application de + * ce que le fichier contient déjà — une version future aux bornes plus + * larges, ou un fichier abîmé. + */ + override suspend fun setPlaybackSpeed(speed: Float) { + dataStore.edit { it[PLAYBACK_SPEED] = PlaybackSpeed.borner(speed) } + } + + /** + * Ce que le fichier ne sait pas dire vaut le défaut. * - * Le cas se présente si une version future en ajoute un puis qu'on - * redescend : l'ancienne lit une valeur qu'elle ne connaît pas, et lever y - * rendrait l'application inutilisable. + * Un nom de thème inconnu, une vitesse hors bornes : le cas se présente si + * une version future en ajoute puis qu'on redescend, l'ancienne lisant + * alors une valeur qu'elle ne connaît pas. Lever ici rendrait + * l'application inutilisable pour un réglage d'apparence. */ private fun Preferences.toAppPreferences(): AppPreferences = AppPreferences( theme = this[THEME] ?.let { name -> ThemeChoice.entries.firstOrNull { it.name == name } } ?: AppPreferences().theme, + playbackSpeed = this[PLAYBACK_SPEED] + ?.let(PlaybackSpeed::borner) + ?: AppPreferences().playbackSpeed, ) private companion object { const val TAG = "PreferencesStore" val THEME = stringPreferencesKey("theme") + val PLAYBACK_SPEED = floatPreferencesKey("playback_speed") /** Trois reprises : de quoi passer un incident, pas une corruption. */ const val MAX_TENTATIVES = 3L diff --git a/app/src/main/java/app/waveflow/model/AppPreferences.kt b/app/src/main/java/app/waveflow/model/AppPreferences.kt index 2c01efe..1672628 100644 --- a/app/src/main/java/app/waveflow/model/AppPreferences.kt +++ b/app/src/main/java/app/waveflow/model/AppPreferences.kt @@ -1,8 +1,15 @@ package app.waveflow.model -/** Ce que l'utilisateur a choisi, et qui survit à la fermeture de l'application. */ +/** + * Ce que l'utilisateur a choisi, et qui survit à la fermeture de l'application. + * + * @property playbackSpeed la vitesse de lecture. Elle se persiste parce qu'elle + * décrit une habitude et non un geste : qui écoute ses podcasts à ×1,5 ne + * veut pas le redire à chaque lancement. Voir [PlaybackSpeed] pour ses bornes. + */ data class AppPreferences( val theme: ThemeChoice = ThemeChoice.System, + val playbackSpeed: Float = PlaybackSpeed.NORMALE, ) /** diff --git a/app/src/main/java/app/waveflow/model/PlaybackSpeed.kt b/app/src/main/java/app/waveflow/model/PlaybackSpeed.kt new file mode 100644 index 0000000..01f14ee --- /dev/null +++ b/app/src/main/java/app/waveflow/model/PlaybackSpeed.kt @@ -0,0 +1,80 @@ +package app.waveflow.model + +/** + * La vitesse de lecture, et ce qui la tient dans des limites raisonnables. + * + * Un nombre et non une énumération comme [ThemeChoice] : la vitesse est une + * grandeur continue, pas un choix parmi des noms. En faire un jeu de constantes + * obligerait à baptiser chaque palier, et interdirait d'ajouter un réglage plus + * fin sans réécrire ce qui est déjà sur le disque. [PROPOSEES] dit ce que + * l'interface offre aujourd'hui ; le format persisté, lui, ne s'y limite pas. + */ +object PlaybackSpeed { + + /** La vitesse d'origine, celle du disque tel qu'il a été gravé. */ + const val NORMALE = 1f + + /** + * Les bornes du réglage. + * + * En dessous de la moitié, l'étirement temporel de Media3 rend une bouillie + * plutôt qu'un ralenti ; au-delà du double, plus personne ne suit une + * parole, et la musique n'y survit pas du tout. + */ + const val MIN = 0.5f + const val MAX = 2f + + /** + * Ce que la feuille de réglage propose. + * + * Des quarts entre l'unité et le double, là où se joue l'écoute d'une + * parole ; la moitié et les trois quarts en dessous, utiles pour repiquer + * un passage à l'instrument. Un pas plus fin ajouterait des lignes sans + * ajouter de choix : personne ne distingue ×1,4 de ×1,45. + */ + val PROPOSEES = listOf(0.5f, 0.75f, 1f, 1.25f, 1.5f, 1.75f, 2f) + + /** + * Ramène [speed] dans les bornes, et rend [NORMALE] de ce qui n'est pas un + * nombre. + * + * Ce n'est pas une précaution de style : la valeur vient du disque, donc + * potentiellement d'une version future qui aurait élargi les bornes, ou + * d'un fichier abîmé. Une vitesse nulle figerait la lecture sans rien dire, + * et un `NaN` — que `coerceIn` laisse passer tel quel — ferait lever Media3 + * au moment de l'appliquer, loin de l'endroit où il a été lu. + */ + fun borner(speed: Float): Float = + if (speed.isFinite()) speed.coerceIn(MIN, MAX) else NORMALE + + /** + * La vitesse telle qu'elle s'écrit : « ×1 », « ×1,5 », « ×0,75 ». + * + * Formatée à la main plutôt qu'avec `String.format` : celui-ci suit la + * locale de l'appareil, qui décide aussi bien du séparateur décimal que du + * jeu de chiffres. L'interface est en français, la virgule y est donc + * constante — et les chiffres doivent le rester. + * + * Les zéros inutiles tombent : « ×1 » et non « ×1,00 », qui suggérerait une + * précision que le réglage n'offre pas. + */ + fun format(speed: Float): String { + val centiemes = Math.round(borner(speed) * CENTIEMES) + val entier = centiemes / CENTIEMES + val reste = centiemes % CENTIEMES + + val decimales = when { + reste == 0 -> "" + // ×1,50 s'écrit « ×1,5 » : le zéro final ne dit rien de plus. + reste % DIXIEMES == 0 -> ",${reste / DIXIEMES}" + // ×1,05 garde le sien, sans quoi il se lirait « ×1,5 ». + reste < DIXIEMES -> ",0$reste" + else -> ",$reste" + } + + return "×$entier$decimales" + } + + private const val CENTIEMES = 100 + private const val DIXIEMES = 10 +} diff --git a/app/src/main/java/app/waveflow/playback/PlaybackService.kt b/app/src/main/java/app/waveflow/playback/PlaybackService.kt index f73cfc8..9d80449 100644 --- a/app/src/main/java/app/waveflow/playback/PlaybackService.kt +++ b/app/src/main/java/app/waveflow/playback/PlaybackService.kt @@ -13,12 +13,15 @@ import androidx.media3.session.MediaLibraryService import androidx.media3.session.MediaSession import app.waveflow.WaveFlowApp import app.waveflow.data.PlayHistoryRepository +import app.waveflow.data.PreferencesStore import coil.imageLoader import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch /** @@ -106,6 +109,7 @@ class PlaybackService : MediaLibraryService() { player.addListener(historyListener(container.playHistoryRepository)) observeSleepTimer(container.sleepTimer, player) + observePlaybackSpeed(container.preferencesStore, player) // Après la session, et pas avant : la première valeur du flux arrive // sans délai, et elle a des abonnés à prévenir. @@ -130,6 +134,28 @@ class PlaybackService : MediaLibraryService() { } } + /** + * Applique la vitesse de lecture choisie, et la réapplique quand elle change. + * + * Observée ici plutôt que posée par l'écran de lecture : le service joue + * aussi quand aucun écran n'est ouvert — en voiture, depuis la notification + * — et une vitesse qui n'existerait qu'une fois le lecteur affiché + * retomberait à ×1 précisément dans ces cas-là. Le flux rend sa valeur + * courante dès l'abonnement, si bien que la lecture démarre à la bonne + * vitesse sans que personne ait à la redemander. + * + * `distinctUntilChanged` sur la seule vitesse : le flux porte toutes les + * préférences, et changer de thème n'a pas à traverser jusqu'au lecteur. + */ + private fun observePlaybackSpeed(preferences: PreferencesStore, player: Player) { + artworkScope.launch { + preferences.preferences + .map { it.playbackSpeed } + .distinctUntilChanged() + .collect { player.setPlaybackSpeed(it) } + } + } + /** * Note ce qu'on écoute, mais pas ce qu'on saute. * diff --git a/app/src/main/java/app/waveflow/ui/player/NowPlayingScreen.kt b/app/src/main/java/app/waveflow/ui/player/NowPlayingScreen.kt index 6c70f31..69363c7 100644 --- a/app/src/main/java/app/waveflow/ui/player/NowPlayingScreen.kt +++ b/app/src/main/java/app/waveflow/ui/player/NowPlayingScreen.kt @@ -54,6 +54,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import app.waveflow.model.PlaybackSpeed import app.waveflow.model.orUnknownArtist import app.waveflow.playback.PlayingTrack import app.waveflow.playback.RepeatMode @@ -83,6 +84,7 @@ fun NowPlayingScreen( onStartSleepTimer: (Long) -> Unit, onCancelSleepTimer: () -> Unit, onSleepTimerRemainingMs: () -> Long?, + onSetPlaybackSpeed: (Float) -> Unit, modifier: Modifier = Modifier, ) { // Local et non remonté : voir la file est une façon de regarder le lecteur, @@ -91,6 +93,7 @@ fun NowPlayingScreen( // Même raison : la feuille est un geste en cours, pas un état à conserver. var sleepSheetShown by rememberSaveable { mutableStateOf(false) } + var speedSheetShown by rememberSaveable { mutableStateOf(false) } // La file peut se vider pendant l'animation de fermeture : on continue // d'afficher le dernier morceau connu le temps que l'écran redescende, @@ -125,6 +128,8 @@ fun NowPlayingScreen( onToggleQueue = { queueShown = !queueShown }, sleepTimerActive = state.sleepTimerActive, onOpenSleepTimer = { sleepSheetShown = true }, + playbackSpeed = state.playbackSpeed, + onOpenPlaybackSpeed = { speedSheetShown = true }, ) if (queueShown) { @@ -195,6 +200,17 @@ fun NowPlayingScreen( onDismiss = { sleepSheetShown = false }, ) } + + if (speedSheetShown) { + PlaybackSpeedSheet( + speed = state.playbackSpeed, + onPick = { vitesse -> + onSetPlaybackSpeed(vitesse) + speedSheetShown = false + }, + onDismiss = { speedSheetShown = false }, + ) + } } } @@ -207,6 +223,8 @@ private fun PlayerHeader( onToggleQueue: () -> Unit, sleepTimerActive: Boolean, onOpenSleepTimer: () -> Unit, + playbackSpeed: Float, + onOpenPlaybackSpeed: () -> Unit, ) { Row( modifier = Modifier.fillMaxWidth(), @@ -238,6 +256,8 @@ private fun PlayerHeader( ) } + PlaybackSpeedButton(speed = playbackSpeed, onClick = onOpenPlaybackSpeed) + IconButton(onClick = onOpenSleepTimer) { Icon( imageVector = Icons.Filled.Bedtime, @@ -432,6 +452,42 @@ private fun PlayerControls( } } +/** + * La vitesse de lecture : le bouton **est** son affichage. + * + * Le chiffre plutôt qu'une icône, contrairement à ses voisins. Une vitesse + * active et invisible est un défaut qu'on cherche longtemps — « pourquoi cette + * voix est-elle pressée ? » — et aucune icône ne dit ×1,5. Elle tient dans la + * même empreinte que les autres boutons de la rangée : `IconButton` et non + * `TextButton`, dont la largeur minimale creuserait un trou dans l'alignement. + * + * La teinte reprend la grammaire du reste de l'en-tête : accentuée quand le + * réglage s'écarte de l'ordinaire, éteinte sinon. + */ +@Composable +private fun PlaybackSpeedButton(speed: Float, onClick: () -> Unit) { + val libelle = PlaybackSpeed.format(speed) + val ordinaire = speed == PlaybackSpeed.NORMALE + + IconButton( + onClick = onClick, + // Le texte seul se lirait « fois un virgule cinq » sans qu'on sache de + // quoi ; la description dit la grandeur, et prend le pas sur lui. + modifier = Modifier.semantics { contentDescription = "Vitesse de lecture : $libelle" }, + ) { + Text( + text = libelle, + style = MaterialTheme.typography.labelLarge, + maxLines = 1, + color = if (ordinaire) { + MaterialTheme.colorScheme.onSurfaceVariant + } else { + MaterialTheme.colorScheme.primary + }, + ) + } +} + /** Bouton secondaire dont la teinte signale l'état actif. */ @Composable private fun ToggleControl( diff --git a/app/src/main/java/app/waveflow/ui/player/PlaybackSpeedSheet.kt b/app/src/main/java/app/waveflow/ui/player/PlaybackSpeedSheet.kt new file mode 100644 index 0000000..3339f7c --- /dev/null +++ b/app/src/main/java/app/waveflow/ui/player/PlaybackSpeedSheet.kt @@ -0,0 +1,104 @@ +package app.waveflow.ui.player + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.selection.selectable +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import app.waveflow.model.PlaybackSpeed + +/** + * Le choix d'une vitesse de lecture. + * + * Une feuille et non un bouton qui fait tourner les valeurs : elles sont sept, + * et parcourir six paliers pour revenir à ×1 ferait de chaque essai un aller + * sans retour. + * + * @param speed la vitesse en vigueur, celle qui porte la coche. Elle vient des + * préférences déjà enregistrées et non d'un choix en attente : la feuille + * n'entretient aucun état, ce qui la dispense de le réconcilier avec le + * réglage que le service applique. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PlaybackSpeedSheet( + speed: Float, + onPick: (speed: Float) -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + ModalBottomSheet(onDismissRequest = onDismiss, modifier = modifier) { + Column(modifier = Modifier.padding(bottom = 24.dp)) { + Text( + text = "Vitesse de lecture", + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.padding(horizontal = 24.dp), + ) + + Spacer(Modifier.height(4.dp)) + + Text( + // Ce que le réglage engage, dit une fois ici plutôt que deviné : + // il ne vaut pas que pour le morceau en cours, et on le + // retrouvera au lancement suivant. + text = "S'applique à toute la lecture, et se retrouve au prochain lancement.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 24.dp), + ) + + Spacer(Modifier.height(12.dp)) + + PlaybackSpeed.PROPOSEES.forEach { proposee -> + val choisie = proposee == speed + + ListItem( + headlineContent = { + Text( + text = PlaybackSpeed.format(proposee) + + if (proposee == PlaybackSpeed.NORMALE) " (normale)" else "", + ) + }, + trailingContent = if (choisie) { + { + Icon( + imageVector = Icons.Filled.Check, + // Rien à décrire : `selectable` porte déjà + // l'état sélectionné, et le lecteur d'écran + // annoncerait deux fois la même chose. + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + } + } else { + null + }, + modifier = Modifier + .fillMaxWidth() + // `selectable` et non `clickable` : ces lignes sont les + // valeurs d'un même réglage, et c'est ce qui fait dire + // « sélectionné » au lecteur d'écran plutôt que de + // laisser la coche muette. + .selectable( + selected = choisie, + role = Role.RadioButton, + onClick = { onPick(proposee) }, + ), + ) + } + } + } +} diff --git a/app/src/main/java/app/waveflow/ui/player/PlayerUiState.kt b/app/src/main/java/app/waveflow/ui/player/PlayerUiState.kt index 420b4b2..93c1b21 100644 --- a/app/src/main/java/app/waveflow/ui/player/PlayerUiState.kt +++ b/app/src/main/java/app/waveflow/ui/player/PlayerUiState.kt @@ -1,5 +1,6 @@ package app.waveflow.ui.player +import app.waveflow.model.PlaybackSpeed import app.waveflow.playback.PlayingTrack import app.waveflow.playback.RepeatMode @@ -14,6 +15,10 @@ import app.waveflow.playback.RepeatMode * du tout quand la lecture est en pause, alors que la minuterie continue de * courir. Le décompte se demande à la minuterie au moment de l'afficher — ce * que fait la feuille de réglage, seul endroit où quelqu'un le lit. + * @property playbackSpeed la vitesse de lecture. Elle vient des préférences et + * non du lecteur : le flux des préférences émet au moment du choix, quand + * celui du lecteur, lui, ne se rafraîchit qu'aux tics de position — donc plus + * du tout en pause, où l'on règle pourtant volontiers sa vitesse. */ data class PlayerUiState( val track: PlayingTrack? = null, @@ -26,6 +31,7 @@ data class PlayerUiState( val queue: List = emptyList(), val queueIndex: Int = -1, val sleepTimerActive: Boolean = false, + val playbackSpeed: Float = PlaybackSpeed.NORMALE, ) { /** Ce qui reste à jouer après le morceau courant. */ diff --git a/app/src/main/java/app/waveflow/ui/player/PlayerViewModel.kt b/app/src/main/java/app/waveflow/ui/player/PlayerViewModel.kt index 96e4774..e8c0f99 100644 --- a/app/src/main/java/app/waveflow/ui/player/PlayerViewModel.kt +++ b/app/src/main/java/app/waveflow/ui/player/PlayerViewModel.kt @@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory import app.waveflow.WaveFlowApp +import app.waveflow.data.PreferencesStore import app.waveflow.model.RemoteSong import app.waveflow.model.Song import app.waveflow.playback.PlaybackController @@ -20,6 +21,7 @@ import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch /** * Tout ce qui touche à la lecture : état du lecteur et commandes. @@ -30,6 +32,7 @@ import kotlinx.coroutines.flow.stateIn class PlayerViewModel( private val playbackController: PlaybackController, private val sleepTimer: SleepTimer, + private val preferencesStore: PreferencesStore, ) : ViewModel() { // Plus de croisement avec la bibliothèque : le lecteur décrit lui-même sa @@ -40,25 +43,35 @@ class PlayerViewModel( // La minuterie s'y joint plutôt que d'être un flux à part : son décompte // n'a de sens qu'à côté du reste, et les tics de position le rafraîchissent // sans qu'elle ait à entretenir une horloge pour l'affichage. - val state: StateFlow = - combine(playbackController.state, sleepTimer.endsAtMs) { playback, endsAt -> - PlayerUiState( - track = playback.current, - isPlaying = playback.isPlaying, - isBuffering = playback.isBuffering, - positionMs = playback.positionMs, - durationMs = playback.durationMs, - shuffleEnabled = playback.shuffleEnabled, - repeatMode = playback.repeatMode, - queue = playback.queue, - queueIndex = playback.queueIndex, - sleepTimerActive = endsAt != null, - ) - }.stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), - initialValue = PlayerUiState(), + // + // La vitesse aussi, et c'est ce qui la rend visible : elle se règle + // volontiers en pause, quand le lecteur n'émet plus rien. Prise du flux des + // préférences, elle apporte son propre battement — l'état se reconstruit au + // moment du choix. Réduite à la seule vitesse pour qu'un changement de + // thème ne traverse pas jusqu'ici. + val state: StateFlow = combine( + playbackController.state, + sleepTimer.endsAtMs, + preferencesStore.preferences.map { it.playbackSpeed }.distinctUntilChanged(), + ) { playback, endsAt, speed -> + PlayerUiState( + track = playback.current, + isPlaying = playback.isPlaying, + isBuffering = playback.isBuffering, + positionMs = playback.positionMs, + durationMs = playback.durationMs, + shuffleEnabled = playback.shuffleEnabled, + repeatMode = playback.repeatMode, + queue = playback.queue, + queueIndex = playback.queueIndex, + sleepTimerActive = endsAt != null, + playbackSpeed = speed, ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), + initialValue = PlayerUiState(), + ) /** * Les pannes de lecture, à dire une fois chacune. @@ -152,10 +165,23 @@ class PlayerViewModel( fun cancelSleepTimer() = sleepTimer.cancel() /** - * Ce qu.il reste avant l.arrêt automatique, lu à l.instant de la demande. + * Change la vitesse de lecture. + * + * Écrite dans les préférences et non posée sur le lecteur : c'est le + * service qui les observe et applique, ce qui la fait tenir d'une session à + * l'autre et jusque dans les lectures démarrées sans écran ouvert. Le + * chemin de retour passe par le même flux, si bien que l'affichage suit ce + * qui a réellement été enregistré plutôt que ce qui a été demandé. + */ + fun setPlaybackSpeed(speed: Float) { + viewModelScope.launch { preferencesStore.setPlaybackSpeed(speed) } + } + + /** + * Ce qu'il reste avant l'arrêt automatique, lu à l'instant de la demande. * - * Une fonction et non un champ de [PlayerUiState] : celui-ci n.est - * reconstruit qu.aux tics de position, donc plus du tout en pause, alors que + * Une fonction et non un champ de [PlayerUiState] : celui-ci n'est + * reconstruit qu'aux tics de position, donc plus du tout en pause, alors que * la minuterie continue de courir. */ fun sleepTimerRemainingMs(): Long? = sleepTimer.remainingMs() @@ -175,6 +201,7 @@ class PlayerViewModel( PlayerViewModel( playbackController = app.container.createPlaybackController(), sleepTimer = app.container.sleepTimer, + preferencesStore = app.container.preferencesStore, ) } } diff --git a/app/src/test/java/app/waveflow/data/PreferencesStoreTest.kt b/app/src/test/java/app/waveflow/data/PreferencesStoreTest.kt index 280da22..ffcdec9 100644 --- a/app/src/test/java/app/waveflow/data/PreferencesStoreTest.kt +++ b/app/src/test/java/app/waveflow/data/PreferencesStoreTest.kt @@ -3,8 +3,10 @@ package app.waveflow.data import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.PreferenceDataStoreFactory import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.floatPreferencesKey import androidx.datastore.preferences.core.preferencesOf import androidx.datastore.preferences.core.stringPreferencesKey +import app.waveflow.model.PlaybackSpeed import app.waveflow.model.ThemeChoice import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -132,6 +134,79 @@ class PreferencesStoreTest { assertEquals(setOf(ThemeChoice.System), vus.toSet()) } + @Test + fun `la vitesse de lecture survit a la relecture`() = runTest { + // C'est là toute sa raison d'être persistée : qui écoute ses podcasts à + // ×1,5 ne veut pas le redire à chaque lancement. + avecUnMagasin { it.setPlaybackSpeed(1.5f) } + + val vitesse = avecUnMagasin { it.preferences.first().playbackSpeed } + + assertEquals(1.5f, vitesse, 0f) + } + + @Test + fun `sans rien de choisi la lecture est a vitesse normale`() = runTest { + val vitesse = avecUnMagasin { it.preferences.first().playbackSpeed } + + assertEquals(PlaybackSpeed.NORMALE, vitesse, 0f) + } + + @Test + fun `une vitesse aberrante ecrite dans le fichier est ramenee dans les bornes`() = runTest { + // Le cas se présente si une version future élargit les bornes puis + // qu'on redescend : l'ancienne lit une vitesse qu'elle ne sait pas + // tenir, et l'appliquer telle quelle rendrait la lecture inaudible. + // Écrit ici sans passer par `setPlaybackSpeed`, qui borne déjà : c'est + // la relecture qu'on éprouve, pas l'écriture. + val magasin = magasinFige(preferencesOf(floatPreferencesKey("playback_speed") to 8f)) + + val vitesse = magasin.preferences.first().playbackSpeed + + assertEquals(PlaybackSpeed.MAX, vitesse, 0f) + } + + @Test + fun `une vitesse aberrante n'est pas meme ecrite dans le fichier`() = runTest { + // La relecture borne déjà, et suffirait à protéger l'application. Ce + // qu'on garde ici, c'est le **fichier** : une valeur aberrante gravée + // sur le disque survivrait à une version future aux bornes plus larges, + // qui la relirait alors sans rien pour l'arrêter. + // + // Lu sous la clé brute et non par `preferences` : celui-ci borne à la + // relecture, et rendrait le test vert quoi qu'on ait écrit. + val brut = avecUnMagasinBrut { magasin, dataStore -> + magasin.setPlaybackSpeed(8f) + dataStore.data.first()[floatPreferencesKey("playback_speed")] + } + + assertEquals(PlaybackSpeed.MAX, brut!!, 0f) + } + + /** Comme [avecUnMagasin], mais donne aussi le DataStore sous-jacent. */ + private suspend fun avecUnMagasinBrut( + bloc: suspend (PreferencesStore, DataStore) -> T, + ): T { + val portee = CoroutineScope(Job() + Dispatchers.IO) + try { + val dataStore = PreferenceDataStoreFactory.create(scope = portee) { fichier } + return bloc(DataStorePreferencesStore(dataStore), dataStore) + } finally { + portee.coroutineContext[Job]!!.cancelAndJoin() + } + } + + /** Un magasin en lecture seule, sur un contenu écrit à la main. */ + private fun magasinFige(contenu: Preferences): PreferencesStore = + DataStorePreferencesStore( + object : DataStore { + override val data: Flow = flow { emit(contenu) } + override suspend fun updateData( + transform: suspend (Preferences) -> Preferences, + ): Preferences = throw UnsupportedOperationException("lecture seule") + }, + ) + @Test fun `changer de theme se voit sans rouvrir le fichier`() = runTest { // Un flux, et non une lecture ponctuelle : l'écran qui change le thème diff --git a/app/src/test/java/app/waveflow/model/PlaybackSpeedTest.kt b/app/src/test/java/app/waveflow/model/PlaybackSpeedTest.kt new file mode 100644 index 0000000..06dc282 --- /dev/null +++ b/app/src/test/java/app/waveflow/model/PlaybackSpeedTest.kt @@ -0,0 +1,68 @@ +package app.waveflow.model + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** La vitesse telle qu'elle se dit, et telle qu'on la garde présentable. */ +class PlaybackSpeedTest { + + @Test + fun `les zeros inutiles ne s'ecrivent pas`() { + // « ×1,00 » suggérerait une précision au centième que le réglage + // n'offre pas, et allongerait un bouton qui doit tenir dans 48 dp. + assertEquals("×1", PlaybackSpeed.format(1f)) + assertEquals("×2", PlaybackSpeed.format(2f)) + assertEquals("×1,5", PlaybackSpeed.format(1.5f)) + } + + @Test + fun `le separateur decimal est la virgule, en toute locale`() { + // `String.format` suivrait la locale de l'appareil : « ×1.5 » sur un + // téléphone en anglais, au milieu d'une interface en français. + val defaut = java.util.Locale.getDefault() + try { + java.util.Locale.setDefault(java.util.Locale.US) + assertEquals("×1,25", PlaybackSpeed.format(1.25f)) + } finally { + java.util.Locale.setDefault(defaut) + } + } + + @Test + fun `un centieme non nul garde son zero de tete`() { + // Sans lui, ×1,05 s'écrirait « ×1,5 » — quatre fois plus rapide que + // demandé, et rigoureusement impossible à repérer à l'écran. + assertEquals("×1,05", PlaybackSpeed.format(1.05f)) + } + + @Test + fun `toutes les vitesses proposees s'ecrivent`() { + // Le bouton les affiche telles quelles : aucune ne doit rendre une + // forme inattendue, et toutes tiennent en cinq caractères. + assertEquals( + listOf("×0,5", "×0,75", "×1", "×1,25", "×1,5", "×1,75", "×2"), + PlaybackSpeed.PROPOSEES.map(PlaybackSpeed::format), + ) + } + + @Test + fun `une vitesse hors bornes est ramenee dedans`() { + // La valeur vient du disque : une version future aux bornes plus larges, + // ou un fichier abîmé. Une vitesse nulle figerait la lecture sans rien + // dire de ce qui se passe. + assertEquals(PlaybackSpeed.MAX, PlaybackSpeed.borner(8f), 0f) + assertEquals(PlaybackSpeed.MIN, PlaybackSpeed.borner(0.1f), 0f) + assertEquals(PlaybackSpeed.MIN, PlaybackSpeed.borner(0f), 0f) + assertEquals(PlaybackSpeed.MIN, PlaybackSpeed.borner(-1f), 0f) + } + + @Test + fun `ce qui n'est pas un nombre vaut la vitesse normale`() { + // `coerceIn` laisse passer NaN tel quel, et Media3 lève en le recevant + // — loin de l'endroit où il a été lu, donc difficile à rattacher au + // fichier de préférences qui l'a produit. + assertEquals(PlaybackSpeed.NORMALE, PlaybackSpeed.borner(Float.NaN), 0f) + assertEquals(PlaybackSpeed.NORMALE, PlaybackSpeed.borner(Float.POSITIVE_INFINITY), 0f) + assertEquals(PlaybackSpeed.NORMALE, PlaybackSpeed.borner(Float.NEGATIVE_INFINITY), 0f) + } +} diff --git a/app/src/test/java/app/waveflow/playback/PlaybackServiceSpeedTest.kt b/app/src/test/java/app/waveflow/playback/PlaybackServiceSpeedTest.kt new file mode 100644 index 0000000..b98c41f --- /dev/null +++ b/app/src/test/java/app/waveflow/playback/PlaybackServiceSpeedTest.kt @@ -0,0 +1,120 @@ +package app.waveflow.playback + +import android.app.Application +import android.os.Bundle +import android.os.Looper +import androidx.media3.session.MediaSession +import androidx.test.core.app.ApplicationProvider +import app.waveflow.WaveFlowApp +import app.waveflow.data.PreferencesStore +import app.waveflow.model.PlaybackSpeed +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.android.controller.ServiceController +import java.util.concurrent.TimeUnit + +/** + * La vitesse de lecture, du fichier de préférences jusqu'au lecteur. + * + * C'est le maillon que rien d'autre ne couvre. `PreferencesStoreTest` prouve + * que la vitesse survit au redémarrage, `PlayerViewModelTest` qu'elle remonte à + * l'écran ; entre les deux, il reste à montrer que **quelqu'un l'applique** — et + * ce quelqu'un est le service, précisément parce qu'il joue aussi quand aucun + * écran n'est ouvert. + * + * Le vrai magasin de l'application, et non un faux : le service va le chercher + * lui-même dans le conteneur, il ne reçoit aucune dépendance en paramètre. La + * vitesse est donc remise à l'ordinaire après chaque test — le délégué + * `preferencesDataStore` mémorise son instance pour toute la machine virtuelle, + * et ce qu'on laisse derrière soi, un autre test le relira. + */ +@RunWith(RobolectricTestRunner::class) +class PlaybackServiceSpeedTest { + + private val app: Application = ApplicationProvider.getApplicationContext() + private val preferences: PreferencesStore get() = (app as WaveFlowApp).container.preferencesStore + + private var service: ServiceController? = null + + @After + fun tearDown() { + service?.destroy() + runBlocking { preferences.setPlaybackSpeed(PlaybackSpeed.NORMALE) } + } + + @Test + fun `la vitesse enregistree est appliquee des le demarrage du service`() { + // Le cas qui motive tout le montage : on règle ×1,5, on ferme + // l'application, puis la lecture repart depuis Android Auto ou la + // notification. Si personne n'applique la préférence à ce moment-là, + // elle retombe à ×1 exactement là où on ne peut pas la corriger. + runBlocking { preferences.setPlaybackSpeed(1.5f) } + + val lecteur = lecteurDuService() + + assertEquals(1.5f, lecteur.attendreVitesse(1.5f), 0f) + } + + @Test + fun `changer la vitesse pendant la lecture la porte jusqu'au lecteur`() { + // Le service observe, il ne lit pas une fois pour toutes : sans cela le + // réglage n'aurait d'effet qu'au redémarrage suivant. + val lecteur = lecteurDuService() + lecteur.attendreVitesse(PlaybackSpeed.NORMALE) + + runBlocking { preferences.setPlaybackSpeed(0.75f) } + + assertEquals(0.75f, lecteur.attendreVitesse(0.75f), 0f) + } + + /** Le lecteur que le service tient, tel qu'Android le lui a fait construire. */ + private fun lecteurDuService(): androidx.media3.common.Player { + val controller = Robolectric.buildService(PlaybackService::class.java).create() + service = controller + return controller.get().onGetSession(appelant())!!.player + } + + /** + * Attend que la vitesse devienne [attendue], puis la rend. + * + * L'observation traverse deux fils : le DataStore lit sur son propre + * dispatcher, et la pose sur le lecteur revient au fil principal, que + * Robolectric n'anime que si on le lui demande. Sans cette attente, le test + * lirait la vitesse avant que la collecte ait eu lieu — et passerait ou + * échouerait selon l'humeur de la machine. + * + * Bornée : une vitesse qui n'arriverait plus doit faire échouer le test, pas + * le faire attendre indéfiniment. L'assertion de l'appelant tranche ensuite, + * y compris quand l'échéance est atteinte. + */ + private fun androidx.media3.common.Player.attendreVitesse(attendue: Float): Float { + val echeance = System.nanoTime() + TimeUnit.SECONDS.toNanos(TIMEOUT_S) + while (playbackParameters.speed != attendue && System.nanoTime() < echeance) { + shadowOf(Looper.getMainLooper()).idle() + Thread.sleep(5) + } + return playbackParameters.speed + } + + private fun appelant(): MediaSession.ControllerInfo = + MediaSession.ControllerInfo.createTestOnlyControllerInfo( + /* packageName = */ "app.waveflow", + /* pid = */ 0, + /* uid = */ 0, + /* libraryVersion = */ 0, + /* interfaceVersion = */ 0, + /* trusted = */ true, + /* connectionHints = */ Bundle.EMPTY, + /* isPackageNameVerified = */ true, + ) + + private companion object { + const val TIMEOUT_S = 5L + } +} diff --git a/app/src/test/java/app/waveflow/testing/Fakes.kt b/app/src/test/java/app/waveflow/testing/Fakes.kt index 00e2bae..5c16aec 100644 --- a/app/src/test/java/app/waveflow/testing/Fakes.kt +++ b/app/src/test/java/app/waveflow/testing/Fakes.kt @@ -3,10 +3,14 @@ package app.waveflow.testing import android.net.Uri import app.waveflow.data.MusicRepository import app.waveflow.data.PlaylistRepository +import app.waveflow.data.PreferencesStore +import app.waveflow.model.AppPreferences +import app.waveflow.model.PlaybackSpeed import app.waveflow.model.Playlist import app.waveflow.model.PlaylistEntry import app.waveflow.model.RemoteSong import app.waveflow.model.Song +import app.waveflow.model.ThemeChoice import app.waveflow.playback.PlaybackController import app.waveflow.playback.PlaybackState import app.waveflow.playback.PlayingTrack @@ -16,6 +20,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.update /** * Fabrique de morceaux pour les tests. @@ -211,3 +216,31 @@ class FakePlaybackController : PlaybackController { _state.value = state } } + +/** + * Les préférences, en mémoire. + * + * Un `MutableStateFlow` et non le vrai magasin : ce que ces tests éprouvent est + * ce que fait l'application d'une préférence qui change, pas la fidélité du + * DataStore — celle-ci se joue dans `PreferencesStoreTest`, sur un vrai fichier. + * + * Il **borne comme le vrai**. Un faux plus permissif que l'original rendrait + * verts des tests qui décriraient une application qui n'existe pas. + */ +class FakePreferencesStore(initial: AppPreferences = AppPreferences()) : PreferencesStore { + + private val flux = MutableStateFlow(initial) + + override val preferences: Flow = flux + + override suspend fun setTheme(choice: ThemeChoice) { + flux.update { it.copy(theme = choice) } + } + + override suspend fun setPlaybackSpeed(speed: Float) { + flux.update { it.copy(playbackSpeed = PlaybackSpeed.borner(speed)) } + } + + val theme: ThemeChoice get() = flux.value.theme + val playbackSpeed: Float get() = flux.value.playbackSpeed +} diff --git a/app/src/test/java/app/waveflow/ui/player/PlaybackSpeedSheetTest.kt b/app/src/test/java/app/waveflow/ui/player/PlaybackSpeedSheetTest.kt new file mode 100644 index 0000000..cdadae2 --- /dev/null +++ b/app/src/test/java/app/waveflow/ui/player/PlaybackSpeedSheetTest.kt @@ -0,0 +1,55 @@ +package app.waveflow.ui.player + +import androidx.compose.ui.test.assertIsNotSelected +import androidx.compose.ui.test.assertIsSelected +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** Ce que la feuille de vitesse montre, et ce qu'elle rend en retour. */ +@RunWith(RobolectricTestRunner::class) +class PlaybackSpeedSheetTest { + + @get:Rule + val compose = createComposeRule() + + @Test + fun `la vitesse en vigueur est celle qui apparait selectionnee`() { + // Sans elle, la feuille serait une liste de sept lignes identiques : + // impossible d'y lire ce qu'on écoute, donc impossible de savoir de + // combien on s'en écarte en choisissant. + compose.setContent { + PlaybackSpeedSheet(speed = 1.5f, onPick = {}, onDismiss = {}) + } + + compose.onNodeWithText("×1,5").assertIsSelected() + } + + @Test + fun `choisir une vitesse la remonte telle quelle`() { + var choisie: Float? = null + compose.setContent { + PlaybackSpeedSheet(speed = 1f, onPick = { choisie = it }, onDismiss = {}) + } + + compose.onNodeWithText("×1,75").performClick() + + assertEquals(1.75f, choisie!!, 0f) + } + + @Test + fun `la vitesse normale se nomme, pour qu'on sache ou revenir`() { + // « ×1 » seul, au milieu de six autres nombres, ne signale pas qu'il est + // le point de départ — et c'est celui qu'on cherche pour tout annuler. + compose.setContent { + PlaybackSpeedSheet(speed = 1.5f, onPick = {}, onDismiss = {}) + } + + compose.onNodeWithText("×1 (normale)").assertIsNotSelected() + } +} diff --git a/app/src/test/java/app/waveflow/ui/player/PlayerViewModelTest.kt b/app/src/test/java/app/waveflow/ui/player/PlayerViewModelTest.kt index cb946d8..e8d4879 100644 --- a/app/src/test/java/app/waveflow/ui/player/PlayerViewModelTest.kt +++ b/app/src/test/java/app/waveflow/ui/player/PlayerViewModelTest.kt @@ -4,12 +4,16 @@ import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelStore import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory +import app.waveflow.data.PreferencesStore +import app.waveflow.model.AppPreferences +import app.waveflow.model.PlaybackSpeed import app.waveflow.playback.PlaybackFailure import app.waveflow.playback.PlaybackState import app.waveflow.playback.PlayingTrack import app.waveflow.playback.SleepTimer import app.waveflow.playback.TrackSource import app.waveflow.testing.FakePlaybackController +import app.waveflow.testing.FakePreferencesStore import app.waveflow.testing.MainDispatcherRule import app.waveflow.testing.remoteSong import app.waveflow.testing.song @@ -39,6 +43,7 @@ class PlayerViewModelTest { private val songs = listOf(song(id = 1L), song(id = 2L), song(id = 3L)) private val remoteSongs = listOf(remoteSong("a"), remoteSong("b"), remoteSong("c")) private val controller = FakePlaybackController() + private val preferences = FakePreferencesStore() /** * Le ViewModel sous test, muni d'une minuterie qui suit l'horloge virtuelle. @@ -47,9 +52,12 @@ class PlayerViewModelTest { * minuterie qui survivrait d'un test à l'autre porterait son échéance avec * elle. */ - private fun TestScope.playerViewModel() = PlayerViewModel( + private fun TestScope.playerViewModel( + preferencesStore: PreferencesStore = preferences, + ) = PlayerViewModel( playbackController = controller, sleepTimer = SleepTimer(backgroundScope) { testScheduler.currentTime }, + preferencesStore = preferencesStore, ) @Test @@ -307,4 +315,52 @@ class PlayerViewModelTest { job.cancel() } + + @Test + fun `la vitesse enregistree habille l'etat des le depart`() = runTest { + // Sans cette remontée, l'écran s'ouvrirait sur « ×1 » pendant que le + // service joue déjà à ×1,5 : le bouton mentirait sur ce qu'on entend. + val viewModel = playerViewModel( + FakePreferencesStore(AppPreferences(playbackSpeed = 1.5f)), + ) + val job = launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.state.collect {} } + advanceUntilIdle() + + assertEquals(1.5f, viewModel.state.value.playbackSpeed, 0f) + + job.cancel() + } + + @Test + fun `changer la vitesse se voit alors que le lecteur n'emet plus rien`() = runTest { + // Le piège du lecteur : `PlaybackState` ne se reconstruit qu'aux tics de + // position, donc plus du tout en pause — et c'est justement en pause + // qu'on règle sa vitesse. Le contrôleur reste ici muet de bout en bout : + // si l'état tenait la vitesse de lui, rien ne bougerait. + val viewModel = playerViewModel() + val job = launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.state.collect {} } + advanceUntilIdle() + + assertEquals(PlaybackSpeed.NORMALE, viewModel.state.value.playbackSpeed, 0f) + + viewModel.setPlaybackSpeed(1.5f) + advanceUntilIdle() + + assertEquals(1.5f, viewModel.state.value.playbackSpeed, 0f) + + job.cancel() + } + + @Test + fun `la vitesse choisie passe par les preferences et non par le lecteur`() = runTest { + // C'est ce qui la fait tenir d'un lancement à l'autre, et s'appliquer + // aux lectures démarrées sans qu'aucun écran soit ouvert : le service + // observe la préférence, il n'attend pas d'ordre de l'interface. + val viewModel = playerViewModel() + + viewModel.setPlaybackSpeed(1.75f) + advanceUntilIdle() + + assertEquals(1.75f, preferences.playbackSpeed, 0f) + } } diff --git a/app/src/test/java/app/waveflow/ui/settings/SettingsViewModelTest.kt b/app/src/test/java/app/waveflow/ui/settings/SettingsViewModelTest.kt index 926f03c..c7b3b70 100644 --- a/app/src/test/java/app/waveflow/ui/settings/SettingsViewModelTest.kt +++ b/app/src/test/java/app/waveflow/ui/settings/SettingsViewModelTest.kt @@ -1,13 +1,10 @@ package app.waveflow.ui.settings -import app.waveflow.data.PreferencesStore import app.waveflow.model.AppPreferences import app.waveflow.model.ThemeChoice +import app.waveflow.testing.FakePreferencesStore import app.waveflow.testing.MainDispatcherRule import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.update import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals @@ -27,16 +24,6 @@ class SettingsViewModelTest { @get:Rule val mainDispatcherRule = MainDispatcherRule() - private class FakePreferencesStore(initial: AppPreferences) : PreferencesStore { - private val flux = MutableStateFlow(initial) - override val preferences: Flow = flux - override suspend fun setTheme(choice: ThemeChoice) { - flux.update { it.copy(theme = choice) } - } - - val theme: ThemeChoice get() = flux.value.theme - } - @Test fun `le theme enregistre est pret sans que personne n'ait collecte`() = runTest(mainDispatcherRule.dispatcher) { diff --git a/docs/PASSATION.md b/docs/PASSATION.md index a2b2b19..bc81cf3 100644 --- a/docs/PASSATION.md +++ b/docs/PASSATION.md @@ -4,12 +4,13 @@ Document vivant : chaque agent qui prend la suite le relit d'abord, et le met à jour avant de partir. Il dit **où en est le chantier et ce qui vient ensuite** — pas l'historique, que `git log` raconte mieux. -Dernière mise à jour : **2026-09-06**, sur `main` = `1c203f6`. +Dernière mise à jour : **2026-09-06**, sur `main` = `59db1fd`. ## État du dépôt -- `main` = `1c203f6`, arbre propre, **aucune PR ouverte**, aucune branche en cours. -- **346 tests verts**, CI verte (workflow `Build & test`, ~4 min 45 s). +- `main` = `59db1fd`. Une PR ouverte : **la vitesse de lecture**, branche + `feat/vitesse-lecture`. +- **364 tests verts**, CI verte (workflow `Build & test`, ~4 min 45 s). - **Aucun avertissement de compilation.** C'est une propriété qu'on tient, pas un hasard — voir le piège `textReport` plus bas avant d'en supprimer un. - Gradle 9.7.1, AGP 9.3.2, OkHttp 5.5.0, media3 1.11.0. @@ -21,7 +22,8 @@ Six lots, dans cet ordre : 1. Réglages / DataStore — **fait** 2. Navigation + identité — **fait** -3. Lecteur : file d'attente, minuterie, vitesse, boucle A-B — **aux deux tiers** +3. Lecteur : file d'attente, minuterie, vitesse, boucle A-B — **il ne reste que + la boucle A-B** 4. Transcodage (remonté du 6ᵉ rang : meilleur rapport travail/effet, le serveur est déjà prêt) 5. Paroles @@ -32,6 +34,30 @@ lecteurs ou une chaîne audio maison. ## Ce que la dernière session a livré +**La vitesse de lecture** (branche `feat/vitesse-lecture`). + +Elle vit dans les **préférences**, et c'est le **service** qui les observe pour +l'appliquer au lecteur — comme il écoute déjà les expirations de la minuterie. +Ni l'écran ni le `PlaybackController` n'y touchent. Deux raisons, et la seconde +est la vraie : la vitesse se persiste, et une lecture démarrée sans écran ouvert +— Android Auto, la notification — doit partir à la bonne vitesse. Un réglage +posé par l'interface retomberait à ×1 précisément là où on ne peut pas le +corriger. + +L'affichage suit le même chemin : `PlayerUiState.playbackSpeed` vient du flux +des préférences, non de `PlaybackState`. C'est ce qui **désamorce le piège n° 1** +plutôt que de le contourner — le flux des préférences émet au moment du choix, y +compris en pause, où l'on règle justement sa vitesse. Le bouton de l'en-tête +*est* son affichage : il porte le chiffre, pas une icône. + +Le bornage est double, à l'écriture **et** à la relecture. Le second protège +l'application d'un fichier écrit par une version future aux bornes plus larges ; +le premier protège le fichier lui-même. Ils s'éprouvent séparément — voir plus +bas. + +**Ce qui n'a pas été fait :** le mini-player ne dit pas la vitesse. Elle n'est +visible qu'une fois le lecteur déplié. À revoir si quelqu'un s'y perd. + **PR #47 — la minuterie de veille.** `SleepTimer` vit dans `AppContainer`, pas dans le service : on règle une @@ -52,10 +78,16 @@ traiter à part. ### 1. `PlayerUiState` ne se reconstruit qu'aux tics de position **Donc plus du tout en pause.** La minuterie s'y est fait prendre : le décompte -était câblé sur l'état figé et ne bougeait plus. La vitesse de lecture et les -bornes A-B liront le même état — il faudra soit les publier sur un flux propre, -soit les relire à l'affichage, comme `SleepTimerSheet` le fait avec un tic local -qui ne vit que le temps où la feuille est ouverte. +était câblé sur l'état figé et ne bougeait plus. Les bornes A-B liront le même +état — il faudra soit les publier sur un flux propre, soit les relire à +l'affichage, comme `SleepTimerSheet` le fait avec un tic local qui ne vit que le +temps où la feuille est ouverte. + +**La vitesse ne s'y est pas laissé prendre**, et c'est la voie à reprendre : elle +ne passe pas par `PlaybackState` du tout, mais par le flux des préférences, qui +émet au moment du choix. Le piège se désamorce mieux qu'il ne se contourne — si +quelque chose d'autre que le lecteur peut porter les bornes A-B, qu'il les +porte. ### 2. « Vérifier puis agir » : la course qui revient @@ -93,22 +125,10 @@ une erreur. Ne pas rouvrir. ## La suite : solder le lot 3 -Rien n'existe encore — ni `setPlaybackSpeed`, ni `PlaybackParameters`, aucune -trace d'A-B dans `PlaybackController`. +La vitesse est faite. Reste la boucle A-B — aucune trace dans +`PlaybackController`. -### a. La vitesse de lecture — à faire en premier - -La plus simple, et de loin. Media3 la fournit d'un appel ; elle se pose dans -`PlaybackController` comme les autres commandes. - -Deux points de conception : - -- **Elle se persiste.** On ne veut pas retomber à ×1 à chaque relance quand on - écoute un podcast à ×1,5. Le `PreferencesStore` est là pour ça. -- **Elle s'affiche.** Une vitesse active et invisible est un défaut qu'on cherche - pendant vingt minutes. Voir le piège n° 1 pour la publier correctement. - -### b. La boucle A-B — ensuite, nettement plus retorse +### La boucle A-B — tout ce qui reste, et le plus retorse Media3 **n'a pas** de « répéter entre deux points ». Il faut échantillonner la position et rembobiner au passage de B, ce qui place le mécanisme **dans le @@ -116,6 +136,11 @@ service**, pas dans l'interface. Ce que Robolectric peut en prouver est déjà borné : `isPlaying` à `true` et l'échantillonnage de position restent hors de portée faute de codec. +**L'en-tête du lecteur est plein.** Quatre boutons y tiennent déjà — réduire, +vitesse, veille, file — et la colonne du titre s'en trouve serrée sur un écran +étroit. L'A-B n'y entrera pas sans un menu de débordement qui regrouperait +veille, vitesse et bornes. C'est le moment de le poser, pas après. + ## En attente d'une décision de l'utilisateur Ni l'un ni l'autre n'est bloquant. Ils ne sont pas oubliés, ils sont posés. @@ -155,6 +180,9 @@ pagine sur le réseau. Le retirer de la barre sans cela l'aurait rendu - **Tout test de régression se valide par retrait** : on enlève le correctif et on vérifie que le bon test — et lui seul — tombe, avec `--rerun-tasks`. Un test qui passe des deux côtés est un test creux, et il y en a six formes connues. + *Sur une machine à court de mémoire, `--rerun-tasks` fait tomber le build ; + un retrait modifie de toute façon une source, ce qui invalide déjà la tâche de + test. Le drapeau ne protège que du cas où rien n'a changé.* - `./gradlew ktlintFormat` **avant chaque commit**. - **Français** pour l'interface, les messages de commit, la KDoc, les commentaires et les noms de tests (`build-and-test.yml` excepté). From 51f962000b718b2d16e7f982a102909d031c35e0 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Mon, 7 Sep 2026 15:58:37 +0200 Subject: [PATCH 2/4] =?UTF-8?q?fix(lecteur):=20tenir=20la=20vitesse=20quan?= =?UTF-8?q?d=20le=20disque,=20ou=20la=20liste,=20se=20d=C3=A9robe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trois points relevés en revue. L'écriture des préférences est retenue dans le magasin, comme l'était déjà la lecture. Les appelants lancent dans la portée de leur ViewModel, qui n'a pas de gestionnaire d'exception : un disque plein y emportait l'application entière — pour une vitesse de lecture. Le choix est alors simplement perdu, ce que l'écran dit de lui-même en restant sur l'ancienne valeur. IOException seule, une annulation traversant elle aussi edit. La feuille joint la vitesse en cours aux propositions quand elle n'y figure pas. Le format persisté ne se limite pas à ces sept valeurs — c'était le motif de préférer un nombre à une énumération — et sans cela la feuille s'ouvrait sans rien de coché, à contredire le bouton qui l'a ouverte. FakePreferencesStore borne dès la construction et non seulement à l'écriture. Sa propre KDoc affirmait qu'il bornait comme le vrai ; il ne le faisait qu'à moitié, et un faux plus permissif rend vert un test décrivant une application qui n'existe pas. Claude-Session: https://claude.ai/code/session_01YXSdDq15CsKFvy1WXWGK6i --- .../app/waveflow/data/PreferencesStore.kt | 17 ++++++++++++++++- .../waveflow/ui/player/PlaybackSpeedSheet.kt | 9 ++++++++- .../app/waveflow/data/PreferencesStoreTest.kt | 19 +++++++++++++++++++ .../test/java/app/waveflow/testing/Fakes.kt | 7 ++++++- .../ui/player/PlaybackSpeedSheetTest.kt | 12 ++++++++++++ 5 files changed, 61 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/app/waveflow/data/PreferencesStore.kt b/app/src/main/java/app/waveflow/data/PreferencesStore.kt index e5b2de3..4db3a5a 100644 --- a/app/src/main/java/app/waveflow/data/PreferencesStore.kt +++ b/app/src/main/java/app/waveflow/data/PreferencesStore.kt @@ -110,9 +110,24 @@ class DataStorePreferencesStore( * de ce que l'appelant apporte, borner en lisant protège l'application de * ce que le fichier contient déjà — une version future aux bornes plus * larges, ou un fichier abîmé. + * + * L'échec d'écriture est retenu ici, comme l'est déjà celui de lecture. Les + * appelants lancent dans la portée de leur ViewModel, laquelle n'a pas de + * gestionnaire d'exception : un disque plein y ferait tomber l'application + * entière — pour une vitesse de lecture. Le choix est alors simplement + * perdu, ce que l'écran dit de lui-même en restant sur l'ancienne valeur, + * le flux n'ayant rien émis. + * + * `IOException` et non tout le reste : une annulation traverse elle aussi + * `edit`, et l'avaler ferait survivre une écriture à la portée qui l'a + * demandée. */ override suspend fun setPlaybackSpeed(speed: Float) { - dataStore.edit { it[PLAYBACK_SPEED] = PlaybackSpeed.borner(speed) } + try { + dataStore.edit { it[PLAYBACK_SPEED] = PlaybackSpeed.borner(speed) } + } catch (erreur: IOException) { + Log.w(TAG, "Vitesse de lecture non enregistrée", erreur) + } } /** diff --git a/app/src/main/java/app/waveflow/ui/player/PlaybackSpeedSheet.kt b/app/src/main/java/app/waveflow/ui/player/PlaybackSpeedSheet.kt index 3339f7c..20089e7 100644 --- a/app/src/main/java/app/waveflow/ui/player/PlaybackSpeedSheet.kt +++ b/app/src/main/java/app/waveflow/ui/player/PlaybackSpeedSheet.kt @@ -62,7 +62,14 @@ fun PlaybackSpeedSheet( Spacer(Modifier.height(12.dp)) - PlaybackSpeed.PROPOSEES.forEach { proposee -> + // La vitesse en cours se joint aux propositions si elle n'y est + // pas. Le format persisté ne se limite pas à cette liste — une + // version future peut offrir un réglage plus fin, et un fichier + // rapporté d'ailleurs porter ×1,1. Sans cela la feuille s'ouvrirait + // sans rien de coché, à contredire le bouton qui l'a ouverte. + val vitesses = (PlaybackSpeed.PROPOSEES + speed).distinct().sorted() + + vitesses.forEach { proposee -> val choisie = proposee == speed ListItem( diff --git a/app/src/test/java/app/waveflow/data/PreferencesStoreTest.kt b/app/src/test/java/app/waveflow/data/PreferencesStoreTest.kt index ffcdec9..d21df2f 100644 --- a/app/src/test/java/app/waveflow/data/PreferencesStoreTest.kt +++ b/app/src/test/java/app/waveflow/data/PreferencesStoreTest.kt @@ -196,6 +196,25 @@ class PreferencesStoreTest { } } + @Test + fun `une ecriture en echec ne fait pas tomber l'appelant`() = runTest { + // Les appelants lancent dans la portée de leur ViewModel, qui n'a pas de + // gestionnaire d'exception : sans cette retenue, un disque plein + // emporterait l'application entière — pour une vitesse de lecture. + val magasin = DataStorePreferencesStore( + object : DataStore { + override val data: Flow = flow { emit(preferencesOf()) } + override suspend fun updateData( + transform: suspend (Preferences) -> Preferences, + ): Preferences = throw IOException("disque plein") + }, + ) + + // Ne lève pas : c'est tout ce qui est demandé. Le choix est perdu, et + // l'écran le dit en restant sur l'ancienne valeur. + magasin.setPlaybackSpeed(1.5f) + } + /** Un magasin en lecture seule, sur un contenu écrit à la main. */ private fun magasinFige(contenu: Preferences): PreferencesStore = DataStorePreferencesStore( diff --git a/app/src/test/java/app/waveflow/testing/Fakes.kt b/app/src/test/java/app/waveflow/testing/Fakes.kt index 5c16aec..0b09fcf 100644 --- a/app/src/test/java/app/waveflow/testing/Fakes.kt +++ b/app/src/test/java/app/waveflow/testing/Fakes.kt @@ -229,7 +229,12 @@ class FakePlaybackController : PlaybackController { */ class FakePreferencesStore(initial: AppPreferences = AppPreferences()) : PreferencesStore { - private val flux = MutableStateFlow(initial) + // Borné dès la construction, et pas seulement à l'écriture : le vrai + // magasin borne ce qu'il relit, et un faux plus permissif rendrait vert un + // test décrivant une application qui n'existe pas. + private val flux = MutableStateFlow( + initial.copy(playbackSpeed = PlaybackSpeed.borner(initial.playbackSpeed)), + ) override val preferences: Flow = flux diff --git a/app/src/test/java/app/waveflow/ui/player/PlaybackSpeedSheetTest.kt b/app/src/test/java/app/waveflow/ui/player/PlaybackSpeedSheetTest.kt index cdadae2..0188443 100644 --- a/app/src/test/java/app/waveflow/ui/player/PlaybackSpeedSheetTest.kt +++ b/app/src/test/java/app/waveflow/ui/player/PlaybackSpeedSheetTest.kt @@ -42,6 +42,18 @@ class PlaybackSpeedSheetTest { assertEquals(1.75f, choisie!!, 0f) } + @Test + fun `une vitesse absente des propositions apparait quand meme`() { + // Le format persisté ne se limite pas aux sept propositions : une + // version future peut offrir un réglage plus fin. La feuille s'ouvrirait + // alors sans rien de coché, à contredire le bouton qui l'a ouverte. + compose.setContent { + PlaybackSpeedSheet(speed = 1.1f, onPick = {}, onDismiss = {}) + } + + compose.onNodeWithText("×1,1").assertIsSelected() + } + @Test fun `la vitesse normale se nomme, pour qu'on sache ou revenir`() { // « ×1 » seul, au milieu de six autres nombres, ne signale pas qu'il est From 9c3d279de9effe8d9620cfd6d34914974988144e Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Mon, 7 Sep 2026 16:13:24 +0200 Subject: [PATCH 3/4] =?UTF-8?q?fix(reglages):=20le=20th=C3=A8me=20non=20pl?= =?UTF-8?q?us=20n'emporte=20l'application=20quand=20le=20disque=20c=C3=A8d?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setTheme portait le même défaut que setPlaybackSpeed avant la revue : une IOException remontait dans la portée du ViewModel, dépourvue de gestionnaire, et faisait tomber l'application — pour un thème. Il préexistait à la vitesse de lecture ; le corriger ici évite de laisser derrière soi la moitié d'un filet. La retenue est factorisée plutôt que recopiée : les deux écritures passent par `ecrire`, qui nomme au journal le réglage perdu. Les deux cas s'éprouvent séparément. Réunis dans un même test, le premier appel lèverait pour les deux et le second ne serait jamais atteint — la protection de la seconde écriture aurait pu disparaître sans que rien ne tombe. Claude-Session: https://claude.ai/code/session_01YXSdDq15CsKFvy1WXWGK6i --- .../app/waveflow/data/PreferencesStore.kt | 27 ++++++++++++------- .../app/waveflow/data/PreferencesStoreTest.kt | 26 +++++++++++++----- 2 files changed, 37 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/app/waveflow/data/PreferencesStore.kt b/app/src/main/java/app/waveflow/data/PreferencesStore.kt index 4db3a5a..69d92e1 100644 --- a/app/src/main/java/app/waveflow/data/PreferencesStore.kt +++ b/app/src/main/java/app/waveflow/data/PreferencesStore.kt @@ -3,6 +3,7 @@ package app.waveflow.data import android.content.Context import android.util.Log import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.MutablePreferences import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.emptyPreferences @@ -99,9 +100,8 @@ class DataStorePreferencesStore( .catch { emit(emptyPreferences()) } .map { it.toAppPreferences() } - override suspend fun setTheme(choice: ThemeChoice) { - dataStore.edit { it[THEME] = choice.name } - } + override suspend fun setTheme(choice: ThemeChoice) = + ecrire("Thème") { it[THEME] = choice.name } /** * La vitesse est bornée à l'écriture **et** à la relecture. @@ -110,23 +110,32 @@ class DataStorePreferencesStore( * de ce que l'appelant apporte, borner en lisant protège l'application de * ce que le fichier contient déjà — une version future aux bornes plus * larges, ou un fichier abîmé. + */ + override suspend fun setPlaybackSpeed(speed: Float) = + ecrire("Vitesse de lecture") { it[PLAYBACK_SPEED] = PlaybackSpeed.borner(speed) } + + /** + * Écrit une préférence sans faire tomber celui qui la demande. * * L'échec d'écriture est retenu ici, comme l'est déjà celui de lecture. Les * appelants lancent dans la portée de leur ViewModel, laquelle n'a pas de * gestionnaire d'exception : un disque plein y ferait tomber l'application - * entière — pour une vitesse de lecture. Le choix est alors simplement - * perdu, ce que l'écran dit de lui-même en restant sur l'ancienne valeur, - * le flux n'ayant rien émis. + * entière — pour un thème ou une vitesse de lecture. Le choix est alors + * simplement perdu, ce que l'écran dit de lui-même en restant sur l'ancienne + * valeur, le flux n'ayant rien émis. * * `IOException` et non tout le reste : une annulation traverse elle aussi * `edit`, et l'avaler ferait survivre une écriture à la portée qui l'a * demandée. + * + * @param quoi ce qu'on tentait d'enregistrer, pour que le journal dise + * lequel des réglages a été perdu. */ - override suspend fun setPlaybackSpeed(speed: Float) { + private suspend fun ecrire(quoi: String, transform: (MutablePreferences) -> Unit) { try { - dataStore.edit { it[PLAYBACK_SPEED] = PlaybackSpeed.borner(speed) } + dataStore.edit(transform) } catch (erreur: IOException) { - Log.w(TAG, "Vitesse de lecture non enregistrée", erreur) + Log.w(TAG, "$quoi non enregistré", erreur) } } diff --git a/app/src/test/java/app/waveflow/data/PreferencesStoreTest.kt b/app/src/test/java/app/waveflow/data/PreferencesStoreTest.kt index d21df2f..8b6bb04 100644 --- a/app/src/test/java/app/waveflow/data/PreferencesStoreTest.kt +++ b/app/src/test/java/app/waveflow/data/PreferencesStoreTest.kt @@ -197,11 +197,28 @@ class PreferencesStoreTest { } @Test - fun `une ecriture en echec ne fait pas tomber l'appelant`() = runTest { + fun `une vitesse qui ne s'ecrit pas ne fait pas tomber l'appelant`() = runTest { // Les appelants lancent dans la portée de leur ViewModel, qui n'a pas de // gestionnaire d'exception : sans cette retenue, un disque plein // emporterait l'application entière — pour une vitesse de lecture. - val magasin = DataStorePreferencesStore( + // + // Ne lève pas : c'est tout ce qui est demandé. Le choix est perdu, et + // l'écran le dit en restant sur l'ancienne valeur. + magasinIncapableDEcrire().setPlaybackSpeed(1.5f) + } + + @Test + fun `un theme qui ne s'ecrit pas ne fait pas tomber l'appelant`() = runTest { + // Un test à part et non deux appels dans le même : la seule assertion + // est qu'il ne soit rien levé, et deux appels à la suite verraient le + // premier lever pour les deux. La seconde écriture n'aurait alors + // jamais été éprouvée. + magasinIncapableDEcrire().setTheme(ThemeChoice.Dark) + } + + /** Un magasin dont toute écriture échoue, la lecture restant possible. */ + private fun magasinIncapableDEcrire(): PreferencesStore = + DataStorePreferencesStore( object : DataStore { override val data: Flow = flow { emit(preferencesOf()) } override suspend fun updateData( @@ -210,11 +227,6 @@ class PreferencesStoreTest { }, ) - // Ne lève pas : c'est tout ce qui est demandé. Le choix est perdu, et - // l'écran le dit en restant sur l'ancienne valeur. - magasin.setPlaybackSpeed(1.5f) - } - /** Un magasin en lecture seule, sur un contenu écrit à la main. */ private fun magasinFige(contenu: Preferences): PreferencesStore = DataStorePreferencesStore( From b6ada7fa0950d25ac0b2edfb84b53c7a52930126 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Mon, 7 Sep 2026 16:30:50 +0200 Subject: [PATCH 4/4] =?UTF-8?q?fix(preferences):=20une=20cl=C3=A9=20au=20m?= =?UTF-8?q?auvais=20type=20ne=20doit=20pas=20emporter=20l'application?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `this[cle]` est un cast non vérifié : une clé portant un autre type que le sien y lève une ClassCastException. Elle survenait **après** le `catch`, posé en amont de la conversion — donc rien ne la retenait, et elle remontait dans la portée du ViewModel, dépourvue de gestionnaire. Un test le montrait avant correction. Les valeurs se lisent maintenant par la carte, avec un `as?` : le défaut est rendu sans rien lever. Déplacer le `catch` en aval du `map` aurait suffi à ne plus tomber, mais aurait terminé le flux — ce que la KDoc défend explicitement, un flux terminé figeant le partage en aval : l'utilisateur changerait encore de thème sans que rien ne bouge. Ici le fichier reste relu, et seule la clé fautive retombe sur son défaut. Le trou vaut pour le thème comme pour la vitesse, et précède celle-ci. Les deux lectures s'éprouvent séparément : réunies, la première lèverait pour les deux et la seconde ne serait jamais atteinte. Claude-Session: https://claude.ai/code/session_01YXSdDq15CsKFvy1WXWGK6i --- .../app/waveflow/data/PreferencesStore.kt | 30 ++++++++++++++----- .../app/waveflow/data/PreferencesStoreTest.kt | 25 ++++++++++++++++ 2 files changed, 47 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/app/waveflow/data/PreferencesStore.kt b/app/src/main/java/app/waveflow/data/PreferencesStore.kt index 69d92e1..77814e4 100644 --- a/app/src/main/java/app/waveflow/data/PreferencesStore.kt +++ b/app/src/main/java/app/waveflow/data/PreferencesStore.kt @@ -147,14 +147,28 @@ class DataStorePreferencesStore( * alors une valeur qu'elle ne connaît pas. Lever ici rendrait * l'application inutilisable pour un réglage d'apparence. */ - private fun Preferences.toAppPreferences(): AppPreferences = AppPreferences( - theme = this[THEME] - ?.let { name -> ThemeChoice.entries.firstOrNull { it.name == name } } - ?: AppPreferences().theme, - playbackSpeed = this[PLAYBACK_SPEED] - ?.let(PlaybackSpeed::borner) - ?: AppPreferences().playbackSpeed, - ) + private fun Preferences.toAppPreferences(): AppPreferences { + // Lues par la carte et non par `this[cle]`, dont le cast n'est pas + // vérifié : une clé portant un autre type que le sien y lève une + // `ClassCastException`. Elle surviendrait **après** le `catch`, posé en + // amont de cette conversion, et emporterait l'application dans la + // portée du ViewModel — pour une préférence. `as?` retombe sur le + // défaut sans rien lever, et sans terminer le flux : le fichier reste + // relu, si bien qu'un autre réglage change encore. + // + // Le cas se présente si une version future change le type d'une clé + // puis qu'on redescend. + val valeurs = asMap() + + return AppPreferences( + theme = (valeurs[THEME] as? String) + ?.let { name -> ThemeChoice.entries.firstOrNull { it.name == name } } + ?: AppPreferences().theme, + playbackSpeed = (valeurs[PLAYBACK_SPEED] as? Float) + ?.let(PlaybackSpeed::borner) + ?: AppPreferences().playbackSpeed, + ) + } private companion object { const val TAG = "PreferencesStore" diff --git a/app/src/test/java/app/waveflow/data/PreferencesStoreTest.kt b/app/src/test/java/app/waveflow/data/PreferencesStoreTest.kt index 8b6bb04..3a488fc 100644 --- a/app/src/test/java/app/waveflow/data/PreferencesStoreTest.kt +++ b/app/src/test/java/app/waveflow/data/PreferencesStoreTest.kt @@ -227,6 +227,31 @@ class PreferencesStoreTest { }, ) + @Test + fun `une vitesse d'un autre type que le sien ne fait pas tomber la collecte`() = runTest { + // Le fichier peut porter une clé au type qu'on n'attend pas : une + // version future qui l'aurait changée, puis un retour en arrière. La + // lecture par `this[cle]` est un cast non vérifié, et ce qu'elle lève + // passerait **après** le `catch`, posé en amont de la conversion. + val magasin = magasinFige(preferencesOf(stringPreferencesKey("playback_speed") to "vite")) + + val prefs = magasin.preferences.first() + + assertEquals(PlaybackSpeed.NORMALE, prefs.playbackSpeed, 0f) + } + + @Test + fun `un theme d'un autre type que le sien ne fait pas tomber la collecte`() = runTest { + // Un test à part : les deux lectures sont distinctes, et réunies ici la + // première lèverait pour les deux — la seconde n'aurait jamais été + // éprouvée. Ce trou-là précède la vitesse de lecture. + val magasin = magasinFige(preferencesOf(floatPreferencesKey("theme") to 2f)) + + val prefs = magasin.preferences.first() + + assertEquals(ThemeChoice.System, prefs.theme) + } + /** Un magasin en lecture seule, sur un contenu écrit à la main. */ private fun magasinFige(contenu: Preferences): PreferencesStore = DataStorePreferencesStore(