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..ac90bd2 --- /dev/null +++ b/app/src/main/java/app/waveflow/playback/BrowseCallback.kt @@ -0,0 +1,151 @@ +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 +import java.util.concurrent.ConcurrentHashMap + +/** + * 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 { + + /** + * 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 + * 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. + * + * 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 watched: MutableSet = ConcurrentHashMap.newKeySet() + + override fun onSubscribe( + session: MediaLibrarySession, + browser: MediaSession.ControllerInfo, + parentId: String, + params: LibraryParams?, + ): ListenableFuture> { + 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) + } + + /** + * 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. 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) { + watched.removeAll { session.getSubscribedControllers(it).isEmpty() } + watched.forEach { parentId -> + session.notifyChildrenChanged(parentId, tree.children(parentId).size, /* params = */ null) + } + } + + 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..e339967 --- /dev/null +++ b/app/src/main/java/app/waveflow/playback/BrowseTree.kt @@ -0,0 +1,246 @@ +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() + + 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() + } + } + + /** 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()) } + + // 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 { + 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(mediaId) + .setMediaMetadata( + MediaMetadata.Builder() + .setTitle(title) + .setArtist(artist) + .setAlbumTitle(album) + .setArtworkUri(artworkUri) + .setIsBrowsable(false) + .setIsPlayable(true) + .setMediaType(MediaMetadata.MEDIA_TYPE_MUSIC) + .build(), + ) + .build() + + /** + * 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 } + + /** + * 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] } + + /** + * 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? = + localSongIdOf(mediaId)?.let { library.songsById[it] } + + 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/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/main/java/app/waveflow/playback/PlaybackService.kt b/app/src/main/java/app/waveflow/playback/PlaybackService.kt index c27005d..d15d578 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,26 @@ 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() + + /** + * 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() @@ -68,12 +96,46 @@ class PlaybackService : MediaSessionService() { // de recharger la même image à chaque rafraîchissement. val bitmapLoader = CacheBitmapLoader(CoilBitmapLoader(this, imageLoader, artworkScope)) - mediaSession = MediaSession.Builder(this, player) + 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) + } + + /** + * 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 { 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) + } + } } - override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? = mediaSession + 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..c047d61 --- /dev/null +++ b/app/src/test/java/app/waveflow/playback/BrowseTreeTest.kt @@ -0,0 +1,221 @@ +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 `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"))) + + 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)) + } +} 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..d3e42ba --- /dev/null +++ b/app/src/test/java/app/waveflow/playback/PlaybackServiceBrowseTest.kt @@ -0,0 +1,228 @@ +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 val browsers = mutableListOf() + + @After + fun tearDown() { + browsers.forEach(MediaBrowser::release) + service?.destroy() + } + + @Test + fun `un navigateur abonne est prevenu quand l'arbre change`() { + val recues = mutableListOf() + val navigateur = navigateurConnecte(recues) + + 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), + ) + + creerPlaylist("Sur la route") + + 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(recues: MutableList): MediaBrowser { + val composant = ComponentName(app, PlaybackService::class.java) + + // 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( + browser: MediaBrowser, + parentId: String, + itemCount: Int, + params: MediaLibraryService.LibraryParams?, + ) { + recues += Annonce(parentId, itemCount) + } + } + + return MediaBrowser.Builder(app, SessionToken(app, composant)) + .setListener(ecouteur) + .buildAsync() + .attendu("la liaison au service") + .also { browsers += 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() } } + } + + /** + * 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 + } +}