From a65065d268e1dfe96f81a85982b864e98d0f3647 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Mon, 10 Aug 2026 13:41:37 +0200 Subject: [PATCH 1/3] =?UTF-8?q?feat(serveur):=20se=20connecter=20=C3=A0=20?= =?UTF-8?q?un=20serveur=20WaveFlow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Premier pas de la source distante : un onglet Serveur qui ouvre une session et la maintient. Rien du catalogue distant n'est encore affiché, et rien de la bibliothèque locale ne part nulle part — les deux sources restent séparées, d'où une section à part plutôt qu'un filtre sur les écrans existants. La connexion poste sur `/api/v2/auth/login` avec le modèle de l'appareil comme nom de session, que le serveur liste parmi les appareils du compte. Le jeton d'accès vaut un quart d'heure et se renouvelle par `/api/v2/auth/refresh`. Ce renouvellement est le point délicat : le jeton de rafraîchissement tourne à chaque usage, le serveur invalidant l'ancien dès qu'il en émet un nouveau. Deux renouvellements concurrents partiraient donc du même jeton, et le second serait refusé — une session perdue alors qu'elle était valide. Tout ce qui touche aux jetons passe par un seul mutex, et l'écriture sur disque précède la mise à jour de l'état en mémoire. Les échecs sont classés selon ce que l'utilisateur peut en faire : un refus d'identifiants demande une ressaisie, un serveur injoignable seulement de réessayer, et le renouvellement ne ferme la session que dans le premier cas. Les jetons vont dans un DataStore, protégés par le bac à sable applicatif et non par du chiffrement : `security-crypto` n'est jamais sorti d'alpha et n'est plus maintenu. Le compromis est documenté dans le code et le README. Le trafic en clair est autorisé, un serveur auto-hébergé vivant le plus souvent sur un réseau local sans certificat. On aimerait restreindre aux plages privées, mais `` n'accepte pas la notation CIDR ; une adresse saisie sans schéma est donc jointe en HTTPS. Validé contre un waveflow-server 2.0.0-beta.0 local : connexion, rotation, rejeu refusé, mauvais mot de passe, nom d'appareil vide, déconnexion, et le renouvellement automatique du dépôt. Claude-Session: https://claude.ai/code/session_01F89rkrDB9TxcwHbfgNoyY1 --- README.md | 66 +++-- app/build.gradle.kts | 7 + app/src/main/AndroidManifest.xml | 4 + .../main/java/app/waveflow/MainActivity.kt | 14 ++ app/src/main/java/app/waveflow/WaveFlowApp.kt | 24 ++ .../main/java/app/waveflow/data/remote/Dto.kt | 45 ++++ .../app/waveflow/data/remote/HttpServerApi.kt | 168 +++++++++++++ .../app/waveflow/data/remote/ServerApi.kt | 65 +++++ .../data/remote/ServerSessionRepository.kt | 134 ++++++++++ .../app/waveflow/data/remote/SessionStore.kt | 95 +++++++ .../java/app/waveflow/model/ServerSession.kt | 35 +++ .../ui/navigation/WaveFlowNavigation.kt | 10 +- .../app/waveflow/ui/server/ServerScreen.kt | 236 ++++++++++++++++++ .../app/waveflow/ui/server/ServerUiState.kt | 20 ++ .../app/waveflow/ui/server/ServerViewModel.kt | 95 +++++++ .../main/res/xml/network_security_config.xml | 15 ++ .../waveflow/data/remote/HttpServerApiTest.kt | 210 ++++++++++++++++ .../remote/ServerSessionRepositoryTest.kt | 206 +++++++++++++++ .../java/app/waveflow/testing/ServerFakes.kt | 90 +++++++ .../waveflow/ui/server/ServerScreenTest.kt | 142 +++++++++++ .../waveflow/ui/server/ServerViewModelTest.kt | 149 +++++++++++ gradle/libs.versions.toml | 14 ++ 22 files changed, 1826 insertions(+), 18 deletions(-) create mode 100644 app/src/main/java/app/waveflow/data/remote/Dto.kt create mode 100644 app/src/main/java/app/waveflow/data/remote/HttpServerApi.kt create mode 100644 app/src/main/java/app/waveflow/data/remote/ServerApi.kt create mode 100644 app/src/main/java/app/waveflow/data/remote/ServerSessionRepository.kt create mode 100644 app/src/main/java/app/waveflow/data/remote/SessionStore.kt create mode 100644 app/src/main/java/app/waveflow/model/ServerSession.kt create mode 100644 app/src/main/java/app/waveflow/ui/server/ServerScreen.kt create mode 100644 app/src/main/java/app/waveflow/ui/server/ServerUiState.kt create mode 100644 app/src/main/java/app/waveflow/ui/server/ServerViewModel.kt create mode 100644 app/src/main/res/xml/network_security_config.xml create mode 100644 app/src/test/java/app/waveflow/data/remote/HttpServerApiTest.kt create mode 100644 app/src/test/java/app/waveflow/data/remote/ServerSessionRepositoryTest.kt create mode 100644 app/src/test/java/app/waveflow/testing/ServerFakes.kt create mode 100644 app/src/test/java/app/waveflow/ui/server/ServerScreenTest.kt create mode 100644 app/src/test/java/app/waveflow/ui/server/ServerViewModelTest.kt diff --git a/README.md b/README.md index c8f1806..ee76eda 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,44 @@ 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. + +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 @@ + + + "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..1cffef4 --- /dev/null +++ b/app/src/main/java/app/waveflow/data/remote/HttpServerApi.kt @@ -0,0 +1,168 @@ +package app.waveflow.data.remote + +import kotlinx.coroutines.CancellableContinuation +import kotlinx.coroutines.Dispatchers +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.resume +import kotlin.coroutines.resumeWithException + +/** + * 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 = OkHttpClient(), +) : 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() + + // OkHttp bloque sur `execute()` ; l'appel asynchrone est enveloppé pour + // rester annulable, et le dispatcher IO couvre la lecture du corps. + val response = withContext(Dispatchers.IO) { client.newCall(request).await() } + + return response.use { + if (it.isSuccessful) it.body?.string().orEmpty() else throw it.toException() + } + } + + /** + * 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}", + ) + } + + 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" + } +} + +/** + * 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) { + continuation.resume(response) + } + + override fun onFailure(call: Call, e: IOException) { + continuation.resumeIfActive( + ServerException.Unreachable(e.message ?: "Serveur injoignable."), + ) + } + }) + 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..ee2fc36 --- /dev/null +++ b/app/src/main/java/app/waveflow/data/remote/ServerApi.kt @@ -0,0 +1,65 @@ +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) : Exception(message) { + + /** 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) : ServerException(message) + + /** Panne côté serveur, ou réponse que le client ne sait pas lire. */ + class Unexpected(message: String) : ServerException(message) +} 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..7b2df4f --- /dev/null +++ b/app/src/main/java/app/waveflow/data/remote/SessionStore.kt @@ -0,0 +1,95 @@ +package app.waveflow.data.remote + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +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.first +import kotlinx.coroutines.flow.map + +/** + * 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.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 { + 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..43f86b6 --- /dev/null +++ b/app/src/main/java/app/waveflow/ui/server/ServerScreen.kt @@ -0,0 +1,236 @@ +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.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.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) } + + 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, + ), + 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(), + ) + } + + Spacer(Modifier.height(24.dp)) + Button( + onClick = { onConnect(serverUrl, username, password) }, + 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..03a716d --- /dev/null +++ b/app/src/main/java/app/waveflow/ui/server/ServerViewModel.kt @@ -0,0 +1,95 @@ +package app.waveflow.ui.server + +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(), + ) + } + } + } + + fun disconnect() { + viewModelScope.launch { + sessionRepository.disconnect() + local.value = ServerUiState() + } + } + + 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 { + 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/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..ea0f7ad --- /dev/null +++ b/app/src/test/java/app/waveflow/data/remote/HttpServerApiTest.kt @@ -0,0 +1,210 @@ +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 `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 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..c41ca14 --- /dev/null +++ b/app/src/test/java/app/waveflow/data/remote/ServerSessionRepositoryTest.kt @@ -0,0 +1,206 @@ +package app.waveflow.data.remote + +import app.waveflow.model.ServerSession +import app.waveflow.testing.FakeServerApi +import app.waveflow.testing.FakeSessionStore +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. + */ +@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 `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..245f259 --- /dev/null +++ b/app/src/test/java/app/waveflow/testing/ServerFakes.kt @@ -0,0 +1,90 @@ +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 + +/** + * 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( + private val loginFailure: Throwable? = null, + private val refreshFailure: Throwable? = null, + private val logoutFailure: Throwable? = null, +) : ServerApi { + + var refreshCalls = 0 + private set + var lastDeviceName: String? = null + private set + var revokedAccessToken: String? = null + private set + + private var generation = 0 + + override suspend fun login( + serverUrl: String, + username: String, + password: String, + deviceName: String, + ): AuthTokens { + lastDeviceName = deviceName + loginFailure?.let { throw it } + return nextTokens(username) + } + + override suspend fun refresh(serverUrl: String, refreshToken: String): AuthTokens { + refreshCalls++ + refreshFailure?.let { throw it } + return nextTokens("admin") + } + + 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, +) : 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 -> { + stored = session + written = session + } + } + } + + override suspend fun clear() { + 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..a8c5a05 --- /dev/null +++ b/app/src/test/java/app/waveflow/ui/server/ServerScreenTest.kt @@ -0,0 +1,142 @@ +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 portant exactement ce texte. */ + private fun ComposeContentTestRule.occurrencesDe(text: String): Int = + onAllNodes(hasText(text)).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..fa3e313 --- /dev/null +++ b/app/src/test/java/app/waveflow/ui/server/ServerViewModelTest.kt @@ -0,0 +1,149 @@ +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 + +@OptIn(ExperimentalCoroutinesApi::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) { + val viewModel = viewModel(api = FakeServerApi()) + val refuse = viewModel(api = FakeServerApi(loginFailure = ServerException.Unauthorized("non"))) + + refuse.connect("https://musique.test", "admin", "faux") + advanceUntilIdle() + assertFalse("le bouton doit redevenir actif", refuse.state.value.isConnecting) + + viewModel.connect("https://musique.test", "admin", "secret") + advanceUntilIdle() + assertTrue(viewModel.state.value.isConnected) + } + + @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) + } +} 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" } From 94d015f37175a26b1780f59f278fc828a87251c3 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Mon, 10 Aug 2026 15:58:28 +0200 Subject: [PATCH 2/3] fix(serveur): suites de la revue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Corrections retenues, par ordre de gravité. L'onglet Serveur était inatteignable sans la permission audio : la porte enveloppait tout le NavHost. Elle enveloppe désormais chaque écran qui lit la bibliothèque de l'appareil, pas le NavHost lui-même — le sortir de la porte l'aurait recomposé dans un autre sous-arbre et lui aurait fait perdre sa pile de navigation. Les deux effets qu'elle déclenchait sont idempotents, donc sans conséquence à être relancés par écran. Un stockage en échec pendant la déconnexion faisait tomber l'application : l'exception quittait `viewModelScope`. Même filet que dans PlaylistsViewModel. Une session illisible ou corrompue faisait de même au démarrage : `read()` rattrape l'IOException et repart déconnecté plutôt que d'empêcher le lancement. Les jetons partaient dans les sauvegardes cloud et le transfert d'appareil. Exclus des deux : stockés en clair, un jeton de rafraîchissement restauré ailleurs y ouvrirait le compte. La lecture du corps de réponse passe désormais sous le dispatcher IO avec l'appel lui-même, et une annulation survenant entre l'arrivée de la réponse et sa remise la referme au lieu de retenir la connexion. Un `callTimeout` borne l'appel entier : les délais par défaut d'OkHttp ne portent que sur chaque étape prise à part. `ServerException` transporte sa cause, pour que les journaux gardent la trace d'origine. « Terminé » au clavier valide le formulaire, et le message d'erreur devient une région active pour que TalkBack l'annonce. Côté tests, deux étaient creux et sont corrigés : la reprise après échec utilisait deux ViewModels au lieu d'un seul, et la recherche de jetons à l'écran comparait en égalité stricte, laissant passer un jeton glissé dans une phrase. S'ajoutent la branche 5xx, la rotation vérifiée sur deux renouvellements successifs, et surtout deux demandes concurrentes de jeton : sans le mutex, ce dernier tombe — la sérialisation n'était couverte par rien jusqu'ici. Écartés : sortir `api.login` du mutex, qui échangerait un invariant du dépôt contre une garde d'interface ; chiffrer les jetons par le Keystore et interdire le trafic en clair, deux arbitrages déjà documentés et qui reviennent à l'utilisateur ; partager le client OkHttp de Coil, dont la configuration ne vise pas les appels d'API. Claude-Session: https://claude.ai/code/session_01F89rkrDB9TxcwHbfgNoyY1 --- README.md | 4 +- .../main/java/app/waveflow/MainActivity.kt | 203 ++++++++++-------- .../app/waveflow/data/remote/HttpServerApi.kt | 41 ++-- .../app/waveflow/data/remote/ServerApi.kt | 11 +- .../app/waveflow/data/remote/SessionStore.kt | 21 +- .../app/waveflow/ui/server/ServerScreen.kt | 19 +- .../app/waveflow/ui/server/ServerViewModel.kt | 19 +- app/src/main/res/xml/backup_rules.xml | 20 +- .../main/res/xml/data_extraction_rules.xml | 24 +-- .../waveflow/data/remote/HttpServerApiTest.kt | 15 ++ .../remote/ServerSessionRepositoryTest.kt | 49 +++++ .../java/app/waveflow/testing/ServerFakes.kt | 26 ++- .../waveflow/ui/server/ServerScreenTest.kt | 9 +- .../waveflow/ui/server/ServerViewModelTest.kt | 39 +++- 14 files changed, 358 insertions(+), 142 deletions(-) diff --git a/README.md b/README.md index ee76eda..cd96b0a 100644 --- a/README.md +++ b/README.md @@ -176,7 +176,9 @@ 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. +is that it can be revoked from the server. The file is excluded from cloud +backup and device transfer, so a token never leaves the device it was issued +to; signing in again simply asks for the password. 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. diff --git a/app/src/main/java/app/waveflow/MainActivity.kt b/app/src/main/java/app/waveflow/MainActivity.kt index d8986e9..76cc5a2 100644 --- a/app/src/main/java/app/waveflow/MainActivity.kt +++ b/app/src/main/java/app/waveflow/MainActivity.kt @@ -243,25 +243,40 @@ private fun WaveFlowRoot() { ) }, ) { innerPadding -> - 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. + */ + val gated: @Composable (@Composable () -> Unit) -> Unit = { content -> + AudioPermissionGate(modifier = Modifier.padding(innerPadding)) { + // 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, @@ -271,8 +286,10 @@ private fun WaveFlowRoot() { onSongLongClick = { songToAdd = it }, ) } + } - composable(Routes.ALBUMS) { + composable(Routes.ALBUMS) { + gated { AlbumsScreen( library = library, onAlbumClick = { navController.navigate(Routes.albumDetail(it.id)) }, @@ -280,8 +297,10 @@ private fun WaveFlowRoot() { bottomPadding = listBottomPadding, ) } + } - composable(Routes.ARTISTS) { + composable(Routes.ARTISTS) { + gated { ArtistsScreen( library = library, onArtistClick = { navController.navigate(Routes.artistDetail(it.id)) }, @@ -289,17 +308,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, @@ -311,14 +332,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, @@ -330,17 +353,19 @@ private fun WaveFlowRoot() { onSongLongClick = { songToAdd = it }, ) } + } - composable(Routes.SERVER) { - ServerScreen( - state = serverState, - onConnect = serverViewModel::connect, - onDisconnect = serverViewModel::disconnect, - bottomPadding = listBottomPadding, - ) - } + composable(Routes.SERVER) { + ServerScreen( + state = serverState, + onConnect = serverViewModel::connect, + onDisconnect = serverViewModel::disconnect, + bottomPadding = listBottomPadding, + ) + } - composable(Routes.PLAYLISTS) { + composable(Routes.PLAYLISTS) { + gated { PlaylistsScreen( state = playlistsState, onPlaylistClick = { navController.navigate(Routes.playlistDetail(it.id)) }, @@ -348,14 +373,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, @@ -371,49 +398,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), + ) + } } } 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 1cffef4..584cf96 100644 --- a/app/src/main/java/app/waveflow/data/remote/HttpServerApi.kt +++ b/app/src/main/java/app/waveflow/data/remote/HttpServerApi.kt @@ -17,8 +17,9 @@ import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response import java.io.IOException -import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException +import kotlin.time.Duration.Companion.seconds +import kotlin.time.toJavaDuration /** * Client HTTP du serveur WaveFlow. @@ -28,7 +29,7 @@ import kotlin.coroutines.resumeWithException * ajoutera, et une réponse enrichie ne doit pas casser une version installée. */ class HttpServerApi( - private val client: OkHttpClient = OkHttpClient(), + private val client: OkHttpClient = defaultClient(), ) : ServerApi { private val json = Json { ignoreUnknownKeys = true } @@ -70,12 +71,12 @@ class HttpServerApi( .apply { accessToken?.let { header("Authorization", "Bearer $it") } } .build() - // OkHttp bloque sur `execute()` ; l'appel asynchrone est enveloppé pour - // rester annulable, et le dispatcher IO couvre la lecture du corps. - val response = withContext(Dispatchers.IO) { client.newCall(request).await() } - - return response.use { - if (it.isSuccessful) it.body?.string().orEmpty() else throw it.toException() + // 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) { + client.newCall(request).await().use { + if (it.isSuccessful) it.body?.string().orEmpty() else throw it.toException() + } } } @@ -125,9 +126,7 @@ class HttpServerApi( ) } } catch (error: SerializationException) { - throw ServerException.Unexpected( - "Réponse illisible du serveur : ${error.message}", - ) + throw ServerException.Unexpected("Réponse illisible du serveur : ${error.message}", error) } private companion object { @@ -135,6 +134,17 @@ class HttpServerApi( 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() } } @@ -147,12 +157,17 @@ class HttpServerApi( private suspend fun Call.await(): Response = suspendCancellableCoroutine { continuation -> enqueue(object : Callback { override fun onResponse(call: Call, response: Response) { - continuation.resume(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."), + ServerException.Unreachable(e.message ?: "Serveur injoignable.", e), ) } }) diff --git a/app/src/main/java/app/waveflow/data/remote/ServerApi.kt b/app/src/main/java/app/waveflow/data/remote/ServerApi.kt index ee2fc36..8a382eb 100644 --- a/app/src/main/java/app/waveflow/data/remote/ServerApi.kt +++ b/app/src/main/java/app/waveflow/data/remote/ServerApi.kt @@ -49,7 +49,10 @@ data class AuthTokens( * 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) : Exception(message) { +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) @@ -58,8 +61,10 @@ sealed class ServerException(message: String) : Exception(message) { class Rejected(message: String) : ServerException(message) /** Serveur injoignable, TLS invalide, coupure en cours de route. */ - class Unreachable(message: String) : ServerException(message) + 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) : ServerException(message) + class Unexpected(message: String, cause: Throwable? = null) : + ServerException(message, cause) } diff --git a/app/src/main/java/app/waveflow/data/remote/SessionStore.kt b/app/src/main/java/app/waveflow/data/remote/SessionStore.kt index 7b2df4f..d810840 100644 --- a/app/src/main/java/app/waveflow/data/remote/SessionStore.kt +++ b/app/src/main/java/app/waveflow/data/remote/SessionStore.kt @@ -1,15 +1,19 @@ 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. @@ -38,7 +42,20 @@ class DataStoreSessionStore(context: Context) : SessionStore { private val dataStore: DataStore = context.applicationContext.sessionDataStore - override suspend fun read(): ServerSession = dataStore.data.map { it.toSession() }.first() + 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) { @@ -81,6 +98,8 @@ class DataStoreSessionStore(context: Context) : SessionStore { } private companion object { + const val TAG = "SessionStore" + val SERVER_URL = stringPreferencesKey("server_url") val USERNAME = stringPreferencesKey("username") val ACCESS_TOKEN = stringPreferencesKey("access_token") 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 43f86b6..3f30763 100644 --- a/app/src/main/java/app/waveflow/ui/server/ServerScreen.kt +++ b/app/src/main/java/app/waveflow/ui/server/ServerScreen.kt @@ -9,6 +9,7 @@ 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 @@ -32,6 +33,9 @@ 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 @@ -83,6 +87,10 @@ private fun ConnectionForm( 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, @@ -145,6 +153,9 @@ private fun ConnectionForm( 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( @@ -170,13 +181,17 @@ private fun ConnectionForm( text = message, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error, - modifier = Modifier.fillMaxWidth(), + 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 = { onConnect(serverUrl, username, password) }, + onClick = { submit() }, enabled = !state.isConnecting, modifier = Modifier.fillMaxWidth(), ) { diff --git a/app/src/main/java/app/waveflow/ui/server/ServerViewModel.kt b/app/src/main/java/app/waveflow/ui/server/ServerViewModel.kt index 03a716d..92e4d93 100644 --- a/app/src/main/java/app/waveflow/ui/server/ServerViewModel.kt +++ b/app/src/main/java/app/waveflow/ui/server/ServerViewModel.kt @@ -1,5 +1,6 @@ package app.waveflow.ui.server +import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope @@ -67,8 +68,20 @@ class ServerViewModel( fun disconnect() { viewModelScope.launch { - sessionRepository.disconnect() - local.value = ServerUiState() + 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.", + ) + } } } @@ -84,6 +97,8 @@ class ServerViewModel( } companion object { + private const val TAG = "ServerViewModel" + val Factory: ViewModelProvider.Factory = viewModelFactory { initializer { val app = this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] 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/test/java/app/waveflow/data/remote/HttpServerApiTest.kt b/app/src/test/java/app/waveflow/data/remote/HttpServerApiTest.kt index ea0f7ad..ca72b16 100644 --- a/app/src/test/java/app/waveflow/data/remote/HttpServerApiTest.kt +++ b/app/src/test/java/app/waveflow/data/remote/HttpServerApiTest.kt @@ -106,6 +106,21 @@ class HttpServerApiTest { 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)) diff --git a/app/src/test/java/app/waveflow/data/remote/ServerSessionRepositoryTest.kt b/app/src/test/java/app/waveflow/data/remote/ServerSessionRepositoryTest.kt index c41ca14..bbc8406 100644 --- a/app/src/test/java/app/waveflow/data/remote/ServerSessionRepositoryTest.kt +++ b/app/src/test/java/app/waveflow/data/remote/ServerSessionRepositoryTest.kt @@ -3,6 +3,10 @@ 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 @@ -20,6 +24,7 @@ import org.robolectric.RobolectricTestRunner * 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 { @@ -142,6 +147,50 @@ class ServerSessionRepositoryTest { 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é")) diff --git a/app/src/test/java/app/waveflow/testing/ServerFakes.kt b/app/src/test/java/app/waveflow/testing/ServerFakes.kt index 245f259..7ecae7c 100644 --- a/app/src/test/java/app/waveflow/testing/ServerFakes.kt +++ b/app/src/test/java/app/waveflow/testing/ServerFakes.kt @@ -4,6 +4,7 @@ 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é. @@ -13,19 +14,31 @@ import app.waveflow.model.ServerSession * qu'une valeur constante ne permettrait pas. */ class FakeServerApi( - private val loginFailure: Throwable? = null, + /** 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, @@ -35,13 +48,18 @@ class FakeServerApi( ): 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 } - return nextTokens("admin") + // 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) { @@ -64,6 +82,8 @@ class FakeServerApi( /** 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 @@ -77,6 +97,7 @@ class FakeSessionStore( when (session) { is ServerSession.Disconnected -> clear() is ServerSession.Connected -> { + writeFailure?.let { throw it } stored = session written = session } @@ -84,6 +105,7 @@ class FakeSessionStore( } 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 index a8c5a05..46c1f33 100644 --- a/app/src/test/java/app/waveflow/ui/server/ServerScreenTest.kt +++ b/app/src/test/java/app/waveflow/ui/server/ServerScreenTest.kt @@ -49,9 +49,14 @@ class ServerScreenTest { } } - /** Nombre de nœuds portant exactement ce texte. */ + /** + * 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)).fetchSemanticsNodes().size + onAllNodes(hasText(text, substring = true)).fetchSemanticsNodes().size @Test fun `sans session l'ecran propose le formulaire`() { diff --git a/app/src/test/java/app/waveflow/ui/server/ServerViewModelTest.kt b/app/src/test/java/app/waveflow/ui/server/ServerViewModelTest.kt index fa3e313..0fd77d0 100644 --- a/app/src/test/java/app/waveflow/ui/server/ServerViewModelTest.kt +++ b/app/src/test/java/app/waveflow/ui/server/ServerViewModelTest.kt @@ -15,8 +15,16 @@ 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 @@ -110,16 +118,21 @@ class ServerViewModelTest { @Test fun `un echec n'empeche pas une nouvelle tentative`() = runTest(mainDispatcherRule.dispatcher) { - val viewModel = viewModel(api = FakeServerApi()) - val refuse = viewModel(api = FakeServerApi(loginFailure = ServerException.Unauthorized("non"))) + // 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) - refuse.connect("https://musique.test", "admin", "faux") + viewModel.connect("https://musique.test", "admin", "faux") advanceUntilIdle() - assertFalse("le bouton doit redevenir actif", refuse.state.value.isConnecting) + 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 @@ -146,4 +159,20 @@ class ServerViewModelTest { assertFalse(viewModel.state.value.isConnected) assertNull(viewModel.state.value.errorMessage) } + + @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, + ) + } } From 55ed9b54afda1e1dac1dbd3d5d9218db044cba70 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Mon, 10 Aug 2026 16:17:15 +0200 Subject: [PATCH 3/3] fix(serveur): suites de la seconde revue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deux crashs de la même famille que ceux déjà corrigés, tous deux sur un chemin d'échec que rien ne rattrapait. Une coupure survenant *pendant* la lecture du corps lève depuis `string()`, et non depuis le rappel d'échec de l'appel : l'IOException nue traversait toute la pile jusqu'à un `viewModelScope` qui n'attend que des `ServerException`. Elle devient un `Unreachable`, avec sa cause. La coroutine est vérifiée active avant la conversion : OkHttp signale aussi l'annulation par une IOException, et la reconvertir masquerait l'abandon de l'écran. `connect` ne rattrapait que les `ServerException`. Le serveur peut accepter la connexion et l'enregistrement échouer ensuite ; l'exception quittait alors la portée. Même filet que `disconnect`. La porte de permission recevait les marges du Scaffold alors que le Box qui l'englobe les pose déjà : le message de refus les prenait deux fois. Le README affirmait qu'un jeton « ne quitte jamais l'appareil », ce qui est faux — il part à chaque requête authentifiée. Ce qui est vrai, et seulement cela, c'est qu'il n'est pas recopié sur un autre appareil par une sauvegarde. Claude-Session: https://claude.ai/code/session_01F89rkrDB9TxcwHbfgNoyY1 --- README.md | 4 ++-- .../main/java/app/waveflow/MainActivity.kt | 6 ++++- .../app/waveflow/data/remote/HttpServerApi.kt | 22 +++++++++++++++++-- .../app/waveflow/ui/server/ServerViewModel.kt | 9 ++++++++ .../waveflow/data/remote/HttpServerApiTest.kt | 16 ++++++++++++++ .../waveflow/ui/server/ServerViewModelTest.kt | 18 +++++++++++++++ 6 files changed, 70 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index cd96b0a..010f10e 100644 --- a/README.md +++ b/README.md @@ -177,8 +177,8 @@ 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 token never leaves the device it was issued -to; signing in again simply asks for the password. +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. diff --git a/app/src/main/java/app/waveflow/MainActivity.kt b/app/src/main/java/app/waveflow/MainActivity.kt index 76cc5a2..ba7c850 100644 --- a/app/src/main/java/app/waveflow/MainActivity.kt +++ b/app/src/main/java/app/waveflow/MainActivity.kt @@ -251,9 +251,13 @@ private fun WaveFlowRoot() { * 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(modifier = Modifier.padding(innerPadding)) { + 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é. 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 584cf96..2d0010d 100644 --- a/app/src/main/java/app/waveflow/data/remote/HttpServerApi.kt +++ b/app/src/main/java/app/waveflow/data/remote/HttpServerApi.kt @@ -2,6 +2,8 @@ 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 @@ -74,8 +76,24 @@ class HttpServerApi( // 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) { - client.newCall(request).await().use { - if (it.isSuccessful) it.body?.string().orEmpty() else throw it.toException() + 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, + ) } } } diff --git a/app/src/main/java/app/waveflow/ui/server/ServerViewModel.kt b/app/src/main/java/app/waveflow/ui/server/ServerViewModel.kt index 92e4d93..719a8c7 100644 --- a/app/src/main/java/app/waveflow/ui/server/ServerViewModel.kt +++ b/app/src/main/java/app/waveflow/ui/server/ServerViewModel.kt @@ -62,6 +62,15 @@ class ServerViewModel( 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.", + ) } } } diff --git a/app/src/test/java/app/waveflow/data/remote/HttpServerApiTest.kt b/app/src/test/java/app/waveflow/data/remote/HttpServerApiTest.kt index ca72b16..a045467 100644 --- a/app/src/test/java/app/waveflow/data/remote/HttpServerApiTest.kt +++ b/app/src/test/java/app/waveflow/data/remote/HttpServerApiTest.kt @@ -130,6 +130,22 @@ class HttpServerApiTest { 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"}""")) diff --git a/app/src/test/java/app/waveflow/ui/server/ServerViewModelTest.kt b/app/src/test/java/app/waveflow/ui/server/ServerViewModelTest.kt index 0fd77d0..ee9d33b 100644 --- a/app/src/test/java/app/waveflow/ui/server/ServerViewModelTest.kt +++ b/app/src/test/java/app/waveflow/ui/server/ServerViewModelTest.kt @@ -160,6 +160,24 @@ class ServerViewModelTest { 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) {