feat(update): add update detection and notification on status screen (WB-008)

Co-authored-by: Junie <junie@jetbrains.com>
This commit is contained in:
Rukira 2026-08-25 10:30:52 +01:00
parent 02cbf20946
commit 613ceb01b7
10 changed files with 557 additions and 8 deletions

View file

@ -33,6 +33,7 @@ kotlin {
}
commonTest.dependencies {
implementation(kotlin("test"))
implementation(libs.kotlinx.coroutinesTest)
}
}
}

View file

@ -3,6 +3,7 @@ package com.rukira.wowbackup.platform
import io.github.oshai.kotlinlogging.KotlinLogging
import java.awt.Desktop
import java.io.File
import java.net.URI
private val logger = KotlinLogging.logger {}
@ -15,4 +16,16 @@ object DesktopActions {
logger.error(e) { "Failed to open folder: $path" }
}
}
fun openUrl(url: String) {
try {
if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) {
Desktop.getDesktop().browse(URI(url))
} else {
logger.warn { "Desktop browsing is not supported on this platform" }
}
} catch (e: Exception) {
logger.error(e) { "Failed to open URL: $url" }
}
}
}

View file

@ -2,6 +2,7 @@ package com.rukira.wowbackup.ui.status
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@ -214,12 +215,33 @@ fun StatusScreen(
}
Spacer(Modifier.weight(1f))
Text(
"WoW Backup v${BuildConfig.VERSION}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
Row(
modifier = Modifier.fillMaxWidth(),
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
)
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"WoW Backup v${BuildConfig.VERSION}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
val update = uiState.updateInfo
if (update != null) {
Spacer(Modifier.width(8.dp))
Text(
"• Update available (v${update.latestVersion})",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
Spacer(Modifier.width(6.dp))
Button(
onClick = { DesktopActions.openUrl(update.releaseUrl) },
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 2.dp),
modifier = Modifier.height(26.dp),
) {
Text("Download", style = MaterialTheme.typography.labelSmall)
}
}
}
}
}

View file

