Design Prot Merge #1

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