feat(update): refactor update checking mechanism and implement new dialog for app and plugin updates

This commit is contained in:
Kingkor Roy Tirtho 2026-09-30 09:58:04 +06:00
parent 3a85ad0896
commit 4e2afeaea4
11 changed files with 376 additions and 69 deletions

View File

@ -31,6 +31,7 @@
- `plugin_interfaces` also has a JS target (`browser()`), used by the plugin system. - `plugin_interfaces` also has a JS target (`browser()`), used by the plugin system.
## UI component patterns ## UI component patterns
- **ALL user-facing strings MUST come from `composeApp/src/commonMain/composeResources/values/strings.xml` via `stringResource(Res.string.xxx)`.** Never hardcode a literal string in a `Text(...)`, dialog title, button label, content description, placeholder, or error message inside `@Composable` code. If the string you need doesn't exist yet, add a new `<string name="...">` entry to `strings.xml` (grouped near related keys, following the existing `feature_context_purpose` naming convention) and reference it — do not inline the text "just this once". This applies to every module, including ones you're about to add (dialogs, screens, snackbars, etc.), not just `modules/settings`. `strings.xml` is the single source of truth for copy, not a showcase file for one screen.
- **ViewModels own ALL state and logic.** Composables are dumb renderers: they collect a single `StateFlow<UiState>` from the ViewModel and forward user events (clicks, text input, drag callbacks) back to ViewModel functions. No business logic, filtering, derivation, reordering buffers, or `LaunchedEffect`-based state syncing belongs in a composable — it goes in the ViewModel. The `combine`/`stateIn` flow chain in the ViewModel must produce fully-computed, ready-to-render UI state so the composable never needs intermediate `remember` derivations or `mutableStateListOf` mirrors. - **ViewModels own ALL state and logic.** Composables are dumb renderers: they collect a single `StateFlow<UiState>` from the ViewModel and forward user events (clicks, text input, drag callbacks) back to ViewModel functions. No business logic, filtering, derivation, reordering buffers, or `LaunchedEffect`-based state syncing belongs in a composable — it goes in the ViewModel. The `combine`/`stateIn` flow chain in the ViewModel must produce fully-computed, ready-to-render UI state so the composable never needs intermediate `remember` derivations or `mutableStateListOf` mirrors.
- **AdaptiveDropdownBottomSheet** (`commonMain/.../core/ui/component/AdaptiveDropdownBottomSheet.kt`): switches between `DropdownMenu` (large screen) and `ModalBottomSheet` (small screen) via `currentWindowAdaptiveInfo()`. Do NOT use expect/actual — all adaptive components that rely ONLY on Compose/Material3 APIs belong in commonMain. - **AdaptiveDropdownBottomSheet** (`commonMain/.../core/ui/component/AdaptiveDropdownBottomSheet.kt`): switches between `DropdownMenu` (large screen) and `ModalBottomSheet` (small screen) via `currentWindowAdaptiveInfo()`. Do NOT use expect/actual — all adaptive components that rely ONLY on Compose/Material3 APIs belong in commonMain.
- **AdaptiveDialogBottomSheet** (`commonMain/.../core/ui/component/AdaptiveDialogBottomSheet.kt`): switches between `ThemedDialog` (large screen) and `ModalBottomSheet` (small screen) via `currentWindowAdaptiveInfo()`. Same rule — keep in commonMain unless platform-specific APIs are required. - **AdaptiveDialogBottomSheet** (`commonMain/.../core/ui/component/AdaptiveDialogBottomSheet.kt`): switches between `ThemedDialog` (large screen) and `ModalBottomSheet` (small screen) via `currentWindowAdaptiveInfo()`. Same rule — keep in commonMain unless platform-specific APIs are required.

View File

@ -198,4 +198,12 @@
<string name="player_go_to_album">Go to album</string> <string name="player_go_to_album">Go to album</string>
<string name="player_sleep_timer">Sleep timer</string> <string name="player_sleep_timer">Sleep timer</string>
<string name="player_track_details">Track details</string> <string name="player_track_details">Track details</string>
<string name="update_action_ignore">Ignore</string>
<string name="update_action_update">Update</string>
<string name="app_update_dialog_title">Spotube %1$s is available</string>
<string name="app_update_dialog_no_notes">See what\'s new in this release on the Spotube downloads page.</string>
<string name="plugin_update_dialog_title">%1$s %2$s is available</string>
<string name="plugin_update_dialog_current_version">Currently installed: %1$s</string>
<string name="plugin_update_dialog_no_notes">See what\'s new in this update from the plugin\'s page.</string>
</resources> </resources>

