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,25 +1,21 @@
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.android.kotlin.multiplatform.library)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.ksp)
alias(libs.plugins.hilt)
}
android {
namespace = "ru.stenfredd.sleepyowl.core.data"
compileSdk = 37
defaultConfig { minSdk = 26 }
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
kotlin {
android {
namespace = "ru.stenfredd.sleepyowl.core.data"
compileSdk = 37
minSdk = 26
}
jvm("desktop")
sourceSets {
commonMain.dependencies {
api(project(":core:jellyfin"))
implementation(libs.kotlinx.serialization.json)
}
}
}
dependencies {
api(project(":core:jellyfin"))
implementation(libs.kotlinx.serialization.json)
implementation(libs.androidx.datastore.preferences)
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
implementation(libs.timber)
}

View File

@@ -0,0 +1,15 @@
package ru.stenfredd.sleepyowl.core.data
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.module
/** Koin: repositories and caches of the shared core (see [ru.stenfredd.sleepyowl.core.jellyfin.jellyfinModule]). */
val dataModule = module {
singleOf(::SessionCache)
singleOf(::LibraryRepository)
singleOf(::WatchRepository)
singleOf(::HomeRepository)
singleOf(::UserSettingsRepository)
singleOf(::ImageUrls)
singleOf(::SearchHistory)
}

View File

@@ -1,8 +1,5 @@
package ru.stenfredd.sleepyowl.core.data
import android.content.Context
import android.os.SystemClock
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -18,16 +15,16 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import ru.stenfredd.sleepyowl.core.common.AppDirs
import ru.stenfredd.sleepyowl.core.common.Log
import ru.stenfredd.sleepyowl.core.common.monotonicMs
import ru.stenfredd.sleepyowl.core.data.model.ItemQuery
import ru.stenfredd.sleepyowl.core.data.model.ItemSummary
import ru.stenfredd.sleepyowl.core.data.model.LibraryView
import ru.stenfredd.sleepyowl.core.data.model.itemKinds
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession
import timber.log.Timber
import java.io.File
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import javax.inject.Singleton
@Serializable
data class HomeContent(
@@ -47,9 +44,8 @@ data class LibraryRow(val library: LibraryView, val items: List<ItemSummary>, va
* run; then refreshed from the server (all requests in parallel, ~0.5 s) and saved again. Library sizes ("N фильмов")
* cost the server ~0.2 s each, so they come last, in a separate update.
*/
@Singleton
class HomeRepository @Inject constructor(
@ApplicationContext context: Context,
class HomeRepository(
dirs: AppDirs,
private val session: JellyfinSession,
private val cache: SessionCache,
private val library: LibraryRepository,
@@ -58,7 +54,7 @@ class HomeRepository @Inject constructor(
private class Remembered(val content: HomeContent, val fetchedAt: Long)
private val remembered = ConcurrentHashMap<String, Remembered>()
private val dir = File(context.filesDir, "home")
private val dir = File(dirs.files, "home")
private val json = Json { ignoreUnknownKeys = true }
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@@ -85,7 +81,7 @@ class HomeRepository @Inject constructor(
val known = remembered[account]
var shown = known?.content ?: readSnapshot(account)
if (shown != null) emit(shown)
if (known == null || SystemClock.elapsedRealtime() - known.fetchedAt >= FRESH_MS) shown = refresh(account, shown)
if (known == null || monotonicMs() - known.fetchedAt >= FRESH_MS) shown = refresh(account, shown)
cache.invalidations.collect { shown = refresh(account, shown) }
}.flowOn(Dispatchers.Default)
@@ -96,12 +92,12 @@ class HomeRepository @Inject constructor(
throw e
} catch (e: Exception) {
if (shown == null) throw e
Timber.w(e, "Home refresh failed, keeping what is shown")
Log.w(e, "Home refresh failed, keeping what is shown")
shown
}
private suspend fun FlowCollector<HomeContent>.fetch(account: String, shown: HomeContent?): HomeContent {
val started = SystemClock.elapsedRealtime()
val started = monotonicMs()
val oldTotals = shown?.libraries.orEmpty().associate { it.library.id to it.total }
val fresh = coroutineScope {
val continueWatching = async { watch.continueWatching() }
@@ -109,7 +105,7 @@ class HomeRepository @Inject constructor(
val rows = library.views().map { view -> async { LibraryRow(view, library.latest(view.id), oldTotals[view.id]) } }
HomeContent(continueWatching.await(), latest.await(), rows.awaitAll())
}
Timber.i("Home fetched in %d ms", SystemClock.elapsedRealtime() - started)
Log.i("Home fetched in %d ms", monotonicMs() - started)
emit(fresh)
remember(account, fresh)
@@ -127,24 +123,24 @@ class HomeRepository @Inject constructor(
}
private suspend fun remember(account: String, content: HomeContent) {
remembered[account] = Remembered(content, SystemClock.elapsedRealtime())
remembered[account] = Remembered(content, monotonicMs())
withContext(Dispatchers.IO) {
runCatching {
dir.mkdirs()
val tmp = File(dir, "${fileName(account)}.tmp")
tmp.writeText(json.encodeToString(HomeContent.serializer(), content))
tmp.renameTo(File(dir, "${fileName(account)}.json"))
}.onFailure { Timber.w(it, "Home snapshot not saved") }
}.onFailure { Log.w(it, "Home snapshot not saved") }
}
}
private suspend fun readSnapshot(account: String): HomeContent? = withContext(Dispatchers.IO) {
val file = File(dir, "${fileName(account)}.json")
if (!file.exists()) return@withContext null
val started = SystemClock.elapsedRealtime()
val started = monotonicMs()
runCatching { json.decodeFromString(HomeContent.serializer(), file.readText()) }
.onSuccess { Timber.i("Home snapshot read in %d ms (%d KB)", SystemClock.elapsedRealtime() - started, file.length() / 1024) }
.onFailure { Timber.w(it, "Home snapshot unreadable") }
.onSuccess { Log.i("Home snapshot read in %d ms (%d KB)", monotonicMs() - started, file.length() / 1024) }
.onFailure { Log.w(it, "Home snapshot unreadable") }
.getOrNull()
}

View File

@@ -5,8 +5,6 @@ import org.jellyfin.sdk.model.api.ImageType
import ru.stenfredd.sleepyowl.core.data.model.ImageKind
import ru.stenfredd.sleepyowl.core.data.model.ImageRef
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession
import javax.inject.Inject
import javax.inject.Singleton
/**
* Image URLs of exactly the displayed size, so the TV only decodes (no scaling, no oversized bitmaps).
@@ -14,8 +12,7 @@ import javax.inject.Singleton
* URLs are built on the server's public address: that is the image cache key, valid at home and away; at home the
* request itself goes to the LAN address (`ServerRouting`).
*/
@Singleton
class ImageUrls @Inject constructor(private val session: JellyfinSession) {
class ImageUrls(private val session: JellyfinSession) {
fun url(ref: ImageRef, widthPx: Int, heightPx: Int, quality: Int = QUALITY): String =
session.api.imageApi.getItemImageUrl(
itemId = ref.itemId,

View File

@@ -25,12 +25,9 @@ import ru.stenfredd.sleepyowl.core.data.model.Person
import ru.stenfredd.sleepyowl.core.data.model.itemKinds
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
/** Browsing the server: libraries, item lists, details, seasons and episodes, people, search. */
@Singleton
class LibraryRepository @Inject constructor(
class LibraryRepository(
private val session: JellyfinSession,
private val cache: SessionCache,
) {

View File

@@ -1,30 +1,29 @@
package ru.stenfredd.sleepyowl.core.data
import android.content.Context
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import org.json.JSONArray
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonPrimitive
import ru.stenfredd.sleepyowl.core.common.PreferenceStores
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession
import javax.inject.Inject
import javax.inject.Singleton
private val Context.searchStore by preferencesDataStore("search")
/** Recent search queries, kept on the device per account, newest first. */
@Singleton
class SearchHistory @Inject constructor(
@ApplicationContext private val context: Context,
class SearchHistory(
stores: PreferenceStores,
private val session: JellyfinSession,
) {
private val searchStore = stores["search"]
private fun key() = stringPreferencesKey("recent:" + session.requireSession().accountId)
fun recent(): Flow<List<String>> {
val key = key()
return context.searchStore.data.map { prefs -> prefs[key]?.let(::parse).orEmpty() }
return searchStore.data.map { prefs -> prefs[key]?.let(::parse).orEmpty() }
}
/** Puts [query] first; the same query typed in another case replaces the old one. */
@@ -32,15 +31,14 @@ class SearchHistory @Inject constructor(
val q = query.trim()
if (q.isEmpty()) return
val key = key()
context.searchStore.edit { prefs ->
searchStore.edit { prefs ->
val list = listOf(q) + prefs[key]?.let(::parse).orEmpty().filterNot { it.equals(q, ignoreCase = true) }
prefs[key] = JSONArray(list.take(LIMIT)).toString()
prefs[key] = JsonArray(list.take(LIMIT).map(::JsonPrimitive)).toString()
}
}
private fun parse(raw: String): List<String> = runCatching {
val array = JSONArray(raw)
List(array.length()) { array.getString(it) }
Json.parseToJsonElement(raw).jsonArray.map { it.jsonPrimitive.content }
}.getOrDefault(emptyList())
private companion object {

View File

@@ -1,6 +1,5 @@
package ru.stenfredd.sleepyowl.core.data
import android.os.SystemClock
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
@@ -15,11 +14,10 @@ import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import ru.stenfredd.sleepyowl.core.common.Log
import ru.stenfredd.sleepyowl.core.common.monotonicMs
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession
import timber.log.Timber
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import javax.inject.Singleton
/**
* Server responses kept in memory while the app runs, so going back to a screen or reopening a tab doesn't wait
@@ -29,8 +27,7 @@ import javax.inject.Singleton
* playback stopped, marked watched or favourite); [invalidations] then tells screens to reload. Library lists and
* counts ([Kind.Stable]) only expire with time. Switching the account drops everything.
*/
@Singleton
class SessionCache @Inject constructor(private val session: JellyfinSession) {
class SessionCache(private val session: JellyfinSession) {
enum class Kind(val ttlMs: Long) {
/** Lists with watch state: resume, next up, latest, details. */
UserData(10 * 60_000L),
@@ -64,7 +61,7 @@ class SessionCache @Inject constructor(private val session: JellyfinSession) {
// Per account, so a call of the previous account finishing after a switch can't land in the new one's cache.
val scoped = "${session.session.value?.accountId}:$key"
while (true) {
val now = SystemClock.elapsedRealtime()
val now = monotonicMs()
val mine = Entry(kind, now, CompletableDeferred())
val entry = entries.compute(scoped) { _, old -> if (old != null && now - old.createdAt < old.kind.ttlMs) old else mine }!!
if (entry !== mine) {
@@ -96,6 +93,6 @@ class SessionCache @Inject constructor(private val session: JellyfinSession) {
private fun drop(kind: Kind) {
val before = entries.size
entries.entries.removeAll { it.value.kind == kind }
Timber.d("Cache: dropped %d entries (%s)", before - entries.size, kind)
Log.d("Cache: dropped %d entries (%s)", before - entries.size, kind)
}
}

View File

@@ -5,8 +5,6 @@ import org.jellyfin.sdk.api.client.extensions.userApi
import org.jellyfin.sdk.model.api.SubtitlePlaybackMode
import org.jellyfin.sdk.model.api.UserConfiguration
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession
import javax.inject.Inject
import javax.inject.Singleton
/**
* Default audio and subtitle languages. They are the user's settings on the server (every Jellyfin app of the user
@@ -25,8 +23,7 @@ enum class SubtitleMode { Default, Always, OnlyForced, Smart, None }
/** Server name, version and address for the settings screen. */
data class ServerInfo(val name: String, val version: String?)
@Singleton
class UserSettingsRepository @Inject constructor(
class UserSettingsRepository(
private val session: JellyfinSession,
private val cache: SessionCache,
) {

View File

@@ -16,12 +16,9 @@ import ru.stenfredd.sleepyowl.core.data.model.Page
import ru.stenfredd.sleepyowl.core.data.model.itemKinds
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
/** What the user watches: continue watching, next up, history, favourites; marking watched and favourite. */
@Singleton
class WatchRepository @Inject constructor(
class WatchRepository(
private val session: JellyfinSession,
private val cache: SessionCache,
private val library: LibraryRepository,