feat(settings): add app update settings action

This commit is contained in:
2026-05-08 22:29:07 +02:00
parent 60d2a1eb2a
commit 80f5454afb
9 changed files with 213 additions and 90 deletions

View File

@@ -1,5 +1,6 @@
package hu.bbara.purefin.ui.screen.settings package hu.bbara.purefin.ui.screen.settings
import android.widget.Toast
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -24,6 +25,7 @@ import androidx.compose.material3.Slider
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -31,6 +33,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
@@ -39,7 +42,6 @@ import hu.bbara.purefin.core.settings.BooleanSetting
import hu.bbara.purefin.core.settings.DropdownSetting import hu.bbara.purefin.core.settings.DropdownSetting
import hu.bbara.purefin.core.settings.RangeSetting import hu.bbara.purefin.core.settings.RangeSetting
import hu.bbara.purefin.core.settings.SettingOption import hu.bbara.purefin.core.settings.SettingOption
import hu.bbara.purefin.core.settings.SettingsOptions
import hu.bbara.purefin.core.settings.StringSetting import hu.bbara.purefin.core.settings.StringSetting
import hu.bbara.purefin.core.settings.VoidSetting import hu.bbara.purefin.core.settings.VoidSetting
import java.util.Locale import java.util.Locale
@@ -50,6 +52,15 @@ fun TvSettingsScreen(
onBack: () -> Unit = viewModel::onBack, onBack: () -> Unit = viewModel::onBack,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val settingGroups by viewModel.settingGroups.collectAsState()
val context = LocalContext.current
LaunchedEffect(viewModel, context) {
viewModel.snackbarMessages.collect { message ->
Toast.makeText(context, message, Toast.LENGTH_LONG).show()
}
}
Scaffold( Scaffold(
modifier = modifier.fillMaxSize(), modifier = modifier.fillMaxSize(),
containerColor = MaterialTheme.colorScheme.background, containerColor = MaterialTheme.colorScheme.background,
@@ -72,7 +83,7 @@ fun TvSettingsScreen(
) )
} }
SettingsOptions.groups.forEach { group -> settingGroups.forEach { group ->
group.title?.let { title -> group.title?.let { title ->
item { item {
Text( Text(
@@ -135,7 +146,7 @@ private fun TvSettingOptionItem(
is VoidSetting -> { is VoidSetting -> {
TvVoidSettingItem( TvVoidSettingItem(
title = option.title, title = option.title,
onClick = {} onClick = { viewModel.onClick(option) }
) )
} }

View File

@@ -9,10 +9,14 @@ import androidx.compose.material.icons.automirrored.outlined.ArrowBack
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
@@ -21,7 +25,6 @@ import hu.bbara.purefin.core.settings.BooleanSetting
import hu.bbara.purefin.core.settings.DropdownSetting import hu.bbara.purefin.core.settings.DropdownSetting
import hu.bbara.purefin.core.settings.RangeSetting import hu.bbara.purefin.core.settings.RangeSetting
import hu.bbara.purefin.core.settings.SettingOption import hu.bbara.purefin.core.settings.SettingOption
import hu.bbara.purefin.core.settings.SettingsOptions
import hu.bbara.purefin.core.settings.StringSetting import hu.bbara.purefin.core.settings.StringSetting
import hu.bbara.purefin.core.settings.VoidSetting import hu.bbara.purefin.core.settings.VoidSetting
import hu.bbara.purefin.ui.screen.home.components.DefaultTopBar import hu.bbara.purefin.ui.screen.home.components.DefaultTopBar
@@ -37,10 +40,20 @@ fun SettingsScreen(
viewModel: SettingsViewModel = hiltViewModel(), viewModel: SettingsViewModel = hiltViewModel(),
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val settingGroups by viewModel.settingGroups.collectAsState()
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(viewModel) {
viewModel.snackbarMessages.collect { message ->
snackbarHostState.showSnackbar(message)
}
}
Scaffold( Scaffold(
modifier = modifier.fillMaxSize(), modifier = modifier.fillMaxSize(),
containerColor = MaterialTheme.colorScheme.background, containerColor = MaterialTheme.colorScheme.background,
contentColor = MaterialTheme.colorScheme.onBackground, contentColor = MaterialTheme.colorScheme.onBackground,
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = { topBar = {
SettingsTopBar(onBack = viewModel::onBack) SettingsTopBar(onBack = viewModel::onBack)
} }
@@ -59,7 +72,7 @@ fun SettingsScreen(
) )
} }
SettingsOptions.groups.forEach { group -> settingGroups.forEach { group ->
group.title?.let { title -> group.title?.let { title ->
item { item {
Text( Text(
@@ -122,7 +135,7 @@ private fun SettingOptionItem(
is VoidSetting -> { is VoidSetting -> {
VoidSettingItem( VoidSettingItem(
title = option.title, title = option.title,
onClick = {} onClick = { viewModel.onClick(option) }
) )
} }

View File

@@ -7,6 +7,7 @@ import hu.bbara.purefin.core.data.HomeRepository
import hu.bbara.purefin.core.data.LocalMediaRepository import hu.bbara.purefin.core.data.LocalMediaRepository
import hu.bbara.purefin.core.data.UserSessionRepository import hu.bbara.purefin.core.data.UserSessionRepository
import hu.bbara.purefin.core.download.MediaDownloadController import hu.bbara.purefin.core.download.MediaDownloadController
import hu.bbara.purefin.core.feature.update.AppUpdateController
import hu.bbara.purefin.core.jellyfin.JellyfinMediaMetadataUpdater import hu.bbara.purefin.core.jellyfin.JellyfinMediaMetadataUpdater
import hu.bbara.purefin.core.model.EpisodeUiModel import hu.bbara.purefin.core.model.EpisodeUiModel
import hu.bbara.purefin.core.model.LibraryUiModel import hu.bbara.purefin.core.model.LibraryUiModel
@@ -40,6 +41,7 @@ class AppViewModel @Inject constructor(
private val jellyfinMediaMetadataUpdater: JellyfinMediaMetadataUpdater, private val jellyfinMediaMetadataUpdater: JellyfinMediaMetadataUpdater,
private val navigationManager: NavigationManager, private val navigationManager: NavigationManager,
private val mediaDownloadManager: MediaDownloadController, private val mediaDownloadManager: MediaDownloadController,
private val appUpdateController: AppUpdateController,
) : ViewModel() { ) : ViewModel() {
private val _isRefreshing = MutableStateFlow(false) private val _isRefreshing = MutableStateFlow(false)
@@ -239,6 +241,10 @@ class AppViewModel @Inject constructor(
viewModelScope.launch { viewModelScope.launch {
try { try {
homeRepository.refreshHomeData() homeRepository.refreshHomeData()
appUpdateController.checkForUpdates(
showUpToDateMessage = false,
showFailureMessage = false
)
} catch (e: Exception) { } catch (e: Exception) {
// Refresh is best-effort; don't crash on failure // Refresh is best-effort; don't crash on failure
} }
@@ -255,6 +261,10 @@ class AppViewModel @Inject constructor(
_isRefreshing.value = true _isRefreshing.value = true
try { try {
homeRepository.refreshHomeData() homeRepository.refreshHomeData()
appUpdateController.checkForUpdates(
showUpToDateMessage = false,
showFailureMessage = false
)
} catch (e: Exception) { } catch (e: Exception) {
// Refresh is best-effort; don't crash on failure // Refresh is best-effort; don't crash on failure
} finally { } finally {

View File

@@ -7,8 +7,20 @@ import hu.bbara.purefin.core.navigation.NavigationManager
import hu.bbara.purefin.core.settings.BooleanSetting import hu.bbara.purefin.core.settings.BooleanSetting
import hu.bbara.purefin.core.settings.DropdownSetting import hu.bbara.purefin.core.settings.DropdownSetting
import hu.bbara.purefin.core.settings.RangeSetting import hu.bbara.purefin.core.settings.RangeSetting
import hu.bbara.purefin.core.settings.SettingGroup
import hu.bbara.purefin.core.settings.SettingsGroupProvider
import hu.bbara.purefin.core.settings.SettingsRepository import hu.bbara.purefin.core.settings.SettingsRepository
import hu.bbara.purefin.core.settings.SettingsOptions
import hu.bbara.purefin.core.settings.StringSetting import hu.bbara.purefin.core.settings.StringSetting
import hu.bbara.purefin.core.settings.VoidSetting
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@@ -16,8 +28,30 @@ import javax.inject.Inject
class SettingsViewModel @Inject constructor( class SettingsViewModel @Inject constructor(
private val settingsRepository: SettingsRepository, private val settingsRepository: SettingsRepository,
private val navigationManager: NavigationManager, private val navigationManager: NavigationManager,
settingsGroupProviders: Set<@JvmSuppressWildcards SettingsGroupProvider>,
) : ViewModel() { ) : ViewModel() {
private val _snackbarMessages = MutableSharedFlow<String>()
val snackbarMessages: SharedFlow<String> = _snackbarMessages.asSharedFlow()
private val dynamicSettingGroups = settingsGroupProviders
.map { it.settingGroups }
.takeIf { it.isNotEmpty() }
?.let { groupFlows ->
combine(groupFlows) { groups -> groups.toList().flatten() }
}
?: flowOf(emptyList())
val settingGroups: StateFlow<List<SettingGroup>> = dynamicSettingGroups
.combine(flowOf(SettingsOptions.groups)) { dynamicGroups, staticGroups ->
staticGroups + dynamicGroups
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = SettingsOptions.groups
)
fun value(option: RangeSetting) = settingsRepository.value(option) fun value(option: RangeSetting) = settingsRepository.value(option)
fun value(option: BooleanSetting) = settingsRepository.value(option) fun value(option: BooleanSetting) = settingsRepository.value(option)
@@ -50,6 +84,16 @@ class SettingsViewModel @Inject constructor(
} }
} }
fun onClick(option: VoidSetting) {
viewModelScope.launch {
try {
option.onClick()
} catch (e: Exception) {
_snackbarMessages.emit(e.message ?: "Setting action failed")
}
}
}
fun onBack() { fun onBack() {
navigationManager.pop() navigationManager.pop()
} }

View File

@@ -0,0 +1,106 @@
package hu.bbara.purefin.core.feature.update
import hu.bbara.purefin.core.settings.SettingGroup
import hu.bbara.purefin.core.settings.SettingsGroupProvider
import hu.bbara.purefin.core.settings.VoidSetting
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.sync.Mutex
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class AppUpdateController @Inject constructor(
private val appUpdateRepository: AppUpdateRepository,
private val appUpdateInstaller: AppUpdateInstaller
) : SettingsGroupProvider {
private val _isCheckingForUpdates = MutableStateFlow(false)
val isCheckingForUpdates: StateFlow<Boolean> = _isCheckingForUpdates.asStateFlow()
private val _availableUpdate = MutableStateFlow<AppUpdateInfo?>(null)
val availableUpdate: StateFlow<AppUpdateInfo?> = _availableUpdate.asStateFlow()
private val _snackbarMessages = MutableSharedFlow<String>()
val snackbarMessages: SharedFlow<String> = _snackbarMessages.asSharedFlow()
private val checkMutex = Mutex()
private val installMutex = Mutex()
override val settingGroups: Flow<List<SettingGroup>> = availableUpdate
.map { update ->
if (update == null) {
emptyList()
} else {
listOf(
SettingGroup(
title = "App",
options = listOf(installUpdateSetting(update))
)
)
}
}
suspend fun checkForUpdates(
showUpToDateMessage: Boolean = true,
showFailureMessage: Boolean = true
) {
if (!checkMutex.tryLock()) {
return
}
_isCheckingForUpdates.value = true
try {
val update = appUpdateRepository.checkForUpdate()
_availableUpdate.value = update
if (update == null && showUpToDateMessage) {
_snackbarMessages.emit("Purefin is up to date")
}
} catch (e: Exception) {
if (showFailureMessage) {
_snackbarMessages.emit(e.message ?: "Update check failed")
}
} finally {
_isCheckingForUpdates.value = false
checkMutex.unlock()
}
}
suspend fun installAvailableUpdate() {
val update = _availableUpdate.value ?: return
if (!installMutex.tryLock()) {
return
}
_availableUpdate.value = null
try {
_snackbarMessages.emit(appUpdateInstaller.installUpdate(update))
} catch (e: Exception) {
_snackbarMessages.emit(e.message ?: "Update install failed")
} finally {
installMutex.unlock()
}
}
fun declineUpdate() {
_availableUpdate.value = null
}
private fun installUpdateSetting(update: AppUpdateInfo): VoidSetting {
val versionLabel = update.versionName?.takeIf { it.isNotBlank() } ?: update.versionCode.toString()
return VoidSetting(
key = INSTALL_APP_UPDATE_KEY,
title = "Install Purefin $versionLabel",
onClick = { installAvailableUpdate() }
)
}
private companion object {
const val INSTALL_APP_UPDATE_KEY = "install_app_update"
}
}

View File

@@ -3,76 +3,35 @@ package hu.bbara.purefin.core.feature.update
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class AppUpdateViewModel @Inject constructor( class AppUpdateViewModel @Inject constructor(
private val appUpdateRepository: AppUpdateRepository, private val appUpdateController: AppUpdateController
private val appUpdateInstaller: AppUpdateInstaller
) : ViewModel() { ) : ViewModel() {
private val _isCheckingForUpdates = MutableStateFlow(false) val isCheckingForUpdates: StateFlow<Boolean> = appUpdateController.isCheckingForUpdates
val isCheckingForUpdates: StateFlow<Boolean> = _isCheckingForUpdates.asStateFlow()
private val _availableUpdate = MutableStateFlow<AppUpdateInfo?>(null) val availableUpdate: StateFlow<AppUpdateInfo?> = appUpdateController.availableUpdate
val availableUpdate: StateFlow<AppUpdateInfo?> = _availableUpdate.asStateFlow()
private val _snackbarMessages = MutableSharedFlow<String>() val snackbarMessages: SharedFlow<String> = appUpdateController.snackbarMessages
val snackbarMessages: SharedFlow<String> = _snackbarMessages.asSharedFlow()
private var isInstallingUpdate = false
fun checkForUpdates(showUpToDateMessage: Boolean = true) { fun checkForUpdates(showUpToDateMessage: Boolean = true) {
if (_isCheckingForUpdates.value) {
return
}
viewModelScope.launch { viewModelScope.launch {
_isCheckingForUpdates.value = true appUpdateController.checkForUpdates(showUpToDateMessage = showUpToDateMessage)
try {
val update = appUpdateRepository.checkForUpdate()
if (update == null) {
if (showUpToDateMessage) {
_snackbarMessages.emit("Purefin is up to date")
}
} else {
_availableUpdate.value = update
}
} catch (e: Exception) {
_snackbarMessages.emit(e.message ?: "Update check failed")
} finally {
_isCheckingForUpdates.value = false
}
} }
} }
fun acceptUpdate() { fun acceptUpdate() {
val update = _availableUpdate.value ?: return
if (isInstallingUpdate) {
return
}
viewModelScope.launch { viewModelScope.launch {
isInstallingUpdate = true appUpdateController.installAvailableUpdate()
_availableUpdate.value = null
try {
_snackbarMessages.emit(appUpdateInstaller.installUpdate(update))
} catch (e: Exception) {
_snackbarMessages.emit(e.message ?: "Update install failed")
} finally {
isInstallingUpdate = false
}
} }
} }
fun declineUpdate() { fun declineUpdate() {
_availableUpdate.value = null appUpdateController.declineUpdate()
} }
} }

View File

@@ -28,7 +28,8 @@ data class StringSetting(
data class VoidSetting( data class VoidSetting(
override val key: String, override val key: String,
override val title: String, override val title: String,
override val defaultValue: Unit = Unit override val defaultValue: Unit = Unit,
val onClick: suspend () -> Unit = {}
) : SettingOption<Unit> ) : SettingOption<Unit>
data class DropdownSetting<T>( data class DropdownSetting<T>(
@@ -44,18 +45,6 @@ data class SettingGroup(
) )
object SettingsOptions { object SettingsOptions {
val defaultPlaybackSpeed = RangeSetting(
key = "default_playback_speed",
title = "Default playback speed",
defaultValue = 1.0,
valueRange = 0.5..2.0
)
val confirmMobileDataPlayback = BooleanSetting(
key = "confirm_mobile_data_playback",
title = "Confirm mobile data playback",
defaultValue = true
)
val autoPlayNextMedia = BooleanSetting( val autoPlayNextMedia = BooleanSetting(
key = "auto_play_next_media", key = "auto_play_next_media",
@@ -63,34 +52,11 @@ object SettingsOptions {
defaultValue = true defaultValue = true
) )
val preferredAudioLanguage = StringSetting(
key = "preferred_audio_language",
title = "Preferred audio language",
defaultValue = "English"
)
val resetPlaybackSettings = VoidSetting(
key = "reset_playback_settings",
title = "Reset playback settings"
)
val streamingQuality = DropdownSetting(
key = "streaming_quality",
title = "Streaming quality",
defaultValue = "Auto",
options = listOf("Auto", "Low", "Medium", "High")
)
val groups = listOf( val groups = listOf(
SettingGroup( SettingGroup(
title = "Playback", title = "Playback",
options = listOf( options = listOf(
defaultPlaybackSpeed,
confirmMobileDataPlayback,
autoPlayNextMedia, autoPlayNextMedia,
preferredAudioLanguage,
resetPlaybackSettings,
streamingQuality
) )
) )
) )

View File

@@ -0,0 +1,7 @@
package hu.bbara.purefin.core.settings
import kotlinx.coroutines.flow.Flow
interface SettingsGroupProvider {
val settingGroups: Flow<List<SettingGroup>>
}

View File

@@ -4,8 +4,11 @@ import dagger.Binds
import dagger.Module import dagger.Module
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
import hu.bbara.purefin.core.feature.update.AppUpdateController
import hu.bbara.purefin.core.feature.update.AppUpdateInstaller import hu.bbara.purefin.core.feature.update.AppUpdateInstaller
import hu.bbara.purefin.core.feature.update.AppVersionProvider import hu.bbara.purefin.core.feature.update.AppVersionProvider
import hu.bbara.purefin.core.settings.SettingsGroupProvider
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
@@ -16,4 +19,8 @@ abstract class AppUpdateModule {
@Binds @Binds
abstract fun bindAppVersionProvider(impl: AndroidAppVersionProvider): AppVersionProvider abstract fun bindAppVersionProvider(impl: AndroidAppVersionProvider): AppVersionProvider
@Binds
@IntoSet
abstract fun bindAppUpdateSettingsProvider(impl: AppUpdateController): SettingsGroupProvider
} }