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,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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user