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
@@ -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)
}
}