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
+156
View File
@@ -0,0 +1,156 @@
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidApplication)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler)
alias(libs.plugins.kotlinSerialization)
}
// Backend connection settings, read from gradle.properties so every target
// (Android, Desktop, iOS) is generated with the same values without
// duplicating them in three places. See mobile/README.md.
val apiBaseUrl: String = providers.gradleProperty("musify.apiBaseUrl").get()
val authIssuer: String = providers.gradleProperty("musify.authIssuer").get()
val authClientId: String = providers.gradleProperty("musify.authClientId").get()
// JavaFX Media's os/arch classifier — the demo only needs to run on this box, but
// this keeps `./gradlew :composeApp:run` working on any dev machine unmodified.
val javafxClassifier: String = run {
val os = org.gradle.internal.os.OperatingSystem.current()
val arch = System.getProperty("os.arch")
when {
os.isWindows -> "win"
os.isMacOsX -> if (arch.contains("aarch64")) "mac-aarch64" else "mac"
else -> if (arch.contains("aarch64")) "linux-aarch64" else "linux"
}
}
kotlin {
androidTarget {
@OptIn(ExperimentalKotlinGradlePluginApi::class)
compilerOptions {
jvmTarget.set(JvmTarget.JVM_11)
}
}
jvm("desktop")
listOf(
iosX64(),
iosArm64(),
iosSimulatorArm64()
).forEach { iosTarget: KotlinNativeTarget ->
iosTarget.binaries.framework {
baseName = "ComposeApp"
isStatic = true
}
}
sourceSets {
val commonMain by getting {
dependencies {
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material3)
implementation(compose.ui)
implementation(compose.components.resources)
implementation(compose.components.uiToolingPreview)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.serialization.json)
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json)
implementation(libs.ktor.client.logging)
implementation(libs.coil.compose)
implementation(libs.coil.network.ktor)
}
}
val androidMain by getting {
dependencies {
implementation(libs.ktor.client.okhttp)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.browser)
implementation(libs.androidx.datastore.preferences)
implementation(libs.androidx.media3.exoplayer)
implementation(libs.androidx.media3.common)
}
}
val desktopMain by getting {
dependencies {
implementation(compose.desktop.currentOs)
implementation(libs.ktor.client.cio)
// MediaPlayer only, via Platform.startup() — no javafx-swing/AWT needed.
// OpenJFX's sibling-module POM dependencies aren't classifier-aware in
// Gradle, so javafx-base/-graphics must be pinned explicitly too, or
// Gradle resolves their classifier-less (empty) stub jars instead.
val javafxVersion = libs.versions.javafx.get()
implementation("org.openjfx:javafx-base:$javafxVersion:$javafxClassifier")
implementation("org.openjfx:javafx-graphics:$javafxVersion:$javafxClassifier")
implementation("org.openjfx:javafx-media:$javafxVersion:$javafxClassifier")
}
}
val iosMain by creating {
dependsOn(commonMain)
dependencies {
implementation(libs.ktor.client.darwin)
}
}
getByName("iosX64Main").dependsOn(iosMain)
getByName("iosArm64Main").dependsOn(iosMain)
getByName("iosSimulatorArm64Main").dependsOn(iosMain)
}
}
android {
namespace = "com.musify.app"
compileSdk = libs.versions.compileSdk.get().toInt()
defaultConfig {
applicationId = "com.musify.app"
minSdk = libs.versions.minSdk.get().toInt()
targetSdk = libs.versions.targetSdk.get().toInt()
versionCode = 1
versionName = "1.0"
buildConfigField("String", "API_BASE_URL", "\"$apiBaseUrl\"")
buildConfigField("String", "AUTH_ISSUER", "\"$authIssuer\"")
buildConfigField("String", "AUTH_CLIENT_ID", "\"$authClientId\"")
}
buildFeatures {
buildConfig = true
compose = true
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
packaging {
resources.excludes.add("/META-INF/{AL2.0,LGPL2.1}")
}
}
compose.desktop {
application {
mainClass = "com.musify.app.MainKt"
jvmArgs += listOf(
"-Dmusify.apiBaseUrl=$apiBaseUrl",
"-Dmusify.authIssuer=$authIssuer",
"-Dmusify.authClientId=$authClientId"
)
nativeDistributions {
targetFormats(org.jetbrains.compose.desktop.application.dsl.TargetFormat.Msi)
packageName = "MusifyMobile"
packageVersion = "1.0.0"
}
}
}
@@ -0,0 +1,36 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:label="Musify"
android:allowBackup="true"
android:supportsRtl="true"
android:theme="@android:style/Theme.Material.Light.NoActionBar">
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@android:style/Theme.Material.Light.NoActionBar">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- Catches the `musify://callback` redirect Zitadel sends after login/register.
See BrowserAuthLauncher.android.kt and AppConfig.nativeRedirectUri. -->
<activity
android:name=".auth.AuthCallbackActivity"
android:exported="true"
android:launchMode="singleTask"
android:theme="@android:style/Theme.Translucent.NoTitleBar">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="musify" android:host="callback" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,12 @@
package com.musify.app
import android.content.Context
/**
* Set once from `MainActivity.onCreate`. Simpler than threading a Context
* through every constructor for a demo this size (DataStore, ExoPlayer and
* Custom Tabs all need one); a real app would use DI instead.
*/
object AppContextHolder {
lateinit var appContext: Context
}
@@ -0,0 +1,13 @@
package com.musify.app
// BuildConfig.* is generated from the buildConfigField() calls in composeApp/build.gradle.kts,
// which read mobile/gradle.properties.
actual object AppConfig {
actual val apiBaseUrl: String = BuildConfig.API_BASE_URL
actual val authIssuer: String = BuildConfig.AUTH_ISSUER
actual val authClientId: String = BuildConfig.AUTH_CLIENT_ID
// Matches the AuthCallbackActivity intent-filter in AndroidManifest.xml and
// the redirect URI registered for the "Musify Native" app in Zitadel.
actual val nativeRedirectUri: String = "musify://callback"
}
@@ -0,0 +1,13 @@
package com.musify.app
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
AppContextHolder.appContext = applicationContext
setContent { App() }
}
}
@@ -0,0 +1,13 @@
package com.musify.app
import io.ktor.client.HttpClient
import io.ktor.client.engine.okhttp.OkHttp
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logging
import io.ktor.serialization.kotlinx.json.json
actual fun createHttpClient(): HttpClient = HttpClient(OkHttp) {
install(ContentNegotiation) { json(jsonConfig) }
install(Logging) { level = LogLevel.INFO }
}
@@ -0,0 +1,58 @@
package com.musify.app.audio
import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer
import com.musify.app.AppContextHolder
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
actual class AudioPlayer actual constructor() {
private val _state = MutableStateFlow(PlaybackState.IDLE)
actual val state: StateFlow<PlaybackState> = _state.asStateFlow()
private val exoPlayer: ExoPlayer by lazy {
ExoPlayer.Builder(AppContextHolder.appContext).build().apply {
addListener(object : Player.Listener {
override fun onPlaybackStateChanged(playbackState: Int) {
_state.value = when (playbackState) {
Player.STATE_BUFFERING -> PlaybackState.LOADING
Player.STATE_READY -> if (playWhenReady) PlaybackState.PLAYING else PlaybackState.PAUSED
Player.STATE_ENDED -> PlaybackState.PAUSED
else -> PlaybackState.IDLE
}
}
override fun onPlayerError(error: PlaybackException) {
_state.value = PlaybackState.ERROR
}
})
}
}
actual fun play(url: String) {
_state.value = PlaybackState.LOADING
exoPlayer.setMediaItem(MediaItem.fromUri(url))
exoPlayer.prepare()
exoPlayer.playWhenReady = true
}
actual fun pause() {
exoPlayer.pause()
}
actual fun resume() {
exoPlayer.play()
}
actual fun stop() {
exoPlayer.stop()
_state.value = PlaybackState.IDLE
}
actual fun dispose() {
exoPlayer.release()
}
}
@@ -0,0 +1,14 @@
package com.musify.app.auth
import kotlinx.coroutines.CompletableDeferred
/** Hands the redirect URL from [AuthCallbackActivity] back to the coroutine awaiting it in [BrowserAuthLauncher]. */
internal object AndroidAuthBridge {
@Volatile
var pending: CompletableDeferred<String>? = null
fun deliver(redirectUrl: String) {
pending?.complete(redirectUrl)
pending = null
}
}
@@ -0,0 +1,27 @@
package com.musify.app.auth
import android.app.Activity
import android.content.Intent
import android.os.Bundle
/**
* Declared in AndroidManifest.xml with an intent-filter for `musify://callback`.
* Chrome Custom Tabs redirects here once Zitadel finishes the login/register
* flow; this activity just forwards the URL to [AndroidAuthBridge] and closes.
*/
class AuthCallbackActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
deliverAndFinish(intent)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
deliverAndFinish(intent)
}
private fun deliverAndFinish(intent: Intent?) {
intent?.data?.toString()?.let { AndroidAuthBridge.deliver(it) }
finish()
}
}
@@ -0,0 +1,20 @@
package com.musify.app.auth
import android.content.Intent
import android.net.Uri
import androidx.browser.customtabs.CustomTabsIntent
import com.musify.app.AppContextHolder
import kotlinx.coroutines.CompletableDeferred
actual class BrowserAuthLauncher actual constructor() {
actual suspend fun launch(authorizeUrl: String, redirectUri: String): String {
val deferred = CompletableDeferred<String>()
AndroidAuthBridge.pending = deferred
val customTabsIntent = CustomTabsIntent.Builder().build()
customTabsIntent.intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
customTabsIntent.launchUrl(AppContextHolder.appContext, Uri.parse(authorizeUrl))
return deferred.await()
}
}
@@ -0,0 +1,7 @@
package com.musify.app.auth
import java.security.MessageDigest
actual fun sha256(input: ByteArray): ByteArray = MessageDigest.getInstance("SHA-256").digest(input)
actual fun nowEpochSeconds(): Long = System.currentTimeMillis() / 1000
@@ -0,0 +1,35 @@
package com.musify.app.auth
import android.content.Context
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.musify.app.AppContextHolder
import com.musify.app.jsonConfig
import kotlinx.coroutines.flow.first
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
private val Context.authDataStore by preferencesDataStore(name = "musify_auth")
private val TOKENS_KEY = stringPreferencesKey("tokens_json")
actual class TokenStore actual constructor() {
private val context get() = AppContextHolder.appContext
actual suspend fun save(tokens: StoredTokens) {
context.authDataStore.edit { prefs -> prefs[TOKENS_KEY] = jsonConfig.encodeToString(tokens) }
}
actual suspend fun load(): StoredTokens? {
val json = context.authDataStore.data.first()[TOKENS_KEY] ?: return null
return try {
jsonConfig.decodeFromString<StoredTokens>(json)
} catch (t: Throwable) {
null
}
}
actual suspend fun clear() {
context.authDataStore.edit { it.clear() }
}
}
@@ -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()
}
@@ -0,0 +1,10 @@
package com.musify.app
// Passed as -Dmusify.* JVM args by the `compose.desktop.application` block
// (composeApp/build.gradle.kts), sourced from mobile/gradle.properties.
actual object AppConfig {
actual val apiBaseUrl: String = System.getProperty("musify.apiBaseUrl", "http://localhost:5111")
actual val authIssuer: String = System.getProperty("musify.authIssuer", "http://host.docker.internal:8080")
actual val authClientId: String = System.getProperty("musify.authClientId", "")
actual val nativeRedirectUri: String = "http://127.0.0.1:17453/callback"
}
@@ -0,0 +1,18 @@
package com.musify.app
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.window.WindowPosition
import androidx.compose.ui.window.WindowState
import androidx.compose.ui.unit.dp
fun main() = application {
Window(
onCloseRequest = ::exitApplication,
title = "Musify",
state = WindowState(size = DpSize(420.dp, 760.dp), position = WindowPosition(alignment = androidx.compose.ui.Alignment.Center))
) {
App()
}
}
@@ -0,0 +1,13 @@
package com.musify.app
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logging
import io.ktor.serialization.kotlinx.json.json
actual fun createHttpClient(): HttpClient = HttpClient(CIO) {
install(ContentNegotiation) { json(jsonConfig) }
install(Logging) { level = LogLevel.INFO }
}
@@ -0,0 +1,81 @@
package com.musify.app.audio
import javafx.application.Platform as JfxPlatform
import javafx.scene.media.Media
import javafx.scene.media.MediaPlayer
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* Boots the JavaFX toolkit headlessly (no Stage/window) so `MediaPlayer` can
* decode/play audio inside a plain Compose Desktop (Swing/Skiko) app.
*/
private object JavaFxBootstrap {
private var started = false
@Synchronized
fun ensureStarted() {
if (started) return
started = true
try {
JfxPlatform.startup {}
JfxPlatform.setImplicitExit(false)
} catch (_: IllegalStateException) {
// Toolkit already running (e.g. re-entrant init) — nothing to do.
}
}
}
actual class AudioPlayer actual constructor() {
private val _state = MutableStateFlow(PlaybackState.IDLE)
actual val state: StateFlow<PlaybackState> = _state.asStateFlow()
private var mediaPlayer: MediaPlayer? = null
actual fun play(url: String) {
JavaFxBootstrap.ensureStarted()
_state.value = PlaybackState.LOADING
JfxPlatform.runLater {
mediaPlayer?.stop()
mediaPlayer?.dispose()
val player = MediaPlayer(Media(url))
mediaPlayer = player
player.setOnPlaying { _state.value = PlaybackState.PLAYING }
player.setOnPaused { _state.value = PlaybackState.PAUSED }
player.setOnStopped { _state.value = PlaybackState.IDLE }
player.setOnEndOfMedia { _state.value = PlaybackState.PAUSED }
player.setOnError { _state.value = PlaybackState.ERROR }
player.play()
}
}
actual fun pause() {
if (mediaPlayer == null) return
JfxPlatform.runLater { mediaPlayer?.pause() }
}
actual fun resume() {
if (mediaPlayer == null) return
JfxPlatform.runLater { mediaPlayer?.play() }
}
actual fun stop() {
// Nothing was ever played (JavaFX toolkit never booted) — runLater would
// throw "Toolkit not initialized" here, e.g. when closing the app cold.
if (mediaPlayer == null) {
_state.value = PlaybackState.IDLE
return
}
JfxPlatform.runLater { mediaPlayer?.stop() }
_state.value = PlaybackState.IDLE
}
actual fun dispose() {
if (mediaPlayer == null) return
JfxPlatform.runLater {
mediaPlayer?.dispose()
mediaPlayer = null
}
}
}
@@ -0,0 +1,46 @@
package com.musify.app.auth
import com.sun.net.httpserver.HttpServer
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.awt.Desktop
import java.net.InetSocketAddress
import java.net.URI
/**
* RFC 8252 §7.3 "loopback interface redirection": a one-shot local HTTP
* server receives the Authorization Code callback in the system browser, then
* shuts itself down. `redirectUri` must match `http://127.0.0.1:17453/callback`
* exactly, as registered for the "Musify Native" Zitadel app.
*/
actual class BrowserAuthLauncher actual constructor() {
actual suspend fun launch(authorizeUrl: String, redirectUri: String): String {
val redirect = URI(redirectUri)
val port = redirect.port.takeIf { it > 0 } ?: error("El redirect URI de Desktop necesita un puerto explícito.")
val path = redirect.path.ifEmpty { "/" }
val result = CompletableDeferred<String>()
val server = HttpServer.create(InetSocketAddress("127.0.0.1", port), 0)
server.createContext(path) { exchange ->
val query = exchange.requestURI.rawQuery.orEmpty()
val body = """
<html><body style="font-family:sans-serif;padding:2rem">
<h2>Musify</h2><p>Ya puedes volver a la app y cerrar esta pestaña.</p>
</body></html>
""".trimIndent().toByteArray()
exchange.responseHeaders.add("Content-Type", "text/html; charset=utf-8")
exchange.sendResponseHeaders(200, body.size.toLong())
exchange.responseBody.use { it.write(body) }
result.complete("$redirectUri?$query")
}
server.start()
return try {
withContext(Dispatchers.IO) { Desktop.getDesktop().browse(URI(authorizeUrl)) }
result.await()
} finally {
server.stop(0)
}
}
}
@@ -0,0 +1,7 @@
package com.musify.app.auth
import java.security.MessageDigest
actual fun sha256(input: ByteArray): ByteArray = MessageDigest.getInstance("SHA-256").digest(input)
actual fun nowEpochSeconds(): Long = System.currentTimeMillis() / 1000
@@ -0,0 +1,31 @@
package com.musify.app.auth
import com.musify.app.jsonConfig
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import java.io.File
actual class TokenStore actual constructor() {
private val file = File(System.getProperty("user.home"), ".musify/tokens.json")
actual suspend fun save(tokens: StoredTokens) = withContext(Dispatchers.IO) {
file.parentFile?.mkdirs()
file.writeText(jsonConfig.encodeToString(tokens))
}
actual suspend fun load(): StoredTokens? = withContext(Dispatchers.IO) {
if (!file.exists()) return@withContext null
try {
jsonConfig.decodeFromString<StoredTokens>(file.readText())
} catch (t: Throwable) {
null
}
}
actual suspend fun clear(): Unit = withContext(Dispatchers.IO) {
file.delete()
Unit
}
}
@@ -0,0 +1,16 @@
package com.musify.app
// Kotlin/Native has no Gradle-property injection at runtime, so these are
// hardcoded — keep them in sync with mobile/gradle.properties. Point
// `apiBaseUrl`/`authIssuer` at your Mac's reachable address for the backend
// (e.g. the LAN IP of the machine running `dotnet run`, or "localhost" if the
// simulator and the backend are on the same Mac).
actual object AppConfig {
actual val apiBaseUrl: String = "http://localhost:5111"
actual val authIssuer: String = "http://localhost:8080"
actual val authClientId: String = "390116136177434631"
// Matches CFBundleURLTypes in Info.plist and the redirect URI registered
// for the "Musify Native" app in Zitadel.
actual val nativeRedirectUri: String = "musify://callback"
}
@@ -0,0 +1,7 @@
package com.musify.app
import androidx.compose.ui.window.ComposeUIViewController
import platform.UIKit.UIViewController
/** Called from Swift's ContentView — see mobile/README.md for the Xcode-side wrapper. */
fun MainViewController(): UIViewController = ComposeUIViewController { App() }
@@ -0,0 +1,13 @@
package com.musify.app
import io.ktor.client.HttpClient
import io.ktor.client.engine.darwin.Darwin
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logging
import io.ktor.serialization.kotlinx.json.json
actual fun createHttpClient(): HttpClient = HttpClient(Darwin) {
install(ContentNegotiation) { json(jsonConfig) }
install(Logging) { level = LogLevel.INFO }
}
@@ -0,0 +1,54 @@
package com.musify.app.audio
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import platform.AVFoundation.AVPlayer
import platform.AVFoundation.AVPlayerItem
import platform.AVFoundation.pause
import platform.AVFoundation.play
import platform.AVFoundation.replaceCurrentItemWithPlayerItem
import platform.Foundation.NSURL
/**
* A deliberately simple `AVPlayer` wrapper: it reports PLAYING/PAUSED
* optimistically on `play()`/`pause()` rather than observing the item's
* `status`/`timeControlStatus` via KVO, to keep this file's Objective-C
* interop small enough to review without a Mac to compile-check it against.
*/
@OptIn(ExperimentalForeignApi::class)
actual class AudioPlayer actual constructor() {
private val _state = MutableStateFlow(PlaybackState.IDLE)
actual val state: StateFlow<PlaybackState> = _state.asStateFlow()
private val player = AVPlayer()
actual fun play(url: String) {
_state.value = PlaybackState.LOADING
val item = AVPlayerItem(uRL = NSURL(string = url)!!)
player.replaceCurrentItemWithPlayerItem(item)
player.play()
_state.value = PlaybackState.PLAYING
}
actual fun pause() {
player.pause()
_state.value = PlaybackState.PAUSED
}
actual fun resume() {
player.play()
_state.value = PlaybackState.PLAYING
}
actual fun stop() {
player.pause()
player.replaceCurrentItemWithPlayerItem(null)
_state.value = PlaybackState.IDLE
}
actual fun dispose() {
stop()
}
}
@@ -0,0 +1,51 @@
package com.musify.app.auth
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.coroutines.suspendCancellableCoroutine
import platform.AuthenticationServices.ASWebAuthenticationPresentationContextProvidingProtocol
import platform.AuthenticationServices.ASWebAuthenticationSession
import platform.Foundation.NSObject
import platform.Foundation.NSURL
import platform.UIKit.UIApplication
import platform.UIKit.UIWindow
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
@OptIn(ExperimentalForeignApi::class)
private class PresentationContextProvider : NSObject(), ASWebAuthenticationPresentationContextProvidingProtocol {
override fun presentationAnchorForWebAuthenticationSession(session: ASWebAuthenticationSession): UIWindow {
@Suppress("UNCHECKED_CAST")
val windows = UIApplication.sharedApplication.windows as List<UIWindow>
return windows.firstOrNull() ?: UIWindow()
}
}
/**
* `ASWebAuthenticationSession` owns the whole round trip: it opens a system
* browser sheet at [authorizeUrl] (Zitadel's hosted login, with its
* "Regístrate" link) and intercepts the `musify://` redirect itself, no app
* delegate / `onOpenURL` wiring required.
*/
@OptIn(ExperimentalForeignApi::class)
actual class BrowserAuthLauncher actual constructor() {
actual suspend fun launch(authorizeUrl: String, redirectUri: String): String =
suspendCancellableCoroutine { continuation ->
val scheme = redirectUri.substringBefore("://")
val contextProvider = PresentationContextProvider()
val session = ASWebAuthenticationSession(
uRL = NSURL(string = authorizeUrl),
callbackURLScheme = scheme,
completionHandler = { callbackUrl, error ->
when {
callbackUrl != null -> continuation.resume(callbackUrl.absoluteString ?: "")
error != null -> continuation.resumeWithException(RuntimeException(error.localizedDescription))
else -> continuation.resumeWithException(RuntimeException("Inicio de sesión cancelado."))
}
}
)
session.presentationContextProvider = contextProvider
session.prefersEphemeralWebBrowserSession = false
session.start()
}
}
@@ -0,0 +1,22 @@
package com.musify.app.auth
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.addressOf
import kotlinx.cinterop.convert
import kotlinx.cinterop.refTo
import kotlinx.cinterop.usePinned
import platform.CoreCrypto.CC_SHA256
import platform.CoreCrypto.CC_SHA256_DIGEST_LENGTH
import platform.Foundation.NSDate
import platform.Foundation.timeIntervalSince1970
@OptIn(ExperimentalForeignApi::class)
actual fun sha256(input: ByteArray): ByteArray {
val digest = UByteArray(CC_SHA256_DIGEST_LENGTH)
input.usePinned { pinned ->
CC_SHA256(pinned.addressOf(0), input.size.convert(), digest.refTo(0))
}
return digest.toByteArray()
}
actual fun nowEpochSeconds(): Long = NSDate().timeIntervalSince1970.toLong()
@@ -0,0 +1,29 @@
package com.musify.app.auth
import com.musify.app.jsonConfig
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import platform.Foundation.NSUserDefaults
private const val TOKENS_KEY = "musify_tokens_json"
actual class TokenStore actual constructor() {
private val defaults = NSUserDefaults.standardUserDefaults
actual suspend fun save(tokens: StoredTokens) {
defaults.setObject(jsonConfig.encodeToString(tokens), forKey = TOKENS_KEY)
}
actual suspend fun load(): StoredTokens? {
val json = defaults.stringForKey(TOKENS_KEY) ?: return null
return try {
jsonConfig.decodeFromString<StoredTokens>(json)
} catch (t: Throwable) {
null
}
}
actual suspend fun clear() {
defaults.removeObjectForKey(TOKENS_KEY)
}
}