Files
Musify/mobile/README.md
Admin 7b8f3f1222 Use a reverse-domain redirect scheme for the native app
- Android/iOS now return to cat.ikerdemo.musify:/callback instead of
  musify://callback: a private-use scheme based on a domain we own
  (RFC 8252 section 7.1), so another app is unlikely to claim it.
- The Musify Native app no longer needs devMode: loopback and custom
  scheme redirects are both valid for a native app.
- zitadel-init also updates an existing app's config, so the change
  reaches instances created before it (the client id is kept).
- iOS takes the scheme from everything before the first ':', since the
  new URI has a single slash.
2026-09-24 15:06:25 +02:00

8.1 KiB

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 — 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: https://www.jetbrains.com/help/kotlin-multiplatform-dev/get-started.html

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: cat.ikerdemo.musify:/callback (custom scheme con dominio invertido, RFC 8252 §7.1).

mobile/gradle.properties controla a qué backend apunta Android/Desktop:

musify.apiBaseUrl=http://localhost:5111
musify.authIssuer=http://host.docker.internal:8080
musify.authClientId=<NATIVE_CLIENT_ID de deploy/.env>

(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:

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):

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)

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

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 cat.ikerdemo.musify en Info.plist:
    <key>CFBundleURLTypes</key>
    <array>
      <dict>
        <key>CFBundleURLSchemes</key>
        <array><string>cat.ikerdemo.musify</string></array>
      </dict>
    </array>
    
  3. ContentView.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í).