View File

@ -22,30 +22,20 @@ import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation3.ui.NavDisplay import androidx.navigation3.ui.NavDisplay
import com.mikepenz.markdown.m3.Markdown
import dev.krtirtho.spotube.core.navigation.Navigator import dev.krtirtho.spotube.core.navigation.Navigator
import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.core.navigation.TOP_LEVEL_ROUTES import dev.krtirtho.spotube.core.navigation.TOP_LEVEL_ROUTES
import dev.krtirtho.spotube.core.navigation.rememberNavigationState import dev.krtirtho.spotube.core.navigation.rememberNavigationState
import dev.krtirtho.spotube.core.navigation.toEntries import dev.krtirtho.spotube.core.navigation.toEntries
import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme
import dev.krtirtho.spotube.core.ui.base.OutlineButton
import dev.krtirtho.spotube.core.ui.base.PrimaryButton
import dev.krtirtho.spotube.core.ui.base.ThemedDialog
import dev.krtirtho.spotube.core.ui.base.rememberBaseUITheme import dev.krtirtho.spotube.core.ui.base.rememberBaseUITheme
import dev.krtirtho.spotube.core.ui.component.LocalSharedTransitionScope import dev.krtirtho.spotube.core.ui.component.LocalSharedTransitionScope
import dev.krtirtho.spotube.core.ui.theming.SpotubeTheme import dev.krtirtho.spotube.core.ui.theming.SpotubeTheme
@ -53,7 +43,10 @@ import dev.krtirtho.spotube.modules.library.LibraryTab
import dev.krtirtho.spotube.modules.settings.SettingsRepository import dev.krtirtho.spotube.modules.settings.SettingsRepository
import dev.krtirtho.spotube.modules.settings.UserSettings import dev.krtirtho.spotube.modules.settings.UserSettings
import dev.krtirtho.spotube.modules.shell.AppShell import dev.krtirtho.spotube.modules.shell.AppShell
import dev.krtirtho.spotube.modules.update.UpdateCheckerViewModel import dev.krtirtho.spotube.modules.update.AppUpdateDialog
import dev.krtirtho.spotube.modules.update.AppUpdateService
import dev.krtirtho.spotube.modules.update.PluginUpdateDialog
import dev.krtirtho.spotube.modules.update.PluginUpdateService
import dev.krtirtho.spotube.modules.webview.WebViewScreen import dev.krtirtho.spotube.modules.webview.WebViewScreen
import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxCd import dev.krtirtho.spotube.resources.iconsax.IconsaxCd
@ -69,7 +62,6 @@ import dev.krtirtho.spotube.resources.iconsax.User
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import org.koin.compose.koinInject import org.koin.compose.koinInject
import org.koin.compose.navigation3.koinEntryProvider import org.koin.compose.navigation3.koinEntryProvider
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.annotation.KoinExperimentalAPI import org.koin.core.annotation.KoinExperimentalAPI
interface NavigationItem { interface NavigationItem {
@ -143,8 +135,10 @@ fun App(
) { ) {
val settingsRepository: SettingsRepository = koinInject<SettingsRepository>() val settingsRepository: SettingsRepository = koinInject<SettingsRepository>()
val userSettings by settingsRepository.userSettings.collectAsStateWithLifecycle(initialValue = UserSettings()) val userSettings by settingsRepository.userSettings.collectAsStateWithLifecycle(initialValue = UserSettings())
val updateCheckerViewModel: UpdateCheckerViewModel = koinViewModel() val appUpdateService: AppUpdateService = koinInject()
val availableUpdate by updateCheckerViewModel.availableUpdate.collectAsStateWithLifecycle() val pluginUpdateService: PluginUpdateService = koinInject()
val availableAppUpdate by appUpdateService.availableUpdate.collectAsStateWithLifecycle()
val availablePluginUpdate by pluginUpdateService.availableUpdate.collectAsStateWithLifecycle()
val navigationState = rememberNavigationState( val navigationState = rememberNavigationState(
startRoute = Routes.Home, topLevelRoutes = TOP_LEVEL_ROUTES startRoute = Routes.Home, topLevelRoutes = TOP_LEVEL_ROUTES
@ -182,42 +176,27 @@ fun App(
} }
} }
content() content()
if (availableUpdate != null) { availableAppUpdate?.let { update ->
val update = availableUpdate!! AppUpdateDialog(
ThemedDialog( update = update,
onDismissRequest = updateCheckerViewModel::dismissUpdate, onDismiss = appUpdateService::dismissUpdate,
title = { onIgnore = appUpdateService::ignoreUpdate,
Text( onUpdate = {
text = "Spotube ${update.tag} is available",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
)
},
actions = {
OutlineButton(onClick = updateCheckerViewModel::ignoreUpdate) {
Text("Ignore")
}
PrimaryButton(
onClick = {
runCatching { openUrlInBrowser("https://spotube.cc/downloads/") } runCatching { openUrlInBrowser("https://spotube.cc/downloads/") }
updateCheckerViewModel.dismissUpdate() appUpdateService.dismissUpdate()
}, },
) {
Text("Update")
}
},
) {
if (update.releaseNotesMarkdown.isNotBlank()) {
SelectionContainer {
Markdown(
content = update.releaseNotesMarkdown,
modifier = Modifier.padding(bottom = 8.dp),
) )
} }
} else { availablePluginUpdate?.let { update ->
Text("See what's new in this release on the Spotube downloads page.") PluginUpdateDialog(
} update = update,
} onDismiss = pluginUpdateService::dismissUpdate,
onIgnore = pluginUpdateService::ignoreUpdate,
onUpdate = {
runCatching { openUrlInBrowser(update.info.directDownloadUrl) }
pluginUpdateService.dismissUpdate()
},
)
} }
} }
} }

