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..8ae7961 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,9 @@ private fun WaveFlowRoot() { val searchQuery by searchViewModel.query.collectAsStateWithLifecycle() val searchResults by searchViewModel.results.collectAsStateWithLifecycle() val serverState by serverViewModel.state.collectAsStateWithLifecycle() + // Les états du catalogue sont collectés dans leurs destinations, et non + // ici : chargés à la racine, chaque page reçue recomposerait le Scaffold, + // le NavHost et tous les écrans. val navController = rememberNavController() val backStackEntry by navController.currentBackStackEntryAsState() @@ -222,6 +235,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 +386,83 @@ 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 { + val remoteAlbums by catalogViewModel.albums + .collectAsStateWithLifecycle() + val remoteArtists by catalogViewModel.artists + .collectAsStateWithLifecycle() + + 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) } + val detail by catalogViewModel.albumDetail.collectAsStateWithLifecycle() + + RemoteAlbumDetailScreen( + state = detail, + 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) } + val detail by catalogViewModel.artistDetail.collectAsStateWithLifecycle() + + RemoteArtistDetailScreen( + state = detail, + onAlbumClick = { + navController.navigate(Routes.serverAlbumDetail(it.id)) + }, + onRetry = { catalogViewModel.openArtist(artistId) }, bottomPadding = listBottomPadding, ) } @@ -509,6 +608,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..20edd99 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 @@ -64,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. @@ -72,11 +81,16 @@ class AppContainer(app: Application) { * sessions ; `Build.MODEL` est ce que l'utilisateur reconnaîtra. */ 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..c743a2b --- /dev/null +++ b/app/src/main/java/app/waveflow/data/remote/HttpCatalogApi.kt @@ -0,0 +1,93 @@ +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, + pathSegment = 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, + pathSegment = 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..8a43d7b --- /dev/null +++ b/app/src/main/java/app/waveflow/data/remote/ServerHttp.kt @@ -0,0 +1,200 @@ +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)) + } + + // `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, 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, pathSegment, 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, + pathSegment: 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 { pathSegment?.let { addPathSegment(it) } } + .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/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 da3d036..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 @@ -29,11 +30,29 @@ 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. */ + 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" fun artistDetail(artistId: Long): String = "$ARTISTS/$artistId" fun playlistDetail(playlistId: Long): String = "$PLAYLISTS/$playlistId" + + /** + * 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/${Uri.encode(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..696664e 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 @@ -238,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/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..209d403 --- /dev/null +++ b/app/src/main/java/app/waveflow/ui/server/catalog/CatalogViewModel.kt @@ -0,0 +1,199 @@ +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 + + /** + * 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 + .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() { + albumsJob = loadNextPage(_albums, albumsJob) { catalogRepository.albums(offset = it) } + } + + fun loadMoreArtists() { + artistsJob = loadNextPage(_artists, artistsJob) { catalogRepository.artists(offset = it) } + } + + /** + * 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 = 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) { + state.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) { + albumDetailJob?.cancel() + _albumDetail.value = AlbumDetailState(isLoading = true) + albumDetailJob = 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) { + artistDetailJob?.cancel() + _artistDetail.value = ArtistDetailState(isLoading = true) + artistDetailJob = 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() + albumDetailJob?.cancel() + artistDetailJob?.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..587d709 --- /dev/null +++ b/app/src/main/java/app/waveflow/ui/server/catalog/RemoteDetailScreens.kt @@ -0,0 +1,162 @@ +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 { + // 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)) + } + + 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(" · "), + 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(" · "), + // 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 new file mode 100644 index 0000000..4c89414 --- /dev/null +++ b/app/src/main/java/app/waveflow/ui/server/catalog/ServerCatalogScreen.kt @@ -0,0 +1,163 @@ +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.LazyListState +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) } + + // 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 -> + Tab( + selected = tab == entry, + onClick = { tab = entry }, + text = { Text(entry.label) }, + ) + } + } + + when (tab) { + CatalogTab.Albums -> AlbumsTab( + state = albums, + listState = albumsListState, + onAlbumClick = onAlbumClick, + onLoadMore = onLoadMoreAlbums, + onRetry = onRetryAlbums, + bottomPadding = bottomPadding, + ) + + CatalogTab.Artists -> ArtistsTab( + state = artists, + listState = artistsListState, + onArtistClick = onArtistClick, + onLoadMore = onLoadMoreArtists, + onRetry = onRetryArtists, + bottomPadding = bottomPadding, + ) + } + } +} + +@Composable +private fun AlbumsTab( + state: PagedList, + listState: LazyListState, + onAlbumClick: (RemoteAlbum) -> Unit, + onLoadMore: () -> Unit, + onRetry: () -> Unit, + bottomPadding: Dp, +) { + 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, + listState: LazyListState, + onArtistClick: (RemoteArtist) -> Unit, + onLoadMore: () -> Unit, + onRetry: () -> Unit, + bottomPadding: Dp, +) { + 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..0725186 --- /dev/null +++ b/app/src/test/java/app/waveflow/data/remote/HttpCatalogApiTest.kt @@ -0,0 +1,275 @@ +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 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( + 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..592d545 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,180 @@ 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, + /** Même rôle que [gate], pour les détails. */ + private val detailGate: 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++ + gate?.await() + failIfDue(artistCalls) + return artists.page(offset, limit) + } + + override suspend fun album( + serverUrl: String, + accessToken: String, + albumId: String, + ): RemoteAlbumDetail { + detailGate?.await() + 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 { + detailGate?.await() + 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..9dde7e5 --- /dev/null +++ b/app/src/test/java/app/waveflow/ui/server/catalog/CatalogViewModelTest.kt @@ -0,0 +1,246 @@ +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 `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) { + 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) + } +}