From 5dde06d4d9948bbddec8e9162bb4a1744611bcc5 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Wed, 19 Aug 2026 00:41:59 +0200 Subject: [PATCH 1/4] feat(cache): montrer le cache de lecture et permettre de le vider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le cache grossissait jusqu'à 200 Mo sans que rien ne le dise ni ne permette de le reprendre. L'écran du compte serveur l'affiche désormais — place occupée sur plafond — et propose de le vider, derrière une confirmation : rien n'est perdu, mais tout est à retélécharger. Sa place est sous le compte plutôt que dans des réglages généraux, qui n'existent pas : le cache ne contient que des pistes du serveur. Le cache passe du service au conteneur applicatif. `SimpleCache` refuse d'ouvrir deux fois le même répertoire, et l'écran doit pouvoir le lire pendant que le service tourne ; une seule instance pour le processus règle les deux. Elle n'est plus relâchée à la destruction du service — son verrou tombe avec le processus, et le service redémarre sur la même. `PlaybackCache` réduit le cache à ce dont l'écran a besoin : un plafond, une mesure, un vidage. Le ViewModel s'éprouve ainsi sans ouvrir de vrai répertoire. Deux détails d'affichage tiennent à ce qu'un chiffre faux coûterait. Une taille pas encore mesurée s'annonce comme telle et non par un zéro, qui ferait croire à un cache vide. Et une mesure qui échoue garde la dernière valeur connue, pour la même raison. Validation par retrait, un test chacun : neutraliser le vidage, la confirmation, la valeur inconnue à l'ouverture ou le repli de la mesure fait tomber exactement le test qui les couvre, et lui seul sur 269. Sur émulateur, avec le vrai serveur : 773 kB affichés après trois pistes lues, 0 B après vidage, bouton désactivé — et sur le disque 828K puis 44K, ce qui reste étant l'index et non des médias. --- .../main/java/app/waveflow/MainActivity.kt | 10 ++ app/src/main/java/app/waveflow/WaveFlowApp.kt | 11 ++ .../app/waveflow/playback/PlaybackCache.kt | 20 +++ .../app/waveflow/playback/PlaybackService.kt | 11 +- .../app/waveflow/playback/RemoteMediaCache.kt | 29 ++++- .../app/waveflow/ui/cache/CacheSection.kt | 122 ++++++++++++++++++ .../app/waveflow/ui/cache/CacheViewModel.kt | 99 ++++++++++++++ .../app/waveflow/ui/server/ServerScreen.kt | 20 ++- .../waveflow/playback/RemoteMediaCacheTest.kt | 35 +++++ .../waveflow/ui/cache/CacheViewModelTest.kt | 120 +++++++++++++++++ .../waveflow/ui/server/ServerScreenTest.kt | 49 ++++++- 11 files changed, 514 insertions(+), 12 deletions(-) create mode 100644 app/src/main/java/app/waveflow/playback/PlaybackCache.kt create mode 100644 app/src/main/java/app/waveflow/ui/cache/CacheSection.kt create mode 100644 app/src/main/java/app/waveflow/ui/cache/CacheViewModel.kt create mode 100644 app/src/test/java/app/waveflow/ui/cache/CacheViewModelTest.kt diff --git a/app/src/main/java/app/waveflow/MainActivity.kt b/app/src/main/java/app/waveflow/MainActivity.kt index d7bc763..501ae04 100644 --- a/app/src/main/java/app/waveflow/MainActivity.kt +++ b/app/src/main/java/app/waveflow/MainActivity.kt @@ -52,6 +52,7 @@ import app.waveflow.ui.browse.AlbumDetailScreen import app.waveflow.ui.browse.AlbumsScreen import app.waveflow.ui.browse.ArtistDetailScreen import app.waveflow.ui.browse.ArtistsScreen +import app.waveflow.ui.cache.CacheViewModel import app.waveflow.ui.library.LibraryScreen import app.waveflow.ui.library.LibraryViewModel import app.waveflow.ui.navigation.Routes @@ -113,6 +114,7 @@ private fun WaveFlowRoot() { val searchViewModel: SearchViewModel = viewModel(factory = SearchViewModel.Factory) val serverViewModel: ServerViewModel = viewModel(factory = ServerViewModel.Factory) val catalogViewModel: CatalogViewModel = viewModel(factory = CatalogViewModel.Factory) + val cacheViewModel: CacheViewModel = viewModel(factory = CacheViewModel.Factory) val library by libraryViewModel.library.collectAsStateWithLifecycle() val playerState by playerViewModel.state.collectAsStateWithLifecycle() @@ -120,6 +122,7 @@ private fun WaveFlowRoot() { val searchQuery by searchViewModel.query.collectAsStateWithLifecycle() val searchResults by searchViewModel.results.collectAsStateWithLifecycle() val serverState by serverViewModel.state.collectAsStateWithLifecycle() + val cacheState by cacheViewModel.state.collectAsStateWithLifecycle() // Les états du catalogue sont collectés dans leurs destinations, et non // ici : chargés à la racine, chaque page reçue recomposerait le Scaffold, // le NavHost et tous les écrans. @@ -445,12 +448,19 @@ private fun WaveFlowRoot() { composable(Routes.SERVER_ACCOUNT) { serverState.connected?.let { session -> + // La taille bouge à chaque piste lue : elle se + // relève à l'ouverture, pas une fois pour toutes. + LaunchedEffect(Unit) { cacheViewModel.refresh() } + ServerAccountScreen( session = session, + cache = cacheState, onDisconnect = { serverViewModel.disconnect() navController.popBackStack() }, + onClearCache = cacheViewModel::clear, + onDismissCacheError = cacheViewModel::dismissError, bottomPadding = listBottomPadding, ) } diff --git a/app/src/main/java/app/waveflow/WaveFlowApp.kt b/app/src/main/java/app/waveflow/WaveFlowApp.kt index 79547c1..ab8d4c9 100644 --- a/app/src/main/java/app/waveflow/WaveFlowApp.kt +++ b/app/src/main/java/app/waveflow/WaveFlowApp.kt @@ -20,6 +20,7 @@ import app.waveflow.data.remote.ServerImageAuthInterceptor import app.waveflow.data.remote.ServerSessionRepository import app.waveflow.playback.Media3PlaybackController import app.waveflow.playback.PlaybackController +import app.waveflow.playback.RemoteMediaCache import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -86,6 +87,16 @@ class AppContainer(app: Application) { */ fun createPlaybackController(): PlaybackController = Media3PlaybackController(appContext) + /** + * Cache des pistes distantes, unique pour le processus. + * + * Porté ici et non par le service : `SimpleCache` refuse d'ouvrir deux fois + * le même répertoire, et l'écran des réglages doit pouvoir en lire la taille + * et le vider pendant que le service tourne. Il vit donc aussi longtemps que + * le processus, ce qui suffit — le verrou tombe avec lui. + */ + val remoteMediaCache: RemoteMediaCache by lazy { RemoteMediaCache(appContext) } + /** * Un seul transport pour tous les appels serveur : un pool de connexions * partagé, et surtout un seul endroit qui classe les erreurs. diff --git a/app/src/main/java/app/waveflow/playback/PlaybackCache.kt b/app/src/main/java/app/waveflow/playback/PlaybackCache.kt new file mode 100644 index 0000000..cf2a633 --- /dev/null +++ b/app/src/main/java/app/waveflow/playback/PlaybackCache.kt @@ -0,0 +1,20 @@ +package app.waveflow.playback + +/** + * Ce que les réglages ont besoin de savoir du cache de lecture. + * + * Volontairement plus étroit que [RemoteMediaCache] : l'écran n'a que faire de + * la chaîne de sources, et le réduire ainsi le rend éprouvable sans ouvrir de + * vrai répertoire de cache. + */ +interface PlaybackCache { + + /** Plafond au-delà duquel les pistes les plus anciennes sont évincées. */ + val maxBytes: Long + + /** Place occupée, à l'octet près. */ + suspend fun usedBytes(): Long + + /** Retire tout ce que le cache contient. */ + suspend fun clear() +} diff --git a/app/src/main/java/app/waveflow/playback/PlaybackService.kt b/app/src/main/java/app/waveflow/playback/PlaybackService.kt index 203dc4e..c27005d 100644 --- a/app/src/main/java/app/waveflow/playback/PlaybackService.kt +++ b/app/src/main/java/app/waveflow/playback/PlaybackService.kt @@ -27,7 +27,6 @@ import kotlinx.coroutines.cancel class PlaybackService : MediaSessionService() { private var mediaSession: MediaSession? = null - private var mediaCache: RemoteMediaCache? = null /** * Portée des chargements de pochette : ils n'ont plus de destinataire une @@ -43,8 +42,7 @@ class PlaybackService : MediaSessionService() { // où le lecteur en a besoin, et le cache s'intercale avant lui pour // qu'une piste déjà lue ne redemande ni ticket ni octets. val container = (application as WaveFlowApp).container - mediaCache = RemoteMediaCache(this) - val dataSourceFactory = mediaCache!!.dataSourceFactory( + val dataSourceFactory = container.remoteMediaCache.dataSourceFactory( RemoteStreamResolver(container.catalogRepository), ) @@ -93,10 +91,9 @@ class PlaybackService : MediaSessionService() { } mediaSession = null artworkScope.cancel() - // Le verrou du répertoire de cache subsisterait sans ça, et la - // prochaine ouverture échouerait. - mediaCache?.release() - mediaCache = null + // Le cache n'est pas relâché ici : il appartient au conteneur, qui le + // partage avec l'écran des réglages. Son verrou tombe avec le processus, + // et le service peut redémarrer sur la même instance. super.onDestroy() } } diff --git a/app/src/main/java/app/waveflow/playback/RemoteMediaCache.kt b/app/src/main/java/app/waveflow/playback/RemoteMediaCache.kt index 153370c..51796be 100644 --- a/app/src/main/java/app/waveflow/playback/RemoteMediaCache.kt +++ b/app/src/main/java/app/waveflow/playback/RemoteMediaCache.kt @@ -9,6 +9,8 @@ import androidx.media3.datasource.ResolvingDataSource import androidx.media3.datasource.cache.CacheDataSource import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor import androidx.media3.datasource.cache.SimpleCache +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import java.io.File /** @@ -21,7 +23,7 @@ import java.io.File * Le répertoire est celui du cache applicatif : le système peut le vider quand * la place manque, ce qui est exactement le contrat qu'on veut ici. */ -class RemoteMediaCache(context: Context) { +class RemoteMediaCache(context: Context) : PlaybackCache { private val appContext = context.applicationContext @@ -62,7 +64,30 @@ class RemoteMediaCache(context: Context) { return DefaultDataSource.Factory(appContext, cached) } - /** À la destruction du service : sans quoi le verrou du répertoire subsiste. */ + override val maxBytes: Long = MAX_BYTES + + /** + * Place occupée, à l'octet près. + * + * Lue depuis l'index du cache et non par un parcours du répertoire : c'est + * le même chiffre que celui sur lequel l'éviction se déclenche. + */ + override suspend fun usedBytes(): Long = withContext(Dispatchers.IO) { cache.cacheSpace } + + /** + * Vide le cache de tout ce qu'il contient. + * + * Rien n'est perdu : chaque piste reste sur le serveur, et une lecture la + * retéléchargera. Une lecture en cours n'est pas interrompue — elle tient + * déjà ses fichiers ouverts, et les supprimer ne les lui retire pas. + */ + override suspend fun clear() = withContext(Dispatchers.IO) { + // `keys` reflète l'index à l'instant de l'appel ; en prendre une copie + // évite de le parcourir pendant qu'on le modifie. + cache.keys.toList().forEach { cache.removeResource(it) } + } + + /** À la fermeture : sans quoi le verrou du répertoire subsiste. */ fun release() { cache.release() } diff --git a/app/src/main/java/app/waveflow/ui/cache/CacheSection.kt b/app/src/main/java/app/waveflow/ui/cache/CacheSection.kt new file mode 100644 index 0000000..4427a44 --- /dev/null +++ b/app/src/main/java/app/waveflow/ui/cache/CacheSection.kt @@ -0,0 +1,122 @@ +package app.waveflow.ui.cache + +import android.text.format.Formatter +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Card +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp + +/** + * Le cache de lecture, montré et vidable. + * + * Il ne contient que des pistes du serveur — les fichiers de l'appareil n'y + * passent jamais — d'où sa place sous le compte plutôt que dans des réglages + * généraux qui n'existent pas. + */ +@Composable +fun CacheSection( + state: CacheUiState, + onClear: () -> Unit, + onDismissError: () -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + var confirmation by remember { mutableStateOf(false) } + + Card(modifier = modifier.fillMaxWidth()) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + Text( + text = "Cache de lecture", + style = MaterialTheme.typography.titleMedium, + ) + + Text( + text = when { + state.usedBytes == null -> "Calcul de la place occupée…" + else -> "%s occupés sur %s".format( + Formatter.formatShortFileSize(context, state.usedBytes), + Formatter.formatShortFileSize(context, state.maxBytes), + ) + }, + style = MaterialTheme.typography.bodyMedium, + ) + + if (state.usedBytes != null && state.maxBytes > 0L) { + LinearProgressIndicator( + progress = { + (state.usedBytes.toFloat() / state.maxBytes).coerceIn(0f, 1f) + }, + modifier = Modifier.fillMaxWidth(), + ) + } + + Text( + text = "Les pistes déjà écoutées depuis le serveur repartent d'ici plutôt " + + "que du réseau. Les vider ne perd rien : elles restent sur le serveur.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + state.errorMessage?.let { message -> + Text( + text = message, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + + TextButton( + // Rien à vider, ou vidage déjà en cours. + enabled = !state.isClearing && state.usedBytes != null && !state.isEmpty, + onClick = { confirmation = true }, + ) { + Text(if (state.isClearing) "Vidage…" else "Vider le cache") + } + } + } + + if (confirmation) { + AlertDialog( + onDismissRequest = { confirmation = false }, + title = { Text("Vider le cache ?") }, + // Ce que ça coûte vraiment : du réseau, pas de la musique perdue. + text = { + Text( + "Les pistes déjà téléchargées seront reprises au serveur à la " + + "prochaine écoute.", + ) + }, + confirmButton = { + TextButton( + onClick = { + confirmation = false + onDismissError() + onClear() + }, + ) { Text("Vider") } + }, + dismissButton = { + TextButton(onClick = { confirmation = false }) { Text("Annuler") } + }, + ) + } +} diff --git a/app/src/main/java/app/waveflow/ui/cache/CacheViewModel.kt b/app/src/main/java/app/waveflow/ui/cache/CacheViewModel.kt new file mode 100644 index 0000000..76bf7cc --- /dev/null +++ b/app/src/main/java/app/waveflow/ui/cache/CacheViewModel.kt @@ -0,0 +1,99 @@ +package app.waveflow.ui.cache + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import app.waveflow.WaveFlowApp +import app.waveflow.playback.PlaybackCache +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +/** + * Ce que l'écran sait du cache de lecture. + * + * @property usedBytes place occupée, `null` tant que la première mesure n'est + * pas revenue — l'écran affiche alors une attente plutôt qu'un zéro trompeur. + * @property maxBytes plafond au-delà duquel les pistes les plus anciennes sont + * évincées. + */ +data class CacheUiState( + val usedBytes: Long? = null, + val maxBytes: Long = 0L, + val isClearing: Boolean = false, + val errorMessage: String? = null, +) { + /** Rien à vider : le bouton n'a alors aucun effet à proposer. */ + val isEmpty: Boolean get() = usedBytes == 0L +} + +/** Le cache des pistes distantes, vu depuis les réglages. */ +class CacheViewModel( + private val cache: PlaybackCache, +) : ViewModel() { + + private val _state = MutableStateFlow(CacheUiState(maxBytes = cache.maxBytes)) + val state: StateFlow = _state.asStateFlow() + + /** À l'ouverture de l'écran : la taille bouge à chaque piste lue. */ + fun refresh() { + viewModelScope.launch { + _state.update { it.copy(usedBytes = mesurer()) } + } + } + + fun clear() { + if (_state.value.isClearing) return + _state.update { it.copy(isClearing = true, errorMessage = null) } + + viewModelScope.launch { + val echec = try { + cache.clear() + null + } catch (cancellation: CancellationException) { + throw cancellation + } catch (error: Throwable) { + // Un fichier verrouillé ou un index abîmé ne doit pas faire + // tomber l'écran. La mesure qui suit dira ce qui reste. + Log.w(TAG, "Vidage du cache incomplet", error) + "Le cache n'a pas pu être entièrement vidé." + } + + _state.update { + it.copy(usedBytes = mesurer(), isClearing = false, errorMessage = echec) + } + } + } + + fun dismissError() { + _state.update { it.copy(errorMessage = null) } + } + + /** Une mesure qui échoue laisse l'ancienne valeur plutôt qu'un chiffre faux. */ + private suspend fun mesurer(): Long? = try { + cache.usedBytes() + } catch (cancellation: CancellationException) { + throw cancellation + } catch (error: Throwable) { + Log.w(TAG, "Taille du cache illisible", error) + _state.value.usedBytes + } + + companion object { + private const val TAG = "CacheViewModel" + + val Factory: ViewModelProvider.Factory = viewModelFactory { + initializer { + val app = this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] + as WaveFlowApp + CacheViewModel(cache = app.container.remoteMediaCache) + } + } + } +} diff --git a/app/src/main/java/app/waveflow/ui/server/ServerScreen.kt b/app/src/main/java/app/waveflow/ui/server/ServerScreen.kt index 696664e..1dd7134 100644 --- a/app/src/main/java/app/waveflow/ui/server/ServerScreen.kt +++ b/app/src/main/java/app/waveflow/ui/server/ServerScreen.kt @@ -45,6 +45,8 @@ import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import app.waveflow.model.ServerSession +import app.waveflow.ui.cache.CacheSection +import app.waveflow.ui.cache.CacheUiState /** * Ouverture d'une session serveur. @@ -67,16 +69,32 @@ fun ServerSignInScreen( } } -/** Le compte connecté, et la sortie. */ +/** + * Le compte connecté, la sortie, et le cache des pistes qu'il a servies. + * + * Le cache trouve sa place ici plutôt que dans des réglages généraux, qui + * n'existent pas : il ne contient que des pistes du serveur. + */ @Composable fun ServerAccountScreen( session: ServerSession.Connected, + cache: CacheUiState, onDisconnect: () -> Unit, + onClearCache: () -> Unit, + onDismissCacheError: () -> Unit, modifier: Modifier = Modifier, bottomPadding: Dp = 0.dp, ) { ScrollableColumn(modifier = modifier, bottomPadding = bottomPadding) { ConnectedAccount(session = session, onDisconnect = onDisconnect) + + Spacer(Modifier.height(24.dp)) + + CacheSection( + state = cache, + onClear = onClearCache, + onDismissError = onDismissCacheError, + ) } } diff --git a/app/src/test/java/app/waveflow/playback/RemoteMediaCacheTest.kt b/app/src/test/java/app/waveflow/playback/RemoteMediaCacheTest.kt index c8e1e55..8607e00 100644 --- a/app/src/test/java/app/waveflow/playback/RemoteMediaCacheTest.kt +++ b/app/src/test/java/app/waveflow/playback/RemoteMediaCacheTest.kt @@ -11,9 +11,11 @@ import androidx.media3.datasource.ResolvingDataSource import androidx.test.core.app.ApplicationProvider import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer +import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -211,6 +213,39 @@ class RemoteMediaCacheTest { assertEquals("aucune requête réseau", 0, server.requestCount) fichier.delete() } + + @Test + fun `la place annoncee est celle qu'occupent les pistes mises en cache`() = runTest { + // Le chiffre que l'écran des réglages affiche : s'il ne suivait pas ce + // qui entre réellement, il n'aiderait personne à décider de vider. + val contenu = "des octets audio".toByteArray() + server.enqueue(MockResponse().setBody(String(contenu))) + assertEquals("un cache neuf n'occupe rien", 0L, mediaCache.usedBytes()) + + lire(mediaCache.dataSourceFactory(resolver).createDataSource(), specDe("piste-1")) + + assertEquals(contenu.size.toLong(), mediaCache.usedBytes()) + } + + @Test + fun `vider le cache renvoie la piste au reseau`() = runTest { + // Le vidage se prouve par le comportement, pas par un compteur remis à + // zéro : la même piste doit repartir chercher ses octets et son ticket. + val contenu = "des octets audio" + repeat(2) { server.enqueue(MockResponse().setBody(contenu)) } + val factory = mediaCache.dataSourceFactory(resolver) + + lire(factory.createDataSource(), specDe("piste-1")) + assertTrue("rien n'a été mis en cache", mediaCache.usedBytes() > 0L) + + mediaCache.clear() + + assertEquals(0L, mediaCache.usedBytes()) + val relu = lire(factory.createDataSource(), specDe("piste-1")) + assertEquals(contenu, String(relu)) + assertEquals("la seconde lecture doit repartir au réseau", 2, server.requestCount) + assertEquals("et redemander un ticket", 2, ticketsDemandes) + } } private const val AUTORITE = "app.waveflow.test.audio" diff --git a/app/src/test/java/app/waveflow/ui/cache/CacheViewModelTest.kt b/app/src/test/java/app/waveflow/ui/cache/CacheViewModelTest.kt new file mode 100644 index 0000000..13e79dc --- /dev/null +++ b/app/src/test/java/app/waveflow/ui/cache/CacheViewModelTest.kt @@ -0,0 +1,120 @@ +package app.waveflow.ui.cache + +import app.waveflow.playback.PlaybackCache +import app.waveflow.testing.MainDispatcherRule +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * Le cache vu depuis les réglages. + * + * Ce qui compte ici n'est pas le vidage — c'est le cache qui le fait, et + * `RemoteMediaCacheTest` le prouve sur la vraie chaîne Media3 — mais ce que + * l'écran affiche quand il échoue, et qu'il ne se referme pas dessus. + */ +@RunWith(RobolectricTestRunner::class) +class CacheViewModelTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + private class CacheFactice( + override val maxBytes: Long = 1_000L, + var occupe: Long = 0L, + var echoueAuVidage: Throwable? = null, + var echoueALaMesure: Throwable? = null, + ) : PlaybackCache { + + var vidages = 0 + private set + + override suspend fun usedBytes(): Long { + echoueALaMesure?.let { throw it } + return occupe + } + + override suspend fun clear() { + vidages++ + echoueAuVidage?.let { throw it } + occupe = 0L + } + } + + @Test + fun `avant toute mesure la taille reste inconnue`() { + // Et non pas zéro : l'écran doit pouvoir annoncer l'attente. + val vm = CacheViewModel(CacheFactice(occupe = 800L)) + + assertNull(vm.state.value.usedBytes) + assertEquals(1_000L, vm.state.value.maxBytes) + } + + @Test + fun `la mesure publie la place occupee`() = runTest(mainDispatcherRule.dispatcher) { + val vm = CacheViewModel(CacheFactice(occupe = 800L)) + + vm.refresh() + + assertEquals(800L, vm.state.value.usedBytes) + } + + @Test + fun `vider republie une taille a jour`() = runTest(mainDispatcherRule.dispatcher) { + val cache = CacheFactice(occupe = 800L) + val vm = CacheViewModel(cache) + vm.refresh() + + vm.clear() + + assertEquals(1, cache.vidages) + assertEquals(0L, vm.state.value.usedBytes) + assertFalse(vm.state.value.isClearing) + assertNull(vm.state.value.errorMessage) + } + + @Test + fun `un vidage qui echoue se dit sans faire tomber l'ecran`() { + // Un fichier verrouillé, un index abîmé : l'écran doit le signaler et + // rester utilisable, pas propager l'exception. + val cache = CacheFactice(occupe = 800L, echoueAuVidage = IllegalStateException("index")) + val vm = CacheViewModel(cache) + + vm.clear() + + assertNotNull(vm.state.value.errorMessage) + assertFalse("l'écran doit se rendre à nouveau utilisable", vm.state.value.isClearing) + // La mesure qui suit dit ce qui reste vraiment. + assertEquals(800L, vm.state.value.usedBytes) + } + + @Test + fun `une mesure illisible garde la derniere valeur connue`() { + // Afficher zéro ferait croire à un cache vide alors qu'il est plein. + val cache = CacheFactice(occupe = 800L) + val vm = CacheViewModel(cache) + vm.refresh() + + cache.echoueALaMesure = IllegalStateException("index") + vm.refresh() + + assertEquals(800L, vm.state.value.usedBytes) + } + + @Test + fun `le message d'erreur se referme`() { + val vm = CacheViewModel(CacheFactice(echoueAuVidage = IllegalStateException("index"))) + vm.clear() + assertNotNull(vm.state.value.errorMessage) + + vm.dismissError() + + assertNull(vm.state.value.errorMessage) + } +} diff --git a/app/src/test/java/app/waveflow/ui/server/ServerScreenTest.kt b/app/src/test/java/app/waveflow/ui/server/ServerScreenTest.kt index afef372..ec63460 100644 --- a/app/src/test/java/app/waveflow/ui/server/ServerScreenTest.kt +++ b/app/src/test/java/app/waveflow/ui/server/ServerScreenTest.kt @@ -11,6 +11,7 @@ import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextInput import app.waveflow.model.ServerSession +import app.waveflow.ui.cache.CacheUiState import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Rule @@ -29,6 +30,7 @@ class ServerScreenTest { private val connexions = mutableListOf>() private var deconnexions = 0 + private var vidages = 0 private val session = ServerSession.Connected( serverUrl = "https://musique.test", @@ -50,9 +52,15 @@ class ServerScreenTest { } /** Écran de compte : atteint depuis la barre du haut, une fois connecté. */ - private fun afficherCompte() { + private fun afficherCompte(cache: CacheUiState = CacheUiState(usedBytes = 0L)) { compose.setContent { - ServerAccountScreen(session = session, onDisconnect = { deconnexions++ }) + ServerAccountScreen( + session = session, + cache = cache, + onDisconnect = { deconnexions++ }, + onClearCache = { vidages++ }, + onDismissCacheError = {}, + ) } } @@ -151,4 +159,41 @@ class ServerScreenTest { assertEquals(1, deconnexions) assertTrue(connexions.isEmpty()) } + + @Test + fun `tant que la taille n'est pas connue l'ecran le dit`() { + // Zéro serait un mensonge : le cache peut être plein et la mesure en + // cours. L'attente s'annonce, et le bouton reste inactif. + afficherCompte(cache = CacheUiState(usedBytes = null)) + + compose.onNodeWithText("Calcul de la place occupée…").assertIsDisplayed() + compose.onNodeWithText("Vider le cache").assertIsNotEnabled() + } + + @Test + fun `un cache vide ne propose pas de le vider`() { + afficherCompte(cache = CacheUiState(usedBytes = 0L, maxBytes = 1_024L)) + + assertEquals(0, compose.occurrencesDe("Calcul de la place occupée")) + compose.onNodeWithText("Vider le cache").assertIsNotEnabled() + } + + @Test + fun `vider le cache demande confirmation avant d'agir`() { + // Vider coûte du réseau à la prochaine écoute : le geste ne doit pas + // partir d'un seul appui. + afficherCompte(cache = CacheUiState(usedBytes = 5_000L, maxBytes = 1_024L * 1_024L)) + + compose.onNodeWithText("Vider le cache").performClick() + assertEquals("le vidage ne doit pas partir tout seul", 0, vidages) + + compose.onNodeWithText("Vider le cache ?").assertIsDisplayed() + compose.onNodeWithText("Annuler").performClick() + assertEquals("annuler ne vide rien", 0, vidages) + + compose.onNodeWithText("Vider le cache").performClick() + compose.onNodeWithText("Vider").performClick() + + assertEquals(1, vidages) + } } From 916ab03e3975ee1e38208f97acb711141b3d00ed Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Wed, 19 Aug 2026 00:51:19 +0200 Subject: [PATCH 2/4] fix(cache): mesurer avant de publier, et non dans la lambda d'update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `MutableStateFlow.update` rejoue sa lambda quand l'état a bougé entre-temps — c'est une boucle de compare-and-set. La mesure du cache y était appelée alors qu'elle suspend le temps d'un accès disque : un changement d'état pendant cette suspension la faisait repartir sur le disque pour rien. Les deux chemins étaient concernés, `refresh` comme la reprise après vidage. Le test rend la course déterministe : la mesure attend derrière une barrière, l'état change pendant, puis on la libère. Remettre l'appel dans la lambda le fait tomber sur trois mesures au lieu de deux. --- .../app/waveflow/ui/cache/CacheViewModel.kt | 9 +++-- .../waveflow/ui/cache/CacheViewModelTest.kt | 36 +++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/app/waveflow/ui/cache/CacheViewModel.kt b/app/src/main/java/app/waveflow/ui/cache/CacheViewModel.kt index 76bf7cc..62470d3 100644 --- a/app/src/main/java/app/waveflow/ui/cache/CacheViewModel.kt +++ b/app/src/main/java/app/waveflow/ui/cache/CacheViewModel.kt @@ -44,7 +44,11 @@ class CacheViewModel( /** À l'ouverture de l'écran : la taille bouge à chaque piste lue. */ fun refresh() { viewModelScope.launch { - _state.update { it.copy(usedBytes = mesurer()) } + // Mesurer d'abord, publier ensuite. `update` rejoue sa lambda quand + // l'état a bougé entre-temps, et la mesure suspend le temps d'un + // accès disque : la laisser dedans la ferait repartir pour rien. + val mesure = mesurer() + _state.update { it.copy(usedBytes = mesure) } } } @@ -65,8 +69,9 @@ class CacheViewModel( "Le cache n'a pas pu être entièrement vidé." } + val mesure = mesurer() _state.update { - it.copy(usedBytes = mesurer(), isClearing = false, errorMessage = echec) + it.copy(usedBytes = mesure, isClearing = false, errorMessage = echec) } } } diff --git a/app/src/test/java/app/waveflow/ui/cache/CacheViewModelTest.kt b/app/src/test/java/app/waveflow/ui/cache/CacheViewModelTest.kt index 13e79dc..049209b 100644 --- a/app/src/test/java/app/waveflow/ui/cache/CacheViewModelTest.kt +++ b/app/src/test/java/app/waveflow/ui/cache/CacheViewModelTest.kt @@ -2,6 +2,7 @@ package app.waveflow.ui.cache import app.waveflow.playback.PlaybackCache import app.waveflow.testing.MainDispatcherRule +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -35,7 +36,15 @@ class CacheViewModelTest { var vidages = 0 private set + var mesures = 0 + private set + + /** Posée par un test, elle retient la mesure le temps voulu. */ + var barriere: CompletableDeferred? = null + override suspend fun usedBytes(): Long { + mesures++ + barriere?.await() echoueALaMesure?.let { throw it } return occupe } @@ -107,6 +116,33 @@ class CacheViewModelTest { assertEquals(800L, vm.state.value.usedBytes) } + @Test + fun `un etat qui bouge pendant la mesure ne la fait pas repartir`() { + // `MutableStateFlow.update` rejoue sa lambda quand l'état a changé + // entre-temps. La mesure suspend le temps d'un accès disque : la placer + // dans cette lambda la ferait repartir sur le disque pour rien. + // + // On rend la course déterministe — la mesure attend, l'état change, + // puis on la libère. + val cache = CacheFactice(occupe = 800L, echoueAuVidage = IllegalStateException("index")) + val vm = CacheViewModel(cache) + // Un échec d'abord, pour avoir un message à retirer ensuite : sans + // changement réel, l'état resterait égal et le CAS ne rejouerait rien. + vm.clear() + assertNotNull(vm.state.value.errorMessage) + + val barriere = CompletableDeferred() + cache.barriere = barriere + val avant = cache.mesures + + vm.refresh() + vm.dismissError() + barriere.complete(Unit) + + assertEquals("la mesure ne doit pas être rejouée", avant + 1, cache.mesures) + assertEquals(800L, vm.state.value.usedBytes) + } + @Test fun `le message d'erreur se referme`() { val vm = CacheViewModel(CacheFactice(echoueAuVidage = IllegalStateException("index"))) From 114b8280d5d8fd74dd33b76707d16137d5fec27b Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Wed, 19 Aug 2026 01:01:31 +0200 Subject: [PATCH 3/4] fix(cache): n'attraper que les Exception, pas les Error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Throwable` couvre aussi `OutOfMemoryError` et consorts : les avaler pour afficher « le cache n'a pas pu être vidé » masquerait un processus déjà perdu, et rendrait la panne bien plus difficile à comprendre. `CancellationException` étant une `Exception`, la relance explicite qui la précède garde son sens. Tous les autres ViewModels du dépôt attrapent déjà `Exception` ; celui-ci était le seul à ne pas le faire. Comportement inchangé pour ce qui est réellement rattrapable — les tests d'échec passent à l'identique, et faire relancer la capture au lieu de rendre un message les fait toujours tomber. --- app/src/main/java/app/waveflow/ui/cache/CacheViewModel.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/app/waveflow/ui/cache/CacheViewModel.kt b/app/src/main/java/app/waveflow/ui/cache/CacheViewModel.kt index 62470d3..37c8321 100644 --- a/app/src/main/java/app/waveflow/ui/cache/CacheViewModel.kt +++ b/app/src/main/java/app/waveflow/ui/cache/CacheViewModel.kt @@ -62,7 +62,7 @@ class CacheViewModel( null } catch (cancellation: CancellationException) { throw cancellation - } catch (error: Throwable) { + } catch (error: Exception) { // Un fichier verrouillé ou un index abîmé ne doit pas faire // tomber l'écran. La mesure qui suit dira ce qui reste. Log.w(TAG, "Vidage du cache incomplet", error) @@ -85,7 +85,7 @@ class CacheViewModel( cache.usedBytes() } catch (cancellation: CancellationException) { throw cancellation - } catch (error: Throwable) { + } catch (error: Exception) { Log.w(TAG, "Taille du cache illisible", error) _state.value.usedBytes } From bdf763c688e344526f900d4c118acf987057565f Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Wed, 19 Aug 2026 09:48:09 +0200 Subject: [PATCH 4/4] =?UTF-8?q?fix(cache):=20s=C3=A9rialiser=20la=20mesure?= =?UTF-8?q?=20et=20le=20vidage=20du=20cache?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Une mesure lancée avant un vidage pouvait publier son résultat après lui : l'écran réaffichait alors la place que le vidage venait de libérer. Le cas est atteignable — revenir sur l'écran du compte relance `refresh()`, et le bouton reste actif tant que la taille précédente est connue. Un `Mutex` couvre désormais l'accès au cache **et** la publication qui en découle, dans `refresh()` comme dans `clear()` : c'est leur écartement, et non l'accès seul, qui laissait passer la valeur périmée. Le faux cache relève sa taille à l'appel plutôt qu'au retour, comme le ferait un vrai accès disque — sans quoi une mesure périmée resterait inobservable. Retirer le verrou fait tomber le nouveau test, et lui seul : « expected:<0> but was:<800> ». --- .../app/waveflow/ui/cache/CacheViewModel.kt | 55 ++++++++++++------- .../waveflow/ui/cache/CacheViewModelTest.kt | 35 +++++++++++- 2 files changed, 69 insertions(+), 21 deletions(-) diff --git a/app/src/main/java/app/waveflow/ui/cache/CacheViewModel.kt b/app/src/main/java/app/waveflow/ui/cache/CacheViewModel.kt index 37c8321..f6e19a7 100644 --- a/app/src/main/java/app/waveflow/ui/cache/CacheViewModel.kt +++ b/app/src/main/java/app/waveflow/ui/cache/CacheViewModel.kt @@ -14,6 +14,8 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock /** * Ce que l'écran sait du cache de lecture. @@ -41,14 +43,27 @@ class CacheViewModel( private val _state = MutableStateFlow(CacheUiState(maxBytes = cache.maxBytes)) val state: StateFlow = _state.asStateFlow() + /** + * Sérialise les accès au cache avec la publication qui en découle. + * + * Sans lui, une mesure prise avant un vidage peut être publiée après : + * l'écran annoncerait alors une place occupée que le vidage vient de + * libérer. Le verrou couvre l'accès au cache **et** la publication, car + * c'est leur écartement qui laisse passer la valeur périmée. + */ + private val verrou = Mutex() + /** À l'ouverture de l'écran : la taille bouge à chaque piste lue. */ fun refresh() { viewModelScope.launch { - // Mesurer d'abord, publier ensuite. `update` rejoue sa lambda quand - // l'état a bougé entre-temps, et la mesure suspend le temps d'un - // accès disque : la laisser dedans la ferait repartir pour rien. - val mesure = mesurer() - _state.update { it.copy(usedBytes = mesure) } + verrou.withLock { + // Mesurer d'abord, publier ensuite. `update` rejoue sa lambda + // quand l'état a bougé entre-temps, et la mesure suspend le + // temps d'un accès disque : la laisser dedans la ferait + // repartir pour rien. + val mesure = mesurer() + _state.update { it.copy(usedBytes = mesure) } + } } } @@ -57,21 +72,23 @@ class CacheViewModel( _state.update { it.copy(isClearing = true, errorMessage = null) } viewModelScope.launch { - val echec = try { - cache.clear() - null - } catch (cancellation: CancellationException) { - throw cancellation - } catch (error: Exception) { - // Un fichier verrouillé ou un index abîmé ne doit pas faire - // tomber l'écran. La mesure qui suit dira ce qui reste. - Log.w(TAG, "Vidage du cache incomplet", error) - "Le cache n'a pas pu être entièrement vidé." - } + verrou.withLock { + val echec = try { + cache.clear() + null + } catch (cancellation: CancellationException) { + throw cancellation + } catch (error: Exception) { + // Un fichier verrouillé ou un index abîmé ne doit pas faire + // tomber l'écran. La mesure qui suit dira ce qui reste. + Log.w(TAG, "Vidage du cache incomplet", error) + "Le cache n'a pas pu être entièrement vidé." + } - val mesure = mesurer() - _state.update { - it.copy(usedBytes = mesure, isClearing = false, errorMessage = echec) + val mesure = mesurer() + _state.update { + it.copy(usedBytes = mesure, isClearing = false, errorMessage = echec) + } } } } diff --git a/app/src/test/java/app/waveflow/ui/cache/CacheViewModelTest.kt b/app/src/test/java/app/waveflow/ui/cache/CacheViewModelTest.kt index 049209b..7815526 100644 --- a/app/src/test/java/app/waveflow/ui/cache/CacheViewModelTest.kt +++ b/app/src/test/java/app/waveflow/ui/cache/CacheViewModelTest.kt @@ -44,9 +44,17 @@ class CacheViewModelTest { override suspend fun usedBytes(): Long { mesures++ - barriere?.await() + // La taille est relevée à l'appel, comme le ferait un vrai accès + // disque. C'est ce qui rend observable une mesure devenue périmée : + // elle rapporte ce qu'elle a vu, pas ce que le cache contient au + // moment où elle revient. + val instantane = occupe + // La barrière ne retient que la mesure suivante, pas toutes. + val porte = barriere + barriere = null + porte?.await() echoueALaMesure?.let { throw it } - return occupe + return instantane } override suspend fun clear() { @@ -143,6 +151,29 @@ class CacheViewModelTest { assertEquals(800L, vm.state.value.usedBytes) } + @Test + fun `une mesure commencee avant le vidage ne le contredit pas`() { + // L'écran se rouvre — donc une mesure part — et l'utilisateur vide dans + // la foulée. La mesure a relevé 800 avant le vidage ; si elle publie + // après lui, l'écran réaffiche une place que le vidage a libérée. + // + // La course est rendue déterministe : la mesure attend, le vidage + // s'intercale, puis on la libère. + val cache = CacheFactice(occupe = 800L) + val vm = CacheViewModel(cache) + + val barriere = CompletableDeferred() + cache.barriere = barriere + + vm.refresh() + vm.clear() + barriere.complete(Unit) + + assertEquals(1, cache.vidages) + assertEquals("le vidage doit avoir le dernier mot", 0L, vm.state.value.usedBytes) + assertFalse(vm.state.value.isClearing) + } + @Test fun `le message d'erreur se referme`() { val vm = CacheViewModel(CacheFactice(echoueAuVidage = IllegalStateException("index")))