wolfskin/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt
justinzeus 327f631f0d
Some checks failed
review/security-claude BLOCK: DnsConnectionRetry crashes app on Jellyfin WebSocket (NoSuchElementException)
Cache DNS answers and paint saved people before launch revalidation
2026-11-07 00:09:57 +01:00

604 lines
24 KiB
Kotlin

package com.github.damontecres.wholphin
import android.content.Context
import android.content.Intent
import android.content.res.Configuration
import android.os.Bundle
import android.view.KeyEvent
import android.view.WindowManager
import android.widget.Toast
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.annotation.VisibleForTesting
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.datastore.core.DataStore
import androidx.lifecycle.ViewModel
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.viewModelScope
import androidx.navigation3.runtime.NavBackStack
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Surface
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.RestoredSession
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.PlayerBackend
import com.github.damontecres.wholphin.services.AppUpgradeHandler
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.DatePlayedInvalidationService
import com.github.damontecres.wholphin.services.DeviceProfileService
import com.github.damontecres.wholphin.services.ImageUrlService
import com.github.damontecres.wholphin.services.IntentResult
import com.github.damontecres.wholphin.services.IntentService
import com.github.damontecres.wholphin.services.LatestNextUpSchedulerService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.PlaybackLifecycleObserver
import com.github.damontecres.wholphin.services.RefreshRateService
import com.github.damontecres.wholphin.services.ScreensaverService
import com.github.damontecres.wholphin.services.SeerrRatingsService
import com.github.damontecres.wholphin.services.ServerEventListener
import com.github.damontecres.wholphin.services.SetupDestination
import com.github.damontecres.wholphin.services.SetupNavigationManager
import com.github.damontecres.wholphin.services.SuggestionsSchedulerService
import com.github.damontecres.wholphin.services.UpdateChecker
import com.github.damontecres.wholphin.services.UserPreferencesService
import com.github.damontecres.wholphin.services.UserSwitchListener
import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
import com.github.damontecres.wholphin.services.tvprovider.TvProviderSchedulerService
import com.github.damontecres.wholphin.ui.CoilConfig
import com.github.damontecres.wholphin.ui.LocalImageUrlService
import com.github.damontecres.wholphin.ui.collectLatestIn
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.components.LocalSeerrRatingsService
import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.playback.PlayExternalViewModel
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
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
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
import timber.log.Timber
import javax.inject.Inject
import kotlin.time.Duration.Companion.milliseconds
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
private val viewModel: MainActivityViewModel by viewModels()
private val playExternalViewModel: PlayExternalViewModel by viewModels()
@Inject
lateinit var userPreferencesDataStore: DataStore<AppPreferences>
@Inject
lateinit var userPreferencesService: UserPreferencesService
@AuthOkHttpClient
@Inject
lateinit var okHttpClient: OkHttpClient
@Inject
lateinit var navigationManager: NavigationManager
@Inject
lateinit var setupNavigationManager: SetupNavigationManager
@Inject
lateinit var updateChecker: UpdateChecker
@Inject
lateinit var playbackLifecycleObserver: PlaybackLifecycleObserver
@Inject
lateinit var imageUrlService: ImageUrlService
@Inject
lateinit var seerrRatingsService: SeerrRatingsService
@Inject
lateinit var refreshRateService: RefreshRateService
@Inject
lateinit var userSwitchListener: UserSwitchListener
@Inject
lateinit var tvProviderSchedulerService: TvProviderSchedulerService
@Inject
lateinit var suggestionsSchedulerService: SuggestionsSchedulerService
@Inject
lateinit var latestNextUpSchedulerService: LatestNextUpSchedulerService
@Inject
lateinit var backdropService: BackdropService
// Note: unused but injected to ensure it is created
@Inject
lateinit var serverEventListener: ServerEventListener
// Note: unused but injected to ensure it is created
@Inject
lateinit var datePlayedInvalidationService: DatePlayedInvalidationService
@Inject
lateinit var screensaverService: ScreensaverService
@Inject
lateinit var intentService: IntentService
private var signInAuto = true
private var playerBackend: PlayerBackend? = null
private val json =
Json {
classDiscriminator = "_type"
serializersModule = requestSerializersModule
}
@OptIn(ExperimentalTvMaterial3Api::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
instance = this
Timber.i("MainActivity.onCreate: savedInstanceState is null=${savedInstanceState == null}")
lifecycle.addObserver(playbackLifecycleObserver)
val backStackStr = savedInstanceState?.getString(KEY_BACK_STACK)
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 = restoredBackStack
if (!playExternalViewModel.launched.value) {
val lastDest = backStack.lastOrNull()
if (lastDest.isPlayback) {
Timber.v("Restoring back stack without playback")
backStack = backStack.toMutableList().apply { removeAt(lastIndex) }
}
}
navigationManager.backStack = NavBackStack(*backStack.toTypedArray())
} else {
navigationManager.backStack = NavBackStack(Destination.Home())
}
viewModel.serverRepository.currentUserFlow
.onEach { user ->
withContext(WholphinDispatchers.Main) {
if (user?.hasPin == true) {
window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
} else {
window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
}
}
}.catch { ex ->
Timber.e(ex, "Error with settings flag secure")
}.launchIn(lifecycleScope)
screensaverService.keepScreenOn
.onEach { keepScreenOn ->
Timber.v("keepScreenOn: %s", keepScreenOn)
withContext(WholphinDispatchers.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)
userPreferencesDataStore.data.collectLatestIn(lifecycleScope) { prefs ->
signInAuto = prefs.signInAutomatically
playerBackend = prefs.playbackPreferences.playerBackend
}
viewModel.appStart(intent)
setContent {
MaterialTheme {
Surface(Modifier.fillMaxSize()) {
val userPreferences by userPreferencesService.flow.collectAsState(null)
if (userPreferences == null) {
// Show loading page if it is taking a while to get app preferences
var showLoading by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
delay(500.milliseconds)
Timber.i("Showing loading page")
showLoading = true
}
if (showLoading) {
Box(
modifier =
Modifier
.fillMaxSize()
.background(Color.Black),
) {
LoadingPage()
}
}
} else {
userPreferences?.let { userPreferences ->
val appPreferences = userPreferences.appPreferences
CoilConfig(
prefs = appPreferences,
okHttpClient = okHttpClient,
debugLogging = false,
enableCache = true,
)
LaunchedEffect(appPreferences.debugLogging) {
DebugLogTree.INSTANCE.enabled = appPreferences.debugLogging
}
CompositionLocalProvider(
LocalImageUrlService provides imageUrlService,
LocalSeerrRatingsService provides seerrRatingsService,
) {
WholphinTheme(
true,
appThemeColors = appPreferences.interfacePreferences.appThemeColors,
) {
ProvideLocalClock {
MainContent(
backStack = setupNavigationManager.backStack,
navigationManager = navigationManager,
userPreferences = userPreferences,
backdropService = backdropService,
screensaverService = screensaverService,
modifier = Modifier.fillMaxSize(),
)
}
}
}
}
}
}
}
}
}
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
if (screensaverService.state.value.run { show || showDim }) {
screensaverService.stop(false)
screensaverService.pulse()
return true
} else {
screensaverService.pulse()
return super.dispatchKeyEvent(event)
}
}
override fun onResume() {
super.onResume()
Timber.d("onResume")
viewModel.appResume()
lifecycleScope.launchDefault {
screensaverService.pulse()
}
}
override fun onRestart() {
super.onRestart()
Timber.d("onRestart")
viewModel.appStart(null)
if (!playExternalViewModel.launched.value) {
// If restarting during playback that is not external, go back a page
val lastDest = navigationManager.backStack.lastOrNull()
if (lastDest.isPlayback) {
Timber.v("onRestart: go back from playback")
navigationManager.goBack()
}
}
}
override fun onStop() {
super.onStop()
Timber.d("onStop")
screensaverService.stop(true)
tvProviderSchedulerService.launchOneTimeRefresh()
}
override fun onPause() {
super.onPause()
Timber.d("onPause")
}
override fun onStart() {
super.onStart()
Timber.d("onStart")
lifecycleScope.launchDefault {
val appPreferences = userPreferencesDataStore.data.first()
if (UpdateChecker.ACTIVE && appPreferences.autoCheckForUpdates) {
try {
updateChecker.maybeShowUpdateToast(
appPreferences.updateUrl,
)
} catch (ex: Exception) {
Timber.w(
ex,
"Exception during update check",
)
}
}
}
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
Timber.d("onSaveInstanceState")
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")
}
outState.putBoolean(KEY_EXTERNAL_PLAYER, playerBackend == PlayerBackend.EXTERNAL_PLAYER)
}
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
super.onRestoreInstanceState(savedInstanceState)
Timber.d("onRestoreInstanceState")
}
override fun onDestroy() {
super.onDestroy()
Timber.d("onDestroy")
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
Timber.d("onConfigurationChanged: newConfig=%s", newConfig)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
Timber.v("onNewIntent")
setIntent(intent)
viewModel.appStart(intent)
}
fun changeDisplayMode(modeId: Int) {
lifecycleScope.launch(WholphinDispatchers.Main + ExceptionHandler(autoToast = true)) {
try {
val attrs = window.attributes
if (attrs.preferredDisplayModeId != modeId) {
Timber.d("Switch preferredDisplayModeId to %s", modeId)
window.attributes = attrs.apply { preferredDisplayModeId = modeId }
}
} catch (ex: Exception) {
Timber.e(ex, "Error switching preferredDisplayModeId to %s", modeId)
Toast
.makeText(this@MainActivity, "Error changing display mode", Toast.LENGTH_SHORT)
.show()
}
}
}
companion object {
private const val KEY_BACK_STACK = "backStack"
private const val KEY_EXTERNAL_PLAYER = "extPlayer"
lateinit var instance: MainActivity
private set
}
}
@HiltViewModel
class MainActivityViewModel
@Inject
constructor(
@param:ApplicationContext private val context: Context,
private val preferences: DataStore<AppPreferences>,
val serverRepository: ServerRepository,
private val setupNavigationManager: SetupNavigationManager,
private val navigationManager: NavigationManager,
private val deviceProfileService: DeviceProfileService,
private val backdropService: BackdropService,
private val appUpgradeHandler: AppUpgradeHandler,
private val intentService: IntentService,
) : ViewModel() {
private val mutex = Mutex()
fun appStart(intent: Intent?) {
viewModelScope.launchDefault {
mutex.withLock {
try {
handleAppUpgrade()
} catch (ex: Exception) {
Timber.e(ex, "Error during app upgrade!")
showToast(
context,
"Error during app upgrade: ${ex.localizedMessage}",
)
}
try {
val result = handleIntent(intent)
if (result) {
Timber.i("appStart was handled by intent")
return@withLock
}
} catch (ex: Exception) {
Timber.e(ex, "Error parsing intent")
}
try {
handleAppStart()
} catch (ex: Exception) {
Timber.e(ex, "Error during appStart")
setupNavigationManager.navigateTo(SetupDestination.ServerList)
}
}
}
viewModelScope.launchDefault {
// Create the mediaCodecCapabilitiesTest if needed
deviceProfileService.mediaCodecCapabilitiesTest.supportsAVC()
}
}
fun appResume() {
viewModelScope.launchDefault {
mutex.withLock {
try {
Timber.v("Updating userDto")
serverRepository.updateUserDto()
} catch (ex: Exception) {
Timber.w(ex, "Error during appResume")
}
}
}
}
/**
* Handle an [Intent], navigating if needed
*
* @return true if the intent was handled, false if normal app start should proceed
*/
@VisibleForTesting
internal suspend fun handleIntent(intent: Intent?): Boolean {
val result = intent?.let { intentService.parseIntent(intent) }
when (result) {
is IntentResult.Error -> {
val current = serverRepository.current.value
val destination =
if (current != null) {
SetupDestination.UserList(current.server)
} else {
SetupDestination.ServerList
}
setupNavigationManager.navigateTo(destination)
Timber.e("Error parsing intent: %s", result.message)
showToast(context, result.message)
return true
}
is IntentResult.Target -> {
val current = serverRepository.current.value
if (current != null) {
Timber.i("Received valid intent, switching to AppContent")
if (result.addHomeToBackStack) {
navigationManager.reloadHome()
} else {
navigationManager.backStack.clear()
}
navigationManager.backStack.addAll(result.destinations)
setupNavigationManager.navigateTo(
SetupDestination.AppContent(current),
)
} else {
// This should never happen, but just reset app state if it does
setupNavigationManager.navigateTo(SetupDestination.ServerList)
Timber.e("Error parsing intent, no user is active")
showToast(context, "An error occurred parsing the intent")
}
return true
}
IntentResult.NoOp,
null,
-> {
// Not handled
return false
}
}
}
private suspend fun handleAppUpgrade() {
val needUpgrade = appUpgradeHandler.needUpgrade()
if (needUpgrade) {
showToast(
context,
context.getString(
R.string.updated_toast,
appUpgradeHandler.currentVersion.toString(),
),
)
appUpgradeHandler.run()
}
appUpgradeHandler.copySubfont(false)
}
/**
* Handle starting the app including auto sign in and navigating to the right starting page
*/
@VisibleForTesting
internal suspend fun handleAppStart() {
val signInAutomatically = preferences.data.first().signInAutomatically
val currentUser = serverRepository.current.value
if (signInAutomatically && currentUser != null && !currentUser.user.isProtected) {
// Hot reload
Timber.v("Hot reload restoring state")
setupNavigationManager.navigateTo(
SetupDestination.AppContent(currentUser),
)
} else {
val restoredSession =
if (signInAutomatically) {
Timber.v("Sign in automatically")
serverRepository.restoreSavedSession()
} else {
serverRepository.getMostRecentServer()
}
Timber.d("Restore last session result=%s", restoredSession)
setupNavigationManager.navigateTo(restoredSession.destination)
if (signInAutomatically && restoredSession is RestoredSession.Success) {
viewModelScope.launchDefault {
try {
serverRepository.revalidateSession(restoredSession.currentUser)
} catch (exception: Exception) {
if (exception is kotlinx.coroutines.CancellationException) throw exception
Timber.w(exception, "Saved launch session revalidation failed")
}
}
}
}
}
}
private val Destination?.isPlayback: Boolean
get() =
this is Destination.Playback ||
this is Destination.PlaybackList ||
this is Destination.Slideshow