From 63b37ef3f42841838cfeb8bf4200207825b2adab Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Sun, 8 Feb 2026 16:21:38 -0500 Subject: [PATCH 1/6] Fix glitchy nav drawer scrolling when opening (#848) ## Description The nav drawer would always glitch a little when it gained focus due it to trying to keep the selected page in view. #842 made this even worst. This PR fixes that issue by hoisting the list scroll state up higher which ensures it is remembered between recompositions. This change also eliminates the need for workarounds to try and scroll it programmatically which is also a tiny performance boost. ### Related issues Follow up to #842 ### Testing Emulator & nvidia shield ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/ui/nav/ApplicationContent.kt | 3 ++ .../damontecres/wholphin/ui/nav/NavDrawer.kt | 35 ++----------------- 2 files changed, 6 insertions(+), 32 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt index ff7643d8..0e3d5a43 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.saveable.rememberSerializable @@ -231,6 +232,7 @@ fun ApplicationContent( ) } } + val navDrawerListState = rememberLazyListState() NavDisplay( backStack = navigationManager.backStack, onBack = { navigationManager.goBack() }, @@ -257,6 +259,7 @@ fun ApplicationContent( user = user, server = server, drawerState = drawerState, + navDrawerListState = navDrawerListState, onClearBackdrop = viewModel::clearBackdrop, modifier = Modifier.fillMaxSize(), ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt index 0b9c8135..6eac3206 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt @@ -18,8 +18,8 @@ import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.Home @@ -41,7 +41,6 @@ import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalView @@ -88,12 +87,10 @@ import com.github.damontecres.wholphin.ui.spacedByWithFooter import com.github.damontecres.wholphin.ui.theme.LocalTheme import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.ui.tryRequestFocus -import com.github.damontecres.wholphin.util.ExceptionHandler import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.imageApi @@ -272,6 +269,7 @@ fun NavDrawer( user: JellyfinUser, server: JellyfinServer, drawerState: DrawerState, + navDrawerListState: LazyListState, onClearBackdrop: () -> Unit, modifier: Modifier = Modifier, viewModel: NavDrawerViewModel = @@ -320,8 +318,6 @@ fun NavDrawer( visibilityThreshold = IntOffset.VisibilityThreshold, ), ) - val config = LocalConfiguration.current - val heightInPx = remember { with(density) { config.screenHeightDp.dp.roundToPx() } } ModalNavigationDrawer( modifier = modifier, @@ -329,28 +325,8 @@ fun NavDrawer( drawerContent = { drawerValue -> val isOpen = drawerValue.isOpen val spacedBy = 4.dp - val listState = rememberLazyListState() val searchFocusRequester = remember { FocusRequester() } - suspend fun scrollToSelected() { - val target = selectedIndex + 2 - try { - if (target !in - listState.firstVisibleItemIndex.. Date: Mon, 9 Feb 2026 15:36:20 -0500 Subject: [PATCH 2/6] Add settings for max days in next up (#850) ## Description Add a settings to set the maximum amount of time items can appear in Next Up. Options range from 7 days to a year, or no limit (current & default behavior) ### Related issues Closes #662 ### Testing Tested on the emulator and verified URLs, added unit tests ## Screenshots N/A, just a new slider bar setting ## AI or LLM usage None --- .../wholphin/preferences/AppPreference.kt | 40 ++++++ .../preferences/AppPreferencesSerializer.kt | 1 + .../wholphin/services/AppUpgradeHandler.kt | 9 ++ .../wholphin/services/LatestNextUpService.kt | 4 + .../services/tvprovider/TvProviderWorker.kt | 4 +- .../wholphin/ui/main/HomeViewModel.kt | 1 + .../ui/preferences/SliderPreference.kt | 2 +- app/src/main/proto/WholphinDataStore.proto | 1 + app/src/main/res/values/strings.xml | 7 + .../services/SuggestionsWorkerTest.kt | 10 +- .../damontecres/wholphin/test/NextUpTest.kt | 136 ++++++++++++++++++ 11 files changed, 208 insertions(+), 7 deletions(-) create mode 100644 app/src/test/java/com/github/damontecres/wholphin/test/NextUpTest.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt index 6015a128..4d7dd76f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt @@ -186,6 +186,45 @@ sealed interface AppPreference { summarizer = { value -> value?.toString() }, ) + val MaxDaysNextUpOptions = listOf(7, 14, 30, 60, 90, 180, 365) + val MaxDaysNextUp = + AppSliderPreference( + title = R.string.max_days_next_up, + defaultValue = -1, + min = 0, + // Max is "no limit" stored as -1 + max = MaxDaysNextUpOptions.lastIndex + 1L, + interval = 1, + getter = { + MaxDaysNextUpOptions + .indexOf(it.homePagePreferences.maxDaysNextUp) + .takeIf { it in MaxDaysNextUpOptions.indices } + ?.toLong() + ?: MaxDaysNextUpOptions.size.toLong() + }, + setter = { prefs, index -> + prefs.updateHomePagePreferences { + maxDaysNextUp = MaxDaysNextUpOptions.getOrNull(index.toInt()) ?: -1 + } + }, + summarizer = { value -> + if (value != null) { + val v = MaxDaysNextUpOptions.getOrNull(value.toInt()) ?: -1 + if (v == -1) { + WholphinApplication.instance.getString(R.string.no_limit) + } else { + WholphinApplication.instance.resources.getQuantityString( + R.plurals.days, + v, + v.toString(), + ) + } + } else { + null + } + }, + ) + val CombineContinueNext = AppSwitchPreference( title = R.string.combine_continue_next, @@ -956,6 +995,7 @@ val basicPreferences = AppPreference.HomePageItems, AppPreference.CombineContinueNext, AppPreference.RewatchNextUp, + AppPreference.MaxDaysNextUp, AppPreference.PlayThemeMusic, AppPreference.RememberSelectedTab, AppPreference.SubtitleStyle, diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt index e2182aa3..6adcdca4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt @@ -82,6 +82,7 @@ class AppPreferencesSerializer maxItemsPerRow = AppPreference.HomePageItems.defaultValue.toInt() enableRewatchingNextUp = AppPreference.RewatchNextUp.defaultValue combineContinueNext = AppPreference.CombineContinueNext.defaultValue + maxDaysNextUp = AppPreference.MaxDaysNextUp.defaultValue.toInt() }.build() interfacePreferences = InterfacePreferences diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt b/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt index 96130f48..057ec550 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt @@ -10,6 +10,7 @@ import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.update import com.github.damontecres.wholphin.preferences.updateAdvancedPreferences +import com.github.damontecres.wholphin.preferences.updateHomePagePreferences import com.github.damontecres.wholphin.preferences.updateInterfacePreferences import com.github.damontecres.wholphin.preferences.updateLiveTvPreferences import com.github.damontecres.wholphin.preferences.updateMpvOptions @@ -237,4 +238,12 @@ suspend fun upgradeApp( } } } + + if (previous.isEqualOrBefore(Version.fromString("0.4.1-14-g0"))) { + appPreferences.updateData { + it.updateHomePagePreferences { + maxDaysNextUp = AppPreference.MaxDaysNextUp.defaultValue.toInt() + } + } + } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt index 44ed1a58..6ac016a3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt @@ -75,7 +75,10 @@ class LatestNextUpService limit: Int, enableRewatching: Boolean, enableResumable: Boolean, + maxDays: Int, ): List { + val nextUpDateCutoff = + maxDays.takeIf { it > 0 }?.let { LocalDateTime.now().minusDays(it.toLong()) } val request = GetNextUpRequest( userId = userId, @@ -86,6 +89,7 @@ class LatestNextUpService enableResumable = enableResumable, enableUserData = true, enableRewatching = enableRewatching, + nextUpDateCutoff = nextUpDateCutoff, ) val nextUp = api.tvShowsApi diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt b/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt index 134e4903..d8816e71 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt @@ -81,6 +81,7 @@ class TvProviderWorker userId, prefs.homePagePreferences.enableRewatchingNextUp, prefs.homePagePreferences.combineContinueNext, + prefs.homePagePreferences.maxDaysNextUp, ) val potentialItemsToAddIds = potentialItemsToAdd.map { it.id.toString() } @@ -145,12 +146,13 @@ class TvProviderWorker userId: UUID, enableRewatching: Boolean, combineContinueNext: Boolean, + maxDaysNextUp: Int, ): List { val resumeItems = latestNextUpService.getResume(userId, 10, true) val seriesIds = resumeItems.mapNotNull { it.data.seriesId } val nextUpItems = latestNextUpService - .getNextUp(userId, 10, enableRewatching, false) + .getNextUp(userId, 10, enableRewatching, false, maxDaysNextUp) .filter { it.data.seriesId != null && it.data.seriesId !in seriesIds } return if (combineContinueNext) { latestNextUpService.buildCombined(resumeItems, nextUpItems) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt index 8e7846b8..0e571544 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt @@ -98,6 +98,7 @@ class HomeViewModel limit, prefs.enableRewatchingNextUp, false, + prefs.maxDaysNextUp, ) val watching = buildList { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/SliderPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/SliderPreference.kt index e5564ee2..ce686708 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/SliderPreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/SliderPreference.kt @@ -65,7 +65,7 @@ fun SliderPreference( } Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier diff --git a/app/src/main/proto/WholphinDataStore.proto b/app/src/main/proto/WholphinDataStore.proto index 41fd78ef..737dbb57 100644 --- a/app/src/main/proto/WholphinDataStore.proto +++ b/app/src/main/proto/WholphinDataStore.proto @@ -82,6 +82,7 @@ message HomePagePreferences{ int32 max_items_per_row = 1; bool enable_rewatching_next_up = 2; bool combine_continue_next = 3; + int32 max_days_next_up = 4; } enum ThemeSongVolume { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 63c7c3d2..2dbad4f1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -260,6 +260,11 @@ %d hour %d hours + + %s days + %s day + %s days + %d items %d item @@ -485,6 +490,8 @@ Slideshow duration Play videos during slideshow Send media info log to server + No limit + Max days in Next Up Disabled 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 ebef9aec..7e5d2af0 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 @@ -78,11 +78,6 @@ class SuggestionsWorkerTest { every { homePagePreferences } returns mockk { every { maxItemsPerRow } returns 25 } } - private fun mockQueryResult(items: List = emptyList()) = - mockk>(relaxed = true) { - every { content } returns mockk { every { this@mockk.items } returns items } - } - @Test fun returns_failure_on_invalid_input() = runTest { @@ -231,3 +226,8 @@ class SuggestionsWorkerTest { assertEquals(ListenableWorker.Result.retry(), result) } } + +fun mockQueryResult(items: List = emptyList()) = + mockk>(relaxed = true) { + every { content } returns mockk { every { this@mockk.items } returns items } + } diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/NextUpTest.kt b/app/src/test/java/com/github/damontecres/wholphin/test/NextUpTest.kt new file mode 100644 index 00000000..0e9bb92e --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/test/NextUpTest.kt @@ -0,0 +1,136 @@ +package com.github.damontecres.wholphin.test + +import android.content.Context +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.github.damontecres.wholphin.preferences.AppPreference +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.updateHomePagePreferences +import com.github.damontecres.wholphin.services.DatePlayedService +import com.github.damontecres.wholphin.services.LatestNextUpService +import com.github.damontecres.wholphin.services.mockQueryResult +import io.mockk.CapturingSlot +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.tvShowsApi +import org.jellyfin.sdk.api.operations.TvShowsApi +import org.jellyfin.sdk.model.UUID +import org.jellyfin.sdk.model.api.request.GetNextUpRequest +import org.junit.Assert +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import java.time.LocalDate + +class NextUpTest { + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + private val testDispatcher = StandardTestDispatcher() + + private val mockTvShowsApi = mockk() + private val mockApi = mockk(relaxed = true) + private val mockContext = mockk() + private val mockDatePlayedService = mockk() + + private val latestNextUpService = + LatestNextUpService(mockContext, mockApi, mockDatePlayedService) + + @Before + fun setUp() { + every { mockApi.tvShowsApi } returns mockTvShowsApi + } + + @Test + fun `Test max 30 days in next up`() = + runTest { + val maxDays = 30 + val nextUpSlot = CapturingSlot() + coEvery { mockTvShowsApi.getNextUp(capture(nextUpSlot)) } returns mockQueryResult() + latestNextUpService.getNextUp( + userId = UUID.randomUUID(), + limit = 10, + enableRewatching = true, + enableResumable = true, + maxDays = maxDays, + ) + Assert.assertEquals(10, nextUpSlot.captured.limit) + val expected = LocalDate.now().minusDays(maxDays.toLong()) + Assert.assertEquals(expected, nextUpSlot.captured.nextUpDateCutoff?.toLocalDate()) + } + + @Test + fun `Test no limit in next up`() = + runTest { + val nextUpSlot = CapturingSlot() + coEvery { mockTvShowsApi.getNextUp(capture(nextUpSlot)) } returns mockQueryResult() + latestNextUpService.getNextUp( + userId = UUID.randomUUID(), + limit = 10, + enableRewatching = true, + enableResumable = true, + maxDays = -1, + ) + Assert.assertEquals(10, nextUpSlot.captured.limit) + Assert.assertNull(nextUpSlot.captured.nextUpDateCutoff) + } + + @Test + fun `Test storing preference`() { + AppPreference.MaxDaysNextUp.setter.invoke(AppPreferences.getDefaultInstance(), 0).let { + Assert.assertEquals(7, it.homePagePreferences.maxDaysNextUp) + } + + AppPreference.MaxDaysNextUp.setter + .invoke( + AppPreferences.getDefaultInstance(), + AppPreference.MaxDaysNextUpOptions.lastIndex.toLong(), + ).let { + Assert.assertEquals(365, it.homePagePreferences.maxDaysNextUp) + } + + AppPreference.MaxDaysNextUp.setter + .invoke(AppPreferences.getDefaultInstance(), 3) + .let { + Assert.assertEquals(60, it.homePagePreferences.maxDaysNextUp) + } + + AppPreference.MaxDaysNextUp.setter + .invoke( + AppPreferences.getDefaultInstance(), + AppPreference.MaxDaysNextUpOptions.lastIndex + 1L, + ).let { + Assert.assertEquals(-1, it.homePagePreferences.maxDaysNextUp) + } + } + + @Test + fun `Test getting preference`() { + AppPreferences + .getDefaultInstance() + .updateHomePagePreferences { maxDaysNextUp = 7 } + .let { + val result = AppPreference.MaxDaysNextUp.getter.invoke(it) + Assert.assertEquals(0, result) + } + + AppPreferences + .getDefaultInstance() + .updateHomePagePreferences { maxDaysNextUp = 60 } + .let { + val result = AppPreference.MaxDaysNextUp.getter.invoke(it) + Assert.assertEquals(3, result) + } + + AppPreferences + .getDefaultInstance() + .updateHomePagePreferences { maxDaysNextUp = -1 } + .let { + val result = AppPreference.MaxDaysNextUp.getter.invoke(it) + Assert.assertEquals(AppPreference.MaxDaysNextUpOptions.lastIndex + 1L, result) + } + } +} From 28ed1a491bf997b8a93e72d5c908cfd0e8cd4caf Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 9 Feb 2026 17:28:03 -0500 Subject: [PATCH 3/6] Configure Renovate (#852) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Welcome to [Renovate](https://redirect.github.com/renovatebot/renovate)! This is an onboarding PR to help you understand and configure settings before regular Pull Requests begin. 🚦 To activate Renovate, merge this Pull Request. To disable Renovate, simply close this Pull Request unmerged. --- ### Detected Package Files * `.github/actions/native-build/action.yml` (github-actions) * `.github/actions/setup/action.yml` (github-actions) * `.github/workflows/main.yml` (github-actions) * `.github/workflows/pr.yml` (github-actions) * `.github/workflows/release.yml` (github-actions) * `gradle.properties` (gradle) * `settings.gradle.kts` (gradle) * `build.gradle.kts` (gradle) * `app/build.gradle.kts` (gradle) * `gradle/libs.versions.toml` (gradle) * `gradle/wrapper/gradle-wrapper.properties` (gradle-wrapper) ### Configuration Summary Based on the default config's presets, Renovate will: - Start dependency updates only once this onboarding PR is merged - Hopefully safe environment variables to allow users to configure. - Show all Merge Confidence badges for pull requests. - Enable Renovate Dependency Dashboard creation. - Use semantic commit type `fix` for dependencies and `chore` for all others if semantic commits are in use. - Ignore `node_modules`, `bower_components`, `vendor` and various test/tests (except for nuget) directories. - Group known monorepo packages together. - Use curated list of recommended non-monorepo package groupings. - Show only the Age and Confidence Merge Confidence badges for pull requests. - Apply crowd-sourced package replacement rules. - Apply crowd-sourced workarounds for known problems with packages. - Ensure that every dependency pinned by digest and sourced from GitHub.com contains a link to the commit-to-commit diff - Correctly link to the source code for golang.org/x packages - Link to pkg.go.dev/... for golang.org/x packages' title 🔡 Do you want to change how Renovate upgrades your dependencies? Add your custom config to `renovate.json` in this branch. Renovate will update the Pull Request description the next time it runs. --- ### What to Expect With your current configuration, Renovate will create 11 Pull Requests:
Update Androidx Media3 to v1.9.2 - Schedule: ["at any time"] - Branch name: `renovate/androidx-media3` - Merge into: `main` - Upgrade [androidx.media3:media3-effect](https://redirect.github.com/androidx/media) to `1.9.2` - Upgrade [androidx.media3:media3-ui-compose](https://redirect.github.com/androidx/media) to `1.9.2` - Upgrade [androidx.media3:media3-ui](https://redirect.github.com/androidx/media) to `1.9.2` - Upgrade [androidx.media3:media3-exoplayer-dash](https://redirect.github.com/androidx/media) to `1.9.2` - Upgrade [androidx.media3:media3-exoplayer-hls](https://redirect.github.com/androidx/media) to `1.9.2` - Upgrade [androidx.media3:media3-session](https://redirect.github.com/androidx/media) to `1.9.2` - Upgrade [androidx.media3:media3-exoplayer](https://redirect.github.com/androidx/media) to `1.9.2` - Upgrade [androidx.media3:media3-datasource-okhttp](https://redirect.github.com/androidx/media) to `1.9.2`
Update dependency com.google.devtools.ksp to v2.3.5 - Schedule: ["at any time"] - Branch name: `renovate/ksp-monorepo` - Merge into: `main` - Upgrade [com.google.devtools.ksp](https://redirect.github.com/google/ksp) to `2.3.5`
Update Dependencies - Schedule: ["at any time"] - Branch name: `renovate/dependencies` - Merge into: `main` - Upgrade [com.google.dagger:hilt-android-compiler](https://redirect.github.com/google/dagger) to `2.59.1` - Upgrade [com.google.dagger:hilt-android](https://redirect.github.com/google/dagger) to `2.59.1` - Upgrade [com.google.protobuf:protobuf-kotlin-lite](https://redirect.github.com/protocolbuffers/protobuf) to `4.33.5` - Upgrade [com.mikepenz:multiplatform-markdown-renderer-m3](https://redirect.github.com/mikepenz/multiplatform-markdown-renderer) to `0.39.2` - Upgrade [com.mikepenz:multiplatform-markdown-renderer](https://redirect.github.com/mikepenz/multiplatform-markdown-renderer) to `0.39.2` - Upgrade [io.mockk:mockk-android](https://redirect.github.com/mockk/mockk) to `1.14.9` - Upgrade [io.mockk:mockk-agent](https://redirect.github.com/mockk/mockk) to `1.14.9`
Update dependency com.google.dagger.hilt.android to v2.59.1 - Schedule: ["at any time"] - Branch name: `renovate/hilt` - Merge into: `main` - Upgrade [com.google.dagger.hilt.android](https://redirect.github.com/google/dagger) to `2.59.1`
Update dependency python to 3.14 - Schedule: ["at any time"] - Branch name: `renovate/github-actions` - Merge into: `main` - Upgrade [python](https://redirect.github.com/actions/python-versions) to `3.14`
Update Gradle to v8.14.4 - Schedule: ["at any time"] - Branch name: `renovate/gradle-8.x` - Merge into: `main` - Upgrade [gradle](https://redirect.github.com/gradle/gradle) to `8.14.4`
Update Jellyfin SDK to v1.8.6 - Schedule: ["at any time"] - Branch name: `renovate/jellyfin-sdk` - Merge into: `main` - Upgrade [org.jellyfin.sdk:jellyfin-api-okhttp-jvm](https://redirect.github.com/jellyfin/jellyfin-sdk-kotlin) to `1.8.6` - Upgrade [org.jellyfin.sdk:jellyfin-api](https://redirect.github.com/jellyfin/jellyfin-sdk-kotlin) to `1.8.6` - Upgrade [org.jellyfin.sdk:jellyfin-core](https://redirect.github.com/jellyfin/jellyfin-sdk-kotlin) to `1.8.6`
Update Kotlin - Schedule: ["at any time"] - Branch name: `renovate/kotlin` - Merge into: `main` - Upgrade [org.jetbrains.kotlinx:kotlinx-coroutines-test](https://redirect.github.com/Kotlin/kotlinx.coroutines) to `1.10.2` - Upgrade [org.jetbrains.kotlin.plugin.serialization](https://redirect.github.com/JetBrains/kotlin) to `2.3.10` - Upgrade [org.jetbrains.kotlin.jvm](https://redirect.github.com/JetBrains/kotlin) to `2.3.10` - Upgrade [org.jetbrains.kotlin.plugin.compose](https://redirect.github.com/JetBrains/kotlin) to `2.3.10` - Upgrade [org.jetbrains.kotlin.android](https://redirect.github.com/JetBrains/kotlin) to `2.3.10`
Update dependency com.android.application to v9 - Schedule: ["at any time"] - Branch name: `renovate/major-agp` - Merge into: `main` - Upgrade [com.android.application](https://android.googlesource.com/platform/tools/base) to `9.0.0`
Update Github Actions (major) - Schedule: ["at any time"] - Branch name: `renovate/major-github-actions` - Merge into: `main` - Upgrade [actions/cache](https://redirect.github.com/actions/cache) to `v5` - Upgrade [actions/checkout](https://redirect.github.com/actions/checkout) to `v6` - Upgrade [actions/setup-python](https://redirect.github.com/actions/setup-python) to `v6` - Upgrade [actions/upload-artifact](https://redirect.github.com/actions/upload-artifact) to `v6`
Update Gradle to v9 - Schedule: ["at any time"] - Branch name: `renovate/gradle-9.x` - Merge into: `main` - Upgrade [gradle](https://redirect.github.com/gradle/gradle) to `9.3.1`
🚸 Branch creation will be limited to maximum 2 per hour, so it doesn't swamp any CI resources or overwhelm the project. See docs for `prhourlylimit` for details. --- ❓ Got questions? Check out Renovate's [Docs](https://docs.renovatebot.com/), particularly the Getting Started section. If you need any further assistance then you can also [request help here](https://redirect.github.com/renovatebot/renovate/discussions). --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/damontecres/Wholphin). --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Damontecres --- renovate.json | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 renovate.json diff --git a/renovate.json b/renovate.json new file mode 100644 index 00000000..dc6fbf78 --- /dev/null +++ b/renovate.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:recommended" + ], + "packageRules": [ + { + "groupName": "Dependencies", + "matchDepTypes": ["dependencies"], + "separateMultipleMajor": true + }, + { + "groupName": "Github Actions", + "matchManagers": ["github-actions"] + }, + { + "groupName": "Androidx Media3", + "matchPackageNames": [ + "androidx.media3:**" + ] + }, + { + "groupName": "Jellyfin SDK", + "matchPackageNames": [ + "org.jellyfin.sdk:**" + ] + }, + { + "groupName": "AGP", + "matchPackageNames": [ + "com.android.application:**" + ] + }, + { + "groupName": "Kotlin", + "matchPackageNames": [ + "org.jetbrains.kotlin**" + ] + } + ] +} From b06761eaa58b44760c153c8e1438db32099fa1c5 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Mon, 9 Feb 2026 17:39:40 -0500 Subject: [PATCH 4/6] 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 --- app/build.gradle.kts | 2 + .../services/PlaybackLifecycleObserver.kt | 5 - .../wholphin/services/SuggestionService.kt | 48 +++-- .../wholphin/services/SuggestionsCache.kt | 179 ++++-------------- .../services/SuggestionsSchedulerService.kt | 8 +- .../wholphin/services/SuggestionsWorker.kt | 45 +++-- .../tvprovider/TvProviderSchedulerService.kt | 4 +- .../services/SuggestionServiceTest.kt | 8 - .../wholphin/services/SuggestionsCacheTest.kt | 7 +- .../SuggestionsSchedulerServiceTest.kt | 12 +- .../services/SuggestionsWorkerTest.kt | 1 - gradle/libs.versions.toml | 3 + 12 files changed, 99 insertions(+), 223 deletions(-) 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" } From 4e45502c1c2580e8f35034b265f404fcba33a7c9 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 9 Feb 2026 17:49:17 -0500 Subject: [PATCH 5/6] Update Androidx Media3 to v1.9.2 (#854) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) | |---|---|---|---| | [androidx.media3:media3-effect](https://redirect.github.com/androidx/media) | `1.9.1` → `1.9.2` | ![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.media3:media3-effect/1.9.2?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.media3:media3-effect/1.9.1/1.9.2?slim=true) | | [androidx.media3:media3-ui-compose](https://redirect.github.com/androidx/media) | `1.9.1` → `1.9.2` | ![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.media3:media3-ui-compose/1.9.2?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.media3:media3-ui-compose/1.9.1/1.9.2?slim=true) | | [androidx.media3:media3-ui](https://redirect.github.com/androidx/media) | `1.9.1` → `1.9.2` | ![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.media3:media3-ui/1.9.2?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.media3:media3-ui/1.9.1/1.9.2?slim=true) | | [androidx.media3:media3-exoplayer-dash](https://redirect.github.com/androidx/media) | `1.9.1` → `1.9.2` | ![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.media3:media3-exoplayer-dash/1.9.2?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.media3:media3-exoplayer-dash/1.9.1/1.9.2?slim=true) | | [androidx.media3:media3-exoplayer-hls](https://redirect.github.com/androidx/media) | `1.9.1` → `1.9.2` | ![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.media3:media3-exoplayer-hls/1.9.2?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.media3:media3-exoplayer-hls/1.9.1/1.9.2?slim=true) | | [androidx.media3:media3-session](https://redirect.github.com/androidx/media) | `1.9.1` → `1.9.2` | ![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.media3:media3-session/1.9.2?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.media3:media3-session/1.9.1/1.9.2?slim=true) | | [androidx.media3:media3-exoplayer](https://redirect.github.com/androidx/media) | `1.9.1` → `1.9.2` | ![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.media3:media3-exoplayer/1.9.2?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.media3:media3-exoplayer/1.9.1/1.9.2?slim=true) | | [androidx.media3:media3-datasource-okhttp](https://redirect.github.com/androidx/media) | `1.9.1` → `1.9.2` | ![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.media3:media3-datasource-okhttp/1.9.2?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.media3:media3-datasource-okhttp/1.9.1/1.9.2?slim=true) | --- ### Release Notes
androidx/media (androidx.media3:media3-effect) ### [`v1.9.2`](https://redirect.github.com/androidx/media/blob/HEAD/RELEASENOTES.md#192-2026-02-06) [Compare Source](https://redirect.github.com/androidx/media/compare/1.9.1...1.9.2) This release includes the following changes since [1.9.1 release](#​191-2026-01-26): - ExoPlayer: - Fix bug where `ProgressiveMediaSource` propagates out-of-date timeline info to player and the queued periods unexpectedly get removed ([#​3016](https://redirect.github.com/androidx/media/issues/3016)). - Audio: - Improve the retry logic of `AudioOutput` initialization in `DefaultAudioSink` ([#​2905](https://redirect.github.com/androidx/media/issues/2905)). - Session: - Fix issue where system UI button placement workaround negatively affects other UI surface like Android Auto or manufacturers not needing the workaround ([#​3041](https://redirect.github.com/androidx/media/issues/3041)). - Cast extension: - Fix bug where transferring from Cast to local playback was broken.
--- ### Configuration 📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about these updates again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/damontecres/Wholphin). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d5298ea6..02ae6466 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -26,7 +26,7 @@ tvFoundation = "1.0.0-alpha12" tvMaterial = "1.0.1" lifecycleRuntimeKtx = "2.10.0" activityCompose = "1.12.3" -androidx-media3 = "1.9.1" +androidx-media3 = "1.9.2" coil = "3.3.0" jellyfin-sdk = "1.7.1" nav3Core = "1.0.0" From 6ec10b9b825ba9d671aa84550d79e3a318fca98a Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 9 Feb 2026 17:51:49 -0500 Subject: [PATCH 6/6] Update dependency com.google.devtools.ksp to v2.3.5 (#855) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) | |---|---|---|---| | [com.google.devtools.ksp](https://goo.gle/ksp) ([source](https://redirect.github.com/google/ksp)) | `2.3.0` → `2.3.5` | ![age](https://developer.mend.io/api/mc/badges/age/maven/com.google.devtools.ksp:com.google.devtools.ksp.gradle.plugin/2.3.5?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/com.google.devtools.ksp:com.google.devtools.ksp.gradle.plugin/2.3.0/2.3.5?slim=true) | --- ### Release Notes
google/ksp (com.google.devtools.ksp) ### [`v2.3.5`](https://redirect.github.com/google/ksp/releases/tag/2.3.5) [Compare Source](https://redirect.github.com/google/ksp/compare/2.3.4...2.3.5) #### What's Changed - KSPCoreEnvironment.instance\_prop leaks memory when used programmatically [#​2742](https://redirect.github.com/google/ksp/issues/2742) - Missing first annotation argument when toByte is used [#​2672](https://redirect.github.com/google/ksp/issues/2672) - Fix circular dependency between KSP and KAPT in AGP 9.0 [#​2743](https://redirect.github.com/google/ksp/issues/2743) #### Contributors - Thanks to everyone who reported bugs and participated in discussions! **Full Changelog**: ### [`v2.3.4`](https://redirect.github.com/google/ksp/releases/tag/2.3.4) [Compare Source](https://redirect.github.com/google/ksp/compare/2.3.3...2.3.4) #### What's Changed - KSP ignores sources in Kotlin directory [#​2730](https://redirect.github.com/google/ksp/issues/2730) - Avoid recording Java symbol lookups in non-incremental builds [#​2728](https://redirect.github.com/google/ksp/issues/2728) - Clean up ThreadLocals when processing is done [#​2709](https://redirect.github.com/google/ksp/issues/2709) #### Contributors - Thanks to everyone who reported bugs and participated in discussions! **Full Changelog**: ### [`v2.3.3`](https://redirect.github.com/google/ksp/releases/tag/2.3.3) [Compare Source](https://redirect.github.com/google/ksp/compare/2.3.2...2.3.3) #### What's Changed - Migrate away from a deprecated compilerOptions KGP API [#​2703](https://redirect.github.com/google/ksp/issues/2703) #### Contributors - Thanks to everyone who reported bugs and participated in discussions! **Full Changelog**: ### [`v2.3.2`](https://redirect.github.com/google/ksp/releases/tag/2.3.2) [Compare Source](https://redirect.github.com/google/ksp/compare/2.3.1...2.3.2) #### What's Changed **Note:** This release is a hotfix for a regression introduced in 2.3.1 - Fixed an issue where KSP incorrectly processed specific nullable annotations from Java interfaces, leading to incorrect nullability in the generated Kotlin code [#​2696](https://redirect.github.com/google/ksp/issues/2696) - Fixed a regression introduced in [#​2656](https://redirect.github.com/google/ksp/issues/2656) that caused runtime failures for projects using AGP 8.8.0 and older due to an incompatible, version-specific type check. [#​2694](https://redirect.github.com/google/ksp/issues/2694) #### Contributors Thanks to everyone who reported bugs and participated in discussions! **Full Changelog**: ### [`v2.3.1`](https://redirect.github.com/google/ksp/releases/tag/2.3.1) [Compare Source](https://redirect.github.com/google/ksp/compare/2.3.0...2.3.1) #### What's Changed - Added support for AGP 9.0 and built-in Kotlin [#​2674](https://redirect.github.com/google/ksp/issues/2674) - Fixed a bug in getJvmCheckedException that incorrectly handled nested classes. [#​2584](https://redirect.github.com/google/ksp/issues/2584) - Removed incorrect caching for KSValueArgumentLiteImpl that wrongly merged arguments with different parents, origins, or locations [#​2677](https://redirect.github.com/google/ksp/issues/2677) #### Contributors Thanks to everyone who reported bugs and participated in discussions! **Full Changelog**:
--- ### Configuration 📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/damontecres/Wholphin). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 02ae6466..32a80878 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,7 +11,7 @@ hiltWork = "1.3.0" kache = "2.1.1" kotlin = "2.3.0" kotlinxCoroutinesCore = "1.10.2" -ksp = "2.3.0" +ksp = "2.3.5" coreKtx = "1.17.0" appcompat = "1.7.1" composeBom = "2026.01.01"