Add Kotlin Multiplatform mobile client
A Compose Multiplatform app (mobile/) targeting Android, iOS, and desktop,
sharing one Kotlin codebase: OIDC login via PKCE against the same Zitadel
instance the web player uses (loopback redirect on desktop, a musify://
custom scheme on mobile), a token store per platform, and a first pass at
browsing/streaming tracks through the existing API.
deploy/compose.yml's zitadel-init gains a third OIDC app registration
("Musify Native", public client, no secret) alongside the existing API and
web apps, writing its client id to NATIVE_CLIENT_ID in the env file the same
way the other two already do.
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
package com.musify.app
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
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.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import com.musify.app.audio.PlaybackState
|
||||
import com.musify.app.auth.AuthState
|
||||
|
||||
private val MusifyGreen = Color(0xFF1DB954)
|
||||
|
||||
@Composable
|
||||
fun App() {
|
||||
val viewModel = remember { AppViewModel() }
|
||||
DisposableEffect(Unit) {
|
||||
onDispose { viewModel.dispose() }
|
||||
}
|
||||
|
||||
MaterialTheme(colorScheme = darkColorScheme(primary = MusifyGreen)) {
|
||||
HomeScreen(viewModel)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun HomeScreen(viewModel: AppViewModel) {
|
||||
val authState by viewModel.authState.collectAsState()
|
||||
val tracks by viewModel.tracks.collectAsState()
|
||||
val loadState by viewModel.loadState.collectAsState()
|
||||
val nowPlaying by viewModel.nowPlaying.collectAsState()
|
||||
val playbackState by viewModel.playbackState.collectAsState()
|
||||
val playbackError by viewModel.playbackError.collectAsState()
|
||||
|
||||
Scaffold(
|
||||
topBar = { MusifyTopBar(authState, onSignIn = viewModel::signIn, onSignOut = viewModel::signOut) },
|
||||
bottomBar = {
|
||||
nowPlaying?.let {
|
||||
MiniPlayerBar(
|
||||
track = it,
|
||||
playbackState = playbackState,
|
||||
onToggle = viewModel::togglePlayPause
|
||||
)
|
||||
}
|
||||
}
|
||||
) { padding ->
|
||||
Column(Modifier.fillMaxSize().padding(padding)) {
|
||||
playbackError?.let { message ->
|
||||
Surface(color = MaterialTheme.colorScheme.errorContainer, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(message, Modifier.padding(12.dp), color = MaterialTheme.colorScheme.onErrorContainer)
|
||||
}
|
||||
}
|
||||
|
||||
when (val state = loadState) {
|
||||
is LoadState.Loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
|
||||
is LoadState.Error -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(state.message, color = MaterialTheme.colorScheme.error)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Button(onClick = viewModel::refreshTracks) { Text("Reintentar") }
|
||||
}
|
||||
}
|
||||
|
||||
LoadState.Idle -> if (tracks.isEmpty()) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text("Todavía no hay canciones en el catálogo.")
|
||||
}
|
||||
} else {
|
||||
TrackList(tracks, nowPlaying?.id, onPlay = viewModel::playTrack)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun MusifyTopBar(authState: AuthState, onSignIn: () -> Unit, onSignOut: () -> Unit) {
|
||||
TopAppBar(
|
||||
title = { Text("Musify", fontWeight = FontWeight.Bold) },
|
||||
actions = {
|
||||
when (authState) {
|
||||
is AuthState.SignedIn ->
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(authState.displayName ?: "Cuenta", Modifier.padding(end = 8.dp))
|
||||
TextButton(onClick = onSignOut) { Text("Cerrar sesión") }
|
||||
}
|
||||
|
||||
AuthState.SigningIn ->
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
CircularProgressIndicator(Modifier.size(20.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Abriendo navegador…")
|
||||
}
|
||||
|
||||
is AuthState.Error, AuthState.SignedOut ->
|
||||
OutlinedButton(onClick = onSignIn, modifier = Modifier.padding(end = 12.dp)) {
|
||||
Text("Iniciar sesión / Registrarse")
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TrackList(tracks: List<TrackItem>, playingId: String?, onPlay: (TrackItem) -> Unit) {
|
||||
LazyColumn(contentPadding = PaddingValues(vertical = 8.dp)) {
|
||||
items(tracks, key = { it.id }) { track ->
|
||||
TrackRow(track, isPlaying = track.id == playingId, onClick = { onPlay(track) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TrackRow(track: TrackItem, isPlaying: Boolean, onClick: () -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.background(if (isPlaying) MusifyGreen.copy(alpha = 0.15f) else Color.Transparent)
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
AsyncImage(
|
||||
model = track.coverUrl,
|
||||
contentDescription = track.title,
|
||||
modifier = Modifier.size(48.dp).clip(RoundedCornerShape(6.dp))
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(track.title, maxLines = 1, overflow = TextOverflow.Ellipsis, fontWeight = FontWeight.Medium)
|
||||
track.artist?.let { Text(it, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodySmall) }
|
||||
}
|
||||
Text(
|
||||
text = if (isPlaying) "⏸" else "▶",
|
||||
color = if (isPlaying) MusifyGreen else MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MiniPlayerBar(track: TrackItem, playbackState: PlaybackState, onToggle: () -> Unit) {
|
||||
Surface(shadowElevation = 8.dp) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
AsyncImage(
|
||||
model = track.coverUrl,
|
||||
contentDescription = track.title,
|
||||
modifier = Modifier.size(40.dp).clip(RoundedCornerShape(6.dp))
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(track.title, maxLines = 1, overflow = TextOverflow.Ellipsis, fontWeight = FontWeight.Medium)
|
||||
Text(
|
||||
when (playbackState) {
|
||||
PlaybackState.LOADING -> "Cargando…"
|
||||
PlaybackState.PLAYING -> "Reproduciendo"
|
||||
PlaybackState.PAUSED -> "Pausado"
|
||||
PlaybackState.ERROR -> "Error de reproducción"
|
||||
PlaybackState.IDLE -> ""
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onToggle) {
|
||||
Text(if (playbackState == PlaybackState.PLAYING) "⏸" else "▶")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package com.musify.app
|
||||
|
||||
import com.musify.app.audio.AudioPlayer
|
||||
import com.musify.app.audio.PlaybackState
|
||||
import com.musify.app.auth.AuthController
|
||||
import com.musify.app.auth.AuthState
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class TrackItem(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val artist: String?,
|
||||
val coverUrl: String
|
||||
)
|
||||
|
||||
sealed interface LoadState {
|
||||
data object Loading : LoadState
|
||||
data object Idle : LoadState
|
||||
data class Error(val message: String) : LoadState
|
||||
}
|
||||
|
||||
/**
|
||||
* Plain coroutine-scoped holder (not an androidx ViewModel, to keep the demo
|
||||
* free of a platform-specific DI/factory dance) wiring auth, the catalog and
|
||||
* playback together for [App].
|
||||
*/
|
||||
class AppViewModel {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
|
||||
val auth = AuthController()
|
||||
private val api = MusifyApi(accessTokenProvider = { auth.currentAccessToken() })
|
||||
val player = AudioPlayer()
|
||||
|
||||
val authState: StateFlow<AuthState> = auth.state
|
||||
val playbackState: StateFlow<PlaybackState> = player.state
|
||||
|
||||
private val _tracks = MutableStateFlow<List<TrackItem>>(emptyList())
|
||||
val tracks: StateFlow<List<TrackItem>> = _tracks.asStateFlow()
|
||||
|
||||
private val _loadState = MutableStateFlow<LoadState>(LoadState.Idle)
|
||||
val loadState: StateFlow<LoadState> = _loadState.asStateFlow()
|
||||
|
||||
private val _nowPlaying = MutableStateFlow<TrackItem?>(null)
|
||||
val nowPlaying: StateFlow<TrackItem?> = _nowPlaying.asStateFlow()
|
||||
|
||||
private val _playbackError = MutableStateFlow<String?>(null)
|
||||
val playbackError: StateFlow<String?> = _playbackError.asStateFlow()
|
||||
|
||||
init {
|
||||
scope.launch { auth.restoreSession() }
|
||||
refreshTracks()
|
||||
}
|
||||
|
||||
fun signIn() {
|
||||
scope.launch { auth.signIn() }
|
||||
}
|
||||
|
||||
fun signOut() {
|
||||
scope.launch {
|
||||
player.stop()
|
||||
_nowPlaying.value = null
|
||||
auth.signOut()
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshTracks() {
|
||||
scope.launch {
|
||||
_loadState.value = LoadState.Loading
|
||||
try {
|
||||
val response = api.getTracks(pageSize = 30)
|
||||
_tracks.value = response.items.mapNotNull { item ->
|
||||
item.track?.let { track ->
|
||||
TrackItem(
|
||||
id = track.id,
|
||||
title = track.title,
|
||||
artist = track.artist,
|
||||
coverUrl = api.coverUrl(track.id)
|
||||
)
|
||||
}
|
||||
}
|
||||
_loadState.value = LoadState.Idle
|
||||
} catch (t: Throwable) {
|
||||
_loadState.value = LoadState.Error(t.message ?: "No se pudo cargar el catálogo.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun playTrack(track: TrackItem) {
|
||||
scope.launch {
|
||||
_playbackError.value = null
|
||||
try {
|
||||
val stream = api.getTrackStream(track.id)
|
||||
_nowPlaying.value = track
|
||||
player.play(stream.playableUrl())
|
||||
} catch (t: Throwable) {
|
||||
_playbackError.value = t.message ?: "No se pudo reproducir la canción."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun togglePlayPause() {
|
||||
when (playbackState.value) {
|
||||
PlaybackState.PLAYING -> player.pause()
|
||||
PlaybackState.PAUSED -> player.resume()
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
fun dispose() {
|
||||
player.dispose()
|
||||
scope.cancel()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.musify.app
|
||||
|
||||
/**
|
||||
* Backend connection settings. Values come from `mobile/gradle.properties`
|
||||
* (`musify.*` keys) on Android and Desktop; the iOS actual hardcodes the same
|
||||
* values because a Kotlin/Native framework has no Gradle-property injection
|
||||
* at runtime. Keep the three in sync when pointing at a different backend.
|
||||
*
|
||||
* `nativeRedirectUri` is the OAuth redirect registered for the "Musify
|
||||
* Native" Zitadel app (see deploy/compose.yml, zitadel-init): a fixed
|
||||
* loopback port on Desktop (RFC 8252 native-app loopback flow), and the
|
||||
* `musify://callback` custom scheme on Android/iOS.
|
||||
*/
|
||||
expect object AppConfig {
|
||||
val apiBaseUrl: String
|
||||
val authIssuer: String
|
||||
val authClientId: String
|
||||
val nativeRedirectUri: String
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.musify.app
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
|
||||
/** Mirrors `Musify.Application.Tracks.Responses.TrackApplicationResponse`. */
|
||||
@Serializable
|
||||
data class TrackApplicationResponse(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val artist: String? = null,
|
||||
val source: String,
|
||||
val externalId: String? = null,
|
||||
val audioStatus: String,
|
||||
val duration: Double,
|
||||
val listensCount: Int,
|
||||
val ownerUserId: Long? = null,
|
||||
val createdAt: String,
|
||||
val updatedAt: String
|
||||
)
|
||||
|
||||
/**
|
||||
* Mirrors `TrackSearchItemResponse`: exactly one of [track] / [youTubeSong] is
|
||||
* populated. The demo only lists local catalog tracks, so [youTubeSong] is
|
||||
* kept untyped (raw JSON) rather than modeled in full.
|
||||
*/
|
||||
@Serializable
|
||||
data class TrackSearchItemResponse(
|
||||
val source: String,
|
||||
val track: TrackApplicationResponse? = null,
|
||||
val youTubeSong: JsonElement? = null
|
||||
)
|
||||
|
||||
/** Mirrors `TracksSearchResponse`. */
|
||||
@Serializable
|
||||
data class TracksSearchResponse(
|
||||
val items: List<TrackSearchItemResponse>,
|
||||
val pageNumber: Int,
|
||||
val pageSize: Int,
|
||||
val pageCount: Int,
|
||||
val totalItemCount: Int,
|
||||
val hasPreviousPage: Boolean,
|
||||
val hasNextLocalPage: Boolean,
|
||||
val nextYoutubeContinuationToken: String? = null,
|
||||
val youtubeUnavailable: Boolean = false,
|
||||
val hasNextPage: Boolean = false
|
||||
)
|
||||
|
||||
/** Mirrors `TrackStreamResponse`: a manifest URL plus a short-lived signed ticket. */
|
||||
@Serializable
|
||||
data class TrackStreamResponse(
|
||||
val manifestUrl: String,
|
||||
val ticket: String,
|
||||
val expiresInSeconds: Int
|
||||
)
|
||||
|
||||
/** The final URL the streaming gateway accepts: `ManifestUrl?t=Ticket` (see TicketValidationMiddleware). */
|
||||
fun TrackStreamResponse.playableUrl(): String {
|
||||
val separator = if (manifestUrl.contains("?")) "&" else "?"
|
||||
return "$manifestUrl${separator}t=$ticket"
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.musify.app
|
||||
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.request.header
|
||||
import io.ktor.client.request.parameter
|
||||
|
||||
/**
|
||||
* Thin wrapper over the two endpoints the demo needs:
|
||||
* - `GET /tracks` is public (see TrackEndpoints.cs) — no token required.
|
||||
* - `GET /tracks/{id}/stream` requires a Bearer access token.
|
||||
*/
|
||||
class MusifyApi(private val accessTokenProvider: () -> String?) {
|
||||
|
||||
suspend fun getTracks(pageNumber: Int = 1, pageSize: Int = 20, name: String? = null): TracksSearchResponse =
|
||||
httpClient.get("${AppConfig.apiBaseUrl}/tracks") {
|
||||
parameter("pageNumber", pageNumber)
|
||||
parameter("pageSize", pageSize)
|
||||
if (!name.isNullOrBlank()) parameter("name", name)
|
||||
}.body()
|
||||
|
||||
suspend fun getTrackStream(trackId: String): TrackStreamResponse {
|
||||
val token = accessTokenProvider() ?: error("Inicia sesión para reproducir canciones.")
|
||||
return httpClient.get("${AppConfig.apiBaseUrl}/tracks/$trackId/stream") {
|
||||
header("Authorization", "Bearer $token")
|
||||
}.body()
|
||||
}
|
||||
|
||||
fun coverUrl(trackId: String): String = "${AppConfig.apiBaseUrl}/tracks/$trackId/cover?size=medium"
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.musify.app
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
val jsonConfig: Json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
}
|
||||
|
||||
/** Each platform installs its own engine (OkHttp/CIO/Darwin) with the same plugins. */
|
||||
expect fun createHttpClient(): HttpClient
|
||||
|
||||
val httpClient: HttpClient by lazy { createHttpClient() }
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.musify.app.audio
|
||||
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
enum class PlaybackState { IDLE, LOADING, PLAYING, PAUSED, ERROR }
|
||||
|
||||
/**
|
||||
* Streams a single URL — the gateway-signed `manifestUrl?t=ticket` from
|
||||
* [com.musify.app.TrackStreamResponse.playableUrl] — with the platform's
|
||||
* native audio stack: Media3/ExoPlayer on Android, JavaFX `MediaPlayer` on
|
||||
* Desktop, `AVPlayer` on iOS. No custom headers are needed: the ticket
|
||||
* travels as a query parameter, so any plain HTTP media player works.
|
||||
*/
|
||||
expect class AudioPlayer() {
|
||||
val state: StateFlow<PlaybackState>
|
||||
fun play(url: String)
|
||||
fun pause()
|
||||
fun resume()
|
||||
fun stop()
|
||||
fun dispose()
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package com.musify.app.auth
|
||||
|
||||
import com.musify.app.AppConfig
|
||||
import com.musify.app.httpClient
|
||||
import com.musify.app.util.Base64Url
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.request.forms.submitForm
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.http.Parameters
|
||||
import io.ktor.http.decodeURLQueryComponent
|
||||
import io.ktor.http.encodeURLParameter
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
|
||||
/** Requesting `offline_access` gets a refresh token back so the session survives an access-token expiry. */
|
||||
private const val SCOPES = "openid profile email offline_access"
|
||||
|
||||
class AuthController(
|
||||
private val tokenStore: TokenStore = TokenStore(),
|
||||
private val browserLauncher: BrowserAuthLauncher = BrowserAuthLauncher()
|
||||
) {
|
||||
private val _state = MutableStateFlow<AuthState>(AuthState.SignedOut)
|
||||
val state: StateFlow<AuthState> = _state.asStateFlow()
|
||||
|
||||
private var discovery: OidcDiscoveryDocument? = null
|
||||
|
||||
/** Call once at startup: restores a still-valid session without touching the browser. */
|
||||
suspend fun restoreSession() {
|
||||
val stored = tokenStore.load() ?: return
|
||||
if (stored.expiresAtEpochSeconds > nowEpochSeconds() + 30) {
|
||||
_state.value = AuthState.SignedIn(stored.accessToken, stored.displayName)
|
||||
} else {
|
||||
tokenStore.clear()
|
||||
}
|
||||
}
|
||||
|
||||
/** Opens Zitadel's hosted login (with a "Regístrate" link) and completes the PKCE exchange. */
|
||||
suspend fun signIn() {
|
||||
_state.value = AuthState.SigningIn
|
||||
try {
|
||||
val doc = discoveryDocument()
|
||||
val verifier = Pkce.generateCodeVerifier()
|
||||
val challenge = Pkce.codeChallenge(verifier)
|
||||
val expectedState = Pkce.randomState()
|
||||
|
||||
val authorizeUrl = buildString {
|
||||
append(doc.authorizationEndpoint)
|
||||
append("?client_id=").append(AppConfig.authClientId.encodeURLParameter())
|
||||
append("&response_type=code")
|
||||
append("&redirect_uri=").append(AppConfig.nativeRedirectUri.encodeURLParameter())
|
||||
append("&scope=").append(SCOPES.encodeURLParameter())
|
||||
append("&code_challenge=").append(challenge)
|
||||
append("&code_challenge_method=S256")
|
||||
append("&state=").append(expectedState)
|
||||
}
|
||||
|
||||
val redirected = browserLauncher.launch(authorizeUrl, AppConfig.nativeRedirectUri)
|
||||
val params = parseQuery(redirected.substringAfter('?', ""))
|
||||
|
||||
check(params["state"] == expectedState) { "El estado OAuth no coincide; intenta iniciar sesión de nuevo." }
|
||||
params["error"]?.let { error("Zitadel devolvió un error: $it ${params["error_description"].orEmpty()}") }
|
||||
val code = params["code"] ?: error("No se recibió el código de autorización.")
|
||||
|
||||
val tokenResponse: TokenResponse = httpClient.submitForm(
|
||||
url = doc.tokenEndpoint,
|
||||
formParameters = Parameters.build {
|
||||
append("grant_type", "authorization_code")
|
||||
append("code", code)
|
||||
append("redirect_uri", AppConfig.nativeRedirectUri)
|
||||
append("client_id", AppConfig.authClientId)
|
||||
append("code_verifier", verifier)
|
||||
}
|
||||
).body()
|
||||
|
||||
val claims = decodeIdTokenClaims(tokenResponse.idToken)
|
||||
val displayName = claims?.get("name") ?: claims?.get("preferred_username") ?: claims?.get("email")
|
||||
|
||||
tokenStore.save(
|
||||
StoredTokens(
|
||||
accessToken = tokenResponse.accessToken,
|
||||
refreshToken = tokenResponse.refreshToken,
|
||||
expiresAtEpochSeconds = nowEpochSeconds() + tokenResponse.expiresInSeconds,
|
||||
displayName = displayName
|
||||
)
|
||||
)
|
||||
_state.value = AuthState.SignedIn(tokenResponse.accessToken, displayName)
|
||||
} catch (t: Throwable) {
|
||||
_state.value = AuthState.Error(t.message ?: "No se pudo iniciar sesión.")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun signOut() {
|
||||
tokenStore.clear()
|
||||
_state.value = AuthState.SignedOut
|
||||
}
|
||||
|
||||
fun currentAccessToken(): String? = (_state.value as? AuthState.SignedIn)?.accessToken
|
||||
|
||||
private suspend fun discoveryDocument(): OidcDiscoveryDocument {
|
||||
discovery?.let { return it }
|
||||
val doc: OidcDiscoveryDocument = httpClient
|
||||
.get("${AppConfig.authIssuer}/.well-known/openid-configuration")
|
||||
.body()
|
||||
discovery = doc
|
||||
return doc
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseQuery(query: String): Map<String, String> =
|
||||
query.split("&")
|
||||
.filter { it.isNotBlank() }
|
||||
.associate { pair ->
|
||||
val idx = pair.indexOf('=')
|
||||
if (idx < 0) pair to "" else pair.take(idx) to pair.substring(idx + 1).decodeURLQueryComponent()
|
||||
}
|
||||
|
||||
private fun decodeIdTokenClaims(idToken: String?): Map<String, String>? {
|
||||
val payload = idToken?.split(".")?.getOrNull(1) ?: return null
|
||||
return try {
|
||||
Json.parseToJsonElement(Base64Url.decodeToString(payload)).jsonObject
|
||||
.mapValues { it.value.jsonPrimitive.content }
|
||||
} catch (t: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.musify.app.auth
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/** `GET {issuer}/.well-known/openid-configuration` (OIDC discovery). */
|
||||
@Serializable
|
||||
data class OidcDiscoveryDocument(
|
||||
val issuer: String,
|
||||
@SerialName("authorization_endpoint") val authorizationEndpoint: String,
|
||||
@SerialName("token_endpoint") val tokenEndpoint: String,
|
||||
@SerialName("end_session_endpoint") val endSessionEndpoint: String? = null
|
||||
)
|
||||
|
||||
/** `POST {token_endpoint}` response (RFC 6749 §5.1). */
|
||||
@Serializable
|
||||
data class TokenResponse(
|
||||
@SerialName("access_token") val accessToken: String,
|
||||
@SerialName("refresh_token") val refreshToken: String? = null,
|
||||
@SerialName("id_token") val idToken: String? = null,
|
||||
@SerialName("expires_in") val expiresInSeconds: Long = 0,
|
||||
@SerialName("token_type") val tokenType: String = "Bearer"
|
||||
)
|
||||
|
||||
/** What [TokenStore] persists locally between app launches. */
|
||||
@Serializable
|
||||
data class StoredTokens(
|
||||
val accessToken: String,
|
||||
val refreshToken: String?,
|
||||
val expiresAtEpochSeconds: Long,
|
||||
val displayName: String?
|
||||
)
|
||||
|
||||
/** Where the demo's UI is at, driven by [AuthController.state]. */
|
||||
sealed interface AuthState {
|
||||
data object SignedOut : AuthState
|
||||
data object SigningIn : AuthState
|
||||
data class SignedIn(val accessToken: String, val displayName: String?) : AuthState
|
||||
data class Error(val message: String) : AuthState
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.musify.app.auth
|
||||
|
||||
/**
|
||||
* Opens the system/in-app browser at [authorizeUrl] and suspends until Zitadel
|
||||
* redirects back to [redirectUri], returning the full redirect URL (including
|
||||
* its `?code=...&state=...` or `?error=...` query string).
|
||||
*
|
||||
* Zitadel's hosted login page (opened here) is also where "¿No tienes cuenta?
|
||||
* Regístrate" lives — the demo doesn't need a separate register screen or
|
||||
* endpoint, the same Authorization Code + PKCE round trip covers both.
|
||||
*
|
||||
* - Desktop: a one-shot loopback HTTP server on the fixed port registered in
|
||||
* Zitadel (RFC 8252 §7.3), opened via the OS default browser.
|
||||
* - Android: Chrome Custom Tabs; the redirect is caught by
|
||||
* [AuthCallbackActivity] via the `musify://callback` intent filter.
|
||||
* - iOS: `ASWebAuthenticationSession`, which owns the whole round trip.
|
||||
*/
|
||||
expect class BrowserAuthLauncher() {
|
||||
suspend fun launch(authorizeUrl: String, redirectUri: String): String
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.musify.app.auth
|
||||
|
||||
import com.musify.app.util.Base64Url
|
||||
import kotlin.random.Random
|
||||
|
||||
/** SHA-256, implemented per platform (java.security on JVM, CommonCrypto on iOS). */
|
||||
expect fun sha256(input: ByteArray): ByteArray
|
||||
|
||||
/** Wall-clock seconds since epoch, needed to persist token expiry across restarts. */
|
||||
expect fun nowEpochSeconds(): Long
|
||||
|
||||
/** RFC 7636 PKCE helpers for the Authorization Code flow the native clients use. */
|
||||
object Pkce {
|
||||
private val unreservedChars = (('A'..'Z') + ('a'..'z') + ('0'..'9') + listOf('-', '_', '.', '~'))
|
||||
|
||||
fun generateCodeVerifier(): String {
|
||||
val random = Random.Default
|
||||
return (1..64).map { unreservedChars[random.nextInt(unreservedChars.size)] }.joinToString("")
|
||||
}
|
||||
|
||||
fun codeChallenge(verifier: String): String =
|
||||
Base64Url.encode(sha256(verifier.encodeToByteArray()))
|
||||
|
||||
fun randomState(): String = generateCodeVerifier().take(24)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.musify.app.auth
|
||||
|
||||
/** Local persistence for [StoredTokens] — a file on Desktop, DataStore on Android, NSUserDefaults on iOS. */
|
||||
expect class TokenStore() {
|
||||
suspend fun save(tokens: StoredTokens)
|
||||
suspend fun load(): StoredTokens?
|
||||
suspend fun clear()
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.musify.app.util
|
||||
|
||||
private const val ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
|
||||
|
||||
/**
|
||||
* Minimal, dependency-free base64url codec (RFC 4648 §5, no padding) used for
|
||||
* the PKCE code challenge and for peeking at the ID token's payload. Kept
|
||||
* hand-rolled instead of `kotlin.io.encoding.Base64` so the demo doesn't
|
||||
* depend on a still-evolving experimental stdlib API.
|
||||
*/
|
||||
object Base64Url {
|
||||
fun encode(bytes: ByteArray): String {
|
||||
val sb = StringBuilder()
|
||||
var i = 0
|
||||
while (i < bytes.size) {
|
||||
val b0 = bytes[i].toInt() and 0xFF
|
||||
val b1 = if (i + 1 < bytes.size) bytes[i + 1].toInt() and 0xFF else 0
|
||||
val b2 = if (i + 2 < bytes.size) bytes[i + 2].toInt() and 0xFF else 0
|
||||
sb.append(ALPHABET[b0 shr 2])
|
||||
sb.append(ALPHABET[((b0 and 0x3) shl 4) or (b1 shr 4)])
|
||||
if (i + 1 < bytes.size) sb.append(ALPHABET[((b1 and 0xF) shl 2) or (b2 shr 6)])
|
||||
if (i + 2 < bytes.size) sb.append(ALPHABET[b2 and 0x3F])
|
||||
i += 3
|
||||
}
|
||||
return sb.toString().replace('+', '-').replace('/', '_')
|
||||
}
|
||||
|
||||
fun decode(value: String): ByteArray {
|
||||
val cleaned = value.replace('-', '+').replace('_', '/')
|
||||
val padded = cleaned + "=".repeat((4 - cleaned.length % 4) % 4)
|
||||
val output = ArrayList<Byte>(padded.length / 4 * 3)
|
||||
var i = 0
|
||||
while (i < padded.length) {
|
||||
val c0 = ALPHABET.indexOf(padded[i])
|
||||
val c1 = ALPHABET.indexOf(padded[i + 1])
|
||||
val c2 = if (padded[i + 2] != '=') ALPHABET.indexOf(padded[i + 2]) else -1
|
||||
val c3 = if (padded[i + 3] != '=') ALPHABET.indexOf(padded[i + 3]) else -1
|
||||
|
||||
output.add(((c0 shl 2) or (c1 shr 4)).toByte())
|
||||
if (c2 >= 0) output.add((((c1 and 0xF) shl 4) or (c2 shr 2)).toByte())
|
||||
if (c3 >= 0) output.add((((c2 and 0x3) shl 6) or c3).toByte())
|
||||
i += 4
|
||||
}
|
||||
return output.toByteArray()
|
||||
}
|
||||
|
||||
fun decodeToString(value: String): String = decode(value).decodeToString()
|
||||
}
|
||||
Reference in New Issue
Block a user