feat(update): add update detection and notification on status screen (WB-008)
Co-authored-by: Junie <junie@jetbrains.com>
This commit is contained in:
parent
02cbf20946
commit
613ceb01b7
10 changed files with 557 additions and 8 deletions
|
|
@ -33,6 +33,7 @@ kotlin {
|
||||||
}
|
}
|
||||||
commonTest.dependencies {
|
commonTest.dependencies {
|
||||||
implementation(kotlin("test"))
|
implementation(kotlin("test"))
|
||||||
|
implementation(libs.kotlinx.coroutinesTest)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package com.rukira.wowbackup.platform
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
import java.awt.Desktop
|
import java.awt.Desktop
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.net.URI
|
||||||
|
|
||||||
private val logger = KotlinLogging.logger {}
|
private val logger = KotlinLogging.logger {}
|
||||||
|
|
||||||
|
|
@ -15,4 +16,16 @@ object DesktopActions {
|
||||||
logger.error(e) { "Failed to open folder: $path" }
|
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" }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package com.rukira.wowbackup.ui.status
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
|
@ -214,12 +215,33 @@ fun StatusScreen(
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(Modifier.weight(1f))
|
Spacer(Modifier.weight(1f))
|
||||||
Text(
|
Row(
|
||||||
"WoW Backup v${BuildConfig.VERSION}",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,13 +7,18 @@ import com.rukira.wowbackup.backup.BackupProgress
|
||||||
import com.rukira.wowbackup.backup.BackupResult
|
import com.rukira.wowbackup.backup.BackupResult
|
||||||
import com.rukira.wowbackup.backup.BackupScheduler
|
import com.rukira.wowbackup.backup.BackupScheduler
|
||||||
import com.rukira.wowbackup.config.ConfigManager
|
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.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.datetime.LocalDateTime
|
import kotlinx.datetime.LocalDateTime
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
|
@ -32,10 +37,25 @@ data class StatusUiState(
|
||||||
val backupProgress: BackupProgress? = null,
|
val backupProgress: BackupProgress? = null,
|
||||||
val totalBackups: Int = 0,
|
val totalBackups: Int = 0,
|
||||||
val backupPath: String? = null,
|
val backupPath: String? = null,
|
||||||
|
val updateInfo: UpdateInfo? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
@OptIn(kotlin.time.ExperimentalTime::class)
|
@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
|
// Only recomputes when backupPath or lastBackupResult changes — not on every progress tick
|
||||||
private val totalBackups = combine(
|
private val totalBackups = combine(
|
||||||
|
|
@ -53,7 +73,8 @@ class StatusViewModel : ViewModel() {
|
||||||
ConfigManager.config,
|
ConfigManager.config,
|
||||||
BackupScheduler.state,
|
BackupScheduler.state,
|
||||||
totalBackups,
|
totalBackups,
|
||||||
) { config, scheduler, backupCount ->
|
updateInfo,
|
||||||
|
) { config, scheduler, backupCount, update ->
|
||||||
StatusUiState(
|
StatusUiState(
|
||||||
isConfigured = config.isConfigured,
|
isConfigured = config.isConfigured,
|
||||||
lastBackupTime = scheduler.lastBackupTime?.let { formatRelativeTime(it) },
|
lastBackupTime = scheduler.lastBackupTime?.let { formatRelativeTime(it) },
|
||||||
|
|
@ -66,6 +87,7 @@ class StatusViewModel : ViewModel() {
|
||||||
backupProgress = scheduler.currentProgress,
|
backupProgress = scheduler.currentProgress,
|
||||||
totalBackups = backupCount,
|
totalBackups = backupCount,
|
||||||
backupPath = config.backupPath,
|
backupPath = config.backupPath,
|
||||||
|
updateInfo = update,
|
||||||
)
|
)
|
||||||
}.flowOn(Dispatchers.IO).stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), StatusUiState())
|
}.flowOn(Dispatchers.IO).stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), StatusUiState())
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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-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" }
|
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-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-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" }
|
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
|
||||||
cardiologist = { module = "io.github.kevincianfarini.cardiologist:cardiologist", version.ref = "cardiologist" }
|
cardiologist = { module = "io.github.kevincianfarini.cardiologist:cardiologist", version.ref = "cardiologist" }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue