Rewrite SuggestionsCache & limit parallelism (#851)

## Description
A rewrite of the `SuggestionsCache` to simplify it as a read-through
cache. The downside of this is that updated suggestions won't be
reflected in the UI unless the user reloads the page, but I think that's
an okay trade off.

Also adds parallelism limits to fetching suggestions to reduce the
number of simultaneous API calls. And moved the combining logic to use
the `Default` dispatcher to free up an IO one.

Also adds an initial delay to both the suggestions & tv provider
workers, so a cold start of the app isn't starved.

### Related issues
Related to #849

### Testing
Emulator & unit testing

## Screenshots
N/A

## AI or LLM usage
None
This commit is contained in:
Ray 2026-02-09 17:39:40 -05:00 committed by GitHub
parent 28ed1a491b
commit b06761eaa5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 99 additions and 223 deletions

View file

@ -285,6 +285,8 @@ dependencies {
ksp(libs.auto.service.ksp)
implementation(platform(libs.okhttp.bom))
implementation(libs.okhttp)
implementation(libs.kache)
implementation(libs.kache.file)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4)

View file

@ -4,9 +4,6 @@ import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import com.github.damontecres.wholphin.ui.nav.Destination
import dagger.hilt.android.scopes.ActivityRetainedScoped
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
@ -19,7 +16,6 @@ class PlaybackLifecycleObserver
private val navigationManager: NavigationManager,
private val playerFactory: PlayerFactory,
private val themeSongPlayer: ThemeSongPlayer,
private val suggestionsCache: SuggestionsCache,
) : DefaultLifecycleObserver {
private var wasPlaying: Boolean? = null
@ -52,6 +48,5 @@ class PlaybackLifecycleObserver
override fun onStop(owner: LifecycleOwner) {
themeSongPlayer.stop()
CoroutineScope(Dispatchers.Main).launch { suggestionsCache.save() }
}
}

View file

