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