Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,8 @@ in-memory SQLite for Room, so the DAO is exercised without a device.
| `CatalogViewModelTest` | paging, end of list, in-flight guard, clear on sign-out |
| `MediaItemMapperTest` | local vs remote track identity, unreachable marker URI |
| `RemoteStreamResolverTest` | ticket swap, local passthrough, DataSpec preserved |
| `ArtworkUrlsTest` | URL only when a cover exists, proxy prefix, invalid address |
| `ServerImageAuthInterceptorTest` | signing scope, third-party host, refresh on 401 |

Fakes and the `Dispatchers.Main` rule live in `src/test/java/app/waveflow/testing/`.

Expand Down Expand Up @@ -186,9 +188,19 @@ and a long queue would outlast it before reaching its last tracks. A
player and one queue mechanism.

Listing endpoints return a bare array — no total, no cursor — so the end of a
list is inferred from a page shorter than requested. Cover art is not shown:
the v2 API exposes an `artwork_hash` but no endpoint serving the image; only
the Subsonic facade does, behind its own separate credential.
list is inferred from a page shorter than requested.

Cover art comes from `/api/v2/artwork/{artwork_hash}`, behind the same bearer as
the rest of the native API. Coil knows nothing about the session, so an
interceptor signs those requests — and only those: an origin other than the
connected server's (scheme, host and port) is never handed the token.

