base updater classes

This commit is contained in:
2026-07-25 17:21:43 +03:00
parent 64603dc368
commit 7bd09459fa
22 changed files with 545 additions and 29 deletions
@@ -30,8 +30,6 @@ dependencies {
implementation(project(":libs:video-player"))
implementation(project(":libs:ui"))
implementation(project(":libs:base"))
debugImplementation(libs.ui.tooling)
compileOnly(libs.ui.tooling.preview.android)
testImplementation(libs.koin.test)
testImplementation(libs.mockk)
testImplementation(kotlin("test-junit"))
+1 -1
View File
@@ -28,7 +28,7 @@ dependencies {
implementation(project(":libs:http-client"))
implementation(project(":libs:video-player"))
debugImplementation(libs.ui.tooling)
compileOnly(libs.ui.tooling.preview.android)
implementation(libs.ui.tooling.preview)
testImplementation(libs.koin.test)
testImplementation(libs.mockk)
testImplementation(kotlin("test-junit"))
@@ -28,7 +28,10 @@ class AppModule {
@Single
fun provideAppConfig(): AppConfig {
return object : AppConfig {
override fun isDebug(): Boolean = BuildConfig.DEBUG
override val debug = BuildConfig.DEBUG
override val appId = "VBox"
override val versionCode = BuildConfig.VERSION_CODE.toLong()
override val versionName = BuildConfig.VERSION_NAME
}
}
}
@@ -44,7 +44,8 @@ class AppModule {
@Single
fun provideAppConfig(): AppConfig {
return object : AppConfig {
override fun isDebug(): Boolean = false
override val appId = "VboxWeb"
override val debug = false
}
}
}
+2 -2
View File
@@ -32,7 +32,7 @@ postgresql = "42.7.13"
runner = "1.7.0"
sqlite = "2.7.0"
sqlite-driver = "2.3.2"
ui-tooling = "1.11.4"
ui-tooling = "1.11.1"
essenty = "1.3.0"
stately-common = "2.0.6"
proj-target = "37"
@@ -129,7 +129,7 @@ ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "k
ui-compose = { module = "androidx.compose.ui:ui" }
ui-compose-util = { module = "androidx.compose.ui:ui-util" }
ui-tooling-preview-android = { module = "androidx.compose.ui:ui-tooling-preview-android", version.ref = "ui-tooling" }
ui-tooling-preview = { module = "org.jetbrains.compose.ui:ui-tooling-preview", version.ref = "ui-tooling" }
ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "ui-tooling" }
kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin-version" }
web-worker-driver-wasm-js = { module = "app.cash.sqldelight:web-worker-driver-wasm-js", version.ref = "sqlite-driver" }
-9
View File
@@ -1,9 +0,0 @@
import com.android.build.api.dsl.LibraryExtension
plugins {
alias(libs.plugins.convention.android.lib)
}
extensions.configure<LibraryExtension> {
namespace = "ru.shadowsparky.androidstub"
}
@@ -1,5 +0,0 @@
package com.android.internal.telephony;
public interface ITelephony {
boolean endCall();
}
@@ -1,6 +1,9 @@
package ru.shadowsparky.domain
interface AppConfig {
fun isDebug(): Boolean
fun isBackend(): Boolean = false
val debug: Boolean
val appId: String
val backend: Boolean get() = false
val versionCode: Long? get() = null
val versionName: String? get() = null
}
@@ -29,8 +29,6 @@ import ru.shadowsparky.http.domain.HttpException
import ru.shadowsparky.http.domain.UserAgentProvider
import ru.shadowsparky.koin
const val USE_HTTP_CUSTOM_CERTS = "use_http_custom_certs"
private const val MESSAGE_LONG = "message"
private const val MESSAGE = "msg"
@@ -40,7 +38,8 @@ class HttpClientSettings(
private val userAgentProvider: UserAgentProvider?,
private val bearerAuthProvider: BearerAuthProvider,
private val appenderList: List<ClientKtorExtension>,
private val log: Log
private val log: Log,
private val appConfig: AppConfig?
) {
fun setup(
@@ -71,7 +70,7 @@ class HttpClientSettings(
}
install(Logging) {
val isDebug = koin.getOrNull<AppConfig>()?.isDebug() ?: true
val isDebug = koin.getOrNull<AppConfig>()?.debug ?: true
if (isDebug) {
this.level = LogLevel.ALL
this.logger = object : Logger {
@@ -94,7 +93,7 @@ class HttpClientSettings(
defaultRequest {
header(HttpHeaders.Accept, ContentType.Application.Json)
header(HttpHeaders.ContentType, ContentType.Application.Json)
userAgentProvider?.provide()?.let {
userAgentProvider?.provide() ?: appConfig?.userAgent?.let {
header(HttpHeaders.UserAgent, it)
}
}
@@ -108,4 +107,10 @@ class HttpClientSettings(
appenderList.forEach { it.append(this) }
}
private val AppConfig.userAgent: String get() = buildString {
append(appId)
if (versionName != null) append(" $versionName")
if (versionCode != null) append("/$versionCode")
}
}
+24
View File
@@ -0,0 +1,24 @@
plugins {
alias(libs.plugins.convention.kmp)
alias(libs.plugins.convention.android.lib)
alias(libs.plugins.convention.serialization)
alias(libs.plugins.convention.koin)
alias(libs.plugins.convention.compose)
}
kotlin {
sourceSets {
val commonMain by getting {
dependencies {
implementation(project(":libs:ui"))
implementation(project(":libs:base"))
implementation(libs.ui.tooling.preview)
}
}
}
android { namespace = "ru.shadowsparky.updater" }
}
dependencies {
androidRuntimeClasspath(libs.ui.tooling)
}
@@ -0,0 +1,15 @@
<resources>
<string name="updater_title_available">Доступно обновление</string>
<string name="updater_btn_download">Скачать</string>
<string name="updater_changelog_title">Что нового:</string>
<string name="updater_changelog_empty">Улучшения производительности и исправления ошибок.</string>
<string name="updater_title_permission">Нужно разрешение</string>
<string name="updater_desc_permission">Для установки обновления приложению требуется разрешение на установку из неизвестных источников. Пожалуйста, включите его в настройках.</string>
<string name="updater_btn_grant">В настройки</string>
<string name="updater_title_downloading">Скачивание обновления…</string>
<string name="updater_download_progress">%1$s МБ из %2$s МБ</string>
<string name="updater_title_ready">Обновление готово к установке</string>
<string name="updater_btn_install">Установить</string>
<string name="updater_title_error">Ошибка обновления</string>
<string name="updater_btn_retry">Повторить</string>
</resources>
@@ -0,0 +1,10 @@
package ru.shadowsparky.updater
import org.koin.core.annotation.ComponentScan
import org.koin.core.annotation.Configuration
import org.koin.core.annotation.Module
@Module
@ComponentScan
@Configuration
class UpdaterModule
@@ -0,0 +1,13 @@
package ru.shadowsparky.updater.domain
import kotlinx.coroutines.flow.Flow
interface UpdateDownloader {
fun download(url: String): Flow<DownloadProgress>
}
sealed interface DownloadProgress {
data class Active(val progress: Float, val downloadedBytes: Long, val totalBytes: Long) : DownloadProgress
data class Success(val path: String) : DownloadProgress
data class Error(val throwable: Throwable) : DownloadProgress
}
@@ -0,0 +1,5 @@
package ru.shadowsparky.updater.domain
interface UpdateFetcher {
suspend fun fetch(): UpdateInfo
}
@@ -0,0 +1,12 @@
package ru.shadowsparky.updater.domain
import kotlinx.serialization.Serializable
@Serializable
data class UpdateInfo(
val versionCode: Long? = null,
val versionName: String? = null,
val url: String? = null,
val changelog: String? = null,
val force: Boolean = false
)
@@ -0,0 +1,7 @@
package ru.shadowsparky.updater.domain
interface UpdateInstaller {
suspend fun hasInstallPermission(): Boolean
suspend fun requestInstallPermission()
suspend fun install(path: String)
}
@@ -0,0 +1,84 @@
package ru.shadowsparky.updater.domain
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import org.koin.core.annotation.Factory
import ru.shadowsparky.domain.AppConfig
@Factory
class UpdateInteractor(
private val fetcher: UpdateFetcher?,
private val downloader: UpdateDownloader?,
private val installer: UpdateInstaller?,
private val appConfig: AppConfig?
) {
fun checkUpdate(): Flow<UpdateState> = flow {
if (appConfig?.versionCode == null || fetcher == null) {
emit(UpdateState.UpToDate)
return@flow
}
emit(UpdateState.Checking)
try {
val updateInfo = fetcher.fetch()
val serverVersion = updateInfo.versionCode ?: 0L
val currentVersion = appConfig.versionCode ?: 0L
if (serverVersion <= currentVersion) {
emit(UpdateState.UpToDate)
} else {
emit(
UpdateState.UpdateAvailable(
versionName = updateInfo.versionName ?: "v$serverVersion",
changeLog = updateInfo.changelog,
isForceUpdate = updateInfo.force
)
)
}
} catch (e: Exception) {
emit(UpdateState.Error(message = e.message ?: "Ошибка проверки", throwable = e))
}
}
fun startDownload(url: String): Flow<UpdateState> = flow {
if (installer == null || downloader == null) {
emit(UpdateState.UpToDate)
return@flow
}
if (!installer.hasInstallPermission()) {
emit(UpdateState.InstallPermissionRequired)
return@flow
}
downloader.download(url).collect { progress ->
when (progress) {
is DownloadProgress.Active -> {
emit(
UpdateState.Downloading(
progress = progress.progress,
downloadedBytes = progress.downloadedBytes,
totalBytes = progress.totalBytes
)
)
}
is DownloadProgress.Success -> {
emit(UpdateState.ReadyToInstall(filePath = progress.path))
}
is DownloadProgress.Error -> {
emit(UpdateState.Error(message = progress.throwable.message ?: "Ошибка скачивания", throwable = progress.throwable))
}
}
}
}
suspend fun requestInstallPermission() {
installer?.requestInstallPermission()
}
suspend fun installApk(path: String) {
installer?.install(path)
}
suspend fun hasInstallPermission(): Boolean {
return installer?.hasInstallPermission() ?: false
}
}
@@ -0,0 +1,34 @@
package ru.shadowsparky.updater.domain
import androidx.compose.runtime.Immutable
@Immutable
sealed interface UpdateState {
data object Idle : UpdateState
data object Checking : UpdateState
data class UpdateAvailable(
val versionName: String,
val changeLog: String?,
val isForceUpdate: Boolean
) : UpdateState
data object InstallPermissionRequired : UpdateState
data object UpToDate : UpdateState
data class Downloading(
val progress: Float,
val downloadedBytes: Long,
val totalBytes: Long
) : UpdateState
data class ReadyToInstall(val filePath: String) : UpdateState
data class Error(
val message: String,
val throwable: Throwable? = null
) : UpdateState
}
@@ -0,0 +1,250 @@
package ru.shadowsparky.updater.presentation
import androiddev.libs.updater.generated.resources.Res
import androiddev.libs.updater.generated.resources.updater_btn_download
import androiddev.libs.updater.generated.resources.updater_btn_grant
import androiddev.libs.updater.generated.resources.updater_btn_install
import androiddev.libs.updater.generated.resources.updater_btn_retry
import androiddev.libs.updater.generated.resources.updater_changelog_empty
import androiddev.libs.updater.generated.resources.updater_changelog_title
import androiddev.libs.updater.generated.resources.updater_desc_permission
import androiddev.libs.updater.generated.resources.updater_download_progress
import androiddev.libs.updater.generated.resources.updater_title_available
import androiddev.libs.updater.generated.resources.updater_title_downloading
import androiddev.libs.updater.generated.resources.updater_title_error
import androiddev.libs.updater.generated.resources.updater_title_permission
import androiddev.libs.updater.generated.resources.updater_title_ready
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.layout.Arrangement
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.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.arkivanov.decompose.extensions.compose.subscribeAsState
import org.jetbrains.compose.resources.stringResource
import ru.shadowsparky.updater.domain.UpdateState
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun UpdateBottomSheet(
component: UpdateComponent,
modifier: Modifier = Modifier
) {
val state by component.state.subscribeAsState()
val sheetState = rememberModalBottomSheetState(
confirmValueChange = { state !is UpdateState.UpdateAvailable || !(state as UpdateState.UpdateAvailable).isForceUpdate }
)
val isVisible = remember(state) { state !is UpdateState.Idle && state !is UpdateState.UpToDate }
if (isVisible) {
ModalBottomSheet(onDismissRequest = {}, sheetState = sheetState, modifier = modifier) {
AnimatedContent(
targetState = state,
label = "UpdateStateAnimation",
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp, bottom = 32.dp)
) { currentState ->
when (currentState) {
is UpdateState.Checking -> UpdateCheckingContent()
is UpdateState.UpdateAvailable -> UpdateAvailableContent(
versionName = currentState.versionName,
changeLog = currentState.changeLog,
onDownloadClick = { component.downloadUpdate(currentState.versionName) }
)
is UpdateState.InstallPermissionRequired -> UpdatePermissionContent(
onGrantClick = { component.requestPermission() }
)
is UpdateState.Downloading -> UpdateDownloadingContent(
progress = currentState.progress,
downloadedBytes = currentState.downloadedBytes,
totalBytes = currentState.totalBytes
)
is UpdateState.ReadyToInstall -> UpdateReadyContent(
onInstallClick = { component.installApk(currentState.filePath) }
)
is UpdateState.Error -> UpdateErrorContent(
message = currentState.message,
onRetryClick = { component.checkUpdates() }
)
else -> Unit
}
}
}
}
}
@Composable
fun UpdateCheckingContent(modifier: Modifier = Modifier) {
Column(
modifier = modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) { CircularProgressIndicator() }
}
@Composable
fun UpdateAvailableContent(
versionName: String,
changeLog: String?,
onDownloadClick: () -> Unit,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = stringResource(Res.string.updater_title_available, versionName),
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.align(Alignment.CenterHorizontally)
)
Column(modifier = Modifier.fillMaxWidth()) {
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
)
}
Button(onClick = onDownloadClick, modifier = Modifier.fillMaxWidth()) {
Text(stringResource(Res.string.updater_btn_download), style = MaterialTheme.typography.labelLarge)
}
}
}
@Composable
fun UpdatePermissionContent(
onGrantClick: () -> Unit,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(text = stringResource(Res.string.updater_title_permission), style = MaterialTheme.typography.titleLarge)
Text(text = stringResource(Res.string.updater_desc_permission), style = MaterialTheme.typography.bodyMedium)
Button(onClick = onGrantClick, modifier = Modifier.fillMaxWidth()) {
Text(stringResource(Res.string.updater_btn_grant), style = MaterialTheme.typography.labelLarge)
}
}
}
@Composable
fun UpdateDownloadingContent(
progress: Float,
downloadedBytes: Long,
totalBytes: Long,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(text = stringResource(Res.string.updater_title_downloading), style = MaterialTheme.typography.titleMedium)
LinearProgressIndicator(progress = { progress }, modifier = Modifier.fillMaxWidth())
val downloadedMb = ((downloadedBytes / (1024f * 1024f)) * 10).toInt() / 10.0
val totalMb = ((totalBytes / (1024f * 1024f)) * 10).toInt() / 10.0
Text(
text = stringResource(Res.string.updater_download_progress, downloadedMb.toString(), totalMb.toString()),
style = MaterialTheme.typography.bodySmall
)
}
}
@Composable
fun UpdateReadyContent(
onInstallClick: () -> Unit,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(text = stringResource(Res.string.updater_title_ready), style = MaterialTheme.typography.titleLarge)
Button(onClick = onInstallClick, modifier = Modifier.fillMaxWidth()) {
Text(stringResource(Res.string.updater_btn_install), style = MaterialTheme.typography.labelLarge)
}
}
}
@Composable
fun UpdateErrorContent(
message: String,
onRetryClick: () -> Unit,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(text = stringResource(Res.string.updater_title_error), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.error)
Text(text = message, style = MaterialTheme.typography.bodyMedium)
Button(onClick = onRetryClick, modifier = Modifier.fillMaxWidth()) {
Text(stringResource(Res.string.updater_btn_retry), style = MaterialTheme.typography.labelLarge)
}
}
}
@Preview
@Composable
private fun UpdateAvailablePreview() {
MaterialTheme {
UpdateAvailableContent(
versionName = "v1.4.0",
changeLog = "- Добавили темную тему\n- Исправили падение на главном экране\n- Повысили стабильность",
onDownloadClick = {}
)
}
}
@Preview
@Composable
private fun UpdateDownloadingPreview() {
MaterialTheme {
UpdateDownloadingContent(
progress = 0.45f,
downloadedBytes = 24_117_248L, // ~23 MB
totalBytes = 52_428_800L // 50 MB
)
}
}
@Preview
@Composable
private fun UpdatePermissionPreview() {
MaterialTheme {
UpdatePermissionContent(onGrantClick = {})
}
}
@Preview
@Composable
private fun UpdateErrorPreview() {
MaterialTheme {
UpdateErrorContent(message = "Http status 503: Service Unavailable", onRetryClick = {})
}
}
@@ -0,0 +1,66 @@
package ru.shadowsparky.updater.presentation
import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.value.MutableValue
import com.arkivanov.decompose.value.Value
import com.arkivanov.essenty.lifecycle.doOnDestroy
import com.arkivanov.essenty.lifecycle.doOnResume
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import ru.shadowsparky.updater.domain.UpdateInteractor
import ru.shadowsparky.updater.domain.UpdateState
class UpdateComponent(
componentContext: ComponentContext,
private val interactor: UpdateInteractor,
) : ComponentContext by componentContext {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private val _state = MutableValue<UpdateState>(UpdateState.Idle)
val state: Value<UpdateState> = _state
private var pendingUrl: String? = null
init {
lifecycle.doOnDestroy { scope.cancel() }
lifecycle.doOnResume {
val url = pendingUrl
if (_state.value is UpdateState.InstallPermissionRequired && url != null) {
scope.launch {
if (interactor.hasInstallPermission()) downloadUpdate(url)
}
}
}
}
fun checkUpdates() {
scope.launch {
interactor.checkUpdate().collect { _state.value = it }
}
}
fun downloadUpdate(url: String) {
pendingUrl = url
scope.launch {
interactor.startDownload(url).collect { newState ->
_state.value = newState
if (newState !is UpdateState.InstallPermissionRequired) {
pendingUrl = null
}
}
}
}
fun requestPermission() {
scope.launch { interactor.requestInstallPermission() }
}
fun installApk(path: String) {
scope.launch { interactor.installApk(path) }
}
}
+1 -1
View File
@@ -42,5 +42,5 @@ include(":libs:backend-base")
include(":libs:base")
include(":libs:ui")
include(":libs:http-client")
include(":libs:android-stub")
include(":libs:video-player")
include(":libs:updater")