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

@@ -7,6 +7,7 @@ import hu.bbara.purefin.core.data.HomeRepository
import hu.bbara.purefin.core.data.LocalMediaRepository
import hu.bbara.purefin.core.data.UserSessionRepository
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.model.EpisodeUiModel
import hu.bbara.purefin.core.model.LibraryUiModel
@@ -40,6 +41,7 @@ class AppViewModel @Inject constructor(
private val jellyfinMediaMetadataUpdater: JellyfinMediaMetadataUpdater,
private val navigationManager: NavigationManager,
private val mediaDownloadManager: MediaDownloadController,
private val appUpdateController: AppUpdateController,
) : ViewModel() {
private val _isRefreshing = MutableStateFlow(false)
@@ -239,6 +241,10 @@ class AppViewModel @Inject constructor(
viewModelScope.launch {
try {
homeRepository.refreshHomeData()
appUpdateController.checkForUpdates(
showUpToDateMessage = false,
showFailureMessage = false
)
} catch (e: Exception) {
// Refresh is best-effort; don't crash on failure
}
@@ -255,6 +261,10 @@ class AppViewModel @Inject constructor(
_isRefreshing.value = true
try {
homeRepository.refreshHomeData()
appUpdateController.checkForUpdates(
showUpToDateMessage = false,
showFailureMessage = false
)
} catch (e: Exception) {
// Refresh is best-effort; don't crash on failure
} 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.DropdownSetting
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.SettingsOptions
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 javax.inject.Inject
@@ -16,8 +28,30 @@ import javax.inject.Inject
class SettingsViewModel @Inject constructor(
private val settingsRepository: SettingsRepository,
private val navigationManager: NavigationManager,
settingsGroupProviders: Set<@JvmSuppressWildcards SettingsGroupProvider>,
) : 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: 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() {
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.viewModelScope
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.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class AppUpdateViewModel @Inject constructor(
private val appUpdateRepository: AppUpdateRepository,
private val appUpdateInstaller: AppUpdateInstaller
private val appUpdateController: AppUpdateController
) : ViewModel() {
private val _isCheckingForUpdates = MutableStateFlow(false)
val isCheckingForUpdates: StateFlow<Boolean> = _isCheckingForUpdates.asStateFlow()
val isCheckingForUpdates: StateFlow<Boolean> = appUpdateController.isCheckingForUpdates
private val _availableUpdate = MutableStateFlow<AppUpdateInfo?>(null)
val availableUpdate: StateFlow<AppUpdateInfo?> = _availableUpdate.asStateFlow()
val availableUpdate: StateFlow<AppUpdateInfo?> = appUpdateController.availableUpdate
private val _snackbarMessages = MutableSharedFlow<String>()
val snackbarMessages: SharedFlow<String> = _snackbarMessages.asSharedFlow()
private var isInstallingUpdate = false
val snackbarMessages: SharedFlow<String> = appUpdateController.snackbarMessages
fun checkForUpdates(showUpToDateMessage: Boolean = true) {
if (_isCheckingForUpdates.value) {
return
}
viewModelScope.launch {
_isCheckingForUpdates.value = true
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
}
appUpdateController.checkForUpdates(showUpToDateMessage = showUpToDateMessage)
}
}
fun acceptUpdate() {
val update = _availableUpdate.value ?: return
if (isInstallingUpdate) {
return
}
viewModelScope.launch {
isInstallingUpdate = true
_availableUpdate.value = null
try {
_snackbarMessages.emit(appUpdateInstaller.installUpdate(update))
} catch (e: Exception) {
_snackbarMessages.emit(e.message ?: "Update install failed")
} finally {
isInstallingUpdate = false
}
appUpdateController.installAvailableUpdate()
}
}
fun declineUpdate() {
_availableUpdate.value = null
appUpdateController.declineUpdate()
}
}

View File

@@ -28,7 +28,8 @@ data class StringSetting(
data class VoidSetting(
override val key: String,
override val title: String,
override val defaultValue: Unit = Unit
override val defaultValue: Unit = Unit,
val onClick: suspend () -> Unit = {}
) : SettingOption<Unit>
data class DropdownSetting<T>(
@@ -44,18 +45,6 @@ data class SettingGroup(
)
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(
key = "auto_play_next_media",
@@ -63,34 +52,11 @@ object SettingsOptions {
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(
SettingGroup(
title = "Playback",
options = listOf(
defaultPlaybackSpeed,
confirmMobileDataPlayback,
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.hilt.InstallIn
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.AppVersionProvider
import hu.bbara.purefin.core.settings.SettingsGroupProvider
@Module
@InstallIn(SingletonComponent::class)
@@ -16,4 +19,8 @@ abstract class AppUpdateModule {
@Binds
abstract fun bindAppVersionProvider(impl: AndroidAppVersionProvider): AppVersionProvider
@Binds
@IntoSet
abstract fun bindAppUpdateSettingsProvider(impl: AppUpdateController): SettingsGroupProvider
}