From 427a4ee87e2f88212c7c78b2fdb35ab60a325a04 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Sun, 23 Aug 2026 23:31:58 +0200 Subject: [PATCH 1/4] =?UTF-8?q?feat(auto):=20exposer=20la=20biblioth=C3=A8?= =?UTF-8?q?que=20de=20l'appareil=20=C3=A0=20Android=20Auto?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `PlaybackService` devient un `MediaLibraryService` : Android Auto ne se contente pas de commander la lecture, il veut parcourir la bibliothèque. Pour l'application rien ne change — un `MediaLibrarySession` est une `MediaSession`. L'arbre ne sert que la bibliothèque de l'appareil. Le catalogue du serveur en est absent à dessein : il se demande par le réseau, page par page, et une branche qui met deux secondes à répondre ou qui se vide hors couverture est pire, au volant, qu'une branche qui n'existe pas. Le local est déjà en mémoire quand la question arrive, donc toutes les réponses sont immédiates. Bibliothèque ├── Albums ├── Artistes ├── Playlists (masquée s'il n'y en a aucune) └── Toutes les pistes `BrowseTree` et la pagination sont hors du service, donc éprouvables sans en démarrer un. Les feuilles listées n'ont pas d'URI — l'hôte les affiche, il ne les ouvre pas ; `resolve` la leur rend au moment de jouer, par le même `toMediaItem` que l'application, donc les mêmes clés de cache et le même résolveur. Les identifiants de navigation portent un préfixe `browse:` distinct de `local:` et `remote:` : un nœud ne peut pas être pris pour une piste, ni l'inverse. Le lint a trouvé deux choses justes. Une vraie erreur d'abord, corrigée : `LibraryResult.ofError` attend un `SessionError`. Puis l'absence de `MEDIA_PLAY_FROM_SEARCH`, que déclarer l'application à Auto rend exigible — la règle est rétrogradée en avertissement, avec sa raison : la recherche vocale est un chantier à part, et déclarer un filtre qu'on ne sert pas ouvrirait une porte sur une pièce vide. Un test d'ordre était creux, révélé par le retrait : ses entrées de playlist étaient déjà rangées par position, si bien que le tri ne changeait rien. Fixture remise à rebours — le test tombe désormais quand on retire `sortedBy`. Non validé sur Desktop Head Unit : l'arbre est prouvé par les tests, son rendu dans la voiture ne l'est pas encore. Claude-Session: https://claude.ai/code/session_01CHnmk73TFtaDDJWoCsLHUD --- app/lint.xml | 13 + app/src/main/AndroidManifest.xml | 13 +- .../app/waveflow/playback/BrowseCallback.kt | 98 ++++++++ .../java/app/waveflow/playback/BrowseTree.kt | 223 ++++++++++++++++++ .../app/waveflow/playback/PlaybackService.kt | 65 ++++- app/src/main/res/xml/automotive_app_desc.xml | 11 + .../app/waveflow/playback/BrowsePageTest.kt | 63 +++++ .../app/waveflow/playback/BrowseTreeTest.kt | 208 ++++++++++++++++ 8 files changed, 685 insertions(+), 9 deletions(-) create mode 100644 app/src/main/java/app/waveflow/playback/BrowseCallback.kt create mode 100644 app/src/main/java/app/waveflow/playback/BrowseTree.kt create mode 100644 app/src/main/res/xml/automotive_app_desc.xml create mode 100644 app/src/test/java/app/waveflow/playback/BrowsePageTest.kt create mode 100644 app/src/test/java/app/waveflow/playback/BrowseTreeTest.kt diff --git a/app/lint.xml b/app/lint.xml index 3fe4389..1a914fe 100644 --- a/app/lint.xml +++ b/app/lint.xml @@ -18,4 +18,17 @@ name="opt-in" value="androidx.media3.common.util.UnstableApi" /> + + + diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c2c1fa4..8ad8fd4 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -41,13 +41,24 @@ - + + + + + + + diff --git a/app/src/main/java/app/waveflow/playback/BrowseCallback.kt b/app/src/main/java/app/waveflow/playback/BrowseCallback.kt new file mode 100644 index 0000000..4426e22 --- /dev/null +++ b/app/src/main/java/app/waveflow/playback/BrowseCallback.kt @@ -0,0 +1,98 @@ +package app.waveflow.playback + +import androidx.media3.common.MediaItem +import androidx.media3.session.LibraryResult +import androidx.media3.session.MediaLibraryService.LibraryParams +import androidx.media3.session.MediaLibraryService.MediaLibrarySession +import androidx.media3.session.MediaSession +import androidx.media3.session.SessionError +import com.google.common.collect.ImmutableList +import com.google.common.util.concurrent.Futures +import com.google.common.util.concurrent.ListenableFuture + +/** + * Le pont entre [BrowseTree] et ce que Media3 attend d'une bibliothèque. + * + * Séparé du service pour être éprouvable sans en démarrer un : tout ce qui suit + * est de la traduction, et une traduction se vérifie. + * + * Toutes les réponses sont immédiates. L'arbre lit un instantané déjà en + * mémoire ; rien ici ne part sur le réseau ni sur le disque, et un hôte comme + * Android Auto n'attend pas. + */ +class BrowseCallback(private val tree: BrowseTree) : MediaLibrarySession.Callback { + + override fun onGetLibraryRoot( + session: MediaLibrarySession, + browser: MediaSession.ControllerInfo, + params: LibraryParams?, + ): ListenableFuture> = + Futures.immediateFuture(LibraryResult.ofItem(tree.root(), params)) + + override fun onGetChildren( + session: MediaLibrarySession, + browser: MediaSession.ControllerInfo, + parentId: String, + page: Int, + pageSize: Int, + params: LibraryParams?, + ): ListenableFuture>> { + val slice = tree.children(parentId).page(page, pageSize) + + return Futures.immediateFuture( + LibraryResult.ofItemList(ImmutableList.copyOf(slice), params), + ) + } + + override fun onGetItem( + session: MediaLibrarySession, + browser: MediaSession.ControllerInfo, + mediaId: String, + ): ListenableFuture> { + val item = tree.item(mediaId) + ?: return Futures.immediateFuture(LibraryResult.ofError(SessionError.ERROR_BAD_VALUE)) + + return Futures.immediateFuture(LibraryResult.ofItem(item, null)) + } + + /** + * Donne au lecteur des éléments qu'il sait ouvrir. + * + * Ceux que l'hôte renvoie viennent de [BrowseTree.children] : ils portent un + * `mediaId` et des métadonnées, mais **pas d'URI** — l'hôte les affiche, il + * ne les lit pas. Sans cette résolution, le lecteur recevrait des éléments + * sans source et ne jouerait rien. + * + * Un élément qui porte déjà une URI est laissé tel quel : il vient alors de + * l'application elle-même, qui construit ses files complètes. + */ + override fun onAddMediaItems( + mediaSession: MediaSession, + controller: MediaSession.ControllerInfo, + mediaItems: MutableList, + ): ListenableFuture> { + val resolved = mediaItems.flatMap { item -> + if (item.localConfiguration != null) listOf(item) else tree.resolve(item.mediaId) + } + + return Futures.immediateFuture(resolved.toMutableList()) + } +} + +/** + * La tranche que l'hôte réclame, bornée à ce qui existe. + * + * Une page hors bornes n'est pas une erreur : c'est la fin de la liste. Rendre + * un échec ferait apparaître un avertissement là où il n'y a simplement rien de + * plus à voir — et une tranche calculée sans borne lèverait, ce qui couperait + * la navigation au lieu de la terminer. + * + * Une taille de page nulle ou négative ne décrit aucune tranche : rien à rendre. + */ +internal fun List.page(page: Int, pageSize: Int): List { + if (pageSize <= 0 || page < 0) return emptyList() + + val from = (page.toLong() * pageSize).coerceAtMost(size.toLong()).toInt() + val to = (from.toLong() + pageSize).coerceAtMost(size.toLong()).toInt() + return subList(from, to) +} diff --git a/app/src/main/java/app/waveflow/playback/BrowseTree.kt b/app/src/main/java/app/waveflow/playback/BrowseTree.kt new file mode 100644 index 0000000..41365c7 --- /dev/null +++ b/app/src/main/java/app/waveflow/playback/BrowseTree.kt @@ -0,0 +1,223 @@ +package app.waveflow.playback + +import androidx.media3.common.MediaItem +import androidx.media3.common.MediaMetadata +import app.waveflow.model.Library +import app.waveflow.model.Playlist +import app.waveflow.model.PlaylistEntry +import app.waveflow.model.Song + +/** + * Ce qu'un hôte extérieur — Android Auto, Assistant — voit de la bibliothèque. + * + * L'arbre ne sert que la bibliothèque de l'appareil. Le catalogue du serveur en + * est absent à dessein : il se demande par le réseau, page par page, et une + * branche qui met deux secondes à répondre ou qui se vide hors couverture est + * pire, au volant, qu'une branche qui n'existe pas. Le local, lui, est déjà en + * mémoire quand la question arrive. + * + * Les identifiants de navigation portent le préfixe [BROWSE_PREFIX], que rien + * d'autre n'emploie : une feuille garde le `mediaId` que [toMediaItem] lui + * donne, si bien qu'un nœud et une piste ne peuvent pas se confondre. + */ +class BrowseTree(private val snapshot: () -> BrowseSnapshot) { + + /** + * La racine, seule réponse possible tant que la bibliothèque n'est pas lue. + * + * Android Auto la demande avant tout le reste et n'attend pas : rendre ici + * un nœud vide au motif que le chargement n'est pas fini afficherait une + * voiture sans musique jusqu'au prochain balayage. + */ + fun root(): MediaItem = browsableNode(ROOT_ID, "Bibliothèque") + + /** + * Les enfants de [parentId], ou une liste vide si le nœud n'existe pas. + * + * Une liste vide est aussi ce que rend un nœud réel mais sans contenu — un + * album dont les fichiers ont disparu, par exemple. L'hôte ne distingue pas + * les deux, et n'a pas à le faire : dans les deux cas il n'y a rien à + * montrer. + */ + fun children(parentId: String): List { + val state = snapshot() + + return when { + parentId == ROOT_ID -> rootSections(state) + parentId == ALBUMS_ID -> state.library.albums.map(::albumNode) + parentId == ARTISTS_ID -> state.library.artists.map(::artistNode) + parentId == PLAYLISTS_ID -> state.playlists.map(::playlistNode) + parentId == SONGS_ID -> state.library.songs.map { it.toBrowsableLeaf() } + parentId.startsWith(ALBUM_PREFIX) -> state.songsOfAlbum(parentId.idAfter(ALBUM_PREFIX)) + parentId.startsWith(ARTIST_PREFIX) -> state.songsOfArtist(parentId.idAfter(ARTIST_PREFIX)) + parentId.startsWith(PLAYLIST_PREFIX) -> + state.songsOfPlaylist(parentId.idAfter(PLAYLIST_PREFIX)) + else -> emptyList() + } + } + + /** Le nœud ou la feuille que désigne [mediaId], `null` s'il n'existe pas. */ + fun item(mediaId: String): MediaItem? { + if (mediaId == ROOT_ID) return root() + + val state = snapshot() + return rootSections(state).firstOrNull { it.mediaId == mediaId } + ?: state.library.albums.firstOrNull { albumId(it.id) == mediaId }?.let(::albumNode) + ?: state.library.artists.firstOrNull { artistId(it.id) == mediaId }?.let(::artistNode) + ?: state.playlists.firstOrNull { playlistId(it.id) == mediaId }?.let(::playlistNode) + ?: state.songOf(mediaId)?.toBrowsableLeaf() + } + + /** + * Ce qu'il faut mettre dans le lecteur quand l'hôte demande [mediaId]. + * + * Les [MediaItem] rendus par [children] n'ont **pas** d'URI : l'hôte les + * affiche, il ne les ouvre pas. C'est ici que la piste retrouve la sienne, + * par [toMediaItem] — le même chemin que l'application elle-même, donc les + * mêmes clés de cache et le même résolveur. + * + * Un nœud est jouable comme ses feuilles : demander un album, c'est + * demander ses pistes dans l'ordre. + */ + fun resolve(mediaId: String): List { + val state = snapshot() + + state.songOf(mediaId)?.let { return listOf(it.toMediaItem()) } + + return children(mediaId).mapNotNull { state.songOf(it.mediaId)?.toMediaItem() } + } + + private fun rootSections(state: BrowseSnapshot): List = buildList { + add(browsableNode(ALBUMS_ID, "Albums")) + add(browsableNode(ARTISTS_ID, "Artistes")) + // Une section de playlists vide n'apprend rien et occupe une place que + // la voiture n'a pas. + if (state.playlists.isNotEmpty()) add(browsableNode(PLAYLISTS_ID, "Playlists")) + add(browsableNode(SONGS_ID, "Toutes les pistes")) + } + + private fun albumNode(album: app.waveflow.model.Album): MediaItem = browsableNode( + mediaId = albumId(album.id), + title = album.title, + subtitle = album.displayArtist, + artworkUri = album.artworkUri, + mediaType = MediaMetadata.MEDIA_TYPE_ALBUM, + ) + + private fun artistNode(artist: app.waveflow.model.Artist): MediaItem = browsableNode( + mediaId = artistId(artist.id), + title = artist.name, + artworkUri = artist.artworkUri, + mediaType = MediaMetadata.MEDIA_TYPE_ARTIST, + ) + + private fun playlistNode(playlist: Playlist): MediaItem = browsableNode( + mediaId = playlistId(playlist.id), + title = playlist.name, + mediaType = MediaMetadata.MEDIA_TYPE_PLAYLIST, + ) + + private fun browsableNode( + mediaId: String, + title: String, + subtitle: String? = null, + artworkUri: android.net.Uri? = null, + mediaType: Int = MediaMetadata.MEDIA_TYPE_FOLDER_MIXED, + ): MediaItem = MediaItem.Builder() + .setMediaId(mediaId) + .setMediaMetadata( + MediaMetadata.Builder() + .setTitle(title) + .setSubtitle(subtitle) + .setArtworkUri(artworkUri) + .setIsBrowsable(true) + .setIsPlayable(false) + .setMediaType(mediaType) + .build(), + ) + .build() + + /** + * Une piste telle que l'hôte la liste : sans URI. + * + * Elle porte le même `mediaId` que l'élément jouable — c'est ce qui permet + * à [resolve] de la retrouver quand l'hôte la redemande pour la lire. + */ + private fun Song.toBrowsableLeaf(): MediaItem = MediaItem.Builder() + .setMediaId(toMediaItem().mediaId) + .setMediaMetadata( + MediaMetadata.Builder() + .setTitle(title) + .setArtist(artist) + .setAlbumTitle(album) + .setArtworkUri(artworkUri) + .setIsBrowsable(false) + .setIsPlayable(true) + .setMediaType(MediaMetadata.MEDIA_TYPE_MUSIC) + .build(), + ) + .build() + + private fun BrowseSnapshot.songsOfAlbum(id: Long?): List = + library.songs.filter { it.albumId == id }.map { it.toBrowsableLeaf() } + + private fun BrowseSnapshot.songsOfArtist(id: Long?): List = + library.songs.filter { it.artistId == id }.map { it.toBrowsableLeaf() } + + /** + * Les pistes d'une playlist, dans l'ordre voulu. + * + * Une entrée dont le fichier a disparu de l'appareil est ignorée : la + * playlist garde la mémoire d'un morceau que le MediaStore ne connaît plus. + */ + private fun BrowseSnapshot.songsOfPlaylist(id: Long?): List = playlistEntries + .filter { it.playlistId == id } + .sortedBy(PlaylistEntry::position) + .mapNotNull { library.songsById[it.songId] } + .map { it.toBrowsableLeaf() } + + /** Le morceau que désigne ce `mediaId` de feuille, `null` si c'en est un autre. */ + private fun BrowseSnapshot.songOf(mediaId: String): Song? = + library.songs.firstOrNull { it.toMediaItem().mediaId == mediaId } + + private fun String.idAfter(prefix: String): Long? = removePrefix(prefix).toLongOrNull() + + private fun albumId(id: Long) = "$ALBUM_PREFIX$id" + + private fun artistId(id: Long) = "$ARTIST_PREFIX$id" + + private fun playlistId(id: Long) = "$PLAYLIST_PREFIX$id" + + companion object { + /** + * Préfixe des nœuds de navigation. + * + * Distinct de ceux des pistes (`local:`, `remote:`) pour qu'aucun nœud + * ne puisse être pris pour une piste, ni l'inverse. + */ + const val BROWSE_PREFIX = "browse:" + + const val ROOT_ID = "${BROWSE_PREFIX}root" + + private const val ALBUMS_ID = "${BROWSE_PREFIX}albums" + private const val ARTISTS_ID = "${BROWSE_PREFIX}artists" + private const val PLAYLISTS_ID = "${BROWSE_PREFIX}playlists" + private const val SONGS_ID = "${BROWSE_PREFIX}songs" + + private const val ALBUM_PREFIX = "${BROWSE_PREFIX}album/" + private const val ARTIST_PREFIX = "${BROWSE_PREFIX}artist/" + private const val PLAYLIST_PREFIX = "${BROWSE_PREFIX}playlist/" + } +} + +/** + * L'état de la bibliothèque à l'instant où l'hôte pose sa question. + * + * Un instantané plutôt que des flux : [BrowseTree] répond de façon synchrone, et + * la question vient d'un processus extérieur qui n'attendra pas. + */ +data class BrowseSnapshot( + val library: Library = Library(), + val playlists: List = emptyList(), + val playlistEntries: List = emptyList(), +) diff --git a/app/src/main/java/app/waveflow/playback/PlaybackService.kt b/app/src/main/java/app/waveflow/playback/PlaybackService.kt index c27005d..5286862 100644 --- a/app/src/main/java/app/waveflow/playback/PlaybackService.kt +++ b/app/src/main/java/app/waveflow/playback/PlaybackService.kt @@ -6,27 +6,35 @@ import androidx.media3.common.C import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.media3.session.CacheBitmapLoader +import androidx.media3.session.MediaLibraryService import androidx.media3.session.MediaSession -import androidx.media3.session.MediaSessionService import app.waveflow.WaveFlowApp 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.launch /** * Service de lecture porté par Media3. * - * Un [MediaSessionService] héberge un [ExoPlayer] + une [MediaSession] : Android - * en tire automatiquement la notification média, les contrôles de l'écran de - * verrouillage et la lecture en arrière-plan. L'UI se connecte via un + * Un [MediaLibraryService] héberge un [ExoPlayer] + une [MediaLibrarySession] : + * Android en tire automatiquement la notification média, les contrôles de + * l'écran de verrouillage et la lecture en arrière-plan. L'UI se connecte via un * `MediaController` (voir [PlaybackController]) — elle ne parle jamais * directement à l'ExoPlayer. + * + * C'est un [MediaLibraryService] et non un simple `MediaSessionService` parce + * qu'Android Auto ne se contente pas de commander la lecture : il veut parcourir + * la bibliothèque. Cette différence tient à l'arbre exposé par [BrowseTree] ; + * pour l'application, rien ne change — un `MediaLibrarySession` est une + * `MediaSession`. */ -class PlaybackService : MediaSessionService() { +class PlaybackService : MediaLibraryService() { - private var mediaSession: MediaSession? = null + private var mediaSession: MediaLibrarySession? = null /** * Portée des chargements de pochette : ils n'ont plus de destinataire une @@ -34,6 +42,17 @@ class PlaybackService : MediaSessionService() { */ private val artworkScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + /** + * Ce que l'arbre de navigation donne à voir, tenu à jour en continu. + * + * `@Volatile` parce qu'il est écrit par la coroutine qui observe et lu par + * le fil d'où l'hôte pose ses questions. L'instantané est remplacé d'un + * bloc, jamais modifié en place : un lecteur voit donc toujours un état + * cohérent, fût-il d'un instant plus tôt. + */ + @Volatile + private var browseSnapshot = BrowseSnapshot() + override fun onCreate() { super.onCreate() @@ -68,12 +87,42 @@ class PlaybackService : MediaSessionService() { // de recharger la même image à chaque rafraîchissement. val bitmapLoader = CacheBitmapLoader(CoilBitmapLoader(this, imageLoader, artworkScope)) - mediaSession = MediaSession.Builder(this, player) + observeLibrary(container) + + mediaSession = MediaLibrarySession.Builder( + this, + player, + BrowseCallback(BrowseTree { browseSnapshot }), + ) .setBitmapLoader(bitmapLoader) .build() } - override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? = mediaSession + /** + * Tient [browseSnapshot] à jour tant que le service vit. + * + * `load()` est demandé ici parce que le service peut démarrer sans que + * l'application ait été ouverte — Android Auto s'y connecte directement. Si + * la permission audio manque, la bibliothèque restera vide et l'arbre le + * sera aussi : c'est le seul comportement honnête, l'hôte n'ayant aucun + * moyen de la demander. + */ + private fun observeLibrary(container: app.waveflow.AppContainer) { + container.libraryStore.load() + + artworkScope.launch { + combine( + container.libraryStore.library, + container.playlistRepository.observePlaylists(), + container.playlistRepository.observeEntries(), + ) { library, playlists, entries -> + BrowseSnapshot(library, playlists, entries) + }.collect { browseSnapshot = it } + } + } + + override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaLibrarySession? = + mediaSession // Si l'app est balayée depuis les récents alors que rien ne joue, on arrête // le service pour ne pas laisser une notification fantôme. diff --git a/app/src/main/res/xml/automotive_app_desc.xml b/app/src/main/res/xml/automotive_app_desc.xml new file mode 100644 index 0000000..afad23a --- /dev/null +++ b/app/src/main/res/xml/automotive_app_desc.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/test/java/app/waveflow/playback/BrowsePageTest.kt b/app/src/test/java/app/waveflow/playback/BrowsePageTest.kt new file mode 100644 index 0000000..b2a65e6 --- /dev/null +++ b/app/src/test/java/app/waveflow/playback/BrowsePageTest.kt @@ -0,0 +1,63 @@ +package app.waveflow.playback + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Le découpage en pages demandé par l'hôte. + * + * C'est la seule arithmétique du pont vers Media3, et celle où une borne + * oubliée ne se voit pas : `subList` lève au lieu de rendre une liste vide, et + * la navigation se couperait au dernier écran plutôt que de s'y terminer. + */ +class BrowsePageTest { + + private val cinq = listOf("a", "b", "c", "d", "e") + + @Test + fun `la premiere page rend le debut de la liste`() { + assertEquals(listOf("a", "b"), cinq.page(page = 0, pageSize = 2)) + } + + @Test + fun `la derniere page est tronquee a ce qui reste`() { + // Cinq éléments par pages de deux : la troisième page n'en a qu'un. + assertEquals(listOf("e"), cinq.page(page = 2, pageSize = 2)) + } + + @Test + fun `une page au-dela de la fin rend une liste vide`() { + // L'hôte demande la page suivante jusqu'à ce qu'elle soit vide : c'est + // ainsi qu'il sait qu'il a tout vu. Lever ici couperait la navigation. + assertEquals(emptyList(), cinq.page(page = 9, pageSize = 2)) + } + + @Test + fun `une page qui commence pile a la fin rend une liste vide`() { + // Le cas limite exact : `from` vaut la taille de la liste. + assertEquals(emptyList(), cinq.page(page = 1, pageSize = 5)) + } + + @Test + fun `une taille de page plus grande que la liste la rend entiere`() { + assertEquals(cinq, cinq.page(page = 0, pageSize = 100)) + } + + @Test + fun `une taille de page nulle ou negative ne decrit aucune tranche`() { + assertEquals(emptyList(), cinq.page(page = 0, pageSize = 0)) + assertEquals(emptyList(), cinq.page(page = 0, pageSize = -1)) + } + + @Test + fun `un tres grand numero de page ne deborde pas`() { + // `page * pageSize` sur des entiers déborderait et rendrait un indice + // négatif, que `subList` refuserait. + assertEquals(emptyList(), cinq.page(page = Int.MAX_VALUE, pageSize = 1000)) + } + + @Test + fun `une liste vide rend une liste vide`() { + assertEquals(emptyList(), emptyList().page(page = 0, pageSize = 10)) + } +} diff --git a/app/src/test/java/app/waveflow/playback/BrowseTreeTest.kt b/app/src/test/java/app/waveflow/playback/BrowseTreeTest.kt new file mode 100644 index 0000000..db7e19d --- /dev/null +++ b/app/src/test/java/app/waveflow/playback/BrowseTreeTest.kt @@ -0,0 +1,208 @@ +package app.waveflow.playback + +import androidx.core.net.toUri +import app.waveflow.model.Library +import app.waveflow.model.Playlist +import app.waveflow.model.PlaylistEntry +import app.waveflow.model.Song +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * L'arbre que voit Android Auto. + * + * Ce qui se joue ici n'est pas l'affichage mais le **contrat** : un hôte + * extérieur pose des questions par identifiant et n'a aucun moyen de deviner ce + * qu'on a voulu dire. Un identifiant qui ne se retrouve pas, une feuille sans + * source, et la voiture reste muette sans rien signaler. + * + * Robolectric parce que `Uri` et `MediaItem` viennent du cadre Android. + */ +@RunWith(RobolectricTestRunner::class) +class BrowseTreeTest { + + private fun song( + id: Long, + title: String, + album: String = "Album", + albumId: Long = 10L, + artist: String = "Artiste", + artistId: Long = 100L, + ) = Song( + id = id, + uri = "content://media/external/audio/media/$id".toUri(), + title = title, + artist = artist, + artistId = artistId, + album = album, + albumId = albumId, + durationMs = 180_000L, + artworkUri = null, + ) + + private fun tree( + songs: List = emptyList(), + playlists: List = emptyList(), + entries: List = emptyList(), + ) = BrowseTree { + BrowseSnapshot( + library = Library(isLoading = false, songs = songs), + playlists = playlists, + playlistEntries = entries, + ) + } + + private fun titlesOf(items: List) = + items.map { it.mediaMetadata.title.toString() } + + @Test + fun `la racine repond meme sans bibliotheque`() { + // Android Auto demande la racine avant tout le reste et n'attend pas. + // Rendre un échec ou rien tant que le chargement n'est pas fini + // laisserait la voiture sans musique jusqu'au prochain balayage. + val root = tree().root() + + assertEquals(BrowseTree.ROOT_ID, root.mediaId) + assertTrue(root.mediaMetadata.isBrowsable == true) + assertFalse(root.mediaMetadata.isPlayable == true) + } + + @Test + fun `la racine offre les sections de la bibliotheque`() { + val items = tree(songs = listOf(song(1, "Un"))).children(BrowseTree.ROOT_ID) + + assertEquals(listOf("Albums", "Artistes", "Toutes les pistes"), titlesOf(items)) + } + + @Test + fun `la section des playlists n'apparait que s'il y en a`() { + // Une section vide n'apprend rien et occupe une place que la voiture + // n'a pas. + val avec = tree( + songs = listOf(song(1, "Un")), + playlists = listOf(Playlist(id = 7L, name = "Route")), + ).children(BrowseTree.ROOT_ID) + + assertTrue("Playlists" in titlesOf(avec)) + } + + @Test + fun `un album ne rend que ses propres pistes`() { + val songs = listOf( + song(1, "Une", album = "Bleu", albumId = 10L), + song(2, "Deux", album = "Bleu", albumId = 10L), + song(3, "Trois", album = "Rouge", albumId = 20L), + ) + val arbre = tree(songs) + + val albums = arbre.children("${BrowseTree.BROWSE_PREFIX}albums") + val bleu = albums.first { it.mediaMetadata.title.toString() == "Bleu" } + + assertEquals(listOf("Une", "Deux"), titlesOf(arbre.children(bleu.mediaId))) + } + + @Test + fun `une playlist rend ses pistes dans l'ordre voulu`() { + // L'ordre d'une playlist est le sien, pas celui de la bibliothèque : + // les entrées portent une position, et c'est elle qui décide. + // + // Les entrées sont volontairement données **à rebours de leur + // position** : rangées dans le bon ordre, elles rendraient le tri + // invisible et ce test passerait sans lui. + val songs = listOf(song(1, "Une"), song(2, "Deux"), song(3, "Trois")) + val arbre = tree( + songs = songs, + playlists = listOf(Playlist(id = 7L, name = "Route")), + entries = listOf( + PlaylistEntry(playlistId = 7L, songId = 1L, position = 1), + PlaylistEntry(playlistId = 7L, songId = 3L, position = 0), + ), + ) + + val pistes = arbre.children("${BrowseTree.BROWSE_PREFIX}playlist/7") + + assertEquals(listOf("Trois", "Une"), titlesOf(pistes)) + } + + @Test + fun `une entree de playlist dont le fichier a disparu est ignoree`() { + // Une playlist garde la mémoire d'un morceau que le MediaStore ne + // connaît plus. La sauter vaut mieux que rendre une entrée sans source, + // que le lecteur ne saurait pas ouvrir. + val arbre = tree( + songs = listOf(song(1, "Une")), + playlists = listOf(Playlist(id = 7L, name = "Route")), + entries = listOf( + PlaylistEntry(playlistId = 7L, songId = 1L, position = 0), + PlaylistEntry(playlistId = 7L, songId = 404L, position = 1), + ), + ) + + assertEquals(listOf("Une"), titlesOf(arbre.children("${BrowseTree.BROWSE_PREFIX}playlist/7"))) + } + + @Test + fun `les feuilles listees n'ont pas de source`() { + // L'hôte les affiche, il ne les ouvre pas. C'est `resolve` qui leur rend + // une URI au moment de jouer — sinon le lecteur recevrait des éléments + // sans source et ne jouerait rien. + val items = tree(songs = listOf(song(1, "Une"))).children("${BrowseTree.BROWSE_PREFIX}songs") + + val feuille = items.single() + assertNull("une feuille de navigation ne porte pas d'URI", feuille.localConfiguration) + assertTrue(feuille.mediaMetadata.isPlayable == true) + } + + @Test + fun `resoudre une feuille lui rend sa source`() { + val arbre = tree(songs = listOf(song(1, "Une"))) + val feuille = arbre.children("${BrowseTree.BROWSE_PREFIX}songs").single() + + val jouable = arbre.resolve(feuille.mediaId).single() + + assertEquals(feuille.mediaId, jouable.mediaId) + assertNotNull("le lecteur doit recevoir une source", jouable.localConfiguration) + } + + @Test + fun `resoudre un album rend toutes ses pistes dans l'ordre`() { + // Demander un album, c'est demander ses pistes : l'hôte propose de + // jouer un nœud entier, et le lecteur doit recevoir la file complète. + val songs = listOf(song(1, "Une"), song(2, "Deux")) + val arbre = tree(songs) + val album = arbre.children("${BrowseTree.BROWSE_PREFIX}albums").single() + + val file = arbre.resolve(album.mediaId) + + assertEquals(listOf("Une", "Deux"), titlesOf(file)) + assertTrue(file.all { it.localConfiguration != null }) + } + + @Test + fun `un identifiant inconnu ne rend rien plutot que d'echouer`() { + val arbre = tree(songs = listOf(song(1, "Une"))) + + assertEquals(emptyList(), arbre.children("${BrowseTree.BROWSE_PREFIX}album/999")) + assertEquals(emptyList(), arbre.children("n'importe quoi")) + assertNull(arbre.item("n'importe quoi")) + } + + @Test + fun `un noeud de navigation ne peut pas etre pris pour une piste`() { + // Les deux familles d'identifiants se croisent dans les mêmes appels. + // Si un préfixe recouvrait l'autre, demander un album jouerait une + // piste, ou l'inverse. + val arbre = tree(songs = listOf(song(1, "Une"))) + val piste = arbre.children("${BrowseTree.BROWSE_PREFIX}songs").single() + val album = arbre.children("${BrowseTree.BROWSE_PREFIX}albums").single() + + assertFalse(piste.mediaId.startsWith(BrowseTree.BROWSE_PREFIX)) + assertTrue(album.mediaId.startsWith(BrowseTree.BROWSE_PREFIX)) + } +} From 21c0c0aeee9719e5e320d7c1c1b18b44de6683c8 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Sun, 23 Aug 2026 23:53:36 +0200 Subject: [PATCH 2/4] =?UTF-8?q?perf(auto):=20r=C3=A9soudre=20un=20n=C5=93u?= =?UTF-8?q?d=20sans=20parcourir=20la=20biblioth=C3=A8que=20par=20piste?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `resolve` passait par `children`, qui construit une feuille par morceau, puis retrouvait chaque morceau en comparant son identifiant à celui d'un `MediaItem` reconstruit pour l'occasion. Demander « Toutes les pistes » coûtait donc N² comparaisons et 2N constructions — sur le fil où Media3 attend la file avant de rendre la main à l'hôte. Les deux chemins partent maintenant de la même liste de `Song` : `children` en tire des feuilles, `resolve` des éléments jouables, chacun une seule fois par piste. La reconnaissance d'un identifiant de piste passe par l'index déjà tenu par la bibliothèque plutôt que par un balayage. Le comportement ne change pas — les tests existants en font foi. `songsUnder` distingue toutefois « ce nœud ne contient pas de pistes » de « ce nœud n'en contient aucune » ; un test verrouille cette distinction, sans quoi demander la section Albums verserait la bibliothèque entière dans le lecteur. Claude-Session: https://claude.ai/code/session_01NaDC2u4EF6J3JMNxnzpdwg --- .../java/app/waveflow/playback/BrowseTree.kt | 63 +++++++++++++------ .../app/waveflow/playback/MediaItemMapper.kt | 19 +++++- .../app/waveflow/playback/BrowseTreeTest.kt | 13 ++++ 3 files changed, 73 insertions(+), 22 deletions(-) diff --git a/app/src/main/java/app/waveflow/playback/BrowseTree.kt b/app/src/main/java/app/waveflow/playback/BrowseTree.kt index 41365c7..e339967 100644 --- a/app/src/main/java/app/waveflow/playback/BrowseTree.kt +++ b/app/src/main/java/app/waveflow/playback/BrowseTree.kt @@ -42,16 +42,13 @@ class BrowseTree(private val snapshot: () -> BrowseSnapshot) { fun children(parentId: String): List { val state = snapshot() - return when { - parentId == ROOT_ID -> rootSections(state) - parentId == ALBUMS_ID -> state.library.albums.map(::albumNode) - parentId == ARTISTS_ID -> state.library.artists.map(::artistNode) - parentId == PLAYLISTS_ID -> state.playlists.map(::playlistNode) - parentId == SONGS_ID -> state.library.songs.map { it.toBrowsableLeaf() } - parentId.startsWith(ALBUM_PREFIX) -> state.songsOfAlbum(parentId.idAfter(ALBUM_PREFIX)) - parentId.startsWith(ARTIST_PREFIX) -> state.songsOfArtist(parentId.idAfter(ARTIST_PREFIX)) - parentId.startsWith(PLAYLIST_PREFIX) -> - state.songsOfPlaylist(parentId.idAfter(PLAYLIST_PREFIX)) + state.songsUnder(parentId)?.let { songs -> return songs.map { it.toBrowsableLeaf() } } + + return when (parentId) { + ROOT_ID -> rootSections(state) + ALBUMS_ID -> state.library.albums.map(::albumNode) + ARTISTS_ID -> state.library.artists.map(::artistNode) + PLAYLISTS_ID -> state.playlists.map(::playlistNode) else -> emptyList() } } @@ -84,7 +81,12 @@ class BrowseTree(private val snapshot: () -> BrowseSnapshot) { state.songOf(mediaId)?.let { return listOf(it.toMediaItem()) } - return children(mediaId).mapNotNull { state.songOf(it.mediaId)?.toMediaItem() } + // Les morceaux du nœud, pas les feuilles que [children] en tirerait : + // repasser par elles construirait un [MediaItem] par piste pour le + // jeter aussitôt, et il faudrait ensuite retrouver chaque morceau à + // partir de son identifiant. Cet appel est synchrone — Media3 attend + // la file avant de rendre la main à l'hôte. + return state.songsUnder(mediaId).orEmpty().map { it.toMediaItem() } } private fun rootSections(state: BrowseSnapshot): List = buildList { @@ -144,7 +146,7 @@ class BrowseTree(private val snapshot: () -> BrowseSnapshot) { * à [resolve] de la retrouver quand l'hôte la redemande pour la lire. */ private fun Song.toBrowsableLeaf(): MediaItem = MediaItem.Builder() - .setMediaId(toMediaItem().mediaId) + .setMediaId(mediaId) .setMediaMetadata( MediaMetadata.Builder() .setTitle(title) @@ -158,11 +160,27 @@ class BrowseTree(private val snapshot: () -> BrowseSnapshot) { ) .build() - private fun BrowseSnapshot.songsOfAlbum(id: Long?): List = - library.songs.filter { it.albumId == id }.map { it.toBrowsableLeaf() } + /** + * Les morceaux que contient ce nœud, `null` s'il n'en contient pas. + * + * `null` et liste vide se distinguent : le premier dit « ce n'est pas un + * nœud de pistes » — la racine, la section des albums — le second « ce nœud + * en contient zéro ». [resolve] s'appuie sur cette différence, faute de quoi + * demander la section Albums mettrait toute la bibliothèque dans le lecteur. + */ + private fun BrowseSnapshot.songsUnder(parentId: String): List? = when { + parentId == SONGS_ID -> library.songs + parentId.startsWith(ALBUM_PREFIX) -> songsOfAlbum(parentId.idAfter(ALBUM_PREFIX)) + parentId.startsWith(ARTIST_PREFIX) -> songsOfArtist(parentId.idAfter(ARTIST_PREFIX)) + parentId.startsWith(PLAYLIST_PREFIX) -> songsOfPlaylist(parentId.idAfter(PLAYLIST_PREFIX)) + else -> null + } + + private fun BrowseSnapshot.songsOfAlbum(id: Long?): List = + library.songs.filter { it.albumId == id } - private fun BrowseSnapshot.songsOfArtist(id: Long?): List = - library.songs.filter { it.artistId == id }.map { it.toBrowsableLeaf() } + private fun BrowseSnapshot.songsOfArtist(id: Long?): List = + library.songs.filter { it.artistId == id } /** * Les pistes d'une playlist, dans l'ordre voulu. @@ -170,15 +188,20 @@ class BrowseTree(private val snapshot: () -> BrowseSnapshot) { * Une entrée dont le fichier a disparu de l'appareil est ignorée : la * playlist garde la mémoire d'un morceau que le MediaStore ne connaît plus. */ - private fun BrowseSnapshot.songsOfPlaylist(id: Long?): List = playlistEntries + private fun BrowseSnapshot.songsOfPlaylist(id: Long?): List = playlistEntries .filter { it.playlistId == id } .sortedBy(PlaylistEntry::position) .mapNotNull { library.songsById[it.songId] } - .map { it.toBrowsableLeaf() } - /** Le morceau que désigne ce `mediaId` de feuille, `null` si c'en est un autre. */ + /** + * Le morceau que désigne ce `mediaId` de feuille, `null` si c'en est un autre. + * + * Par l'index de la bibliothèque : l'hôte peut demander la résolution d'une + * section entière, et un balayage par identifiant y coûterait un parcours + * complet par piste. + */ private fun BrowseSnapshot.songOf(mediaId: String): Song? = - library.songs.firstOrNull { it.toMediaItem().mediaId == mediaId } + localSongIdOf(mediaId)?.let { library.songsById[it] } private fun String.idAfter(prefix: String): Long? = removePrefix(prefix).toLongOrNull() diff --git a/app/src/main/java/app/waveflow/playback/MediaItemMapper.kt b/app/src/main/java/app/waveflow/playback/MediaItemMapper.kt index e5e2c6e..a549c5c 100644 --- a/app/src/main/java/app/waveflow/playback/MediaItemMapper.kt +++ b/app/src/main/java/app/waveflow/playback/MediaItemMapper.kt @@ -16,7 +16,7 @@ import app.waveflow.model.Song */ fun Song.toMediaItem(): MediaItem = MediaItem.Builder() - .setMediaId("$LOCAL_PREFIX$id") + .setMediaId(mediaId) .setUri(uri) .setMediaMetadata( MediaMetadata.Builder() @@ -71,9 +71,24 @@ fun MediaItem.toPlayingTrack(): PlayingTrack = PlayingTrack( val RemoteSong.mediaId: String get() = "$REMOTE_PREFIX$id" +/** Identité de ce morceau local, sans passer par [toMediaItem]. */ +val Song.mediaId: String + get() = "$LOCAL_PREFIX$id" + /** Identifiant MediaStore porté par ce [MediaItem], ou `null` s'il vient d'ailleurs. */ val MediaItem.localSongId: Long? - get() = mediaId.removePrefix(LOCAL_PREFIX).takeIf { mediaId.startsWith(LOCAL_PREFIX) }?.toLongOrNull() + get() = localSongIdOf(mediaId) + +/** + * Identifiant MediaStore que porte ce `mediaId`, ou `null` s'il vient d'ailleurs. + * + * La même lecture que [MediaItem.localSongId], mais sur la chaîne seule : un + * hôte extérieur redemande une piste par son identifiant, sans le [MediaItem] + * qui l'accompagnait. Retrouver le morceau en reconstruisant un [MediaItem] par + * candidat coûterait un balayage de la bibliothèque là où un index suffit. + */ +internal fun localSongIdOf(mediaId: String): Long? = + mediaId.takeIf { it.startsWith(LOCAL_PREFIX) }?.removePrefix(LOCAL_PREFIX)?.toLongOrNull() /** * Clé de cache d'une piste distante. diff --git a/app/src/test/java/app/waveflow/playback/BrowseTreeTest.kt b/app/src/test/java/app/waveflow/playback/BrowseTreeTest.kt index db7e19d..c047d61 100644 --- a/app/src/test/java/app/waveflow/playback/BrowseTreeTest.kt +++ b/app/src/test/java/app/waveflow/playback/BrowseTreeTest.kt @@ -184,6 +184,19 @@ class BrowseTreeTest { assertTrue(file.all { it.localConfiguration != null }) } + @Test + fun `resoudre une section de navigation ne verse pas la bibliotheque entiere`() { + // `resolve` distingue un nœud qui contient des pistes d'un nœud qui + // contient d'autres nœuds. Confondre les deux ferait de « Albums » un + // raccourci vers toute la bibliothèque, alors que l'hôte n'a demandé + // qu'à ouvrir une section. + val arbre = tree(songs = listOf(song(1, "Une"), song(2, "Deux"))) + + assertEquals(emptyList(), arbre.resolve("${BrowseTree.BROWSE_PREFIX}albums")) + assertEquals(emptyList(), arbre.resolve("${BrowseTree.BROWSE_PREFIX}artists")) + assertEquals(emptyList(), arbre.resolve(BrowseTree.ROOT_ID)) + } + @Test fun `un identifiant inconnu ne rend rien plutot que d'echouer`() { val arbre = tree(songs = listOf(song(1, "Une"))) From e88222a8323d53f251f8f809cfe0e175572be11e Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Sun, 23 Aug 2026 23:53:36 +0200 Subject: [PATCH 3/4] =?UTF-8?q?fix(auto):=20pr=C3=A9venir=20les=20navigate?= =?UTF-8?q?urs=20quand=20la=20biblioth=C3=A8que=20change?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le service remplaçait son instantané sans rien dire à personne. Un navigateur déjà connecté ne redemande rien de lui-même : il gardait ce qu'il avait lu en se connectant. C'est précisément le cas de la voiture — Android Auto se lie au service au démarrage, avant que la bibliothèque de l'appareil ne soit lue, et serait resté devant un arbre vide jusqu'à la fin du trajet. `BrowseCallback` retient désormais les nœuds qu'un navigateur regarde, et le service lui demande de les notifier à chaque nouvel instantané. Retenir plutôt que balayer : Media3 sait dire qui est abonné à un nœud donné, pas quels nœuds ont un abonné, et notifier tous les albums reviendrait à parcourir la bibliothèque une fois par album. Les nœuds que plus personne ne regarde sont oubliés au passage, un navigateur pouvant disparaître sans se désabonner. L'observation démarre après la construction de la session : la première valeur du flux arrive sans délai, et elle a des abonnés à prévenir. Le test lie un vrai `MediaBrowser` à un vrai service, l'abonne à la racine, puis crée une playlist par le chemin ordinaire de l'application — la section Playlists apparaît. Retirer la notification le fait échouer. Claude-Session: https://claude.ai/code/session_01NaDC2u4EF6J3JMNxnzpdwg --- .../app/waveflow/playback/BrowseCallback.kt | 55 ++++++ .../app/waveflow/playback/PlaybackService.kt | 29 ++- .../playback/PlaybackServiceBrowseTest.kt | 170 ++++++++++++++++++ 3 files changed, 246 insertions(+), 8 deletions(-) create mode 100644 app/src/test/java/app/waveflow/playback/PlaybackServiceBrowseTest.kt diff --git a/app/src/main/java/app/waveflow/playback/BrowseCallback.kt b/app/src/main/java/app/waveflow/playback/BrowseCallback.kt index 4426e22..71fe1d3 100644 --- a/app/src/main/java/app/waveflow/playback/BrowseCallback.kt +++ b/app/src/main/java/app/waveflow/playback/BrowseCallback.kt @@ -9,6 +9,7 @@ import androidx.media3.session.SessionError import com.google.common.collect.ImmutableList import com.google.common.util.concurrent.Futures import com.google.common.util.concurrent.ListenableFuture +import java.util.concurrent.ConcurrentHashMap /** * Le pont entre [BrowseTree] et ce que Media3 attend d'une bibliothèque. @@ -22,6 +23,60 @@ import com.google.common.util.concurrent.ListenableFuture */ class BrowseCallback(private val tree: BrowseTree) : MediaLibrarySession.Callback { + /** + * Les nœuds qu'un navigateur regarde en ce moment. + * + * Media3 tient la liste des abonnés d'un nœud donné, mais ne sait pas dire + * quels nœuds ont un abonné : il faut donc les retenir pour savoir qui + * prévenir quand la bibliothèque change. C'est aussi ce qui borne le coût — + * notifier tous les albums d'une bibliothèque reviendrait à la parcourir une + * fois par album, quand un navigateur n'en regarde qu'un. + * + * Concurrent parce que rien ne garantit que les abonnements et les mises à + * jour de la bibliothèque arrivent du même fil. + */ + private val subscribed: MutableSet = ConcurrentHashMap.newKeySet() + + override fun onSubscribe( + session: MediaLibrarySession, + browser: MediaSession.ControllerInfo, + parentId: String, + params: LibraryParams?, + ): ListenableFuture> { + subscribed += parentId + // Le comportement par défaut est conservé : c'est lui qui envoie au + // navigateur le premier état du nœud, aussitôt après l'abonnement. + return super.onSubscribe(session, browser, parentId, params) + } + + override fun onUnsubscribe( + session: MediaLibrarySession, + browser: MediaSession.ControllerInfo, + parentId: String, + ): ListenableFuture> { + subscribed -= parentId + return super.onUnsubscribe(session, browser, parentId) + } + + /** + * Prévient les navigateurs que l'arbre a changé sous eux. + * + * Sans cet appel, un navigateur garde ce qu'il a lu la première fois. Le cas + * n'est pas marginal : Android Auto se connecte au démarrage de la voiture, + * avant que la bibliothèque de l'appareil ne soit lue, et resterait donc + * devant un arbre vide jusqu'à ce qu'on l'oblige à redemander. + * + * Les nœuds que plus personne ne regarde sont oubliés au passage : un + * navigateur peut disparaître sans se désabonner, et la session est seule à + * le savoir. + */ + fun notifySubscribers(session: MediaLibrarySession) { + subscribed.removeAll { session.getSubscribedControllers(it).isEmpty() } + subscribed.forEach { parentId -> + session.notifyChildrenChanged(parentId, tree.children(parentId).size, /* params = */ null) + } + } + override fun onGetLibraryRoot( session: MediaLibrarySession, browser: MediaSession.ControllerInfo, diff --git a/app/src/main/java/app/waveflow/playback/PlaybackService.kt b/app/src/main/java/app/waveflow/playback/PlaybackService.kt index 5286862..d15d578 100644 --- a/app/src/main/java/app/waveflow/playback/PlaybackService.kt +++ b/app/src/main/java/app/waveflow/playback/PlaybackService.kt @@ -53,6 +53,15 @@ class PlaybackService : MediaLibraryService() { @Volatile private var browseSnapshot = BrowseSnapshot() + /** + * Ce que la session répond aux hôtes qui parcourent la bibliothèque. + * + * Tenu ici, et non construit au vol dans le constructeur de la session, + * parce qu'il faut pouvoir lui redemander de prévenir ses abonnés à chaque + * fois que [browseSnapshot] change. + */ + private val browseCallback = BrowseCallback(BrowseTree { browseSnapshot }) + override fun onCreate() { super.onCreate() @@ -87,15 +96,13 @@ class PlaybackService : MediaLibraryService() { // de recharger la même image à chaque rafraîchissement. val bitmapLoader = CacheBitmapLoader(CoilBitmapLoader(this, imageLoader, artworkScope)) - observeLibrary(container) - - mediaSession = MediaLibrarySession.Builder( - this, - player, - BrowseCallback(BrowseTree { browseSnapshot }), - ) + mediaSession = MediaLibrarySession.Builder(this, player, browseCallback) .setBitmapLoader(bitmapLoader) .build() + + // 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. + observeLibrary(container) } /** @@ -117,7 +124,13 @@ class PlaybackService : MediaLibraryService() { container.playlistRepository.observeEntries(), ) { library, playlists, entries -> BrowseSnapshot(library, playlists, entries) - }.collect { browseSnapshot = it } + }.collect { snapshot -> + browseSnapshot = snapshot + // L'instantané seul ne suffit pas : un navigateur déjà connecté + // ne redemande rien de lui-même, il attend qu'on lui dise que + // ce qu'il affiche a changé. + mediaSession?.let(browseCallback::notifySubscribers) + } } } diff --git a/app/src/test/java/app/waveflow/playback/PlaybackServiceBrowseTest.kt b/app/src/test/java/app/waveflow/playback/PlaybackServiceBrowseTest.kt new file mode 100644 index 0000000..559a04c --- /dev/null +++ b/app/src/test/java/app/waveflow/playback/PlaybackServiceBrowseTest.kt @@ -0,0 +1,170 @@ +package app.waveflow.playback + +import android.app.Application +import android.content.ComponentName +import android.content.Intent +import androidx.media3.session.MediaBrowser +import androidx.media3.session.MediaLibraryService +import androidx.media3.session.MediaSessionService +import androidx.media3.session.SessionToken +import androidx.test.core.app.ApplicationProvider +import app.waveflow.WaveFlowApp +import com.google.common.util.concurrent.ListenableFuture +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.fail +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 org.robolectric.shadows.ShadowLooper +import java.util.concurrent.TimeUnit + +/** + * Ce qu'un navigateur déjà connecté apprend des changements de la bibliothèque. + * + * Le cas est celui de la voiture : Android Auto se lie au service au démarrage, + * bien avant que la bibliothèque de l'appareil ne soit lue, et n'a aucune raison + * de redemander quoi que ce soit ensuite. Si personne ne le prévient, il reste + * devant l'arbre qu'il a vu la première fois — vide. + * + * Rien n'est simulé : un vrai [PlaybackService], un vrai `MediaBrowser` abonné, + * et un changement provoqué par le chemin ordinaire de l'application — la + * création d'une playlist, qui fait apparaître une section dans la racine. Le + * MediaStore n'est pas mobilisé : il resterait vide sous Robolectric, alors que + * les playlists passent par une base dont l'application est propriétaire. + */ +@RunWith(RobolectricTestRunner::class) +class PlaybackServiceBrowseTest { + + private val app: Application = ApplicationProvider.getApplicationContext() + + private var service: ServiceController? = null + private var browser: MediaBrowser? = null + + @After + fun tearDown() { + browser?.release() + service?.destroy() + } + + @Test + fun `un navigateur abonne est prevenu quand l'arbre change`() { + val changements = mutableListOf() + val navigateur = navigateurConnecte { parentId -> changements += parentId } + + navigateur.subscribe(BrowseTree.ROOT_ID, null).attendu("l'abonnement à la racine") + // L'état de départ : la bibliothèque de l'appareil est vide et aucune + // playlist n'existe, la section n'a donc pas lieu d'être. + assertEquals( + listOf("Albums", "Artistes", "Toutes les pistes"), + titresDesEnfants(navigateur), + ) + changements.clear() + + creerPlaylist("Sur la route") + + attendre("la notification du navigateur") { BrowseTree.ROOT_ID in changements } + assertEquals( + listOf("Albums", "Artistes", "Playlists", "Toutes les pistes"), + titresDesEnfants(navigateur), + ) + } + + /** Le service tel qu'Android le crée, et un navigateur qui s'y lie. */ + private fun navigateurConnecte(onChildrenChanged: (String) -> Unit): MediaBrowser { + val demarre = Robolectric.buildService(PlaybackService::class.java).create() + service = demarre + + // Robolectric ne démarre pas de vrai service sur `bindService` : on lui + // donne le `Binder` que le service rend lui-même. Les deux actions parce + // que le navigateur choisit la sienne d'après ce que le manifeste + // déclare, et que le manifeste les déclare toutes les deux. + val composant = ComponentName(app, PlaybackService::class.java) + listOf(MediaLibraryService.SERVICE_INTERFACE, MediaSessionService.SERVICE_INTERFACE) + .forEach { action -> + val intent = Intent(action).setComponent(composant) + shadowOf(app).setComponentNameAndServiceForBindServiceForIntent( + intent, + composant, + demarre.get().onBind(intent), + ) + } + + val ecouteur = object : MediaBrowser.Listener { + override fun onChildrenChanged( + browser: MediaBrowser, + parentId: String, + itemCount: Int, + params: MediaLibraryService.LibraryParams?, + ) = onChildrenChanged(parentId) + } + + return MediaBrowser.Builder(app, SessionToken(app, composant)) + .setListener(ecouteur) + .buildAsync() + .attendu("la liaison au service") + .also { browser = it } + } + + private fun titresDesEnfants(navigateur: MediaBrowser): List = + navigateur.getChildren(BrowseTree.ROOT_ID, 0, Int.MAX_VALUE, null) + .attendu("les enfants de la racine") + .value + .orEmpty() + .map { it.mediaMetadata.title.toString() } + + /** + * Crée une playlist par le chemin de l'application. + * + * Le service observe le même dépôt : l'écriture fait donc émettre le flux + * qu'il collecte, exactement comme un ajout depuis l'écran des playlists. + */ + private fun creerPlaylist(nom: String) { + runBlocking { (app as WaveFlowApp).container.playlistRepository.create(nom) } + } + + /** + * Attend un futur en écoulant les messages entre deux essais. + * + * Un `get()` bloquant depuis le fil de test figerait la boucle principale, + * dont dépend justement la réponse de la session. + */ + private fun ListenableFuture.attendu(quoi: String): T { + attendre(quoi) { isDone } + return get() + } + + private fun attendre(quoi: String, condition: () -> Boolean) { + val echeance = System.nanoTime() + TimeUnit.SECONDS.toNanos(TIMEOUT_S) + while (true) { + ecouler() + if (condition()) return + if (System.nanoTime() >= echeance) break + Thread.sleep(PAUSE_MS) + } + fail("Délai dépassé en attendant : $quoi") + } + + /** + * Écoule les messages de toutes les boucles encore vivantes. + * + * Toutes, et pas seulement la principale : ExoPlayer tient sa machine à + * états sur un `HandlerThread` à lui. + */ + private fun ecouler() { + ShadowLooper.getAllLoopers() + .filter { it.thread.isAlive } + // `getAllLoopers` ramasse aussi les boucles des tests précédents, + // dont les fils s'arrêtent — « Looper is quitting ». + .forEach { boucle -> runCatching { shadowOf(boucle).idle() } } + } + + private companion object { + const val TIMEOUT_S = 15L + const val PAUSE_MS = 5L + } +} From 731fd3d9fcab9304b475bda4973ef37ab43bcf21 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Mon, 24 Aug 2026 00:09:26 +0200 Subject: [PATCH 4/4] =?UTF-8?q?fix(auto):=20laisser=20la=20session=20arbit?= =?UTF-8?q?rer=20qui=20est=20encore=20abonn=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retirer un nœud du registre au désabonnement le retirait pour tout le monde : deux navigateurs peuvent suivre la même branche — Android Auto et l'Assistant, par exemple — et le départ de l'un privait l'autre de toute notification. Le registre n'est plus qu'une liste de candidats ; à chaque instantané, c'est la session qui dit lesquels valent encore, elle qui tient déjà cette comptabilité. Le test à deux navigateurs reproduit le cas : rétablir le retrait au désabonnement le fait échouer. Le premier test attendait qu'une notification arrive, sans regarder laquelle. Le service en émet plusieurs pendant que la bibliothèque et les playlists se chargent, et l'une d'elles se faisait prendre pour celle du changement — ce qui l'a rendu instable dès qu'il a partagé sa classe avec un second test. Les deux attendent désormais l'annonce d'une racine à quatre sections : le compte transmis fait partie de ce qu'on éprouve, et non plus la seule survenue d'un message. Claude-Session: https://claude.ai/code/session_01NaDC2u4EF6J3JMNxnzpdwg --- .../app/waveflow/playback/BrowseCallback.kt | 32 +++-- .../playback/PlaybackServiceBrowseTest.kt | 110 +++++++++++++----- 2 files changed, 99 insertions(+), 43 deletions(-) diff --git a/app/src/main/java/app/waveflow/playback/BrowseCallback.kt b/app/src/main/java/app/waveflow/playback/BrowseCallback.kt index 71fe1d3..ac90bd2 100644 --- a/app/src/main/java/app/waveflow/playback/BrowseCallback.kt +++ b/app/src/main/java/app/waveflow/playback/BrowseCallback.kt @@ -24,7 +24,7 @@ import java.util.concurrent.ConcurrentHashMap class BrowseCallback(private val tree: BrowseTree) : MediaLibrarySession.Callback { /** - * Les nœuds qu'un navigateur regarde en ce moment. + * Les nœuds qu'un navigateur a demandé à suivre, au moins une fois. * * Media3 tient la liste des abonnés d'un nœud donné, mais ne sait pas dire * quels nœuds ont un abonné : il faut donc les retenir pour savoir qui @@ -32,10 +32,16 @@ class BrowseCallback(private val tree: BrowseTree) : MediaLibrarySession.Callbac * notifier tous les albums d'une bibliothèque reviendrait à la parcourir une * fois par album, quand un navigateur n'en regarde qu'un. * + * Une liste de candidats, et non l'état des abonnements : c'est la session + * qui dit lesquels valent encore, et elle seule peut le dire. Deux + * navigateurs peuvent suivre le même nœud, et compter les abonnements ici + * reviendrait à tenir en double une comptabilité qu'elle tient déjà — un + * désabonnement retirerait un nœud que l'autre regarde encore. + * * Concurrent parce que rien ne garantit que les abonnements et les mises à * jour de la bibliothèque arrivent du même fil. */ - private val subscribed: MutableSet = ConcurrentHashMap.newKeySet() + private val watched: MutableSet = ConcurrentHashMap.newKeySet() override fun onSubscribe( session: MediaLibrarySession, @@ -43,21 +49,12 @@ class BrowseCallback(private val tree: BrowseTree) : MediaLibrarySession.Callbac parentId: String, params: LibraryParams?, ): ListenableFuture> { - subscribed += parentId + watched += parentId // Le comportement par défaut est conservé : c'est lui qui envoie au // navigateur le premier état du nœud, aussitôt après l'abonnement. return super.onSubscribe(session, browser, parentId, params) } - override fun onUnsubscribe( - session: MediaLibrarySession, - browser: MediaSession.ControllerInfo, - parentId: String, - ): ListenableFuture> { - subscribed -= parentId - return super.onUnsubscribe(session, browser, parentId) - } - /** * Prévient les navigateurs que l'arbre a changé sous eux. * @@ -66,13 +63,14 @@ class BrowseCallback(private val tree: BrowseTree) : MediaLibrarySession.Callbac * avant que la bibliothèque de l'appareil ne soit lue, et resterait donc * devant un arbre vide jusqu'à ce qu'on l'oblige à redemander. * - * Les nœuds que plus personne ne regarde sont oubliés au passage : un - * navigateur peut disparaître sans se désabonner, et la session est seule à - * le savoir. + * Les nœuds que plus personne ne regarde sont oubliés au passage. On le + * demande à la session plutôt que de le déduire des désabonnements : un + * navigateur peut disparaître sans se désabonner, et un nœud que deux + * navigateurs suivent reste suivi quand l'un des deux s'en va. */ fun notifySubscribers(session: MediaLibrarySession) { - subscribed.removeAll { session.getSubscribedControllers(it).isEmpty() } - subscribed.forEach { parentId -> + watched.removeAll { session.getSubscribedControllers(it).isEmpty() } + watched.forEach { parentId -> session.notifyChildrenChanged(parentId, tree.children(parentId).size, /* params = */ null) } } diff --git a/app/src/test/java/app/waveflow/playback/PlaybackServiceBrowseTest.kt b/app/src/test/java/app/waveflow/playback/PlaybackServiceBrowseTest.kt index 559a04c..d3e42ba 100644 --- a/app/src/test/java/app/waveflow/playback/PlaybackServiceBrowseTest.kt +++ b/app/src/test/java/app/waveflow/playback/PlaybackServiceBrowseTest.kt @@ -43,56 +43,99 @@ class PlaybackServiceBrowseTest { private val app: Application = ApplicationProvider.getApplicationContext() private var service: ServiceController? = null - private var browser: MediaBrowser? = null + private val browsers = mutableListOf() @After fun tearDown() { - browser?.release() + browsers.forEach(MediaBrowser::release) service?.destroy() } @Test fun `un navigateur abonne est prevenu quand l'arbre change`() { - val changements = mutableListOf() - val navigateur = navigateurConnecte { parentId -> changements += parentId } + val recues = mutableListOf() + val navigateur = navigateurConnecte(recues) - navigateur.subscribe(BrowseTree.ROOT_ID, null).attendu("l'abonnement à la racine") + navigateur.abonneALaRacine(recues) // L'état de départ : la bibliothèque de l'appareil est vide et aucune // playlist n'existe, la section n'a donc pas lieu d'être. assertEquals( listOf("Albums", "Artistes", "Toutes les pistes"), titresDesEnfants(navigateur), ) - changements.clear() creerPlaylist("Sur la route") - attendre("la notification du navigateur") { BrowseTree.ROOT_ID in changements } + attendre("la racine annoncée à quatre sections") { RACINE_AVEC_PLAYLISTS in recues } assertEquals( listOf("Albums", "Artistes", "Playlists", "Toutes les pistes"), titresDesEnfants(navigateur), ) } + @Test + fun `le depart d'un navigateur ne prive pas l'autre des notifications`() { + // Android Auto n'est pas seul à parcourir la bibliothèque : l'Assistant + // ou une autre application peuvent suivre le même nœud. Tenir soi-même + // le compte des abonnés reviendrait à retirer un nœud que quelqu'un + // regarde encore, et à le laisser sur un arbre figé. + val partant = mutableListOf() + val restant = mutableListOf() + val navigateurPartant = navigateurConnecte(partant) + val navigateurRestant = navigateurConnecte(restant) + + navigateurPartant.abonneALaRacine(partant) + navigateurRestant.abonneALaRacine(restant) + navigateurPartant.unsubscribe(BrowseTree.ROOT_ID).attendu("le désabonnement du premier") + + creerPlaylist("Sur la route") + + attendre("la racine annoncée à quatre sections au navigateur resté") { + RACINE_AVEC_PLAYLISTS in restant + } + assertEquals( + listOf("Albums", "Artistes", "Playlists", "Toutes les pistes"), + titresDesEnfants(navigateurRestant), + ) + } + + /** + * S'abonne à la racine et attend l'état initial que la session envoie. + * + * Le futur de `subscribe` dit que l'abonnement est pris, pas que le premier + * `onChildrenChanged` est passé. Sans cette attente, celui-ci pourrait + * arriver après coup et se faire prendre pour la notification du changement + * qu'on cherche à prouver. + */ + private fun MediaBrowser.abonneALaRacine(recues: MutableList) { + subscribe(BrowseTree.ROOT_ID, null).attendu("l'abonnement à la racine") + attendre("l'état initial de la racine") { recues.any { it.parentId == BrowseTree.ROOT_ID } } + recues.clear() + } + /** Le service tel qu'Android le crée, et un navigateur qui s'y lie. */ - private fun navigateurConnecte(onChildrenChanged: (String) -> Unit): MediaBrowser { - val demarre = Robolectric.buildService(PlaybackService::class.java).create() - service = demarre - - // Robolectric ne démarre pas de vrai service sur `bindService` : on lui - // donne le `Binder` que le service rend lui-même. Les deux actions parce - // que le navigateur choisit la sienne d'après ce que le manifeste - // déclare, et que le manifeste les déclare toutes les deux. + private fun navigateurConnecte(recues: MutableList): MediaBrowser { val composant = ComponentName(app, PlaybackService::class.java) - listOf(MediaLibraryService.SERVICE_INTERFACE, MediaSessionService.SERVICE_INTERFACE) - .forEach { action -> - val intent = Intent(action).setComponent(composant) - shadowOf(app).setComponentNameAndServiceForBindServiceForIntent( - intent, - composant, - demarre.get().onBind(intent), - ) - } + + // Un seul service pour tous les navigateurs, comme sur l'appareil. + if (service == null) { + val demarre = Robolectric.buildService(PlaybackService::class.java).create() + service = demarre + + // Robolectric ne démarre pas de vrai service sur `bindService` : on + // lui donne le `Binder` que le service rend lui-même. Les deux + // actions parce que le navigateur choisit la sienne d'après ce que + // le manifeste déclare, et qu'il les déclare toutes les deux. + listOf(MediaLibraryService.SERVICE_INTERFACE, MediaSessionService.SERVICE_INTERFACE) + .forEach { action -> + val intent = Intent(action).setComponent(composant) + shadowOf(app).setComponentNameAndServiceForBindServiceForIntent( + intent, + composant, + demarre.get().onBind(intent), + ) + } + } val ecouteur = object : MediaBrowser.Listener { override fun onChildrenChanged( @@ -100,14 +143,16 @@ class PlaybackServiceBrowseTest { parentId: String, itemCount: Int, params: MediaLibraryService.LibraryParams?, - ) = onChildrenChanged(parentId) + ) { + recues += Annonce(parentId, itemCount) + } } return MediaBrowser.Builder(app, SessionToken(app, composant)) .setListener(ecouteur) .buildAsync() .attendu("la liaison au service") - .also { browser = it } + .also { browsers += it } } private fun titresDesEnfants(navigateur: MediaBrowser): List = @@ -163,7 +208,20 @@ class PlaybackServiceBrowseTest { .forEach { boucle -> runCatching { shadowOf(boucle).idle() } } } + /** + * Ce qu'un navigateur apprend d'un nœud qui a changé. + * + * Le compte fait partie de ce qu'on éprouve, et pas seulement l'arrivée + * d'une annonce : le service en émet plusieurs pendant que la + * bibliothèque et les playlists se chargent, et n'importe laquelle serait + * sinon prise pour celle qu'on attend. + */ + private data class Annonce(val parentId: String, val itemCount: Int) + private companion object { + /** La racine une fois qu'une playlist existe : la quatrième section. */ + val RACINE_AVEC_PLAYLISTS = Annonce(BrowseTree.ROOT_ID, 4) + const val TIMEOUT_S = 15L const val PAUSE_MS = 5L }