@ -8,7 +8,6 @@ import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
@ -50,32 +49,31 @@ class SuggestionService
.asFlow()
.flatMapLatest { user ->
val userId = user?.id ?: return@flatMapLatest flowOf(SuggestionsResource.Empty)
cache.cacheVersion
.map { cache.get(userId, parentId, itemKind)?.ids.orEmpty() }
.distinctUntilChanged()
.flatMapLatest { cachedIds ->
if (cachedIds.isNotEmpty()) {
flow {
try {
emit(SuggestionsResource.Success(fetchItemsByIds(cachedIds, itemKind)))
} catch (e: Exception) {
Timber.e(e, "Failed to fetch items")
emit(SuggestionsResource.Empty)
}
}
} else {
workManager
.getWorkInfosForUniqueWorkFlow(SuggestionsWorker.WORK_NAME)
.map { workInfos ->
val isActive =
workInfos.any {
it.state == WorkInfo.State.RUNNING || it.state == WorkInfo.State.ENQUEUED
}
if (isActive) SuggestionsResource.Loading else SuggestionsResource.Empty
}
val cachedIds = cache.get(userId, parentId, itemKind)?.ids.orEmpty()
if (cachedIds.isNotEmpty()) {
flow {
try {
emit(
SuggestionsResource.Success(
fetchItemsByIds(cachedIds, itemKind),
),
)
} catch (e: Exception) {
Timber.e(e, "Failed to fetch items")
emit(SuggestionsResource.Empty)
}
}
} else {
workManager
.getWorkInfosForUniqueWorkFlow(SuggestionsWorker.WORK_NAME)
.map { workInfos ->
val isActive =
workInfos.any {
it.state == WorkInfo.State.RUNNING || it.state == WorkInfo.State.ENQUEUED
}
if (isActive) SuggestionsResource.Loading else SuggestionsResource.Empty
}
}
}
}

View file

@ -4,15 +4,13 @@ package com.github.damontecres.wholphin.services
import android.content.Context
import com.github.damontecres.wholphin.ui.toServerString
import com.mayakapps.kache.InMemoryKache
import com.mayakapps.kache.ObjectKache
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.UseSerializers
@ -38,39 +36,15 @@ class SuggestionsCache
constructor(
@param:ApplicationContext private val context: Context,
) {
private val cacheDir: File
get() = File(context.cacheDir, "suggestions")
private val json = Json { ignoreUnknownKeys = true }
private val _cacheVersion = MutableStateFlow(0L)
val cacheVersion: StateFlow<Long> = _cacheVersion.asStateFlow()
private val memoryCache: MutableMap<String, CachedSuggestions> =
LinkedHashMap(MAX_MEMORY_CACHE_SIZE, 0.75f, true)
@Volatile
private var diskCacheLoadedUserId: UUID? = null
private val dirtyKeys: MutableSet<String> = mutableSetOf()
private val mutex = Mutex()
@OptIn(ExperimentalSerializationApi::class)
private fun writeEntryToDisk(
key: String,
cached: CachedSuggestions,
) {
runCatching {
val suggestionsDir = cacheDir.apply { mkdirs() }
File(suggestionsDir, "$key.json")
.outputStream()
.use { json.encodeToStream(cached, it) }
}.onFailure { Timber.w(it, "Failed to write evicted cache: $key") }
}
private fun checkForEviction(newKey: String): Pair<String, CachedSuggestions>? {
if (memoryCache.containsKey(newKey) || memoryCache.size < MAX_MEMORY_CACHE_SIZE) {
return null
private val memoryCache =
InMemoryKache<String, CachedSuggestions>(maxSize = 8) {
creationScope = CoroutineScope(Dispatchers.IO)
}
val eldest = memoryCache.entries.firstOrNull() ?: return null
memoryCache.remove(eldest.key)
return if (dirtyKeys.remove(eldest.key)) eldest.key to eldest.value else null
}
private fun cacheKey(
userId: UUID,
@ -78,63 +52,28 @@ class SuggestionsCache
itemKind: BaseItemKind,
) = "${userId.toServerString()}_${libraryId.toServerString()}_${itemKind.serialName}"
private val cacheDir: File
get() = File(context.cacheDir, "suggestions")
@OptIn(ExperimentalSerializationApi::class)
private suspend fun loadFromDisk(userId: UUID) {
if (diskCacheLoadedUserId == userId) return
mutex.withLock {
if (diskCacheLoadedUserId == userId) return@withLock
withContext(Dispatchers.IO) {
val suggestionsDir = cacheDir
if (!suggestionsDir.exists()) {
diskCacheLoadedUserId = userId
return@withContext
}
memoryCache.clear()
suggestionsDir
.listFiles {
it.name.startsWith(userId.toServerString())
}.orEmpty()
.take(MAX_MEMORY_CACHE_SIZE)
.forEach { file ->
runCatching {
val key = file.nameWithoutExtension
val cached =
file
.inputStream()
.use { json.decodeFromStream<CachedSuggestions>(it) }
memoryCache[key] = cached
}.onFailure { Timber.w(it, "Failed to read cache file: ${file.name}") }
}
diskCacheLoadedUserId = userId
}
}
}
@OptIn(ExperimentalSerializationApi::class)
suspend fun get(
userId: UUID,
libraryId: UUID,
itemKind: BaseItemKind,
): CachedSuggestions? {
loadFromDisk(userId)
val key = cacheKey(userId, libraryId, itemKind)
memoryCache[key]?.let { return it }
return withContext(Dispatchers.IO) {
runCatching {
File(cacheDir, "$key.json")
.takeIf { it.exists() }
?.inputStream()
?.use {
return memoryCache.getOrPut(key) {
try {
mutex.withLock {
File(cacheDir, "$key.json").inputStream().use {
json.decodeFromStream<CachedSuggestions>(it)
}?.also { memoryCache[key] = it }
}.onFailure { Timber.w(it, "Failed to read cache: $key") }
.getOrNull()
}
}
} catch (ex: Exception) {
Timber.e(ex, "Exception reading from disk cache")
null
}
}
}
@OptIn(ExperimentalSerializationApi::class)
suspend fun put(
userId: UUID,
libraryId: UUID,
@ -142,75 +81,23 @@ class SuggestionsCache
ids: List<UUID>,
) {
val key = cacheKey(userId, libraryId, itemKind)
val cached = CachedSuggestions(ids)
val evictedEntry =
val suggestions = CachedSuggestions(ids)
memoryCache.put(key, suggestions)
try {
cacheDir.mkdirs()
mutex.withLock {
val evicted = checkForEviction(key)
memoryCache[key] = cached
dirtyKeys.add(key)
_cacheVersion.update { it + 1 }
evicted
}
evictedEntry?.let { (evictedKey, evictedValue) ->
withContext(Dispatchers.IO) {
writeEntryToDisk(evictedKey, evictedValue)
}
}
}
suspend fun isEmpty(): Boolean =
mutex.withLock {
if (memoryCache.isNotEmpty() || dirtyKeys.isNotEmpty()) {
return@withLock false
}
withContext(Dispatchers.IO) {
val files = cacheDir.listFiles()
files == null || files.isEmpty()
}
}
@OptIn(ExperimentalSerializationApi::class)
suspend fun save() {
val entriesToSave =
mutex.withLock {
if (dirtyKeys.isEmpty()) return
val entries =
dirtyKeys.mapNotNull { key ->
memoryCache[key]?.let { key to it }
}
dirtyKeys.clear()
entries
}
withContext(Dispatchers.IO) {
val suggestionsDir =
cacheDir.apply {
if (!mkdirs() && !exists()) Timber.w("Failed to create suggestions cache directory")
File(cacheDir, "$key.json").outputStream().use {
json.encodeToStream(suggestions, it)
}
entriesToSave.forEach { (key, value) ->
runCatching {
File(suggestionsDir, "$key.json")
.outputStream()
.use { json.encodeToStream(value, it) }
}.onFailure { Timber.w(it, "Failed to write cache: $key") }
}
} catch (ex: Exception) {
Timber.e(ex, "Exception writing to disk cache")
}
}
suspend fun clear() {
mutex.withLock {
memoryCache.clear()
dirtyKeys.clear()
_cacheVersion.update { it + 1 }
diskCacheLoadedUserId = null
}
withContext(Dispatchers.IO) {
runCatching { cacheDir.deleteRecursively() }
.onFailure { Timber.w(it, "Failed to clear suggestions cache") }
}
}
companion object {
private const val MAX_MEMORY_CACHE_SIZE = 8
}
}
fun ObjectKache<*, *>.isEmpty(): Boolean = this.size == 0L
fun ObjectKache<*, *>.isNotEmpty(): Boolean = !isEmpty()
suspend fun <T : Any> ObjectKache<T, *>.containsKey(key: T): Boolean = get(key) != null

View file

@ -80,13 +80,7 @@ class SuggestionsSchedulerService
BackoffPolicy.EXPONENTIAL,
15.minutes.toJavaDuration(),
).setInputData(inputData)
if (cache.isEmpty()) {
Timber.i("Suggestions cache empty, scheduling periodic fetch with 30s delay")
periodicWorkRequestBuilder.setInitialDelay(30.seconds.toJavaDuration())
} else {
Timber.i("Scheduling periodic SuggestionsWorker")
}
.setInitialDelay(60.seconds.toJavaDuration())
workManager.enqueueUniquePeriodicWork(
uniqueWorkName = SuggestionsWorker.WORK_NAME,

View file

@ -18,6 +18,7 @@ import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.supervisorScope
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.exception.ApiClientException
import org.jellyfin.sdk.api.client.extensions.userViewsApi
@ -31,6 +32,7 @@ import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber
import java.util.UUID
import kotlin.coroutines.CoroutineContext
private val BaseItemDto.relevantId: UUID get() = seriesId ?: id
@ -79,6 +81,7 @@ class SuggestionsWorker
}
val results =
supervisorScope {
val context = Dispatchers.IO.limitedParallelism(2, "fetchSuggestions")
views
.mapNotNull { view ->
val itemKind =
@ -90,7 +93,14 @@ class SuggestionsWorker
async(Dispatchers.IO) {
runCatching {
Timber.v("Fetching suggestions for view %s", view.id)
val suggestions = fetchSuggestions(view.id, userId, itemKind, itemsPerRow)
val suggestions =
fetchSuggestions(
context,
view.id,
userId,
itemKind,
itemsPerRow,
)
ensureActive()
cache.put(
userId,
@ -110,7 +120,6 @@ class SuggestionsWorker
}
val successCount = results.count { it.isSuccess }
val failureCount = results.count { it.isFailure }
cache.save()
if (failureCount > 0 && successCount == 0) {
Timber.w("All attempts failed ($failureCount views), scheduling retry")
return Result.retry()
@ -127,6 +136,7 @@ class SuggestionsWorker
}
private suspend fun fetchSuggestions(
coroutineContext: CoroutineContext,
parentId: UUID,
userId: UUID,
itemKind: BaseItemKind,
@ -141,7 +151,7 @@ class SuggestionsWorker
val freshLimit = (itemsPerRow * 0.3).toInt().coerceAtLeast(1)
val historyDeferred =
async(Dispatchers.IO) {
async(coroutineContext) {
fetchItems(
parentId = parentId,
userId = userId,
@ -163,7 +173,7 @@ class SuggestionsWorker
val excludeIds = seedItems.mapTo(HashSet()) { it.relevantId }
val contextualDeferred =
async(Dispatchers.IO) {
async(coroutineContext) {
if (allGenreIds.isEmpty()) {
emptyList()
} else {
@ -181,7 +191,7 @@ class SuggestionsWorker
}
val randomDeferred =
async(Dispatchers.IO) {
async(coroutineContext) {
fetchItems(
parentId = parentId,
userId = userId,
@ -193,7 +203,7 @@ class SuggestionsWorker
}
val freshDeferred =
async(Dispatchers.IO) {
async(coroutineContext) {
fetchItems(
parentId = parentId,
userId = userId,
@ -204,18 +214,19 @@ class SuggestionsWorker
limit = freshLimit,
)
}
withContext(Dispatchers.Default) {
val contextual = contextualDeferred.await()
val random = randomDeferred.await()
val fresh = freshDeferred.await()
val contextual = contextualDeferred.await()
val random = randomDeferred.await()
val fresh = freshDeferred.await()
(contextual + fresh + random)
.asSequence()
.distinctBy { it.id }
.filterNot { excludeIds.contains(it.relevantId) }
.toList()
.shuffled()
.take(itemsPerRow)
(contextual + fresh + random)
.asSequence()
.distinctBy { it.id }
.filterNot { excludeIds.contains(it.relevantId) }
.toList()
.shuffled()
.take(itemsPerRow)
}
}
private suspend fun fetchItems(

View file

@ -21,6 +21,7 @@ import timber.log.Timber
import javax.inject.Inject
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
import kotlin.time.toJavaDuration
@ActivityScoped
@ -60,7 +61,8 @@ class TvProviderSchedulerService
TvProviderWorker.PARAM_USER_ID to user.user.id.toString(),
TvProviderWorker.PARAM_SERVER_ID to user.server.id.toString(),
),
).build(),
).setInitialDelay(60.seconds.toJavaDuration())
.build(),
).await()
}
}

View file

@ -12,7 +12,6 @@ import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.StandardTestDispatcher
@ -86,7 +85,6 @@ class SuggestionServiceTest {
runTest {
val currentUser = MutableLiveData<JellyfinUser?>(null)
every { mockServerRepository.currentUser } returns currentUser
every { mockCache.cacheVersion } returns MutableStateFlow(0L)
every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns flowOf(emptyList())
val service = createService()
@ -104,7 +102,6 @@ class SuggestionServiceTest {
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
every { mockServerRepository.currentUser } returns currentUser
every { mockCache.cacheVersion } returns MutableStateFlow(0L)
coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns null
every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns
flowOf(listOf(mockWorkInfo(state)))
@ -125,7 +122,6 @@ class SuggestionServiceTest {
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
every { mockServerRepository.currentUser } returns currentUser
every { mockCache.cacheVersion } returns MutableStateFlow(0L)
coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns null
every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns
flowOf(listOf(mockWorkInfo(state)))
@ -145,7 +141,6 @@ class SuggestionServiceTest {
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
every { mockServerRepository.currentUser } returns currentUser
every { mockCache.cacheVersion } returns MutableStateFlow(0L)
coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns null
every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns flowOf(emptyList())
@ -164,7 +159,6 @@ class SuggestionServiceTest {
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
every { mockServerRepository.currentUser } returns currentUser
every { mockCache.cacheVersion } returns MutableStateFlow(0L)
every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns flowOf(emptyList())
coEvery { mockCache.get(userId, libraryId, BaseItemKind.MOVIE) } returns null
@ -199,7 +193,6 @@ class SuggestionServiceTest {
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
every { mockServerRepository.currentUser } returns currentUser
every { mockCache.cacheVersion } returns MutableStateFlow(0L)
val cachedId = UUID.randomUUID()
coEvery {
@ -237,7 +230,6 @@ class SuggestionServiceTest {
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
every { mockServerRepository.currentUser } returns currentUser
every { mockCache.cacheVersion } returns MutableStateFlow(0L)
val cachedId = UUID.randomUUID()
coEvery {

View file

@ -1,6 +1,7 @@
package com.github.damontecres.wholphin.services
import android.content.Context
import com.mayakapps.kache.ObjectKache
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
@ -28,11 +29,11 @@ class SuggestionsCacheTest {
return SuggestionsCache(mockContext)
}
private fun memoryCacheOf(cache: SuggestionsCache): MutableMap<String, CachedSuggestions> {
private fun memoryCacheOf(cache: SuggestionsCache): ObjectKache<String, CachedSuggestions> {
val field = SuggestionsCache::class.java.getDeclaredField("memoryCache")
field.isAccessible = true
@Suppress("UNCHECKED_CAST")
return field.get(cache) as MutableMap<String, CachedSuggestions>
return field.get(cache) as ObjectKache<String, CachedSuggestions>
}
@Test
@ -57,7 +58,6 @@ class SuggestionsCacheTest {
val libId = UUID.randomUUID()
cache1.put(userId, libId, BaseItemKind.MOVIE, emptyList())
cache1.save()
// Create a fresh instance which won't have the memory entry
val cache2 = testCacheWithTempDir()
@ -192,7 +192,6 @@ class SuggestionsCacheTest {
val cache1 = testCacheWithTempDir()
cache1.put(userId, lib1, BaseItemKind.MOVIE, ids1)
cache1.put(userId, lib2, BaseItemKind.SERIES, ids2)
cache1.save()
// Read with fresh cache instance (empty memory cache, reads from disk)
val cache2 = testCacheWithTempDir()

View file

@ -12,7 +12,6 @@ import com.github.damontecres.wholphin.data.CurrentUser
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import io.mockk.slot
@ -30,7 +29,6 @@ import org.junit.Before
import org.junit.Rule
import org.junit.Test
import java.util.UUID
import java.util.concurrent.TimeUnit
@OptIn(ExperimentalCoroutinesApi::class)
class SuggestionsSchedulerServiceTest {
@ -65,7 +63,6 @@ class SuggestionsSchedulerServiceTest {
@Test
fun schedules_periodic_work_when_user_present() =
runTest {
coEvery { mockCache.isEmpty() } returns false
createService()
currentLiveData.value =
CurrentUser(
@ -79,7 +76,6 @@ class SuggestionsSchedulerServiceTest {
@Test
fun cancels_work_when_user_null() =
runTest {
coEvery { mockCache.isEmpty() } returns false
createService()
currentLiveData.value =
CurrentUser(
@ -95,7 +91,6 @@ class SuggestionsSchedulerServiceTest {
@Test
fun schedules_periodic_work_with_delay_when_cache_empty() =
runTest {
coEvery { mockCache.isEmpty() } returns true
val workRequestSlot = slot<PeriodicWorkRequest>()
every {
mockWorkManager.enqueueUniquePeriodicWork(
@ -114,13 +109,12 @@ class SuggestionsSchedulerServiceTest {
advanceUntilIdle()
verify { mockWorkManager.enqueueUniquePeriodicWork(SuggestionsWorker.WORK_NAME, any(), any()) }
assertEquals(30000L, workRequestSlot.captured.workSpec.initialDelay)
assertEquals(60000L, workRequestSlot.captured.workSpec.initialDelay)
}
@Test
fun schedules_periodic_work_without_delay_when_cache_not_empty() =
fun schedules_periodic_work_with_delay_when_cache_not_empty() =
runTest {
coEvery { mockCache.isEmpty() } returns false
val workRequestSlot = slot<PeriodicWorkRequest>()
every {
mockWorkManager.enqueueUniquePeriodicWork(
@ -139,6 +133,6 @@ class SuggestionsSchedulerServiceTest {
advanceUntilIdle()
verify { mockWorkManager.enqueueUniquePeriodicWork(SuggestionsWorker.WORK_NAME, any(), any()) }
assertEquals(0L, workRequestSlot.captured.workSpec.initialDelay)
assertEquals(60000L, workRequestSlot.captured.workSpec.initialDelay)
}
}

View file

@ -132,7 +132,6 @@ class SuggestionsWorkerTest {
assertEquals(ListenableWorker.Result.success(), result)
coVerify { mockCache.put(testUserId, viewId, itemKind, any()) }
coVerify { mockCache.save() }
}
}

View file

@ -8,6 +8,7 @@ desugar_jdk_libs = "2.1.5"
hiltCompiler = "1.3.0"
hiltNavigationCompose = "1.3.0"
hiltWork = "1.3.0"
kache = "2.1.1"
kotlin = "2.3.0"
kotlinxCoroutinesCore = "1.10.2"
ksp = "2.3.0"
@ -80,6 +81,8 @@ auto-service-ksp = { module = "dev.zacsweers.autoservice:auto-service-ksp", vers
desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar_jdk_libs" }
hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }
hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" }
kache = { module = "com.mayakapps.kache:kache", version.ref = "kache" }
kache-file = { module = "com.mayakapps.kache:file-kache", version.ref = "kache" }
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutinesCore" }
mockk-agent = { module = "io.mockk:mockk-agent", version.ref = "mockk" }
mockk-android = { module = "io.mockk:mockk-android", version.ref = "mockk" }