View File

@ -73,7 +73,8 @@ import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksViewModel import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksViewModel
import dev.krtirtho.spotube.modules.search.SearchRepository import dev.krtirtho.spotube.modules.search.SearchRepository
import dev.krtirtho.spotube.modules.search.SearchScreenViewModel import dev.krtirtho.spotube.modules.search.SearchScreenViewModel
import dev.krtirtho.spotube.modules.update.UpdateCheckerViewModel import dev.krtirtho.spotube.modules.update.AppUpdateService
import dev.krtirtho.spotube.modules.update.PluginUpdateService
import dev.krtirtho.spotube.modules.settings.SettingsProvider import dev.krtirtho.spotube.modules.settings.SettingsProvider
import dev.krtirtho.spotube.modules.settings.SettingsRepository import dev.krtirtho.spotube.modules.settings.SettingsRepository
import dev.krtirtho.spotube.modules.settings.UserSettingsSource import dev.krtirtho.spotube.modules.settings.UserSettingsSource
@ -136,7 +137,10 @@ val sharedModules = module {
singleOf(::SettingsRepository) { bind<UserSettingsSource>() } singleOf(::SettingsRepository) { bind<UserSettingsSource>() }
viewModelOf(::SettingsViewModel) { bind<SettingsProvider>() } viewModelOf(::SettingsViewModel) { bind<SettingsProvider>() }
viewModelOf(::JamSettingsViewModel) viewModelOf(::JamSettingsViewModel)
viewModelOf(::UpdateCheckerViewModel)
// Updates
single { AppUpdateService(get()) } withOptions { createdAtStart() }
single { PluginUpdateService(get(), get()) } withOptions { createdAtStart() }
// Downloads // Downloads
singleOf(::DownloadManager) singleOf(::DownloadManager)

View File

@ -87,7 +87,8 @@ data class UserSettings(
// Updates // Updates
val autoCheckForUpdates: Boolean = true, val autoCheckForUpdates: Boolean = true,
val ignoredUpdateVersion: String? = null, val ignoredAppUpdateVersion: String? = null,
val ignoredPluginUpdateVersions: Map<String, String> = emptyMap(),
) )
/** /**

View File

@ -0,0 +1,80 @@
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.krtirtho.spotube.modules.update
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.mikepenz.markdown.m3.Markdown
import dev.krtirtho.spotube.core.ui.base.OutlineButton
import dev.krtirtho.spotube.core.ui.base.PrimaryButton
import dev.krtirtho.spotube.core.ui.base.ThemedDialog
import org.jetbrains.compose.resources.stringResource
import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.app_update_dialog_no_notes
import spotube.composeapp.generated.resources.app_update_dialog_title
import spotube.composeapp.generated.resources.update_action_ignore
import spotube.composeapp.generated.resources.update_action_update
/**
* Prompts the user that a newer Spotube release is available on GitHub. [onUpdate]
* should take the user to the downloads page; [onIgnore] persistently silences this
* specific release tag so the check moves on to the next newer one.
*/
@Composable
fun AppUpdateDialog(
update: AvailableAppUpdate,
onDismiss: () -> Unit,
onIgnore: () -> Unit,
onUpdate: () -> Unit,
) {
ThemedDialog(
onDismissRequest = onDismiss,
title = {
Text(
text = stringResource(Res.string.app_update_dialog_title, update.tag),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
)
},
actions = {
OutlineButton(onClick = onIgnore) {
Text(stringResource(Res.string.update_action_ignore))
}
PrimaryButton(onClick = onUpdate) {
Text(stringResource(Res.string.update_action_update))
}
},
) {
if (update.releaseNotesMarkdown.isNotBlank()) {
SelectionContainer {
Markdown(
content = update.releaseNotesMarkdown,
modifier = Modifier.padding(bottom = 8.dp),
)
}
} else {
Text(stringResource(Res.string.app_update_dialog_no_notes))
}
}
}

View File

@ -17,8 +17,6 @@
package dev.krtirtho.spotube.modules.update package dev.krtirtho.spotube.modules.update
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import co.touchlab.kermit.Logger import co.touchlab.kermit.Logger
import dev.krtirtho.spotube.AppBuildInfo import dev.krtirtho.spotube.AppBuildInfo
import dev.krtirtho.spotube.modules.settings.SettingsRepository import dev.krtirtho.spotube.modules.settings.SettingsRepository
@ -30,6 +28,9 @@ import io.ktor.client.request.get
import io.ktor.client.request.header import io.ktor.client.request.header
import io.ktor.http.HttpHeaders import io.ktor.http.HttpHeaders
import io.ktor.serialization.kotlinx.json.json import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@ -38,27 +39,35 @@ import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import net.swiftzer.semver.SemVer import net.swiftzer.semver.SemVer
import org.koin.core.component.KoinComponent
data class AvailableUpdate( data class AvailableAppUpdate(
val tag: String, val tag: String,
val releaseNotesMarkdown: String, val releaseNotesMarkdown: String,
) )
class UpdateCheckerViewModel( /**
* Checks GitHub for a newer app release once on startup and exposes it for the UI to
* surface via [dev.krtirtho.spotube.modules.update.AppUpdateDialog]. This is an
* app-scoped singleton (not a ViewModel) since the check runs once for the whole
* process, independent of any screen's lifecycle.
*/
class AppUpdateService(
private val settingsRepository: SettingsRepository, private val settingsRepository: SettingsRepository,
) : ViewModel() { ) : KoinComponent, AutoCloseable {
private val logger = Logger.withTag("UpdateChecker") private val logger = Logger.withTag("AppUpdateService")
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val httpClient = HttpClient(CIO) { private val httpClient = HttpClient(CIO) {
install(ContentNegotiation) { install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true }) json(Json { ignoreUnknownKeys = true })
} }
} }
private val _availableUpdate = MutableStateFlow<AvailableUpdate?>(null) private val _availableUpdate = MutableStateFlow<AvailableAppUpdate?>(null)
val availableUpdate: StateFlow<AvailableUpdate?> = _availableUpdate.asStateFlow() val availableUpdate: StateFlow<AvailableAppUpdate?> = _availableUpdate.asStateFlow()
init { init {
viewModelScope.launch { scope.launch {
checkForUpdate() checkForUpdate()
} }
} }
@ -80,8 +89,8 @@ class UpdateCheckerViewModel(
val currentVersion = parseVersion(AppBuildInfo.VERSION) val currentVersion = parseVersion(AppBuildInfo.VERSION)
?: return logger.w { "Ignoring update check for invalid app version: ${AppBuildInfo.VERSION}" } ?: return logger.w { "Ignoring update check for invalid app version: ${AppBuildInfo.VERSION}" }
if (latestVersion > currentVersion && release.tagName != settings.ignoredUpdateVersion) { if (latestVersion > currentVersion && release.tagName != settings.ignoredAppUpdateVersion) {
_availableUpdate.value = AvailableUpdate( _availableUpdate.value = AvailableAppUpdate(
tag = release.tagName, tag = release.tagName,
releaseNotesMarkdown = release.body.orEmpty(), releaseNotesMarkdown = release.body.orEmpty(),
) )
@ -94,10 +103,10 @@ class UpdateCheckerViewModel(
fun ignoreUpdate() { fun ignoreUpdate() {
val update = _availableUpdate.value ?: return val update = _availableUpdate.value ?: return
_availableUpdate.value = null _availableUpdate.value = null
viewModelScope.launch { scope.launch {
runCatching { runCatching {
val settings = settingsRepository.getCurrentSettings() val settings = settingsRepository.getCurrentSettings()
settingsRepository.updateSettings(settings.copy(ignoredUpdateVersion = update.tag)) settingsRepository.updateSettings(settings.copy(ignoredAppUpdateVersion = update.tag))
}.onFailure { e -> }.onFailure { e ->
logger.e(e) { "Failed to persist ignored app version" } logger.e(e) { "Failed to persist ignored app version" }
} }
@ -112,7 +121,7 @@ class UpdateCheckerViewModel(
SemVer.parse(version) SemVer.parse(version)
}.getOrNull() }.getOrNull()
override fun onCleared() { override fun close() {
httpClient.close() httpClient.close()
} }
} }

View File

@ -0,0 +1,89 @@
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.krtirtho.spotube.modules.update
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.mikepenz.markdown.m3.Markdown
import dev.krtirtho.spotube.core.ui.base.OutlineButton
import dev.krtirtho.spotube.core.ui.base.PrimaryButton
import dev.krtirtho.spotube.core.ui.base.ThemedDialog
import org.jetbrains.compose.resources.stringResource
import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.plugin_update_dialog_current_version
import spotube.composeapp.generated.resources.plugin_update_dialog_no_notes
import spotube.composeapp.generated.resources.plugin_update_dialog_title
import spotube.composeapp.generated.resources.update_action_ignore
import spotube.composeapp.generated.resources.update_action_update
/**
* Prompts the user that a newer version of one of their installed plugins is
* available. [onUpdate] should open the plugin's direct download URL; [onIgnore]
* persistently silences this specific plugin version so the check moves on to the
* next newer one (or another plugin).
*/
@Composable
fun PluginUpdateDialog(
update: AvailablePluginUpdate,
onDismiss: () -> Unit,
onIgnore: () -> Unit,
onUpdate: () -> Unit,
) {
ThemedDialog(
onDismissRequest = onDismiss,
title = {
Text(
text = stringResource(
Res.string.plugin_update_dialog_title,
update.pluginName,
update.info.latestVersion,
),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
)
},
actions = {
OutlineButton(onClick = onIgnore) {
Text(stringResource(Res.string.update_action_ignore))
}
PrimaryButton(onClick = onUpdate) {
Text(stringResource(Res.string.update_action_update))
}
},
) {
Text(
text = stringResource(Res.string.plugin_update_dialog_current_version, update.currentVersion),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 8.dp),
)
if (update.info.releaseNotes.isNotBlank()) {
SelectionContainer {
Markdown(content = update.info.releaseNotes)
}
} else {
Text(stringResource(Res.string.plugin_update_dialog_no_notes))
}
}
}

View File

@ -0,0 +1,137 @@
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.krtirtho.spotube.modules.update
import co.touchlab.kermit.Logger
import dev.krtirtho.plugin_interfaces.plugin_apis.core.PluginUpdateInfo
import dev.krtirtho.spotube.core.zipline.PluginService
import dev.krtirtho.spotube.modules.plugin.PluginEntry
import dev.krtirtho.spotube.modules.plugin.PluginManager
import dev.krtirtho.spotube.modules.plugin.PluginManagerStates
import dev.krtirtho.spotube.modules.settings.SettingsRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.launch
import net.swiftzer.semver.SemVer
import org.koin.core.component.KoinComponent
data class AvailablePluginUpdate(
val pluginId: String,
val pluginName: String,
val currentVersion: String,
val info: PluginUpdateInfo,
)
/**
* Checks the currently active plugins for updates through their own [PluginService.use]
* -> `coreAPI.checkPluginUpdates` API whenever the selected plugin set changes, and
* exposes at most one pending update at a time for the UI to surface via
* [dev.krtirtho.spotube.modules.update.PluginUpdateDialog]. This is an app-scoped
* singleton (not a ViewModel) since plugin runtimes are owned by [PluginManager]
* independent of any screen's lifecycle.
*/
class PluginUpdateService(
private val pluginManager: PluginManager,
private val settingsRepository: SettingsRepository,
) : KoinComponent {
private val logger = Logger.withTag("PluginUpdateService")
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val _availableUpdate = MutableStateFlow<AvailablePluginUpdate?>(null)
val availableUpdate: StateFlow<AvailablePluginUpdate?> = _availableUpdate.asStateFlow()
init {
scope.launch {
pluginManager.ziplineServices.filterNotNull().collect { services ->
checkForUpdates(services.values.filterNotNull())
}
}
}
private suspend fun checkForUpdates(services: List<PluginService>) {
// Only surface one plugin update prompt at a time; re-checks happen the
// next time the selected plugin set changes.
if (_availableUpdate.value != null) return
val settings = settingsRepository.getCurrentSettings()
if (!settings.autoCheckForUpdates) return
for (service in services.distinctBy { it.pluginId }) {
val pluginEntry = findPluginEntry(service.pluginId) ?: continue
val currentVersion = parseVersion(pluginEntry.version) ?: continue
try {
val updateInfo = service.use { coreAPI.checkPluginUpdates(currentVersion) } ?: continue
val latestVersion = parseVersion(updateInfo.latestVersion)
if (latestVersion == null) {
logger.w { "Ignoring plugin update with invalid SemVer: ${updateInfo.latestVersion}" }
continue
}
val ignoredVersion = settings.ignoredPluginUpdateVersions[pluginEntry.id]
if (latestVersion > currentVersion && updateInfo.latestVersion != ignoredVersion) {
_availableUpdate.value = AvailablePluginUpdate(
pluginId = pluginEntry.id,
pluginName = pluginEntry.name,
currentVersion = pluginEntry.version,
info = updateInfo,
)
return
}
} catch (e: Exception) {
logger.w(e) { "Failed to check updates for plugin ${pluginEntry.name}" }
}
}
}
private fun findPluginEntry(pluginId: String): PluginEntry? {
val state = pluginManager.state.value as? PluginManagerStates.Data ?: return null
return state.plugins.find { it.id == pluginId }
}
fun ignoreUpdate() {
val update = _availableUpdate.value ?: return
_availableUpdate.value = null
scope.launch {
runCatching {
val settings = settingsRepository.getCurrentSettings()
settingsRepository.updateSettings(
settings.copy(
ignoredPluginUpdateVersions = settings.ignoredPluginUpdateVersions +
(update.pluginId to update.info.latestVersion)
)
)
}.onFailure { e ->
logger.e(e) { "Failed to persist ignored plugin update version" }
}
}
}
fun dismissUpdate() {
_availableUpdate.value = null
}
private fun parseVersion(version: String): SemVer? = runCatching {
SemVer.parse(version)
}.getOrNull()
}

View File

@ -26,5 +26,4 @@ toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/402983f310a88ac6
toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/e50b80b5a11d194a898bc3e6211b7c4b/redirect toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/e50b80b5a11d194a898bc3e6211b7c4b/redirect
toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/476d3c08f4989328dee56d22e202d98d/redirect toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/476d3c08f4989328dee56d22e202d98d/redirect
toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/5a88b04b5e582b332d2e6bc12b45f1b9/redirect toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/5a88b04b5e582b332d2e6bc12b45f1b9/redirect
toolchainVendor=JETBRAINS
toolchainVersion=21 toolchainVersion=21

View File

@ -50,7 +50,7 @@ multiplatformMarkdownRenderer = "0.45.0"
murmurhash = "0.4.2" murmurhash = "0.4.2"
newpipeextractor = "v0.26.5" newpipeextractor = "v0.26.5"
newpipeExtractorKmp = "1.3.0" newpipeExtractorKmp = "1.3.0"
nucleusNucleusApplication = "2.4.6" nucleusNucleusApplication = "2.5.18"
semver = "2.1.0" semver = "2.1.0"
vlcj = "4.12.1" vlcj = "4.12.1"
vlcjNative = "4.12.0" vlcjNative = "4.12.0"
@ -61,8 +61,8 @@ kotlinStdlib = "2.4.10"
runner = "1.7.0" runner = "1.7.0"
core = "1.7.0" core = "1.7.0"
filekit = "0.14.2" filekit = "0.14.2"
compose-webview = "1.0.1" compose-webview = "1.0.3"
composeNativeTray = "2.0.3" composeNativeTray = "2.1.6"
koin = "4.2.2" koin = "4.2.2"
multiplatform-nav3-ui = "1.1.1" multiplatform-nav3-ui = "1.1.1"
compose-multiplatform-adaptive = "1.3.0-alpha05" compose-multiplatform-adaptive = "1.3.0-alpha05"