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.DebugLogTree
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.WholphinDispatchers import com.github.damontecres.wholphin.util.WholphinDispatchers
import com.github.damontecres.wholphin.util.requestSerializersModule
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
@ -141,6 +142,7 @@ class MainActivity : AppCompatActivity() {
private val json = private val json =
Json { Json {
classDiscriminator = "_type" classDiscriminator = "_type"
serializersModule = requestSerializersModule
} }
@OptIn(ExperimentalTvMaterial3Api::class) @OptIn(ExperimentalTvMaterial3Api::class)
@ -151,10 +153,22 @@ class MainActivity : AppCompatActivity() {
lifecycle.addObserver(playbackLifecycleObserver) lifecycle.addObserver(playbackLifecycleObserver)
val backStackStr = savedInstanceState?.getString(KEY_BACK_STACK) 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") Timber.d("Restoring back stack")
var backStack = json.decodeFromString<List<Destination>>(backStackStr) var backStack = restoredBackStack
if (!playExternalViewModel.launched.value) { if (!playExternalViewModel.launched.value) {
val lastDest = backStack.lastOrNull() val lastDest = backStack.lastOrNull()
if (lastDest.isPlayback) { if (lastDest.isPlayback) {
@ -320,8 +334,14 @@ class MainActivity : AppCompatActivity() {
override fun onSaveInstanceState(outState: Bundle) { override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState) super.onSaveInstanceState(outState)
Timber.d("onSaveInstanceState") Timber.d("onSaveInstanceState")
val str = json.encodeToString(navigationManager.backStack.toList()) try {
outState.putString(KEY_BACK_STACK, str) 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 = val playerBackend =
runBlocking { userPreferencesDataStore.data.firstOrNull() }?.playbackPreferences?.playerBackend runBlocking { userPreferencesDataStore.data.firstOrNull() }?.playbackPreferences?.playerBackend
outState.putBoolean(KEY_EXTERNAL_PLAYER, playerBackend == PlayerBackend.EXTERNAL_PLAYER) 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.CoroutineScope
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.Serializable 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.ApiClient
import org.jellyfin.sdk.api.client.Response import org.jellyfin.sdk.api.client.Response
import org.jellyfin.sdk.api.client.extensions.genresApi 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 * 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) * Prepare the given request with the specified parameters (eg which page to fetch)
*/ */
@ -128,245 +132,255 @@ interface RequestHandler<T> {
} }
@Serializable @Serializable
val GetItemsRequestHandler = object GetItemsRequestHandler : RequestHandler<GetItemsRequest> {
object : RequestHandler<GetItemsRequest> { override fun prepare(
override fun prepare( request: GetItemsRequest,
request: GetItemsRequest, startIndex: Int,
startIndex: Int, limit: Int,
limit: Int, enableTotalRecordCount: Boolean,
enableTotalRecordCount: Boolean, ): GetItemsRequest =
): GetItemsRequest = request.copy(
request.copy( startIndex = startIndex,
startIndex = startIndex, limit = limit,
limit = limit, enableTotalRecordCount = enableTotalRecordCount,
enableTotalRecordCount = enableTotalRecordCount, )
)
override suspend fun execute( override suspend fun execute(
api: ApiClient, api: ApiClient,
request: GetItemsRequest, request: GetItemsRequest,
): Response<BaseItemDtoQueryResult> = api.itemsApi.getItems(request) ): Response<BaseItemDtoQueryResult> = api.itemsApi.getItems(request)
} }
@Serializable @Serializable
val GetEpisodesRequestHandler = object GetEpisodesRequestHandler : RequestHandler<GetEpisodesRequest> {
object : RequestHandler<GetEpisodesRequest> { override fun prepare(
override fun prepare( request: GetEpisodesRequest,
request: GetEpisodesRequest, startIndex: Int,
startIndex: Int, limit: Int,
limit: Int, enableTotalRecordCount: Boolean,
enableTotalRecordCount: Boolean, ): GetEpisodesRequest =
): GetEpisodesRequest = request.copy(
request.copy( startIndex = startIndex,
startIndex = startIndex, limit = limit,
limit = limit, )
)
override suspend fun execute( override suspend fun execute(
api: ApiClient, api: ApiClient,
request: GetEpisodesRequest, request: GetEpisodesRequest,
): Response<BaseItemDtoQueryResult> = api.tvShowsApi.getEpisodes(request) ): Response<BaseItemDtoQueryResult> = api.tvShowsApi.getEpisodes(request)
} }
@Serializable @Serializable
val GetResumeItemsRequestHandler = object GetResumeItemsRequestHandler : RequestHandler<GetResumeItemsRequest> {
object : RequestHandler<GetResumeItemsRequest> { override fun prepare(
override fun prepare( request: GetResumeItemsRequest,
request: GetResumeItemsRequest, startIndex: Int,
startIndex: Int, limit: Int,
limit: Int, enableTotalRecordCount: Boolean,
enableTotalRecordCount: Boolean, ): GetResumeItemsRequest =
): GetResumeItemsRequest = request.copy(
request.copy( startIndex = startIndex,
startIndex = startIndex, limit = limit,
limit = limit, enableTotalRecordCount = enableTotalRecordCount,
enableTotalRecordCount = enableTotalRecordCount, )
)
override suspend fun execute( override suspend fun execute(
api: ApiClient, api: ApiClient,
request: GetResumeItemsRequest, request: GetResumeItemsRequest,
): Response<BaseItemDtoQueryResult> = api.itemsApi.getResumeItems(request) ): Response<BaseItemDtoQueryResult> = api.itemsApi.getResumeItems(request)
} }
@Serializable @Serializable
val GetNextUpRequestHandler = object GetNextUpRequestHandler : RequestHandler<GetNextUpRequest> {
object : RequestHandler<GetNextUpRequest> { override fun prepare(
override fun prepare( request: GetNextUpRequest,
request: GetNextUpRequest, startIndex: Int,
startIndex: Int, limit: Int,
limit: Int, enableTotalRecordCount: Boolean,
enableTotalRecordCount: Boolean, ): GetNextUpRequest =
): GetNextUpRequest = request.copy(
request.copy( startIndex = startIndex,
startIndex = startIndex, limit = limit,
limit = limit, enableTotalRecordCount = enableTotalRecordCount,
enableTotalRecordCount = enableTotalRecordCount, )
)
override suspend fun execute( override suspend fun execute(
api: ApiClient, api: ApiClient,
request: GetNextUpRequest, request: GetNextUpRequest,
): Response<BaseItemDtoQueryResult> = api.tvShowsApi.getNextUp(request) ): Response<BaseItemDtoQueryResult> = api.tvShowsApi.getNextUp(request)
} }
@Serializable @Serializable
val GetSuggestionsRequestHandler = object GetSuggestionsRequestHandler : RequestHandler<GetSuggestionsRequest> {
object : RequestHandler<GetSuggestionsRequest> { override fun prepare(
override fun prepare( request: GetSuggestionsRequest,
request: GetSuggestionsRequest, startIndex: Int,
startIndex: Int, limit: Int,
limit: Int, enableTotalRecordCount: Boolean,
enableTotalRecordCount: Boolean, ): GetSuggestionsRequest =
): GetSuggestionsRequest = request.copy(
request.copy( startIndex = startIndex,
startIndex = startIndex, limit = limit,
limit = limit, enableTotalRecordCount = enableTotalRecordCount,
enableTotalRecordCount = enableTotalRecordCount, )
)
override suspend fun execute( override suspend fun execute(
api: ApiClient, api: ApiClient,
request: GetSuggestionsRequest, request: GetSuggestionsRequest,
): Response<BaseItemDtoQueryResult> = api.suggestionsApi.getSuggestions(request) ): Response<BaseItemDtoQueryResult> = api.suggestionsApi.getSuggestions(request)
} }
@Serializable @Serializable
val GetPlaylistItemsRequestHandler = object GetPlaylistItemsRequestHandler : RequestHandler<GetPlaylistItemsRequest> {
object : RequestHandler<GetPlaylistItemsRequest> { override fun prepare(
override fun prepare( request: GetPlaylistItemsRequest,
request: GetPlaylistItemsRequest, startIndex: Int,
startIndex: Int, limit: Int,
limit: Int, enableTotalRecordCount: Boolean,
enableTotalRecordCount: Boolean, ): GetPlaylistItemsRequest =
): GetPlaylistItemsRequest = request.copy(
request.copy( startIndex = startIndex,
startIndex = startIndex, limit = limit,
limit = limit, )
)
override suspend fun execute( override suspend fun execute(
api: ApiClient, api: ApiClient,
request: GetPlaylistItemsRequest, request: GetPlaylistItemsRequest,
): Response<BaseItemDtoQueryResult> = api.playlistsApi.getPlaylistItems(request) ): Response<BaseItemDtoQueryResult> = api.playlistsApi.getPlaylistItems(request)
} }
@Serializable @Serializable
val GetGenresRequestHandler = object GetGenresRequestHandler : RequestHandler<GetGenresRequest> {
object : RequestHandler<GetGenresRequest> { override fun prepare(
override fun prepare( request: GetGenresRequest,
request: GetGenresRequest, startIndex: Int,
startIndex: Int, limit: Int,
limit: Int, enableTotalRecordCount: Boolean,
enableTotalRecordCount: Boolean, ): GetGenresRequest =
): GetGenresRequest = request.copy(
request.copy( startIndex = startIndex,
startIndex = startIndex, limit = limit,
limit = limit, enableTotalRecordCount = enableTotalRecordCount,
enableTotalRecordCount = enableTotalRecordCount, )
)
override suspend fun execute( override suspend fun execute(
api: ApiClient, api: ApiClient,
request: GetGenresRequest, request: GetGenresRequest,
): Response<BaseItemDtoQueryResult> = api.genresApi.getGenres(request) ): 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)
}
@Serializable @Serializable
val GetPersonsHandler = object GetProgramsDtoHandler : RequestHandler<GetProgramsDto> {
object : RequestHandler<GetPersonsRequest> { override fun prepare(
override fun prepare( request: GetProgramsDto,
request: GetPersonsRequest, startIndex: Int,
startIndex: Int, limit: Int,
limit: Int, enableTotalRecordCount: Boolean,
enableTotalRecordCount: Boolean, ): GetProgramsDto =
): GetPersonsRequest = request.copy(
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, // startIndex = startIndex,
limit = limit, limit = limit,
) )
override suspend fun execute( override suspend fun execute(
api: ApiClient, api: ApiClient,
request: GetPersonsRequest, request: GetPersonsRequest,
): Response<BaseItemDtoQueryResult> = api.personsApi.getPersons((request)) ): Response<BaseItemDtoQueryResult> = api.personsApi.getPersons((request))
} }
val GetStudiosRequestHandler = @Serializable
object : RequestHandler<GetStudiosRequest> { object GetStudiosRequestHandler : RequestHandler<GetStudiosRequest> {
override fun prepare( override fun prepare(
request: GetStudiosRequest, request: GetStudiosRequest,
startIndex: Int, startIndex: Int,
limit: Int, limit: Int,
enableTotalRecordCount: Boolean, enableTotalRecordCount: Boolean,
): GetStudiosRequest = ): GetStudiosRequest =
request.copy( request.copy(
startIndex = startIndex, startIndex = startIndex,
limit = limit, limit = limit,
enableTotalRecordCount = enableTotalRecordCount, enableTotalRecordCount = enableTotalRecordCount,
) )
override suspend fun execute( override suspend fun execute(
api: ApiClient, api: ApiClient,
request: GetStudiosRequest, request: GetStudiosRequest,
): Response<BaseItemDtoQueryResult> = api.studiosApi.getStudios(request) ): Response<BaseItemDtoQueryResult> = api.studiosApi.getStudios(request)
} }
val GetRecordingsRequestHandler = @Serializable
object : RequestHandler<GetRecordingsRequest> { object GetRecordingsRequestHandler : RequestHandler<GetRecordingsRequest> {
override fun prepare( override fun prepare(
request: GetRecordingsRequest, request: GetRecordingsRequest,
startIndex: Int, startIndex: Int,
limit: Int, limit: Int,
enableTotalRecordCount: Boolean, enableTotalRecordCount: Boolean,
): GetRecordingsRequest = ): GetRecordingsRequest =
request.copy( request.copy(
startIndex = startIndex, startIndex = startIndex,
limit = limit, limit = limit,
enableTotalRecordCount = enableTotalRecordCount, enableTotalRecordCount = enableTotalRecordCount,
) )
override suspend fun execute( override suspend fun execute(
api: ApiClient, api: ApiClient,
request: GetRecordingsRequest, request: GetRecordingsRequest,
): Response<BaseItemDtoQueryResult> = api.liveTvApi.getRecordings(request) ): Response<BaseItemDtoQueryResult> = api.liveTvApi.getRecordings(request)
} }
val GetLiveTvChannelsRequestHandler = @Serializable
object : RequestHandler<GetLiveTvChannelsRequest> { object GetLiveTvChannelsRequestHandler : RequestHandler<GetLiveTvChannelsRequest> {
override fun prepare( override fun prepare(
request: GetLiveTvChannelsRequest, request: GetLiveTvChannelsRequest,
startIndex: Int, startIndex: Int,
limit: Int, limit: Int,
enableTotalRecordCount: Boolean, enableTotalRecordCount: Boolean,
): GetLiveTvChannelsRequest = ): GetLiveTvChannelsRequest =
request.copy( request.copy(
startIndex = startIndex, startIndex = startIndex,
limit = limit, limit = limit,
// enableTotalRecordCount = enableTotalRecordCount, // enableTotalRecordCount = enableTotalRecordCount,
) )
override suspend fun execute( override suspend fun execute(
api: ApiClient, api: ApiClient,
request: GetLiveTvChannelsRequest, request: GetLiveTvChannelsRequest,
): Response<BaseItemDtoQueryResult> = api.liveTvApi.getLiveTvChannels(request) ): 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)
}
} }