Fix crash on certain grid pages when the app is sent to the background (#1605)

## Description
Fixes a crash when backgrounding/restoring the app on certain grid pages

### Related issues
Fixes https://github.com/damontecres/Wholphin/issues/1598
Replaces #1599

### Testing
Emulator

It's easy to reproduce by going to a library's recommended tab,
scrolling to any row's view more, then pressing home to background the
app

## Screenshots
N/A

## AI or LLM usage
None, but includes a commit from #1599 which did, see the note there

---------

Co-authored-by: Christian Tismer <tismer@stackless.com>
This commit is contained in:
Damontecres 2026-06-20 20:06:34 -04:00 • committed by GitHub
parent 8e0798ee16
commit ae49e977ce
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 251 additions and 217 deletions

View file

@ -61,6 +61,7 @@ import com.github.damontecres.wholphin.ui.util.ProvideLocalClock
import com.github.damontecres.wholphin.util.DebugLogTree
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.WholphinDispatchers
import com.github.damontecres.wholphin.util.requestSerializersModule
import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
@ -141,6 +142,7 @@ class MainActivity : AppCompatActivity() {
private val json =
Json {
classDiscriminator = "_type"
serializersModule = requestSerializersModule
}
@OptIn(ExperimentalTvMaterial3Api::class)
@ -151,10 +153,22 @@ class MainActivity : AppCompatActivity() {
lifecycle.addObserver(playbackLifecycleObserver)
val backStackStr = savedInstanceState?.getString(KEY_BACK_STACK)
if (backStackStr != null) {
val restoredBackStack =
if (backStackStr != null) {
try {
json.decodeFromString<List<Destination>>(backStackStr)
} catch (ex: Exception) {
// Best-effort: a previously persisted back stack we can no longer decode
// must not crash startup; fall back to a fresh start destination.
Timber.w(ex, "Could not restore back stack; starting fresh")
null
}
} else {
null
}
if (restoredBackStack != null) {
Timber.d("Restoring back stack")
var backStack = json.decodeFromString<List<Destination>>(backStackStr)
var backStack = restoredBackStack
if (!playExternalViewModel.launched.value) {
val lastDest = backStack.lastOrNull()
if (lastDest.isPlayback) {
@ -320,8 +334,14 @@ class MainActivity : AppCompatActivity() {
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
Timber.d("onSaveInstanceState")
val str = json.encodeToString(navigationManager.backStack.toList())
outState.putString(KEY_BACK_STACK, str)
try {
val str = json.encodeToString(navigationManager.backStack.toList())
outState.putString(KEY_BACK_STACK, str)
} catch (ex: Exception) {
// Best-effort: some destinations carry types we can't serialize; skip persisting
// the back stack rather than crashing in onSaveInstanceState.
Timber.w(ex, "Could not persist back stack; skipping")
}
val playerBackend =
runBlocking { userPreferencesDataStore.data.firstOrNull() }?.playbackPreferences?.playerBackend
outState.putBoolean(KEY_EXTERNAL_PLAYER, playerBackend == PlayerBackend.EXTERNAL_PLAYER)

View file

@ -5,6 +5,9 @@ import com.github.damontecres.wholphin.ui.DEFAULT_PAGE_SIZE
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.Serializable
import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.modules.polymorphic
import kotlinx.serialization.modules.subclass
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.Response
import org.jellyfin.sdk.api.client.extensions.genresApi
@ -107,7 +110,8 @@ class ApiRequestPager<T>(
/**
* Specifies how a [RequestPager] should prepare and execute API calls
*/
interface RequestHandler<T> {
@Serializable
sealed interface RequestHandler<T> {
/**
* Prepare the given request with the specified parameters (eg which page to fetch)
*/
@ -128,245 +132,255 @@ interface RequestHandler<T> {
}
@Serializable
val GetItemsRequestHandler =
object : RequestHandler<GetItemsRequest> {
override fun prepare(
request: GetItemsRequest,
startIndex: Int,
limit: Int,
enableTotalRecordCount: Boolean,
): GetItemsRequest =
request.copy(
startIndex = startIndex,
limit = limit,
enableTotalRecordCount = enableTotalRecordCount,
)
object GetItemsRequestHandler : RequestHandler<GetItemsRequest> {
override fun prepare(
request: GetItemsRequest,
startIndex: Int,
limit: Int,
enableTotalRecordCount: Boolean,
): GetItemsRequest =
request.copy(
startIndex = startIndex,
limit = limit,
enableTotalRecordCount = enableTotalRecordCount,
)
override suspend fun execute(
api: ApiClient,
request: GetItemsRequest,
): Response<BaseItemDtoQueryResult> = api.itemsApi.getItems(request)
}
override suspend fun execute(
api: ApiClient,
request: GetItemsRequest,
): Response<BaseItemDtoQueryResult> = api.itemsApi.getItems(request)
}
@Serializable
val GetEpisodesRequestHandler =
object : RequestHandler<GetEpisodesRequest> {
override fun prepare(
request: GetEpisodesRequest,
startIndex: Int,
limit: Int,
enableTotalRecordCount: Boolean,
): GetEpisodesRequest =
request.copy(
startIndex = startIndex,
limit = limit,
)
object GetEpisodesRequestHandler : RequestHandler<GetEpisodesRequest> {
override fun prepare(
request: GetEpisodesRequest,
startIndex: Int,
limit: Int,
enableTotalRecordCount: Boolean,
): GetEpisodesRequest =
request.copy(
startIndex = startIndex,
limit = limit,
)
override suspend fun execute(
api: ApiClient,
request: GetEpisodesRequest,
): Response<BaseItemDtoQueryResult> = api.tvShowsApi.getEpisodes(request)
}
override suspend fun execute(
api: ApiClient,
request: GetEpisodesRequest,
): Response<BaseItemDtoQueryResult> = api.tvShowsApi.getEpisodes(request)
}
@Serializable
val GetResumeItemsRequestHandler =
object : RequestHandler<GetResumeItemsRequest> {
override fun prepare(
request: GetResumeItemsRequest,
startIndex: Int,
limit: Int,
enableTotalRecordCount: Boolean,
): GetResumeItemsRequest =
request.copy(
startIndex = startIndex,
limit = limit,
enableTotalRecordCount = enableTotalRecordCount,
)
object GetResumeItemsRequestHandler : RequestHandler<GetResumeItemsRequest> {
override fun prepare(
request: GetResumeItemsRequest,
startIndex: Int,
limit: Int,
enableTotalRecordCount: Boolean,
): GetResumeItemsRequest =
request.copy(
startIndex = startIndex,
limit = limit,
enableTotalRecordCount = enableTotalRecordCount,
)
override suspend fun execute(
api: ApiClient,
request: GetResumeItemsRequest,
): Response<BaseItemDtoQueryResult> = api.itemsApi.getResumeItems(request)
}
override suspend fun execute(
api: ApiClient,
request: GetResumeItemsRequest,
): Response<BaseItemDtoQueryResult> = api.itemsApi.getResumeItems(request)
}
@Serializable
val GetNextUpRequestHandler =
object : RequestHandler<GetNextUpRequest> {
override fun prepare(
request: GetNextUpRequest,
startIndex: Int,
limit: Int,
enableTotalRecordCount: Boolean,
): GetNextUpRequest =
request.copy(
startIndex = startIndex,
limit = limit,
enableTotalRecordCount = enableTotalRecordCount,
)
object GetNextUpRequestHandler : RequestHandler<GetNextUpRequest> {
override fun prepare(
request: GetNextUpRequest,
startIndex: Int,
limit: Int,
enableTotalRecordCount: Boolean,
): GetNextUpRequest =
request.copy(
startIndex = startIndex,
limit = limit,
enableTotalRecordCount = enableTotalRecordCount,
)
override suspend fun execute(
api: ApiClient,
request: GetNextUpRequest,
): Response<BaseItemDtoQueryResult> = api.tvShowsApi.getNextUp(request)
}
override suspend fun execute(
api: ApiClient,
request: GetNextUpRequest,
): Response<BaseItemDtoQueryResult> = api.tvShowsApi.getNextUp(request)
}
@Serializable
val GetSuggestionsRequestHandler =
object : RequestHandler<GetSuggestionsRequest> {
override fun prepare(
request: GetSuggestionsRequest,
startIndex: Int,
limit: Int,
enableTotalRecordCount: Boolean,
): GetSuggestionsRequest =
request.copy(
startIndex = startIndex,
limit = limit,
enableTotalRecordCount = enableTotalRecordCount,
)
object GetSuggestionsRequestHandler : RequestHandler<GetSuggestionsRequest> {
override fun prepare(
request: GetSuggestionsRequest,
startIndex: Int,
limit: Int,
enableTotalRecordCount: Boolean,
): GetSuggestionsRequest =
request.copy(
startIndex = startIndex,
limit = limit,
enableTotalRecordCount = enableTotalRecordCount,
)
override suspend fun execute(
api: ApiClient,
request: GetSuggestionsRequest,
): Response<BaseItemDtoQueryResult> = api.suggestionsApi.getSuggestions(request)
}
override suspend fun execute(
api: ApiClient,
request: GetSuggestionsRequest,
): Response<BaseItemDtoQueryResult> = api.suggestionsApi.getSuggestions(request)
}
@Serializable
val GetPlaylistItemsRequestHandler =
object : RequestHandler<GetPlaylistItemsRequest> {
override fun prepare(
request: GetPlaylistItemsRequest,
startIndex: Int,
limit: Int,
enableTotalRecordCount: Boolean,
): GetPlaylistItemsRequest =
request.copy(
startIndex = startIndex,
limit = limit,
)
object GetPlaylistItemsRequestHandler : RequestHandler<GetPlaylistItemsRequest> {
override fun prepare(
request: GetPlaylistItemsRequest,
startIndex: Int,
limit: Int,
enableTotalRecordCount: Boolean,
): GetPlaylistItemsRequest =
request.copy(
startIndex = startIndex,
limit = limit,
)
override suspend fun execute(
api: ApiClient,
request: GetPlaylistItemsRequest,
): Response<BaseItemDtoQueryResult> = api.playlistsApi.getPlaylistItems(request)
}
override suspend fun execute(
api: ApiClient,
request: GetPlaylistItemsRequest,
): Response<BaseItemDtoQueryResult> = api.playlistsApi.getPlaylistItems(request)
}
@Serializable
val GetGenresRequestHandler =
object : RequestHandler<GetGenresRequest> {
override fun prepare(
request: GetGenresRequest,
startIndex: Int,
limit: Int,
enableTotalRecordCount: Boolean,
): GetGenresRequest =
request.copy(
startIndex = startIndex,
limit = limit,
enableTotalRecordCount = enableTotalRecordCount,
)
object GetGenresRequestHandler : RequestHandler<GetGenresRequest> {
override fun prepare(
request: GetGenresRequest,
startIndex: Int,
limit: Int,
enableTotalRecordCount: Boolean,
): GetGenresRequest =
request.copy(
startIndex = startIndex,
limit = limit,
enableTotalRecordCount = enableTotalRecordCount,
)
override suspend fun execute(
api: ApiClient,
request: GetGenresRequest,
): Response<BaseItemDtoQueryResult> = api.genresApi.getGenres(request)
}
val GetProgramsDtoHandler =
object : RequestHandler<GetProgramsDto> {
override fun prepare(
request: GetProgramsDto,
startIndex: Int,
limit: Int,
enableTotalRecordCount: Boolean,
): GetProgramsDto =
request.copy(
startIndex = startIndex,
limit = limit,
)
override suspend fun execute(
api: ApiClient,
request: GetProgramsDto,
): Response<BaseItemDtoQueryResult> = api.liveTvApi.getPrograms(request)
}
override suspend fun execute(
api: ApiClient,
request: GetGenresRequest,
): Response<BaseItemDtoQueryResult> = api.genresApi.getGenres(request)
}
@Serializable
val GetPersonsHandler =
object : RequestHandler<GetPersonsRequest> {
override fun prepare(
request: GetPersonsRequest,
startIndex: Int,
limit: Int,
enableTotalRecordCount: Boolean,
): GetPersonsRequest =
request.copy(
object GetProgramsDtoHandler : RequestHandler<GetProgramsDto> {
override fun prepare(
request: GetProgramsDto,
startIndex: Int,
limit: Int,
enableTotalRecordCount: Boolean,
): GetProgramsDto =
request.copy(
startIndex = startIndex,
limit = limit,
)
override suspend fun execute(
api: ApiClient,
request: GetProgramsDto,
): Response<BaseItemDtoQueryResult> = api.liveTvApi.getPrograms(request)
}
@Serializable
object GetPersonsHandler : RequestHandler<GetPersonsRequest> {
override fun prepare(
request: GetPersonsRequest,
startIndex: Int,
limit: Int,
enableTotalRecordCount: Boolean,
): GetPersonsRequest =
request.copy(
// startIndex = startIndex,
limit = limit,
)
limit = limit,
)
override suspend fun execute(
api: ApiClient,
request: GetPersonsRequest,
): Response<BaseItemDtoQueryResult> = api.personsApi.getPersons((request))
}
override suspend fun execute(
api: ApiClient,
request: GetPersonsRequest,
): Response<BaseItemDtoQueryResult> = api.personsApi.getPersons((request))
}
val GetStudiosRequestHandler =
object : RequestHandler<GetStudiosRequest> {
override fun prepare(
request: GetStudiosRequest,
startIndex: Int,
limit: Int,
enableTotalRecordCount: Boolean,
): GetStudiosRequest =
request.copy(
startIndex = startIndex,
limit = limit,
enableTotalRecordCount = enableTotalRecordCount,
)
@Serializable
object GetStudiosRequestHandler : RequestHandler<GetStudiosRequest> {
override fun prepare(
request: GetStudiosRequest,
startIndex: Int,
limit: Int,
enableTotalRecordCount: Boolean,
): GetStudiosRequest =
request.copy(
startIndex = startIndex,
limit = limit,
enableTotalRecordCount = enableTotalRecordCount,
)
override suspend fun execute(
api: ApiClient,
request: GetStudiosRequest,
): Response<BaseItemDtoQueryResult> = api.studiosApi.getStudios(request)
}
override suspend fun execute(
api: ApiClient,
request: GetStudiosRequest,
): Response<BaseItemDtoQueryResult> = api.studiosApi.getStudios(request)
}
val GetRecordingsRequestHandler =
object : RequestHandler<GetRecordingsRequest> {
override fun prepare(
request: GetRecordingsRequest,
startIndex: Int,
limit: Int,
enableTotalRecordCount: Boolean,
): GetRecordingsRequest =
request.copy(
startIndex = startIndex,
limit = limit,
enableTotalRecordCount = enableTotalRecordCount,
)
@Serializable
object GetRecordingsRequestHandler : RequestHandler<GetRecordingsRequest> {
override fun prepare(
request: GetRecordingsRequest,
startIndex: Int,
limit: Int,
enableTotalRecordCount: Boolean,
): GetRecordingsRequest =
request.copy(
startIndex = startIndex,
limit = limit,
enableTotalRecordCount = enableTotalRecordCount,
)
override suspend fun execute(
api: ApiClient,
request: GetRecordingsRequest,
): Response<BaseItemDtoQueryResult> = api.liveTvApi.getRecordings(request)
}
override suspend fun execute(
api: ApiClient,
request: GetRecordingsRequest,
): Response<BaseItemDtoQueryResult> = api.liveTvApi.getRecordings(request)
}
val GetLiveTvChannelsRequestHandler =
object : RequestHandler<GetLiveTvChannelsRequest> {
override fun prepare(
request: GetLiveTvChannelsRequest,
startIndex: Int,
limit: Int,
enableTotalRecordCount: Boolean,
): GetLiveTvChannelsRequest =
request.copy(
startIndex = startIndex,
limit = limit,
@Serializable
object GetLiveTvChannelsRequestHandler : RequestHandler<GetLiveTvChannelsRequest> {
override fun prepare(
request: GetLiveTvChannelsRequest,
startIndex: Int,
limit: Int,
enableTotalRecordCount: Boolean,
): GetLiveTvChannelsRequest =
request.copy(
startIndex = startIndex,
limit = limit,
// enableTotalRecordCount = enableTotalRecordCount,
)
)
override suspend fun execute(
api: ApiClient,
request: GetLiveTvChannelsRequest,
): Response<BaseItemDtoQueryResult> = api.liveTvApi.getLiveTvChannels(request)
override suspend fun execute(
api: ApiClient,
request: GetLiveTvChannelsRequest,
): Response<BaseItemDtoQueryResult> = api.liveTvApi.getLiveTvChannels(request)
}
val requestSerializersModule =
SerializersModule {
polymorphic(Any::class) {
subclass(GetItemsRequest::class)
subclass(GetEpisodesRequest::class)
subclass(GetResumeItemsRequest::class)
subclass(GetNextUpRequest::class)
subclass(GetSuggestionsRequest::class)
subclass(GetPlaylistItemsRequest::class)
subclass(GetGenresRequest::class)
subclass(GetProgramsDto::class)
subclass(GetPersonsRequest::class)
subclass(GetStudiosRequest::class)
subclass(GetRecordingsRequest::class)
subclass(GetLiveTvChannelsRequest::class)
}
}