Fix some IO related crashes (#1008)

## Description
- Catch network exceptions when querying to populate the nav drawer
- Perform screensaver work on IO thread
- Fix possible crash if screensaver service runs while regular activity
is destroyed
- Fix genre images & backdrop images not loading

This PR also adds an Jellyfin SDK `ApiClient` wrapper to push all
network calls onto the IO thread. And tries to use a more strict
network-on-main policy for debug builds.

### Related issues
Fixes #994
Fixes #1001
Should fix #1003

### Testing
Emulator with simulated exception throws

## Screenshots
N/A

## AI or LLM usage
None
This commit is contained in:
Ray 2026-02-26 15:48:39 -05:00 committed by GitHub
parent 37d52ef57e
commit 9b995bf6ba
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 147 additions and 42 deletions

View file

@ -79,8 +79,12 @@ import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.serializer.toUUIDOrNull import org.jellyfin.sdk.model.serializer.toUUIDOrNull
@ -165,6 +169,20 @@ class MainActivity : AppCompatActivity() {
window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
} }
} }
screensaverService.keepScreenOn
.onEach { keepScreenOn ->
Timber.v("keepScreenOn: %s", keepScreenOn)
withContext(Dispatchers.Main) {
if (keepScreenOn) {
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
} else {
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
}
}.catch { ex ->
Timber.e(ex, "Error with keepScreenOn")
}.launchIn(lifecycleScope)
viewModel.appStart() viewModel.appStart()
setContent { setContent {
val appPreferences by userPreferencesDataStore.data.collectAsState(null) val appPreferences by userPreferencesDataStore.data.collectAsState(null)

View file

@ -3,7 +3,6 @@ package com.github.damontecres.wholphin
import android.app.Application import android.app.Application
import android.os.Build import android.os.Build
import android.os.StrictMode import android.os.StrictMode
import android.os.StrictMode.ThreadPolicy
import android.util.Log import android.util.Log
import androidx.compose.runtime.Composer import androidx.compose.runtime.Composer
import androidx.compose.runtime.ExperimentalComposeRuntimeApi import androidx.compose.runtime.ExperimentalComposeRuntimeApi
@ -27,16 +26,6 @@ class WholphinApplication :
init { init {
instance = this instance = this
if (BuildConfig.DEBUG) {
StrictMode.setThreadPolicy(
ThreadPolicy
.Builder()
.detectNetwork()
.penaltyLog()
.build(),
)
}
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
Timber.plant(Timber.DebugTree()) Timber.plant(Timber.DebugTree())
} else { } else {
@ -64,6 +53,23 @@ class WholphinApplication :
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
if (BuildConfig.DEBUG) {
StrictMode.setThreadPolicy(
StrictMode.ThreadPolicy
.Builder()
.detectNetwork()
.penaltyLog()
.penaltyDeathOnNetwork()
.build(),
)
StrictMode.setVmPolicy(
StrictMode.VmPolicy
.Builder()
.detectAll()
.penaltyLog()
.build(),
)
}
OkHttp.initialize(this) OkHttp.initialize(this)
initAcra { initAcra {
buildConfigClass = BuildConfig::class.java buildConfigClass = BuildConfig::class.java

View file

@ -42,8 +42,6 @@ class ServerRepository
val apiClient: ApiClient, val apiClient: ApiClient,
val userPreferencesDataStore: DataStore<AppPreferences>, val userPreferencesDataStore: DataStore<AppPreferences>,
) { ) {
private val sharedPreferences = getServerSharedPreferences(context)
private var _current = EqualityMutableLiveData<CurrentUser?>(null) private var _current = EqualityMutableLiveData<CurrentUser?>(null)
val current: LiveData<CurrentUser?> = _current val current: LiveData<CurrentUser?> = _current
@ -107,7 +105,7 @@ class ServerRepository
_current.value = CurrentUser(updatedServer, updatedUser) _current.value = CurrentUser(updatedServer, updatedUser)
_currentUserDto.value = userDto _currentUserDto.value = userDto
} }
sharedPreferences.edit(true) { getServerSharedPreferences(context).edit(true) {
putString(SERVER_URL_KEY, updatedServer.url) putString(SERVER_URL_KEY, updatedServer.url)
putString(ACCESS_TOKEN_KEY, updatedUser.accessToken) putString(ACCESS_TOKEN_KEY, updatedUser.accessToken)
} }

View file

@ -52,7 +52,7 @@ class BackdropService
if (item.type == BaseItemKind.GENRE) { if (item.type == BaseItemKind.GENRE) {
item.imageUrlOverride item.imageUrlOverride
} else { } else {
imageUrlService.getItemImageUrl(item, ImageType.BACKDROP)!! imageUrlService.getItemImageUrl(item, ImageType.BACKDROP)
} }
submit(item.id.toString(), imageUrl) submit(item.id.toString(), imageUrl)
} }

View file

@ -28,11 +28,11 @@ class ImageUrlService
itemType: BaseItemKind, itemType: BaseItemKind,
seriesId: UUID?, seriesId: UUID?,
useSeriesForPrimary: Boolean, useSeriesForPrimary: Boolean,
imageTags: Map<ImageType, String?>,
imageType: ImageType, imageType: ImageType,
imageTags: Map<ImageType, String?>,
backdropTags: List<String>,
parentThumbId: UUID? = null, parentThumbId: UUID? = null,
parentBackdropId: UUID? = null, parentBackdropId: UUID? = null,
backdropTags: List<String> = emptyList(),
fillWidth: Int? = null, fillWidth: Int? = null,
fillHeight: Int? = null, fillHeight: Int? = null,
): String? = ): String? =

View file

@ -11,11 +11,13 @@ import com.github.damontecres.wholphin.ui.main.settings.Library
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.nav.NavDrawerItem import com.github.damontecres.wholphin.ui.nav.NavDrawerItem
import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.util.supportedCollectionTypes import com.github.damontecres.wholphin.util.supportedCollectionTypes
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
@ -60,7 +62,11 @@ class NavDrawerService
if (user != null && userDto != null && user.id == userDto.id) { if (user != null && userDto != null && user.id == userDto.id) {
updateNavDrawer(user, userDto) updateNavDrawer(user, userDto)
} }
}.catch { ex ->
Timber.e(ex, "Error updating nav drawer")
showToast(context, "Error fetching user's views")
}.launchIn(coroutineScope) }.launchIn(coroutineScope)
seerrServerRepository.active seerrServerRepository.active
.onEach { discoverActive -> .onEach { discoverActive ->
_state.update { it.copy(discoverEnabled = discoverActive) } _state.update { it.copy(discoverEnabled = discoverActive) }

View file

@ -1,13 +1,12 @@
package com.github.damontecres.wholphin.services package com.github.damontecres.wholphin.services
import android.content.Context import android.content.Context
import android.view.WindowManager
import coil3.imageLoader import coil3.imageLoader
import coil3.request.ImageRequest import coil3.request.ImageRequest
import com.github.damontecres.wholphin.MainActivity
import com.github.damontecres.wholphin.services.hilt.DefaultCoroutineScope import com.github.damontecres.wholphin.services.hilt.DefaultCoroutineScope
import com.github.damontecres.wholphin.ui.components.CurrentItem import com.github.damontecres.wholphin.ui.components.CurrentItem
import com.github.damontecres.wholphin.ui.formatDate import com.github.damontecres.wholphin.ui.formatDate
import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler
@ -23,11 +22,11 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.cancellable import kotlinx.coroutines.flow.cancellable
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.libraryApi import org.jellyfin.sdk.api.client.extensions.libraryApi
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
@ -52,6 +51,8 @@ class ScreensaverService
private val _state = MutableStateFlow(ScreensaverState(false, false, false, false)) private val _state = MutableStateFlow(ScreensaverState(false, false, false, false))
val state: StateFlow<ScreensaverState> = _state val state: StateFlow<ScreensaverState> = _state
val keepScreenOn = MutableStateFlow(false)
private var waitJob: Job? = null private var waitJob: Job? = null
init { init {
@ -114,7 +115,7 @@ class ScreensaverService
} }
fun keepScreenOn(keep: Boolean) { fun keepScreenOn(keep: Boolean) {
scope.launch { scope.launchDefault {
val screensaverEnabled = _state.value.enabled val screensaverEnabled = _state.value.enabled
Timber.d("Keep screen on: %s, screensaverEnabled=%s", keep, screensaverEnabled) Timber.d("Keep screen on: %s, screensaverEnabled=%s", keep, screensaverEnabled)
if (screensaverEnabled) { if (screensaverEnabled) {
@ -131,15 +132,9 @@ class ScreensaverService
} }
} }
private suspend fun keepScreenOnInternal(keep: Boolean) = private fun keepScreenOnInternal(keep: Boolean) {
withContext(Dispatchers.Main) { keepScreenOn.update { keep }
val window = MainActivity.instance.window }
if (keep) {
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
} else {
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
}
fun createItemFlow(scope: CoroutineScope): Flow<CurrentItem?> = fun createItemFlow(scope: CoroutineScope): Flow<CurrentItem?> =
flow { flow {
@ -186,7 +181,7 @@ class ScreensaverService
} }
val logoUrl = imageUrlService.getItemImageUrl(item, ImageType.LOGO) val logoUrl = imageUrlService.getItemImageUrl(item, ImageType.LOGO)
if (backdropUrl != null) { if (backdropUrl != null) {
val result = try {
context.imageLoader context.imageLoader
.enqueue( .enqueue(
ImageRequest ImageRequest
@ -195,7 +190,6 @@ class ScreensaverService
.build(), .build(),
).job ).job
.await() .await()
try {
emit(CurrentItem(item, backdropUrl, logoUrl, title ?: "")) emit(CurrentItem(item, backdropUrl, logoUrl, title ?: ""))
} catch (_: CancellationException) { } catch (_: CancellationException) {
break break
@ -211,7 +205,7 @@ class ScreensaverService
index++ index++
} }
} }
}.cancellable() }.flowOn(Dispatchers.Default).cancellable()
} }
data class ScreensaverState( data class ScreensaverState(

View file

@ -10,6 +10,7 @@ import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.preferences.updateInterfacePreferences import com.github.damontecres.wholphin.preferences.updateInterfacePreferences
import com.github.damontecres.wholphin.services.SeerrApi import com.github.damontecres.wholphin.services.SeerrApi
import com.github.damontecres.wholphin.util.CoroutineContextApiClientFactory
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.RememberTabManager import com.github.damontecres.wholphin.util.RememberTabManager
import dagger.Module import dagger.Module
@ -111,12 +112,12 @@ object AppModule {
@Singleton @Singleton
fun okHttpFactory( fun okHttpFactory(
@StandardOkHttpClient okHttpClient: OkHttpClient, @StandardOkHttpClient okHttpClient: OkHttpClient,
) = OkHttpFactory(okHttpClient) ) = CoroutineContextApiClientFactory(OkHttpFactory(okHttpClient))
@Provides @Provides
@Singleton @Singleton
fun jellyfin( fun jellyfin(
okHttpFactory: OkHttpFactory, okHttpFactory: CoroutineContextApiClientFactory,
@ApplicationContext context: Context, @ApplicationContext context: Context,
clientInfo: ClientInfo, clientInfo: ClientInfo,
deviceInfo: DeviceInfo, deviceInfo: DeviceInfo,

View file

@ -45,7 +45,6 @@ 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.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.extensions.ticks
import timber.log.Timber import timber.log.Timber
import java.util.UUID import java.util.UUID
@ -429,12 +428,6 @@ fun Response<BaseItemDtoQueryResult>.toBaseItems(
useSeriesForPrimary: Boolean, useSeriesForPrimary: Boolean,
) = this.content.items.map { BaseItem.from(it, api, useSeriesForPrimary) } ) = this.content.items.map { BaseItem.from(it, api, useSeriesForPrimary) }
@Composable
fun rememberBackDropImage(item: BaseItem): String? {
val imageUrlService = LocalImageUrlService.current
return remember(item) { imageUrlService.getItemImageUrl(item, ImageType.BACKDROP) }
}
/** /**
* Check if this, coalescing nulls to zero, is greater than that * Check if this, coalescing nulls to zero, is greater than that
*/ */

View file

@ -209,6 +209,7 @@ suspend fun getGenreImageMap(
imageType = ImageType.BACKDROP, imageType = ImageType.BACKDROP,
imageTags = item.imageTags.orEmpty(), imageTags = item.imageTags.orEmpty(),
fillWidth = cardWidthPx, fillWidth = cardWidthPx,
backdropTags = item.backdropImageTags.orEmpty(),
) )
} }
} }

