Add Kotlin Multiplatform mobile client

A Compose Multiplatform app (mobile/) targeting Android, iOS, and desktop,
sharing one Kotlin codebase: OIDC login via PKCE against the same Zitadel
instance the web player uses (loopback redirect on desktop, a musify://
custom scheme on mobile), a token store per platform, and a first pass at
browsing/streaming tracks through the existing API.

deploy/compose.yml's zitadel-init gains a third OIDC app registration
("Musify Native", public client, no secret) alongside the existing API and
web apps, writing its client id to NATIVE_CLIENT_ID in the env file the same
way the other two already do.
This commit is contained in:
2026-09-10 10:03:11 +02:00
parent aca7238020
commit c5b2b71eea
50 changed files with 2200 additions and 0 deletions
+156
View File
@@ -0,0 +1,156 @@
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidApplication)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler)
alias(libs.plugins.kotlinSerialization)
}
// Backend connection settings, read from gradle.properties so every target
// (Android, Desktop, iOS) is generated with the same values without
// duplicating them in three places. See mobile/README.md.
val apiBaseUrl: String = providers.gradleProperty("musify.apiBaseUrl").get()
val authIssuer: String = providers.gradleProperty("musify.authIssuer").get()
val authClientId: String = providers.gradleProperty("musify.authClientId").get()
// JavaFX Media's os/arch classifier — the demo only needs to run on this box, but
// this keeps `./gradlew :composeApp:run` working on any dev machine unmodified.
val javafxClassifier: String = run {
val os = org.gradle.internal.os.OperatingSystem.current()
val arch = System.getProperty("os.arch")
when {
os.isWindows -> "win"
os.isMacOsX -> if (arch.contains("aarch64")) "mac-aarch64" else "mac"
else -> if (arch.contains("aarch64")) "linux-aarch64" else "linux"
}
}
kotlin {
androidTarget {
@OptIn(ExperimentalKotlinGradlePluginApi::class)
compilerOptions {
jvmTarget.set(JvmTarget.JVM_11)
}
}
jvm("desktop")
listOf(
iosX64(),
iosArm64(),
iosSimulatorArm64()
).forEach { iosTarget: KotlinNativeTarget ->
iosTarget.binaries.framework {
baseName = "ComposeApp"
isStatic = true
}
}
sourceSets {
val commonMain by getting {
dependencies {
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material3)
implementation(compose.ui)
implementation(compose.components.resources)
implementation(compose.components.uiToolingPreview)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.serialization.json)
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json)
implementation(libs.ktor.client.logging)
implementation(libs.coil.compose)
implementation(libs.coil.network.ktor)
}
}
val androidMain by getting {
dependencies {
implementation(libs.ktor.client.okhttp)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.browser)
implementation(libs.androidx.datastore.preferences)
implementation(libs.androidx.media3.exoplayer)
implementation(libs.androidx.media3.common)
}
}
val desktopMain by getting {
dependencies {
implementation(compose.desktop.currentOs)
implementation(libs.ktor.client.cio)
// MediaPlayer only, via Platform.startup() — no javafx-swing/AWT needed.
// OpenJFX's sibling-module POM dependencies aren't classifier-aware in
// Gradle, so javafx-base/-graphics must be pinned explicitly too, or
// Gradle resolves their classifier-less (empty) stub jars instead.
val javafxVersion = libs.versions.javafx.get()
implementation("org.openjfx:javafx-base:$javafxVersion:$javafxClassifier")
implementation("org.openjfx:javafx-graphics:$javafxVersion:$javafxClassifier")
implementation("org.openjfx:javafx-media:$javafxVersion:$javafxClassifier")
}
}
val iosMain by creating {
dependsOn(commonMain)
dependencies {
implementation(libs.ktor.client.darwin)
}
}
getByName("iosX64Main").dependsOn(iosMain)
getByName("iosArm64Main").dependsOn(iosMain)
getByName("iosSimulatorArm64Main").dependsOn(iosMain)
}
}
android {
namespace = "com.musify.app"
compileSdk = libs.versions.compileSdk.get().toInt()
defaultConfig {
applicationId = "com.musify.app"
minSdk = libs.versions.minSdk.get().toInt()
targetSdk = libs.versions.targetSdk.get().toInt()
versionCode = 1
versionName = "1.0"
buildConfigField("String", "API_BASE_URL", "\"$apiBaseUrl\"")
buildConfigField("String", "AUTH_ISSUER", "\"$authIssuer\"")
buildConfigField("String", "AUTH_CLIENT_ID", "\"$authClientId\"")
}
buildFeatures {
buildConfig = true
compose = true
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
packaging {
resources.excludes.add("/META-INF/{AL2.0,LGPL2.1}")
}
}
compose.desktop {
application {
mainClass = "com.musify.app.MainKt"
jvmArgs += listOf(
"-Dmusify.apiBaseUrl=$apiBaseUrl",
"-Dmusify.authIssuer=$authIssuer",
"-Dmusify.authClientId=$authClientId"
)
nativeDistributions {
targetFormats(org.jetbrains.compose.desktop.application.dsl.TargetFormat.Msi)
packageName = "MusifyMobile"
packageVersion = "1.0.0"
}
}
}