diff --git a/README.md b/README.md index c8f1806..010f10e 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,9 @@ Native Android client for [WaveFlow](https://github.com/InstaZDLL/WaveFlow) — a local-first music player. Kotlin + Jetpack Compose + Media3. -> **Status:** local-only. Plays, browses, searches and organises the device's -> own files. Nothing talks to a WaveFlow server yet — see -> [Server sync](#server-sync) for what that will take. +> **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. ## Stack @@ -18,6 +18,7 @@ local-first music player. Kotlin + Jetpack Compose + Media3. - **Local store:** Room — playlists only; tracks are never duplicated out of `MediaStore` (schemas versioned under `app/schemas/`) - **Images:** Coil +- **Server:** OkHttp + kotlinx.serialization; session tokens in DataStore - **DI:** manual container for now (`AppContainer`); Hilt later - **Min SDK:** 26 (Android 8.0) · **Target SDK:** 36 · **Compile SDK:** 37 @@ -40,7 +41,8 @@ app/src/main/java/app/waveflow/ │ ├─ MediaStoreMusicRepository.kt MediaStore query + ContentObserver │ ├─ PlaylistRepository.kt Local playlist abstraction │ ├─ RoomPlaylistRepository.kt Room-backed implementation -│ └─ local/ Room entities, DAO, database +│ ├─ local/ Room entities, DAO, database +│ └─ remote/ WaveFlow server: auth API, session, tokens ├─ playback/ │ ├─ PlaybackService.kt Media3 MediaSessionService (ExoPlayer) │ ├─ PlaybackController.kt Playback facade + PlaybackState @@ -71,6 +73,10 @@ app/src/main/java/app/waveflow/ │ ├─ SearchViewModel.kt Query → filtered library │ ├─ SearchScreen.kt Songs / albums / artists sections │ └─ SearchField.kt Query input + ├─ server/ + │ ├─ ServerViewModel.kt Sign in / out, error mapping + │ ├─ ServerUiState.kt Session + progress + last failure + │ └─ ServerScreen.kt Sign-in form, then the account ├─ permission/ │ └─ AudioPermissionGate.kt Grant / deny / permanently-denied flow ├─ player/ @@ -124,6 +130,10 @@ in-memory SQLite for Room, so the DAO is exercised without a device. | `SearchViewModelTest` | query → results, clearing, following the library | | `GroupingTest` | album / artist derivation, sorting, missing tags | | `DurationFormatTest` | `m:ss` / `h:mm:ss` formatting | +| `HttpServerApiTest` | request shapes, error classes, URL handling, unknown fields | +| `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 | Fakes and the `Dispatchers.Main` rule live in `src/test/java/app/waveflow/testing/`. @@ -141,22 +151,46 @@ into `DragState` and tested there instead. - [x] Drag-to-reorder inside a playlist - [x] Search across songs, albums and artists - [x] Compose UI tests (Robolectric, no device) -- [ ] WaveFlow server as a remote source: browse and stream its catalogue +- [x] Sign in to a WaveFlow server (session, refresh, sign-out) +- [ ] Browse the server catalogue +- [ ] Stream from the server - [ ] Server user-data sync (playlists, favorites, ratings) — see below - [ ] Android Auto (Media3 `MediaLibraryService`) -### Server sync - -[WaveFlow Server](https://github.com/InstaZDLL/waveflow-server) ships the sync -protocol today — `/api/v2/sync/snapshot`, `/changes`, `/ack` and a wake-up -socket, specified in its `RFC-003`. Bearer tokens for native clients and -authorized streaming (`/api/v2/tracks/{id}/stream`) are already there too, so -consuming the server as a *remote source* needs no server-side work. - -Syncing the **local** library is a different matter, and it is not blocked on -this app: the protocol carries server track UUIDs, and RFC-003 states it "never -guesses a local/server track match" — reconciliation is a later milestone with -its own RFC. Until that exists, local playlists stay local. +## 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. + +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 +lasts fifteen minutes and is renewed through `/api/v2/auth/refresh`; the +refresh token **rotates** on every use, which is why all token work is +serialised behind one mutex — two concurrent renewals would start from the same +token and one would be rejected, dropping a session that was perfectly valid. + +Tokens live in a DataStore, protected by the app sandbox rather than by +encryption: `security-crypto` never left alpha and is no longer maintained. A +rooted or unlocked device therefore exposes the refresh token — the mitigation +is that it can be revoked from the server. The file is excluded from cloud +backup and device transfer, so a stored token is not copied onto another +device; signing in there simply asks for the password again. + +Cleartext HTTP is permitted, because a self-hosted server usually sits on a LAN +without a certificate. An address typed without a scheme is joined over HTTPS. + +### Sync + +The sync protocol — `/api/v2/sync/snapshot`, `/changes`, `/ack` and a wake-up +socket — already exists server-side, specified in its `RFC-003`. Syncing the +**local** library is nonetheless out of reach, and not because of this app: the +protocol carries server track UUIDs, and RFC-003 states it "never guesses a +local/server track match". Reconciliation is a later milestone with its own +RFC. Until that exists, local playlists stay local. Whenever it does land, `playlist_songs` will need a Room migration: it keys on `MediaStore` ids, which do not survive a device re-index, let alone identify a diff --git a/app/build.gradle.kts b/app/build.gradle.kts index cdd8f3f..ba563af 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,6 +1,7 @@ plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.compose) + alias(libs.plugins.kotlin.serialization) alias(libs.plugins.ksp) } @@ -77,7 +78,13 @@ dependencies { implementation(libs.androidx.room.runtime) ksp(libs.androidx.room.compiler) + // Serveur WaveFlow — client HTTP et jetons de session + implementation(libs.okhttp) + implementation(libs.kotlinx.serialization.json) + implementation(libs.androidx.datastore.preferences) + testImplementation(libs.junit) + testImplementation(libs.okhttp.mockwebserver) testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.robolectric) testImplementation(libs.androidx.test.core) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 9f2d3c2..4b4a166 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -15,11 +15,15 @@ + + + - AudioPermissionGate(modifier = Modifier.padding(innerPadding)) { - // Ne démarre le scan et la connexion au service qu'une fois la - // permission acquise. - LaunchedEffect(Unit) { - libraryViewModel.onAudioAccessGranted() - playerViewModel.connect() + /** + * Porte de permission audio, posée autour du seul contenu qui lit + * la bibliothèque de l'appareil. + * + * Elle enveloppe chaque écran concerné plutôt que le `NavHost` : + * l'onglet Serveur n'a besoin d'aucune permission, et sortir le + * `NavHost` de la porte le recomposerait dans un autre sous-arbre, + * lui faisant perdre sa pile de navigation. + * + * Sans modificateur : les marges du `Scaffold` sont déjà posées par + * le `Box` qui l'englobe, les redonner ici les doublerait sur le + * message de refus. + */ + val gated: @Composable (@Composable () -> Unit) -> Unit = { content -> + AudioPermissionGate { + // Ne démarre le scan et la connexion au service qu'une fois + // la permission acquise. Les deux appels sont idempotents, + // ce qui permet de les relancer à chaque écran gardé. + LaunchedEffect(Unit) { + libraryViewModel.onAudioAccessGranted() + playerViewModel.connect() + } + content() } + } - Box( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding), + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + ) { + NavHost( + navController = navController, + startDestination = Routes.SONGS, + modifier = Modifier.fillMaxSize(), ) { - NavHost( - navController = navController, - startDestination = Routes.SONGS, - modifier = Modifier.fillMaxSize(), - ) { - composable(Routes.SONGS) { + composable(Routes.SONGS) { + gated { LibraryScreen( library = library, nowPlayingId = nowPlayingId, @@ -267,8 +290,10 @@ private fun WaveFlowRoot() { onSongLongClick = { songToAdd = it }, ) } + } - composable(Routes.ALBUMS) { + composable(Routes.ALBUMS) { + gated { AlbumsScreen( library = library, onAlbumClick = { navController.navigate(Routes.albumDetail(it.id)) }, @@ -276,8 +301,10 @@ private fun WaveFlowRoot() { bottomPadding = listBottomPadding, ) } + } - composable(Routes.ARTISTS) { + composable(Routes.ARTISTS) { + gated { ArtistsScreen( library = library, onArtistClick = { navController.navigate(Routes.artistDetail(it.id)) }, @@ -285,17 +312,19 @@ private fun WaveFlowRoot() { bottomPadding = listBottomPadding, ) } + } - composable( - route = Routes.ALBUM_DETAIL, - arguments = listOf(navArgument(Routes.ARG_ALBUM_ID) { type = NavType.LongType }), - ) { entry -> - val albumId = entry.arguments?.getLong(Routes.ARG_ALBUM_ID) ?: return@composable - // Le suivi de position recompose la racine toutes les - // 500 ms : sans mémorisation, on refiltrerait toute la - // bibliothèque à chaque fois. - val songs = remember(library, albumId) { library.songsOfAlbum(albumId) } - + composable( + route = Routes.ALBUM_DETAIL, + arguments = listOf(navArgument(Routes.ARG_ALBUM_ID) { type = NavType.LongType }), + ) { entry -> + val albumId = entry.arguments?.getLong(Routes.ARG_ALBUM_ID) ?: return@composable + // Le suivi de position recompose la racine toutes les + // 500 ms : sans mémorisation, on refiltrerait toute la + // bibliothèque à chaque fois. + val songs = remember(library, albumId) { library.songsOfAlbum(albumId) } + + gated { AlbumDetailScreen( album = library.album(albumId), songs = songs, @@ -307,14 +336,16 @@ private fun WaveFlowRoot() { onSongLongClick = { songToAdd = it }, ) } + } - composable( - route = Routes.ARTIST_DETAIL, - arguments = listOf(navArgument(Routes.ARG_ARTIST_ID) { type = NavType.LongType }), - ) { entry -> - val artistId = entry.arguments?.getLong(Routes.ARG_ARTIST_ID) ?: return@composable - val songs = remember(library, artistId) { library.songsOfArtist(artistId) } + composable( + route = Routes.ARTIST_DETAIL, + arguments = listOf(navArgument(Routes.ARG_ARTIST_ID) { type = NavType.LongType }), + ) { entry -> + val artistId = entry.arguments?.getLong(Routes.ARG_ARTIST_ID) ?: return@composable + val songs = remember(library, artistId) { library.songsOfArtist(artistId) } + gated { ArtistDetailScreen( artist = library.artist(artistId), songs = songs, @@ -326,8 +357,19 @@ private fun WaveFlowRoot() { onSongLongClick = { songToAdd = it }, ) } + } - composable(Routes.PLAYLISTS) { + composable(Routes.SERVER) { + ServerScreen( + state = serverState, + onConnect = serverViewModel::connect, + onDisconnect = serverViewModel::disconnect, + bottomPadding = listBottomPadding, + ) + } + + composable(Routes.PLAYLISTS) { + gated { PlaylistsScreen( state = playlistsState, onPlaylistClick = { navController.navigate(Routes.playlistDetail(it.id)) }, @@ -335,14 +377,16 @@ private fun WaveFlowRoot() { bottomPadding = listBottomPadding, ) } + } - composable( - route = Routes.PLAYLIST_DETAIL, - arguments = listOf(navArgument(Routes.ARG_PLAYLIST_ID) { type = NavType.LongType }), - ) { entry -> - val playlistId = entry.arguments?.getLong(Routes.ARG_PLAYLIST_ID) ?: return@composable - val songs = playlistsState.songs(playlistId) + composable( + route = Routes.PLAYLIST_DETAIL, + arguments = listOf(navArgument(Routes.ARG_PLAYLIST_ID) { type = NavType.LongType }), + ) { entry -> + val playlistId = entry.arguments?.getLong(Routes.ARG_PLAYLIST_ID) ?: return@composable + val songs = playlistsState.songs(playlistId) + gated { PlaylistDetailScreen( playlist = playlistsState.playlist(playlistId), songs = songs, @@ -358,49 +402,49 @@ private fun WaveFlowRoot() { ) } } + } - // Posée par-dessus le NavHost plutôt qu'à sa place : la - // pile de navigation reste intacte, et fermer la recherche - // rend l'écran exactement tel qu'il était. - if (searchActive) { - Surface( - color = MaterialTheme.colorScheme.background, - modifier = Modifier.fillMaxSize(), - ) { - SearchScreen( - query = searchQuery, - results = searchResults, - nowPlayingId = nowPlayingId, - // La file de lecture est la liste affichée : - // enchaîner sur les résultats suivants est le - // comportement attendu. - onSongClick = { playerViewModel.playFrom(searchResults.songs, it) }, - onAlbumClick = { - closeSearch() - navController.navigate(Routes.albumDetail(it.id)) - }, - onArtistClick = { - closeSearch() - navController.navigate(Routes.artistDetail(it.id)) - }, - bottomPadding = listBottomPadding, - onSongLongClick = { songToAdd = it }, - ) - } - } - - // Inutile de le composer sous le lecteur plein écran, qui - // le recouvre entièrement. - if (!playerExpanded) { - MiniPlayer( - state = playerState, - onExpand = { playerExpanded = true }, - onTogglePlayPause = playerViewModel::togglePlayPause, - onSkipNext = playerViewModel::skipNext, - modifier = Modifier.align(Alignment.BottomCenter), + // Posée par-dessus le NavHost plutôt qu'à sa place : la + // pile de navigation reste intacte, et fermer la recherche + // rend l'écran exactement tel qu'il était. + if (searchActive) { + Surface( + color = MaterialTheme.colorScheme.background, + modifier = Modifier.fillMaxSize(), + ) { + SearchScreen( + query = searchQuery, + results = searchResults, + nowPlayingId = nowPlayingId, + // La file de lecture est la liste affichée : + // enchaîner sur les résultats suivants est le + // comportement attendu. + onSongClick = { playerViewModel.playFrom(searchResults.songs, it) }, + onAlbumClick = { + closeSearch() + navController.navigate(Routes.albumDetail(it.id)) + }, + onArtistClick = { + closeSearch() + navController.navigate(Routes.artistDetail(it.id)) + }, + bottomPadding = listBottomPadding, + onSongLongClick = { songToAdd = it }, ) } } + + // Inutile de le composer sous le lecteur plein écran, qui + // le recouvre entièrement. + if (!playerExpanded) { + MiniPlayer( + state = playerState, + onExpand = { playerExpanded = true }, + onTogglePlayPause = playerViewModel::togglePlayPause, + onSkipNext = playerViewModel::skipNext, + modifier = Modifier.align(Alignment.BottomCenter), + ) + } } } @@ -464,6 +508,7 @@ private fun currentScreenTitle( Routes.ALBUMS -> "Albums" Routes.ARTISTS -> "Artistes" Routes.PLAYLISTS -> "Playlists" + Routes.SERVER -> "Serveur" 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 37126ab..30a04f7 100644 --- a/app/src/main/java/app/waveflow/WaveFlowApp.kt +++ b/app/src/main/java/app/waveflow/WaveFlowApp.kt @@ -1,17 +1,22 @@ package app.waveflow import android.app.Application +import android.os.Build import app.waveflow.data.LibraryStore import app.waveflow.data.MediaStoreMusicRepository 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.DataStoreSessionStore +import app.waveflow.data.remote.HttpServerApi +import app.waveflow.data.remote.ServerSessionRepository import app.waveflow.playback.Media3PlaybackController import app.waveflow.playback.PlaybackController import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch /** * Point d'entrée de l'application. @@ -27,6 +32,7 @@ class WaveFlowApp : Application() { override fun onCreate() { super.onCreate() container = AppContainer(this) + container.restoreServerSession() } } @@ -57,4 +63,22 @@ class AppContainer(app: Application) { * par le composant qui le demande (voir `PlayerViewModel.onCleared`). */ fun createPlaybackController(): PlaybackController = Media3PlaybackController(appContext) + + /** + * Session serveur, indépendante de la bibliothèque locale : elle n'a besoin + * ni de la permission audio ni du MediaStore. + * + * Le nom d'appareil est celui que le serveur affichera dans la liste des + * sessions ; `Build.MODEL` est ce que l'utilisateur reconnaîtra. + */ + val serverSessionRepository = ServerSessionRepository( + api = HttpServerApi(), + store = DataStoreSessionStore(app), + deviceName = Build.MODEL ?: "Android", + ) + + /** 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/Dto.kt b/app/src/main/java/app/waveflow/data/remote/Dto.kt new file mode 100644 index 0000000..a697f67 --- /dev/null +++ b/app/src/main/java/app/waveflow/data/remote/Dto.kt @@ -0,0 +1,45 @@ +package app.waveflow.data.remote + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Ce qui circule sur le fil, et qui ne sort pas de ce paquet. + * + * Les noms sont ceux du serveur ; le reste de l'app manipule [AuthTokens] et + * [app.waveflow.model.ServerSession], que l'API distante ne contraint pas. + */ +@Serializable +internal data class LoginRequest( + val username: String, + val password: String, + @SerialName("device_name") val deviceName: String, +) + +@Serializable +internal data class RefreshRequest( + @SerialName("refresh_token") val refreshToken: String, +) + +@Serializable +internal data class AuthResponse( + @SerialName("access_token") val accessToken: String, + @SerialName("refresh_token") val refreshToken: String, + @SerialName("expires_in") val expiresIn: Long, + @SerialName("device_id") val deviceId: String, + val user: UserResponse, +) + +@Serializable +internal data class UserResponse( + val id: String, + val username: String, + val role: String, +) + +/** Corps d'erreur du serveur : `{"code": "...", "message": "..."}`. */ +@Serializable +internal data class ErrorBody( + val code: String, + val message: String, +) diff --git a/app/src/main/java/app/waveflow/data/remote/HttpServerApi.kt b/app/src/main/java/app/waveflow/data/remote/HttpServerApi.kt new file mode 100644 index 0000000..2d0010d --- /dev/null +++ b/app/src/main/java/app/waveflow/data/remote/HttpServerApi.kt @@ -0,0 +1,201 @@ +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. + */ +class HttpServerApi( + private val client: OkHttpClient = defaultClient(), +) : ServerApi { + + private val json = Json { ignoreUnknownKeys = true } + + override suspend fun login( + serverUrl: String, + username: String, + password: String, + deviceName: String, + ): AuthTokens = post( + serverUrl = serverUrl, + path = AUTH_LOGIN, + body = json.encodeToString( + LoginRequest(username = username, password = password, deviceName = deviceName), + ), + ).toTokens() + + override suspend fun refresh(serverUrl: String, refreshToken: String): AuthTokens = post( + serverUrl = serverUrl, + path = AUTH_REFRESH, + body = 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) + } + } + + private fun String.toTokens(): AuthTokens = try { + json.decodeFromString(this).let { + AuthTokens( + accessToken = it.accessToken, + refreshToken = it.refreshToken, + username = it.user.username, + deviceId = it.deviceId, + expiresInSeconds = it.expiresIn, + ) + } + } catch (error: SerializationException) { + throw ServerException.Unexpected("Réponse illisible du serveur : ${error.message}", error) + } + + 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/ServerApi.kt b/app/src/main/java/app/waveflow/data/remote/ServerApi.kt new file mode 100644 index 0000000..8a382eb --- /dev/null +++ b/app/src/main/java/app/waveflow/data/remote/ServerApi.kt @@ -0,0 +1,70 @@ +package app.waveflow.data.remote + +/** + * Les appels d'authentification du serveur WaveFlow. + * + * Interface plutôt qu'implémentation directe : les tests de session doivent + * pouvoir simuler un refus ou une panne réseau sans serveur en face. + */ +interface ServerApi { + + /** `POST /api/v2/auth/login`. */ + suspend fun login( + serverUrl: String, + username: String, + password: String, + deviceName: String, + ): AuthTokens + + /** + * `POST /api/v2/auth/refresh`. + * + * Le serveur invalide [refreshToken] dès qu'il répond : la réponse perdue + * est une session perdue, d'où la sérialisation côté appelant. + */ + suspend fun refresh(serverUrl: String, refreshToken: String): AuthTokens + + /** + * `POST /api/v2/auth/logout`. + * + * Révoque la session côté serveur. L'échec n'est pas fatal : l'appelant + * oublie ses jetons de toute façon. + */ + suspend fun logout(serverUrl: String, accessToken: String) +} + +/** Jetons renvoyés par une connexion ou un renouvellement. */ +data class AuthTokens( + val accessToken: String, + val refreshToken: String, + val username: String, + val deviceId: String, + /** Durée de vie de [accessToken], en secondes, à compter de la réception. */ + val expiresInSeconds: Long, +) + +/** + * Un appel qui n'a pas abouti. + * + * Distingue ce sur quoi l'utilisateur peut agir de ce qui relève de la panne : + * l'écran ne propose de ressaisir un mot de passe que pour [Unauthorized]. + */ +sealed class ServerException( + message: String, + cause: Throwable? = null, +) : Exception(message, cause) { + + /** Identifiants refusés, ou jeton de rafraîchissement périmé. */ + class Unauthorized(message: String) : ServerException(message) + + /** Requête rejetée : adresse mal formée, nom d'appareil vide. */ + class Rejected(message: String) : ServerException(message) + + /** Serveur injoignable, TLS invalide, coupure en cours de route. */ + class Unreachable(message: String, cause: Throwable? = null) : + ServerException(message, cause) + + /** Panne côté serveur, ou réponse que le client ne sait pas lire. */ + class Unexpected(message: String, cause: Throwable? = null) : + ServerException(message, cause) +} diff --git a/app/src/main/java/app/waveflow/data/remote/ServerSessionRepository.kt b/app/src/main/java/app/waveflow/data/remote/ServerSessionRepository.kt new file mode 100644 index 0000000..24bddfd --- /dev/null +++ b/app/src/main/java/app/waveflow/data/remote/ServerSessionRepository.kt @@ -0,0 +1,134 @@ +package app.waveflow.data.remote + +import android.util.Log +import app.waveflow.model.ServerSession +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * La session serveur, et les seuls chemins qui la font changer. + * + * Porté au niveau application : les jetons ne survivraient pas à la recréation + * d'un ViewModel, et plusieurs appelants devront s'en servir dès que le + * catalogue distant arrivera. + * + * @param now horloge injectée — l'échéance du jeton se calcule ici, et un test + * doit pouvoir la franchir sans attendre. + */ +class ServerSessionRepository( + private val api: ServerApi, + private val store: SessionStore, + private val deviceName: String, + private val now: () -> Long = System::currentTimeMillis, +) { + + private val _session = MutableStateFlow(ServerSession.Disconnected) + val session: StateFlow = _session.asStateFlow() + + /** + * Sérialise tout ce qui touche aux jetons. + * + * Le serveur invalide le jeton de rafraîchissement dès qu'il en émet un + * nouveau. Deux renouvellements concurrents partiraient du même jeton : + * le second serait refusé, et la session tomberait alors qu'elle était + * valide. Un seul à la fois, donc. + */ + private val mutex = Mutex() + + /** Relit la session persistée. À appeler au démarrage. */ + suspend fun restore() = mutex.withLock { + _session.value = store.read() + } + + /** + * Ouvre une session. + * + * @throws ServerException si le serveur refuse ou reste injoignable ; la + * session en cours, s'il y en avait une, n'est pas touchée. + */ + suspend fun connect(serverUrl: String, username: String, password: String) = mutex.withLock { + val tokens = api.login(serverUrl, username, password, deviceName) + persist(tokens.toSession(serverUrl)) + } + + /** + * Ferme la session. + * + * La révocation côté serveur est tentée mais non exigée : l'utilisateur a + * demandé à se déconnecter, un serveur injoignable ne doit pas l'en + * empêcher. Le jeton reste alors valide jusqu'à son échéance, ce qui est le + * prix d'une déconnexion hors ligne. + */ + suspend fun disconnect() = mutex.withLock { + val current = _session.value + if (current is ServerSession.Connected) { + try { + api.logout(current.serverUrl, current.accessToken) + } catch (cancellation: CancellationException) { + throw cancellation + } catch (error: ServerException) { + Log.i(TAG, "Révocation impossible, session oubliée localement", error) + } + } + persist(ServerSession.Disconnected) + } + + /** + * Un jeton d'accès utilisable, renouvelé si son échéance approche. + * + * Renvoie `null` quand il n'y a pas de session, ou quand le renouvellement + * a été refusé — auquel cas la session est effacée et l'utilisateur devra + * ressaisir son mot de passe. + * + * @throws ServerException.Unreachable si le serveur ne répond pas ; la + * session est conservée, l'appelant réessaiera. + */ + suspend fun validAccessToken(): String? = mutex.withLock { + val current = _session.value as? ServerSession.Connected ?: return@withLock null + if (now() < current.accessExpiresAtMs - EXPIRY_MARGIN_MS) return@withLock current.accessToken + + try { + val tokens = api.refresh(current.serverUrl, current.refreshToken) + persist(tokens.toSession(current.serverUrl)) + tokens.accessToken + } catch (refused: ServerException.Unauthorized) { + // Le jeton de rafraîchissement est mort : révoqué ailleurs, ou + // périmé. Rien à réessayer, il faut une nouvelle connexion. + Log.i(TAG, "Rafraîchissement refusé, session fermée", refused) + persist(ServerSession.Disconnected) + null + } + } + + /** À 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 + // session que le prochain démarrage ne retrouverait pas. + store.write(session) + _session.value = session + } + + private fun AuthTokens.toSession(serverUrl: String) = ServerSession.Connected( + serverUrl = serverUrl, + username = username, + accessToken = accessToken, + refreshToken = refreshToken, + deviceId = deviceId, + accessExpiresAtMs = now() + expiresInSeconds * 1_000, + ) + + private companion object { + const val TAG = "ServerSession" + + /** + * De quoi couvrir l'aller-retour d'une requête lancée juste avant + * l'échéance — sans cette marge, un jeton valide à l'envoi peut être + * périmé à l'arrivée. + */ + const val EXPIRY_MARGIN_MS = 60_000L + } +} diff --git a/app/src/main/java/app/waveflow/data/remote/SessionStore.kt b/app/src/main/java/app/waveflow/data/remote/SessionStore.kt new file mode 100644 index 0000000..d810840 --- /dev/null +++ b/app/src/main/java/app/waveflow/data/remote/SessionStore.kt @@ -0,0 +1,114 @@ +package app.waveflow.data.remote + +import android.content.Context +import android.util.Log +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.longPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import app.waveflow.model.ServerSession +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import java.io.IOException + +/** + * Là où la session survit au redémarrage. + * + * Interface pour que les tests n'aient pas à écrire sur disque. + */ +interface SessionStore { + suspend fun read(): ServerSession + suspend fun write(session: ServerSession) + suspend fun clear() +} + +/** + * Session rangée dans un DataStore de préférences. + * + * Le stockage n'est pas chiffré : il repose sur le bac à sable applicatif et le + * chiffrement de l'appareil. `security-crypto`, la seule brique androidx qui + * offrirait mieux, n'est jamais sortie d'alpha et n'est plus maintenue. Un + * appareil rooté ou déverrouillé donne donc accès au jeton de rafraîchissement ; + * la contrepartie est qu'il se révoque depuis le serveur. + * + * Hors de Room à dessein : ce n'est pas de la donnée relationnelle, et surtout + * ça n'a rien à faire dans une sauvegarde de playlists. + */ +class DataStoreSessionStore(context: Context) : SessionStore { + + private val dataStore: DataStore = context.applicationContext.sessionDataStore + + override suspend fun read(): ServerSession = dataStore.data + // Un fichier illisible ou corrompu remonte en IOException. La lecture a + // lieu au démarrage, hors de toute portée qui rattraperait : mieux vaut + // repartir déconnecté et redemander le mot de passe que ne pas démarrer. + .catch { error -> + if (error is IOException) { + Log.w(TAG, "Session illisible, on repart déconnecté", error) + emit(emptyPreferences()) + } else { + throw error + } + } + .map { it.toSession() } + .first() + + override suspend fun write(session: ServerSession) { + when (session) { + is ServerSession.Disconnected -> clear() + is ServerSession.Connected -> dataStore.edit { + it[SERVER_URL] = session.serverUrl + it[USERNAME] = session.username + it[ACCESS_TOKEN] = session.accessToken + it[REFRESH_TOKEN] = session.refreshToken + it[DEVICE_ID] = session.deviceId + it[ACCESS_EXPIRES_AT] = session.accessExpiresAtMs + } + } + } + + override suspend fun clear() { + dataStore.edit { it.clear() } + } + + /** + * Une session partiellement écrite vaut pas de session : sans l'un de ces + * champs, aucun appel authentifié n'aboutirait, et mieux vaut redemander le + * mot de passe que boucler sur des 401. + */ + private fun Preferences.toSession(): ServerSession { + val serverUrl = this[SERVER_URL] ?: return ServerSession.Disconnected + val username = this[USERNAME] ?: return ServerSession.Disconnected + val accessToken = this[ACCESS_TOKEN] ?: return ServerSession.Disconnected + val refreshToken = this[REFRESH_TOKEN] ?: return ServerSession.Disconnected + val deviceId = this[DEVICE_ID] ?: return ServerSession.Disconnected + + return ServerSession.Connected( + serverUrl = serverUrl, + username = username, + accessToken = accessToken, + refreshToken = refreshToken, + deviceId = deviceId, + accessExpiresAtMs = this[ACCESS_EXPIRES_AT] ?: 0L, + ) + } + + private companion object { + const val TAG = "SessionStore" + + val SERVER_URL = stringPreferencesKey("server_url") + val USERNAME = stringPreferencesKey("username") + val ACCESS_TOKEN = stringPreferencesKey("access_token") + val REFRESH_TOKEN = stringPreferencesKey("refresh_token") + val DEVICE_ID = stringPreferencesKey("device_id") + val ACCESS_EXPIRES_AT = longPreferencesKey("access_expires_at") + } +} + +private val Context.sessionDataStore: DataStore by preferencesDataStore( + name = "session_serveur", +) diff --git a/app/src/main/java/app/waveflow/model/ServerSession.kt b/app/src/main/java/app/waveflow/model/ServerSession.kt new file mode 100644 index 0000000..846d438 --- /dev/null +++ b/app/src/main/java/app/waveflow/model/ServerSession.kt @@ -0,0 +1,35 @@ +package app.waveflow.model + +/** + * Lien avec un serveur WaveFlow. + * + * Le serveur est une source à part : rien ici ne touche à la bibliothèque de + * l'appareil, et une session absente ne dégrade en rien le reste de l'app. + */ +sealed interface ServerSession { + + /** Aucun serveur configuré, ou déconnexion demandée. */ + data object Disconnected : ServerSession + + /** + * Session ouverte. + * + * [accessToken] est de courte durée — un quart d'heure côté serveur — et se + * renouvelle avec [refreshToken]. Ce dernier tourne à chaque usage : le + * serveur invalide l'ancien dès qu'il en émet un nouveau. + * + * @property deviceId appareil enregistré à la connexion. Le serveur s'en + * sert pour attribuer les mutations et pour l'accusé de synchronisation ; + * il rejette un appareil appartenant à un autre compte. + * @property accessExpiresAtMs échéance absolue, calculée à la réception à + * partir du `expires_in` relatif renvoyé par le serveur. + */ + data class Connected( + val serverUrl: String, + val username: String, + val accessToken: String, + val refreshToken: String, + val deviceId: String, + val accessExpiresAtMs: Long, + ) : ServerSession +} 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 4497477..da3d036 100644 --- a/app/src/main/java/app/waveflow/ui/navigation/WaveFlowNavigation.kt +++ b/app/src/main/java/app/waveflow/ui/navigation/WaveFlowNavigation.kt @@ -3,6 +3,7 @@ package app.waveflow.ui.navigation import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.QueueMusic import androidx.compose.material.icons.filled.Album +import androidx.compose.material.icons.filled.Cloud import androidx.compose.material.icons.filled.MusicNote import androidx.compose.material.icons.filled.Person import androidx.compose.material3.Icon @@ -18,6 +19,7 @@ object Routes { const val ALBUMS = "albums" const val ARTISTS = "artists" const val PLAYLISTS = "playlists" + const val SERVER = "server" const val ARG_ALBUM_ID = "albumId" const val ARG_ARTIST_ID = "artistId" @@ -34,7 +36,12 @@ object Routes { fun playlistDetail(playlistId: Long): String = "$PLAYLISTS/$playlistId" } -/** Les quatre sections atteignables depuis la barre du bas. */ +/** + * Les sections atteignables depuis la barre du bas. + * + * Serveur est en dernier et à part : les quatre premières décrivent la + * bibliothèque de l'appareil, celle-ci une source distante. + */ enum class TopLevelDestination( val route: String, val label: String, @@ -44,6 +51,7 @@ enum class TopLevelDestination( Albums(Routes.ALBUMS, "Albums", Icons.Filled.Album), Artists(Routes.ARTISTS, "Artistes", Icons.Filled.Person), Playlists(Routes.PLAYLISTS, "Playlists", Icons.AutoMirrored.Filled.QueueMusic), + Server(Routes.SERVER, "Serveur", Icons.Filled.Cloud), ; /** diff --git a/app/src/main/java/app/waveflow/ui/server/ServerScreen.kt b/app/src/main/java/app/waveflow/ui/server/ServerScreen.kt new file mode 100644 index 0000000..3f30763 --- /dev/null +++ b/app/src/main/java/app/waveflow/ui/server/ServerScreen.kt @@ -0,0 +1,251 @@ +package app.waveflow.ui.server + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Cloud +import androidx.compose.material.icons.filled.CloudDone +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import app.waveflow.model.ServerSession + +/** + * Onglet Serveur : connexion, puis compte connecté. + * + * 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. + */ +@Composable +fun ServerScreen( + state: ServerUiState, + onConnect: (serverUrl: String, username: String, password: String) -> Unit, + onDisconnect: () -> Unit, + modifier: Modifier = Modifier, + bottomPadding: Dp = 0.dp, +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier + .fillMaxSize() + .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) + } + } +} + +@Composable +private fun ConnectionForm( + state: ServerUiState, + onConnect: (serverUrl: String, username: String, password: String) -> Unit, +) { + // `rememberSaveable` : une rotation ne doit pas vider un formulaire à + // moitié rempli. Le mot de passe en est exclu — il ne part pas dans l'état + // sauvegardé, qui survit au processus. + var serverUrl by rememberSaveable { mutableStateOf("") } + var username by rememberSaveable { mutableStateOf("") } + var password by remember { mutableStateOf("") } + var passwordVisible by rememberSaveable { mutableStateOf(false) } + + fun submit() { + if (!state.isConnecting) onConnect(serverUrl, username, password) + } + + Icon( + imageVector = Icons.Filled.Cloud, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(56.dp), + ) + Spacer(Modifier.height(16.dp)) + Text("Se connecter à un serveur", style = MaterialTheme.typography.headlineSmall) + Spacer(Modifier.height(8.dp)) + Text( + text = "Votre bibliothèque locale reste inchangée.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(32.dp)) + + OutlinedTextField( + value = serverUrl, + onValueChange = { serverUrl = it }, + label = { Text("Adresse du serveur") }, + placeholder = { Text("musique.exemple.fr") }, + singleLine = true, + enabled = !state.isConnecting, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Uri, + imeAction = ImeAction.Next, + autoCorrectEnabled = false, + ), + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(12.dp)) + + OutlinedTextField( + value = username, + onValueChange = { username = it }, + label = { Text("Identifiant") }, + singleLine = true, + enabled = !state.isConnecting, + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.None, + imeAction = ImeAction.Next, + autoCorrectEnabled = false, + ), + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(12.dp)) + + OutlinedTextField( + value = password, + onValueChange = { password = it }, + label = { Text("Mot de passe") }, + singleLine = true, + enabled = !state.isConnecting, + visualTransformation = if (passwordVisible) { + VisualTransformation.None + } else { + PasswordVisualTransformation() + }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done, + ), + // « Terminé » doit valider : proposer l'action puis ne rien en faire + // oblige à revenir chercher le bouton. + keyboardActions = KeyboardActions(onDone = { submit() }), + trailingIcon = { + IconButton(onClick = { passwordVisible = !passwordVisible }) { + Icon( + imageVector = if (passwordVisible) { + Icons.Filled.VisibilityOff + } else { + Icons.Filled.Visibility + }, + contentDescription = if (passwordVisible) { + "Masquer le mot de passe" + } else { + "Afficher le mot de passe" + }, + ) + } + }, + modifier = Modifier.fillMaxWidth(), + ) + + state.errorMessage?.let { message -> + Spacer(Modifier.height(16.dp)) + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + modifier = Modifier + .fillMaxWidth() + // Le message apparaît sans que le foyer bouge : sans région + // active, TalkBack ne dirait rien de l'échec. + .semantics { liveRegion = LiveRegionMode.Polite }, + ) + } + + Spacer(Modifier.height(24.dp)) + Button( + onClick = { submit() }, + enabled = !state.isConnecting, + modifier = Modifier.fillMaxWidth(), + ) { + if (state.isConnecting) { + CircularProgressIndicator( + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(12.dp)) + Text("Connexion…") + } else { + Text("Se connecter") + } + } + + Spacer(Modifier.height(16.dp)) + Text( + text = "Sans schéma, l'adresse est jointe en HTTPS.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) +} + +@Composable +private fun ConnectedAccount( + session: ServerSession.Connected, + onDisconnect: () -> Unit, +) { + Icon( + imageVector = Icons.Filled.CloudDone, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(56.dp), + ) + Spacer(Modifier.height(16.dp)) + Text(session.username, style = MaterialTheme.typography.headlineSmall) + Spacer(Modifier.height(4.dp)) + Text( + text = session.serverUrl, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(Modifier.height(32.dp)) + Text( + text = "Le catalogue du serveur arrive dans une prochaine version. " + + "Cette connexion ne fait encore rien d'autre.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(Modifier.height(32.dp)) + OutlinedButton(onClick = onDisconnect, modifier = Modifier.fillMaxWidth()) { + Text("Se déconnecter") + } +} diff --git a/app/src/main/java/app/waveflow/ui/server/ServerUiState.kt b/app/src/main/java/app/waveflow/ui/server/ServerUiState.kt new file mode 100644 index 0000000..3bfec63 --- /dev/null +++ b/app/src/main/java/app/waveflow/ui/server/ServerUiState.kt @@ -0,0 +1,20 @@ +package app.waveflow.ui.server + +import app.waveflow.model.ServerSession + +/** + * Ce que l'onglet Serveur affiche. + * + * [session] vient du dépôt, le reste appartient à l'écran : une connexion en + * cours et le message du dernier échec n'ont pas à survivre à la fermeture de + * l'app. + */ +data class ServerUiState( + val session: ServerSession = ServerSession.Disconnected, + val isConnecting: Boolean = false, + val errorMessage: String? = null, +) { + val isConnected: Boolean get() = session is ServerSession.Connected + + val connected: ServerSession.Connected? get() = session as? ServerSession.Connected +} diff --git a/app/src/main/java/app/waveflow/ui/server/ServerViewModel.kt b/app/src/main/java/app/waveflow/ui/server/ServerViewModel.kt new file mode 100644 index 0000000..719a8c7 --- /dev/null +++ b/app/src/main/java/app/waveflow/ui/server/ServerViewModel.kt @@ -0,0 +1,119 @@ +package app.waveflow.ui.server + +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.ServerException +import app.waveflow.data.remote.ServerSessionRepository +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch + +/** Connexion au serveur WaveFlow : saisie, état, déconnexion. */ +class ServerViewModel( + private val sessionRepository: ServerSessionRepository, +) : ViewModel() { + + /** Ce que l'écran ajoute à la session : progression et dernier échec. */ + private val local = MutableStateFlow(ServerUiState()) + + private val _state = MutableStateFlow(ServerUiState()) + val state: StateFlow = _state.asStateFlow() + + init { + combine(sessionRepository.session, local) { session, local -> + local.copy(session = session) + }.onEach { _state.value = it }.launchIn(viewModelScope) + } + + fun connect(serverUrl: String, username: String, password: String) { + if (local.value.isConnecting) return + + val url = serverUrl.trim() + val user = username.trim() + if (url.isEmpty() || user.isEmpty() || password.isEmpty()) { + local.value = local.value.copy( + errorMessage = "Adresse, identifiant et mot de passe sont requis.", + ) + return + } + + local.value = local.value.copy(isConnecting = true, errorMessage = null) + viewModelScope.launch { + try { + sessionRepository.connect(url, user, password) + local.value = ServerUiState() + } catch (cancellation: CancellationException) { + throw cancellation + } catch (error: ServerException) { + // Le message du serveur est repris tel quel pour les refus + // d'identifiants ; le reste est reformulé, « connection reset » + // ne disant rien à personne. + local.value = local.value.copy( + isConnecting = false, + errorMessage = error.toMessage(), + ) + } catch (error: Exception) { + // Le serveur a pu accepter la connexion et l'enregistrement + // échouer ensuite. Sans ce filet, l'exception quitterait + // `viewModelScope` et ferait tomber l'application. + Log.w(TAG, "Session non enregistrée", error) + local.value = local.value.copy( + isConnecting = false, + errorMessage = "La session n'a pas pu être enregistrée sur l'appareil.", + ) + } + } + } + + fun disconnect() { + viewModelScope.launch { + try { + sessionRepository.disconnect() + local.value = ServerUiState() + } catch (cancellation: CancellationException) { + throw cancellation + } catch (error: Exception) { + // Le dépôt tolère déjà un serveur injoignable, mais l'effacement + // sur disque peut échouer. Sans ce filet, l'exception quitterait + // `viewModelScope` et ferait tomber l'application. + Log.w(TAG, "Déconnexion incomplète", error) + local.value = local.value.copy( + errorMessage = "La déconnexion n'a pas pu être enregistrée.", + ) + } + } + } + + fun dismissError() { + local.value = local.value.copy(errorMessage = null) + } + + private fun ServerException.toMessage(): String = when (this) { + is ServerException.Unauthorized -> "Identifiant ou mot de passe refusé." + is ServerException.Rejected -> message ?: "Requête refusée par le serveur." + is ServerException.Unreachable -> "Serveur injoignable. Vérifiez l'adresse et le réseau." + is ServerException.Unexpected -> "Le serveur a répondu de façon inattendue." + } + + companion object { + private const val TAG = "ServerViewModel" + + val Factory: ViewModelProvider.Factory = viewModelFactory { + initializer { + val app = this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] + as WaveFlowApp + ServerViewModel(sessionRepository = app.container.serverSessionRepository) + } + } + } +} diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml index 4df9255..94a0969 100644 --- a/app/src/main/res/xml/backup_rules.xml +++ b/app/src/main/res/xml/backup_rules.xml @@ -1,13 +1,13 @@ - - \ No newline at end of file + Les jetons de session serveur ne sortent pas de l'appareil : ils sont + stockés en clair, et un jeton de rafraîchissement restauré sur un autre + appareil y ouvrirait le compte. + --> + + diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml index 9ee9997..d159e19 100644 --- a/app/src/main/res/xml/data_extraction_rules.xml +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -1,19 +1,17 @@ - - + - - \ No newline at end of file + diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..cf2d093 --- /dev/null +++ b/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/test/java/app/waveflow/data/remote/HttpServerApiTest.kt b/app/src/test/java/app/waveflow/data/remote/HttpServerApiTest.kt new file mode 100644 index 0000000..a045467 --- /dev/null +++ b/app/src/test/java/app/waveflow/data/remote/HttpServerApiTest.kt @@ -0,0 +1,241 @@ +package app.waveflow.data.remote + +import kotlinx.coroutines.test.runTest +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.SocketPolicy +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** + * Le client HTTP face à un serveur de test. + * + * Les corps de réponse et d'erreur sont ceux relevés sur `waveflow-server` + * 2.0.0-beta.0 — une réponse inventée ne prouverait pas grand-chose. + */ +class HttpServerApiTest { + + private lateinit var server: MockWebServer + private lateinit var api: ServerApi + + @Before + fun setUp() { + server = MockWebServer() + server.start() + api = HttpServerApi() + } + + @After + fun tearDown() { + server.shutdown() + } + + private fun url(): String = server.url("/").toString().trimEnd('/') + + /** L'exception levée par [bloc], ou un échec de test s'il n'en lève aucune. */ + private suspend fun echecDe(bloc: suspend () -> Unit): Throwable = + runCatching { bloc() }.exceptionOrNull() + ?: throw AssertionError("aucune exception levée") + + @Test + fun `la connexion envoie les identifiants et lit les jetons`() = runTest { + server.enqueue(MockResponse().setBody(AUTH_BODY)) + + val tokens = api.login(url(), "admin", "secret", "Pixel de test") + + val request = server.takeRequest() + assertEquals("POST", request.method) + assertEquals("/api/v2/auth/login", request.path) + assertEquals("application/json; charset=utf-8", request.getHeader("Content-Type")) + + val body = request.body.readUtf8() + assertTrue(body, body.contains("\"username\":\"admin\"")) + // Le serveur attend `device_name`, pas le nom Kotlin. + assertTrue(body, body.contains("\"device_name\":\"Pixel de test\"")) + + assertEquals("wfa_acces", tokens.accessToken) + assertEquals("wfr_rafraichir", tokens.refreshToken) + assertEquals("admin", tokens.username) + assertEquals("da5adeb0-2eb7-4904-94fd-97c1e5534be2", tokens.deviceId) + assertEquals(900L, tokens.expiresInSeconds) + } + + @Test + fun `un 401 devient un refus d'identifiants et reprend le message`() = runTest { + server.enqueue( + MockResponse() + .setResponseCode(401) + .setBody("""{"code":"unauthorized","message":"Authentication failed"}"""), + ) + + val error = echecDe { api.login(url(), "admin", "faux", "Pixel") } + + assertTrue(error.toString(), error is ServerException.Unauthorized) + assertEquals("Authentication failed", error.message) + } + + @Test + fun `un 422 devient un rejet, pas un refus d'identifiants`() = runTest { + server.enqueue( + MockResponse() + .setResponseCode(422) + .setBody("""{"code":"validation_error","message":"The request is invalid"}"""), + ) + + val error = echecDe { api.login(url(), "admin", "secret", "") } + + assertTrue(error.toString(), error is ServerException.Rejected) + } + + @Test + fun `une erreur en texte brut ne fait pas echouer la lecture du message`() = runTest { + // Relevé sur le vrai serveur : un corps mal formé lui fait renvoyer du + // texte, pas le `{code, message}` habituel. + server.enqueue( + MockResponse() + .setResponseCode(422) + .setBody("Failed to deserialize the JSON body: missing field `password`"), + ) + + val error = echecDe { api.login(url(), "admin", "secret", "Pixel") } + + assertTrue(error.toString(), error is ServerException.Rejected) + assertTrue(error.message.orEmpty(), error.message.orEmpty().contains("missing field")) + } + + @Test + fun `une panne serveur est signalee comme inattendue, pas comme un rejet`() = runTest { + // Rien à ressaisir : c'est le serveur qui va mal, l'appel est à refaire. + server.enqueue( + MockResponse() + .setResponseCode(500) + .setBody("""{"code":"unavailable","message":"Database unavailable"}"""), + ) + + val error = echecDe { api.login(url(), "admin", "secret", "Pixel") } + + assertTrue(error.toString(), error is ServerException.Unexpected) + assertEquals("Database unavailable", error.message) + } + + @Test + fun `un serveur injoignable ne remonte pas comme un refus`() = runTest { + server.enqueue(MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AT_START)) + + val error = echecDe { api.login(url(), "admin", "secret", "Pixel") } + + assertTrue(error.toString(), error is ServerException.Unreachable) + } + + @Test + fun `une coupure pendant la lecture du corps reste une exception de service`() = runTest { + // L'en-tête est arrivé, la coupure survient ensuite : l'échec ne passe + // donc pas par le rappel de l'appel mais par la lecture du corps. Une + // IOException nue traverserait la pile jusqu'à faire tomber l'app. + server.enqueue( + MockResponse() + .setBody(AUTH_BODY) + .setSocketPolicy(SocketPolicy.DISCONNECT_DURING_RESPONSE_BODY), + ) + + val error = echecDe { api.login(url(), "admin", "secret", "Pixel") } + + assertTrue(error.toString(), error is ServerException.Unreachable) + } + + @Test + fun `une reponse illisible est signalee comme inattendue`() = runTest { + server.enqueue(MockResponse().setBody("""{"pas":"ce qu'on attend"}""")) + + val error = echecDe { api.login(url(), "admin", "secret", "Pixel") } + + assertTrue(error.toString(), error is ServerException.Unexpected) + } + + @Test + fun `les champs inconnus d'une reponse ne cassent rien`() = runTest { + // Le serveur en ajoutera : une version installée ne doit pas tomber + // parce qu'une réponse s'est enrichie. + server.enqueue(MockResponse().setBody(AUTH_BODY_WITH_EXTRAS)) + + val tokens = api.login(url(), "admin", "secret", "Pixel") + + assertEquals("wfa_acces", tokens.accessToken) + } + + @Test + fun `le rafraichissement envoie le jeton et lit le nouveau`() = runTest { + server.enqueue(MockResponse().setBody(AUTH_BODY)) + + val tokens = api.refresh(url(), "wfr_ancien") + + val request = server.takeRequest() + assertEquals("/api/v2/auth/refresh", request.path) + assertTrue(request.body.readUtf8().contains("\"refresh_token\":\"wfr_ancien\"")) + assertEquals("wfr_rafraichir", tokens.refreshToken) + } + + @Test + fun `la deconnexion presente le jeton d'acces`() = runTest { + server.enqueue(MockResponse().setResponseCode(204)) + + api.logout(url(), "wfa_acces") + + val request = server.takeRequest() + assertEquals("/api/v2/auth/logout", request.path) + assertEquals("Bearer wfa_acces", request.getHeader("Authorization")) + } + + @Test + fun `une adresse sans schema est jointe en HTTPS`() = runTest { + // Le serveur de test parle en clair : joint en HTTPS, il ne répond pas. + // C'est précisément ce qui prouve le schéma retenu — une adresse restée + // relative n'aurait même pas été tentée. + val hostPort = server.hostName + ":" + server.port + + val error = echecDe { api.login(hostPort, "admin", "secret", "Pixel") } + + assertTrue(error.toString(), error is ServerException.Unreachable) + } + + @Test + fun `une adresse vide est refusee avant tout appel`() = runTest { + val error = echecDe { api.login(" ", "admin", "secret", "Pixel") } + + assertTrue(error.toString(), error is ServerException.Rejected) + } + + @Test + fun `un chemin deja present dans l'adresse est conserve`() = runTest { + server.enqueue(MockResponse().setBody(AUTH_BODY)) + + api.login("${url()}/musique", "admin", "secret", "Pixel") + + assertEquals("/musique/api/v2/auth/login", server.takeRequest().path) + } + + private companion object { + val AUTH_BODY = """ + { + "access_token": "wfa_acces", + "refresh_token": "wfr_rafraichir", + "token_type": "Bearer", + "expires_in": 900, + "user": { + "id": "10cafbdc-282f-4783-98c1-7a4139c269d6", + "username": "admin", + "role": "admin" + }, + "device_id": "da5adeb0-2eb7-4904-94fd-97c1e5534be2" + } + """.trimIndent() + + val AUTH_BODY_WITH_EXTRAS = AUTH_BODY.replace( + "\"token_type\": \"Bearer\",", + "\"token_type\": \"Bearer\", \"capacites\": [\"sync\"], \"serveur\": {\"version\": \"3\"},", + ) + } +} diff --git a/app/src/test/java/app/waveflow/data/remote/ServerSessionRepositoryTest.kt b/app/src/test/java/app/waveflow/data/remote/ServerSessionRepositoryTest.kt new file mode 100644 index 0000000..bbc8406 --- /dev/null +++ b/app/src/test/java/app/waveflow/data/remote/ServerSessionRepositoryTest.kt @@ -0,0 +1,255 @@ +package app.waveflow.data.remote + +import app.waveflow.model.ServerSession +import app.waveflow.testing.FakeServerApi +import app.waveflow.testing.FakeSessionStore +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * La vie d'une session serveur. + * + * L'horloge est injectée : l'échéance d'un jeton d'accès est de quinze minutes, + * et aucun test n'a vocation à les attendre. + * + * Robolectric parce que le dépôt journalise ses fermetures de session par + * `android.util.Log`, qui lève sur une JVM nue. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +class ServerSessionRepositoryTest { + + private var maintenant = 1_000_000L + + private fun repository( + api: FakeServerApi = FakeServerApi(), + store: FakeSessionStore = FakeSessionStore(), + ) = ServerSessionRepository( + api = api, + store = store, + deviceName = "Pixel de test", + now = { maintenant }, + ) + + /** + * Session déjà persistée avant le test. + * + * Ses jetons portent un suffixe distinct de ceux qu'émet [FakeServerApi] : + * sans ça, un renouvellement rendrait la même valeur que le jeton stocké et + * aucune assertion ne pourrait distinguer « renouvelé » de « inchangé ». + */ + private fun connectedSession( + accessToken: String = "wfa_stocke", + expiresAtMs: Long = maintenant + 900_000L, + ) = ServerSession.Connected( + serverUrl = "https://musique.test", + username = "admin", + accessToken = accessToken, + refreshToken = "wfr_stocke", + deviceId = "appareil-1", + accessExpiresAtMs = expiresAtMs, + ) + + @Test + fun `la connexion expose la session et la persiste`() = runTest { + val store = FakeSessionStore() + val repository = repository(store = store) + + repository.connect("https://musique.test", "admin", "secret") + + val session = repository.session.value as ServerSession.Connected + assertEquals("https://musique.test", session.serverUrl) + assertEquals("admin", session.username) + assertEquals("appareil-1", session.deviceId) + // `expires_in` est relatif ; l'échéance est absolue. + assertEquals(maintenant + 900_000L, session.accessExpiresAtMs) + assertEquals(session, store.written) + } + + @Test + fun `la connexion transmet le nom d'appareil`() = runTest { + val api = FakeServerApi() + + repository(api = api).connect("https://musique.test", "admin", "secret") + + assertEquals("Pixel de test", api.lastDeviceName) + } + + @Test + fun `une connexion refusee laisse la session precedente intacte`() = runTest { + val store = FakeSessionStore(stored = connectedSession()) + val api = FakeServerApi(loginFailure = ServerException.Unauthorized("refusé")) + val repository = repository(api = api, store = store) + repository.restore() + + val error = runCatching { + repository.connect("https://autre.test", "admin", "faux") + }.exceptionOrNull() + + assertTrue(error is ServerException.Unauthorized) + assertEquals(connectedSession(), repository.session.value) + } + + @Test + fun `la session persistee est relue au demarrage`() = runTest { + val repository = repository(store = FakeSessionStore(stored = connectedSession())) + + repository.restore() + + assertEquals(connectedSession(), repository.session.value) + } + + @Test + fun `un jeton encore valide est rendu sans appeler le serveur`() = runTest { + val api = FakeServerApi() + val repository = repository(api = api, store = FakeSessionStore(stored = connectedSession())) + repository.restore() + + assertEquals("wfa_stocke", repository.validAccessToken()) + assertEquals(0, api.refreshCalls) + } + + @Test + fun `un jeton proche de l'echeance est renouvele avant d'etre rendu`() = runTest { + val api = FakeServerApi() + val repository = repository( + api = api, + // Encore valide une demi-minute : moins que la marge de sécurité. + store = FakeSessionStore(stored = connectedSession(expiresAtMs = maintenant + 30_000L)), + ) + repository.restore() + + assertEquals("wfa_1", repository.validAccessToken()) + assertEquals(1, api.refreshCalls) + } + + @Test + fun `le renouvellement enregistre le nouveau jeton de rafraichissement`() = runTest { + // Le serveur invalide l'ancien dès qu'il en émet un nouveau : conserver + // le précédent condamnerait le renouvellement suivant. + val store = FakeSessionStore(stored = connectedSession(expiresAtMs = maintenant)) + val repository = repository(store = store) + repository.restore() + + repository.validAccessToken() + + val session = repository.session.value as ServerSession.Connected + assertEquals("wfr_1", session.refreshToken) + assertEquals(session, store.written) + } + + @Test + fun `le renouvellement suivant repart du jeton renouvele`() = runTest { + // C'est tout l'enjeu de la rotation : réutiliser l'ancien vaudrait 401. + val api = FakeServerApi() + val repository = repository( + api = api, + store = FakeSessionStore(stored = connectedSession(expiresAtMs = maintenant)), + ) + repository.restore() + + repository.validAccessToken() + assertEquals("wfr_stocke", api.lastRefreshToken) + + maintenant += 900_000L + repository.validAccessToken() + assertEquals("wfr_1", api.lastRefreshToken) + } + + @Test + fun `deux demandes concurrentes ne declenchent qu'un renouvellement`() = runTest { + // Le serveur invalide l'ancien jeton dès qu'il en émet un nouveau : + // deux renouvellements partis du même jeton en perdraient un. Le portail + // maintient le premier appel en vol le temps que le second se présente, + // sans quoi ils ne se croiseraient jamais. + val portail = CompletableDeferred() + val api = FakeServerApi(refreshGate = portail) + val repository = repository( + api = api, + store = FakeSessionStore(stored = connectedSession(expiresAtMs = maintenant)), + ) + repository.restore() + + val premier = async { repository.validAccessToken() } + val second = async { repository.validAccessToken() } + runCurrent() + + portail.complete(Unit) + + assertEquals("wfa_1", premier.await()) + // Le second trouve un jeton frais et n'a plus rien à renouveler. + assertEquals("wfa_1", second.await()) + assertEquals(1, api.refreshCalls) + } + + @Test + fun `un renouvellement refuse ferme la session`() = runTest { + val api = FakeServerApi(refreshFailure = ServerException.Unauthorized("périmé")) + val store = FakeSessionStore(stored = connectedSession(expiresAtMs = maintenant)) + val repository = repository(api = api, store = store) + repository.restore() + + assertNull(repository.validAccessToken()) + assertEquals(ServerSession.Disconnected, repository.session.value) + assertTrue("la session doit aussi être effacée du disque", store.cleared) + } + + @Test + fun `un serveur injoignable au renouvellement conserve la session`() = runTest { + // Contrairement à un refus : il n'y a rien à ressaisir, seulement à + // réessayer plus tard. + val api = FakeServerApi(refreshFailure = ServerException.Unreachable("coupure")) + val repository = repository( + api = api, + store = FakeSessionStore(stored = connectedSession(expiresAtMs = maintenant)), + ) + repository.restore() + + val error = runCatching { repository.validAccessToken() }.exceptionOrNull() + + assertTrue(error is ServerException.Unreachable) + assertTrue(repository.session.value is ServerSession.Connected) + } + + @Test + fun `sans session il n'y a pas de jeton`() = runTest { + assertNull(repository().validAccessToken()) + } + + @Test + fun `la deconnexion revoque cote serveur puis oublie la session`() = runTest { + val api = FakeServerApi() + val store = FakeSessionStore(stored = connectedSession()) + val repository = repository(api = api, store = store) + repository.restore() + + repository.disconnect() + + assertEquals("wfa_stocke", api.revokedAccessToken) + assertEquals(ServerSession.Disconnected, repository.session.value) + assertTrue(store.cleared) + } + + @Test + fun `une revocation impossible n'empeche pas la deconnexion`() = runTest { + // Se déconnecter hors ligne doit marcher : l'utilisateur l'a demandé. + val api = FakeServerApi(logoutFailure = ServerException.Unreachable("hors ligne")) + val store = FakeSessionStore(stored = connectedSession()) + val repository = repository(api = api, store = store) + repository.restore() + + repository.disconnect() + + assertEquals(ServerSession.Disconnected, repository.session.value) + assertTrue(store.cleared) + } +} diff --git a/app/src/test/java/app/waveflow/testing/ServerFakes.kt b/app/src/test/java/app/waveflow/testing/ServerFakes.kt new file mode 100644 index 0000000..7ecae7c --- /dev/null +++ b/app/src/test/java/app/waveflow/testing/ServerFakes.kt @@ -0,0 +1,112 @@ +package app.waveflow.testing + +import app.waveflow.data.remote.AuthTokens +import app.waveflow.data.remote.ServerApi +import app.waveflow.data.remote.SessionStore +import app.waveflow.model.ServerSession +import kotlinx.coroutines.CompletableDeferred + +/** + * Serveur simulé. + * + * Les jetons sont numérotés : `wfa_1` à la connexion, puis `wfa_2`, `wfa_3`… + * à chaque renouvellement. Un test peut ainsi affirmer *lequel* est rendu, ce + * qu'une valeur constante ne permettrait pas. + */ +class FakeServerApi( + /** Modifiable : un test peut faire échouer une connexion puis l'accepter. */ + var loginFailure: Throwable? = null, + private val refreshFailure: Throwable? = null, + private val logoutFailure: Throwable? = null, + /** + * Si non nul, `refresh` attend ce signal avant de rendre la main. + * + * Sans lui, chaque renouvellement s'achève avant que le suivant ne parte : + * deux ne sont jamais en vol ensemble, et la sérialisation ne peut pas être + * mise à l'épreuve. + */ + private val refreshGate: CompletableDeferred? = null, +) : ServerApi { + + var refreshCalls = 0 + private set + var lastDeviceName: String? = null + private set + var lastRefreshToken: String? = null + private set + var revokedAccessToken: String? = null + private set + + private var generation = 0 + private var username = "admin" + + override suspend fun login( + serverUrl: String, + username: String, + password: String, + deviceName: String, + ): AuthTokens { + lastDeviceName = deviceName + loginFailure?.let { throw it } + this.username = username + return nextTokens(username) + } + + override suspend fun refresh(serverUrl: String, refreshToken: String): AuthTokens { + refreshCalls++ + lastRefreshToken = refreshToken + refreshGate?.await() + refreshFailure?.let { throw it } + // Le compte reste celui de la connexion : le serveur ne le change pas + // au renouvellement. + return nextTokens(username) + } + + override suspend fun logout(serverUrl: String, accessToken: String) { + revokedAccessToken = accessToken + logoutFailure?.let { throw it } + } + + private fun nextTokens(username: String): AuthTokens { + generation++ + return AuthTokens( + accessToken = "wfa_$generation", + refreshToken = "wfr_$generation", + username = username, + deviceId = "appareil-1", + expiresInSeconds = 900L, + ) + } +} + +/** Persistance en mémoire, qui retient ce qu'on lui a demandé d'écrire. */ +class FakeSessionStore( + private var stored: ServerSession = ServerSession.Disconnected, + /** Si non nul, toute écriture ou effacement échoue avec cette exception. */ + private val writeFailure: Throwable? = null, +) : SessionStore { + + var written: ServerSession? = null + private set + var cleared = false + private set + + override suspend fun read(): ServerSession = stored + + override suspend fun write(session: ServerSession) { + when (session) { + is ServerSession.Disconnected -> clear() + is ServerSession.Connected -> { + writeFailure?.let { throw it } + stored = session + written = session + } + } + } + + override suspend fun clear() { + writeFailure?.let { throw it } + stored = ServerSession.Disconnected + cleared = true + } +} diff --git a/app/src/test/java/app/waveflow/ui/server/ServerScreenTest.kt b/app/src/test/java/app/waveflow/ui/server/ServerScreenTest.kt new file mode 100644 index 0000000..46c1f33 --- /dev/null +++ b/app/src/test/java/app/waveflow/ui/server/ServerScreenTest.kt @@ -0,0 +1,147 @@ +package app.waveflow.ui.server + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.ComposeContentTestRule +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import app.waveflow.model.ServerSession +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** L'écran de connexion, du point de vue de ce qui est affiché et cliquable. */ +@RunWith(RobolectricTestRunner::class) +@Config(qualifiers = "w411dp-h2000dp-xhdpi") +class ServerScreenTest { + + @get:Rule + val compose = createComposeRule() + + private val connexions = mutableListOf>() + private var deconnexions = 0 + + private val session = ServerSession.Connected( + serverUrl = "https://musique.test", + username = "admin", + accessToken = "wfa_1", + refreshToken = "wfr_1", + deviceId = "appareil-1", + accessExpiresAtMs = 0L, + ) + + private fun afficher(state: ServerUiState) { + compose.setContent { + ServerScreen( + state = state, + onConnect = { url, user, password -> connexions += Triple(url, user, password) }, + onDisconnect = { deconnexions++ }, + ) + } + } + + /** + * Nombre de nœuds dont un texte contient [text]. + * + * En sous-chaîne : un jeton glissé au milieu d'une phrase échapperait à une + * comparaison stricte, et c'est justement ce qu'on cherche à exclure. + */ + private fun ComposeContentTestRule.occurrencesDe(text: String): Int = + onAllNodes(hasText(text, substring = true)).fetchSemanticsNodes().size + + @Test + fun `sans session l'ecran propose le formulaire`() { + afficher(ServerUiState()) + + compose.onNodeWithText("Se connecter à un serveur").assertIsDisplayed() + compose.onNodeWithText("Se connecter").assertIsDisplayed() + } + + @Test + fun `la saisie est transmise telle quelle`() { + afficher(ServerUiState()) + + compose.onNodeWithText("Adresse du serveur").performTextInput("musique.test") + compose.onNodeWithText("Identifiant").performTextInput("admin") + compose.onNodeWithText("Mot de passe").performTextInput("secret") + compose.onNodeWithText("Se connecter").performClick() + + // Le rognage est du ressort du ViewModel, pas de l'écran. + assertEquals(listOf(Triple("musique.test", "admin", "secret")), connexions) + } + + @Test + fun `l'affichage du mot de passe se demande et se reprend`() { + // Le masquage lui-même est une transformation visuelle : l'arbre de + // sémantique porte le texte brut dans les deux cas, et ne peut donc pas + // en témoigner. Ce qui s'y voit, c'est l'état de la bascule. + afficher(ServerUiState()) + compose.onNodeWithText("Mot de passe").performTextInput("secret") + + compose.onNodeWithContentDescription("Afficher le mot de passe").performClick() + compose.onNodeWithContentDescription("Masquer le mot de passe").assertIsDisplayed() + + compose.onNodeWithContentDescription("Masquer le mot de passe").performClick() + compose.onNodeWithContentDescription("Afficher le mot de passe").assertIsDisplayed() + } + + @Test + fun `pendant la connexion le bouton est inactif`() { + afficher(ServerUiState(isConnecting = true)) + + compose.onNodeWithText("Connexion…").assertIsNotEnabled() + } + + @Test + fun `hors connexion le bouton est actif`() { + afficher(ServerUiState()) + + compose.onNodeWithText("Se connecter").assertIsEnabled() + } + + @Test + fun `le message d'erreur est affiche`() { + afficher(ServerUiState(errorMessage = "Identifiant ou mot de passe refusé.")) + + compose.onNodeWithText("Identifiant ou mot de passe refusé.").assertIsDisplayed() + } + + @Test + fun `une session ouverte remplace le formulaire par le compte`() { + afficher(ServerUiState(session = session)) + + compose.onNodeWithText("admin").assertIsDisplayed() + compose.onNodeWithText("https://musique.test").assertIsDisplayed() + compose.onNodeWithText("Se déconnecter").assertIsDisplayed() + // Le formulaire ne doit pas cohabiter avec le compte. + assertEquals(0, compose.occurrencesDe("Adresse du serveur")) + } + + @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)) + + assertEquals(0, compose.occurrencesDe("wfa_1")) + assertEquals(0, compose.occurrencesDe("wfr_1")) + } + + @Test + fun `le bouton de deconnexion previent l'appelant`() { + afficher(ServerUiState(session = session)) + + compose.onNodeWithText("Se déconnecter").performClick() + + assertEquals(1, deconnexions) + assertTrue(connexions.isEmpty()) + } +} diff --git a/app/src/test/java/app/waveflow/ui/server/ServerViewModelTest.kt b/app/src/test/java/app/waveflow/ui/server/ServerViewModelTest.kt new file mode 100644 index 0000000..ee9d33b --- /dev/null +++ b/app/src/test/java/app/waveflow/ui/server/ServerViewModelTest.kt @@ -0,0 +1,196 @@ +package app.waveflow.ui.server + +import app.waveflow.data.remote.ServerException +import app.waveflow.data.remote.ServerSessionRepository +import app.waveflow.model.ServerSession +import app.waveflow.testing.FakeServerApi +import app.waveflow.testing.FakeSessionStore +import app.waveflow.testing.MainDispatcherRule +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +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 +import java.io.IOException + +/** + * Robolectric parce que le ViewModel journalise ses échecs par + * `android.util.Log`, qui lève sur une JVM nue. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +class ServerViewModelTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + private fun viewModel( + api: FakeServerApi = FakeServerApi(), + store: FakeSessionStore = FakeSessionStore(), + ) = ServerViewModel( + ServerSessionRepository( + api = api, + store = store, + deviceName = "Pixel de test", + now = { 0L }, + ), + ) + + @Test + fun `une connexion reussie expose le compte et efface la progression`() = + runTest(mainDispatcherRule.dispatcher) { + val viewModel = viewModel() + + viewModel.connect("https://musique.test", "admin", "secret") + advanceUntilIdle() + + val state = viewModel.state.value + assertTrue(state.isConnected) + assertEquals("admin", state.connected?.username) + assertFalse(state.isConnecting) + assertNull(state.errorMessage) + } + + @Test + fun `l'adresse et l'identifiant sont rognes avant l'appel`() = + runTest(mainDispatcherRule.dispatcher) { + val store = FakeSessionStore() + val viewModel = viewModel(store = store) + + viewModel.connect(" https://musique.test ", " admin ", "secret") + advanceUntilIdle() + + val session = store.written as ServerSession.Connected + assertEquals("https://musique.test", session.serverUrl) + } + + @Test + fun `un champ vide est refuse sans toucher au reseau`() = + runTest(mainDispatcherRule.dispatcher) { + val api = FakeServerApi() + val viewModel = viewModel(api = api) + + viewModel.connect("https://musique.test", " ", "secret") + advanceUntilIdle() + + assertNull(api.lastDeviceName) + assertEquals( + "Adresse, identifiant et mot de passe sont requis.", + viewModel.state.value.errorMessage, + ) + } + + @Test + fun `un refus d'identifiants devient un message comprehensible`() = + runTest(mainDispatcherRule.dispatcher) { + val api = FakeServerApi(loginFailure = ServerException.Unauthorized("Authentication failed")) + val viewModel = viewModel(api = api) + + viewModel.connect("https://musique.test", "admin", "faux") + advanceUntilIdle() + + // Le message du serveur est en anglais et technique : il ne sort pas. + assertEquals("Identifiant ou mot de passe refusé.", viewModel.state.value.errorMessage) + assertFalse(viewModel.state.value.isConnecting) + } + + @Test + fun `un serveur injoignable se distingue d'un refus`() = + runTest(mainDispatcherRule.dispatcher) { + val api = FakeServerApi(loginFailure = ServerException.Unreachable("connection reset")) + val viewModel = viewModel(api = api) + + viewModel.connect("https://musique.test", "admin", "secret") + advanceUntilIdle() + + assertEquals( + "Serveur injoignable. Vérifiez l'adresse et le réseau.", + viewModel.state.value.errorMessage, + ) + } + + @Test + fun `un echec n'empeche pas une nouvelle tentative`() = + runTest(mainDispatcherRule.dispatcher) { + // Le même ViewModel : c'est justement la reprise après échec qu'on + // vérifie, pas deux instances indépendantes. + val api = FakeServerApi(loginFailure = ServerException.Unauthorized("non")) + val viewModel = viewModel(api = api) + + viewModel.connect("https://musique.test", "admin", "faux") + advanceUntilIdle() + assertFalse("le bouton doit redevenir actif", viewModel.state.value.isConnecting) + + api.loginFailure = null + viewModel.connect("https://musique.test", "admin", "secret") + advanceUntilIdle() + + assertTrue(viewModel.state.value.isConnected) + assertNull("l'échec précédent ne doit pas survivre", viewModel.state.value.errorMessage) + } + + @Test + fun `le message d'erreur se referme`() = runTest(mainDispatcherRule.dispatcher) { + val api = FakeServerApi(loginFailure = ServerException.Unauthorized("non")) + val viewModel = viewModel(api = api) + + viewModel.connect("https://musique.test", "admin", "faux") + advanceUntilIdle() + viewModel.dismissError() + + assertNull(viewModel.state.value.errorMessage) + } + + @Test + fun `la deconnexion ramene au formulaire`() = runTest(mainDispatcherRule.dispatcher) { + val viewModel = viewModel() + viewModel.connect("https://musique.test", "admin", "secret") + advanceUntilIdle() + + viewModel.disconnect() + advanceUntilIdle() + + assertFalse(viewModel.state.value.isConnected) + assertNull(viewModel.state.value.errorMessage) + } + + @Test + fun `un stockage en echec pendant la connexion devient un message, pas un crash`() = + runTest(mainDispatcherRule.dispatcher) { + // Le serveur a accepté, c'est l'enregistrement qui échoue : rien ne + // rattraperait l'exception hors de `viewModelScope`. + val store = FakeSessionStore(writeFailure = IOException("disque plein")) + val viewModel = viewModel(store = store) + + viewModel.connect("https://musique.test", "admin", "secret") + advanceUntilIdle() + + assertEquals( + "La session n'a pas pu être enregistrée sur l'appareil.", + viewModel.state.value.errorMessage, + ) + assertFalse("le bouton doit redevenir actif", viewModel.state.value.isConnecting) + } + + @Test + fun `un stockage en echec pendant la deconnexion devient un message, pas un crash`() = + runTest(mainDispatcherRule.dispatcher) { + // L'exception quitterait `viewModelScope` et ferait tomber l'app. + val store = FakeSessionStore(writeFailure = IOException("disque plein")) + val viewModel = viewModel(store = store) + + viewModel.disconnect() + advanceUntilIdle() + + assertEquals( + "La déconnexion n'a pas pu être enregistrée.", + viewModel.state.value.errorMessage, + ) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8d91144..b83d84f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -15,6 +15,9 @@ ksp = "2.3.11" coroutinesTest = "1.11.0" robolectric = "4.16.1" androidxTestCore = "1.7.0" +okhttp = "4.12.0" +serialization = "1.7.3" +datastore = "1.1.7" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -56,8 +59,19 @@ coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coi # Palette — couleur dominante extraite de la pochette (fond du lecteur) androidx-palette = { group = "androidx.palette", name = "palette-ktx", version.ref = "palette" } +# Serveur WaveFlow — OkHttp arrivait déjà par Coil, déclaré ici parce qu'on +# s'en sert directement. `mockwebserver` sert les tests du client HTTP. +okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } +okhttp-mockwebserver = { group = "com.squareup.okhttp3", name = "mockwebserver", version.ref = "okhttp" } +kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serialization" } + +# DataStore — jetons de session, hors de Room : ce n'est pas de la donnée +# relationnelle et ça ne doit pas voyager avec les sauvegardes de playlists. +androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } + [plugins] android-application = { id = "com.android.application", version.ref = "agp" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }