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:
@@ -0,0 +1,12 @@
|
||||
package com.musify.app
|
||||
|
||||
import android.content.Context
|
||||
|
||||
/**
|
||||
* Set once from `MainActivity.onCreate`. Simpler than threading a Context
|
||||
* through every constructor for a demo this size (DataStore, ExoPlayer and
|
||||
* Custom Tabs all need one); a real app would use DI instead.
|
||||
*/
|
||||
object AppContextHolder {
|
||||
lateinit var appContext: Context
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.musify.app
|
||||
|
||||
// BuildConfig.* is generated from the buildConfigField() calls in composeApp/build.gradle.kts,
|
||||
// which read mobile/gradle.properties.
|
||||
actual object AppConfig {
|
||||
actual val apiBaseUrl: String = BuildConfig.API_BASE_URL
|
||||
actual val authIssuer: String = BuildConfig.AUTH_ISSUER
|
||||
actual val authClientId: String = BuildConfig.AUTH_CLIENT_ID
|
||||
|
||||
// Matches the AuthCallbackActivity intent-filter in AndroidManifest.xml and
|
||||
// the redirect URI registered for the "Musify Native" app in Zitadel.
|
||||
actual val nativeRedirectUri: String = "musify://callback"
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.musify.app
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
AppContextHolder.appContext = applicationContext
|
||||
setContent { App() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.musify.app
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.okhttp.OkHttp
|
||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||
import io.ktor.client.plugins.logging.LogLevel
|
||||
import io.ktor.client.plugins.logging.Logging
|
||||
import io.ktor.serialization.kotlinx.json.json
|
||||
|
||||
actual fun createHttpClient(): HttpClient = HttpClient(OkHttp) {
|
||||
install(ContentNegotiation) { json(jsonConfig) }
|
||||
install(Logging) { level = LogLevel.INFO }
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.musify.app.audio
|
||||
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.PlaybackException
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import com.musify.app.AppContextHolder
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
actual class AudioPlayer actual constructor() {
|
||||
private val _state = MutableStateFlow(PlaybackState.IDLE)
|
||||
actual val state: StateFlow<PlaybackState> = _state.asStateFlow()
|
||||
|
||||
private val exoPlayer: ExoPlayer by lazy {
|
||||
ExoPlayer.Builder(AppContextHolder.appContext).build().apply {
|
||||
addListener(object : Player.Listener {
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
_state.value = when (playbackState) {
|
||||
Player.STATE_BUFFERING -> PlaybackState.LOADING
|
||||
Player.STATE_READY -> if (playWhenReady) PlaybackState.PLAYING else PlaybackState.PAUSED
|
||||
Player.STATE_ENDED -> PlaybackState.PAUSED
|
||||
else -> PlaybackState.IDLE
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPlayerError(error: PlaybackException) {
|
||||
_state.value = PlaybackState.ERROR
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
actual fun play(url: String) {
|
||||
_state.value = PlaybackState.LOADING
|
||||
exoPlayer.setMediaItem(MediaItem.fromUri(url))
|
||||
exoPlayer.prepare()
|
||||
exoPlayer.playWhenReady = true
|
||||
}
|
||||
|
||||
actual fun pause() {
|
||||
exoPlayer.pause()
|
||||
}
|
||||
|
||||
actual fun resume() {
|
||||
exoPlayer.play()
|
||||
}
|
||||
|
||||
actual fun stop() {
|
||||
exoPlayer.stop()
|
||||
_state.value = PlaybackState.IDLE
|
||||
}
|
||||
|
||||
actual fun dispose() {
|
||||
exoPlayer.release()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.musify.app.auth
|
||||
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
|
||||
/** Hands the redirect URL from [AuthCallbackActivity] back to the coroutine awaiting it in [BrowserAuthLauncher]. */
|
||||
internal object AndroidAuthBridge {
|
||||
@Volatile
|
||||
var pending: CompletableDeferred<String>? = null
|
||||
|
||||
fun deliver(redirectUrl: String) {
|
||||
pending?.complete(redirectUrl)
|
||||
pending = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.musify.app.auth
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
|
||||
/**
|
||||
* Declared in AndroidManifest.xml with an intent-filter for `musify://callback`.
|
||||
* Chrome Custom Tabs redirects here once Zitadel finishes the login/register
|
||||
* flow; this activity just forwards the URL to [AndroidAuthBridge] and closes.
|
||||
*/
|
||||
class AuthCallbackActivity : Activity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
deliverAndFinish(intent)
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
deliverAndFinish(intent)
|
||||
}
|
||||
|
||||
private fun deliverAndFinish(intent: Intent?) {
|
||||
intent?.data?.toString()?.let { AndroidAuthBridge.deliver(it) }
|
||||
finish()
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package com.musify.app.auth
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.browser.customtabs.CustomTabsIntent
|
||||
import com.musify.app.AppContextHolder
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
|
||||
actual class BrowserAuthLauncher actual constructor() {
|
||||
actual suspend fun launch(authorizeUrl: String, redirectUri: String): String {
|
||||
val deferred = CompletableDeferred<String>()
|
||||
AndroidAuthBridge.pending = deferred
|
||||
|
||||
val customTabsIntent = CustomTabsIntent.Builder().build()
|
||||
customTabsIntent.intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
customTabsIntent.launchUrl(AppContextHolder.appContext, Uri.parse(authorizeUrl))
|
||||
|
||||
return deferred.await()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.musify.app.auth
|
||||
|
||||
import java.security.MessageDigest
|
||||
|
||||
actual fun sha256(input: ByteArray): ByteArray = MessageDigest.getInstance("SHA-256").digest(input)
|
||||
|
||||
actual fun nowEpochSeconds(): Long = System.currentTimeMillis() / 1000
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.musify.app.auth
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import com.musify.app.AppContextHolder
|
||||
import com.musify.app.jsonConfig
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
|
||||
private val Context.authDataStore by preferencesDataStore(name = "musify_auth")
|
||||
private val TOKENS_KEY = stringPreferencesKey("tokens_json")
|
||||
|
||||
actual class TokenStore actual constructor() {
|
||||
private val context get() = AppContextHolder.appContext
|
||||
|
||||
actual suspend fun save(tokens: StoredTokens) {
|
||||
context.authDataStore.edit { prefs -> prefs[TOKENS_KEY] = jsonConfig.encodeToString(tokens) }
|
||||
}
|
||||
|
||||
actual suspend fun load(): StoredTokens? {
|
||||
val json = context.authDataStore.data.first()[TOKENS_KEY] ?: return null
|
||||
return try {
|
||||
jsonConfig.decodeFromString<StoredTokens>(json)
|
||||
} catch (t: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
actual suspend fun clear() {
|
||||
context.authDataStore.edit { it.clear() }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user