From bc33395792347dc0fd1cad0529da37b75279b585 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Mon, 10 Aug 2026 16:58:37 +0200 Subject: [PATCH 1/3] feat(serveur): parcourir le catalogue distant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deuxième étape de la source distante : l'onglet Serveur affiche le catalogue une fois connecté — albums, artistes, et ce que chacun contient. La lecture n'est pas branchée, un morceau distant ne fait donc encore rien. Le transport HTTP est extrait de HttpServerApi vers ServerHttp. Deux clients partagent désormais la construction d'URL et surtout le classement des erreurs : les laisser diverger reviendrait à répondre différemment à la même panne. Le catalogue a ses propres types. Ses identifiants sont des UUID, pas des entiers MediaStore, et rien ne permet aujourd'hui d'affirmer qu'une piste distante est le même fichier qu'une piste locale — la RFC-003 du serveur renvoie cette réconciliation à un jalon ultérieur. Fusionner les modèles maintenant préjugerait de ce travail. Trois particularités du serveur, relevées sur une instance réelle : - les listes renvoient un tableau nu, sans total ni curseur : la fin se déduit d'une page plus courte que demandée ; - les détails sont aplatis — `/albums/{id}` rend les champs de l'album au premier niveau, avec `songs` à côté, et non un objet imbriqué ; - `album_count` est présent sur la liste des artistes et absent de leur détail. L'écran n'affiche alors pas de sous-titre plutôt qu'un « 0 album » faux. Un jeton peut être révoqué depuis un autre appareil : il reste valide selon l'horloge locale et le serveur le refuse. Le dépôt le périme alors et rejoue l'appel une fois ; un second refus n'est plus réessayé. Pas de pochettes : l'API v2 expose un `artwork_hash` mais aucun point d'accès à l'image. Seul le pont Subsonic les sert, derrière des identifiants distincts. D'où des listes plutôt qu'une grille, qui n'afficherait que des vignettes vides. Le compte quitte l'onglet, que le catalogue occupe, et devient un écran ouvert depuis la barre du haut — d'où le découpage de ServerScreen en deux composables. Validé contre un waveflow-server 2.0.0-beta.0 local, alimenté d'une bibliothèque de six fichiers : listes, pagination, détails, ordre des pistes, et la reprise après péremption du jeton. Claude-Session: https://claude.ai/code/session_01F89rkrDB9TxcwHbfgNoyY1 --- README.md | 34 ++- .../main/java/app/waveflow/MainActivity.kt | 106 ++++++- app/src/main/java/app/waveflow/WaveFlowApp.kt | 16 +- .../app/waveflow/data/remote/CatalogApi.kt | 46 ++++ .../waveflow/data/remote/CatalogRepository.kt | 74 +++++ .../main/java/app/waveflow/data/remote/Dto.kt | 80 ++++++ .../waveflow/data/remote/HttpCatalogApi.kt | 91 ++++++ .../app/waveflow/data/remote/HttpServerApi.kt | 164 +---------- .../app/waveflow/data/remote/ServerHttp.kt | 183 ++++++++++++ .../data/remote/ServerSessionRepository.kt | 12 + .../java/app/waveflow/model/RemoteCatalog.kt | 48 ++++ .../ui/navigation/WaveFlowNavigation.kt | 9 + .../app/waveflow/ui/server/ServerScreen.kt | 39 ++- .../ui/server/catalog/CatalogUiState.kt | 37 +++ .../ui/server/catalog/CatalogViewModel.kt | 190 +++++++++++++ .../ui/server/catalog/PagedListContainer.kt | 114 ++++++++ .../ui/server/catalog/RemoteDetailScreens.kt | 161 +++++++++++ .../ui/server/catalog/ServerCatalogScreen.kt | 155 +++++++++++ .../data/remote/CatalogRepositoryTest.kt | 108 ++++++++ .../data/remote/HttpCatalogApiTest.kt | 260 ++++++++++++++++++ .../java/app/waveflow/testing/ServerFakes.kt | 175 ++++++++++++ .../waveflow/ui/server/ServerScreenTest.kt | 19 +- .../ui/server/catalog/CatalogViewModelTest.kt | 223 +++++++++++++++ 23 files changed, 2158 insertions(+), 186 deletions(-) create mode 100644 app/src/main/java/app/waveflow/data/remote/CatalogApi.kt create mode 100644 app/src/main/java/app/waveflow/data/remote/CatalogRepository.kt create mode 100644 app/src/main/java/app/waveflow/data/remote/HttpCatalogApi.kt create mode 100644 app/src/main/java/app/waveflow/data/remote/ServerHttp.kt create mode 100644 app/src/main/java/app/waveflow/model/RemoteCatalog.kt create mode 100644 app/src/main/java/app/waveflow/ui/server/catalog/CatalogUiState.kt create mode 100644 app/src/main/java/app/waveflow/ui/server/catalog/CatalogViewModel.kt create mode 100644 app/src/main/java/app/waveflow/ui/server/catalog/PagedListContainer.kt create mode 100644 app/src/main/java/app/waveflow/ui/server/catalog/RemoteDetailScreens.kt create mode 100644 app/src/main/java/app/waveflow/ui/server/catalog/ServerCatalogScreen.kt create mode 100644 app/src/test/java/app/waveflow/data/remote/CatalogRepositoryTest.kt create mode 100644 app/src/test/java/app/waveflow/data/remote/HttpCatalogApiTest.kt create mode 100644 app/src/test/java/app/waveflow/ui/server/catalog/CatalogViewModelTest.kt diff --git a/README.md b/README.md index 010f10e..5b692b5 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,8 @@ Native Android client for [WaveFlow](https://github.com/InstaZDLL/WaveFlow) — local-first music player. Kotlin + Jetpack Compose + Media3. > **Status:** local-first. Plays, browses, searches and organises the device's -> own files. A WaveFlow server can be signed into; its catalogue is not exposed -> yet — see [Server](#server) for what is and isn't wired up. +> own files. A WaveFlow server can be signed into and its catalogue browsed; +> nothing streams from it yet — see [Server](#server). ## Stack @@ -42,7 +42,7 @@ app/src/main/java/app/waveflow/ │ ├─ PlaylistRepository.kt Local playlist abstraction │ ├─ RoomPlaylistRepository.kt Room-backed implementation │ ├─ local/ Room entities, DAO, database -│ └─ remote/ WaveFlow server: auth API, session, tokens +│ └─ remote/ WaveFlow server: HTTP, auth, session, catalogue ├─ playback/ │ ├─ PlaybackService.kt Media3 MediaSessionService (ExoPlayer) │ ├─ PlaybackController.kt Playback facade + PlaybackState @@ -76,7 +76,8 @@ app/src/main/java/app/waveflow/ ├─ server/ │ ├─ ServerViewModel.kt Sign in / out, error mapping │ ├─ ServerUiState.kt Session + progress + last failure - │ └─ ServerScreen.kt Sign-in form, then the account + │ ├─ ServerScreen.kt Sign-in form and account screens + │ └─ catalog/ Remote albums / artists, paginated ├─ permission/ │ └─ AudioPermissionGate.kt Grant / deny / permanently-denied flow ├─ player/ @@ -134,6 +135,9 @@ in-memory SQLite for Room, so the DAO is exercised without a device. | `ServerSessionRepositoryTest` | token refresh and rotation, session lifetime, sign-out | | `ServerViewModelTest` | validation, error wording, connection progress | | `ServerScreenTest` | sign-in form, connected account, no token on screen | +| `HttpCatalogApiTest` | paging params, flattened details, track ordering | +| `CatalogRepositoryTest` | token plumbing, retry after a refused token | +| `CatalogViewModelTest` | paging, end of list, in-flight guard, clear on sign-out | Fakes and the `Dispatchers.Main` rule live in `src/test/java/app/waveflow/testing/`. @@ -152,7 +156,7 @@ into `DragState` and tested there instead. - [x] Search across songs, albums and artists - [x] Compose UI tests (Robolectric, no device) - [x] Sign in to a WaveFlow server (session, refresh, sign-out) -- [ ] Browse the server catalogue +- [x] Browse the server catalogue (albums, artists, paginated) - [ ] Stream from the server - [ ] Server user-data sync (playlists, favorites, ratings) — see below - [ ] Android Auto (Media3 `MediaLibraryService`) @@ -160,11 +164,21 @@ into `DragState` and tested there instead. ## Server The **Server** tab signs in to a [WaveFlow -Server](https://github.com/InstaZDLL/waveflow-server) and keeps the session -alive. That is all it does so far: nothing of the server's catalogue is shown, -and nothing of the local library is sent anywhere. The two sources stay -separate by design — the tab is its own section rather than a filter over the -existing screens. +Server](https://github.com/InstaZDLL/waveflow-server), keeps the session alive +and browses its catalogue — albums, artists, and what each contains. Playback +is not wired up yet, so tapping a remote track does nothing. Nothing of the +local library is sent anywhere. + +The two sources stay separate by design: the tab is its own section rather than +a filter over the existing screens, and `RemoteAlbum` / `RemoteArtist` / +`RemoteSong` are distinct types from their local counterparts. Their ids are +UUIDs rather than `MediaStore` integers, and nothing can currently say that a +remote track is the same file as a local one. + +Listing endpoints return a bare array — no total, no cursor — so the end of a +list is inferred from a page shorter than requested. Cover art is not shown: +the v2 API exposes an `artwork_hash` but no endpoint serving the image; only +the Subsonic facade does, behind its own separate credential. Sign-in posts to `/api/v2/auth/login` with the device model as the session name, so the server lists it among the account's devices. The access token diff --git a/app/src/main/java/app/waveflow/MainActivity.kt b/app/src/main/java/app/waveflow/MainActivity.kt index ba7c850..7dceabc 100644 --- a/app/src/main/java/app/waveflow/MainActivity.kt +++ b/app/src/main/java/app/waveflow/MainActivity.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.AccountCircle import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Search import androidx.compose.material3.ExperimentalMaterial3Api @@ -69,8 +70,13 @@ import app.waveflow.ui.playlists.PlaylistsViewModel import app.waveflow.ui.search.SearchField import app.waveflow.ui.search.SearchScreen import app.waveflow.ui.search.SearchViewModel -import app.waveflow.ui.server.ServerScreen +import app.waveflow.ui.server.ServerAccountScreen +import app.waveflow.ui.server.ServerSignInScreen import app.waveflow.ui.server.ServerViewModel +import app.waveflow.ui.server.catalog.CatalogViewModel +import app.waveflow.ui.server.catalog.RemoteAlbumDetailScreen +import app.waveflow.ui.server.catalog.RemoteArtistDetailScreen +import app.waveflow.ui.server.catalog.ServerCatalogScreen import app.waveflow.ui.theme.WaveFlowTheme class MainActivity : ComponentActivity() { @@ -96,6 +102,9 @@ private val DETAIL_ROUTES = setOf( Routes.ALBUM_DETAIL, Routes.ARTIST_DETAIL, Routes.PLAYLIST_DETAIL, + Routes.SERVER_ALBUM_DETAIL, + Routes.SERVER_ARTIST_DETAIL, + Routes.SERVER_ACCOUNT, ) @OptIn(ExperimentalMaterial3Api::class) @@ -106,6 +115,7 @@ private fun WaveFlowRoot() { val playlistsViewModel: PlaylistsViewModel = viewModel(factory = PlaylistsViewModel.Factory) val searchViewModel: SearchViewModel = viewModel(factory = SearchViewModel.Factory) val serverViewModel: ServerViewModel = viewModel(factory = ServerViewModel.Factory) + val catalogViewModel: CatalogViewModel = viewModel(factory = CatalogViewModel.Factory) val library by libraryViewModel.library.collectAsStateWithLifecycle() val playerState by playerViewModel.state.collectAsStateWithLifecycle() @@ -113,6 +123,10 @@ private fun WaveFlowRoot() { val searchQuery by searchViewModel.query.collectAsStateWithLifecycle() val searchResults by searchViewModel.results.collectAsStateWithLifecycle() val serverState by serverViewModel.state.collectAsStateWithLifecycle() + val remoteAlbums by catalogViewModel.albums.collectAsStateWithLifecycle() + val remoteArtists by catalogViewModel.artists.collectAsStateWithLifecycle() + val remoteAlbumDetail by catalogViewModel.albumDetail.collectAsStateWithLifecycle() + val remoteArtistDetail by catalogViewModel.artistDetail.collectAsStateWithLifecycle() val navController = rememberNavController() val backStackEntry by navController.currentBackStackEntryAsState() @@ -222,6 +236,19 @@ private fun WaveFlowRoot() { } } + // Le compte n'a plus sa place dans l'onglet, que le + // catalogue occupe : il s'ouvre depuis ici. + if (currentRoute == Routes.SERVER && serverState.isConnected) { + IconButton( + onClick = { navController.navigate(Routes.SERVER_ACCOUNT) }, + ) { + Icon( + imageVector = Icons.Filled.AccountCircle, + contentDescription = "Compte du serveur", + ) + } + } + openPlaylist?.let { playlist -> PlaylistMenu( playlist = playlist, @@ -360,10 +387,76 @@ private fun WaveFlowRoot() { } composable(Routes.SERVER) { - ServerScreen( - state = serverState, - onConnect = serverViewModel::connect, - onDisconnect = serverViewModel::disconnect, + // Connecté, l'onglet montre le catalogue ; le compte + // devient un écran qu'on ouvre depuis la barre du haut. + val connected = serverState.connected + if (connected == null) { + ServerSignInScreen( + state = serverState, + onConnect = serverViewModel::connect, + bottomPadding = listBottomPadding, + ) + } else { + ServerCatalogScreen( + albums = remoteAlbums, + artists = remoteArtists, + onAlbumClick = { + navController.navigate(Routes.serverAlbumDetail(it.id)) + }, + onArtistClick = { + navController.navigate(Routes.serverArtistDetail(it.id)) + }, + onLoadMoreAlbums = catalogViewModel::loadMoreAlbums, + onLoadMoreArtists = catalogViewModel::loadMoreArtists, + onRetryAlbums = catalogViewModel::retryAlbums, + onRetryArtists = catalogViewModel::retryArtists, + bottomPadding = listBottomPadding, + ) + } + } + + composable(Routes.SERVER_ACCOUNT) { + serverState.connected?.let { session -> + ServerAccountScreen( + session = session, + onDisconnect = { + serverViewModel.disconnect() + navController.popBackStack() + }, + bottomPadding = listBottomPadding, + ) + } + } + + composable( + route = Routes.SERVER_ALBUM_DETAIL, + arguments = listOf(navArgument(Routes.ARG_ALBUM_ID) { type = NavType.StringType }), + ) { entry -> + val albumId = entry.arguments?.getString(Routes.ARG_ALBUM_ID) + ?: return@composable + LaunchedEffect(albumId) { catalogViewModel.openAlbum(albumId) } + + RemoteAlbumDetailScreen( + state = remoteAlbumDetail, + onRetry = { catalogViewModel.openAlbum(albumId) }, + bottomPadding = listBottomPadding, + ) + } + + composable( + route = Routes.SERVER_ARTIST_DETAIL, + arguments = listOf(navArgument(Routes.ARG_ARTIST_ID) { type = NavType.StringType }), + ) { entry -> + val artistId = entry.arguments?.getString(Routes.ARG_ARTIST_ID) + ?: return@composable + LaunchedEffect(artistId) { catalogViewModel.openArtist(artistId) } + + RemoteArtistDetailScreen( + state = remoteArtistDetail, + onAlbumClick = { + navController.navigate(Routes.serverAlbumDetail(it.id)) + }, + onRetry = { catalogViewModel.openArtist(artistId) }, bottomPadding = listBottomPadding, ) } @@ -509,6 +602,9 @@ private fun currentScreenTitle( Routes.ARTISTS -> "Artistes" Routes.PLAYLISTS -> "Playlists" Routes.SERVER -> "Serveur" + Routes.SERVER_ACCOUNT -> "Compte" + Routes.SERVER_ALBUM_DETAIL -> "Album" + Routes.SERVER_ARTIST_DETAIL -> "Artiste" Routes.ALBUM_DETAIL -> albumId?.let { library.album(it)?.title } ?: "Album" Routes.ARTIST_DETAIL -> artistId?.let { library.artist(it)?.name } ?: "Artiste" Routes.PLAYLIST_DETAIL -> playlistName ?: "Playlist" diff --git a/app/src/main/java/app/waveflow/WaveFlowApp.kt b/app/src/main/java/app/waveflow/WaveFlowApp.kt index 30a04f7..c49d9d7 100644 --- a/app/src/main/java/app/waveflow/WaveFlowApp.kt +++ b/app/src/main/java/app/waveflow/WaveFlowApp.kt @@ -8,8 +8,11 @@ import app.waveflow.data.MusicRepository import app.waveflow.data.PlaylistRepository import app.waveflow.data.RoomPlaylistRepository import app.waveflow.data.local.WaveFlowDatabase +import app.waveflow.data.remote.CatalogRepository import app.waveflow.data.remote.DataStoreSessionStore +import app.waveflow.data.remote.HttpCatalogApi import app.waveflow.data.remote.HttpServerApi +import app.waveflow.data.remote.ServerHttp import app.waveflow.data.remote.ServerSessionRepository import app.waveflow.playback.Media3PlaybackController import app.waveflow.playback.PlaybackController @@ -71,12 +74,23 @@ class AppContainer(app: Application) { * Le nom d'appareil est celui que le serveur affichera dans la liste des * sessions ; `Build.MODEL` est ce que l'utilisateur reconnaîtra. */ + /** + * Un seul transport pour tous les appels serveur : un pool de connexions + * partagé, et surtout un seul endroit qui classe les erreurs. + */ + private val serverHttp = ServerHttp() + val serverSessionRepository = ServerSessionRepository( - api = HttpServerApi(), + api = HttpServerApi(serverHttp), store = DataStoreSessionStore(app), deviceName = Build.MODEL ?: "Android", ) + val catalogRepository = CatalogRepository( + api = HttpCatalogApi(serverHttp), + sessionRepository = serverSessionRepository, + ) + /** Relit la session persistée, sans bloquer le démarrage. */ fun restoreServerSession() { applicationScope.launch { serverSessionRepository.restore() } diff --git a/app/src/main/java/app/waveflow/data/remote/CatalogApi.kt b/app/src/main/java/app/waveflow/data/remote/CatalogApi.kt new file mode 100644 index 0000000..71405f5 --- /dev/null +++ b/app/src/main/java/app/waveflow/data/remote/CatalogApi.kt @@ -0,0 +1,46 @@ +package app.waveflow.data.remote + +import app.waveflow.model.RemoteAlbum +import app.waveflow.model.RemoteAlbumDetail +import app.waveflow.model.RemoteArtist +import app.waveflow.model.RemoteArtistDetail + +/** + * Lecture du catalogue d'un serveur WaveFlow. + * + * Chaque appel porte son jeton d'accès plutôt que d'aller le chercher : c'est + * à [CatalogRepository] de décider quand le renouveler, et ce découpage rend + * l'API testable sans session. + */ +interface CatalogApi { + + /** `GET /api/v2/albums`, trié par le serveur. */ + suspend fun albums( + serverUrl: String, + accessToken: String, + offset: Int, + limit: Int, + ): List + + /** `GET /api/v2/artists`. */ + suspend fun artists( + serverUrl: String, + accessToken: String, + offset: Int, + limit: Int, + ): List + + /** `GET /api/v2/albums/{id}` : l'album et ses morceaux d'un seul coup. */ + suspend fun album(serverUrl: String, accessToken: String, albumId: String): RemoteAlbumDetail + + /** `GET /api/v2/artists/{id}` : l'artiste et ses albums. */ + suspend fun artist(serverUrl: String, accessToken: String, artistId: String): RemoteArtistDetail +} + +/** + * Nombre d'éléments demandés par page. + * + * Le serveur refuse au-delà de 500 ; on reste bien en deçà, une page devant + * arriver assez vite pour que le défilement ne marque pas d'arrêt. + */ +const val CATALOG_PAGE_SIZE = 50 diff --git a/app/src/main/java/app/waveflow/data/remote/CatalogRepository.kt b/app/src/main/java/app/waveflow/data/remote/CatalogRepository.kt new file mode 100644 index 0000000..7f5c930 --- /dev/null +++ b/app/src/main/java/app/waveflow/data/remote/CatalogRepository.kt @@ -0,0 +1,74 @@ +package app.waveflow.data.remote + +import app.waveflow.model.RemoteAlbum +import app.waveflow.model.RemoteAlbumDetail +import app.waveflow.model.RemoteArtist +import app.waveflow.model.RemoteArtistDetail +import app.waveflow.model.ServerSession + +/** + * Le catalogue distant, muni d'une session. + * + * Fait le lien entre [CatalogApi], qui ne connaît que des jetons, et + * [ServerSessionRepository], qui les détient. + */ +class CatalogRepository( + private val api: CatalogApi, + private val sessionRepository: ServerSessionRepository, +) { + + suspend fun albums(offset: Int, limit: Int = CATALOG_PAGE_SIZE): List = + authorized { url, token -> api.albums(url, token, offset, limit) } + + suspend fun artists(offset: Int, limit: Int = CATALOG_PAGE_SIZE): List = + authorized { url, token -> api.artists(url, token, offset, limit) } + + suspend fun album(albumId: String): RemoteAlbumDetail = + authorized { url, token -> api.album(url, token, albumId) } + + suspend fun artist(artistId: String): RemoteArtistDetail = + authorized { url, token -> api.artist(url, token, artistId) } + + /** + * Exécute [call] avec un jeton valide, en réessayant une fois sur refus. + * + * [ServerSessionRepository.validAccessToken] renouvelle déjà avant + * l'échéance, mais un jeton peut être révoqué depuis un autre appareil : il + * est alors valide selon l'horloge et refusé par le serveur. Le second essai + * repart d'un jeton fraîchement obtenu ; s'il échoue à son tour, c'est que + * la session est bel et bien fermée. + */ + private suspend fun authorized(call: suspend (String, String) -> T): T { + val first = token() ?: throw ServerException.Unauthorized(SESSION_CLOSED) + + return try { + call(first.first, first.second) + } catch (refused: ServerException.Unauthorized) { + val renewed = renewedToken() ?: throw refused + call(renewed.first, renewed.second) + } + } + + /** Adresse et jeton courants, ou `null` sans session. */ + private suspend fun token(): Pair? { + val accessToken = sessionRepository.validAccessToken() ?: return null + val url = (sessionRepository.session.value as? ServerSession.Connected)?.serverUrl + ?: return null + return url to accessToken + } + + /** + * Force un renouvellement en périmant le jeton courant. + * + * Sans ça, le second essai réutiliserait celui que le serveur vient de + * refuser : l'échéance locale le croit encore bon. + */ + private suspend fun renewedToken(): Pair? { + sessionRepository.expireAccessToken() + return token() + } + + private companion object { + const val SESSION_CLOSED = "Aucune session serveur." + } +} diff --git a/app/src/main/java/app/waveflow/data/remote/Dto.kt b/app/src/main/java/app/waveflow/data/remote/Dto.kt index a697f67..0adf124 100644 --- a/app/src/main/java/app/waveflow/data/remote/Dto.kt +++ b/app/src/main/java/app/waveflow/data/remote/Dto.kt @@ -1,5 +1,8 @@ package app.waveflow.data.remote +import app.waveflow.model.RemoteAlbum +import app.waveflow.model.RemoteArtist +import app.waveflow.model.RemoteSong import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -43,3 +46,80 @@ internal data class ErrorBody( val code: String, val message: String, ) + +// --- Catalogue ------------------------------------------------------------ +// +// Le serveur aplatit ses détails : `/albums/{id}` renvoie les champs de l'album +// au premier niveau, avec `songs` à côté, et non un objet `album` imbriqué. +// D'où la répétition des champs dans les réponses de détail. + +@Serializable +internal data class AlbumResponse( + val id: String, + val title: String, + val artist: String? = null, + @SerialName("artist_id") val artistId: String? = null, + val year: Int? = null, +) { + fun toModel() = RemoteAlbum( + id = id, + title = title, + artist = artist, + artistId = artistId, + year = year, + ) +} + +@Serializable +internal data class ArtistResponse( + val id: String, + val name: String, + @SerialName("album_count") val albumCount: Int? = null, +) { + fun toModel() = RemoteArtist(id = id, name = name, albumCount = albumCount) +} + +@Serializable +internal data class SongResponse( + val id: String, + val title: String, + val album: String? = null, + @SerialName("album_id") val albumId: String? = null, + val artist: String? = null, + val track: Int? = null, + @SerialName("duration_ms") val durationMs: Long, +) { + fun toModel() = RemoteSong( + id = id, + title = title, + album = album, + albumId = albumId, + artist = artist, + trackNumber = track, + durationMs = durationMs, + ) +} + +@Serializable +internal data class AlbumDetailResponse( + val id: String, + val title: String, + val artist: String? = null, + @SerialName("artist_id") val artistId: String? = null, + val year: Int? = null, + val songs: List = emptyList(), +) { + val album: AlbumResponse + get() = AlbumResponse(id, title, artist, artistId, year) +} + +@Serializable +internal data class ArtistDetailResponse( + val id: String, + val name: String, + @SerialName("album_count") val albumCount: Int? = null, + val albums: List = emptyList(), +) { + val artist: ArtistResponse + get() = ArtistResponse(id, name, albumCount) +} diff --git a/app/src/main/java/app/waveflow/data/remote/HttpCatalogApi.kt b/app/src/main/java/app/waveflow/data/remote/HttpCatalogApi.kt new file mode 100644 index 0000000..b9b8a97 --- /dev/null +++ b/app/src/main/java/app/waveflow/data/remote/HttpCatalogApi.kt @@ -0,0 +1,91 @@ +package app.waveflow.data.remote + +import app.waveflow.model.RemoteAlbum +import app.waveflow.model.RemoteAlbumDetail +import app.waveflow.model.RemoteArtist +import app.waveflow.model.RemoteArtistDetail +import kotlinx.serialization.SerializationException + +/** Catalogue distant, par-dessus [ServerHttp]. */ +class HttpCatalogApi( + private val http: ServerHttp = ServerHttp(), +) : CatalogApi { + + override suspend fun albums( + serverUrl: String, + accessToken: String, + offset: Int, + limit: Int, + ): List = http.get( + serverUrl = serverUrl, + path = ALBUMS, + query = pageQuery(offset, limit), + accessToken = accessToken, + ).decode>().map { it.toModel() } + + override suspend fun artists( + serverUrl: String, + accessToken: String, + offset: Int, + limit: Int, + ): List = http.get( + serverUrl = serverUrl, + path = ARTISTS, + query = pageQuery(offset, limit), + accessToken = accessToken, + ).decode>().map { it.toModel() } + + override suspend fun album( + serverUrl: String, + accessToken: String, + albumId: String, + ): RemoteAlbumDetail = http.get( + serverUrl = serverUrl, + path = "$ALBUMS/$albumId", + accessToken = accessToken, + ).decode().let { response -> + RemoteAlbumDetail( + album = response.album.toModel(), + // Le serveur ne garantit pas l'ordre des morceaux d'un album ; + // le numéro de piste, lui, est ce que l'utilisateur attend. + songs = response.songs.map { it.toModel() }.sortedWith(BY_TRACK_THEN_TITLE), + ) + } + + override suspend fun artist( + serverUrl: String, + accessToken: String, + artistId: String, + ): RemoteArtistDetail = http.get( + serverUrl = serverUrl, + path = "$ARTISTS/$artistId", + accessToken = accessToken, + ).decode().let { response -> + RemoteArtistDetail( + artist = response.artist.toModel(), + albums = response.albums.map { it.toModel() }, + ) + } + + private fun pageQuery(offset: Int, limit: Int) = mapOf( + "offset" to offset.toString(), + "limit" to limit.toString(), + ) + + private inline fun String.decode(): T = try { + http.json.decodeFromString(this) + } catch (error: SerializationException) { + throw ServerException.Unexpected("Catalogue illisible : ${error.message}", error) + } + + private companion object { + const val ALBUMS = "api/v2/albums" + const val ARTISTS = "api/v2/artists" + + /** Sans numéro de piste, on retombe sur le titre plutôt que sur rien. */ + val BY_TRACK_THEN_TITLE = compareBy( + { it.trackNumber ?: Int.MAX_VALUE }, + { it.title }, + ) + } +} diff --git a/app/src/main/java/app/waveflow/data/remote/HttpServerApi.kt b/app/src/main/java/app/waveflow/data/remote/HttpServerApi.kt index 2d0010d..a45ebb1 100644 --- a/app/src/main/java/app/waveflow/data/remote/HttpServerApi.kt +++ b/app/src/main/java/app/waveflow/data/remote/HttpServerApi.kt @@ -1,140 +1,38 @@ package app.waveflow.data.remote -import kotlinx.coroutines.CancellableContinuation -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.currentCoroutineContext -import kotlinx.coroutines.ensureActive -import kotlinx.coroutines.suspendCancellableCoroutine -import kotlinx.coroutines.withContext import kotlinx.serialization.SerializationException import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json -import okhttp3.Call -import okhttp3.Callback -import okhttp3.HttpUrl -import okhttp3.HttpUrl.Companion.toHttpUrlOrNull -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.RequestBody.Companion.toRequestBody -import okhttp3.Response -import java.io.IOException -import kotlin.coroutines.resumeWithException -import kotlin.time.Duration.Companion.seconds -import kotlin.time.toJavaDuration -/** - * Client HTTP du serveur WaveFlow. - * - * OkHttp arrivait déjà par Coil ; le réutiliser évite un second pool de - * connexions. Le [json] est tolérant aux champs inconnus : le serveur en - * ajoutera, et une réponse enrichie ne doit pas casser une version installée. - */ +/** Authentification du serveur WaveFlow, par-dessus [ServerHttp]. */ class HttpServerApi( - private val client: OkHttpClient = defaultClient(), + private val http: ServerHttp = ServerHttp(), ) : ServerApi { - private val json = Json { ignoreUnknownKeys = true } - override suspend fun login( serverUrl: String, username: String, password: String, deviceName: String, - ): AuthTokens = post( + ): AuthTokens = http.post( serverUrl = serverUrl, path = AUTH_LOGIN, - body = json.encodeToString( + body = http.json.encodeToString( LoginRequest(username = username, password = password, deviceName = deviceName), ), ).toTokens() - override suspend fun refresh(serverUrl: String, refreshToken: String): AuthTokens = post( + override suspend fun refresh(serverUrl: String, refreshToken: String): AuthTokens = http.post( serverUrl = serverUrl, path = AUTH_REFRESH, - body = json.encodeToString(RefreshRequest(refreshToken = refreshToken)), + body = http.json.encodeToString(RefreshRequest(refreshToken = refreshToken)), ).toTokens() override suspend fun logout(serverUrl: String, accessToken: String) { - post(serverUrl = serverUrl, path = AUTH_LOGOUT, body = "{}", accessToken = accessToken) - } - - private suspend fun post( - serverUrl: String, - path: String, - body: String, - accessToken: String? = null, - ): String { - val url = serverUrl.toApiUrl(path) - - val request = Request.Builder() - .url(url) - .post(body.toRequestBody(JSON_MEDIA_TYPE)) - .apply { accessToken?.let { header("Authorization", "Bearer $it") } } - .build() - - // La lecture du corps est bloquante et lit sur le réseau : elle doit - // rester sous le dispatcher IO, au même titre que l'appel lui-même. - return withContext(Dispatchers.IO) { - try { - client.newCall(request).await().use { - if (it.isSuccessful) it.body?.string().orEmpty() else throw it.toException() - } - } catch (broken: IOException) { - // Une coupure pendant la lecture du corps lève ici, et non dans - // le rappel d'échec de l'appel : sans cette conversion, une - // IOException nue traverserait toute la pile jusqu'à un - // `viewModelScope` qui ne la rattrape pas. - // - // OkHttp signale aussi l'annulation par une IOException. La - // reconvertir en « serveur injoignable » masquerait l'abandon - // de l'écran, d'où la vérification préalable. - currentCoroutineContext().ensureActive() - throw ServerException.Unreachable( - broken.message ?: "Connexion interrompue.", - broken, - ) - } - } - } - - /** - * Construit l'URL d'un point d'API à partir de ce qu'a saisi l'utilisateur. - * - * L'adresse est reprise telle quelle, sauf le schéma : `192.168.1.10:4533` - * seul n'est pas une URL pour OkHttp alors que c'est ce qu'on tape. Un - * chemin déjà présent est conservé — le serveur peut vivre derrière un - * proxy qui le préfixe. - */ - private fun String.toApiUrl(path: String): HttpUrl { - val trimmed = trim().trimEnd('/') - if (trimmed.isEmpty()) throw ServerException.Rejected("Adresse du serveur vide.") - - val absolute = if (trimmed.contains("://")) trimmed else "https://$trimmed" - val base = absolute.toHttpUrlOrNull() - ?: throw ServerException.Rejected("Adresse du serveur invalide : $this") - - return base.newBuilder().addPathSegments(path).build() - } - - private fun Response.toException(): ServerException { - // Le serveur répond `{code, message}` sur ses erreurs métier, mais un - // corps mal formé lui fait renvoyer du texte brut : lire le message - // sans supposer du JSON. - val raw = body?.string().orEmpty() - val message = runCatching { json.decodeFromString(raw).message } - .getOrNull() - ?: raw.ifBlank { "Erreur $code" } - - return when (code) { - 401, 403 -> ServerException.Unauthorized(message) - in 400..499 -> ServerException.Rejected(message) - else -> ServerException.Unexpected(message) - } + http.post(serverUrl = serverUrl, path = AUTH_LOGOUT, body = "{}", accessToken = accessToken) } private fun String.toTokens(): AuthTokens = try { - json.decodeFromString(this).let { + http.json.decodeFromString(this).let { AuthTokens( accessToken = it.accessToken, refreshToken = it.refreshToken, @@ -148,54 +46,8 @@ class HttpServerApi( } private companion object { - val JSON_MEDIA_TYPE = "application/json; charset=utf-8".toMediaType() const val AUTH_LOGIN = "api/v2/auth/login" const val AUTH_REFRESH = "api/v2/auth/refresh" const val AUTH_LOGOUT = "api/v2/auth/logout" - - /** - * Les délais par défaut d'OkHttp portent sur chaque étape prise à part ; - * aucun ne borne l'appel entier. Un serveur qui répond au compte-gouttes - * laisserait donc l'écran sur « Connexion… » indéfiniment. - */ - val CALL_TIMEOUT = 30.seconds.toJavaDuration() - - fun defaultClient(): OkHttpClient = OkHttpClient.Builder() - .callTimeout(CALL_TIMEOUT) - .build() } } - -/** - * Fait d'un appel OkHttp une suspension annulable. - * - * L'annulation de la coroutine annule l'appel : sans ça, un écran quitté - * laisserait la requête vivre jusqu'à son délai d'expiration. - */ -private suspend fun Call.await(): Response = suspendCancellableCoroutine { continuation -> - enqueue(object : Callback { - override fun onResponse(call: Call, response: Response) { - // Une annulation entre l'arrivée de la réponse et sa remise laisse - // le corps ouvert, donc la connexion retenue : c'est à cette - // variante de `resume` de le refermer. - continuation.resume(response) { _, delivered, _ -> - runCatching { delivered.close() } - } - } - - override fun onFailure(call: Call, e: IOException) { - continuation.resumeIfActive( - ServerException.Unreachable(e.message ?: "Serveur injoignable.", e), - ) - } - }) - continuation.invokeOnCancellation { cancel() } -} - -/** - * Un appel annulé rapporte quand même son échec ; le reprendre alors ferait - * lever `IllegalStateException` à la place de l'annulation attendue. - */ -private fun CancellableContinuation.resumeIfActive(error: Throwable) { - if (isActive) resumeWithException(error) -} diff --git a/app/src/main/java/app/waveflow/data/remote/ServerHttp.kt b/app/src/main/java/app/waveflow/data/remote/ServerHttp.kt new file mode 100644 index 0000000..2943000 --- /dev/null +++ b/app/src/main/java/app/waveflow/data/remote/ServerHttp.kt @@ -0,0 +1,183 @@ +package app.waveflow.data.remote + +import kotlinx.coroutines.CancellableContinuation +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import okhttp3.Call +import okhttp3.Callback +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import java.io.IOException +import kotlin.coroutines.resumeWithException +import kotlin.time.Duration.Companion.seconds +import kotlin.time.toJavaDuration + +/** + * Le tuyau commun à tous les appels du serveur WaveFlow. + * + * Extrait de [HttpServerApi] quand le catalogue est arrivé : la construction + * d'URL et surtout le classement des erreurs doivent rester en un seul endroit, + * sinon deux clients répondent différemment à la même panne. + */ +class ServerHttp( + private val client: OkHttpClient = defaultClient(), +) { + + /** + * Tolérant aux champs inconnus : le serveur en ajoutera, et une réponse + * enrichie ne doit pas casser une version déjà installée. + */ + val json = Json { ignoreUnknownKeys = true } + + suspend fun post( + serverUrl: String, + path: String, + body: String, + accessToken: String? = null, + ): String = execute(serverUrl, path, accessToken) { + post(body.toRequestBody(JSON_MEDIA_TYPE)) + } + + suspend fun get( + serverUrl: String, + path: String, + query: Map = emptyMap(), + accessToken: String? = null, + ): String = execute(serverUrl, path, accessToken, query) { get() } + + private suspend fun execute( + serverUrl: String, + path: String, + accessToken: String?, + query: Map = emptyMap(), + method: Request.Builder.() -> Request.Builder, + ): String { + val url = serverUrl.toApiUrl(path, query) + + val request = Request.Builder() + .url(url) + .method() + .apply { accessToken?.let { header("Authorization", "Bearer $it") } } + .build() + + // La lecture du corps est bloquante et lit sur le réseau : elle doit + // rester sous le dispatcher IO, au même titre que l'appel lui-même. + return withContext(Dispatchers.IO) { + try { + client.newCall(request).await().use { + if (it.isSuccessful) it.body?.string().orEmpty() else throw it.toException() + } + } catch (broken: IOException) { + // Une coupure pendant la lecture du corps lève ici, et non dans + // le rappel d'échec de l'appel : sans cette conversion, une + // IOException nue traverserait toute la pile jusqu'à un + // `viewModelScope` qui ne la rattrape pas. + // + // OkHttp signale aussi l'annulation par une IOException. La + // reconvertir en « serveur injoignable » masquerait l'abandon + // de l'écran, d'où la vérification préalable. + currentCoroutineContext().ensureActive() + throw ServerException.Unreachable( + broken.message ?: "Connexion interrompue.", + broken, + ) + } + } + } + + /** + * Construit l'URL d'un point d'API à partir de ce qu'a saisi l'utilisateur. + * + * L'adresse est reprise telle quelle, sauf le schéma : `192.168.1.10:4533` + * seul n'est pas une URL pour OkHttp alors que c'est ce qu'on tape. Un + * chemin déjà présent est conservé — le serveur peut vivre derrière un + * proxy qui le préfixe. + */ + private fun String.toApiUrl(path: String, query: Map): HttpUrl { + val trimmed = trim().trimEnd('/') + if (trimmed.isEmpty()) throw ServerException.Rejected("Adresse du serveur vide.") + + val absolute = if (trimmed.contains("://")) trimmed else "https://$trimmed" + val base = absolute.toHttpUrlOrNull() + ?: throw ServerException.Rejected("Adresse du serveur invalide : $this") + + return base.newBuilder() + .addPathSegments(path) + .apply { query.forEach { (name, value) -> addQueryParameter(name, value) } } + .build() + } + + private fun Response.toException(): ServerException { + // Le serveur répond `{code, message}` sur ses erreurs métier, mais un + // corps mal formé lui fait renvoyer du texte brut : lire le message + // sans supposer du JSON. + val raw = body?.string().orEmpty() + val message = runCatching { json.decodeFromString(raw).message } + .getOrNull() + ?: raw.ifBlank { "Erreur $code" } + + return when (code) { + 401, 403 -> ServerException.Unauthorized(message) + in 400..499 -> ServerException.Rejected(message) + else -> ServerException.Unexpected(message) + } + } + + companion object { + private val JSON_MEDIA_TYPE = "application/json; charset=utf-8".toMediaType() + + /** + * Les délais par défaut d'OkHttp portent sur chaque étape prise à part ; + * aucun ne borne l'appel entier. Un serveur qui répond au compte-gouttes + * laisserait donc l'écran sur son indicateur indéfiniment. + */ + private val CALL_TIMEOUT = 30.seconds.toJavaDuration() + + fun defaultClient(): OkHttpClient = OkHttpClient.Builder() + .callTimeout(CALL_TIMEOUT) + .build() + } +} + +/** + * Fait d'un appel OkHttp une suspension annulable. + * + * L'annulation de la coroutine annule l'appel : sans ça, un écran quitté + * laisserait la requête vivre jusqu'à son délai d'expiration. + */ +private suspend fun Call.await(): Response = suspendCancellableCoroutine { continuation -> + enqueue(object : Callback { + override fun onResponse(call: Call, response: Response) { + // Une annulation entre l'arrivée de la réponse et sa remise laisse + // le corps ouvert, donc la connexion retenue : c'est à cette + // variante de `resume` de le refermer. + continuation.resume(response) { _, delivered, _ -> + runCatching { delivered.close() } + } + } + + override fun onFailure(call: Call, e: IOException) { + continuation.resumeIfActive( + ServerException.Unreachable(e.message ?: "Serveur injoignable.", e), + ) + } + }) + continuation.invokeOnCancellation { cancel() } +} + +/** + * Un appel annulé rapporte quand même son échec ; le reprendre alors ferait + * lever `IllegalStateException` à la place de l'annulation attendue. + */ +private fun CancellableContinuation.resumeIfActive(error: Throwable) { + if (isActive) resumeWithException(error) +} diff --git a/app/src/main/java/app/waveflow/data/remote/ServerSessionRepository.kt b/app/src/main/java/app/waveflow/data/remote/ServerSessionRepository.kt index 24bddfd..5b68227 100644 --- a/app/src/main/java/app/waveflow/data/remote/ServerSessionRepository.kt +++ b/app/src/main/java/app/waveflow/data/remote/ServerSessionRepository.kt @@ -104,6 +104,18 @@ class ServerSessionRepository( } } + /** + * Marque le jeton d'accès comme périmé. + * + * Utile quand le serveur en refuse un que l'horloge locale croit encore + * bon — révoqué depuis un autre appareil, par exemple. Le prochain + * [validAccessToken] renouvellera au lieu de resservir le même. + */ + suspend fun expireAccessToken() = mutex.withLock { + val current = _session.value as? ServerSession.Connected ?: return@withLock + persist(current.copy(accessExpiresAtMs = 0L)) + } + /** À n'appeler que sous [mutex]. */ private suspend fun persist(session: ServerSession) { // Le disque d'abord : l'état en mémoire ne doit jamais annoncer une diff --git a/app/src/main/java/app/waveflow/model/RemoteCatalog.kt b/app/src/main/java/app/waveflow/model/RemoteCatalog.kt new file mode 100644 index 0000000..ad9dcda --- /dev/null +++ b/app/src/main/java/app/waveflow/model/RemoteCatalog.kt @@ -0,0 +1,48 @@ +package app.waveflow.model + +/** + * Le catalogue d'un serveur WaveFlow. + * + * Volontairement distinct de [Song], [Album] et [Artist], qui décrivent la + * bibliothèque de l'appareil : les identifiants sont des UUID et non des + * entiers MediaStore, et rien ne permet aujourd'hui de dire qu'une piste + * distante est la même qu'une piste locale — la RFC-003 du serveur renvoie + * explicitement cette réconciliation à un jalon ultérieur. Fusionner les deux + * modèles maintenant reviendrait à préjuger de ce travail. + */ +data class RemoteAlbum( + val id: String, + val title: String, + val artist: String?, + val artistId: String?, + val year: Int?, +) + +data class RemoteArtist( + val id: String, + val name: String, + /** Connu depuis la liste, absent du détail : le serveur ne le renvoie pas. */ + val albumCount: Int?, +) + +data class RemoteSong( + val id: String, + val title: String, + val album: String?, + val albumId: String?, + val artist: String?, + val trackNumber: Int?, + val durationMs: Long, +) + +/** Un album et son contenu, tels que renvoyés d'un seul appel. */ +data class RemoteAlbumDetail( + val album: RemoteAlbum, + val songs: List, +) + +/** Un artiste et ses albums, tels que renvoyés d'un seul appel. */ +data class RemoteArtistDetail( + val artist: RemoteArtist, + val albums: List, +) diff --git a/app/src/main/java/app/waveflow/ui/navigation/WaveFlowNavigation.kt b/app/src/main/java/app/waveflow/ui/navigation/WaveFlowNavigation.kt index da3d036..c99c318 100644 --- a/app/src/main/java/app/waveflow/ui/navigation/WaveFlowNavigation.kt +++ b/app/src/main/java/app/waveflow/ui/navigation/WaveFlowNavigation.kt @@ -29,11 +29,20 @@ object Routes { const val ARTIST_DETAIL = "$ARTISTS/{$ARG_ARTIST_ID}" const val PLAYLIST_DETAIL = "$PLAYLISTS/{$ARG_PLAYLIST_ID}" + /** Détails distants, sous la section Serveur : leurs clés sont des UUID. */ + const val SERVER_ALBUM_DETAIL = "$SERVER/albums/{$ARG_ALBUM_ID}" + const val SERVER_ARTIST_DETAIL = "$SERVER/artists/{$ARG_ARTIST_ID}" + const val SERVER_ACCOUNT = "$SERVER/compte" + fun albumDetail(albumId: Long): String = "$ALBUMS/$albumId" fun artistDetail(artistId: Long): String = "$ARTISTS/$artistId" fun playlistDetail(playlistId: Long): String = "$PLAYLISTS/$playlistId" + + fun serverAlbumDetail(albumId: String): String = "$SERVER/albums/$albumId" + + fun serverArtistDetail(artistId: String): String = "$SERVER/artists/$artistId" } /** diff --git a/app/src/main/java/app/waveflow/ui/server/ServerScreen.kt b/app/src/main/java/app/waveflow/ui/server/ServerScreen.kt index 3f30763..a21b8c9 100644 --- a/app/src/main/java/app/waveflow/ui/server/ServerScreen.kt +++ b/app/src/main/java/app/waveflow/ui/server/ServerScreen.kt @@ -1,6 +1,7 @@ package app.waveflow.ui.server import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -46,18 +47,44 @@ import androidx.compose.ui.unit.dp import app.waveflow.model.ServerSession /** - * Onglet Serveur : connexion, puis compte connecté. + * Ouverture d'une session serveur. * * Le serveur est une source à part de la bibliothèque de l'appareil ; rien ici * ne dépend de la permission audio ni du MediaStore. + * + * Séparé du compte depuis l'arrivée du catalogue : une fois connecté, l'onglet + * affiche ce catalogue, et le compte devient un écran qu'on ouvre. */ @Composable -fun ServerScreen( +fun ServerSignInScreen( state: ServerUiState, onConnect: (serverUrl: String, username: String, password: String) -> Unit, + modifier: Modifier = Modifier, + bottomPadding: Dp = 0.dp, +) { + ScrollableColumn(modifier = modifier, bottomPadding = bottomPadding) { + ConnectionForm(state = state, onConnect = onConnect) + } +} + +/** Le compte connecté, et la sortie. */ +@Composable +fun ServerAccountScreen( + session: ServerSession.Connected, onDisconnect: () -> Unit, modifier: Modifier = Modifier, bottomPadding: Dp = 0.dp, +) { + ScrollableColumn(modifier = modifier, bottomPadding = bottomPadding) { + ConnectedAccount(session = session, onDisconnect = onDisconnect) + } +} + +@Composable +private fun ScrollableColumn( + modifier: Modifier, + bottomPadding: Dp, + content: @Composable ColumnScope.() -> Unit, ) { Column( horizontalAlignment = Alignment.CenterHorizontally, @@ -66,12 +93,8 @@ fun ServerScreen( .verticalScroll(rememberScrollState()) .padding(horizontal = 24.dp) .padding(top = 32.dp, bottom = bottomPadding + 32.dp), - ) { - when (val connected = state.connected) { - null -> ConnectionForm(state = state, onConnect = onConnect) - else -> ConnectedAccount(session = connected, onDisconnect = onDisconnect) - } - } + content = content, + ) } @Composable diff --git a/app/src/main/java/app/waveflow/ui/server/catalog/CatalogUiState.kt b/app/src/main/java/app/waveflow/ui/server/catalog/CatalogUiState.kt new file mode 100644 index 0000000..b949e91 --- /dev/null +++ b/app/src/main/java/app/waveflow/ui/server/catalog/CatalogUiState.kt @@ -0,0 +1,37 @@ +package app.waveflow.ui.server.catalog + +import app.waveflow.model.RemoteAlbumDetail +import app.waveflow.model.RemoteArtistDetail + +/** + * Une liste paginée en cours de chargement. + * + * Le serveur renvoie un tableau nu, sans total ni curseur : la fin se déduit + * d'une page plus courte que demandée. [endReached] porte cette déduction pour + * que l'écran cesse de redemander. + */ +data class PagedList( + val items: List = emptyList(), + val isLoading: Boolean = false, + val endReached: Boolean = false, + val errorMessage: String? = null, +) { + /** Vrai quand rien n'a encore été chargé et qu'il n'y a rien à montrer. */ + val isInitialLoad: Boolean get() = isLoading && items.isEmpty() + + val isEmpty: Boolean get() = !isLoading && errorMessage == null && items.isEmpty() + + /** Une erreur survenue après coup ne doit pas effacer ce qui est déjà là. */ + val hasContent: Boolean get() = items.isNotEmpty() +} + +/** Un détail chargé à la demande : album ou artiste. */ +data class DetailState( + val value: T? = null, + val isLoading: Boolean = false, + val errorMessage: String? = null, +) + +typealias AlbumDetailState = DetailState + +typealias ArtistDetailState = DetailState diff --git a/app/src/main/java/app/waveflow/ui/server/catalog/CatalogViewModel.kt b/app/src/main/java/app/waveflow/ui/server/catalog/CatalogViewModel.kt new file mode 100644 index 0000000..93c27a2 --- /dev/null +++ b/app/src/main/java/app/waveflow/ui/server/catalog/CatalogViewModel.kt @@ -0,0 +1,190 @@ +package app.waveflow.ui.server.catalog + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import app.waveflow.WaveFlowApp +import app.waveflow.data.remote.CATALOG_PAGE_SIZE +import app.waveflow.data.remote.CatalogRepository +import app.waveflow.data.remote.ServerException +import app.waveflow.model.RemoteAlbum +import app.waveflow.model.RemoteArtist +import app.waveflow.model.ServerSession +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch + +/** + * Parcours du catalogue distant : albums et artistes, page par page. + * + * Les pages ne sont pas conservées à la déconnexion : elles appartiennent à un + * compte, et l'écran suivant pourrait être celui d'un autre. + */ +class CatalogViewModel( + private val catalogRepository: CatalogRepository, + session: StateFlow, +) : ViewModel() { + + private val _albums = MutableStateFlow(PagedList()) + val albums: StateFlow> = _albums.asStateFlow() + + private val _artists = MutableStateFlow(PagedList()) + val artists: StateFlow> = _artists.asStateFlow() + + private val _albumDetail = MutableStateFlow(AlbumDetailState()) + val albumDetail: StateFlow = _albumDetail.asStateFlow() + + private val _artistDetail = MutableStateFlow(ArtistDetailState()) + val artistDetail: StateFlow = _artistDetail.asStateFlow() + + /** Une seule page en vol par liste : deux requêtes doubleraient le contenu. */ + private var albumsJob: Job? = null + private var artistsJob: Job? = null + private var detailJob: Job? = null + + init { + session + .map { it is ServerSession.Connected } + .distinctUntilChanged() + .onEach { connected -> if (connected) loadFirstPages() else clear() } + .launchIn(viewModelScope) + } + + /** Charge la page suivante d'albums, si elle a lieu d'être. */ + fun loadMoreAlbums() { + val current = _albums.value + if (albumsJob?.isActive == true || current.endReached) return + + albumsJob = viewModelScope.launch { + _albums.value = current.copy(isLoading = true, errorMessage = null) + try { + val page = catalogRepository.albums(offset = current.items.size) + _albums.value = PagedList( + items = current.items + page, + isLoading = false, + // Le serveur ne dit pas combien il en reste : une page plus + // courte que demandée est le seul signal de fin. + endReached = page.size < CATALOG_PAGE_SIZE, + ) + } catch (cancellation: CancellationException) { + throw cancellation + } catch (error: Exception) { + _albums.value = current.copy(isLoading = false, errorMessage = error.toMessage()) + } + } + } + + fun loadMoreArtists() { + val current = _artists.value + if (artistsJob?.isActive == true || current.endReached) return + + artistsJob = viewModelScope.launch { + _artists.value = current.copy(isLoading = true, errorMessage = null) + try { + val page = catalogRepository.artists(offset = current.items.size) + _artists.value = PagedList( + items = current.items + page, + isLoading = false, + endReached = page.size < CATALOG_PAGE_SIZE, + ) + } catch (cancellation: CancellationException) { + throw cancellation + } catch (error: Exception) { + _artists.value = current.copy(isLoading = false, errorMessage = error.toMessage()) + } + } + } + + /** Repart de zéro : après une erreur, ou sur demande explicite. */ + fun retryAlbums() { + albumsJob?.cancel() + _albums.value = PagedList() + loadMoreAlbums() + } + + fun retryArtists() { + artistsJob?.cancel() + _artists.value = PagedList() + loadMoreArtists() + } + + fun openAlbum(albumId: String) { + detailJob?.cancel() + _albumDetail.value = AlbumDetailState(isLoading = true) + detailJob = viewModelScope.launch { + try { + _albumDetail.value = AlbumDetailState(value = catalogRepository.album(albumId)) + } catch (cancellation: CancellationException) { + throw cancellation + } catch (error: Exception) { + _albumDetail.value = AlbumDetailState(errorMessage = error.toMessage()) + } + } + } + + fun openArtist(artistId: String) { + detailJob?.cancel() + _artistDetail.value = ArtistDetailState(isLoading = true) + detailJob = viewModelScope.launch { + try { + _artistDetail.value = ArtistDetailState(value = catalogRepository.artist(artistId)) + } catch (cancellation: CancellationException) { + throw cancellation + } catch (error: Exception) { + _artistDetail.value = ArtistDetailState(errorMessage = error.toMessage()) + } + } + } + + private fun loadFirstPages() { + loadMoreAlbums() + loadMoreArtists() + } + + private fun clear() { + albumsJob?.cancel() + artistsJob?.cancel() + detailJob?.cancel() + _albums.value = PagedList() + _artists.value = PagedList() + _albumDetail.value = AlbumDetailState() + _artistDetail.value = ArtistDetailState() + } + + private fun Exception.toMessage(): String = when (this) { + is ServerException.Unauthorized -> "Session expirée. Reconnectez-vous." + is ServerException.Unreachable -> "Serveur injoignable." + is ServerException -> "Le serveur n'a pas pu répondre." + else -> { + // Un échec qui n'est pas de nature réseau : le journaliser, sinon + // il ne resterait qu'un message générique sans trace. + Log.w(TAG, "Échec inattendu du catalogue", this) + "Une erreur inattendue est survenue." + } + } + + companion object { + private const val TAG = "CatalogViewModel" + + val Factory: ViewModelProvider.Factory = viewModelFactory { + initializer { + val app = this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] + as WaveFlowApp + CatalogViewModel( + catalogRepository = app.container.catalogRepository, + session = app.container.serverSessionRepository.session, + ) + } + } + } +} diff --git a/app/src/main/java/app/waveflow/ui/server/catalog/PagedListContainer.kt b/app/src/main/java/app/waveflow/ui/server/catalog/PagedListContainer.kt new file mode 100644 index 0000000..fffebca --- /dev/null +++ b/app/src/main/java/app/waveflow/ui/server/catalog/PagedListContainer.kt @@ -0,0 +1,114 @@ +package app.waveflow.ui.server.catalog + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import app.waveflow.ui.components.CenteredMessage + +/** + * États d'une liste paginée qui n'a encore rien à montrer. + * + * Une fois du contenu affiché, l'écran ne le remplace plus : une page qui + * échoue se signale en pied de liste, pas en effaçant ce qui est déjà lu. + */ +@Composable +fun PagedListContainer( + state: PagedList<*>, + emptyMessage: String, + onRetry: () -> Unit, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + when { + state.hasContent -> content() + + state.isInitialLoad -> Box(modifier = modifier.fillMaxSize()) { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } + + state.errorMessage != null -> Box(modifier = modifier.fillMaxSize()) { + CenteredMessage( + message = state.errorMessage, + modifier = Modifier.align(Alignment.Center), + action = { Button(onClick = onRetry) { Text("Réessayer") } }, + ) + } + + state.isEmpty -> Box(modifier = modifier.fillMaxSize()) { + CenteredMessage( + message = emptyMessage, + modifier = Modifier.align(Alignment.Center), + ) + } + } +} + +/** + * Pied de liste : progression de la page suivante, ou son échec. + * + * L'erreur est ici plutôt qu'à la place de la liste : le contenu déjà chargé + * reste consultable, et réessayer ne fait repartir que la page manquante. + */ +@Composable +fun PagedListFooter( + state: PagedList<*>, + onRetry: () -> Unit, + modifier: Modifier = Modifier, +) { + when { + state.isLoading -> Box( + contentAlignment = Alignment.Center, + modifier = modifier + .fillMaxWidth() + .padding(16.dp), + ) { + CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(24.dp)) + } + + state.errorMessage != null -> CenteredMessage( + message = state.errorMessage, + modifier = modifier.fillMaxWidth(), + action = { Button(onClick = onRetry) { Text("Réessayer") } }, + ) + } +} + +/** + * Demande la page suivante quand le bas de la liste approche. + * + * [PREFETCH_DISTANCE] éléments d'avance, pour que la page arrive avant que le + * doigt n'atteigne le vide. + */ +@Composable +fun LoadMoreOnApproachingEnd( + listState: LazyListState, + itemCount: Int, + onLoadMore: () -> Unit, +) { + val shouldLoad by remember(itemCount) { + derivedStateOf { + val last = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: return@derivedStateOf false + last >= itemCount - PREFETCH_DISTANCE + } + } + + LaunchedEffect(shouldLoad, itemCount) { + if (shouldLoad) onLoadMore() + } +} + +private const val PREFETCH_DISTANCE = 5 diff --git a/app/src/main/java/app/waveflow/ui/server/catalog/RemoteDetailScreens.kt b/app/src/main/java/app/waveflow/ui/server/catalog/RemoteDetailScreens.kt new file mode 100644 index 0000000..67b5c7e --- /dev/null +++ b/app/src/main/java/app/waveflow/ui/server/catalog/RemoteDetailScreens.kt @@ -0,0 +1,161 @@ +package app.waveflow.ui.server.catalog + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import app.waveflow.model.RemoteAlbum +import app.waveflow.model.RemoteSong +import app.waveflow.model.orUnknownArtist +import app.waveflow.ui.albumCountLabel +import app.waveflow.ui.components.CenteredMessage +import app.waveflow.ui.components.MediaRow +import app.waveflow.ui.formatDuration +import app.waveflow.ui.trackCountLabel + +/** Un album distant et ses morceaux. */ +@Composable +fun RemoteAlbumDetailScreen( + state: AlbumDetailState, + onRetry: () -> Unit, + modifier: Modifier = Modifier, + bottomPadding: Dp = 0.dp, +) { + DetailContainer(state = state, onRetry = onRetry, modifier = modifier) { detail -> + LazyColumn( + contentPadding = PaddingValues(bottom = bottomPadding), + modifier = Modifier.fillMaxSize(), + ) { + item { + RemoteDetailHeader( + title = detail.album.title, + subtitle = detail.album.artist.orUnknownArtist(), + summary = listOfNotNull( + trackCountLabel(detail.songs.size), + detail.album.year?.toString(), + ).joinToString(" · "), + ) + } + + items(detail.songs, key = { it.id }) { song -> + RemoteSongRow(song = song) + } + } + } +} + +/** Un artiste distant et ses albums. */ +@Composable +fun RemoteArtistDetailScreen( + state: ArtistDetailState, + onAlbumClick: (RemoteAlbum) -> Unit, + onRetry: () -> Unit, + modifier: Modifier = Modifier, + bottomPadding: Dp = 0.dp, +) { + DetailContainer(state = state, onRetry = onRetry, modifier = modifier) { detail -> + LazyColumn( + contentPadding = PaddingValues(bottom = bottomPadding), + modifier = Modifier.fillMaxSize(), + ) { + item { + RemoteDetailHeader( + title = detail.artist.name, + subtitle = "Artiste", + // Le compte du serveur est absent sur ce chemin ; celui des + // albums renvoyés est ce qu'on sait vraiment. + summary = albumCountLabel(detail.albums.size), + ) + } + + items(detail.albums, key = { it.id }) { album -> + MediaRow( + artworkUri = null, + title = album.title, + subtitle = album.year?.toString().orEmpty(), + onClick = { onAlbumClick(album) }, + ) + } + } + } +} + +/** + * Chargement, échec, ou contenu. + * + * Un détail se charge d'un seul appel : contrairement aux listes paginées, il + * n'y a pas de contenu partiel à préserver derrière une erreur. + */ +@Composable +private fun DetailContainer( + state: DetailState, + onRetry: () -> Unit, + modifier: Modifier = Modifier, + content: @Composable (T) -> Unit, +) { + val value = state.value + when { + value != null -> content(value) + + state.isLoading -> Box(modifier = modifier.fillMaxSize()) { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } + + else -> Box(modifier = modifier.fillMaxSize()) { + CenteredMessage( + message = state.errorMessage ?: "Introuvable sur le serveur.", + modifier = Modifier.align(Alignment.Center), + action = { Button(onClick = onRetry) { Text("Réessayer") } }, + ) + } + } +} + +/** + * En-tête d'un détail distant. + * + * `DetailHeader` de la navigation locale n'est pas réutilisé : il porte les + * boutons Lecture et Aléatoire, or rien n'est encore lisible depuis le serveur. + * Proposer des commandes inertes serait pire que de ne pas les montrer. + */ +@Composable +private fun RemoteDetailHeader( + title: String, + subtitle: String, + summary: String, +) { + MediaRow( + artworkUri = null, + title = title, + subtitle = listOf(subtitle, summary).filter { it.isNotBlank() }.joinToString(" · "), + onClick = {}, + artworkShape = CircleShape, + modifier = Modifier.fillMaxWidth(), + ) +} + +@Composable +private fun RemoteSongRow(song: RemoteSong) { + MediaRow( + artworkUri = null, + title = song.title, + subtitle = listOfNotNull( + song.artist?.takeIf { it.isNotBlank() }, + formatDuration(song.durationMs), + ).joinToString(" · "), + // La lecture distante arrive à l'étape suivante : une ligne qui ne + // réagit pas vaut mieux qu'une qui promet et ne fait rien. + onClick = {}, + ) +} diff --git a/app/src/main/java/app/waveflow/ui/server/catalog/ServerCatalogScreen.kt b/app/src/main/java/app/waveflow/ui/server/catalog/ServerCatalogScreen.kt new file mode 100644 index 0000000..a26f037 --- /dev/null +++ b/app/src/main/java/app/waveflow/ui/server/catalog/ServerCatalogScreen.kt @@ -0,0 +1,155 @@ +package app.waveflow.ui.server.catalog + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Tab +import androidx.compose.material3.PrimaryTabRow +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import app.waveflow.model.RemoteAlbum +import app.waveflow.model.RemoteArtist +import app.waveflow.model.orUnknownArtist +import app.waveflow.ui.albumCountLabel +import app.waveflow.ui.components.MediaRow + +private enum class CatalogTab(val label: String) { + Albums("Albums"), + Artists("Artistes"), +} + +/** + * Catalogue d'un serveur connecté. + * + * En listes et non en grille de pochettes, contrairement aux albums locaux : + * l'API v2 n'expose aucun point d'accès aux images, une grille n'afficherait + * donc que des vignettes vides. + */ +@Composable +fun ServerCatalogScreen( + albums: PagedList, + artists: PagedList, + onAlbumClick: (RemoteAlbum) -> Unit, + onArtistClick: (RemoteArtist) -> Unit, + onLoadMoreAlbums: () -> Unit, + onLoadMoreArtists: () -> Unit, + onRetryAlbums: () -> Unit, + onRetryArtists: () -> Unit, + modifier: Modifier = Modifier, + bottomPadding: Dp = 0.dp, +) { + var tab by rememberSaveable { mutableStateOf(CatalogTab.Albums) } + + Column(modifier = modifier.fillMaxSize()) { + PrimaryTabRow(selectedTabIndex = tab.ordinal) { + CatalogTab.entries.forEach { entry -> + Tab( + selected = tab == entry, + onClick = { tab = entry }, + text = { Text(entry.label) }, + ) + } + } + + when (tab) { + CatalogTab.Albums -> AlbumsTab( + state = albums, + onAlbumClick = onAlbumClick, + onLoadMore = onLoadMoreAlbums, + onRetry = onRetryAlbums, + bottomPadding = bottomPadding, + ) + + CatalogTab.Artists -> ArtistsTab( + state = artists, + onArtistClick = onArtistClick, + onLoadMore = onLoadMoreArtists, + onRetry = onRetryArtists, + bottomPadding = bottomPadding, + ) + } + } +} + +@Composable +private fun AlbumsTab( + state: PagedList, + onAlbumClick: (RemoteAlbum) -> Unit, + onLoadMore: () -> Unit, + onRetry: () -> Unit, + bottomPadding: Dp, +) { + val listState = rememberLazyListState() + LoadMoreOnApproachingEnd(listState, state.items.size, onLoadMore) + + PagedListContainer( + state = state, + emptyMessage = "Ce serveur n'a aucun album. Lancez une analyse depuis son administration.", + onRetry = onRetry, + ) { + LazyColumn( + state = listState, + contentPadding = PaddingValues(bottom = bottomPadding), + modifier = Modifier.fillMaxSize(), + ) { + items(state.items, key = { it.id }) { album -> + MediaRow( + artworkUri = null, + title = album.title, + subtitle = album.artist.orUnknownArtist(), + onClick = { onAlbumClick(album) }, + ) + } + item { PagedListFooter(state = state, onRetry = onRetry) } + } + } +} + +@Composable +private fun ArtistsTab( + state: PagedList, + onArtistClick: (RemoteArtist) -> Unit, + onLoadMore: () -> Unit, + onRetry: () -> Unit, + bottomPadding: Dp, +) { + val listState = rememberLazyListState() + LoadMoreOnApproachingEnd(listState, state.items.size, onLoadMore) + + PagedListContainer( + state = state, + emptyMessage = "Ce serveur n'a aucun artiste.", + onRetry = onRetry, + ) { + LazyColumn( + state = listState, + contentPadding = PaddingValues(bottom = bottomPadding), + modifier = Modifier.fillMaxSize(), + ) { + items(state.items, key = { it.id }) { artist -> + MediaRow( + artworkUri = null, + title = artist.name, + // Le serveur omet le compte sur certains chemins : mieux + // vaut une ligne sans sous-titre qu'un « 0 album » faux. + subtitle = artist.albumCount?.let(::albumCountLabel).orEmpty(), + onClick = { onArtistClick(artist) }, + artworkShape = CircleShape, + ) + } + item { PagedListFooter(state = state, onRetry = onRetry) } + } + } +} + diff --git a/app/src/test/java/app/waveflow/data/remote/CatalogRepositoryTest.kt b/app/src/test/java/app/waveflow/data/remote/CatalogRepositoryTest.kt new file mode 100644 index 0000000..e6b77ba --- /dev/null +++ b/app/src/test/java/app/waveflow/data/remote/CatalogRepositoryTest.kt @@ -0,0 +1,108 @@ +package app.waveflow.data.remote + +import app.waveflow.testing.FakeCatalogApi +import app.waveflow.testing.FakeServerApi +import app.waveflow.testing.FakeSessionStore +import app.waveflow.model.ServerSession +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** Le catalogue muni d'une session : ce qui se passe quand le jeton lâche. */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +class CatalogRepositoryTest { + + private val session = ServerSession.Connected( + serverUrl = "https://musique.test", + username = "admin", + accessToken = "wfa_stocke", + refreshToken = "wfr_stocke", + deviceId = "appareil-1", + accessExpiresAtMs = Long.MAX_VALUE, + ) + + private suspend fun connectedSessionRepository( + api: FakeServerApi = FakeServerApi(), + ): ServerSessionRepository = ServerSessionRepository( + api = api, + store = FakeSessionStore(stored = session), + deviceName = "Pixel de test", + now = { 0L }, + ).also { it.restore() } + + @Test + fun `les appels portent l'adresse et le jeton de la session`() = runTest { + val catalog = FakeCatalogApi() + val repository = CatalogRepository(catalog, connectedSessionRepository()) + + repository.albums(offset = 10, limit = 20) + + assertEquals("https://musique.test", catalog.lastServerUrl) + assertEquals("wfa_stocke", catalog.lastAccessToken) + assertEquals(10 to 20, catalog.lastPage) + } + + @Test + fun `sans session l'appel echoue plutot que de partir sans jeton`() = runTest { + val catalog = FakeCatalogApi() + val repository = CatalogRepository( + catalog, + ServerSessionRepository( + api = FakeServerApi(), + store = FakeSessionStore(), + deviceName = "Pixel de test", + now = { 0L }, + ), + ) + + val error = runCatching { repository.albums(0) }.exceptionOrNull() + + assertTrue(error is ServerException.Unauthorized) + assertEquals(0, catalog.calls) + } + + @Test + fun `un jeton refuse est renouvele et l'appel rejoue`() = runTest { + // Cas réel : le jeton est révoqué depuis un autre appareil. L'horloge + // locale le croit encore valide, seul le serveur sait qu'il ne l'est + // plus — donc pas de renouvellement anticipé possible. + val catalog = FakeCatalogApi(failuresBeforeSuccess = 1) + val serverApi = FakeServerApi() + val repository = CatalogRepository(catalog, connectedSessionRepository(serverApi)) + + repository.albums(0) + + assertEquals("deux appels : le refusé, puis celui d'après", 2, catalog.calls) + assertEquals(1, serverApi.refreshCalls) + assertEquals("wfa_1", catalog.lastAccessToken) + } + + @Test + fun `un second refus n'est pas rejoue indefiniment`() = runTest { + val catalog = FakeCatalogApi(failuresBeforeSuccess = Int.MAX_VALUE) + val repository = CatalogRepository(catalog, connectedSessionRepository()) + + val error = runCatching { repository.albums(0) }.exceptionOrNull() + + assertTrue(error is ServerException.Unauthorized) + assertEquals(2, catalog.calls) + } + + @Test + fun `une panne reseau n'est pas prise pour un jeton perime`() = runTest { + val catalog = FakeCatalogApi(failure = ServerException.Unreachable("coupure")) + val serverApi = FakeServerApi() + val repository = CatalogRepository(catalog, connectedSessionRepository(serverApi)) + + val error = runCatching { repository.albums(0) }.exceptionOrNull() + + assertTrue(error is ServerException.Unreachable) + assertEquals("rien à renouveler", 0, serverApi.refreshCalls) + assertEquals(1, catalog.calls) + } +} diff --git a/app/src/test/java/app/waveflow/data/remote/HttpCatalogApiTest.kt b/app/src/test/java/app/waveflow/data/remote/HttpCatalogApiTest.kt new file mode 100644 index 0000000..8f44566 --- /dev/null +++ b/app/src/test/java/app/waveflow/data/remote/HttpCatalogApiTest.kt @@ -0,0 +1,260 @@ +package app.waveflow.data.remote + +import kotlinx.coroutines.test.runTest +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** + * Le client du catalogue face à un serveur de test. + * + * Les corps sont ceux relevés sur `waveflow-server` 2.0.0-beta.0 : notamment le + * fait que les détails sont **aplatis** — `/albums/{id}` renvoie les champs de + * l'album au premier niveau, avec `songs` à côté, et non un objet imbriqué. + */ +class HttpCatalogApiTest { + + private lateinit var server: MockWebServer + private lateinit var api: CatalogApi + + @Before + fun setUp() { + server = MockWebServer() + server.start() + api = HttpCatalogApi() + } + + @After + fun tearDown() { + server.shutdown() + } + + private fun url(): String = server.url("/").toString().trimEnd('/') + + private suspend fun echecDe(bloc: suspend () -> Unit): Throwable = + runCatching { bloc() }.exceptionOrNull() + ?: throw AssertionError("aucune exception levée") + + @Test + fun `la liste d'albums est paginee et authentifiee`() = runTest { + server.enqueue(MockResponse().setBody(ALBUMS_BODY)) + + val albums = api.albums(url(), "wfa_1", offset = 50, limit = 25) + + val request = server.takeRequest() + assertEquals("GET", request.method) + assertEquals("/api/v2/albums?offset=50&limit=25", request.path) + assertEquals("Bearer wfa_1", request.getHeader("Authorization")) + + assertEquals(2, albums.size) + assertEquals("Nuit Blanche", albums[0].title) + assertEquals("Aurore", albums[0].artist) + assertEquals("f7ba66f7-dfae-4e86-b1e1-f356f4c092b7", albums[0].artistId) + // `year` est nul sur ces fichiers : le champ doit rester facultatif. + assertNull(albums[0].year) + } + + @Test + fun `la liste d'artistes reprend le compte d'albums`() = runTest { + server.enqueue(MockResponse().setBody(ARTISTS_BODY)) + + val artists = api.artists(url(), "wfa_1", offset = 0, limit = 50) + + assertEquals("/api/v2/artists?offset=0&limit=50", server.takeRequest().path) + assertEquals("Aurore", artists[0].name) + assertEquals(2, artists[0].albumCount) + } + + @Test + fun `le detail d'un album est aplati et ses morceaux ordonnes`() = runTest { + server.enqueue(MockResponse().setBody(ALBUM_DETAIL_BODY)) + + val detail = api.album(url(), "wfa_1", "1daf991a") + + assertEquals("/api/v2/albums/1daf991a", server.takeRequest().path) + assertEquals("Nuit Blanche", detail.album.title) + assertEquals("Aurore", detail.album.artist) + // Le corps les donne dans le désordre : c'est le numéro de piste qui + // fait foi, pas l'ordre d'arrivée. + assertEquals(listOf("Première Lueur", "Ciel Bas", "Aube Grise"), detail.songs.map { it.title }) + assertEquals(3030L, detail.songs[0].durationMs) + } + + @Test + fun `un morceau sans numero de piste passe apres les autres`() = runTest { + server.enqueue(MockResponse().setBody(ALBUM_WITHOUT_TRACK_NUMBERS)) + + val detail = api.album(url(), "wfa_1", "album") + + assertEquals(listOf("Avec numéro", "Sans numéro"), detail.songs.map { it.title }) + } + + @Test + fun `le detail d'un artiste porte ses albums et pas de compte`() = runTest { + server.enqueue(MockResponse().setBody(ARTIST_DETAIL_BODY)) + + val detail = api.artist(url(), "wfa_1", "f7ba66f7") + + assertEquals("/api/v2/artists/f7ba66f7", server.takeRequest().path) + assertEquals("Aurore", detail.artist.name) + // Le serveur ne le renvoie pas sur ce chemin : ne rien inventer. + assertNull(detail.artist.albumCount) + assertEquals(listOf("Nuit Blanche", "Second Souffle"), detail.albums.map { it.title }) + } + + @Test + fun `un jeton refuse remonte comme tel`() = runTest { + server.enqueue( + MockResponse() + .setResponseCode(401) + .setBody("""{"code":"unauthorized","message":"Authentication failed"}"""), + ) + + val error = echecDe { api.albums(url(), "wfa_perime", 0, 50) } + + assertTrue(error.toString(), error is ServerException.Unauthorized) + } + + @Test + fun `un catalogue illisible est signale comme inattendu`() = runTest { + server.enqueue(MockResponse().setBody("""{"pas":"un tableau"}""")) + + val error = echecDe { api.albums(url(), "wfa_1", 0, 50) } + + assertTrue(error.toString(), error is ServerException.Unexpected) + } + + @Test + fun `les champs inconnus du catalogue ne cassent rien`() = runTest { + server.enqueue(MockResponse().setBody(ALBUMS_BODY_WITH_EXTRAS)) + + assertEquals(2, api.albums(url(), "wfa_1", 0, 50).size) + } + + private companion object { + val ALBUMS_BODY = """ + [ + { + "id": "1daf991a-5f98-4b02-a885-fb64a664d308", + "library_id": "ad75e269-f5b6-4960-b90f-c940008ce014", + "title": "Nuit Blanche", + "artist": "Aurore", + "artist_id": "f7ba66f7-dfae-4e86-b1e1-f356f4c092b7", + "artwork_hash": null, + "year": null, + "created_at": 1786372969163, + "starred_at": null, + "user_rating": null, + "play_count": 0, + "last_played_at": null + }, + { + "id": "ecbc899a-5d7b-41a0-aabf-5c594e77591b", + "library_id": "ad75e269-f5b6-4960-b90f-c940008ce014", + "title": "Second Souffle", + "artist": "Aurore", + "artist_id": "f7ba66f7-dfae-4e86-b1e1-f356f4c092b7", + "artwork_hash": null, + "year": 2024, + "created_at": 1786372969163, + "starred_at": null, + "user_rating": null, + "play_count": 0, + "last_played_at": null + } + ] + """.trimIndent() + + val ALBUMS_BODY_WITH_EXTRAS = ALBUMS_BODY.replace( + "\"play_count\": 0,", + "\"play_count\": 0, \"nouveaute\": {\"a\": 1},", + ) + + val ARTISTS_BODY = """ + [ + { + "id": "f7ba66f7-dfae-4e86-b1e1-f356f4c092b7", + "library_id": "ad75e269-f5b6-4960-b90f-c940008ce014", + "name": "Aurore", + "artwork_hash": null, + "starred_at": null, + "user_rating": null, + "album_count": 2 + } + ] + """.trimIndent() + + /** Les morceaux sont volontairement donnés dans le désordre. */ + val ALBUM_DETAIL_BODY = """ + { + "id": "1daf991a-5f98-4b02-a885-fb64a664d308", + "library_id": "ad75e269-f5b6-4960-b90f-c940008ce014", + "title": "Nuit Blanche", + "artist": "Aurore", + "artist_id": "f7ba66f7-dfae-4e86-b1e1-f356f4c092b7", + "year": null, + "songs": [ + { + "id": "c3", "library_id": "l", "album_id": "a", + "title": "Aube Grise", "album": "Nuit Blanche", "artist": "Aurore", + "track": 3, "duration_ms": 3030, "suffix": "mp3", "size": 1, + "created_at": 0 + }, + { + "id": "c1", "library_id": "l", "album_id": "a", + "title": "Première Lueur", "album": "Nuit Blanche", "artist": "Aurore", + "track": 1, "duration_ms": 3030, "suffix": "mp3", "size": 1, + "created_at": 0 + }, + { + "id": "c2", "library_id": "l", "album_id": "a", + "title": "Ciel Bas", "album": "Nuit Blanche", "artist": "Aurore", + "track": 2, "duration_ms": 3030, "suffix": "mp3", "size": 1, + "created_at": 0 + } + ] + } + """.trimIndent() + + val ALBUM_WITHOUT_TRACK_NUMBERS = """ + { + "id": "a", "library_id": "l", "title": "Sans ordre", + "songs": [ + { + "id": "s2", "library_id": "l", "title": "Sans numéro", + "duration_ms": 1000, "suffix": "mp3", "size": 1, "created_at": 0 + }, + { + "id": "s1", "library_id": "l", "title": "Avec numéro", + "track": 1, "duration_ms": 1000, "suffix": "mp3", "size": 1, + "created_at": 0 + } + ] + } + """.trimIndent() + + val ARTIST_DETAIL_BODY = """ + { + "id": "f7ba66f7-dfae-4e86-b1e1-f356f4c092b7", + "library_id": "ad75e269-f5b6-4960-b90f-c940008ce014", + "name": "Aurore", + "artwork_hash": null, + "albums": [ + { + "id": "1daf991a", "library_id": "l", "title": "Nuit Blanche", + "artist": "Aurore", "created_at": 0, "play_count": 0 + }, + { + "id": "ecbc899a", "library_id": "l", "title": "Second Souffle", + "artist": "Aurore", "created_at": 0, "play_count": 0 + } + ] + } + """.trimIndent() + } +} diff --git a/app/src/test/java/app/waveflow/testing/ServerFakes.kt b/app/src/test/java/app/waveflow/testing/ServerFakes.kt index 7ecae7c..1c3a8c1 100644 --- a/app/src/test/java/app/waveflow/testing/ServerFakes.kt +++ b/app/src/test/java/app/waveflow/testing/ServerFakes.kt @@ -1,8 +1,14 @@ package app.waveflow.testing import app.waveflow.data.remote.AuthTokens +import app.waveflow.data.remote.CatalogApi import app.waveflow.data.remote.ServerApi +import app.waveflow.data.remote.ServerException import app.waveflow.data.remote.SessionStore +import app.waveflow.model.RemoteAlbum +import app.waveflow.model.RemoteAlbumDetail +import app.waveflow.model.RemoteArtist +import app.waveflow.model.RemoteArtistDetail import app.waveflow.model.ServerSession import kotlinx.coroutines.CompletableDeferred @@ -79,6 +85,175 @@ class FakeServerApi( } } +/** + * Catalogue simulé. + * + * Retient ce qu'on lui a passé, et sait refuser un nombre donné d'appels avant + * d'accepter — ce qu'il faut pour éprouver le rejeu après renouvellement. + */ +class FakeCatalogApi( + private val failuresBeforeSuccess: Int = 0, + private val failure: Throwable? = null, + private val albums: List = emptyList(), + private val artists: List = emptyList(), +) : CatalogApi { + + var calls = 0 + private set + var lastServerUrl: String? = null + private set + var lastAccessToken: String? = null + private set + var lastPage: Pair? = null + private set + + override suspend fun albums( + serverUrl: String, + accessToken: String, + offset: Int, + limit: Int, + ): List { + record(serverUrl, accessToken, offset to limit) + return albums + } + + override suspend fun artists( + serverUrl: String, + accessToken: String, + offset: Int, + limit: Int, + ): List { + record(serverUrl, accessToken, offset to limit) + return artists + } + + override suspend fun album( + serverUrl: String, + accessToken: String, + albumId: String, + ): RemoteAlbumDetail { + record(serverUrl, accessToken, null) + return RemoteAlbumDetail( + album = RemoteAlbum(albumId, "Album", null, null, null), + songs = emptyList(), + ) + } + + override suspend fun artist( + serverUrl: String, + accessToken: String, + artistId: String, + ): RemoteArtistDetail { + record(serverUrl, accessToken, null) + return RemoteArtistDetail( + artist = RemoteArtist(artistId, "Artiste", null), + albums = emptyList(), + ) + } + + private fun record(serverUrl: String, accessToken: String, page: Pair?) { + calls++ + lastServerUrl = serverUrl + lastAccessToken = accessToken + page?.let { lastPage = it } + + failure?.let { throw it } + if (calls <= failuresBeforeSuccess) { + throw ServerException.Unauthorized("jeton refusé") + } + } +} + +/** + * Catalogue simulé qui pagine pour de vrai. + * + * Découpe [albums] et [artists] selon l'offset et la limite reçus : une fixture + * qui rendrait toujours la même page ne prouverait rien de la pagination. + */ +class PagingCatalogApi( + private val albums: List = emptyList(), + private val artists: List = emptyList(), + /** Numéro d'appel à partir duquel les listes échouent, 0 pour jamais. */ + private var failFromCall: Int = 0, + private val detailFailure: Throwable? = null, + /** + * Si non nul, les listes attendent ce signal avant de rendre la main. + * + * Sans lui, un dispatcher non confiné termine chaque page avant que la + * suivante ne parte : aucune requête n'est jamais réellement en vol, et la + * garde contre le doublement ne peut pas être mise à l'épreuve. + */ + private val gate: CompletableDeferred? = null, +) : CatalogApi { + + var albumCalls = 0 + private set + var artistCalls = 0 + private set + + fun stopFailing() { + failFromCall = 0 + } + + override suspend fun albums( + serverUrl: String, + accessToken: String, + offset: Int, + limit: Int, + ): List { + albumCalls++ + gate?.await() + failIfDue(albumCalls) + return albums.page(offset, limit) + } + + override suspend fun artists( + serverUrl: String, + accessToken: String, + offset: Int, + limit: Int, + ): List { + artistCalls++ + failIfDue(artistCalls) + return artists.page(offset, limit) + } + + override suspend fun album( + serverUrl: String, + accessToken: String, + albumId: String, + ): RemoteAlbumDetail { + detailFailure?.let { throw it } + return RemoteAlbumDetail( + album = albums.firstOrNull { it.id == albumId } + ?: RemoteAlbum(albumId, "Album", null, null, null), + songs = emptyList(), + ) + } + + override suspend fun artist( + serverUrl: String, + accessToken: String, + artistId: String, + ): RemoteArtistDetail { + detailFailure?.let { throw it } + return RemoteArtistDetail( + artist = artists.firstOrNull { it.id == artistId } + ?: RemoteArtist(artistId, "Artiste", null), + albums = emptyList(), + ) + } + + private fun failIfDue(call: Int) { + if (failFromCall > 0 && call >= failFromCall) { + throw ServerException.Unreachable("coupure") + } + } + + private fun List.page(offset: Int, limit: Int): List = + drop(offset).take(limit) +} + /** Persistance en mémoire, qui retient ce qu'on lui a demandé d'écrire. */ class FakeSessionStore( private var stored: ServerSession = ServerSession.Disconnected, diff --git a/app/src/test/java/app/waveflow/ui/server/ServerScreenTest.kt b/app/src/test/java/app/waveflow/ui/server/ServerScreenTest.kt index 46c1f33..afef372 100644 --- a/app/src/test/java/app/waveflow/ui/server/ServerScreenTest.kt +++ b/app/src/test/java/app/waveflow/ui/server/ServerScreenTest.kt @@ -39,16 +39,23 @@ class ServerScreenTest { accessExpiresAtMs = 0L, ) + /** Formulaire de connexion : l'écran affiché tant qu'aucune session n'existe. */ private fun afficher(state: ServerUiState) { compose.setContent { - ServerScreen( + ServerSignInScreen( state = state, onConnect = { url, user, password -> connexions += Triple(url, user, password) }, - onDisconnect = { deconnexions++ }, ) } } + /** Écran de compte : atteint depuis la barre du haut, une fois connecté. */ + private fun afficherCompte() { + compose.setContent { + ServerAccountScreen(session = session, onDisconnect = { deconnexions++ }) + } + } + /** * Nombre de nœuds dont un texte contient [text]. * @@ -116,8 +123,8 @@ class ServerScreenTest { } @Test - fun `une session ouverte remplace le formulaire par le compte`() { - afficher(ServerUiState(session = session)) + fun `l'ecran de compte montre l'identifiant et l'adresse`() { + afficherCompte() compose.onNodeWithText("admin").assertIsDisplayed() compose.onNodeWithText("https://musique.test").assertIsDisplayed() @@ -129,7 +136,7 @@ class ServerScreenTest { @Test fun `aucun jeton n'est affiche a l'ecran`() { // Ils passent par l'état, ils ne doivent pas se retrouver lisibles. - afficher(ServerUiState(session = session)) + afficherCompte() assertEquals(0, compose.occurrencesDe("wfa_1")) assertEquals(0, compose.occurrencesDe("wfr_1")) @@ -137,7 +144,7 @@ class ServerScreenTest { @Test fun `le bouton de deconnexion previent l'appelant`() { - afficher(ServerUiState(session = session)) + afficherCompte() compose.onNodeWithText("Se déconnecter").performClick() diff --git a/app/src/test/java/app/waveflow/ui/server/catalog/CatalogViewModelTest.kt b/app/src/test/java/app/waveflow/ui/server/catalog/CatalogViewModelTest.kt new file mode 100644 index 0000000..643b854 --- /dev/null +++ b/app/src/test/java/app/waveflow/ui/server/catalog/CatalogViewModelTest.kt @@ -0,0 +1,223 @@ +package app.waveflow.ui.server.catalog + +import app.waveflow.data.remote.CATALOG_PAGE_SIZE +import app.waveflow.data.remote.CatalogRepository +import app.waveflow.data.remote.ServerException +import app.waveflow.model.RemoteAlbum +import app.waveflow.model.ServerSession +import app.waveflow.testing.FakeServerApi +import app.waveflow.testing.FakeSessionStore +import app.waveflow.testing.MainDispatcherRule +import app.waveflow.testing.PagingCatalogApi +import app.waveflow.data.remote.ServerSessionRepository +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * Le parcours paginé du catalogue. + * + * Robolectric parce que le ViewModel journalise les échecs inattendus par + * `android.util.Log`. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +class CatalogViewModelTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + private val connected = ServerSession.Connected( + serverUrl = "https://musique.test", + username = "admin", + accessToken = "wfa_1", + refreshToken = "wfr_1", + deviceId = "appareil-1", + accessExpiresAtMs = Long.MAX_VALUE, + ) + + private suspend fun viewModel( + catalog: PagingCatalogApi, + session: MutableStateFlow = MutableStateFlow(connected), + ): CatalogViewModel { + val sessions = ServerSessionRepository( + api = FakeServerApi(), + store = FakeSessionStore(stored = connected), + deviceName = "Pixel de test", + now = { 0L }, + ) + sessions.restore() + return CatalogViewModel(CatalogRepository(catalog, sessions), session) + } + + private fun albums(count: Int): List = + (1..count).map { RemoteAlbum("id-$it", "Album $it", "Aurore", "artiste-1", null) } + + @Test + fun `une session ouverte declenche la premiere page`() = + runTest(mainDispatcherRule.dispatcher) { + val catalog = PagingCatalogApi(albums = albums(3)) + val viewModel = viewModel(catalog) + advanceUntilIdle() + + val state = viewModel.albums.value + assertEquals(3, state.items.size) + assertFalse(state.isLoading) + // Une page plus courte que demandée : il n'y a rien après. + assertTrue(state.endReached) + } + + @Test + fun `une page pleine n'est pas prise pour la derniere`() = + runTest(mainDispatcherRule.dispatcher) { + val catalog = PagingCatalogApi(albums = albums(CATALOG_PAGE_SIZE)) + val viewModel = viewModel(catalog) + advanceUntilIdle() + + assertFalse(viewModel.albums.value.endReached) + } + + @Test + fun `la page suivante s'ajoute a la precedente sans la remplacer`() = + runTest(mainDispatcherRule.dispatcher) { + val catalog = PagingCatalogApi(albums = albums(CATALOG_PAGE_SIZE + 3)) + val viewModel = viewModel(catalog) + advanceUntilIdle() + + viewModel.loadMoreAlbums() + advanceUntilIdle() + + val state = viewModel.albums.value + assertEquals(CATALOG_PAGE_SIZE + 3, state.items.size) + assertEquals("Album 1", state.items.first().title) + assertTrue(state.endReached) + assertEquals("deux pages, deux appels", 2, catalog.albumCalls) + } + + @Test + fun `arrive au bout on cesse de redemander`() = + runTest(mainDispatcherRule.dispatcher) { + // Le défilement appelle en continu : sans cette garde, chaque + // recomposition relancerait une requête inutile. + val catalog = PagingCatalogApi(albums = albums(2)) + val viewModel = viewModel(catalog) + advanceUntilIdle() + + viewModel.loadMoreAlbums() + viewModel.loadMoreAlbums() + advanceUntilIdle() + + assertEquals(1, catalog.albumCalls) + } + + @Test + fun `une page en vol n'est pas doublee`() = + runTest(mainDispatcherRule.dispatcher) { + // Le portail maintient la première page en vol : sans lui, elle + // s'achèverait avant la seconde demande et la garde ne servirait + // jamais. Le défilement, lui, redemande sans attendre. + val portail = CompletableDeferred() + val catalog = PagingCatalogApi(albums = albums(CATALOG_PAGE_SIZE), gate = portail) + val viewModel = viewModel(catalog) + runCurrent() + + viewModel.loadMoreAlbums() + portail.complete(Unit) + advanceUntilIdle() + + assertEquals(1, catalog.albumCalls) + assertEquals(CATALOG_PAGE_SIZE, viewModel.albums.value.items.size) + } + + @Test + fun `un echec laisse un message sans effacer ce qui est deja la`() = + runTest(mainDispatcherRule.dispatcher) { + val catalog = PagingCatalogApi( + albums = albums(CATALOG_PAGE_SIZE), + failFromCall = 2, + ) + val viewModel = viewModel(catalog) + advanceUntilIdle() + + viewModel.loadMoreAlbums() + advanceUntilIdle() + + val state = viewModel.albums.value + assertEquals("la première page reste lisible", CATALOG_PAGE_SIZE, state.items.size) + assertEquals("Serveur injoignable.", state.errorMessage) + } + + @Test + fun `reessayer repart de la premiere page`() = + runTest(mainDispatcherRule.dispatcher) { + val catalog = PagingCatalogApi(albums = albums(3), failFromCall = 1) + val viewModel = viewModel(catalog) + advanceUntilIdle() + assertTrue(viewModel.albums.value.errorMessage != null) + + catalog.stopFailing() + viewModel.retryAlbums() + advanceUntilIdle() + + val state = viewModel.albums.value + assertEquals(3, state.items.size) + assertNull(state.errorMessage) + } + + @Test + fun `la deconnexion vide le catalogue`() = + runTest(mainDispatcherRule.dispatcher) { + // Les pages appartiennent à un compte : l'écran suivant pourrait + // être celui d'un autre. + val session = MutableStateFlow(connected) + val viewModel = viewModel(PagingCatalogApi(albums = albums(3)), session) + advanceUntilIdle() + assertEquals(3, viewModel.albums.value.items.size) + + session.value = ServerSession.Disconnected + advanceUntilIdle() + + assertEquals(0, viewModel.albums.value.items.size) + } + + @Test + fun `ouvrir un album expose son detail`() = + runTest(mainDispatcherRule.dispatcher) { + val viewModel = viewModel(PagingCatalogApi(albums = albums(1))) + advanceUntilIdle() + + viewModel.openAlbum("id-1") + advanceUntilIdle() + + assertEquals("id-1", viewModel.albumDetail.value.value?.album?.id) + assertNull(viewModel.albumDetail.value.errorMessage) + } + + @Test + fun `un detail qui echoue devient un message`() = + runTest(mainDispatcherRule.dispatcher) { + val catalog = PagingCatalogApi( + albums = albums(1), + detailFailure = ServerException.Unauthorized("périmé"), + ) + val viewModel = viewModel(catalog) + advanceUntilIdle() + + viewModel.openAlbum("id-1") + advanceUntilIdle() + + assertEquals("Session expirée. Reconnectez-vous.", viewModel.albumDetail.value.errorMessage) + assertNull(viewModel.albumDetail.value.value) + } +} From 4516eef4656acbac2350a44c6c6f6ee085586fdf Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Mon, 10 Aug 2026 17:59:25 +0200 Subject: [PATCH 2/3] fix(serveur): suites de la revue du catalogue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deux défauts de comportement, le reste tenant à la mise en page et à la lisibilité. Ouvrir un album depuis la page d'un artiste annulait le chargement de cet artiste : les deux détails partageaient un job. L'écran de l'artiste, toujours dans la pile, restait alors bloqué sur son indicateur au retour. Un job par sorte de détail. Les états du catalogue étaient collectés à la racine de WaveFlowRoot : chaque page reçue recomposait le Scaffold, le NavHost et tous les écrans. Ils sont désormais lus dans les seules destinations qui les affichent. Le reste : - l'identifiant d'un détail passe en segment de chemin plutôt qu'interpolé, donc encodé — il vient d'une réponse serveur ou d'un argument de navigation, et un `/` qui s'y glisserait désignerait un autre point d'API ; - les positions de défilement des onglets sont remontées dans l'écran : l'onglet masqué quitte la composition et repartait du haut à chaque retour ; - `MediaRow` accepte un clic nul. Un `Modifier.clickable` inerte annonce la ligne comme actionnable à TalkBack et promet une navigation qui n'arrive pas ; - `DetailContainer` applique le modificateur de l'appelant à la branche chargée, qui le perdait ; - la pagination des albums et des artistes, identique à l'appel près, passe par un helper unique ; - les segments de route serveur ne sont plus écrits deux fois ; - le KDoc de la session, séparé de sa déclaration par l'ajout du transport, la rejoint ; - l'écran de compte n'annonce plus le catalogue comme à venir, il est là. Claude-Session: https://claude.ai/code/session_01F89rkrDB9TxcwHbfgNoyY1 --- .../main/java/app/waveflow/MainActivity.kt | 18 +++-- app/src/main/java/app/waveflow/WaveFlowApp.kt | 12 +-- .../waveflow/data/remote/HttpCatalogApi.kt | 6 +- .../app/waveflow/data/remote/ServerHttp.kt | 23 +++++- .../app/waveflow/ui/components/MediaRow.kt | 8 +- .../ui/navigation/WaveFlowNavigation.kt | 11 ++- .../app/waveflow/ui/server/ServerScreen.kt | 4 +- .../ui/server/catalog/CatalogViewModel.kt | 75 +++++++++++-------- .../ui/server/catalog/RemoteDetailScreens.kt | 11 +-- .../ui/server/catalog/ServerCatalogScreen.kt | 14 +++- .../data/remote/HttpCatalogApiTest.kt | 15 ++++ .../java/app/waveflow/testing/ServerFakes.kt | 4 + .../ui/server/catalog/CatalogViewModelTest.kt | 23 ++++++ 13 files changed, 158 insertions(+), 66 deletions(-) diff --git a/app/src/main/java/app/waveflow/MainActivity.kt b/app/src/main/java/app/waveflow/MainActivity.kt index 7dceabc..8ae7961 100644 --- a/app/src/main/java/app/waveflow/MainActivity.kt +++ b/app/src/main/java/app/waveflow/MainActivity.kt @@ -123,10 +123,9 @@ private fun WaveFlowRoot() { val searchQuery by searchViewModel.query.collectAsStateWithLifecycle() val searchResults by searchViewModel.results.collectAsStateWithLifecycle() val serverState by serverViewModel.state.collectAsStateWithLifecycle() - val remoteAlbums by catalogViewModel.albums.collectAsStateWithLifecycle() - val remoteArtists by catalogViewModel.artists.collectAsStateWithLifecycle() - val remoteAlbumDetail by catalogViewModel.albumDetail.collectAsStateWithLifecycle() - val remoteArtistDetail by catalogViewModel.artistDetail.collectAsStateWithLifecycle() + // Les états du catalogue sont collectés dans leurs destinations, et non + // ici : chargés à la racine, chaque page reçue recomposerait le Scaffold, + // le NavHost et tous les écrans. val navController = rememberNavController() val backStackEntry by navController.currentBackStackEntryAsState() @@ -397,6 +396,11 @@ private fun WaveFlowRoot() { bottomPadding = listBottomPadding, ) } else { + val remoteAlbums by catalogViewModel.albums + .collectAsStateWithLifecycle() + val remoteArtists by catalogViewModel.artists + .collectAsStateWithLifecycle() + ServerCatalogScreen( albums = remoteAlbums, artists = remoteArtists, @@ -435,9 +439,10 @@ private fun WaveFlowRoot() { val albumId = entry.arguments?.getString(Routes.ARG_ALBUM_ID) ?: return@composable LaunchedEffect(albumId) { catalogViewModel.openAlbum(albumId) } + val detail by catalogViewModel.albumDetail.collectAsStateWithLifecycle() RemoteAlbumDetailScreen( - state = remoteAlbumDetail, + state = detail, onRetry = { catalogViewModel.openAlbum(albumId) }, bottomPadding = listBottomPadding, ) @@ -450,9 +455,10 @@ private fun WaveFlowRoot() { val artistId = entry.arguments?.getString(Routes.ARG_ARTIST_ID) ?: return@composable LaunchedEffect(artistId) { catalogViewModel.openArtist(artistId) } + val detail by catalogViewModel.artistDetail.collectAsStateWithLifecycle() RemoteArtistDetailScreen( - state = remoteArtistDetail, + state = detail, onAlbumClick = { navController.navigate(Routes.serverAlbumDetail(it.id)) }, diff --git a/app/src/main/java/app/waveflow/WaveFlowApp.kt b/app/src/main/java/app/waveflow/WaveFlowApp.kt index c49d9d7..20edd99 100644 --- a/app/src/main/java/app/waveflow/WaveFlowApp.kt +++ b/app/src/main/java/app/waveflow/WaveFlowApp.kt @@ -67,6 +67,12 @@ class AppContainer(app: Application) { */ fun createPlaybackController(): PlaybackController = Media3PlaybackController(appContext) + /** + * Un seul transport pour tous les appels serveur : un pool de connexions + * partagé, et surtout un seul endroit qui classe les erreurs. + */ + private val serverHttp = ServerHttp() + /** * Session serveur, indépendante de la bibliothèque locale : elle n'a besoin * ni de la permission audio ni du MediaStore. @@ -74,12 +80,6 @@ class AppContainer(app: Application) { * Le nom d'appareil est celui que le serveur affichera dans la liste des * sessions ; `Build.MODEL` est ce que l'utilisateur reconnaîtra. */ - /** - * Un seul transport pour tous les appels serveur : un pool de connexions - * partagé, et surtout un seul endroit qui classe les erreurs. - */ - private val serverHttp = ServerHttp() - val serverSessionRepository = ServerSessionRepository( api = HttpServerApi(serverHttp), store = DataStoreSessionStore(app), diff --git a/app/src/main/java/app/waveflow/data/remote/HttpCatalogApi.kt b/app/src/main/java/app/waveflow/data/remote/HttpCatalogApi.kt index b9b8a97..c743a2b 100644 --- a/app/src/main/java/app/waveflow/data/remote/HttpCatalogApi.kt +++ b/app/src/main/java/app/waveflow/data/remote/HttpCatalogApi.kt @@ -41,7 +41,8 @@ class HttpCatalogApi( albumId: String, ): RemoteAlbumDetail = http.get( serverUrl = serverUrl, - path = "$ALBUMS/$albumId", + path = ALBUMS, + pathSegment = albumId, accessToken = accessToken, ).decode().let { response -> RemoteAlbumDetail( @@ -58,7 +59,8 @@ class HttpCatalogApi( artistId: String, ): RemoteArtistDetail = http.get( serverUrl = serverUrl, - path = "$ARTISTS/$artistId", + path = ARTISTS, + pathSegment = artistId, accessToken = accessToken, ).decode().let { response -> RemoteArtistDetail( diff --git a/app/src/main/java/app/waveflow/data/remote/ServerHttp.kt b/app/src/main/java/app/waveflow/data/remote/ServerHttp.kt index 2943000..8a43d7b 100644 --- a/app/src/main/java/app/waveflow/data/remote/ServerHttp.kt +++ b/app/src/main/java/app/waveflow/data/remote/ServerHttp.kt @@ -47,21 +47,33 @@ class ServerHttp( post(body.toRequestBody(JSON_MEDIA_TYPE)) } + // `execute` prend son `method` en dernier pour la syntaxe de lambda finale ; + // les paramètres facultatifs qui le précèdent gardent leurs valeurs par + // défaut pour les appels qui n'en ont pas besoin. + + /** + * @param pathSegment ajouté tel quel après [path], et encodé. Un + * identifiant n'a pas à être interpolé dans le chemin : il viendrait + * d'une réponse serveur ou d'un argument de navigation, et un `/` qui s'y + * glisserait désignerait un autre point d'API. + */ suspend fun get( serverUrl: String, path: String, + pathSegment: String? = null, query: Map = emptyMap(), accessToken: String? = null, - ): String = execute(serverUrl, path, accessToken, query) { get() } + ): String = execute(serverUrl, path, accessToken, pathSegment, query) { get() } private suspend fun execute( serverUrl: String, path: String, accessToken: String?, + pathSegment: String? = null, query: Map = emptyMap(), method: Request.Builder.() -> Request.Builder, ): String { - val url = serverUrl.toApiUrl(path, query) + val url = serverUrl.toApiUrl(path, pathSegment, query) val request = Request.Builder() .url(url) @@ -102,7 +114,11 @@ class ServerHttp( * chemin déjà présent est conservé — le serveur peut vivre derrière un * proxy qui le préfixe. */ - private fun String.toApiUrl(path: String, query: Map): HttpUrl { + private fun String.toApiUrl( + path: String, + pathSegment: String?, + query: Map, + ): HttpUrl { val trimmed = trim().trimEnd('/') if (trimmed.isEmpty()) throw ServerException.Rejected("Adresse du serveur vide.") @@ -112,6 +128,7 @@ class ServerHttp( return base.newBuilder() .addPathSegments(path) + .apply { pathSegment?.let { addPathSegment(it) } } .apply { query.forEach { (name, value) -> addQueryParameter(name, value) } } .build() } diff --git a/app/src/main/java/app/waveflow/ui/components/MediaRow.kt b/app/src/main/java/app/waveflow/ui/components/MediaRow.kt index 69a691e..58cb216 100644 --- a/app/src/main/java/app/waveflow/ui/components/MediaRow.kt +++ b/app/src/main/java/app/waveflow/ui/components/MediaRow.kt @@ -27,21 +27,25 @@ import androidx.compose.ui.unit.dp * une destination. * * @param artworkShape ronde pour un artiste, arrondie pour un album. + * @param onClick `null` pour une ligne purement informative. Rendre le clic + * facultatif plutôt que d'en passer un vide : un `Modifier.clickable` inerte + * annonce quand même la ligne comme actionnable à TalkBack, et l'ondulation + * promet une navigation qui n'arrive pas. */ @Composable fun MediaRow( artworkUri: Uri?, title: String, subtitle: String, - onClick: () -> Unit, modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null, artworkShape: Shape = RoundedCornerShape(6.dp), ) { Row( verticalAlignment = Alignment.CenterVertically, modifier = modifier .fillMaxWidth() - .clickable(onClick = onClick) + .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier) .padding(horizontal = 16.dp, vertical = 8.dp), ) { Artwork( diff --git a/app/src/main/java/app/waveflow/ui/navigation/WaveFlowNavigation.kt b/app/src/main/java/app/waveflow/ui/navigation/WaveFlowNavigation.kt index c99c318..e15de7d 100644 --- a/app/src/main/java/app/waveflow/ui/navigation/WaveFlowNavigation.kt +++ b/app/src/main/java/app/waveflow/ui/navigation/WaveFlowNavigation.kt @@ -30,8 +30,11 @@ object Routes { const val PLAYLIST_DETAIL = "$PLAYLISTS/{$ARG_PLAYLIST_ID}" /** Détails distants, sous la section Serveur : leurs clés sont des UUID. */ - const val SERVER_ALBUM_DETAIL = "$SERVER/albums/{$ARG_ALBUM_ID}" - const val SERVER_ARTIST_DETAIL = "$SERVER/artists/{$ARG_ARTIST_ID}" + private const val SERVER_ALBUMS = "$SERVER/albums" + private const val SERVER_ARTISTS = "$SERVER/artists" + + const val SERVER_ALBUM_DETAIL = "$SERVER_ALBUMS/{$ARG_ALBUM_ID}" + const val SERVER_ARTIST_DETAIL = "$SERVER_ARTISTS/{$ARG_ARTIST_ID}" const val SERVER_ACCOUNT = "$SERVER/compte" fun albumDetail(albumId: Long): String = "$ALBUMS/$albumId" @@ -40,9 +43,9 @@ object Routes { fun playlistDetail(playlistId: Long): String = "$PLAYLISTS/$playlistId" - fun serverAlbumDetail(albumId: String): String = "$SERVER/albums/$albumId" + fun serverAlbumDetail(albumId: String): String = "$SERVER_ALBUMS/$albumId" - fun serverArtistDetail(artistId: String): String = "$SERVER/artists/$artistId" + fun serverArtistDetail(artistId: String): String = "$SERVER_ARTISTS/$artistId" } /** diff --git a/app/src/main/java/app/waveflow/ui/server/ServerScreen.kt b/app/src/main/java/app/waveflow/ui/server/ServerScreen.kt index a21b8c9..696664e 100644 --- a/app/src/main/java/app/waveflow/ui/server/ServerScreen.kt +++ b/app/src/main/java/app/waveflow/ui/server/ServerScreen.kt @@ -261,8 +261,8 @@ private fun ConnectedAccount( Spacer(Modifier.height(32.dp)) Text( - text = "Le catalogue du serveur arrive dans une prochaine version. " + - "Cette connexion ne fait encore rien d'autre.", + text = "Le catalogue de ce serveur est consultable depuis l'onglet Serveur. " + + "La lecture à distance arrive dans une prochaine version.", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) diff --git a/app/src/main/java/app/waveflow/ui/server/catalog/CatalogViewModel.kt b/app/src/main/java/app/waveflow/ui/server/catalog/CatalogViewModel.kt index 93c27a2..209d403 100644 --- a/app/src/main/java/app/waveflow/ui/server/catalog/CatalogViewModel.kt +++ b/app/src/main/java/app/waveflow/ui/server/catalog/CatalogViewModel.kt @@ -50,7 +50,16 @@ class CatalogViewModel( /** Une seule page en vol par liste : deux requêtes doubleraient le contenu. */ private var albumsJob: Job? = null private var artistsJob: Job? = null - private var detailJob: Job? = null + + /** + * Un job par sorte de détail. + * + * Un job commun ferait annuler le chargement d'un artiste par l'ouverture + * d'un album depuis sa page : l'écran de l'artiste, encore dans la pile, + * resterait alors bloqué sur son indicateur au retour. + */ + private var albumDetailJob: Job? = null + private var artistDetailJob: Job? = null init { session @@ -62,45 +71,44 @@ class CatalogViewModel( /** Charge la page suivante d'albums, si elle a lieu d'être. */ fun loadMoreAlbums() { - val current = _albums.value - if (albumsJob?.isActive == true || current.endReached) return - - albumsJob = viewModelScope.launch { - _albums.value = current.copy(isLoading = true, errorMessage = null) - try { - val page = catalogRepository.albums(offset = current.items.size) - _albums.value = PagedList( - items = current.items + page, - isLoading = false, - // Le serveur ne dit pas combien il en reste : une page plus - // courte que demandée est le seul signal de fin. - endReached = page.size < CATALOG_PAGE_SIZE, - ) - } catch (cancellation: CancellationException) { - throw cancellation - } catch (error: Exception) { - _albums.value = current.copy(isLoading = false, errorMessage = error.toMessage()) - } - } + albumsJob = loadNextPage(_albums, albumsJob) { catalogRepository.albums(offset = it) } } fun loadMoreArtists() { - val current = _artists.value - if (artistsJob?.isActive == true || current.endReached) return + artistsJob = loadNextPage(_artists, artistsJob) { catalogRepository.artists(offset = it) } + } - artistsJob = viewModelScope.launch { - _artists.value = current.copy(isLoading = true, errorMessage = null) + /** + * Ajoute une page à [state], ou ne fait rien s'il n'y a pas lieu. + * + * @param inFlight la page en cours pour cette liste, le cas échéant. Le + * défilement redemande sans attendre : sans cette garde, la même page + * serait chargée deux fois et affichée en double. + * @return le job à retenir — celui qui vient de partir, ou [inFlight]. + */ + private fun loadNextPage( + state: MutableStateFlow>, + inFlight: Job?, + fetch: suspend (offset: Int) -> List, + ): Job? { + val current = state.value + if (inFlight?.isActive == true || current.endReached) return inFlight + + return viewModelScope.launch { + state.value = current.copy(isLoading = true, errorMessage = null) try { - val page = catalogRepository.artists(offset = current.items.size) - _artists.value = PagedList( + val page = fetch(current.items.size) + state.value = PagedList( items = current.items + page, isLoading = false, + // Le serveur ne dit pas combien il en reste : une page plus + // courte que demandée est le seul signal de fin. endReached = page.size < CATALOG_PAGE_SIZE, ) } catch (cancellation: CancellationException) { throw cancellation } catch (error: Exception) { - _artists.value = current.copy(isLoading = false, errorMessage = error.toMessage()) + state.value = current.copy(isLoading = false, errorMessage = error.toMessage()) } } } @@ -119,9 +127,9 @@ class CatalogViewModel( } fun openAlbum(albumId: String) { - detailJob?.cancel() + albumDetailJob?.cancel() _albumDetail.value = AlbumDetailState(isLoading = true) - detailJob = viewModelScope.launch { + albumDetailJob = viewModelScope.launch { try { _albumDetail.value = AlbumDetailState(value = catalogRepository.album(albumId)) } catch (cancellation: CancellationException) { @@ -133,9 +141,9 @@ class CatalogViewModel( } fun openArtist(artistId: String) { - detailJob?.cancel() + artistDetailJob?.cancel() _artistDetail.value = ArtistDetailState(isLoading = true) - detailJob = viewModelScope.launch { + artistDetailJob = viewModelScope.launch { try { _artistDetail.value = ArtistDetailState(value = catalogRepository.artist(artistId)) } catch (cancellation: CancellationException) { @@ -154,7 +162,8 @@ class CatalogViewModel( private fun clear() { albumsJob?.cancel() artistsJob?.cancel() - detailJob?.cancel() + albumDetailJob?.cancel() + artistDetailJob?.cancel() _albums.value = PagedList() _artists.value = PagedList() _albumDetail.value = AlbumDetailState() diff --git a/app/src/main/java/app/waveflow/ui/server/catalog/RemoteDetailScreens.kt b/app/src/main/java/app/waveflow/ui/server/catalog/RemoteDetailScreens.kt index 67b5c7e..587d709 100644 --- a/app/src/main/java/app/waveflow/ui/server/catalog/RemoteDetailScreens.kt +++ b/app/src/main/java/app/waveflow/ui/server/catalog/RemoteDetailScreens.kt @@ -106,7 +106,9 @@ private fun DetailContainer( ) { val value = state.value when { - value != null -> content(value) + // Le modificateur de l'appelant porte la mise en page attendue par + // l'écran : le perdre ici la laisserait à la LazyColumn par défaut. + value != null -> Box(modifier = modifier) { content(value) } state.isLoading -> Box(modifier = modifier.fillMaxSize()) { CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) @@ -139,7 +141,6 @@ private fun RemoteDetailHeader( artworkUri = null, title = title, subtitle = listOf(subtitle, summary).filter { it.isNotBlank() }.joinToString(" · "), - onClick = {}, artworkShape = CircleShape, modifier = Modifier.fillMaxWidth(), ) @@ -154,8 +155,8 @@ private fun RemoteSongRow(song: RemoteSong) { song.artist?.takeIf { it.isNotBlank() }, formatDuration(song.durationMs), ).joinToString(" · "), - // La lecture distante arrive à l'étape suivante : une ligne qui ne - // réagit pas vaut mieux qu'une qui promet et ne fait rien. - onClick = {}, + // Sans `onClick` : la lecture distante arrive à l'étape suivante, et une + // ligne annoncée cliquable qui ne fait rien vaut moins qu'une ligne + // simplement informative. ) } diff --git a/app/src/main/java/app/waveflow/ui/server/catalog/ServerCatalogScreen.kt b/app/src/main/java/app/waveflow/ui/server/catalog/ServerCatalogScreen.kt index a26f037..4c89414 100644 --- a/app/src/main/java/app/waveflow/ui/server/catalog/ServerCatalogScreen.kt +++ b/app/src/main/java/app/waveflow/ui/server/catalog/ServerCatalogScreen.kt @@ -5,7 +5,7 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Tab import androidx.compose.material3.PrimaryTabRow @@ -51,6 +51,12 @@ fun ServerCatalogScreen( ) { var tab by rememberSaveable { mutableStateOf(CatalogTab.Albums) } + // Remontés ici : l'onglet masqué quitte la composition, et un état déclaré + // à l'intérieur repartirait donc du haut à chaque retour. `rememberSaveable` + // les fait aussi survivre à une rotation. + val albumsListState = rememberSaveable(saver = LazyListState.Saver) { LazyListState() } + val artistsListState = rememberSaveable(saver = LazyListState.Saver) { LazyListState() } + Column(modifier = modifier.fillMaxSize()) { PrimaryTabRow(selectedTabIndex = tab.ordinal) { CatalogTab.entries.forEach { entry -> @@ -65,6 +71,7 @@ fun ServerCatalogScreen( when (tab) { CatalogTab.Albums -> AlbumsTab( state = albums, + listState = albumsListState, onAlbumClick = onAlbumClick, onLoadMore = onLoadMoreAlbums, onRetry = onRetryAlbums, @@ -73,6 +80,7 @@ fun ServerCatalogScreen( CatalogTab.Artists -> ArtistsTab( state = artists, + listState = artistsListState, onArtistClick = onArtistClick, onLoadMore = onLoadMoreArtists, onRetry = onRetryArtists, @@ -85,12 +93,12 @@ fun ServerCatalogScreen( @Composable private fun AlbumsTab( state: PagedList, + listState: LazyListState, onAlbumClick: (RemoteAlbum) -> Unit, onLoadMore: () -> Unit, onRetry: () -> Unit, bottomPadding: Dp, ) { - val listState = rememberLazyListState() LoadMoreOnApproachingEnd(listState, state.items.size, onLoadMore) PagedListContainer( @@ -119,12 +127,12 @@ private fun AlbumsTab( @Composable private fun ArtistsTab( state: PagedList, + listState: LazyListState, onArtistClick: (RemoteArtist) -> Unit, onLoadMore: () -> Unit, onRetry: () -> Unit, bottomPadding: Dp, ) { - val listState = rememberLazyListState() LoadMoreOnApproachingEnd(listState, state.items.size, onLoadMore) PagedListContainer( diff --git a/app/src/test/java/app/waveflow/data/remote/HttpCatalogApiTest.kt b/app/src/test/java/app/waveflow/data/remote/HttpCatalogApiTest.kt index 8f44566..0725186 100644 --- a/app/src/test/java/app/waveflow/data/remote/HttpCatalogApiTest.kt +++ b/app/src/test/java/app/waveflow/data/remote/HttpCatalogApiTest.kt @@ -107,6 +107,21 @@ class HttpCatalogApiTest { assertEquals(listOf("Nuit Blanche", "Second Souffle"), detail.albums.map { it.title }) } + @Test + fun `un identifiant est encode et ne peut pas designer un autre point d'API`() = runTest { + // L'identifiant vient d'une réponse serveur ou d'un argument de + // navigation : interpolé dans le chemin, un `/` qui s'y glisserait + // pointerait ailleurs. + server.enqueue(MockResponse().setBody(ALBUM_DETAIL_BODY)) + + api.album(url(), "wfa_1", "../artists/autre") + + assertEquals( + "/api/v2/albums/..%2Fartists%2Fautre", + server.takeRequest().path, + ) + } + @Test fun `un jeton refuse remonte comme tel`() = runTest { server.enqueue( diff --git a/app/src/test/java/app/waveflow/testing/ServerFakes.kt b/app/src/test/java/app/waveflow/testing/ServerFakes.kt index 1c3a8c1..c2258e5 100644 --- a/app/src/test/java/app/waveflow/testing/ServerFakes.kt +++ b/app/src/test/java/app/waveflow/testing/ServerFakes.kt @@ -184,6 +184,8 @@ class PagingCatalogApi( * garde contre le doublement ne peut pas être mise à l'épreuve. */ private val gate: CompletableDeferred? = null, + /** Même rôle que [gate], pour les détails. */ + private val detailGate: CompletableDeferred? = null, ) : CatalogApi { var albumCalls = 0 @@ -223,6 +225,7 @@ class PagingCatalogApi( accessToken: String, albumId: String, ): RemoteAlbumDetail { + detailGate?.await() detailFailure?.let { throw it } return RemoteAlbumDetail( album = albums.firstOrNull { it.id == albumId } @@ -236,6 +239,7 @@ class PagingCatalogApi( accessToken: String, artistId: String, ): RemoteArtistDetail { + detailGate?.await() detailFailure?.let { throw it } return RemoteArtistDetail( artist = artists.firstOrNull { it.id == artistId } diff --git a/app/src/test/java/app/waveflow/ui/server/catalog/CatalogViewModelTest.kt b/app/src/test/java/app/waveflow/ui/server/catalog/CatalogViewModelTest.kt index 643b854..9dde7e5 100644 --- a/app/src/test/java/app/waveflow/ui/server/catalog/CatalogViewModelTest.kt +++ b/app/src/test/java/app/waveflow/ui/server/catalog/CatalogViewModelTest.kt @@ -204,6 +204,29 @@ class CatalogViewModelTest { assertNull(viewModel.albumDetail.value.errorMessage) } + @Test + fun `ouvrir un album n'annule pas le chargement de l'artiste`() = + runTest(mainDispatcherRule.dispatcher) { + // Parcours réel : depuis la page d'un artiste, on ouvre un de ses + // albums avant que l'artiste ait fini de charger. Avec un job + // commun, l'écran de l'artiste — toujours dans la pile — resterait + // bloqué sur son indicateur au retour. + val portail = CompletableDeferred() + val viewModel = viewModel( + PagingCatalogApi(albums = albums(1), detailGate = portail), + ) + advanceUntilIdle() + + viewModel.openArtist("id-1") + viewModel.openAlbum("id-1") + portail.complete(Unit) + advanceUntilIdle() + + assertFalse(viewModel.artistDetail.value.isLoading) + assertEquals("id-1", viewModel.artistDetail.value.value?.artist?.id) + assertEquals("id-1", viewModel.albumDetail.value.value?.album?.id) + } + @Test fun `un detail qui echoue devient un message`() = runTest(mainDispatcherRule.dispatcher) { From c63ddfde6fe5ee9230ab71ba17c0de13616d7f4d Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Mon, 10 Aug 2026 19:30:12 +0200 Subject: [PATCH 3/3] fix(serveur): encoder les identifiants distants dans les routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Un identifiant distant est une chaîne, contrairement aux entiers des routes locales. Un `/` qui s'y glisserait scinderait la route, qui ne correspondrait alors à aucune destination. La navigation décode d'elle-même à la lecture de l'argument, l'aller-retour est donc symétrique. Aligne aussi `PagingCatalogApi.artists` sur `albums` : le portail retenait les albums et pas les artistes, ce qui rendait son contrat faux pour un futur test de concurrence sur les artistes. Claude-Session: https://claude.ai/code/session_01F89rkrDB9TxcwHbfgNoyY1 --- .../app/waveflow/ui/navigation/WaveFlowNavigation.kt | 11 +++++++++-- app/src/test/java/app/waveflow/testing/ServerFakes.kt | 1 + 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/app/waveflow/ui/navigation/WaveFlowNavigation.kt b/app/src/main/java/app/waveflow/ui/navigation/WaveFlowNavigation.kt index e15de7d..cf1994e 100644 --- a/app/src/main/java/app/waveflow/ui/navigation/WaveFlowNavigation.kt +++ b/app/src/main/java/app/waveflow/ui/navigation/WaveFlowNavigation.kt @@ -1,5 +1,6 @@ package app.waveflow.ui.navigation +import android.net.Uri import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.QueueMusic import androidx.compose.material.icons.filled.Album @@ -43,9 +44,15 @@ object Routes { fun playlistDetail(playlistId: Long): String = "$PLAYLISTS/$playlistId" - fun serverAlbumDetail(albumId: String): String = "$SERVER_ALBUMS/$albumId" + /** + * Les identifiants distants sont encodés, contrairement aux locaux qui sont + * des entiers : un `/` dans un identifiant scinderait la route, qui ne + * correspondrait alors à aucune destination. La navigation les décode + * d'elle-même à la lecture de l'argument. + */ + fun serverAlbumDetail(albumId: String): String = "$SERVER_ALBUMS/${Uri.encode(albumId)}" - fun serverArtistDetail(artistId: String): String = "$SERVER_ARTISTS/$artistId" + fun serverArtistDetail(artistId: String): String = "$SERVER_ARTISTS/${Uri.encode(artistId)}" } /** diff --git a/app/src/test/java/app/waveflow/testing/ServerFakes.kt b/app/src/test/java/app/waveflow/testing/ServerFakes.kt index c2258e5..592d545 100644 --- a/app/src/test/java/app/waveflow/testing/ServerFakes.kt +++ b/app/src/test/java/app/waveflow/testing/ServerFakes.kt @@ -216,6 +216,7 @@ class PagingCatalogApi( limit: Int, ): List { artistCalls++ + gate?.await() failIfDue(artistCalls) return artists.page(offset, limit) }