Add Kotlin Multiplatform mobile client
A Compose Multiplatform app (mobile/) targeting Android, iOS, and desktop,
sharing one Kotlin codebase: OIDC login via PKCE against the same Zitadel
instance the web player uses (loopback redirect on desktop, a musify://
custom scheme on mobile), a token store per platform, and a first pass at
browsing/streaming tracks through the existing API.
deploy/compose.yml's zitadel-init gains a third OIDC app registration
("Musify Native", public client, no secret) alongside the existing API and
web apps, writing its client id to NATIVE_CLIENT_ID in the env file the same
way the other two already do.
This commit is contained in:
@@ -355,9 +355,35 @@ services:
|
|||||||
echo " created (clientId $$WEB_CLIENT_ID)"
|
echo " created (clientId $$WEB_CLIENT_ID)"
|
||||||
fi
|
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" AUTH_CLIENT_ID "$$API_CLIENT_ID"
|
||||||
env_set "$$ENV_FILE" WEB_CLIENT_ID "$$WEB_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" WEB_CLIENT_SECRET "$$WEB_CLIENT_SECRET"
|
||||||
|
env_set "$$ENV_FILE" NATIVE_CLIENT_ID "$$NATIVE_CLIENT_ID"
|
||||||
echo "==> wired into $$ENV_FILE"
|
echo "==> wired into $$ENV_FILE"
|
||||||
restart: "no"
|
restart: "no"
|
||||||
|
|
||||||
|
|||||||
@@ -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/
|
||||||
@@ -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: <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: `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=<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:
|
||||||
|
|
||||||
|
```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
|
||||||
|
<key>CFBundleURLTypes</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleURLSchemes</key>
|
||||||
|
<array><string>musify</string></array>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
```
|
||||||
|
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í).
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
|
||||||
|
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||||
|
import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.kotlinMultiplatform)
|
||||||
|
alias(libs.plugins.androidApplication)
|
||||||
|
alias(libs.plugins.composeMultiplatform)
|
||||||
|
alias(libs.plugins.composeCompiler)
|
||||||
|
alias(libs.plugins.kotlinSerialization)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backend connection settings, read from gradle.properties so every target
|
||||||
|
// (Android, Desktop, iOS) is generated with the same values without
|
||||||
|
// duplicating them in three places. See mobile/README.md.
|
||||||
|
val apiBaseUrl: String = providers.gradleProperty("musify.apiBaseUrl").get()
|
||||||
|
val authIssuer: String = providers.gradleProperty("musify.authIssuer").get()
|
||||||
|
val authClientId: String = providers.gradleProperty("musify.authClientId").get()
|
||||||
|
|
||||||
|
// JavaFX Media's os/arch classifier — the demo only needs to run on this box, but
|
||||||
|
// this keeps `./gradlew :composeApp:run` working on any dev machine unmodified.
|
||||||
|
val javafxClassifier: String = run {
|
||||||
|
val os = org.gradle.internal.os.OperatingSystem.current()
|
||||||
|
val arch = System.getProperty("os.arch")
|
||||||
|
when {
|
||||||
|
os.isWindows -> "win"
|
||||||
|
os.isMacOsX -> if (arch.contains("aarch64")) "mac-aarch64" else "mac"
|
||||||
|
else -> if (arch.contains("aarch64")) "linux-aarch64" else "linux"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
androidTarget {
|
||||||
|
@OptIn(ExperimentalKotlinGradlePluginApi::class)
|
||||||
|
compilerOptions {
|
||||||
|
jvmTarget.set(JvmTarget.JVM_11)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
jvm("desktop")
|
||||||
|
|
||||||
|
listOf(
|
||||||
|
iosX64(),
|
||||||
|
iosArm64(),
|
||||||
|
iosSimulatorArm64()
|
||||||
|
).forEach { iosTarget: KotlinNativeTarget ->
|
||||||
|
iosTarget.binaries.framework {
|
||||||
|
baseName = "ComposeApp"
|
||||||
|
isStatic = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceSets {
|
||||||
|
val commonMain by getting {
|
||||||
|
dependencies {
|
||||||
|
implementation(compose.runtime)
|
||||||
|
implementation(compose.foundation)
|
||||||
|
implementation(compose.material3)
|
||||||
|
implementation(compose.ui)
|
||||||
|
implementation(compose.components.resources)
|
||||||
|
implementation(compose.components.uiToolingPreview)
|
||||||
|
|
||||||
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
|
implementation(libs.kotlinx.serialization.json)
|
||||||
|
|
||||||
|
implementation(libs.ktor.client.core)
|
||||||
|
implementation(libs.ktor.client.content.negotiation)
|
||||||
|
implementation(libs.ktor.serialization.kotlinx.json)
|
||||||
|
implementation(libs.ktor.client.logging)
|
||||||
|
|
||||||
|
implementation(libs.coil.compose)
|
||||||
|
implementation(libs.coil.network.ktor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val androidMain by getting {
|
||||||
|
dependencies {
|
||||||
|
implementation(libs.ktor.client.okhttp)
|
||||||
|
implementation(libs.androidx.activity.compose)
|
||||||
|
implementation(libs.androidx.browser)
|
||||||
|
implementation(libs.androidx.datastore.preferences)
|
||||||
|
implementation(libs.androidx.media3.exoplayer)
|
||||||
|
implementation(libs.androidx.media3.common)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val desktopMain by getting {
|
||||||
|
dependencies {
|
||||||
|
implementation(compose.desktop.currentOs)
|
||||||
|
implementation(libs.ktor.client.cio)
|
||||||
|
// MediaPlayer only, via Platform.startup() — no javafx-swing/AWT needed.
|
||||||
|
// OpenJFX's sibling-module POM dependencies aren't classifier-aware in
|
||||||
|
// Gradle, so javafx-base/-graphics must be pinned explicitly too, or
|
||||||
|
// Gradle resolves their classifier-less (empty) stub jars instead.
|
||||||
|
val javafxVersion = libs.versions.javafx.get()
|
||||||
|
implementation("org.openjfx:javafx-base:$javafxVersion:$javafxClassifier")
|
||||||
|
implementation("org.openjfx:javafx-graphics:$javafxVersion:$javafxClassifier")
|
||||||
|
implementation("org.openjfx:javafx-media:$javafxVersion:$javafxClassifier")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val iosMain by creating {
|
||||||
|
dependsOn(commonMain)
|
||||||
|
dependencies {
|
||||||
|
implementation(libs.ktor.client.darwin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
getByName("iosX64Main").dependsOn(iosMain)
|
||||||
|
getByName("iosArm64Main").dependsOn(iosMain)
|
||||||
|
getByName("iosSimulatorArm64Main").dependsOn(iosMain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "com.musify.app"
|
||||||
|
compileSdk = libs.versions.compileSdk.get().toInt()
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "com.musify.app"
|
||||||
|
minSdk = libs.versions.minSdk.get().toInt()
|
||||||
|
targetSdk = libs.versions.targetSdk.get().toInt()
|
||||||
|
versionCode = 1
|
||||||
|
versionName = "1.0"
|
||||||
|
|
||||||
|
buildConfigField("String", "API_BASE_URL", "\"$apiBaseUrl\"")
|
||||||
|
buildConfigField("String", "AUTH_ISSUER", "\"$authIssuer\"")
|
||||||
|
buildConfigField("String", "AUTH_CLIENT_ID", "\"$authClientId\"")
|
||||||
|
}
|
||||||
|
buildFeatures {
|
||||||
|
buildConfig = true
|
||||||
|
compose = true
|
||||||
|
}
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_11
|
||||||
|
targetCompatibility = JavaVersion.VERSION_11
|
||||||
|
}
|
||||||
|
packaging {
|
||||||
|
resources.excludes.add("/META-INF/{AL2.0,LGPL2.1}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
compose.desktop {
|
||||||
|
application {
|
||||||
|
mainClass = "com.musify.app.MainKt"
|
||||||
|
jvmArgs += listOf(
|
||||||
|
"-Dmusify.apiBaseUrl=$apiBaseUrl",
|
||||||
|
"-Dmusify.authIssuer=$authIssuer",
|
||||||
|
"-Dmusify.authClientId=$authClientId"
|
||||||
|
)
|
||||||
|
nativeDistributions {
|
||||||
|
targetFormats(org.jetbrains.compose.desktop.application.dsl.TargetFormat.Msi)
|
||||||
|
packageName = "MusifyMobile"
|
||||||
|
packageVersion = "1.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:label="Musify"
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@android:style/Theme.Material.Light.NoActionBar">
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:theme="@android:style/Theme.Material.Light.NoActionBar">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<!-- Catches the `musify://callback` redirect Zitadel sends after login/register.
|
||||||
|
See BrowserAuthLauncher.android.kt and AppConfig.nativeRedirectUri. -->
|
||||||
|
<activity
|
||||||
|
android:name=".auth.AuthCallbackActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:launchMode="singleTask"
|
||||||
|
android:theme="@android:style/Theme.Translucent.NoTitleBar">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="musify" android:host="callback" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.musify.app
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set once from `MainActivity.onCreate`. Simpler than threading a Context
|
||||||
|
* through every constructor for a demo this size (DataStore, ExoPlayer and
|
||||||
|
* Custom Tabs all need one); a real app would use DI instead.
|
||||||
|
*/
|
||||||
|
object AppContextHolder {
|
||||||
|
lateinit var appContext: Context
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.musify.app
|
||||||
|
|
||||||
|
// BuildConfig.* is generated from the buildConfigField() calls in composeApp/build.gradle.kts,
|
||||||
|
// which read mobile/gradle.properties.
|
||||||
|
actual object AppConfig {
|
||||||
|
actual val apiBaseUrl: String = BuildConfig.API_BASE_URL
|
||||||
|
actual val authIssuer: String = BuildConfig.AUTH_ISSUER
|
||||||
|
actual val authClientId: String = BuildConfig.AUTH_CLIENT_ID
|
||||||
|
|
||||||
|
// Matches the AuthCallbackActivity intent-filter in AndroidManifest.xml and
|
||||||
|
// the redirect URI registered for the "Musify Native" app in Zitadel.
|
||||||
|
actual val nativeRedirectUri: String = "musify://callback"
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.musify.app
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
|
||||||
|
class MainActivity : ComponentActivity() {
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
AppContextHolder.appContext = applicationContext
|
||||||
|
setContent { App() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.musify.app
|
||||||
|
|
||||||
|
import io.ktor.client.HttpClient
|
||||||
|
import io.ktor.client.engine.okhttp.OkHttp
|
||||||
|
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||||
|
import io.ktor.client.plugins.logging.LogLevel
|
||||||
|
import io.ktor.client.plugins.logging.Logging
|
||||||
|
import io.ktor.serialization.kotlinx.json.json
|
||||||
|
|
||||||
|
actual fun createHttpClient(): HttpClient = HttpClient(OkHttp) {
|
||||||
|
install(ContentNegotiation) { json(jsonConfig) }
|
||||||
|
install(Logging) { level = LogLevel.INFO }
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package com.musify.app.audio
|
||||||
|
|
||||||
|
import androidx.media3.common.MediaItem
|
||||||
|
import androidx.media3.common.PlaybackException
|
||||||
|
import androidx.media3.common.Player
|
||||||
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
|
import com.musify.app.AppContextHolder
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
|
||||||
|
actual class AudioPlayer actual constructor() {
|
||||||
|
private val _state = MutableStateFlow(PlaybackState.IDLE)
|
||||||
|
actual val state: StateFlow<PlaybackState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
private val exoPlayer: ExoPlayer by lazy {
|
||||||
|
ExoPlayer.Builder(AppContextHolder.appContext).build().apply {
|
||||||
|
addListener(object : Player.Listener {
|
||||||
|
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||||
|
_state.value = when (playbackState) {
|
||||||
|
Player.STATE_BUFFERING -> PlaybackState.LOADING
|
||||||
|
Player.STATE_READY -> if (playWhenReady) PlaybackState.PLAYING else PlaybackState.PAUSED
|
||||||
|
Player.STATE_ENDED -> PlaybackState.PAUSED
|
||||||
|
else -> PlaybackState.IDLE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPlayerError(error: PlaybackException) {
|
||||||
|
_state.value = PlaybackState.ERROR
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun play(url: String) {
|
||||||
|
_state.value = PlaybackState.LOADING
|
||||||
|
exoPlayer.setMediaItem(MediaItem.fromUri(url))
|
||||||
|
exoPlayer.prepare()
|
||||||
|
exoPlayer.playWhenReady = true
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun pause() {
|
||||||
|
exoPlayer.pause()
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun resume() {
|
||||||
|
exoPlayer.play()
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun stop() {
|
||||||
|
exoPlayer.stop()
|
||||||
|
_state.value = PlaybackState.IDLE
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun dispose() {
|
||||||
|
exoPlayer.release()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package com.musify.app.auth
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
|
||||||
|
/** Hands the redirect URL from [AuthCallbackActivity] back to the coroutine awaiting it in [BrowserAuthLauncher]. */
|
||||||
|
internal object AndroidAuthBridge {
|
||||||
|
@Volatile
|
||||||
|
var pending: CompletableDeferred<String>? = null
|
||||||
|
|
||||||
|
fun deliver(redirectUrl: String) {
|
||||||
|
pending?.complete(redirectUrl)
|
||||||
|
pending = null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package com.musify.app.auth
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Declared in AndroidManifest.xml with an intent-filter for `musify://callback`.
|
||||||
|
* Chrome Custom Tabs redirects here once Zitadel finishes the login/register
|
||||||
|
* flow; this activity just forwards the URL to [AndroidAuthBridge] and closes.
|
||||||
|
*/
|
||||||
|
class AuthCallbackActivity : Activity() {
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
deliverAndFinish(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onNewIntent(intent: Intent) {
|
||||||
|
super.onNewIntent(intent)
|
||||||
|
deliverAndFinish(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deliverAndFinish(intent: Intent?) {
|
||||||
|
intent?.data?.toString()?.let { AndroidAuthBridge.deliver(it) }
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
package com.musify.app.auth
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.browser.customtabs.CustomTabsIntent
|
||||||
|
import com.musify.app.AppContextHolder
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
|
||||||
|
actual class BrowserAuthLauncher actual constructor() {
|
||||||
|
actual suspend fun launch(authorizeUrl: String, redirectUri: String): String {
|
||||||
|
val deferred = CompletableDeferred<String>()
|
||||||
|
AndroidAuthBridge.pending = deferred
|
||||||
|
|
||||||
|
val customTabsIntent = CustomTabsIntent.Builder().build()
|
||||||
|
customTabsIntent.intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
customTabsIntent.launchUrl(AppContextHolder.appContext, Uri.parse(authorizeUrl))
|
||||||
|
|
||||||
|
return deferred.await()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.musify.app.auth
|
||||||
|
|
||||||
|
import java.security.MessageDigest
|
||||||
|
|
||||||
|
actual fun sha256(input: ByteArray): ByteArray = MessageDigest.getInstance("SHA-256").digest(input)
|
||||||
|
|
||||||
|
actual fun nowEpochSeconds(): Long = System.currentTimeMillis() / 1000
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package com.musify.app.auth
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
|
import com.musify.app.AppContextHolder
|
||||||
|
import com.musify.app.jsonConfig
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.serialization.decodeFromString
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
|
||||||
|
private val Context.authDataStore by preferencesDataStore(name = "musify_auth")
|
||||||
|
private val TOKENS_KEY = stringPreferencesKey("tokens_json")
|
||||||
|
|
||||||
|
actual class TokenStore actual constructor() {
|
||||||
|
private val context get() = AppContextHolder.appContext
|
||||||
|
|
||||||
|
actual suspend fun save(tokens: StoredTokens) {
|
||||||
|
context.authDataStore.edit { prefs -> prefs[TOKENS_KEY] = jsonConfig.encodeToString(tokens) }
|
||||||
|
}
|
||||||
|
|
||||||
|
actual suspend fun load(): StoredTokens? {
|
||||||
|
val json = context.authDataStore.data.first()[TOKENS_KEY] ?: return null
|
||||||
|
return try {
|
||||||
|
jsonConfig.decodeFromString<StoredTokens>(json)
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
actual suspend fun clear() {
|
||||||
|
context.authDataStore.edit { it.clear() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
package com.musify.app
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.darkColorScheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import coil3.compose.AsyncImage
|
||||||
|
import com.musify.app.audio.PlaybackState
|
||||||
|
import com.musify.app.auth.AuthState
|
||||||
|
|
||||||
|
private val MusifyGreen = Color(0xFF1DB954)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun App() {
|
||||||
|
val viewModel = remember { AppViewModel() }
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
onDispose { viewModel.dispose() }
|
||||||
|
}
|
||||||
|
|
||||||
|
MaterialTheme(colorScheme = darkColorScheme(primary = MusifyGreen)) {
|
||||||
|
HomeScreen(viewModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun HomeScreen(viewModel: AppViewModel) {
|
||||||
|
val authState by viewModel.authState.collectAsState()
|
||||||
|
val tracks by viewModel.tracks.collectAsState()
|
||||||
|
val loadState by viewModel.loadState.collectAsState()
|
||||||
|
val nowPlaying by viewModel.nowPlaying.collectAsState()
|
||||||
|
val playbackState by viewModel.playbackState.collectAsState()
|
||||||
|
val playbackError by viewModel.playbackError.collectAsState()
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = { MusifyTopBar(authState, onSignIn = viewModel::signIn, onSignOut = viewModel::signOut) },
|
||||||
|
bottomBar = {
|
||||||
|
nowPlaying?.let {
|
||||||
|
MiniPlayerBar(
|
||||||
|
track = it,
|
||||||
|
playbackState = playbackState,
|
||||||
|
onToggle = viewModel::togglePlayPause
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) { padding ->
|
||||||
|
Column(Modifier.fillMaxSize().padding(padding)) {
|
||||||
|
playbackError?.let { message ->
|
||||||
|
Surface(color = MaterialTheme.colorScheme.errorContainer, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text(message, Modifier.padding(12.dp), color = MaterialTheme.colorScheme.onErrorContainer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
when (val state = loadState) {
|
||||||
|
is LoadState.Loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
|
||||||
|
is LoadState.Error -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Text(state.message, color = MaterialTheme.colorScheme.error)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Button(onClick = viewModel::refreshTracks) { Text("Reintentar") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LoadState.Idle -> if (tracks.isEmpty()) {
|
||||||
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
Text("Todavía no hay canciones en el catálogo.")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
TrackList(tracks, nowPlaying?.id, onPlay = viewModel::playTrack)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun MusifyTopBar(authState: AuthState, onSignIn: () -> Unit, onSignOut: () -> Unit) {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("Musify", fontWeight = FontWeight.Bold) },
|
||||||
|
actions = {
|
||||||
|
when (authState) {
|
||||||
|
is AuthState.SignedIn ->
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(authState.displayName ?: "Cuenta", Modifier.padding(end = 8.dp))
|
||||||
|
TextButton(onClick = onSignOut) { Text("Cerrar sesión") }
|
||||||
|
}
|
||||||
|
|
||||||
|
AuthState.SigningIn ->
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
CircularProgressIndicator(Modifier.size(20.dp))
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text("Abriendo navegador…")
|
||||||
|
}
|
||||||
|
|
||||||
|
is AuthState.Error, AuthState.SignedOut ->
|
||||||
|
OutlinedButton(onClick = onSignIn, modifier = Modifier.padding(end = 12.dp)) {
|
||||||
|
Text("Iniciar sesión / Registrarse")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TrackList(tracks: List<TrackItem>, playingId: String?, onPlay: (TrackItem) -> Unit) {
|
||||||
|
LazyColumn(contentPadding = PaddingValues(vertical = 8.dp)) {
|
||||||
|
items(tracks, key = { it.id }) { track ->
|
||||||
|
TrackRow(track, isPlaying = track.id == playingId, onClick = { onPlay(track) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TrackRow(track: TrackItem, isPlaying: Boolean, onClick: () -> Unit) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.background(if (isPlaying) MusifyGreen.copy(alpha = 0.15f) else Color.Transparent)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
AsyncImage(
|
||||||
|
model = track.coverUrl,
|
||||||
|
contentDescription = track.title,
|
||||||
|
modifier = Modifier.size(48.dp).clip(RoundedCornerShape(6.dp))
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text(track.title, maxLines = 1, overflow = TextOverflow.Ellipsis, fontWeight = FontWeight.Medium)
|
||||||
|
track.artist?.let { Text(it, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodySmall) }
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = if (isPlaying) "⏸" else "▶",
|
||||||
|
color = if (isPlaying) MusifyGreen else MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MiniPlayerBar(track: TrackItem, playbackState: PlaybackState, onToggle: () -> Unit) {
|
||||||
|
Surface(shadowElevation = 8.dp) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
AsyncImage(
|
||||||
|
model = track.coverUrl,
|
||||||
|
contentDescription = track.title,
|
||||||
|
modifier = Modifier.size(40.dp).clip(RoundedCornerShape(6.dp))
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text(track.title, maxLines = 1, overflow = TextOverflow.Ellipsis, fontWeight = FontWeight.Medium)
|
||||||
|
Text(
|
||||||
|
when (playbackState) {
|
||||||
|
PlaybackState.LOADING -> "Cargando…"
|
||||||
|
PlaybackState.PLAYING -> "Reproduciendo"
|
||||||
|
PlaybackState.PAUSED -> "Pausado"
|
||||||
|
PlaybackState.ERROR -> "Error de reproducción"
|
||||||
|
PlaybackState.IDLE -> ""
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.bodySmall
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(onClick = onToggle) {
|
||||||
|
Text(if (playbackState == PlaybackState.PLAYING) "⏸" else "▶")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
package com.musify.app
|
||||||
|
|
||||||
|
import com.musify.app.audio.AudioPlayer
|
||||||
|
import com.musify.app.audio.PlaybackState
|
||||||
|
import com.musify.app.auth.AuthController
|
||||||
|
import com.musify.app.auth.AuthState
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
data class TrackItem(
|
||||||
|
val id: String,
|
||||||
|
val title: String,
|
||||||
|
val artist: String?,
|
||||||
|
val coverUrl: String
|
||||||
|
)
|
||||||
|
|
||||||
|
sealed interface LoadState {
|
||||||
|
data object Loading : LoadState
|
||||||
|
data object Idle : LoadState
|
||||||
|
data class Error(val message: String) : LoadState
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plain coroutine-scoped holder (not an androidx ViewModel, to keep the demo
|
||||||
|
* free of a platform-specific DI/factory dance) wiring auth, the catalog and
|
||||||
|
* playback together for [App].
|
||||||
|
*/
|
||||||
|
class AppViewModel {
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
|
|
||||||
|
val auth = AuthController()
|
||||||
|
private val api = MusifyApi(accessTokenProvider = { auth.currentAccessToken() })
|
||||||
|
val player = AudioPlayer()
|
||||||
|
|
||||||
|
val authState: StateFlow<AuthState> = auth.state
|
||||||
|
val playbackState: StateFlow<PlaybackState> = player.state
|
||||||
|
|
||||||
|
private val _tracks = MutableStateFlow<List<TrackItem>>(emptyList())
|
||||||
|
val tracks: StateFlow<List<TrackItem>> = _tracks.asStateFlow()
|
||||||
|
|
||||||
|
private val _loadState = MutableStateFlow<LoadState>(LoadState.Idle)
|
||||||
|
val loadState: StateFlow<LoadState> = _loadState.asStateFlow()
|
||||||
|
|
||||||
|
private val _nowPlaying = MutableStateFlow<TrackItem?>(null)
|
||||||
|
val nowPlaying: StateFlow<TrackItem?> = _nowPlaying.asStateFlow()
|
||||||
|
|
||||||
|
private val _playbackError = MutableStateFlow<String?>(null)
|
||||||
|
val playbackError: StateFlow<String?> = _playbackError.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
scope.launch { auth.restoreSession() }
|
||||||
|
refreshTracks()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun signIn() {
|
||||||
|
scope.launch { auth.signIn() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun signOut() {
|
||||||
|
scope.launch {
|
||||||
|
player.stop()
|
||||||
|
_nowPlaying.value = null
|
||||||
|
auth.signOut()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refreshTracks() {
|
||||||
|
scope.launch {
|
||||||
|
_loadState.value = LoadState.Loading
|
||||||
|
try {
|
||||||
|
val response = api.getTracks(pageSize = 30)
|
||||||
|
_tracks.value = response.items.mapNotNull { item ->
|
||||||
|
item.track?.let { track ->
|
||||||
|
TrackItem(
|
||||||
|
id = track.id,
|
||||||
|
title = track.title,
|
||||||
|
artist = track.artist,
|
||||||
|
coverUrl = api.coverUrl(track.id)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_loadState.value = LoadState.Idle
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
_loadState.value = LoadState.Error(t.message ?: "No se pudo cargar el catálogo.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun playTrack(track: TrackItem) {
|
||||||
|
scope.launch {
|
||||||
|
_playbackError.value = null
|
||||||
|
try {
|
||||||
|
val stream = api.getTrackStream(track.id)
|
||||||
|
_nowPlaying.value = track
|
||||||
|
player.play(stream.playableUrl())
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
_playbackError.value = t.message ?: "No se pudo reproducir la canción."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun togglePlayPause() {
|
||||||
|
when (playbackState.value) {
|
||||||
|
PlaybackState.PLAYING -> player.pause()
|
||||||
|
PlaybackState.PAUSED -> player.resume()
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dispose() {
|
||||||
|
player.dispose()
|
||||||
|
scope.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.musify.app
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backend connection settings. Values come from `mobile/gradle.properties`
|
||||||
|
* (`musify.*` keys) on Android and Desktop; the iOS actual hardcodes the same
|
||||||
|
* values because a Kotlin/Native framework has no Gradle-property injection
|
||||||
|
* at runtime. Keep the three in sync when pointing at a different backend.
|
||||||
|
*
|
||||||
|
* `nativeRedirectUri` is the OAuth redirect registered for the "Musify
|
||||||
|
* Native" Zitadel app (see deploy/compose.yml, zitadel-init): a fixed
|
||||||
|
* loopback port on Desktop (RFC 8252 native-app loopback flow), and the
|
||||||
|
* `musify://callback` custom scheme on Android/iOS.
|
||||||
|
*/
|
||||||
|
expect object AppConfig {
|
||||||
|
val apiBaseUrl: String
|
||||||
|
val authIssuer: String
|
||||||
|
val authClientId: String
|
||||||
|
val nativeRedirectUri: String
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package com.musify.app
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
|
||||||
|
/** Mirrors `Musify.Application.Tracks.Responses.TrackApplicationResponse`. */
|
||||||
|
@Serializable
|
||||||
|
data class TrackApplicationResponse(
|
||||||
|
val id: String,
|
||||||
|
val title: String,
|
||||||
|
val artist: String? = null,
|
||||||
|
val source: String,
|
||||||
|
val externalId: String? = null,
|
||||||
|
val audioStatus: String,
|
||||||
|
val duration: Double,
|
||||||
|
val listensCount: Int,
|
||||||
|
val ownerUserId: Long? = null,
|
||||||
|
val createdAt: String,
|
||||||
|
val updatedAt: String
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrors `TrackSearchItemResponse`: exactly one of [track] / [youTubeSong] is
|
||||||
|
* populated. The demo only lists local catalog tracks, so [youTubeSong] is
|
||||||
|
* kept untyped (raw JSON) rather than modeled in full.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class TrackSearchItemResponse(
|
||||||
|
val source: String,
|
||||||
|
val track: TrackApplicationResponse? = null,
|
||||||
|
val youTubeSong: JsonElement? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Mirrors `TracksSearchResponse`. */
|
||||||
|
@Serializable
|
||||||
|
data class TracksSearchResponse(
|
||||||
|
val items: List<TrackSearchItemResponse>,
|
||||||
|
val pageNumber: Int,
|
||||||
|
val pageSize: Int,
|
||||||
|
val pageCount: Int,
|
||||||
|
val totalItemCount: Int,
|
||||||
|
val hasPreviousPage: Boolean,
|
||||||
|
val hasNextLocalPage: Boolean,
|
||||||
|
val nextYoutubeContinuationToken: String? = null,
|
||||||
|
val youtubeUnavailable: Boolean = false,
|
||||||
|
val hasNextPage: Boolean = false
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Mirrors `TrackStreamResponse`: a manifest URL plus a short-lived signed ticket. */
|
||||||
|
@Serializable
|
||||||
|
data class TrackStreamResponse(
|
||||||
|
val manifestUrl: String,
|
||||||
|
val ticket: String,
|
||||||
|
val expiresInSeconds: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The final URL the streaming gateway accepts: `ManifestUrl?t=Ticket` (see TicketValidationMiddleware). */
|
||||||
|
fun TrackStreamResponse.playableUrl(): String {
|
||||||
|
val separator = if (manifestUrl.contains("?")) "&" else "?"
|
||||||
|
return "$manifestUrl${separator}t=$ticket"
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package com.musify.app
|
||||||
|
|
||||||
|
import io.ktor.client.call.body
|
||||||
|
import io.ktor.client.request.get
|
||||||
|
import io.ktor.client.request.header
|
||||||
|
import io.ktor.client.request.parameter
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thin wrapper over the two endpoints the demo needs:
|
||||||
|
* - `GET /tracks` is public (see TrackEndpoints.cs) — no token required.
|
||||||
|
* - `GET /tracks/{id}/stream` requires a Bearer access token.
|
||||||
|
*/
|
||||||
|
class MusifyApi(private val accessTokenProvider: () -> String?) {
|
||||||
|
|
||||||
|
suspend fun getTracks(pageNumber: Int = 1, pageSize: Int = 20, name: String? = null): TracksSearchResponse =
|
||||||
|
httpClient.get("${AppConfig.apiBaseUrl}/tracks") {
|
||||||
|
parameter("pageNumber", pageNumber)
|
||||||
|
parameter("pageSize", pageSize)
|
||||||
|
if (!name.isNullOrBlank()) parameter("name", name)
|
||||||
|
}.body()
|
||||||
|
|
||||||
|
suspend fun getTrackStream(trackId: String): TrackStreamResponse {
|
||||||
|
val token = accessTokenProvider() ?: error("Inicia sesión para reproducir canciones.")
|
||||||
|
return httpClient.get("${AppConfig.apiBaseUrl}/tracks/$trackId/stream") {
|
||||||
|
header("Authorization", "Bearer $token")
|
||||||
|
}.body()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun coverUrl(trackId: String): String = "${AppConfig.apiBaseUrl}/tracks/$trackId/cover?size=medium"
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package com.musify.app
|
||||||
|
|
||||||
|
import io.ktor.client.HttpClient
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
|
val jsonConfig: Json = Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
isLenient = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Each platform installs its own engine (OkHttp/CIO/Darwin) with the same plugins. */
|
||||||
|
expect fun createHttpClient(): HttpClient
|
||||||
|
|
||||||
|
val httpClient: HttpClient by lazy { createHttpClient() }
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package com.musify.app.audio
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
enum class PlaybackState { IDLE, LOADING, PLAYING, PAUSED, ERROR }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Streams a single URL — the gateway-signed `manifestUrl?t=ticket` from
|
||||||
|
* [com.musify.app.TrackStreamResponse.playableUrl] — with the platform's
|
||||||
|
* native audio stack: Media3/ExoPlayer on Android, JavaFX `MediaPlayer` on
|
||||||
|
* Desktop, `AVPlayer` on iOS. No custom headers are needed: the ticket
|
||||||
|
* travels as a query parameter, so any plain HTTP media player works.
|
||||||
|
*/
|
||||||
|
expect class AudioPlayer() {
|
||||||
|
val state: StateFlow<PlaybackState>
|
||||||
|
fun play(url: String)
|
||||||
|
fun pause()
|
||||||
|
fun resume()
|
||||||
|
fun stop()
|
||||||
|
fun dispose()
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
package com.musify.app.auth
|
||||||
|
|
||||||
|
import com.musify.app.AppConfig
|
||||||
|
import com.musify.app.httpClient
|
||||||
|
import com.musify.app.util.Base64Url
|
||||||
|
import io.ktor.client.call.body
|
||||||
|
import io.ktor.client.request.forms.submitForm
|
||||||
|
import io.ktor.client.request.get
|
||||||
|
import io.ktor.http.Parameters
|
||||||
|
import io.ktor.http.decodeURLQueryComponent
|
||||||
|
import io.ktor.http.encodeURLParameter
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
|
||||||
|
/** Requesting `offline_access` gets a refresh token back so the session survives an access-token expiry. */
|
||||||
|
private const val SCOPES = "openid profile email offline_access"
|
||||||
|
|
||||||
|
class AuthController(
|
||||||
|
private val tokenStore: TokenStore = TokenStore(),
|
||||||
|
private val browserLauncher: BrowserAuthLauncher = BrowserAuthLauncher()
|
||||||
|
) {
|
||||||
|
private val _state = MutableStateFlow<AuthState>(AuthState.SignedOut)
|
||||||
|
val state: StateFlow<AuthState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
private var discovery: OidcDiscoveryDocument? = null
|
||||||
|
|
||||||
|
/** Call once at startup: restores a still-valid session without touching the browser. */
|
||||||
|
suspend fun restoreSession() {
|
||||||
|
val stored = tokenStore.load() ?: return
|
||||||
|
if (stored.expiresAtEpochSeconds > nowEpochSeconds() + 30) {
|
||||||
|
_state.value = AuthState.SignedIn(stored.accessToken, stored.displayName)
|
||||||
|
} else {
|
||||||
|
tokenStore.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Opens Zitadel's hosted login (with a "Regístrate" link) and completes the PKCE exchange. */
|
||||||
|
suspend fun signIn() {
|
||||||
|
_state.value = AuthState.SigningIn
|
||||||
|
try {
|
||||||
|
val doc = discoveryDocument()
|
||||||
|
val verifier = Pkce.generateCodeVerifier()
|
||||||
|
val challenge = Pkce.codeChallenge(verifier)
|
||||||
|
val expectedState = Pkce.randomState()
|
||||||
|
|
||||||
|
val authorizeUrl = buildString {
|
||||||
|
append(doc.authorizationEndpoint)
|
||||||
|
append("?client_id=").append(AppConfig.authClientId.encodeURLParameter())
|
||||||
|
append("&response_type=code")
|
||||||
|
append("&redirect_uri=").append(AppConfig.nativeRedirectUri.encodeURLParameter())
|
||||||
|
append("&scope=").append(SCOPES.encodeURLParameter())
|
||||||
|
append("&code_challenge=").append(challenge)
|
||||||
|
append("&code_challenge_method=S256")
|
||||||
|
append("&state=").append(expectedState)
|
||||||
|
}
|
||||||
|
|
||||||
|
val redirected = browserLauncher.launch(authorizeUrl, AppConfig.nativeRedirectUri)
|
||||||
|
val params = parseQuery(redirected.substringAfter('?', ""))
|
||||||
|
|
||||||
|
check(params["state"] == expectedState) { "El estado OAuth no coincide; intenta iniciar sesión de nuevo." }
|
||||||
|
params["error"]?.let { error("Zitadel devolvió un error: $it ${params["error_description"].orEmpty()}") }
|
||||||
|
val code = params["code"] ?: error("No se recibió el código de autorización.")
|
||||||
|
|
||||||
|
val tokenResponse: TokenResponse = httpClient.submitForm(
|
||||||
|
url = doc.tokenEndpoint,
|
||||||
|
formParameters = Parameters.build {
|
||||||
|
append("grant_type", "authorization_code")
|
||||||
|
append("code", code)
|
||||||
|
append("redirect_uri", AppConfig.nativeRedirectUri)
|
||||||
|
append("client_id", AppConfig.authClientId)
|
||||||
|
append("code_verifier", verifier)
|
||||||
|
}
|
||||||
|
).body()
|
||||||
|
|
||||||
|
val claims = decodeIdTokenClaims(tokenResponse.idToken)
|
||||||
|
val displayName = claims?.get("name") ?: claims?.get("preferred_username") ?: claims?.get("email")
|
||||||
|
|
||||||
|
tokenStore.save(
|
||||||
|
StoredTokens(
|
||||||
|
accessToken = tokenResponse.accessToken,
|
||||||
|
refreshToken = tokenResponse.refreshToken,
|
||||||
|
expiresAtEpochSeconds = nowEpochSeconds() + tokenResponse.expiresInSeconds,
|
||||||
|
displayName = displayName
|
||||||
|
)
|
||||||
|
)
|
||||||
|
_state.value = AuthState.SignedIn(tokenResponse.accessToken, displayName)
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
_state.value = AuthState.Error(t.message ?: "No se pudo iniciar sesión.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun signOut() {
|
||||||
|
tokenStore.clear()
|
||||||
|
_state.value = AuthState.SignedOut
|
||||||
|
}
|
||||||
|
|
||||||
|
fun currentAccessToken(): String? = (_state.value as? AuthState.SignedIn)?.accessToken
|
||||||
|
|
||||||
|
private suspend fun discoveryDocument(): OidcDiscoveryDocument {
|
||||||
|
discovery?.let { return it }
|
||||||
|
val doc: OidcDiscoveryDocument = httpClient
|
||||||
|
.get("${AppConfig.authIssuer}/.well-known/openid-configuration")
|
||||||
|
.body()
|
||||||
|
discovery = doc
|
||||||
|
return doc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseQuery(query: String): Map<String, String> =
|
||||||
|
query.split("&")
|
||||||
|
.filter { it.isNotBlank() }
|
||||||
|
.associate { pair ->
|
||||||
|
val idx = pair.indexOf('=')
|
||||||
|
if (idx < 0) pair to "" else pair.take(idx) to pair.substring(idx + 1).decodeURLQueryComponent()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decodeIdTokenClaims(idToken: String?): Map<String, String>? {
|
||||||
|
val payload = idToken?.split(".")?.getOrNull(1) ?: return null
|
||||||
|
return try {
|
||||||
|
Json.parseToJsonElement(Base64Url.decodeToString(payload)).jsonObject
|
||||||
|
.mapValues { it.value.jsonPrimitive.content }
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package com.musify.app.auth
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/** `GET {issuer}/.well-known/openid-configuration` (OIDC discovery). */
|
||||||
|
@Serializable
|
||||||
|
data class OidcDiscoveryDocument(
|
||||||
|
val issuer: String,
|
||||||
|
@SerialName("authorization_endpoint") val authorizationEndpoint: String,
|
||||||
|
@SerialName("token_endpoint") val tokenEndpoint: String,
|
||||||
|
@SerialName("end_session_endpoint") val endSessionEndpoint: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
/** `POST {token_endpoint}` response (RFC 6749 §5.1). */
|
||||||
|
@Serializable
|
||||||
|
data class TokenResponse(
|
||||||
|
@SerialName("access_token") val accessToken: String,
|
||||||
|
@SerialName("refresh_token") val refreshToken: String? = null,
|
||||||
|
@SerialName("id_token") val idToken: String? = null,
|
||||||
|
@SerialName("expires_in") val expiresInSeconds: Long = 0,
|
||||||
|
@SerialName("token_type") val tokenType: String = "Bearer"
|
||||||
|
)
|
||||||
|
|
||||||
|
/** What [TokenStore] persists locally between app launches. */
|
||||||
|
@Serializable
|
||||||
|
data class StoredTokens(
|
||||||
|
val accessToken: String,
|
||||||
|
val refreshToken: String?,
|
||||||
|
val expiresAtEpochSeconds: Long,
|
||||||
|
val displayName: String?
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Where the demo's UI is at, driven by [AuthController.state]. */
|
||||||
|
sealed interface AuthState {
|
||||||
|
data object SignedOut : AuthState
|
||||||
|
data object SigningIn : AuthState
|
||||||
|
data class SignedIn(val accessToken: String, val displayName: String?) : AuthState
|
||||||
|
data class Error(val message: String) : AuthState
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package com.musify.app.auth
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the system/in-app browser at [authorizeUrl] and suspends until Zitadel
|
||||||
|
* redirects back to [redirectUri], returning the full redirect URL (including
|
||||||
|
* its `?code=...&state=...` or `?error=...` query string).
|
||||||
|
*
|
||||||
|
* Zitadel's hosted login page (opened here) is also where "¿No tienes cuenta?
|
||||||
|
* Regístrate" lives — the demo doesn't need a separate register screen or
|
||||||
|
* endpoint, the same Authorization Code + PKCE round trip covers both.
|
||||||
|
*
|
||||||
|
* - Desktop: a one-shot loopback HTTP server on the fixed port registered in
|
||||||
|
* Zitadel (RFC 8252 §7.3), opened via the OS default browser.
|
||||||
|
* - Android: Chrome Custom Tabs; the redirect is caught by
|
||||||
|
* [AuthCallbackActivity] via the `musify://callback` intent filter.
|
||||||
|
* - iOS: `ASWebAuthenticationSession`, which owns the whole round trip.
|
||||||
|
*/
|
||||||
|
expect class BrowserAuthLauncher() {
|
||||||
|
suspend fun launch(authorizeUrl: String, redirectUri: String): String
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package com.musify.app.auth
|
||||||
|
|
||||||
|
import com.musify.app.util.Base64Url
|
||||||
|
import kotlin.random.Random
|
||||||
|
|
||||||
|
/** SHA-256, implemented per platform (java.security on JVM, CommonCrypto on iOS). */
|
||||||
|
expect fun sha256(input: ByteArray): ByteArray
|
||||||
|
|
||||||
|
/** Wall-clock seconds since epoch, needed to persist token expiry across restarts. */
|
||||||
|
expect fun nowEpochSeconds(): Long
|
||||||
|
|
||||||
|
/** RFC 7636 PKCE helpers for the Authorization Code flow the native clients use. */
|
||||||
|
object Pkce {
|
||||||
|
private val unreservedChars = (('A'..'Z') + ('a'..'z') + ('0'..'9') + listOf('-', '_', '.', '~'))
|
||||||
|
|
||||||
|
fun generateCodeVerifier(): String {
|
||||||
|
val random = Random.Default
|
||||||
|
return (1..64).map { unreservedChars[random.nextInt(unreservedChars.size)] }.joinToString("")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun codeChallenge(verifier: String): String =
|
||||||
|
Base64Url.encode(sha256(verifier.encodeToByteArray()))
|
||||||
|
|
||||||
|
fun randomState(): String = generateCodeVerifier().take(24)
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package com.musify.app.auth
|
||||||
|
|
||||||
|
/** Local persistence for [StoredTokens] — a file on Desktop, DataStore on Android, NSUserDefaults on iOS. */
|
||||||
|
expect class TokenStore() {
|
||||||
|
suspend fun save(tokens: StoredTokens)
|
||||||
|
suspend fun load(): StoredTokens?
|
||||||
|
suspend fun clear()
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package com.musify.app.util
|
||||||
|
|
||||||
|
private const val ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal, dependency-free base64url codec (RFC 4648 §5, no padding) used for
|
||||||
|
* the PKCE code challenge and for peeking at the ID token's payload. Kept
|
||||||
|
* hand-rolled instead of `kotlin.io.encoding.Base64` so the demo doesn't
|
||||||
|
* depend on a still-evolving experimental stdlib API.
|
||||||
|
*/
|
||||||
|
object Base64Url {
|
||||||
|
fun encode(bytes: ByteArray): String {
|
||||||
|
val sb = StringBuilder()
|
||||||
|
var i = 0
|
||||||
|
while (i < bytes.size) {
|
||||||
|
val b0 = bytes[i].toInt() and 0xFF
|
||||||
|
val b1 = if (i + 1 < bytes.size) bytes[i + 1].toInt() and 0xFF else 0
|
||||||
|
val b2 = if (i + 2 < bytes.size) bytes[i + 2].toInt() and 0xFF else 0
|
||||||
|
sb.append(ALPHABET[b0 shr 2])
|
||||||
|
sb.append(ALPHABET[((b0 and 0x3) shl 4) or (b1 shr 4)])
|
||||||
|
if (i + 1 < bytes.size) sb.append(ALPHABET[((b1 and 0xF) shl 2) or (b2 shr 6)])
|
||||||
|
if (i + 2 < bytes.size) sb.append(ALPHABET[b2 and 0x3F])
|
||||||
|
i += 3
|
||||||
|
}
|
||||||
|
return sb.toString().replace('+', '-').replace('/', '_')
|
||||||
|
}
|
||||||
|
|
||||||
|
fun decode(value: String): ByteArray {
|
||||||
|
val cleaned = value.replace('-', '+').replace('_', '/')
|
||||||
|
val padded = cleaned + "=".repeat((4 - cleaned.length % 4) % 4)
|
||||||
|
val output = ArrayList<Byte>(padded.length / 4 * 3)
|
||||||
|
var i = 0
|
||||||
|
while (i < padded.length) {
|
||||||
|
val c0 = ALPHABET.indexOf(padded[i])
|
||||||
|
val c1 = ALPHABET.indexOf(padded[i + 1])
|
||||||
|
val c2 = if (padded[i + 2] != '=') ALPHABET.indexOf(padded[i + 2]) else -1
|
||||||
|
val c3 = if (padded[i + 3] != '=') ALPHABET.indexOf(padded[i + 3]) else -1
|
||||||
|
|
||||||
|
output.add(((c0 shl 2) or (c1 shr 4)).toByte())
|
||||||
|
if (c2 >= 0) output.add((((c1 and 0xF) shl 4) or (c2 shr 2)).toByte())
|
||||||
|
if (c3 >= 0) output.add((((c2 and 0x3) shl 6) or c3).toByte())
|
||||||
|
i += 4
|
||||||
|
}
|
||||||
|
return output.toByteArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun decodeToString(value: String): String = decode(value).decodeToString()
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.musify.app
|
||||||
|
|
||||||
|
// Passed as -Dmusify.* JVM args by the `compose.desktop.application` block
|
||||||
|
// (composeApp/build.gradle.kts), sourced from mobile/gradle.properties.
|
||||||
|
actual object AppConfig {
|
||||||
|
actual val apiBaseUrl: String = System.getProperty("musify.apiBaseUrl", "http://localhost:5111")
|
||||||
|
actual val authIssuer: String = System.getProperty("musify.authIssuer", "http://host.docker.internal:8080")
|
||||||
|
actual val authClientId: String = System.getProperty("musify.authClientId", "")
|
||||||
|
actual val nativeRedirectUri: String = "http://127.0.0.1:17453/callback"
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.musify.app
|
||||||
|
|
||||||
|
import androidx.compose.ui.window.Window
|
||||||
|
import androidx.compose.ui.window.application
|
||||||
|
import androidx.compose.ui.unit.DpSize
|
||||||
|
import androidx.compose.ui.window.WindowPosition
|
||||||
|
import androidx.compose.ui.window.WindowState
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
fun main() = application {
|
||||||
|
Window(
|
||||||
|
onCloseRequest = ::exitApplication,
|
||||||
|
title = "Musify",
|
||||||
|
state = WindowState(size = DpSize(420.dp, 760.dp), position = WindowPosition(alignment = androidx.compose.ui.Alignment.Center))
|
||||||
|
) {
|
||||||
|
App()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.musify.app
|
||||||
|
|
||||||
|
import io.ktor.client.HttpClient
|
||||||
|
import io.ktor.client.engine.cio.CIO
|
||||||
|
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||||
|
import io.ktor.client.plugins.logging.LogLevel
|
||||||
|
import io.ktor.client.plugins.logging.Logging
|
||||||
|
import io.ktor.serialization.kotlinx.json.json
|
||||||
|
|
||||||
|
actual fun createHttpClient(): HttpClient = HttpClient(CIO) {
|
||||||
|
install(ContentNegotiation) { json(jsonConfig) }
|
||||||
|
install(Logging) { level = LogLevel.INFO }
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package com.musify.app.audio
|
||||||
|
|
||||||
|
import javafx.application.Platform as JfxPlatform
|
||||||
|
import javafx.scene.media.Media
|
||||||
|
import javafx.scene.media.MediaPlayer
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Boots the JavaFX toolkit headlessly (no Stage/window) so `MediaPlayer` can
|
||||||
|
* decode/play audio inside a plain Compose Desktop (Swing/Skiko) app.
|
||||||
|
*/
|
||||||
|
private object JavaFxBootstrap {
|
||||||
|
private var started = false
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun ensureStarted() {
|
||||||
|
if (started) return
|
||||||
|
started = true
|
||||||
|
try {
|
||||||
|
JfxPlatform.startup {}
|
||||||
|
JfxPlatform.setImplicitExit(false)
|
||||||
|
} catch (_: IllegalStateException) {
|
||||||
|
// Toolkit already running (e.g. re-entrant init) — nothing to do.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
actual class AudioPlayer actual constructor() {
|
||||||
|
private val _state = MutableStateFlow(PlaybackState.IDLE)
|
||||||
|
actual val state: StateFlow<PlaybackState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
private var mediaPlayer: MediaPlayer? = null
|
||||||
|
|
||||||
|
actual fun play(url: String) {
|
||||||
|
JavaFxBootstrap.ensureStarted()
|
||||||
|
_state.value = PlaybackState.LOADING
|
||||||
|
JfxPlatform.runLater {
|
||||||
|
mediaPlayer?.stop()
|
||||||
|
mediaPlayer?.dispose()
|
||||||
|
val player = MediaPlayer(Media(url))
|
||||||
|
mediaPlayer = player
|
||||||
|
player.setOnPlaying { _state.value = PlaybackState.PLAYING }
|
||||||
|
player.setOnPaused { _state.value = PlaybackState.PAUSED }
|
||||||
|
player.setOnStopped { _state.value = PlaybackState.IDLE }
|
||||||
|
player.setOnEndOfMedia { _state.value = PlaybackState.PAUSED }
|
||||||
|
player.setOnError { _state.value = PlaybackState.ERROR }
|
||||||
|
player.play()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun pause() {
|
||||||
|
if (mediaPlayer == null) return
|
||||||
|
JfxPlatform.runLater { mediaPlayer?.pause() }
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun resume() {
|
||||||
|
if (mediaPlayer == null) return
|
||||||
|
JfxPlatform.runLater { mediaPlayer?.play() }
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun stop() {
|
||||||
|
// Nothing was ever played (JavaFX toolkit never booted) — runLater would
|
||||||
|
// throw "Toolkit not initialized" here, e.g. when closing the app cold.
|
||||||
|
if (mediaPlayer == null) {
|
||||||
|
_state.value = PlaybackState.IDLE
|
||||||
|
return
|
||||||
|
}
|
||||||
|
JfxPlatform.runLater { mediaPlayer?.stop() }
|
||||||
|
_state.value = PlaybackState.IDLE
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun dispose() {
|
||||||
|
if (mediaPlayer == null) return
|
||||||
|
JfxPlatform.runLater {
|
||||||
|
mediaPlayer?.dispose()
|
||||||
|
mediaPlayer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+46
@@ -0,0 +1,46 @@
|
|||||||
|
package com.musify.app.auth
|
||||||
|
|
||||||
|
import com.sun.net.httpserver.HttpServer
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.awt.Desktop
|
||||||
|
import java.net.InetSocketAddress
|
||||||
|
import java.net.URI
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC 8252 §7.3 "loopback interface redirection": a one-shot local HTTP
|
||||||
|
* server receives the Authorization Code callback in the system browser, then
|
||||||
|
* shuts itself down. `redirectUri` must match `http://127.0.0.1:17453/callback`
|
||||||
|
* exactly, as registered for the "Musify Native" Zitadel app.
|
||||||
|
*/
|
||||||
|
actual class BrowserAuthLauncher actual constructor() {
|
||||||
|
actual suspend fun launch(authorizeUrl: String, redirectUri: String): String {
|
||||||
|
val redirect = URI(redirectUri)
|
||||||
|
val port = redirect.port.takeIf { it > 0 } ?: error("El redirect URI de Desktop necesita un puerto explícito.")
|
||||||
|
val path = redirect.path.ifEmpty { "/" }
|
||||||
|
|
||||||
|
val result = CompletableDeferred<String>()
|
||||||
|
val server = HttpServer.create(InetSocketAddress("127.0.0.1", port), 0)
|
||||||
|
server.createContext(path) { exchange ->
|
||||||
|
val query = exchange.requestURI.rawQuery.orEmpty()
|
||||||
|
val body = """
|
||||||
|
<html><body style="font-family:sans-serif;padding:2rem">
|
||||||
|
<h2>Musify</h2><p>Ya puedes volver a la app y cerrar esta pestaña.</p>
|
||||||
|
</body></html>
|
||||||
|
""".trimIndent().toByteArray()
|
||||||
|
exchange.responseHeaders.add("Content-Type", "text/html; charset=utf-8")
|
||||||
|
exchange.sendResponseHeaders(200, body.size.toLong())
|
||||||
|
exchange.responseBody.use { it.write(body) }
|
||||||
|
result.complete("$redirectUri?$query")
|
||||||
|
}
|
||||||
|
server.start()
|
||||||
|
|
||||||
|
return try {
|
||||||
|
withContext(Dispatchers.IO) { Desktop.getDesktop().browse(URI(authorizeUrl)) }
|
||||||
|
result.await()
|
||||||
|
} finally {
|
||||||
|
server.stop(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.musify.app.auth
|
||||||
|
|
||||||
|
import java.security.MessageDigest
|
||||||
|
|
||||||
|
actual fun sha256(input: ByteArray): ByteArray = MessageDigest.getInstance("SHA-256").digest(input)
|
||||||
|
|
||||||
|
actual fun nowEpochSeconds(): Long = System.currentTimeMillis() / 1000
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package com.musify.app.auth
|
||||||
|
|
||||||
|
import com.musify.app.jsonConfig
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.serialization.decodeFromString
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
actual class TokenStore actual constructor() {
|
||||||
|
private val file = File(System.getProperty("user.home"), ".musify/tokens.json")
|
||||||
|
|
||||||
|
actual suspend fun save(tokens: StoredTokens) = withContext(Dispatchers.IO) {
|
||||||
|
file.parentFile?.mkdirs()
|
||||||
|
file.writeText(jsonConfig.encodeToString(tokens))
|
||||||
|
}
|
||||||
|
|
||||||
|
actual suspend fun load(): StoredTokens? = withContext(Dispatchers.IO) {
|
||||||
|
if (!file.exists()) return@withContext null
|
||||||
|
try {
|
||||||
|
jsonConfig.decodeFromString<StoredTokens>(file.readText())
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
actual suspend fun clear(): Unit = withContext(Dispatchers.IO) {
|
||||||
|
file.delete()
|
||||||
|
Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package com.musify.app
|
||||||
|
|
||||||
|
// Kotlin/Native has no Gradle-property injection at runtime, so these are
|
||||||
|
// hardcoded — keep them in sync with mobile/gradle.properties. Point
|
||||||
|
// `apiBaseUrl`/`authIssuer` at your Mac's reachable address for the backend
|
||||||
|
// (e.g. the LAN IP of the machine running `dotnet run`, or "localhost" if the
|
||||||
|
// simulator and the backend are on the same Mac).
|
||||||
|
actual object AppConfig {
|
||||||
|
actual val apiBaseUrl: String = "http://localhost:5111"
|
||||||
|
actual val authIssuer: String = "http://localhost:8080"
|
||||||
|
actual val authClientId: String = "390116136177434631"
|
||||||
|
|
||||||
|
// Matches CFBundleURLTypes in Info.plist and the redirect URI registered
|
||||||
|
// for the "Musify Native" app in Zitadel.
|
||||||
|
actual val nativeRedirectUri: String = "musify://callback"
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.musify.app
|
||||||
|
|
||||||
|
import androidx.compose.ui.window.ComposeUIViewController
|
||||||
|
import platform.UIKit.UIViewController
|
||||||
|
|
||||||
|
/** Called from Swift's ContentView — see mobile/README.md for the Xcode-side wrapper. */
|
||||||
|
fun MainViewController(): UIViewController = ComposeUIViewController { App() }
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.musify.app
|
||||||
|
|
||||||
|
import io.ktor.client.HttpClient
|
||||||
|
import io.ktor.client.engine.darwin.Darwin
|
||||||
|
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||||
|
import io.ktor.client.plugins.logging.LogLevel
|
||||||
|
import io.ktor.client.plugins.logging.Logging
|
||||||
|
import io.ktor.serialization.kotlinx.json.json
|
||||||
|
|
||||||
|
actual fun createHttpClient(): HttpClient = HttpClient(Darwin) {
|
||||||
|
install(ContentNegotiation) { json(jsonConfig) }
|
||||||
|
install(Logging) { level = LogLevel.INFO }
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package com.musify.app.audio
|
||||||
|
|
||||||
|
import kotlinx.cinterop.ExperimentalForeignApi
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import platform.AVFoundation.AVPlayer
|
||||||
|
import platform.AVFoundation.AVPlayerItem
|
||||||
|
import platform.AVFoundation.pause
|
||||||
|
import platform.AVFoundation.play
|
||||||
|
import platform.AVFoundation.replaceCurrentItemWithPlayerItem
|
||||||
|
import platform.Foundation.NSURL
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A deliberately simple `AVPlayer` wrapper: it reports PLAYING/PAUSED
|
||||||
|
* optimistically on `play()`/`pause()` rather than observing the item's
|
||||||
|
* `status`/`timeControlStatus` via KVO, to keep this file's Objective-C
|
||||||
|
* interop small enough to review without a Mac to compile-check it against.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
|
actual class AudioPlayer actual constructor() {
|
||||||
|
private val _state = MutableStateFlow(PlaybackState.IDLE)
|
||||||
|
actual val state: StateFlow<PlaybackState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
private val player = AVPlayer()
|
||||||
|
|
||||||
|
actual fun play(url: String) {
|
||||||
|
_state.value = PlaybackState.LOADING
|
||||||
|
val item = AVPlayerItem(uRL = NSURL(string = url)!!)
|
||||||
|
player.replaceCurrentItemWithPlayerItem(item)
|
||||||
|
player.play()
|
||||||
|
_state.value = PlaybackState.PLAYING
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun pause() {
|
||||||
|
player.pause()
|
||||||
|
_state.value = PlaybackState.PAUSED
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun resume() {
|
||||||
|
player.play()
|
||||||
|
_state.value = PlaybackState.PLAYING
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun stop() {
|
||||||
|
player.pause()
|
||||||
|
player.replaceCurrentItemWithPlayerItem(null)
|
||||||
|
_state.value = PlaybackState.IDLE
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun dispose() {
|
||||||
|
stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package com.musify.app.auth
|
||||||
|
|
||||||
|
import kotlinx.cinterop.ExperimentalForeignApi
|
||||||
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
|
import platform.AuthenticationServices.ASWebAuthenticationPresentationContextProvidingProtocol
|
||||||
|
import platform.AuthenticationServices.ASWebAuthenticationSession
|
||||||
|
import platform.Foundation.NSObject
|
||||||
|
import platform.Foundation.NSURL
|
||||||
|
import platform.UIKit.UIApplication
|
||||||
|
import platform.UIKit.UIWindow
|
||||||
|
import kotlin.coroutines.resume
|
||||||
|
import kotlin.coroutines.resumeWithException
|
||||||
|
|
||||||
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
|
private class PresentationContextProvider : NSObject(), ASWebAuthenticationPresentationContextProvidingProtocol {
|
||||||
|
override fun presentationAnchorForWebAuthenticationSession(session: ASWebAuthenticationSession): UIWindow {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
val windows = UIApplication.sharedApplication.windows as List<UIWindow>
|
||||||
|
return windows.firstOrNull() ?: UIWindow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `ASWebAuthenticationSession` owns the whole round trip: it opens a system
|
||||||
|
* browser sheet at [authorizeUrl] (Zitadel's hosted login, with its
|
||||||
|
* "Regístrate" link) and intercepts the `musify://` redirect itself, no app
|
||||||
|
* delegate / `onOpenURL` wiring required.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
|
actual class BrowserAuthLauncher actual constructor() {
|
||||||
|
actual suspend fun launch(authorizeUrl: String, redirectUri: String): String =
|
||||||
|
suspendCancellableCoroutine { continuation ->
|
||||||
|
val scheme = redirectUri.substringBefore("://")
|
||||||
|
val contextProvider = PresentationContextProvider()
|
||||||
|
|
||||||
|
val session = ASWebAuthenticationSession(
|
||||||
|
uRL = NSURL(string = authorizeUrl),
|
||||||
|
callbackURLScheme = scheme,
|
||||||
|
completionHandler = { callbackUrl, error ->
|
||||||
|
when {
|
||||||
|
callbackUrl != null -> continuation.resume(callbackUrl.absoluteString ?: "")
|
||||||
|
error != null -> continuation.resumeWithException(RuntimeException(error.localizedDescription))
|
||||||
|
else -> continuation.resumeWithException(RuntimeException("Inicio de sesión cancelado."))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
session.presentationContextProvider = contextProvider
|
||||||
|
session.prefersEphemeralWebBrowserSession = false
|
||||||
|
session.start()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package com.musify.app.auth
|
||||||
|
|
||||||
|
import kotlinx.cinterop.ExperimentalForeignApi
|
||||||
|
import kotlinx.cinterop.addressOf
|
||||||
|
import kotlinx.cinterop.convert
|
||||||
|
import kotlinx.cinterop.refTo
|
||||||
|
import kotlinx.cinterop.usePinned
|
||||||
|
import platform.CoreCrypto.CC_SHA256
|
||||||
|
import platform.CoreCrypto.CC_SHA256_DIGEST_LENGTH
|
||||||
|
import platform.Foundation.NSDate
|
||||||
|
import platform.Foundation.timeIntervalSince1970
|
||||||
|
|
||||||
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
|
actual fun sha256(input: ByteArray): ByteArray {
|
||||||
|
val digest = UByteArray(CC_SHA256_DIGEST_LENGTH)
|
||||||
|
input.usePinned { pinned ->
|
||||||
|
CC_SHA256(pinned.addressOf(0), input.size.convert(), digest.refTo(0))
|
||||||
|
}
|
||||||
|
return digest.toByteArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun nowEpochSeconds(): Long = NSDate().timeIntervalSince1970.toLong()
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package com.musify.app.auth
|
||||||
|
|
||||||
|
import com.musify.app.jsonConfig
|
||||||
|
import kotlinx.serialization.decodeFromString
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import platform.Foundation.NSUserDefaults
|
||||||
|
|
||||||
|
private const val TOKENS_KEY = "musify_tokens_json"
|
||||||
|
|
||||||
|
actual class TokenStore actual constructor() {
|
||||||
|
private val defaults = NSUserDefaults.standardUserDefaults
|
||||||
|
|
||||||
|
actual suspend fun save(tokens: StoredTokens) {
|
||||||
|
defaults.setObject(jsonConfig.encodeToString(tokens), forKey = TOKENS_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
actual suspend fun load(): StoredTokens? {
|
||||||
|
val json = defaults.stringForKey(TOKENS_KEY) ?: return null
|
||||||
|
return try {
|
||||||
|
jsonConfig.decodeFromString<StoredTokens>(json)
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
actual suspend fun clear() {
|
||||||
|
defaults.removeObjectForKey(TOKENS_KEY)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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" }
|
||||||
BIN
Binary file not shown.
@@ -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
|
||||||
Vendored
+251
@@ -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" "$@"
|
||||||
Vendored
+94
@@ -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
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
rootProject.name = "MusifyMobile"
|
||||||
|
|
||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
gradlePluginPortal()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("UnstableApiUsage")
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
include(":composeApp")
|
||||||
Reference in New Issue
Block a user