View file

@ -0,0 +1,88 @@
package com.github.damontecres.wholphin.util
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.ApiClientFactory
import org.jellyfin.sdk.api.client.HttpClientOptions
import org.jellyfin.sdk.api.client.HttpMethod
import org.jellyfin.sdk.api.client.RawResponse
import org.jellyfin.sdk.api.okhttp.OkHttpFactory
import org.jellyfin.sdk.api.sockets.SocketApi
import org.jellyfin.sdk.api.sockets.SocketConnection
import org.jellyfin.sdk.api.sockets.SocketConnectionFactory
import org.jellyfin.sdk.model.ClientInfo
import org.jellyfin.sdk.model.DeviceInfo
import kotlin.coroutines.CoroutineContext
/**
* Wraps [ApiClient.request] with the given [CoroutineContext]
*/
class CoroutineContextApiClient(
private val client: ApiClient,
private val coroutineContext: CoroutineContext = Dispatchers.IO,
) : ApiClient() {
override val baseUrl: String?
get() = client.baseUrl
override val accessToken: String?
get() = client.accessToken
override val clientInfo: ClientInfo
get() = client.clientInfo
override val deviceInfo: DeviceInfo
get() = client.deviceInfo
override val httpClientOptions: HttpClientOptions
get() = client.httpClientOptions
override val webSocket: SocketApi
get() = client.webSocket
override fun update(
baseUrl: String?,
accessToken: String?,
clientInfo: ClientInfo,
deviceInfo: DeviceInfo,
) {
client.update(baseUrl, accessToken, clientInfo, deviceInfo)
}
override suspend fun request(
method: HttpMethod,
pathTemplate: String,
pathParameters: Map<String, Any?>,
queryParameters: Map<String, Any?>,
requestBody: Any?,
): RawResponse =
withContext(coroutineContext) {
client.request(
method,
pathTemplate,
pathParameters,
queryParameters,
requestBody,
)
}
}
class CoroutineContextApiClientFactory(
private val factory: OkHttpFactory,
private val coroutineContext: CoroutineContext = Dispatchers.IO,
) : ApiClientFactory,
SocketConnectionFactory {
override fun create(
baseUrl: String?,
accessToken: String?,
clientInfo: ClientInfo,
deviceInfo: DeviceInfo,
httpClientOptions: HttpClientOptions,
socketConnectionFactory: SocketConnectionFactory,
): ApiClient =
CoroutineContextApiClient(
factory.create(baseUrl, accessToken, clientInfo, deviceInfo, httpClientOptions, socketConnectionFactory),
coroutineContext,
)
override fun create(
clientOptions: HttpClientOptions,
scope: CoroutineScope,
): SocketConnection = factory.create(clientOptions, scope)
}