diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5a1dbd68..29c36ac2 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/PlaybackLifecycleObserver.kt b/app/src/main/java/com/github/damontecres/wholphin/services/PlaybackLifecycleObserver.kt index cc9f5779..4be082a5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/PlaybackLifecycleObserver.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/PlaybackLifecycleObserver.kt @@ -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() } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionService.kt index a38c713d..db6cef2c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionService.kt @@ -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 + } + } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsCache.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsCache.kt index f131b13b..5542ea51 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsCache.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsCache.kt @@ -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 = _cacheVersion.asStateFlow() - - private val memoryCache: MutableMap = - LinkedHashMap(MAX_MEMORY_CACHE_SIZE, 0.75f, true) - - @Volatile - private var diskCacheLoadedUserId: UUID? = null - private val dirtyKeys: MutableSet = 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? { - if (memoryCache.containsKey(newKey) || memoryCache.size < MAX_MEMORY_CACHE_SIZE) { - return null + private val memoryCache = + InMemoryKache(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(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(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, ) { 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 ObjectKache.containsKey(key: T): Boolean = get(key) != null diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerService.kt index 42d59dae..edca582b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerService.kt @@ -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, diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsWorker.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsWorker.kt index 405947c4..1ee5870b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsWorker.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsWorker.kt @@ -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( diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderSchedulerService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderSchedulerService.kt index ab0e04bc..297cbd64 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderSchedulerService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderSchedulerService.kt @@ -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() } } diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionServiceTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionServiceTest.kt index 2f858ab1..e1663543 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionServiceTest.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionServiceTest.kt @@ -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(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(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(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(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(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(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(mockUser(userId)) every { mockServerRepository.currentUser } returns currentUser - every { mockCache.cacheVersion } returns MutableStateFlow(0L) val cachedId = UUID.randomUUID() coEvery { diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsCacheTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsCacheTest.kt index 709509c6..82e3f2c8 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsCacheTest.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsCacheTest.kt @@ -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 { + private fun memoryCacheOf(cache: SuggestionsCache): ObjectKache { val field = SuggestionsCache::class.java.getDeclaredField("memoryCache") field.isAccessible = true @Suppress("UNCHECKED_CAST") - return field.get(cache) as MutableMap + return field.get(cache) as ObjectKache } @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() diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerServiceTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerServiceTest.kt index 088b4678..7da1d63c 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerServiceTest.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerServiceTest.kt @@ -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() 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() 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) } } diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsWorkerTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsWorkerTest.kt index 7e5d2af0..7d633d87 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsWorkerTest.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsWorkerTest.kt @@ -132,7 +132,6 @@ class SuggestionsWorkerTest { assertEquals(ListenableWorker.Result.success(), result) coVerify { mockCache.put(testUserId, viewId, itemKind, any()) } - coVerify { mockCache.save() } } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b2a32580..d5298ea6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" }