From 3a85ad08961cc38a4a2e9665f6e3c3b17304161e Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 25 Sep 2026 22:39:33 +0600 Subject: [PATCH] feat(update): implement update checker with dialog for available updates --- composeApp/build.gradle.kts | 64 +++++++-- .../kotlin/dev/krtirtho/spotube/App.kt | 62 +++++++-- .../dev/krtirtho/spotube/core/di/Modules.kt | 2 + .../modules/settings/SettingsModels.kt | 1 + .../modules/settings/SettingsRepository.kt | 8 +- .../modules/update/UpdateCheckerViewModel.kt | 124 ++++++++++++++++++ .../templates/AppBuildInfo.kt.template | 25 ++++ gradle/libs.versions.toml | 3 + 8 files changed, 271 insertions(+), 18 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/UpdateCheckerViewModel.kt create mode 100644 composeApp/src/commonMain/templates/AppBuildInfo.kt.template diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index cdca27ed..3d4027e3 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -20,6 +20,7 @@ import gobley.gradle.cargo.dsl.jvm import dev.nucleusframework.desktop.application.dsl.TargetFormat import org.jetbrains.compose.reload.gradle.ComposeHotRun import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.gradle.api.tasks.Copy import java.io.FileInputStream import java.util.Properties @@ -39,11 +40,53 @@ plugins { kotlin("plugin.atomicfu") version libs.versions.kotlin } +val bundleId = "dev.krtirtho.spotube" +val version = "6.0.0" +val buildNumber = 46 +val targetName = "Spotube" +val homePageUrl = "https://spotube.cc" +val repositoryUrl = "https://github.com/team-spotube/spotube" +val appVersionName = version +val appBuildNumber = buildNumber +val appRepositoryUrl = repositoryUrl +val githubRepositoryPath = repositoryUrl.removePrefix("https://github.com/") +val latestReleaseApiUrl = "https://api.github.com/repos/$githubRepositoryPath/releases/latest" +val generatedAppInfoDir = layout.buildDirectory.dir("generated/appBuildInfo/commonMain/kotlin") +val generateAppBuildInfo by tasks.registering(Copy::class) { + description = "Generate AppBuildInfo.kt" + from(layout.projectDirectory.file("src/commonMain/templates/AppBuildInfo.kt.template")) + into(layout.buildDirectory.dir("generated/appBuildInfo/commonMain/kotlin/dev/krtirtho/spotube")) + rename("AppBuildInfo.kt.template", "AppBuildInfo.kt") + expand( + mapOf( + "appVersion" to appVersionName, + "appBuildNumber" to appBuildNumber, + "repositoryUrl" to appRepositoryUrl, + "latestReleaseApiUrl" to latestReleaseApiUrl, + ) + ) + inputs.properties( + mapOf( + "appVersion" to appVersionName, + "appBuildNumber" to appBuildNumber, + "repositoryUrl" to appRepositoryUrl, + "latestReleaseApiUrl" to latestReleaseApiUrl, + ) + ) +} + +tasks.configureEach { + if (name.startsWith("compile") && name.contains("Kotlin")) { + dependsOn(generateAppBuildInfo) + } +} + vlcjBundler { - packageName = "dev.krtirtho.spotube.core.generated" // choose a different package + packageName = "$bundleId.core.generated" // choose a different package objectName = "VLCBundleLoaderGenerated" // or rename the object } + kotlin { // Note: For Android application modules, androidTarget() is still required as of AGP 8.x. // The deprecation warning is expected. For libraries, use the androidKotlinMultiplatformLibrary plugin instead. @@ -68,6 +111,7 @@ kotlin { sourceSets { val commonMain by getting { + kotlin.srcDir(generatedAppInfoDir) dependencies { // Project dependencies implementation(project(":plugin_interfaces")) @@ -172,6 +216,10 @@ kotlin { implementation(libs.mqtt.x.models) implementation(libs.mqtt.buffer) implementation(libs.mqtt.buffer.codec) + + // Markdown renderer + implementation(libs.multiplatform.markdown.renderer) + implementation(libs.multiplatform.markdown.renderer.m3) } } commonTest.dependencies { @@ -249,11 +297,11 @@ android { compileSdk = libs.versions.android.compileSdk.get().toInt() defaultConfig { - applicationId = "dev.krtirtho.spotube" + applicationId = bundleId minSdk = libs.versions.android.minSdk.get().toInt() targetSdk = libs.versions.android.targetSdk.get().toInt() - versionCode = 1 - versionName = project.findProperty("versionName") as String? ?: "1.0" + versionCode = buildNumber + versionName = version } packaging { resources { @@ -312,8 +360,8 @@ nucleus.application { ) nativeDistributions { - packageName = "dev.krtirtho.spotube" - packageVersion = project.findProperty("versionName") as String? ?: "6.0.0" + packageName = bundleId + packageVersion = version licenseFile = project.file("../LICENSE") appResourcesRootDir.set(vlcjBundler.vlcNativesDirectory) @@ -329,7 +377,7 @@ nucleus.application { modules("jdk.unsupported") - homepage = "https://spotube.cc" + homepage = homePageUrl linux { modules("jdk.security.auth") @@ -371,4 +419,4 @@ cargo { builds.jvm { embedRustLibrary = (rustTarget == GobleyHost.current.rustTarget) } -} \ No newline at end of file +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt index ae862050..bb6785d2 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt @@ -22,49 +22,54 @@ 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.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme -import dev.krtirtho.spotube.core.ui.base.rememberBaseUITheme 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 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.webview.WebViewScreen import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.IconsaxCd import dev.krtirtho.spotube.resources.iconsax.IconsaxDirectboxReceive import dev.krtirtho.spotube.resources.iconsax.IconsaxHome -import dev.krtirtho.spotube.resources.iconsax.IconsaxHomeBroken import dev.krtirtho.spotube.resources.iconsax.IconsaxMirrorScreenRegular -import dev.krtirtho.spotube.resources.iconsax.IconsaxMirroringScreen import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicDashboard import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicLibrary -import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicLibraryOutline import dev.krtirtho.spotube.resources.iconsax.IconsaxSearch -import dev.krtirtho.spotube.resources.iconsax.IconsaxSearchBroken import dev.krtirtho.spotube.resources.iconsax.IconsaxSetting2 -import dev.krtirtho.spotube.resources.iconsax.IconsaxSettingTwotone -import dev.krtirtho.spotube.resources.iconsax.IconsaxSound import dev.krtirtho.spotube.resources.iconsax.IconsaxSoundTwotone 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 { @@ -138,6 +143,8 @@ 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 navigationState = rememberNavigationState( startRoute = Routes.Home, topLevelRoutes = TOP_LEVEL_ROUTES @@ -175,6 +182,43 @@ 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, + ) + }, + actions = { + OutlineButton(onClick = updateCheckerViewModel::ignoreUpdate) { + Text("Ignore") + } + PrimaryButton( + onClick = { + runCatching { openUrlInBrowser("https://spotube.cc/downloads/") } + updateCheckerViewModel.dismissUpdate() + }, + ) { + Text("Update") + } + }, + ) { + 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 4853e271..ca0e7686 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,6 +73,7 @@ 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.settings.SettingsProvider import dev.krtirtho.spotube.modules.settings.SettingsRepository import dev.krtirtho.spotube.modules.settings.UserSettingsSource @@ -135,6 +136,7 @@ val sharedModules = module { singleOf(::SettingsRepository) { bind() } viewModelOf(::SettingsViewModel) { bind() } viewModelOf(::JamSettingsViewModel) + viewModelOf(::UpdateCheckerViewModel) // 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 3a6792ff..1ff47659 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,6 +87,7 @@ data class UserSettings( // Updates val autoCheckForUpdates: Boolean = true, + val ignoredUpdateVersion: String? = null, ) /** diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsRepository.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsRepository.kt index 8a31bd06..1d6b0d6f 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsRepository.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsRepository.kt @@ -59,4 +59,10 @@ class SettingsRepository(private val database: Database) : UserSettingsSource { prefs[SETTINGS_KEY] = Json.encodeToString(newSettings) } } -} \ No newline at end of file + + suspend fun getCurrentSettings(): UserSettings { + val preferences = database.settingsDataStore.data.first() + val settingsJson = preferences[SETTINGS_KEY] ?: return UserSettings() + return Json.decodeFromString(settingsJson) + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/UpdateCheckerViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/UpdateCheckerViewModel.kt new file mode 100644 index 00000000..d81692c7 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/UpdateCheckerViewModel.kt @@ -0,0 +1,124 @@ +/* + * 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.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import co.touchlab.kermit.Logger +import dev.krtirtho.spotube.AppBuildInfo +import dev.krtirtho.spotube.modules.settings.SettingsRepository +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.engine.cio.CIO +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +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.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import net.swiftzer.semver.SemVer + +data class AvailableUpdate( + val tag: String, + val releaseNotesMarkdown: String, +) + +class UpdateCheckerViewModel( + private val settingsRepository: SettingsRepository, +) : ViewModel() { + private val logger = Logger.withTag("UpdateChecker") + private val httpClient = HttpClient(CIO) { + install(ContentNegotiation) { + json(Json { ignoreUnknownKeys = true }) + } + } + + private val _availableUpdate = MutableStateFlow(null) + val availableUpdate: StateFlow = _availableUpdate.asStateFlow() + + init { + viewModelScope.launch { + checkForUpdate() + } + } + + private suspend fun checkForUpdate() { + try { + // Read directly from DataStore so the check honors persisted preferences, + // rather than the repository StateFlow's default value during startup. + val settings = settingsRepository.getCurrentSettings() + if (!settings.autoCheckForUpdates) return + + val release = httpClient.get(AppBuildInfo.LATEST_RELEASE_API_URL) { + header(HttpHeaders.Accept, "application/vnd.github+json") + header(HttpHeaders.UserAgent, "Spotube/${AppBuildInfo.VERSION}") + }.body() + + val latestVersion = parseVersion(release.tagName.removePrefix("v")) + ?: return logger.w { "Ignoring release with invalid SemVer tag: ${release.tagName}" } + 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( + tag = release.tagName, + releaseNotesMarkdown = release.body.orEmpty(), + ) + } + } catch (e: Exception) { + logger.w(e) { "Failed to check for app updates" } + } + } + + fun ignoreUpdate() { + val update = _availableUpdate.value ?: return + _availableUpdate.value = null + viewModelScope.launch { + runCatching { + val settings = settingsRepository.getCurrentSettings() + settingsRepository.updateSettings(settings.copy(ignoredUpdateVersion = update.tag)) + }.onFailure { e -> + logger.e(e) { "Failed to persist ignored app version" } + } + } + } + + fun dismissUpdate() { + _availableUpdate.value = null + } + + private fun parseVersion(version: String): SemVer? = runCatching { + SemVer.parse(version) + }.getOrNull() + + override fun onCleared() { + httpClient.close() + } +} + +@Serializable +private data class GitHubRelease( + @SerialName("tag_name") val tagName: String, + val body: String? = null, +) diff --git a/composeApp/src/commonMain/templates/AppBuildInfo.kt.template b/composeApp/src/commonMain/templates/AppBuildInfo.kt.template new file mode 100644 index 00000000..b0435644 --- /dev/null +++ b/composeApp/src/commonMain/templates/AppBuildInfo.kt.template @@ -0,0 +1,25 @@ +/* + * 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 + +internal object AppBuildInfo { + const val VERSION = "${appVersion}" + const val BUILD_NUMBER = ${appBuildNumber} + const val REPOSITORY_URL = "${repositoryUrl}" + const val LATEST_RELEASE_API_URL = "${latestReleaseApiUrl}" +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2e78d2a0..3eab5800 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -46,6 +46,7 @@ kotlinx-serialization-json = "1.11.0" materialKolor = "4.1.1" mqttClient = "2.1.1" mqttBuffer = "6.30.8" +multiplatformMarkdownRenderer = "0.45.0" murmurhash = "0.4.2" newpipeextractor = "v0.26.5" newpipeExtractorKmp = "1.3.0" @@ -128,6 +129,8 @@ mqtt-x-models = { module = "com.ditchoom:mqtt-5-models", version.ref = "mqttClie mqtt-client = { module = "com.ditchoom:mqtt-client", version.ref = "mqttClient" } mqtt-buffer = { module = "com.ditchoom:buffer", version.ref = "mqttBuffer" } mqtt-buffer-codec = { module = "com.ditchoom:buffer-codec", version.ref = "mqttBuffer" } +multiplatform-markdown-renderer = { module = "com.mikepenz:multiplatform-markdown-renderer", version.ref = "multiplatformMarkdownRenderer" } +multiplatform-markdown-renderer-m3 = { module = "com.mikepenz:multiplatform-markdown-renderer-m3", version.ref = "multiplatformMarkdownRenderer" } murmurhash = { module = "com.goncalossilva:murmurhash", version.ref = "murmurhash" } newpipe-extractor-kmp = { module = "io.github.yushosei:newpipe-extractor-kmp", version.ref = "newpipeExtractorKmp" } newpipeextractor = { module = "com.github.teamnewpipe:NewPipeExtractor", version.ref = "newpipeextractor" }