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,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() }
}
}