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 {
|
||||
implementation(kotlin("test"))
|
||||
implementation(libs.kotlinx.coroutinesTest)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
"WoW Backup v${BuildConfig.VERSION}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||
)
|
||||
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.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())
|
||||
|
||||
|
|
|
|||
|
|
@ -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-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" }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue