diff --git a/AGENTS.md b/AGENTS.md index 7c7a90a5..25f345a7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,6 +31,7 @@ - `plugin_interfaces` also has a JS target (`browser()`), used by the plugin system. ## 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 `` 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` 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. - **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. diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index ec223223..dcddd6af 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -198,4 +198,12 @@ Go to album Sleep timer Track details + + Ignore + Update + Spotube %1$s is available + See what\'s new in this release on the Spotube downloads page. + %1$s %2$s is available + Currently installed: %1$s + See what\'s new in this update from the plugin\'s page. diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt index bb6785d2..6d9c9b22 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt @@ -22,30 +22,20 @@ import androidx.compose.animation.SharedTransitionLayout import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column 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.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier 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.navigation3.ui.NavDisplay -import com.mikepenz.markdown.m3.Markdown import dev.krtirtho.spotube.core.navigation.Navigator import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.navigation.TOP_LEVEL_ROUTES import dev.krtirtho.spotube.core.navigation.rememberNavigationState import dev.krtirtho.spotube.core.navigation.toEntries 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.component.LocalSharedTransitionScope 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.UserSettings 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.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.IconsaxCd @@ -69,7 +62,6 @@ import dev.krtirtho.spotube.resources.iconsax.User import kotlinx.coroutines.ExperimentalCoroutinesApi import org.koin.compose.koinInject import org.koin.compose.navigation3.koinEntryProvider -import org.koin.compose.viewmodel.koinViewModel import org.koin.core.annotation.KoinExperimentalAPI interface NavigationItem { @@ -143,8 +135,10 @@ fun App( ) { val settingsRepository: SettingsRepository = koinInject() val userSettings by settingsRepository.userSettings.collectAsStateWithLifecycle(initialValue = UserSettings()) - val updateCheckerViewModel: UpdateCheckerViewModel = koinViewModel() - val availableUpdate by updateCheckerViewModel.availableUpdate.collectAsStateWithLifecycle() + val appUpdateService: AppUpdateService = koinInject() + val pluginUpdateService: PluginUpdateService = koinInject() + val availableAppUpdate by appUpdateService.availableUpdate.collectAsStateWithLifecycle() + val availablePluginUpdate by pluginUpdateService.availableUpdate.collectAsStateWithLifecycle() val navigationState = rememberNavigationState( startRoute = Routes.Home, topLevelRoutes = TOP_LEVEL_ROUTES @@ -182,42 +176,27 @@ fun App( } } content() - if (availableUpdate != null) { - val update = availableUpdate!! - ThemedDialog( - onDismissRequest = updateCheckerViewModel::dismissUpdate, - title = { - Text( - text = "Spotube ${update.tag} is available", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.SemiBold, - ) + availableAppUpdate?.let { update -> + AppUpdateDialog( + update = update, + onDismiss = appUpdateService::dismissUpdate, + onIgnore = appUpdateService::ignoreUpdate, + onUpdate = { + runCatching { openUrlInBrowser("https://spotube.cc/downloads/") } + appUpdateService.dismissUpdate() }, - actions = { - OutlineButton(onClick = updateCheckerViewModel::ignoreUpdate) { - Text("Ignore") - } - PrimaryButton( - onClick = { - runCatching { openUrlInBrowser("https://spotube.cc/downloads/") } - updateCheckerViewModel.dismissUpdate() - }, - ) { - Text("Update") - } + ) + } + availablePluginUpdate?.let { update -> + PluginUpdateDialog( + update = update, + onDismiss = pluginUpdateService::dismissUpdate, + onIgnore = pluginUpdateService::ignoreUpdate, + onUpdate = { + runCatching { openUrlInBrowser(update.info.directDownloadUrl) } + pluginUpdateService.dismissUpdate() }, - ) { - if (update.releaseNotesMarkdown.isNotBlank()) { - SelectionContainer { - Markdown( - content = update.releaseNotesMarkdown, - modifier = Modifier.padding(bottom = 8.dp), - ) - } - } else { - Text("See what's new in this release on the Spotube downloads page.") - } - } + ) } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt index ca0e7686..e94d4222 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt @@ -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.search.SearchRepository 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.SettingsRepository import dev.krtirtho.spotube.modules.settings.UserSettingsSource @@ -136,7 +137,10 @@ val sharedModules = module { singleOf(::SettingsRepository) { bind() } viewModelOf(::SettingsViewModel) { bind() } viewModelOf(::JamSettingsViewModel) - viewModelOf(::UpdateCheckerViewModel) + + // Updates + single { AppUpdateService(get()) } withOptions { createdAtStart() } + single { PluginUpdateService(get(), get()) } withOptions { createdAtStart() } // Downloads singleOf(::DownloadManager) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsModels.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsModels.kt index 1ff47659..55173afc 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsModels.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsModels.kt @@ -87,7 +87,8 @@ data class UserSettings( // Updates val autoCheckForUpdates: Boolean = true, - val ignoredUpdateVersion: String? = null, + val ignoredAppUpdateVersion: String? = null, + val ignoredPluginUpdateVersions: Map = emptyMap(), ) /** diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/AppUpdateDialog.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/AppUpdateDialog.kt new file mode 100644 index 00000000..b007002d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/AppUpdateDialog.kt @@ -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 . + */ + +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)) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/UpdateCheckerViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/AppUpdateService.kt similarity index 78% rename from composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/UpdateCheckerViewModel.kt rename to composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/AppUpdateService.kt index d81692c7..19c5a8dd 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/UpdateCheckerViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/AppUpdateService.kt @@ -17,8 +17,6 @@ package dev.krtirtho.spotube.modules.update -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope import co.touchlab.kermit.Logger import dev.krtirtho.spotube.AppBuildInfo 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.http.HttpHeaders 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.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -38,27 +39,35 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import net.swiftzer.semver.SemVer +import org.koin.core.component.KoinComponent -data class AvailableUpdate( +data class AvailableAppUpdate( val tag: 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, -) : ViewModel() { - private val logger = Logger.withTag("UpdateChecker") +) : KoinComponent, AutoCloseable { + private val logger = Logger.withTag("AppUpdateService") + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val httpClient = HttpClient(CIO) { install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true }) } } - private val _availableUpdate = MutableStateFlow(null) - val availableUpdate: StateFlow = _availableUpdate.asStateFlow() + private val _availableUpdate = MutableStateFlow(null) + val availableUpdate: StateFlow = _availableUpdate.asStateFlow() init { - viewModelScope.launch { + scope.launch { checkForUpdate() } } @@ -80,8 +89,8 @@ class UpdateCheckerViewModel( val currentVersion = parseVersion(AppBuildInfo.VERSION) ?: return logger.w { "Ignoring update check for invalid app version: ${AppBuildInfo.VERSION}" } - if (latestVersion > currentVersion && release.tagName != settings.ignoredUpdateVersion) { - _availableUpdate.value = AvailableUpdate( + if (latestVersion > currentVersion && release.tagName != settings.ignoredAppUpdateVersion) { + _availableUpdate.value = AvailableAppUpdate( tag = release.tagName, releaseNotesMarkdown = release.body.orEmpty(), ) @@ -94,10 +103,10 @@ class UpdateCheckerViewModel( fun ignoreUpdate() { val update = _availableUpdate.value ?: return _availableUpdate.value = null - viewModelScope.launch { + scope.launch { runCatching { val settings = settingsRepository.getCurrentSettings() - settingsRepository.updateSettings(settings.copy(ignoredUpdateVersion = update.tag)) + settingsRepository.updateSettings(settings.copy(ignoredAppUpdateVersion = update.tag)) }.onFailure { e -> logger.e(e) { "Failed to persist ignored app version" } } @@ -112,7 +121,7 @@ class UpdateCheckerViewModel( SemVer.parse(version) }.getOrNull() - override fun onCleared() { + override fun close() { httpClient.close() } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/PluginUpdateDialog.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/PluginUpdateDialog.kt new file mode 100644 index 00000000..97641c6b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/PluginUpdateDialog.kt @@ -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 . + */ + +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)) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/PluginUpdateService.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/PluginUpdateService.kt new file mode 100644 index 00000000..03f4f53b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/PluginUpdateService.kt @@ -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 . + */ + +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(null) + val availableUpdate: StateFlow = _availableUpdate.asStateFlow() + + init { + scope.launch { + pluginManager.ziplineServices.filterNotNull().collect { services -> + checkForUpdates(services.values.filterNotNull()) + } + } + } + + private suspend fun checkForUpdates(services: List) { + // 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() +} diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties index 55147520..cef9c030 100644 --- a/gradle/gradle-daemon-jvm.properties +++ b/gradle/gradle-daemon-jvm.properties @@ -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.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 -toolchainVendor=JETBRAINS toolchainVersion=21 \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3eab5800..c1505c23 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -50,7 +50,7 @@ multiplatformMarkdownRenderer = "0.45.0" murmurhash = "0.4.2" newpipeextractor = "v0.26.5" newpipeExtractorKmp = "1.3.0" -nucleusNucleusApplication = "2.4.6" +nucleusNucleusApplication = "2.5.18" semver = "2.1.0" vlcj = "4.12.1" vlcjNative = "4.12.0" @@ -61,8 +61,8 @@ kotlinStdlib = "2.4.10" runner = "1.7.0" core = "1.7.0" filekit = "0.14.2" -compose-webview = "1.0.1" -composeNativeTray = "2.0.3" +compose-webview = "1.0.3" +composeNativeTray = "2.1.6" koin = "4.2.2" multiplatform-nav3-ui = "1.1.1" compose-multiplatform-adaptive = "1.3.0-alpha05"