The URL is keyed on the hash rather than on the entity id, which the endpoint
would also accept. The hash names the content, so replacing a cover changes the
URL and the stale image is not served from cache for a day; and an album and its
tracks share one hash, hence one cache entry and one download instead of one per
row. No hash means no cover, and no URL — otherwise every coverless row would
cost a 404.

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
Expand Down
21 changes: 20 additions & 1 deletion app/src/main/java/app/waveflow/WaveFlowApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,15 @@ import app.waveflow.data.MusicRepository
import app.waveflow.data.PlaylistRepository
import app.waveflow.data.RoomPlaylistRepository
import app.waveflow.data.local.WaveFlowDatabase
import coil.ImageLoader
import coil.ImageLoaderFactory
import okhttp3.OkHttpClient
import app.waveflow.data.remote.CatalogRepository
import app.waveflow.data.remote.DataStoreSessionStore
import app.waveflow.data.remote.HttpCatalogApi
import app.waveflow.data.remote.HttpServerApi
import app.waveflow.data.remote.ServerHttp
import app.waveflow.data.remote.ServerImageAuthInterceptor
import app.waveflow.data.remote.ServerSessionRepository
import app.waveflow.playback.Media3PlaybackController
import app.waveflow.playback.PlaybackController
Expand All @@ -28,7 +32,7 @@ import kotlinx.coroutines.launch
* un simple conteneur suffit tant que le graphe reste petit. On migrera vers
* Hilt quand le nombre de dépendances le justifiera.
*/
class WaveFlowApp : Application() {
class WaveFlowApp : Application(), ImageLoaderFactory {
lateinit var container: AppContainer
private set

Expand All @@ -37,6 +41,21 @@ class WaveFlowApp : Application() {
container = AppContainer(this)
container.restoreServerSession()
}

/**
* Chargeur d'images unique, partagé par les pochettes locales et distantes.
*
* Les secondes viennent de `/api/v2/artwork/`, derrière le même jeton que le
* reste de l'API : Coil ne connaît rien de la session, c'est l'intercepteur
* qui la lui apporte.
*/
override fun newImageLoader(): ImageLoader = ImageLoader.Builder(this)
.okHttpClient {
OkHttpClient.Builder()
.addInterceptor(ServerImageAuthInterceptor(container.serverSessionRepository))
.build()
}
.build()
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

/** Conteneur d'objets partagés à l'échelle de l'application. */
Expand Down
44 changes: 44 additions & 0 deletions app/src/main/java/app/waveflow/data/remote/ArtworkUrls.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package app.waveflow.data.remote

import android.net.Uri
import androidx.core.net.toUri

/**
* Construit les adresses de pochettes d'un serveur.
*
* L'adresse est bâtie sur le **hachage** de la pochette, et non sur
* l'identifiant de l'entité qui la porte — `/api/v2/artwork/` accepte les deux.
* Le hachage désigne le contenu, ce qui donne deux propriétés que l'identifiant
* n'a pas :
*
* - remplacer une jaquette change le hachage, donc l'adresse, donc la clé de
* cache. Sur l'identifiant, l'ancienne image resterait affichée aussi
* longtemps que le cache la garde — le serveur annonce `max-age=86400` ;
* - un album et ses pistes portent le même hachage. Une seule entrée de cache
* et un seul téléchargement, là où l'identifiant en aurait produit autant que
* de lignes affichées.
*
* Une entité sans hachage n'a pas de pochette : aucune adresse n'est produite.
* En construire une coûterait un aller-retour par ligne pour un 404 à chaque
* fois, à chaque défilement.
*/
class ArtworkUrls(private val serverUrl: String, private val http: ServerHttp) {

fun forHash(artworkHash: String?): Uri? {
if (artworkHash.isNullOrBlank()) return null

// Le hachage est un segment à part entière, et non interpolé : il vient
// d'une réponse serveur, et un `/` qui s'y glisserait désignerait un
// autre point d'API.
//
// Construite en plein rendu d'une liste : une adresse de serveur
// invalide doit coûter une vignette, pas l'écran entier.
return runCatching {
http.absoluteUrl(serverUrl, "/$PATH", pathSegment = artworkHash).toUri()
}.getOrNull()
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

private companion object {
const val PATH = "api/v2/artwork"
}
}
22 changes: 17 additions & 5 deletions app/src/main/java/app/waveflow/data/remote/Dto.kt
Original file line number Diff line number Diff line change
Expand Up @@ -60,13 +60,15 @@ internal data class AlbumResponse(
val artist: String? = null,
@SerialName("artist_id") val artistId: String? = null,
val year: Int? = null,
@SerialName("artwork_hash") val artworkHash: String? = null,
) {
fun toModel() = RemoteAlbum(
fun toModel(artwork: ArtworkUrls) = RemoteAlbum(
id = id,
title = title,
artist = artist,
artistId = artistId,
year = year,
artworkUri = artwork.forHash(artworkHash),
)
}

Expand All @@ -75,8 +77,14 @@ internal data class ArtistResponse(
val id: String,
val name: String,
@SerialName("album_count") val albumCount: Int? = null,
@SerialName("artwork_hash") val artworkHash: String? = null,
) {
fun toModel() = RemoteArtist(id = id, name = name, albumCount = albumCount)
fun toModel(artwork: ArtworkUrls) = RemoteArtist(
id = id,
name = name,
albumCount = albumCount,
artworkUri = artwork.forHash(artworkHash),
)
}

@Serializable
Expand All @@ -88,15 +96,17 @@ internal data class SongResponse(
val artist: String? = null,
val track: Int? = null,
@SerialName("duration_ms") val durationMs: Long,
@SerialName("artwork_hash") val artworkHash: String? = null,
) {
fun toModel() = RemoteSong(
fun toModel(artwork: ArtworkUrls) = RemoteSong(
id = id,
title = title,
album = album,
albumId = albumId,
artist = artist,
trackNumber = track,
durationMs = durationMs,
artworkUri = artwork.forHash(artworkHash),
)
}

Expand All @@ -107,10 +117,11 @@ internal data class AlbumDetailResponse(
val artist: String? = null,
@SerialName("artist_id") val artistId: String? = null,
val year: Int? = null,
@SerialName("artwork_hash") val artworkHash: String? = null,
val songs: List<SongResponse> = emptyList(),
) {
val album: AlbumResponse
get() = AlbumResponse(id, title, artist, artistId, year)
get() = AlbumResponse(id, title, artist, artistId, year, artworkHash)
}

/** `{"url": "/api/v2/stream/<ticket>", "expires_at": <ms>}` — l'URL est relative. */
Expand All @@ -125,8 +136,9 @@ internal data class ArtistDetailResponse(
val id: String,
val name: String,
@SerialName("album_count") val albumCount: Int? = null,
@SerialName("artwork_hash") val artworkHash: String? = null,
val albums: List<AlbumResponse> = emptyList(),
) {
val artist: ArtistResponse
get() = ArtistResponse(id, name, albumCount)
get() = ArtistResponse(id, name, albumCount, artworkHash)
}
16 changes: 10 additions & 6 deletions app/src/main/java/app/waveflow/data/remote/HttpCatalogApi.kt
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ class HttpCatalogApi(
path = ALBUMS,
query = pageQuery(offset, limit),
accessToken = accessToken,
).decode<List<AlbumResponse>>().map { it.toModel() }
).decode<List<AlbumResponse>>().map { it.toModel(artwork(serverUrl)) }

override suspend fun artists(
serverUrl: String,
Expand All @@ -33,7 +33,7 @@ class HttpCatalogApi(
path = ARTISTS,
query = pageQuery(offset, limit),
accessToken = accessToken,
).decode<List<ArtistResponse>>().map { it.toModel() }
).decode<List<ArtistResponse>>().map { it.toModel(artwork(serverUrl)) }

override suspend fun album(
serverUrl: String,
Expand All @@ -45,11 +45,12 @@ class HttpCatalogApi(
pathSegment = albumId,
accessToken = accessToken,
).decode<AlbumDetailResponse>().let { response ->
val artwork = artwork(serverUrl)
RemoteAlbumDetail(
album = response.album.toModel(),
album = response.album.toModel(artwork),
// Le serveur ne garantit pas l'ordre des morceaux d'un album ;
// le numéro de piste, lui, est ce que l'utilisateur attend.
songs = response.songs.map { it.toModel() }.sortedWith(BY_TRACK_THEN_TITLE),
songs = response.songs.map { it.toModel(artwork) }.sortedWith(BY_TRACK_THEN_TITLE),
)
}

Expand All @@ -63,9 +64,10 @@ class HttpCatalogApi(
pathSegment = artistId,
accessToken = accessToken,
).decode<ArtistDetailResponse>().let { response ->
val artwork = artwork(serverUrl)
RemoteArtistDetail(
artist = response.artist.toModel(),
albums = response.albums.map { it.toModel() },
artist = response.artist.toModel(artwork),
albums = response.albums.map { it.toModel(artwork) },
)
}

Expand All @@ -84,6 +86,8 @@ class HttpCatalogApi(
return http.absoluteUrl(serverUrl, ticket.url)
}

private fun artwork(serverUrl: String) = ArtworkUrls(serverUrl, http)

private fun pageQuery(offset: Int, limit: Int) = mapOf(
"offset" to offset.toString(),
"limit" to limit.toString(),
Expand Down
30 changes: 22 additions & 8 deletions app/src/main/java/app/waveflow/data/remote/ServerHttp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -119,12 +119,7 @@ class ServerHttp(
pathSegment: String?,
query: Map<String, 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")
val base = parseBase(this)

return base.newBuilder()
.addPathSegments(path)
Expand All @@ -145,14 +140,17 @@ class ServerHttp(
* Seul un chemin absolu du serveur est accepté. Une URL complète ou une
* référence réseau (`//hôte/…`) désignerait un autre hôte que celui où
* l'utilisateur s'est authentifié.
*
* @param pathSegment ajouté après [path], et encodé — un identifiant ou un
* hachage venu d'une réponse n'a pas à être interpolé dans le chemin.
*/
fun absoluteUrl(serverUrl: String, path: String): String {
fun absoluteUrl(serverUrl: String, path: String, pathSegment: String? = null): String {
if (!path.startsWith("/") || path.startsWith("//")) {
throw ServerException.Unexpected("Chemin de diffusion inattendu : $path")
}

return serverUrl
.toApiUrl(path = path.removePrefix("/"), pathSegment = null, query = emptyMap())
.toApiUrl(path = path.removePrefix("/"), pathSegment = pathSegment, query = emptyMap())
.toString()
}

Expand All @@ -173,6 +171,22 @@ class ServerHttp(
}

companion object {
/**
* Normalise l'adresse saisie par l'utilisateur.
*
* Le schéma est le seul ajout : `192.168.1.10:4533` seul n'est pas une
* URL pour OkHttp alors que c'est ce qu'on tape. Exposée pour que la
* signature des requêtes d'images compare le même hôte que les appels.
*/
fun parseBase(serverUrl: String): HttpUrl {
val trimmed = serverUrl.trim().trimEnd('/')
if (trimmed.isEmpty()) throw ServerException.Rejected("Adresse du serveur vide.")

val absolute = if (trimmed.contains("://")) trimmed else "https://$trimmed"
return absolute.toHttpUrlOrNull()
?: throw ServerException.Rejected("Adresse du serveur invalide : $serverUrl")
}

private val JSON_MEDIA_TYPE = "application/json; charset=utf-8".toMediaType()

/**
Expand Down
77 changes: 77 additions & 0 deletions app/src/main/java/app/waveflow/data/remote/ServerImageAuth.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package app.waveflow.data.remote

import app.waveflow.model.ServerSession
import kotlinx.coroutines.runBlocking
import okhttp3.Interceptor
import okhttp3.Response

/**
* Porte le jeton de session sur les requêtes d'images du serveur.
*
* `/api/v2/artwork/` exige le même Bearer que le reste de l'API native, et
* Coil ne connaît rien de la session : cet intercepteur l'ajoute pour lui.
*
* Seules les requêtes vers l'origine du serveur connecté sont signées. Une
* pochette locale — un `content://` — ne passe pas par OkHttp, mais une
* jaquette venue d'ailleurs pourrait ; lui joindre le jeton reviendrait à le
* confier à un tiers.
*
* L'appel est bloquant : les intercepteurs OkHttp le sont, et s'exécutent sur
* ses propres fils.
*/
class ServerImageAuthInterceptor(
private val sessionRepository: ServerSessionRepository,
) : Interceptor {

override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
val session = sessionRepository.session.value as? ServerSession.Connected
?: return chain.proceed(request)

if (!request.url.isSameOriginAs(session.serverUrl)) return chain.proceed(request)

val token = runCatching { runBlocking { sessionRepository.validAccessToken() } }
.getOrNull()
?: return chain.proceed(request)

val signed = chain.proceed(request.withBearer(token))
if (signed.code != HTTP_UNAUTHORIZED) return signed

// Jeton révoqué ailleurs : l'horloge locale le croyait bon. Comme pour
// le catalogue, on le périme et on rejoue une fois — un second refus
// veut dire que la session est fermée, et la pochette manquera.
signed.close()
val renewed = runCatching {
runBlocking {
sessionRepository.expireAccessToken()
sessionRepository.validAccessToken()
}
}.getOrNull() ?: return chain.proceed(request)

return chain.proceed(request.withBearer(renewed))
}

private fun okhttp3.Request.withBearer(token: String) =
newBuilder().header("Authorization", "Bearer $token").build()

/**
* Compare l'origine — schéma, hôte et port — à celle du serveur connecté.
*
* Le schéma compte autant que le reste : un serveur joint en HTTPS et une
* adresse en `http://` vers le même hôte et le même port enverraient le
* jeton en clair. Les ports par défaut suffisent à les distinguer quand
* ils sont implicites, pas quand le port est explicite — ce qui est le cas
* courant d'un serveur auto-hébergé.
*
* L'adresse saisie par l'utilisateur passe par la même normalisation que
* les appels d'API : sans schéma, elle est jointe en HTTPS.
*/
private fun okhttp3.HttpUrl.isSameOriginAs(serverUrl: String): Boolean {
val server = runCatching { ServerHttp.parseBase(serverUrl) }.getOrNull() ?: return false
return scheme == server.scheme && host == server.host && port == server.port
}

private companion object {
const val HTTP_UNAUTHORIZED = 401
}
}
Loading