fix(update): restore Linux AppImage auto-update across same-version custom builds

This commit is contained in:
Murilo Scolari 2026-10-24 18:49:58 -03:00
parent 80807ec8ea
commit 83c31444fb
6 changed files with 112 additions and 16 deletions

View file

@ -170,6 +170,7 @@ fun App(
val sleepTimerState by viewModel.sleepTimerState.collectAsStateWithLifecycle()
val nowPlayingData by viewModel.nowPlayingState.collectAsStateWithLifecycle()
val updateData by viewModel.updateResponse.collectAsStateWithLifecycle()
val showedUpdateDialog by viewModel.showedUpdateDialog.collectAsStateWithLifecycle()
val intent by viewModel.intent.collectAsStateWithLifecycle()
val showNotificationPermissionDialog by viewModel.showNotificationPermissionDialog.collectAsStateWithLifecycle()
@ -494,12 +495,12 @@ fun App(
}
}
LaunchedEffect(updateData, viewModel.showedUpdateDialog) {
LaunchedEffect(updateData, showedUpdateDialog) {
val response = updateData ?: return@LaunchedEffect
// Only a NEWER release is offered. This used to compare the tags for inequality, which told a
// build ahead of the latest release (dev, beta) to "update" back down to it, and told every
// F-Droid-channel user to update on each launch: that channel's tag carries no leading "v".
if (viewModel.showedUpdateDialog && VersionManager.isBehind(response.tagName)) {
if (showedUpdateDialog && VersionManager.isBehind(response.tagName)) {
shouldShowUpdateDialog = true
}
}
@ -980,7 +981,7 @@ fun App(
TextButton(
onClick = {
shouldShowUpdateDialog = false
viewModel.showedUpdateDialog = false
viewModel.setShowedUpdateDialog(false)
openUrl("https://github.com/muriloscolari/SimpMusic/releases/latest")
},
) {

View file

@ -1,6 +1,7 @@
package com.maxrave.simpmusic.utils
import com.maxrave.simpmusic.BuildKonfig
import com.maxrave.simpmusic.getPlatform
object VersionManager {
private var versionName: String? = null
@ -26,7 +27,12 @@ object VersionManager {
}
/** True when [tag] names a release newer than this build. */
fun isBehind(tag: String): Boolean = isNewerRelease(current = getVersionName(), tag = tag)
fun isBehind(tag: String): Boolean =
isNewerRelease(
current = getVersionName(),
tag = tag,
isWindows = getPlatform().osName().lowercase().contains("windows"),
)
private fun removeDevSuffix(versionName: String): String {
return if (versionName.endsWith("-dev")) {
@ -46,11 +52,12 @@ object VersionManager {
internal fun isNewerRelease(
current: String,
tag: String,
commitHash: String = VersionManager.getGitCommitHash(),
isWindows: Boolean = false,
): Boolean {
val cleanTag = tag.trim().removePrefix("custom-").removePrefix("v")
val cleanCurrent = current.trim().removePrefix("custom-").removePrefix("v")
val commitHash = VersionManager.getGitCommitHash()
if (commitHash.isNotEmpty() && tag.contains(commitHash)) {
return false
}
@ -59,6 +66,14 @@ internal fun isNewerRelease(
if (versionComparison < 0) return true
if (versionComparison > 0) return false
// When base numeric versions match (e.g. 2.3.0 vs custom-v2.3.0-<sha>):
// On Linux AppImage and Android, custom CI builds can replace any commit build with the same
// base version. On Windows MSIX, Windows Package Manager refuses in-place upgrades when the
// 4-part numeric MSIX version hasn't increased, so skip same-version commit updates on Windows.
if (!isWindows && tag.startsWith("custom-v") && commitHash.isNotEmpty() && !tag.endsWith(commitHash)) {
return true
}
// When base numeric versions match (e.g. 1.0.1 vs 1.0.1-hf, or 0.1.5-beta vs 0.1.5):
val tagIsHotfix = cleanTag.contains("-hf") || cleanTag.contains("-hotfix")
val currentIsHotfix = cleanCurrent.contains("-hf") || cleanCurrent.contains("-hotfix")

View file

@ -154,7 +154,12 @@ class SharedViewModel(
var isFirstLiked: Boolean = false
var isFirstMiniplayer: Boolean = false
var isFirstSuggestions: Boolean = false
var showedUpdateDialog: Boolean = false
private var _showedUpdateDialog = MutableStateFlow(false)
val showedUpdateDialog: StateFlow<Boolean> = _showedUpdateDialog.asStateFlow()
fun setShowedUpdateDialog(value: Boolean) {
_showedUpdateDialog.value = value
}
private val appIdentity: AppIdentity by inject()
val simpConnectRepository: com.maxrave.domain.repository.SimpConnectRepository by inject()
@ -1511,7 +1516,7 @@ class SharedViewModel(
val updateResponse: StateFlow<UpdateData?> = _updateResponse
fun dismissUpdateDialog(tagName: String) {
showedUpdateDialog = false
_showedUpdateDialog.value = false
putString("dismissed_update_tag", tagName)
}
@ -1533,7 +1538,7 @@ class SharedViewModel(
is Resource.Success if (data != null) -> {
_updateResponse.value = data
if (isManual || data.tagName != dismissedTag) {
showedUpdateDialog = true
_showedUpdateDialog.value = true
}
}
@ -1550,7 +1555,7 @@ class SharedViewModel(
is Resource.Success if (data != null) -> {
_updateResponse.value = data
if (isManual || data.tagName != dismissedTag) {
showedUpdateDialog = true
_showedUpdateDialog.value = true
}
}

View file

@ -0,0 +1,40 @@
package com.maxrave.simpmusic.utils
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class VersionManagerTest {
@Test
fun newerNumericVersionIsOffered() {
assertTrue(isNewerRelease(current = "2.2.0", tag = "custom-v2.3.0-80807ec", commitHash = "f9e34abe", isWindows = false))
assertTrue(isNewerRelease(current = "2.2.0", tag = "custom-v2.3.0-80807ec", commitHash = "f9e34abe", isWindows = true))
}
@Test
fun olderNumericVersionIsNotOffered() {
assertFalse(isNewerRelease(current = "2.4.0", tag = "custom-v2.3.0-80807ec", commitHash = "f9e34abe", isWindows = false))
}
@Test
fun sameCommitHashIsNeverOffered() {
assertFalse(isNewerRelease(current = "2.3.0", tag = "custom-v2.3.0-80807ec", commitHash = "80807ec", isWindows = false))
assertFalse(isNewerRelease(current = "2.3.0", tag = "custom-v2.3.0-80807ec8", commitHash = "80807ec", isWindows = false))
}
@Test
fun differentCommitHashOnSameVersionIsOfferedOnLinuxAndAndroid() {
assertTrue(isNewerRelease(current = "2.3.0", tag = "custom-v2.3.0-80807ec", commitHash = "f9e34abe", isWindows = false))
}
@Test
fun differentCommitHashOnSameVersionIsSkippedOnWindowsMsix() {
assertFalse(isNewerRelease(current = "2.3.0", tag = "custom-v2.3.0-80807ec", commitHash = "f9e34abe", isWindows = true))
}
@Test
fun hotfixTagIsOfferedWhenCurrentIsNotHotfix() {
assertTrue(isNewerRelease(current = "2.3.0", tag = "v2.3.0-hf", commitHash = "", isWindows = false))
assertFalse(isNewerRelease(current = "2.3.0-hf", tag = "v2.3.0-hf", commitHash = "", isWindows = false))
}
}

View file

@ -186,10 +186,31 @@ actual object PlatformAppUpdater {
private fun downloadFileWithProgress(urlStr: String, destinationFile: File) {
val tempFile = File(destinationFile.parentFile, destinationFile.name + ".download")
val url = URI(urlStr).toURL()
val connection = url.openConnection() as HttpURLConnection
connection.instanceFollowRedirects = true
connection.connect()
var currentUrl = urlStr
var redirects = 0
var connection: HttpURLConnection
while (true) {
val url = URI(currentUrl).toURL()
connection = url.openConnection() as HttpURLConnection
connection.instanceFollowRedirects = true
connection.connectTimeout = 15_000
connection.readTimeout = 30_000
connection.setRequestProperty("User-Agent", "SimpMusic-Desktop-Updater")
connection.connect()
val code = connection.responseCode
if (code in 300..399) {
val location = connection.getHeaderField("Location")
connection.disconnect()
if (location.isNullOrBlank() || ++redirects > 5) {
throw IllegalStateException("Redirecionamento HTTP inválido ($code)")
}
currentUrl = URI(currentUrl).resolve(location).toString()
continue
}
break
}
if (connection.responseCode !in 200..299) {
throw IllegalStateException("Erro HTTP ${connection.responseCode}: ${connection.responseMessage}")
@ -217,10 +238,23 @@ actual object PlatformAppUpdater {
}
}
if (destinationFile.exists()) {
destinationFile.delete()
tempFile.setExecutable(true, false)
try {
java.nio.file.Files.move(
tempFile.toPath(),
destinationFile.toPath(),
java.nio.file.StandardCopyOption.REPLACE_EXISTING,
)
} catch (_: Exception) {
if (destinationFile.exists()) {
destinationFile.delete()
}
if (!tempFile.renameTo(destinationFile)) {
tempFile.copyTo(destinationFile, overwrite = true)
tempFile.delete()
}
}
tempFile.renameTo(destinationFile)
destinationFile.setExecutable(true, false)
}
private fun runDirectDownload(downloadUrl: String, currentFile: File) {

View file

@ -123,6 +123,7 @@ dependencies {
// obfuscated + size-reduced bytecode instead of raw Gradle output.
// `dependsOn(proguardReleaseJars)` builds the shrunk jars first.
tasks.named<hydraulic.conveyor.gradle.WriteConveyorConfigTask>("writeConveyorConfig") {
dependsOn(":composeApp:generateBuildKonfig")
dependsOn(tasks.named("proguardReleaseJars"))
val proguardJarsDir = layout.buildDirectory.dir("compose/tmp/main-release/proguard")
doLast {