Shared KMP core and UI, switch to Koin

This commit is contained in:
2026-10-25 20:28:07 +03:00
parent f63b09a9d3
commit 69048452b8
161 changed files with 6626 additions and 4485 deletions

View File

@@ -1,27 +1,25 @@
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.ksp)
alias(libs.plugins.hilt)
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.android.kotlin.multiplatform.library)
}
android {
namespace = "ru.stenfredd.sleepyowl.core.jellyfin"
compileSdk = 37
defaultConfig { minSdk = 26 }
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
kotlin {
android {
namespace = "ru.stenfredd.sleepyowl.core.jellyfin"
compileSdk = 37
minSdk = 26
}
jvm("desktop")
sourceSets {
commonMain.dependencies {
api(project(":core:common"))
api(libs.jellyfin.sdk)
api(libs.okhttp)
api(libs.koin.core)
implementation(libs.kotlinx.serialization.json)
// The SDK logs through slf4j but does not ship it.
runtimeOnly(libs.slf4j.api)
}
}
}
dependencies {
api(libs.jellyfin.sdk)
api(libs.kotlinx.coroutines.android)
api(libs.okhttp)
implementation(libs.androidx.datastore.preferences)
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
implementation(libs.timber)
// The SDK logs through slf4j but does not ship it.
runtimeOnly(libs.slf4j.api)
}

View File

@@ -0,0 +1,13 @@
package ru.stenfredd.sleepyowl.core.jellyfin
import android.content.Context
import android.os.Build
import org.jellyfin.sdk.JellyfinOptions
class AndroidJellyfinPlatform(private val context: Context) : JellyfinPlatform {
override val deviceName = "${Build.MANUFACTURER} ${Build.MODEL}"
override fun configure(options: JellyfinOptions.Builder) {
options.context = context
}
}

View File

@@ -1,13 +1,20 @@
package ru.stenfredd.sleepyowl.core.jellyfin
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.first
import org.json.JSONArray
import org.json.JSONObject
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.longOrNull
import kotlinx.serialization.json.put
import java.util.UUID
/** A user signed in on this device: server + user + token. Several can be saved; one is current. */
@@ -25,53 +32,55 @@ data class Account(
) {
val id: String get() = accountId(serverId, userId)
internal fun toJson(): JSONObject = JSONObject()
.put("serverId", serverId)
.put("serverName", serverName)
.put("serverUrl", serverUrl)
.put("userId", userId.toString())
.put("userName", userName)
.put("token", accessToken)
.put("localUrl", localUrl ?: JSONObject.NULL)
.put("lastUsedAt", lastUsedAt)
internal fun toJson(): JsonObject = buildJsonObject {
put("serverId", serverId)
put("serverName", serverName)
put("serverUrl", serverUrl)
put("userId", userId.toString())
put("userName", userName)
put("token", accessToken)
put("localUrl", localUrl)
put("lastUsedAt", lastUsedAt)
}
internal companion object {
fun fromJson(o: JSONObject) = Account(
serverId = o.getString("serverId"),
serverName = o.optString("serverName"),
serverUrl = o.getString("serverUrl"),
userId = UUID.fromString(o.getString("userId")),
userName = o.optString("userName"),
accessToken = o.getString("token"),
localUrl = o.optString("localUrl").takeIf { it.isNotEmpty() && it != "null" },
lastUsedAt = o.optLong("lastUsedAt"),
)
fun fromJson(o: JsonObject): Account {
fun string(key: String) = o[key]?.jsonPrimitive?.contentOrNull
return Account(
serverId = string("serverId")!!,
serverName = string("serverName").orEmpty(),
serverUrl = string("serverUrl")!!,
userId = UUID.fromString(string("userId")),
userName = string("userName").orEmpty(),
accessToken = string("token")!!,
localUrl = string("localUrl")?.takeIf { it.isNotEmpty() && it != "null" },
lastUsedAt = o["lastUsedAt"]?.jsonPrimitive?.longOrNull ?: 0,
)
}
}
}
fun accountId(serverId: String, userId: UUID) = "$serverId/$userId"
private val Context.sessionStore by preferencesDataStore("jellyfin_session")
/** Saved accounts and the device id, in DataStore; the list is a small JSON array. */
internal class AccountStore(private val context: Context) {
internal class AccountStore(private val sessionStore: DataStore<Preferences>) {
class Saved(val accounts: List<Account>, val currentId: String?, val deviceId: String?)
suspend fun read(): Saved {
val prefs = context.sessionStore.data.first()
val prefs = sessionStore.data.first()
return Saved(parse(prefs), prefs[KEY_CURRENT], prefs[KEY_DEVICE_ID])
}
suspend fun setDeviceId(id: String) {
context.sessionStore.edit { it[KEY_DEVICE_ID] = id }
sessionStore.edit { it[KEY_DEVICE_ID] = id }
}
/** Applies [change] to the saved list and sets the current account; returns the new list. */
suspend fun update(currentId: String?, change: (List<Account>) -> List<Account>): List<Account> {
var result = emptyList<Account>()
context.sessionStore.edit { prefs ->
sessionStore.edit { prefs ->
result = change(parse(prefs))
prefs[KEY_ACCOUNTS] = JSONArray(result.map { it.toJson() }).toString()
prefs[KEY_ACCOUNTS] = JsonArray(result.map { it.toJson() }).toString()
if (currentId != null) prefs[KEY_CURRENT] = currentId else prefs.remove(KEY_CURRENT)
}
return result
@@ -79,7 +88,7 @@ internal class AccountStore(private val context: Context) {
/** The single session saved before accounts existed (prototype); null when there is none. */
suspend fun legacySession(): LegacySession? {
val prefs = context.sessionStore.data.first()
val prefs = sessionStore.data.first()
return LegacySession(
url = prefs[LEGACY_URL] ?: return null,
token = prefs[LEGACY_TOKEN] ?: return null,
@@ -89,15 +98,15 @@ internal class AccountStore(private val context: Context) {
}
suspend fun dropLegacySession() {
context.sessionStore.edit { prefs -> listOf(LEGACY_URL, LEGACY_TOKEN, LEGACY_USER_ID, LEGACY_USER_NAME).forEach { prefs.remove(it) } }
sessionStore.edit { prefs -> listOf(LEGACY_URL, LEGACY_TOKEN, LEGACY_USER_ID, LEGACY_USER_NAME).forEach { prefs.remove(it) } }
}
class LegacySession(val url: String, val token: String, val userId: UUID, val userName: String)
private fun parse(prefs: Preferences): List<Account> {
val raw = prefs[KEY_ACCOUNTS] ?: return emptyList()
val array = runCatching { JSONArray(raw) }.getOrNull() ?: return emptyList()
return (0 until array.length()).mapNotNull { runCatching { Account.fromJson(array.getJSONObject(it)) }.getOrNull() }
val array = runCatching { Json.parseToJsonElement(raw).jsonArray }.getOrNull() ?: return emptyList()
return array.mapNotNull { runCatching { Account.fromJson(it.jsonObject) }.getOrNull() }
}
private companion object {

View File

@@ -0,0 +1,16 @@
package ru.stenfredd.sleepyowl.core.jellyfin
import okhttp3.OkHttpClient
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.module
/**
* Koin: the session, login and HTTP of the shared core; the platform adds [JellyfinPlatform] and `PreferenceStores`.
* One HTTP client for the API (SDK) and images (Coil): shared connection pool, LAN routing via [ServerRouting].
*/
val jellyfinModule = module {
singleOf(::ServerRouting)
single { OkHttpClient.Builder().addInterceptor(get<ServerRouting>()).build() }
singleOf(::JellyfinSession)
singleOf(::ServerLogin)
}

View File

@@ -0,0 +1,12 @@
package ru.stenfredd.sleepyowl.core.jellyfin
import org.jellyfin.sdk.JellyfinOptions
/** What [JellyfinSession] takes from the platform it runs on. */
interface JellyfinPlatform {
/** The device's name in the server's list of devices. */
val deviceName: String
/** The platform's part of the SDK setup. */
fun configure(options: JellyfinOptions.Builder) {}
}

View File

@@ -1,9 +1,5 @@
package ru.stenfredd.sleepyowl.core.jellyfin
import android.content.Context
import android.os.Build
import android.os.SystemClock
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -28,10 +24,10 @@ import org.jellyfin.sdk.discovery.DiscoveryService
import org.jellyfin.sdk.model.ClientInfo
import org.jellyfin.sdk.model.DeviceInfo
import org.jellyfin.sdk.model.api.AuthenticationResult
import timber.log.Timber
import ru.stenfredd.sleepyowl.core.common.Log
import ru.stenfredd.sleepyowl.core.common.PreferenceStores
import ru.stenfredd.sleepyowl.core.common.monotonicMs
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
data class SessionInfo(
val accountId: String,
@@ -48,15 +44,15 @@ data class SessionInfo(
* All HTTP goes through one [OkHttpClient] with [ServerRouting]: at home the requests addressed to the server's public
* name go to its LAN address instead (found by [LocalServerDiscovery], remembered per account).
*/
@Singleton
class JellyfinSession @Inject constructor(
@ApplicationContext private val context: Context,
class JellyfinSession(
private val platform: JellyfinPlatform,
stores: PreferenceStores,
httpClient: OkHttpClient,
private val routing: ServerRouting,
) {
private val store = AccountStore(context)
private val store = AccountStore(stores["jellyfin_session"])
private val jellyfin = createJellyfin {
context = this@JellyfinSession.context
platform.configure(this)
clientInfo = ClientInfo(name = CLIENT_NAME, version = CLIENT_VERSION)
apiClientFactory = OkHttpFactory(httpClient)
}
@@ -102,13 +98,13 @@ class JellyfinSession @Inject constructor(
*/
fun refreshLocalAddress() {
val s = _session.value ?: return
val now = SystemClock.elapsedRealtime()
val now = monotonicMs()
if (localCheckedAt != 0L && now - localCheckedAt < LOCAL_RECHECK_MS) return
localCheckedAt = now
backgroundScope.launch {
val found = LocalServerDiscovery.find(s.serverId)
if (_session.value?.accountId != s.accountId) return@launch
if (found != routing.localUrl) Timber.i("Server traffic: %s", found ?: "через ${s.serverUrl}")
if (found != routing.localUrl) Log.i("Server traffic: %s", found ?: "через ${s.serverUrl}")
routing.set(s.serverUrl, found)
mutex.withLock {
if (_accounts.value.firstOrNull { it.id == s.accountId }?.localUrl != found) {
@@ -188,7 +184,7 @@ class JellyfinSession @Inject constructor(
/** Ends the token's session on the server (it disappears from the server's device list); a silent server is not waited for long. */
private suspend fun revoke(client: () -> ApiClient) {
runCatching { withTimeout(REVOKE_TIMEOUT_MS) { withContext(Dispatchers.IO) { client().sessionApi.reportSessionEnded() } } }
.onFailure { Timber.w(it, "Token not revoked on the server") }
.onFailure { Log.w(it, "Token not revoked on the server") }
}
/** The SDK reads response bodies on the calling thread, so every call must leave the main thread. */
@@ -240,7 +236,7 @@ class JellyfinSession @Inject constructor(
val account = Account(serverId, "", legacy.url, legacy.userId, legacy.userName, legacy.token, lastUsedAt = System.currentTimeMillis())
store.update(account.id) { listOf(account) }
store.dropLegacySession()
Timber.i("Migrated the saved session to account %s", account.id)
Log.i("Migrated the saved session to account %s", account.id)
return account
}
@@ -250,7 +246,7 @@ class JellyfinSession @Inject constructor(
return id
}
private fun deviceInfo(id: String) = DeviceInfo(id = id, name = "${Build.MANUFACTURER} ${Build.MODEL}")
private fun deviceInfo(id: String) = DeviceInfo(id = id, name = platform.deviceName)
private companion object {
const val CLIENT_NAME = "SleepyOwl"

View File

@@ -2,8 +2,12 @@ package ru.stenfredd.sleepyowl.core.jellyfin
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
import timber.log.Timber
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import ru.stenfredd.sleepyowl.core.common.Log
import java.net.DatagramPacket
import java.net.DatagramSocket
import java.net.HttpURLConnection
@@ -34,8 +38,8 @@ internal object LocalServerDiscovery {
/** Server id from `/System/Info/Public` of [baseUrl], or null. */
suspend fun serverId(baseUrl: String): String? = withContext(Dispatchers.IO) {
runCatching { JSONObject(get("$baseUrl/System/Info/Public")).optString("Id").takeIf { it.isNotEmpty() } }
.onFailure { Timber.d(it, "Server id not read from %s", baseUrl) }
runCatching { parse(get("$baseUrl/System/Info/Public")).string("Id")?.takeIf { it.isNotEmpty() } }
.onFailure { Log.d(it, "Server id not read from %s", baseUrl) }
.getOrNull()
}
@@ -67,19 +71,23 @@ internal object LocalServerDiscovery {
} catch (_: SocketTimeoutException) {
break
}
val reply = runCatching { JSONObject(String(packet.data, 0, packet.length)) }.getOrNull() ?: continue
val id = reply.optString("Id").takeIf { it.isNotEmpty() } ?: continue
val reply = runCatching { parse(String(packet.data, 0, packet.length)) }.getOrNull() ?: continue
val id = reply.string("Id")?.takeIf { it.isNotEmpty() } ?: continue
val host = packet.address.hostAddress ?: continue
found.getOrPut(id) {
Reply(id, reply.optString("Name"), "http://$host:$HTTP_PORT", reply.optString("Address").trimEnd('/').takeIf { it.startsWith("http") })
Reply(id, reply.string("Name").orEmpty(), "http://$host:$HTTP_PORT", reply.string("Address")?.trimEnd('/')?.takeIf { it.startsWith("http") })
}
if (id == stopAt) break
}
}
}.onFailure { Timber.d(it, "Jellyfin discovery failed") }
}.onFailure { Log.d(it, "Jellyfin discovery failed") }
return found.values.toList()
}
private fun parse(text: String): JsonObject = Json.parseToJsonElement(text).jsonObject
private fun JsonObject.string(key: String): String? = get(key)?.jsonPrimitive?.contentOrNull
private fun get(url: String): String {
val connection = URL(url).openConnection() as HttpURLConnection
connection.connectTimeout = TIMEOUT_MS

View File

@@ -1,6 +1,5 @@
package ru.stenfredd.sleepyowl.core.jellyfin
import android.os.SystemClock
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
@@ -15,10 +14,9 @@ import org.jellyfin.sdk.api.client.extensions.systemApi
import org.jellyfin.sdk.api.client.extensions.userApi
import org.jellyfin.sdk.discovery.RecommendedServerInfoScore
import org.jellyfin.sdk.model.api.QuickConnectDto
import timber.log.Timber
import ru.stenfredd.sleepyowl.core.common.Log
import ru.stenfredd.sleepyowl.core.common.monotonicMs
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Duration.Companion.seconds
/** A Jellyfin server to log in to. */
@@ -51,8 +49,7 @@ class QuickConnectExpiredException : Exception("Quick Connect request expired")
* address, its public users, password and Quick Connect login. A successful login becomes the current account of
* [JellyfinSession].
*/
@Singleton
class ServerLogin @Inject constructor(private val session: JellyfinSession) {
class ServerLogin(private val session: JellyfinSession) {
/** Servers on the local network with version and ping; empty when there are none (or no network). */
suspend fun discover(): List<Server> = coroutineScope {
@@ -75,16 +72,16 @@ class ServerLogin @Inject constructor(private val session: JellyfinSession) {
val api = client(url, quick = true).systemApi
val info = api.getPublicSystemInfo().content
// The first request also opened the connection (and TLS); the ping is a second one on it.
val started = SystemClock.elapsedRealtime()
val started = monotonicMs()
api.getPublicSystemInfo()
Server(
id = checkNotNull(info.id),
name = info.serverName.orEmpty(),
url = url,
version = info.version,
pingMs = SystemClock.elapsedRealtime() - started,
pingMs = monotonicMs() - started,
)
}.onFailure { Timber.d("No Jellyfin server at %s: %s", url, it.toString()) }.getOrNull()
}.onFailure { Log.d("No Jellyfin server at %s: %s", url, it.toString()) }.getOrNull()
}
/**

View File

@@ -4,7 +4,7 @@ import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.Interceptor
import okhttp3.Response
import timber.log.Timber
import ru.stenfredd.sleepyowl.core.common.Log
import java.io.IOException
import java.net.ConnectException
import java.net.NoRouteToHostException
@@ -46,7 +46,7 @@ class ServerRouting internal constructor() : Interceptor {
} catch (e: IOException) {
// Only failures to connect are safe to repeat: the request never reached the server.
if (!e.isConnectFailure() || chain.call().isCanceled()) throw e
Timber.w("LAN address %s unreachable (%s), using %s", r.local, e.javaClass.simpleName, r.public)
Log.w("LAN address %s unreachable (%s), using %s", r.local, e.javaClass.simpleName, r.public)
if (route === r) route = null
onLocalFailed?.invoke()
chain.proceed(request)

View File

@@ -0,0 +1,8 @@
package ru.stenfredd.sleepyowl.core.jellyfin
import java.io.File
class DesktopJellyfinPlatform : JellyfinPlatform {
// /etc/hostname rather than InetAddress: that one may wait for DNS.
override val deviceName = "SleepyOwl · " + (runCatching { File("/etc/hostname").readText().trim() }.getOrNull()?.ifEmpty { null } ?: "Linux")
}

View File

@@ -1,23 +0,0 @@
package ru.stenfredd.sleepyowl.core.jellyfin
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import okhttp3.OkHttpClient
import javax.inject.Singleton
/** One HTTP client for the API (SDK) and images (Coil): shared connection pool, LAN routing via [ServerRouting]. */
@Module
@InstallIn(SingletonComponent::class)
object HttpModule {
@Provides
@Singleton
fun serverRouting(): ServerRouting = ServerRouting()
@Provides
@Singleton
fun okHttpClient(routing: ServerRouting): OkHttpClient = OkHttpClient.Builder()
.addInterceptor(routing)
.build()
}