add basic updater implementation in gigawear

This commit is contained in:
2026-09-14 16:52:27 +03:00
parent 1c573c4366
commit e77046018d
23 changed files with 230 additions and 90 deletions
@@ -0,0 +1,2 @@
/build
/bin
@@ -0,0 +1,14 @@
plugins {
alias(libs.plugins.convention.jvm)
alias(libs.plugins.convention.ktor.client)
alias(libs.plugins.convention.koin)
alias(libs.plugins.convention.serialization)
alias(libs.plugins.ktor)
}
dependencies {
implementation(libs.ktor.server.core.jvm)
implementation(project(":libs:backend-base"))
implementation(project(":libs:http-client"))
implementation(project(":feature:updater:updater-common"))
}
@@ -0,0 +1,10 @@
package ru.shadowsparky.updater.backend
import org.koin.core.annotation.ComponentScan
import org.koin.core.annotation.Configuration
import org.koin.core.annotation.Module
@Module
@ComponentScan
@Configuration
class BackendUpdaterModule
@@ -0,0 +1,73 @@
package ru.shadowsparky.updater.backend.data
import io.ktor.http.HttpStatusCode
import io.ktor.util.cio.readChannel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import org.koin.core.annotation.Factory
import ru.shadowsparky.backend.data.EnvFetcher
import ru.shadowsparky.http.domain.HttpException
import ru.shadowsparky.updater.domain.UpdateFetcher
import ru.shadowsparky.updater.domain.UpdateInfo
import java.io.File
@Factory
class BackendUpdateFetcherFactory(
private val envFetcher: EnvFetcher,
private val json: Json
) {
fun create(userId: Long): BackendUpdateFetcher {
return BackendUpdateFetcher(envFetcher, json, userId)
}
}
class BackendUpdateFetcher(
private val envFetcher: EnvFetcher,
private val json: Json,
private val userId: Long
) : UpdateFetcher {
private val fetchMutex = Mutex()
private val updateFileMutex = Mutex()
override suspend fun fetch(versionCode: Long): UpdateInfo {
updateInfo?.let { return it }
fetchMutex.withLock {
updateInfo?.let { return it }
return fetchInternal().apply { updateInfo = this }
}
}
private suspend fun fetchInternal(): UpdateInfo = withContext(Dispatchers.IO) {
val configPath = envFetcher.get("UPDATE_CONFIG")
if (configPath.isBlank()) {
UpdateInfo()
} else {
json.decodeFromString<UpdateInfo>(File(configPath).readText())
}
}
suspend fun updateFile(): File {
updateFile?.let { return it }
updateFileMutex.withLock {
updateFile?.let { return it }
return updateFileInternal().apply { updateFile = this }
}
}
private suspend fun updateFileInternal(): File = withContext(Dispatchers.IO) {
val updateFilePath = envFetcher.get("UPDATE_FILE")
if (updateFilePath.isBlank()) throw HttpException(HttpStatusCode.NotFound.value, "Update not found")
val file = File(updateFilePath)
file.readChannel()
if (!file.exists()) throw HttpException(HttpStatusCode.NotFound.value, "Update not exists")
file
}
private companion object {
var updateInfo: UpdateInfo? = null
var updateFile: File? = null
}
}
@@ -0,0 +1,27 @@
package ru.shadowsparky.updater.backend.presentation
import io.ktor.server.application.ApplicationCall
import io.ktor.server.response.respond
import io.ktor.server.response.respondFile
import io.ktor.server.routing.Route
import io.ktor.server.routing.get
import io.ktor.server.util.getOrFail
import ru.shadowsparky.backend.presentation.obtainUserId
import ru.shadowsparky.updater.backend.data.BackendUpdateFetcherFactory
import ru.shadowsparky.updater.domain.UpdateFetcher
fun Route.setupUpdates(
updateFetcherFactory: BackendUpdateFetcherFactory,
prefix: String = "",
onUserId: suspend ApplicationCall.() -> Long = { obtainUserId() }
) {
get("${prefix}checkUpdates") {
val versionCode = call.parameters.getOrFail(UpdateFetcher.ARG_VERSION_CODE).toLong()
val updateFetcher = updateFetcherFactory.create(onUserId(call))
call.respond(updateFetcher.fetch(versionCode))
}
get("${prefix}getApk") {
val updateFetcher = updateFetcherFactory.create(onUserId(call))
call.respondFile(updateFetcher.updateFile())
}
}
@@ -20,6 +20,8 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
@@ -42,7 +44,6 @@ import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import ru.shadowsparky.ui.components.AlertContent
import ru.shadowsparky.ui.components.ErrorContent
import ru.shadowsparky.ui.components.LazyColumn
import ru.shadowsparky.ui.components.Loading
import ru.shadowsparky.updater.domain.UpdateState
@@ -128,8 +129,9 @@ private fun UpdateAvailableContent(
focusRequester: FocusRequester,
modifier: Modifier = Modifier
) {
val scrollState = rememberScrollState()
Column(
modifier = modifier.fillMaxWidth(),
modifier = modifier.fillMaxWidth().verticalScroll(scrollState),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
@@ -137,36 +139,26 @@ private fun UpdateAvailableContent(
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.align(Alignment.CenterHorizontally)
)
LazyColumn(modifier = Modifier.fillMaxWidth()) {
item {
Text(
text = stringResource(Res.string.updater_changelog_title),
style = MaterialTheme.typography.titleMedium
)
}
item {
Spacer(modifier = Modifier.height(4.dp))
}
item {
Text(
text = changeLog ?: stringResource(Res.string.updater_changelog_empty),
style = MaterialTheme.typography.bodyMedium
)
}
item {
Spacer(modifier = Modifier.height(8.dp))
}
item {
Button(
onClick = onDownloadClick,
modifier = Modifier.focusRequester(focusRequester).fillMaxWidth()
) {
Text(
stringResource(Res.string.updater_btn_download),
style = MaterialTheme.typography.labelLarge
)
}
}
Text(
text = stringResource(Res.string.updater_changelog_title),
style = MaterialTheme.typography.titleMedium
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = changeLog ?: stringResource(Res.string.updater_changelog_empty),
style = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = onDownloadClick,
modifier = Modifier.focusRequester(focusRequester).fillMaxWidth()
) {
Text(
stringResource(Res.string.updater_btn_download),
style = MaterialTheme.typography.labelLarge
)
}
}
}
@@ -177,8 +169,9 @@ private fun UpdatePermissionContent(
focusRequester: FocusRequester,
modifier: Modifier = Modifier
) {
val scrollState = rememberScrollState()
Column(
modifier = modifier.fillMaxWidth(),
modifier = modifier.fillMaxWidth().verticalScroll(scrollState),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
@@ -210,8 +203,9 @@ private fun UpdateDownloadingContent(
totalBytes: Long,
modifier: Modifier = Modifier
) {
val scrollState = rememberScrollState()
Column(
modifier = modifier.fillMaxWidth(),
modifier = modifier.fillMaxWidth().verticalScroll(scrollState),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
@@ -241,8 +235,9 @@ private fun UpdateReadyContent(
focusRequester: FocusRequester,
modifier: Modifier = Modifier
) {
val scrollState = rememberScrollState()
Column(
modifier = modifier.fillMaxWidth(),
modifier = modifier.fillMaxWidth().verticalScroll(scrollState),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
@@ -269,8 +264,9 @@ private fun UpdateFinishedContent(
focusRequester: FocusRequester,
modifier: Modifier = Modifier
) {
val scrollState = rememberScrollState()
Column(
modifier = modifier.fillMaxWidth(),
modifier = modifier.fillMaxWidth().verticalScroll(scrollState),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {