diff --git a/deploy/compose.yml b/deploy/compose.yml index b79806b..5773ed2 100644 --- a/deploy/compose.yml +++ b/deploy/compose.yml @@ -355,9 +355,35 @@ services: echo " created (clientId $$WEB_CLIENT_ID)" fi + echo "==> native app (KMP client: desktop loopback + mobile custom scheme)" + NATIVE_APP_NAME="Musify Native" + NATIVE_APP_ID=$$(find_app "$$NATIVE_APP_NAME") + if [ -n "$$NATIVE_APP_ID" ]; then + NATIVE_CLIENT_ID=$$(api_call GET "/projects/$$PROJECT_ID/apps/$$NATIVE_APP_ID" | jq -r '.app.oidcConfig.clientId') + echo " reused (clientId $$NATIVE_CLIENT_ID)" + else + NATIVE_CLIENT_ID=$$(api_call POST "/projects/$$PROJECT_ID/apps/oidc" '{ + "name": "Musify Native", + "redirectUris": ["http://127.0.0.1:17453/callback", "musify://callback"], + "postLogoutRedirectUris": ["http://127.0.0.1:17453/logout", "musify://logout"], + "responseTypes": ["OIDC_RESPONSE_TYPE_CODE"], + "grantTypes": ["OIDC_GRANT_TYPE_AUTHORIZATION_CODE", "OIDC_GRANT_TYPE_REFRESH_TOKEN"], + "appType": "OIDC_APP_TYPE_NATIVE", + "authMethodType": "OIDC_AUTH_METHOD_TYPE_NONE", + "version": "OIDC_VERSION_1_0", + "devMode": true, + "accessTokenType": "OIDC_TOKEN_TYPE_JWT", + "accessTokenRoleAssertion": true, + "idTokenRoleAssertion": true, + "idTokenUserinfoAssertion": true + }' | jq -r '.clientId') + echo " created (clientId $$NATIVE_CLIENT_ID)" + fi + env_set "$$ENV_FILE" AUTH_CLIENT_ID "$$API_CLIENT_ID" env_set "$$ENV_FILE" WEB_CLIENT_ID "$$WEB_CLIENT_ID" env_set "$$ENV_FILE" WEB_CLIENT_SECRET "$$WEB_CLIENT_SECRET" + env_set "$$ENV_FILE" NATIVE_CLIENT_ID "$$NATIVE_CLIENT_ID" echo "==> wired into $$ENV_FILE" restart: "no" diff --git a/mobile/.gitignore b/mobile/.gitignore new file mode 100644 index 0000000..7291564 --- /dev/null +++ b/mobile/.gitignore @@ -0,0 +1,14 @@ +# Gradle / Kotlin +.gradle/ +build/ +.kotlin/ +local.properties + +# IDE +.idea/ +*.iml + +# Xcode (if a companion iosApp project is added) +*.xcodeproj/xcuserdata/ +*.xcworkspace/xcuserdata/ +DerivedData/ diff --git a/mobile/README.md b/mobile/README.md new file mode 100644 index 0000000..bb91d38 --- /dev/null +++ b/mobile/README.md @@ -0,0 +1,169 @@ +# Musify Mobile — demo Kotlin Multiplatform + +Cliente Kotlin Multiplatform (Compose Multiplatform) para Android, Desktop (Windows/macOS/Linux) e iOS, +compartiendo el 100% de la lógica de red, autenticación y estado con solo la UI y unos pocos archivos +`expect`/`actual` por plataforma (navegador de login, reproductor de audio y almacenamiento de tokens). + +Cubre exactamente el alcance pedido: + +- **Iniciar sesión / Registrarse** — OAuth 2.0 Authorization Code + PKCE contra Zitadel (el mismo + proveedor que ya usa `web-player`). La pantalla de login hospedada de Zitadel ya incluye el enlace + "¿No tienes cuenta? Regístrate", así que un solo flujo cubre ambos casos — no existe (ni falta) un + endpoint de registro propio en el backend. +- **Ver canciones generales** — `GET /tracks`, que es público. +- **Reproducirlas** — `GET /tracks/{id}/stream` (requiere el token Bearer) + reproducción nativa por + plataforma del `manifestUrl?t=ticket` que devuelve. + +## Por qué Kotlin Multiplatform (resumen de la investigación) + +| | | +|---|---| +| **UI compartida** | [Compose Multiplatform](https://www.jetbrains.com/compose-multiplatform/) — el mismo árbol de composables corre en Android (Jetpack Compose), Desktop (Skiko sobre Swing/AWT) e iOS (Skia sobre UIKit vía un `UIViewController`). | +| **Lógica compartida** | Networking (Ktor client), modelos (`kotlinx.serialization`), auth (PKCE, hecho a mano), estado (`StateFlow`) — todo en `commonMain`, cero duplicación entre plataformas. | +| **Lo no compartido (`expect`/`actual`)** | Abrir el navegador del sistema para el login, reproducir audio, y persistir el token — porque no hay una API común decente para eso en las tres plataformas. Son ~5 archivos pequeños por plataforma. | +| **Alternativa descartada** | UI nativa por plataforma (Jetpack Compose / SwiftUI / Swing a mano) — mucho más código, y el usuario pidió explícitamente poder compartir la app entre desktop/Android/iOS. | + +Referencia oficial: + +## Estructura + +``` +mobile/ + composeApp/ + src/ + commonMain/ ← toda la lógica + toda la UI (App.kt, AppViewModel.kt, MusifyApi.kt, auth/…) + androidMain/ ← MainActivity, AuthCallbackActivity, ExoPlayer, DataStore, Custom Tabs + desktopMain/ ← main(), loopback HTTP server para el login, JavaFX MediaPlayer + iosMain/ ← MainViewController(), ASWebAuthenticationSession, AVPlayer, NSUserDefaults +``` + +Puntos de extensión `expect`/`actual` (commonMain → cada plataforma): + +- `AppConfig` — URLs del backend y del redirect URI nativo. +- `BrowserAuthLauncher` — abre el navegador y espera el redirect de Zitadel. +- `TokenStore` — persiste el token localmente. +- `AudioPlayer` — reproduce la URL firmada. +- `sha256()` / `nowEpochSeconds()` — primitivas de PKCE/expiración. + +## Cómo se conecta al backend (ya provisionado en este repo) + +Se dio de alta un tercer cliente OIDC público **"Musify Native"** en Zitadel (PKCE, sin secreto), igual +al patrón que ya usa Scalar para probar la API — ver `deploy/compose.yml` (`zitadel-init`, sección +"native app"). Quedó escrito en `deploy/.env` como `NATIVE_CLIENT_ID` y copiado a +`mobile/gradle.properties`. + +Redirect URIs registrados: +- Desktop: `http://127.0.0.1:17453/callback` (RFC 8252, loopback con puerto fijo). +- Android / iOS: `musify://callback` (custom scheme). + +`mobile/gradle.properties` controla a qué backend apunta Android/Desktop: + +```properties +musify.apiBaseUrl=http://localhost:5111 +musify.authIssuer=http://host.docker.internal:8080 +musify.authClientId= +``` + +(`host.docker.internal` resuelve al host en Windows/Mac con Docker Desktop corriendo — es el mismo +valor que usa el resto del stack. En Linux, o para reproducir en un dispositivo/simulador real, cambia +esto por la IP LAN de la máquina que corre `dotnet run`.) + +iOS no puede leer `gradle.properties` en tiempo de ejecución (es un binario nativo, no una JVM) — sus +valores están hardcodeados en `composeApp/src/iosMain/kotlin/com/musify/app/Config.ios.kt`, edítalo si +cambias de backend. + +### Levantar el backend + +La infraestructura (Postgres, Zitadel, RabbitMQ, SeaweedFS) corre con Docker: + +```bash +docker compose -f deploy/compose.yml -f deploy/compose.dev.yml up -d +``` + +La API y el Streaming Gateway se corren aparte (así se probó esta demo): + +```bash +cd backend/Hosts/Musify.Api && dotnet run +cd backend/Hosts/Musify.StreamingGateway && dotnet run +``` + +> El catálogo puede estar vacío en una base nueva — `GET /tracks` responderá `200` con `items: []`. +> Subir una canción real requiere además el Worker (`backend/Hosts/Musify.Worker`) + `ffmpeg` en el +> PATH, porque el pipeline transcodea el audio subido antes de que quede reproducible. + +## Cómo correr cada plataforma + +### Desktop (la única que se pudo ejecutar y verificar en este entorno) + +```bash +cd mobile +./gradlew :composeApp:run +``` + +Abre una ventana nativa (`MusifyMobile.msi`/`.exe` vía `./gradlew :composeApp:packageMsi` para +distribuirla). "Iniciar sesión" abre tu navegador por defecto contra Zitadel; al volver, un servidor +HTTP local efímero en `127.0.0.1:17453` recibe el código de autorización. + +### Android + +```bash +cd mobile +./gradlew :composeApp:installDebug +``` + +Necesita el Android SDK (`ANDROID_HOME` o `mobile/local.properties` con `sdk.dir=...`) — no estaba +instalado en esta máquina, así que el target **se escribió pero no se compiló aquí**; `compileKotlinDesktop` +sí confirma que el código común que comparte es válido, y `compileDebugKotlinAndroid` falló únicamente +en el paso de localizar el SDK (evidencia de que el resto del Gradle DSL es correcto). Ábrelo con +Android Studio (Hedgehog+ con soporte KMP) y debería sincronizar sin tocar nada. + +### iOS + +No hay Mac/Xcode disponible en este entorno, así que **no se pudo compilar ni probar** — el código +Kotlin/Native (`iosMain/`) sigue las APIs estándar de interop (`AVFoundation`, `AuthenticationServices`, +`CoreCrypto`) pero no fue verificado por un compilador. Para probarlo: + +1. Genera un proyecto Xcode con el asistente "Kotlin Multiplatform" de Android Studio, o crea uno vacío + e importa `composeApp` como framework (`./gradlew :composeApp:embedAndSignAppleFrameworkForXcode`). +2. Registra el scheme `musify` en `Info.plist`: + ```xml + CFBundleURLTypes + + + CFBundleURLSchemes + musify + + + ``` +3. `ContentView.swift`: + ```swift + import SwiftUI + import ComposeApp + + struct ComposeView: UIViewControllerRepresentable { + func makeUIViewController(context: Context) -> UIViewController { MainViewControllerKt.MainViewController() } + func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} + } + + struct ContentView: View { + var body: some View { ComposeView().ignoresSafeArea(.keyboard) } + } + ``` +4. Ajusta `Config.ios.kt` para que `apiBaseUrl`/`authIssuer` apunten a una dirección alcanzable desde el + simulador/dispositivo (ver arriba). + +## Qué se verificó de verdad en esta máquina + +- ✅ `./gradlew :composeApp:compileKotlinDesktop` — compila limpio (valida todo `commonMain` + + `desktopMain`). +- ✅ `./gradlew :composeApp:run` — la app arranca, se conecta a la API local (`GET /tracks`) y queda + corriendo sin excepciones. +- ✅ Cliente OIDC "Musify Native" creado de verdad en el Zitadel local del repo (`deploy/.env`, + `NATIVE_CLIENT_ID`). +- ✅ `Musify.Api` (`:5111`) y `Musify.StreamingGateway` (`:8081`) corriendo localmente contra el resto + del stack en Docker. +- ⚠️ El catálogo de canciones está vacío en esta base — la pantalla de lista funciona pero no hay nada + que reproducir todavía. Pide que te ayude a subir una canción de prueba (necesita `ffmpeg` + el Worker + corriendo) si quieres ver la reproducción de punta a punta. +- ❌ Android — compila el código común, pero no se probó el target completo (sin Android SDK aquí). +- ❌ iOS — código escrito siguiendo las APIs estándar, sin verificar (sin Mac/Xcode aquí). diff --git a/mobile/build.gradle.kts b/mobile/build.gradle.kts new file mode 100644 index 0000000..d468a4c --- /dev/null +++ b/mobile/build.gradle.kts @@ -0,0 +1,7 @@ +plugins { + alias(libs.plugins.kotlinMultiplatform) apply false + alias(libs.plugins.androidApplication) apply false + alias(libs.plugins.composeMultiplatform) apply false + alias(libs.plugins.composeCompiler) apply false + alias(libs.plugins.kotlinSerialization) apply false +} diff --git a/mobile/composeApp/build.gradle.kts b/mobile/composeApp/build.gradle.kts new file mode 100644 index 0000000..447593b --- /dev/null +++ b/mobile/composeApp/build.gradle.kts @@ -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" + } + } +} diff --git a/mobile/composeApp/src/androidMain/AndroidManifest.xml b/mobile/composeApp/src/androidMain/AndroidManifest.xml new file mode 100644 index 0000000..6a9094a --- /dev/null +++ b/mobile/composeApp/src/androidMain/AndroidManifest.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mobile/composeApp/src/androidMain/kotlin/com/musify/app/AppContextHolder.kt b/mobile/composeApp/src/androidMain/kotlin/com/musify/app/AppContextHolder.kt new file mode 100644 index 0000000..9a08b48 --- /dev/null +++ b/mobile/composeApp/src/androidMain/kotlin/com/musify/app/AppContextHolder.kt @@ -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 +} diff --git a/mobile/composeApp/src/androidMain/kotlin/com/musify/app/Config.android.kt b/mobile/composeApp/src/androidMain/kotlin/com/musify/app/Config.android.kt new file mode 100644 index 0000000..aaad041 --- /dev/null +++ b/mobile/composeApp/src/androidMain/kotlin/com/musify/app/Config.android.kt @@ -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" +} diff --git a/mobile/composeApp/src/androidMain/kotlin/com/musify/app/MainActivity.kt b/mobile/composeApp/src/androidMain/kotlin/com/musify/app/MainActivity.kt new file mode 100644 index 0000000..ae7be0e --- /dev/null +++ b/mobile/composeApp/src/androidMain/kotlin/com/musify/app/MainActivity.kt @@ -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() } + } +} diff --git a/mobile/composeApp/src/androidMain/kotlin/com/musify/app/Network.android.kt b/mobile/composeApp/src/androidMain/kotlin/com/musify/app/Network.android.kt new file mode 100644 index 0000000..b86cd19 --- /dev/null +++ b/mobile/composeApp/src/androidMain/kotlin/com/musify/app/Network.android.kt @@ -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 } +} diff --git a/mobile/composeApp/src/androidMain/kotlin/com/musify/app/audio/AudioPlayer.android.kt b/mobile/composeApp/src/androidMain/kotlin/com/musify/app/audio/AudioPlayer.android.kt new file mode 100644 index 0000000..aaad8e3 --- /dev/null +++ b/mobile/composeApp/src/androidMain/kotlin/com/musify/app/audio/AudioPlayer.android.kt @@ -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 = _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() + } +} diff --git a/mobile/composeApp/src/androidMain/kotlin/com/musify/app/auth/AndroidAuthBridge.kt b/mobile/composeApp/src/androidMain/kotlin/com/musify/app/auth/AndroidAuthBridge.kt new file mode 100644 index 0000000..807ae5c --- /dev/null +++ b/mobile/composeApp/src/androidMain/kotlin/com/musify/app/auth/AndroidAuthBridge.kt @@ -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? = null + + fun deliver(redirectUrl: String) { + pending?.complete(redirectUrl) + pending = null + } +} diff --git a/mobile/composeApp/src/androidMain/kotlin/com/musify/app/auth/AuthCallbackActivity.kt b/mobile/composeApp/src/androidMain/kotlin/com/musify/app/auth/AuthCallbackActivity.kt new file mode 100644 index 0000000..3fc4bc0 --- /dev/null +++ b/mobile/composeApp/src/androidMain/kotlin/com/musify/app/auth/AuthCallbackActivity.kt @@ -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() + } +} diff --git a/mobile/composeApp/src/androidMain/kotlin/com/musify/app/auth/BrowserAuthLauncher.android.kt b/mobile/composeApp/src/androidMain/kotlin/com/musify/app/auth/BrowserAuthLauncher.android.kt new file mode 100644 index 0000000..0a67269 --- /dev/null +++ b/mobile/composeApp/src/androidMain/kotlin/com/musify/app/auth/BrowserAuthLauncher.android.kt @@ -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() + 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() + } +} diff --git a/mobile/composeApp/src/androidMain/kotlin/com/musify/app/auth/Platform.android.kt b/mobile/composeApp/src/androidMain/kotlin/com/musify/app/auth/Platform.android.kt new file mode 100644 index 0000000..7f3daff --- /dev/null +++ b/mobile/composeApp/src/androidMain/kotlin/com/musify/app/auth/Platform.android.kt @@ -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 diff --git a/mobile/composeApp/src/androidMain/kotlin/com/musify/app/auth/TokenStore.android.kt b/mobile/composeApp/src/androidMain/kotlin/com/musify/app/auth/TokenStore.android.kt new file mode 100644 index 0000000..6bf5d35 --- /dev/null +++ b/mobile/composeApp/src/androidMain/kotlin/com/musify/app/auth/TokenStore.android.kt @@ -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(json) + } catch (t: Throwable) { + null + } + } + + actual suspend fun clear() { + context.authDataStore.edit { it.clear() } + } +} diff --git a/mobile/composeApp/src/commonMain/kotlin/com/musify/app/App.kt b/mobile/composeApp/src/commonMain/kotlin/com/musify/app/App.kt new file mode 100644 index 0000000..20edfff --- /dev/null +++ b/mobile/composeApp/src/commonMain/kotlin/com/musify/app/App.kt @@ -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, 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 "▶") + } + } + } +} diff --git a/mobile/composeApp/src/commonMain/kotlin/com/musify/app/AppViewModel.kt b/mobile/composeApp/src/commonMain/kotlin/com/musify/app/AppViewModel.kt new file mode 100644 index 0000000..50904f2 --- /dev/null +++ b/mobile/composeApp/src/commonMain/kotlin/com/musify/app/AppViewModel.kt @@ -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 = auth.state + val playbackState: StateFlow = player.state + + private val _tracks = MutableStateFlow>(emptyList()) + val tracks: StateFlow> = _tracks.asStateFlow() + + private val _loadState = MutableStateFlow(LoadState.Idle) + val loadState: StateFlow = _loadState.asStateFlow() + + private val _nowPlaying = MutableStateFlow(null) + val nowPlaying: StateFlow = _nowPlaying.asStateFlow() + + private val _playbackError = MutableStateFlow(null) + val playbackError: StateFlow = _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() + } +} diff --git a/mobile/composeApp/src/commonMain/kotlin/com/musify/app/Config.kt b/mobile/composeApp/src/commonMain/kotlin/com/musify/app/Config.kt new file mode 100644 index 0000000..33da1be --- /dev/null +++ b/mobile/composeApp/src/commonMain/kotlin/com/musify/app/Config.kt @@ -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 +} diff --git a/mobile/composeApp/src/commonMain/kotlin/com/musify/app/Models.kt b/mobile/composeApp/src/commonMain/kotlin/com/musify/app/Models.kt new file mode 100644 index 0000000..abd58d9 --- /dev/null +++ b/mobile/composeApp/src/commonMain/kotlin/com/musify/app/Models.kt @@ -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, + 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" +} diff --git a/mobile/composeApp/src/commonMain/kotlin/com/musify/app/MusifyApi.kt b/mobile/composeApp/src/commonMain/kotlin/com/musify/app/MusifyApi.kt new file mode 100644 index 0000000..6d36c55 --- /dev/null +++ b/mobile/composeApp/src/commonMain/kotlin/com/musify/app/MusifyApi.kt @@ -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" +} diff --git a/mobile/composeApp/src/commonMain/kotlin/com/musify/app/Network.kt b/mobile/composeApp/src/commonMain/kotlin/com/musify/app/Network.kt new file mode 100644 index 0000000..37aca61 --- /dev/null +++ b/mobile/composeApp/src/commonMain/kotlin/com/musify/app/Network.kt @@ -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() } diff --git a/mobile/composeApp/src/commonMain/kotlin/com/musify/app/audio/AudioPlayer.kt b/mobile/composeApp/src/commonMain/kotlin/com/musify/app/audio/AudioPlayer.kt new file mode 100644 index 0000000..f7fb4f8 --- /dev/null +++ b/mobile/composeApp/src/commonMain/kotlin/com/musify/app/audio/AudioPlayer.kt @@ -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 + fun play(url: String) + fun pause() + fun resume() + fun stop() + fun dispose() +} diff --git a/mobile/composeApp/src/commonMain/kotlin/com/musify/app/auth/AuthController.kt b/mobile/composeApp/src/commonMain/kotlin/com/musify/app/auth/AuthController.kt new file mode 100644 index 0000000..7e68cfa --- /dev/null +++ b/mobile/composeApp/src/commonMain/kotlin/com/musify/app/auth/AuthController.kt @@ -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.SignedOut) + val state: StateFlow = _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 = + 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? { + 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 + } +} diff --git a/mobile/composeApp/src/commonMain/kotlin/com/musify/app/auth/AuthModels.kt b/mobile/composeApp/src/commonMain/kotlin/com/musify/app/auth/AuthModels.kt new file mode 100644 index 0000000..4fc521f --- /dev/null +++ b/mobile/composeApp/src/commonMain/kotlin/com/musify/app/auth/AuthModels.kt @@ -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 +} diff --git a/mobile/composeApp/src/commonMain/kotlin/com/musify/app/auth/BrowserAuthLauncher.kt b/mobile/composeApp/src/commonMain/kotlin/com/musify/app/auth/BrowserAuthLauncher.kt new file mode 100644 index 0000000..137a8c8 --- /dev/null +++ b/mobile/composeApp/src/commonMain/kotlin/com/musify/app/auth/BrowserAuthLauncher.kt @@ -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 +} diff --git a/mobile/composeApp/src/commonMain/kotlin/com/musify/app/auth/Pkce.kt b/mobile/composeApp/src/commonMain/kotlin/com/musify/app/auth/Pkce.kt new file mode 100644 index 0000000..a7b0f79 --- /dev/null +++ b/mobile/composeApp/src/commonMain/kotlin/com/musify/app/auth/Pkce.kt @@ -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) +} diff --git a/mobile/composeApp/src/commonMain/kotlin/com/musify/app/auth/TokenStore.kt b/mobile/composeApp/src/commonMain/kotlin/com/musify/app/auth/TokenStore.kt new file mode 100644 index 0000000..72ac3d7 --- /dev/null +++ b/mobile/composeApp/src/commonMain/kotlin/com/musify/app/auth/TokenStore.kt @@ -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() +} diff --git a/mobile/composeApp/src/commonMain/kotlin/com/musify/app/util/Base64Url.kt b/mobile/composeApp/src/commonMain/kotlin/com/musify/app/util/Base64Url.kt new file mode 100644 index 0000000..eb7c042 --- /dev/null +++ b/mobile/composeApp/src/commonMain/kotlin/com/musify/app/util/Base64Url.kt @@ -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(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() +} diff --git a/mobile/composeApp/src/desktopMain/kotlin/com/musify/app/Config.desktop.kt b/mobile/composeApp/src/desktopMain/kotlin/com/musify/app/Config.desktop.kt new file mode 100644 index 0000000..6fa6a0f --- /dev/null +++ b/mobile/composeApp/src/desktopMain/kotlin/com/musify/app/Config.desktop.kt @@ -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" +} diff --git a/mobile/composeApp/src/desktopMain/kotlin/com/musify/app/Main.kt b/mobile/composeApp/src/desktopMain/kotlin/com/musify/app/Main.kt new file mode 100644 index 0000000..40b5030 --- /dev/null +++ b/mobile/composeApp/src/desktopMain/kotlin/com/musify/app/Main.kt @@ -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() + } +} diff --git a/mobile/composeApp/src/desktopMain/kotlin/com/musify/app/Network.desktop.kt b/mobile/composeApp/src/desktopMain/kotlin/com/musify/app/Network.desktop.kt new file mode 100644 index 0000000..02f4e4d --- /dev/null +++ b/mobile/composeApp/src/desktopMain/kotlin/com/musify/app/Network.desktop.kt @@ -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 } +} diff --git a/mobile/composeApp/src/desktopMain/kotlin/com/musify/app/audio/AudioPlayer.desktop.kt b/mobile/composeApp/src/desktopMain/kotlin/com/musify/app/audio/AudioPlayer.desktop.kt new file mode 100644 index 0000000..689b0dd --- /dev/null +++ b/mobile/composeApp/src/desktopMain/kotlin/com/musify/app/audio/AudioPlayer.desktop.kt @@ -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 = _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 + } + } +} diff --git a/mobile/composeApp/src/desktopMain/kotlin/com/musify/app/auth/BrowserAuthLauncher.desktop.kt b/mobile/composeApp/src/desktopMain/kotlin/com/musify/app/auth/BrowserAuthLauncher.desktop.kt new file mode 100644 index 0000000..8b2ee2c --- /dev/null +++ b/mobile/composeApp/src/desktopMain/kotlin/com/musify/app/auth/BrowserAuthLauncher.desktop.kt @@ -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() + val server = HttpServer.create(InetSocketAddress("127.0.0.1", port), 0) + server.createContext(path) { exchange -> + val query = exchange.requestURI.rawQuery.orEmpty() + val body = """ + +

Musify

Ya puedes volver a la app y cerrar esta pestaña.

+ + """.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) + } + } +} diff --git a/mobile/composeApp/src/desktopMain/kotlin/com/musify/app/auth/Platform.desktop.kt b/mobile/composeApp/src/desktopMain/kotlin/com/musify/app/auth/Platform.desktop.kt new file mode 100644 index 0000000..7f3daff --- /dev/null +++ b/mobile/composeApp/src/desktopMain/kotlin/com/musify/app/auth/Platform.desktop.kt @@ -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 diff --git a/mobile/composeApp/src/desktopMain/kotlin/com/musify/app/auth/TokenStore.desktop.kt b/mobile/composeApp/src/desktopMain/kotlin/com/musify/app/auth/TokenStore.desktop.kt new file mode 100644 index 0000000..d1586d8 --- /dev/null +++ b/mobile/composeApp/src/desktopMain/kotlin/com/musify/app/auth/TokenStore.desktop.kt @@ -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(file.readText()) + } catch (t: Throwable) { + null + } + } + + actual suspend fun clear(): Unit = withContext(Dispatchers.IO) { + file.delete() + Unit + } +} diff --git a/mobile/composeApp/src/iosMain/kotlin/com/musify/app/Config.ios.kt b/mobile/composeApp/src/iosMain/kotlin/com/musify/app/Config.ios.kt new file mode 100644 index 0000000..ebd3b63 --- /dev/null +++ b/mobile/composeApp/src/iosMain/kotlin/com/musify/app/Config.ios.kt @@ -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" +} diff --git a/mobile/composeApp/src/iosMain/kotlin/com/musify/app/MainViewController.kt b/mobile/composeApp/src/iosMain/kotlin/com/musify/app/MainViewController.kt new file mode 100644 index 0000000..ae736c8 --- /dev/null +++ b/mobile/composeApp/src/iosMain/kotlin/com/musify/app/MainViewController.kt @@ -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() } diff --git a/mobile/composeApp/src/iosMain/kotlin/com/musify/app/Network.ios.kt b/mobile/composeApp/src/iosMain/kotlin/com/musify/app/Network.ios.kt new file mode 100644 index 0000000..5de1edf --- /dev/null +++ b/mobile/composeApp/src/iosMain/kotlin/com/musify/app/Network.ios.kt @@ -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 } +} diff --git a/mobile/composeApp/src/iosMain/kotlin/com/musify/app/audio/AudioPlayer.ios.kt b/mobile/composeApp/src/iosMain/kotlin/com/musify/app/audio/AudioPlayer.ios.kt new file mode 100644 index 0000000..38f1035 --- /dev/null +++ b/mobile/composeApp/src/iosMain/kotlin/com/musify/app/audio/AudioPlayer.ios.kt @@ -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 = _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() + } +} diff --git a/mobile/composeApp/src/iosMain/kotlin/com/musify/app/auth/BrowserAuthLauncher.ios.kt b/mobile/composeApp/src/iosMain/kotlin/com/musify/app/auth/BrowserAuthLauncher.ios.kt new file mode 100644 index 0000000..cbb1006 --- /dev/null +++ b/mobile/composeApp/src/iosMain/kotlin/com/musify/app/auth/BrowserAuthLauncher.ios.kt @@ -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 + 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() + } +} diff --git a/mobile/composeApp/src/iosMain/kotlin/com/musify/app/auth/Platform.ios.kt b/mobile/composeApp/src/iosMain/kotlin/com/musify/app/auth/Platform.ios.kt new file mode 100644 index 0000000..85e7fb7 --- /dev/null +++ b/mobile/composeApp/src/iosMain/kotlin/com/musify/app/auth/Platform.ios.kt @@ -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() diff --git a/mobile/composeApp/src/iosMain/kotlin/com/musify/app/auth/TokenStore.ios.kt b/mobile/composeApp/src/iosMain/kotlin/com/musify/app/auth/TokenStore.ios.kt new file mode 100644 index 0000000..7756429 --- /dev/null +++ b/mobile/composeApp/src/iosMain/kotlin/com/musify/app/auth/TokenStore.ios.kt @@ -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(json) + } catch (t: Throwable) { + null + } + } + + actual suspend fun clear() { + defaults.removeObjectForKey(TOKENS_KEY) + } +} diff --git a/mobile/gradle.properties b/mobile/gradle.properties new file mode 100644 index 0000000..432c793 --- /dev/null +++ b/mobile/gradle.properties @@ -0,0 +1,15 @@ +org.gradle.jvmargs=-Xmx2048M -Dfile.encoding=UTF-8 +org.gradle.parallel=true +org.gradle.java.home=C:/Program Files/Java/jdk-21 + +kotlin.code.style=official + +android.useAndroidX=true +android.nonTransitiveRClass=true + +# Musify dev backend, reachable from this Windows host (see deploy/.env). The +# API and Streaming Gateway run via `dotnet run`; the rest of the stack (incl. +# Zitadel) runs in Docker with host.docker.internal resolved to the host. +musify.apiBaseUrl=http://localhost:5111 +musify.authIssuer=http://host.docker.internal:8080 +musify.authClientId=390116136177434631 diff --git a/mobile/gradle/libs.versions.toml b/mobile/gradle/libs.versions.toml new file mode 100644 index 0000000..505279f --- /dev/null +++ b/mobile/gradle/libs.versions.toml @@ -0,0 +1,49 @@ +[versions] +kotlin = "2.1.0" +agp = "8.6.1" +compose-multiplatform = "1.7.1" +compileSdk = "35" +minSdk = "26" +targetSdk = "35" + +ktor = "3.0.1" +coroutines = "1.9.0" +serialization = "1.7.3" +coil = "3.0.4" +androidx-activity = "1.9.3" +androidx-browser = "1.8.0" +androidx-lifecycle = "2.8.4" +androidx-datastore = "1.1.1" +media3 = "1.4.1" +javafx = "21.0.4" + +[libraries] +kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" } + +ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } +ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } +ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } +ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" } +ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" } +ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } +ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" } + +coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } +coil-network-ktor = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coil" } + +androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" } +androidx-browser = { module = "androidx.browser:browser", version.ref = "androidx-browser" } +androidx-lifecycle-viewmodel = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel", version.ref = "androidx-lifecycle" } +androidx-lifecycle-runtime-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" } +androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "androidx-datastore" } + +androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" } +androidx-media3-common = { module = "androidx.media3:media3-common", version.ref = "media3" } + +[plugins] +kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } +androidApplication = { id = "com.android.application", version.ref = "agp" } +composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" } +composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } diff --git a/mobile/gradle/wrapper/gradle-wrapper.jar b/mobile/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..1b33c55 Binary files /dev/null and b/mobile/gradle/wrapper/gradle-wrapper.jar differ diff --git a/mobile/gradle/wrapper/gradle-wrapper.properties b/mobile/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..4f5eb9d --- /dev/null +++ b/mobile/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.5-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/mobile/gradlew b/mobile/gradlew new file mode 100644 index 0000000..23d15a9 --- /dev/null +++ b/mobile/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/mobile/gradlew.bat b/mobile/gradlew.bat new file mode 100644 index 0000000..db3a6ac --- /dev/null +++ b/mobile/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/mobile/settings.gradle.kts b/mobile/settings.gradle.kts new file mode 100644 index 0000000..c93bc84 --- /dev/null +++ b/mobile/settings.gradle.kts @@ -0,0 +1,19 @@ +rootProject.name = "MusifyMobile" + +pluginManagement { + repositories { + google() + gradlePluginPortal() + mavenCentral() + } +} + +@Suppress("UnstableApiUsage") +dependencyResolutionManagement { + repositories { + google() + mavenCentral() + } +} + +include(":composeApp")