@ -7,13 +7,18 @@ import com.rukira.wowbackup.backup.BackupProgress
import com.rukira.wowbackup.backup.BackupResult
import com.rukira.wowbackup.backup.BackupScheduler
import com.rukira.wowbackup.config.ConfigManager
import com.rukira.wowbackup.update.UpdateChecker
import com.rukira.wowbackup.update.UpdateInfo
import com.rukira.wowbackup.update.UpdateService
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
@ -32,10 +37,25 @@ data class StatusUiState(
val backupProgress: BackupProgress? = null,
val totalBackups: Int = 0,
val backupPath: String? = null,
val updateInfo: UpdateInfo? = null,
)
@OptIn(kotlin.time.ExperimentalTime::class)
class StatusViewModel : ViewModel() {
class StatusViewModel(
private val updateService: UpdateService = UpdateChecker(),
) : ViewModel() {
private val updateInfo = MutableStateFlow<UpdateInfo?>(null)
init {
viewModelScope.launch(Dispatchers.IO) {
try {
updateInfo.value = updateService.checkForUpdate()
} catch (e: Exception) {
// Silently handle any uncaught exception
}
}
}
// Only recomputes when backupPath or lastBackupResult changes — not on every progress tick
private val totalBackups = combine(
@ -53,7 +73,8 @@ class StatusViewModel : ViewModel() {
ConfigManager.config,
BackupScheduler.state,
totalBackups,
) { config, scheduler, backupCount ->
updateInfo,
) { config, scheduler, backupCount, update ->
StatusUiState(
isConfigured = config.isConfigured,
lastBackupTime = scheduler.lastBackupTime?.let { formatRelativeTime(it) },
@ -66,6 +87,7 @@ class StatusViewModel : ViewModel() {
backupProgress = scheduler.currentProgress,
totalBackups = backupCount,
backupPath = config.backupPath,
updateInfo = update,
)
}.flowOn(Dispatchers.IO).stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), StatusUiState())

View file

@ -0,0 +1,52 @@
package com.rukira.wowbackup.update
data class SemVer(
val major: Int,
val minor: Int = 0,
val patch: Int = 0,
val preRelease: String? = null,
) : Comparable<SemVer> {
override fun compareTo(other: SemVer): Int {
if (this.major != other.major) return this.major.compareTo(other.major)
if (this.minor != other.minor) return this.minor.compareTo(other.minor)
if (this.patch != other.patch) return this.patch.compareTo(other.patch)
// A version without a pre-release is greater than one with a pre-release
return when {
this.preRelease == null && other.preRelease == null -> 0
this.preRelease == null && other.preRelease != null -> 1
this.preRelease != null && other.preRelease == null -> -1
else -> this.preRelease!!.compareTo(other.preRelease!!)
}
}
fun isNewerThan(other: SemVer): Boolean = this > other
override fun toString(): String {
val base = "$major.$minor.$patch"
return if (preRelease != null) "$base-$preRelease" else base
}
companion object {
fun parseOrNull(version: String?): SemVer? {
if (version.isNullOrBlank()) return null
val cleaned = version.trim().removePrefix("v").removePrefix("V").trim()
if (cleaned.isEmpty()) return null
val parts = cleaned.split("-", limit = 2)
val core = parts[0]
val preRelease = parts.getOrNull(1)?.takeIf { it.isNotBlank() }
val numParts = core.split(".")
if (numParts.isEmpty() || numParts.size > 3) return null
val major = numParts[0].toIntOrNull() ?: return null
val minor = if (numParts.size > 1) numParts[1].toIntOrNull() ?: return null else 0
val patch = if (numParts.size > 2) numParts[2].toIntOrNull() ?: return null else 0
return SemVer(major = major, minor = minor, patch = patch, preRelease = preRelease)
}
}
}

View file

@ -0,0 +1,102 @@
package com.rukira.wowbackup.update
import com.rukira.wowbackup.BuildConfig
import io.github.oshai.kotlinlogging.KotlinLogging
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.net.URI
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
import java.time.Duration
private val logger = KotlinLogging.logger {}
@Serializable
internal data class ReleaseDto(
@SerialName("tag_name") val tagName: String = "",
val name: String? = null,
@SerialName("html_url") val htmlUrl: String? = null,
)
data class UpdateInfo(
val latestVersion: String,
val releaseUrl: String,
)
interface UpdateService {
suspend fun checkForUpdate(currentVersion: String = BuildConfig.VERSION): UpdateInfo?
}
class UpdateChecker(
private val apiUrl: String = DEFAULT_API_URL,
private val fallbackUrl: String = DEFAULT_FALLBACK_URL,
private val httpClient: HttpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.build(),
) : UpdateService {
private val json = Json {
ignoreUnknownKeys = true
isLenient = true
}
override suspend fun checkForUpdate(currentVersion: String): UpdateInfo? = withContext(Dispatchers.IO) {
try {
val request = HttpRequest.newBuilder()
.uri(URI.create(apiUrl))
.timeout(Duration.ofSeconds(5))
.header("Accept", "application/json")
.header("User-Agent", "WoWBackup/${BuildConfig.VERSION}")
.GET()
.build()
val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString())
if (response.statusCode() != 200) {
logger.warn { "Update check received non-200 status code: ${response.statusCode()}" }
return@withContext null
}
val body = response.body()
if (body.isNullOrBlank()) {
logger.warn { "Update check received empty response body" }
return@withContext null
}
val release = json.decodeFromString<ReleaseDto>(body)
val remoteSemVer = SemVer.parseOrNull(release.tagName) ?: run {
logger.warn { "Failed to parse remote release tag: '${release.tagName}'" }
return@withContext null
}
val currentSemVer = SemVer.parseOrNull(currentVersion) ?: run {
logger.warn { "Failed to parse current version: '$currentVersion'" }
return@withContext null
}
if (remoteSemVer.isNewerThan(currentSemVer)) {
val releaseUrl = release.htmlUrl?.takeIf { it.isNotBlank() } ?: fallbackUrl
val cleanTag = release.tagName.trim().removePrefix("v").removePrefix("V")
logger.info { "New update available: v$cleanTag (current: v$currentVersion). URL: $releaseUrl" }
UpdateInfo(
latestVersion = cleanTag,
releaseUrl = releaseUrl,
)
} else {
logger.debug { "App is up to date: current=$currentVersion, remote=${release.tagName}" }
null
}
} catch (e: Exception) {
logger.warn(e) { "Failed to check for updates: ${e.message}" }
null
}
}
companion object {
const val DEFAULT_API_URL = "https://git.asarius.site/api/v1/repos/rukira/wow-backup/releases/latest"
const val DEFAULT_FALLBACK_URL = "https://git.asarius.site/rukira/wow-backup/releases"
}
}

View file

@ -0,0 +1,67 @@
package com.rukira.wowbackup.ui.status
import com.rukira.wowbackup.update.UpdateInfo
import com.rukira.wowbackup.update.UpdateService
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
@OptIn(ExperimentalCoroutinesApi::class)
class StatusViewModelTest {
private val testDispatcher = StandardTestDispatcher()
@BeforeTest
fun setUp() {
Dispatchers.setMain(testDispatcher)
}
@AfterTest
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun testViewModelReceivesUpdateInfo() = runTest(testDispatcher) {
val mockService = object : UpdateService {
override suspend fun checkForUpdate(currentVersion: String): UpdateInfo {
return UpdateInfo(
latestVersion = "1.0.2",
releaseUrl = "https://git.asarius.site/rukira/wow-backup/releases/tag/v1.0.2",
)
}
}
val viewModel = StatusViewModel(updateService = mockService)
advanceUntilIdle()
val state = viewModel.state.first { it.updateInfo != null }
assertNotNull(state.updateInfo)
assertEquals("1.0.2", state.updateInfo!!.latestVersion)
assertEquals("https://git.asarius.site/rukira/wow-backup/releases/tag/v1.0.2", state.updateInfo!!.releaseUrl)
}
@Test
fun testViewModelHandlesNoUpdate() = runTest(testDispatcher) {
val mockService = object : UpdateService {
override suspend fun checkForUpdate(currentVersion: String): UpdateInfo? = null
}
val viewModel = StatusViewModel(updateService = mockService)
advanceUntilIdle()
val state = viewModel.state.value
assertNull(state.updateInfo)
}
}

View file

@ -0,0 +1,97 @@
package com.rukira.wowbackup.update
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
class SemVerTest {
@Test
fun testParseStandardVersions() {
val v1 = SemVer.parseOrNull("1.0.0")
assertNotNull(v1)
assertEquals(1, v1.major)
assertEquals(0, v1.minor)
assertEquals(0, v1.patch)
assertNull(v1.preRelease)
val v2 = SemVer.parseOrNull("v1.2.3")
assertNotNull(v2)
assertEquals(1, v2.major)
assertEquals(2, v2.minor)
assertEquals(3, v2.patch)
assertNull(v2.preRelease)
val v3 = SemVer.parseOrNull("V2.0")
assertNotNull(v3)
assertEquals(2, v3.major)
assertEquals(0, v3.minor)
assertEquals(0, v3.patch)
val v4 = SemVer.parseOrNull("0.0.1")
assertNotNull(v4)
assertEquals(0, v4.major)
assertEquals(0, v4.minor)
assertEquals(1, v4.patch)
}
@Test
fun testParsePreReleaseVersions() {
val v = SemVer.parseOrNull("1.0.0-beta.1")
assertNotNull(v)
assertEquals(1, v.major)
assertEquals(0, v.minor)
assertEquals(0, v.patch)
assertEquals("beta.1", v.preRelease)
}
@Test
fun testParseInvalidVersions() {
assertNull(SemVer.parseOrNull(null))
assertNull(SemVer.parseOrNull(""))
assertNull(SemVer.parseOrNull(" "))
assertNull(SemVer.parseOrNull("invalid"))
assertNull(SemVer.parseOrNull("1.a.3"))
assertNull(SemVer.parseOrNull("1.2.3.4"))
}
@Test
fun testVersionComparison() {
val v001 = SemVer.parseOrNull("0.0.1")!!
val v100 = SemVer.parseOrNull("1.0.0")!!
val v101 = SemVer.parseOrNull("1.0.1")!!
val v102 = SemVer.parseOrNull("v1.0.2")!!
val v110 = SemVer.parseOrNull("1.1.0")!!
val v200 = SemVer.parseOrNull("2.0.0")!!
assertTrue(v100.isNewerThan(v001))
assertTrue(v101.isNewerThan(v100))
assertTrue(v102.isNewerThan(v101))
assertTrue(v110.isNewerThan(v102))
assertTrue(v200.isNewerThan(v110))
assertFalse(v101.isNewerThan(v102))
assertFalse(v102.isNewerThan(SemVer.parseOrNull("1.0.2")!!))
}
@Test
fun testPreReleaseComparison() {
val release = SemVer.parseOrNull("1.0.0")!!
val preRelease = SemVer.parseOrNull("1.0.0-alpha")!!
val preReleaseBeta = SemVer.parseOrNull("1.0.0-beta")!!
assertTrue(release.isNewerThan(preRelease))
assertFalse(preRelease.isNewerThan(release))
assertTrue(preReleaseBeta.isNewerThan(preRelease))
}
@Test
fun testToString() {
assertEquals("1.0.0", SemVer.parseOrNull("1.0.0")?.toString())
assertEquals("1.2.0", SemVer.parseOrNull("v1.2")?.toString())
assertEquals("1.0.0-rc1", SemVer.parseOrNull("1.0.0-rc1")?.toString())
}
}

View file

@ -0,0 +1,172 @@
package com.rukira.wowbackup.update
import com.sun.net.httpserver.HttpServer
import kotlinx.coroutines.runBlocking
import java.net.InetSocketAddress
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
class UpdateCheckerTest {
private lateinit var server: HttpServer
private var serverPort: Int = 0
@BeforeTest
fun setUp() {
server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0)
serverPort = server.address.port
server.start()
}
@AfterTest
fun tearDown() {
server.stop(0)
}
@Test
fun testUpdateAvailableWhenRemoteIsNewer() = runBlocking {
val jsonResponse = """
{
"id": 5,
"tag_name": "v1.0.2",
"name": "WoW Backup v1.0.2",
"html_url": "https://git.asarius.site/rukira/wow-backup/releases/tag/v1.0.2"
}
""".trimIndent()
server.createContext("/releases/latest") { exchange ->
exchange.responseHeaders.add("Content-Type", "application/json")
val bytes = jsonResponse.toByteArray()
exchange.sendResponseHeaders(200, bytes.size.toLong())
exchange.responseBody.write(bytes)
exchange.close()
}
val checker = UpdateChecker(
apiUrl = "http://127.0.0.1:$serverPort/releases/latest",
fallbackUrl = "https://git.asarius.site/rukira/wow-backup/releases",
)
val result = checker.checkForUpdate(currentVersion = "1.0.1")
assertNotNull(result)
assertEquals("1.0.2", result.latestVersion)
assertEquals("https://git.asarius.site/rukira/wow-backup/releases/tag/v1.0.2", result.releaseUrl)
}
@Test
fun testNoUpdateWhenUpToDate() = runBlocking {
val jsonResponse = """
{
"id": 5,
"tag_name": "v1.0.2",
"name": "WoW Backup v1.0.2",
"html_url": "https://git.asarius.site/rukira/wow-backup/releases/tag/v1.0.2"
}
""".trimIndent()
server.createContext("/releases/latest") { exchange ->
exchange.responseHeaders.add("Content-Type", "application/json")
val bytes = jsonResponse.toByteArray()
exchange.sendResponseHeaders(200, bytes.size.toLong())
exchange.responseBody.write(bytes)
exchange.close()
}
val checker = UpdateChecker(
apiUrl = "http://127.0.0.1:$serverPort/releases/latest",
)
val result = checker.checkForUpdate(currentVersion = "1.0.2")
assertNull(result)
}
@Test
fun testNoUpdateWhenRunningNewerVersion() = runBlocking {
val jsonResponse = """
{
"id": 5,
"tag_name": "v1.0.2",
"name": "WoW Backup v1.0.2",
"html_url": "https://git.asarius.site/rukira/wow-backup/releases/tag/v1.0.2"
}
""".trimIndent()
server.createContext("/releases/latest") { exchange ->
exchange.responseHeaders.add("Content-Type", "application/json")
val bytes = jsonResponse.toByteArray()
exchange.sendResponseHeaders(200, bytes.size.toLong())
exchange.responseBody.write(bytes)
exchange.close()
}
val checker = UpdateChecker(
apiUrl = "http://127.0.0.1:$serverPort/releases/latest",
)
val result = checker.checkForUpdate(currentVersion = "2.0.0")
assertNull(result)
}
@Test
fun testHttpErrorGracefullyReturnsNull() = runBlocking {
server.createContext("/releases/latest") { exchange ->
exchange.sendResponseHeaders(404, 0)
exchange.close()
}
val checker = UpdateChecker(
apiUrl = "http://127.0.0.1:$serverPort/releases/latest",
)
val result = checker.checkForUpdate(currentVersion = "1.0.0")
assertNull(result)
}
@Test
fun testMalformedJsonGracefullyReturnsNull() = runBlocking {
server.createContext("/releases/latest") { exchange ->
val bytes = "Not valid JSON".toByteArray()
exchange.sendResponseHeaders(200, bytes.size.toLong())
exchange.responseBody.write(bytes)
exchange.close()
}
val checker = UpdateChecker(
apiUrl = "http://127.0.0.1:$serverPort/releases/latest",
)
val result = checker.checkForUpdate(currentVersion = "1.0.0")
assertNull(result)
}
@Test
fun testFallbackUrlUsedWhenHtmlUrlMissing() = runBlocking {
val jsonResponse = """
{
"id": 5,
"tag_name": "v1.0.2",
"name": "WoW Backup v1.0.2"
}
""".trimIndent()
server.createContext("/releases/latest") { exchange ->
val bytes = jsonResponse.toByteArray()
exchange.sendResponseHeaders(200, bytes.size.toLong())
exchange.responseBody.write(bytes)
exchange.close()
}
val checker = UpdateChecker(
apiUrl = "http://127.0.0.1:$serverPort/releases/latest",
fallbackUrl = "https://git.asarius.site/rukira/wow-backup/releases",
)
val result = checker.checkForUpdate(currentVersion = "1.0.0")
assertNotNull(result)
assertEquals("https://git.asarius.site/rukira/wow-backup/releases", result.releaseUrl)
}
}

View file

@ -14,6 +14,7 @@ materialKolor = "2.0.0"
androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" }
androidx-lifecycle-runtimeCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" }
kotlinx-coroutinesSwing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }
kotlinx-coroutinesTest = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" }
kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
cardiologist = { module = "io.github.kevincianfarini.cardiologist:cardiologist", version.ref = "cardiologist" }