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:
2026-09-10 10:03:11 +02:00
parent aca7238020
commit c5b2b71eea
50 changed files with 2200 additions and 0 deletions
@@ -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()
}