From 7bd09459fac43dc5753883edaa59ddc2c031762e Mon Sep 17 00:00:00 2001 From: Eugene Date: Sat, 25 Jul 2026 17:21:43 +0300 Subject: [PATCH] base updater classes --- .../androidApp/build.gradle.kts | 2 - apps/vbox/client/androidApp/build.gradle.kts | 2 +- .../videobox/multiplatform/App.kt | 5 +- .../kotlin/ru/shadowsparky/vbox/Main.kt | 3 +- gradle/libs.versions.toml | 4 +- libs/android-stub/build.gradle.kts | 9 - .../internal/telephony/ITelephony.java | 5 - .../ru/shadowsparky/domain/AppConfig.kt | 7 +- .../http/data/HttpClientSettings.kt | 15 +- libs/{android-stub => updater}/.gitignore | 0 libs/updater/build.gradle.kts | 24 ++ .../composeResources/values/strings.xml | 15 ++ .../ru/shadowsparky/updater/UpdaterModule.kt | 10 + .../updater/domain/UpdateDownloader.kt | 13 + .../updater/domain/UpdateFetcher.kt | 5 + .../shadowsparky/updater/domain/UpdateInfo.kt | 12 + .../updater/domain/UpdateInstaller.kt | 7 + .../updater/domain/UpdateInteractor.kt | 84 ++++++ .../updater/domain/UpdateState.kt | 34 +++ .../updater/presentation/UpdateBottomSheet.kt | 250 ++++++++++++++++++ .../updater/presentation/UpdateComponent.kt | 66 +++++ settings.gradle.kts | 2 +- 22 files changed, 545 insertions(+), 29 deletions(-) delete mode 100644 libs/android-stub/build.gradle.kts delete mode 100644 libs/android-stub/src/main/java/com/android/internal/telephony/ITelephony.java rename libs/{android-stub => updater}/.gitignore (100%) create mode 100644 libs/updater/build.gradle.kts create mode 100644 libs/updater/src/commonMain/composeResources/values/strings.xml create mode 100644 libs/updater/src/commonMain/kotlin/ru/shadowsparky/updater/UpdaterModule.kt create mode 100644 libs/updater/src/commonMain/kotlin/ru/shadowsparky/updater/domain/UpdateDownloader.kt create mode 100644 libs/updater/src/commonMain/kotlin/ru/shadowsparky/updater/domain/UpdateFetcher.kt create mode 100644 libs/updater/src/commonMain/kotlin/ru/shadowsparky/updater/domain/UpdateInfo.kt create mode 100644 libs/updater/src/commonMain/kotlin/ru/shadowsparky/updater/domain/UpdateInstaller.kt create mode 100644 libs/updater/src/commonMain/kotlin/ru/shadowsparky/updater/domain/UpdateInteractor.kt create mode 100644 libs/updater/src/commonMain/kotlin/ru/shadowsparky/updater/domain/UpdateState.kt create mode 100644 libs/updater/src/commonMain/kotlin/ru/shadowsparky/updater/presentation/UpdateBottomSheet.kt create mode 100644 libs/updater/src/commonMain/kotlin/ru/shadowsparky/updater/presentation/UpdateComponent.kt diff --git a/apps/experimentarium/androidApp/build.gradle.kts b/apps/experimentarium/androidApp/build.gradle.kts index 30d3b62..950e696 100644 --- a/apps/experimentarium/androidApp/build.gradle.kts +++ b/apps/experimentarium/androidApp/build.gradle.kts @@ -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")) diff --git a/apps/vbox/client/androidApp/build.gradle.kts b/apps/vbox/client/androidApp/build.gradle.kts index 75e07a1..78e67c3 100644 --- a/apps/vbox/client/androidApp/build.gradle.kts +++ b/apps/vbox/client/androidApp/build.gradle.kts @@ -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")) diff --git a/apps/vbox/client/androidApp/src/main/kotlin/ru/shadowsparky/videobox/multiplatform/App.kt b/apps/vbox/client/androidApp/src/main/kotlin/ru/shadowsparky/videobox/multiplatform/App.kt index 479748c..ecdcdb8 100644 --- a/apps/vbox/client/androidApp/src/main/kotlin/ru/shadowsparky/videobox/multiplatform/App.kt +++ b/apps/vbox/client/androidApp/src/main/kotlin/ru/shadowsparky/videobox/multiplatform/App.kt @@ -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 } } } diff --git a/apps/vbox/client/web/src/wasmJsMain/kotlin/ru/shadowsparky/vbox/Main.kt b/apps/vbox/client/web/src/wasmJsMain/kotlin/ru/shadowsparky/vbox/Main.kt index 09ebbe1..0c82fd1 100644 --- a/apps/vbox/client/web/src/wasmJsMain/kotlin/ru/shadowsparky/vbox/Main.kt +++ b/apps/vbox/client/web/src/wasmJsMain/kotlin/ru/shadowsparky/vbox/Main.kt @@ -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 } } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ca27bcc..71a107b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" } diff --git a/libs/android-stub/build.gradle.kts b/libs/android-stub/build.gradle.kts deleted file mode 100644 index 6c92603..0000000 --- a/libs/android-stub/build.gradle.kts +++ /dev/null @@ -1,9 +0,0 @@ -import com.android.build.api.dsl.LibraryExtension - -plugins { - alias(libs.plugins.convention.android.lib) -} - -extensions.configure { - namespace = "ru.shadowsparky.androidstub" -} diff --git a/libs/android-stub/src/main/java/com/android/internal/telephony/ITelephony.java b/libs/android-stub/src/main/java/com/android/internal/telephony/ITelephony.java deleted file mode 100644 index 966aa14..0000000 --- a/libs/android-stub/src/main/java/com/android/internal/telephony/ITelephony.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.android.internal.telephony; - -public interface ITelephony { - boolean endCall(); -} diff --git a/libs/base/src/commonMain/kotlin/ru/shadowsparky/domain/AppConfig.kt b/libs/base/src/commonMain/kotlin/ru/shadowsparky/domain/AppConfig.kt index 254202f..857e37c 100644 --- a/libs/base/src/commonMain/kotlin/ru/shadowsparky/domain/AppConfig.kt +++ b/libs/base/src/commonMain/kotlin/ru/shadowsparky/domain/AppConfig.kt @@ -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 } diff --git a/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/data/HttpClientSettings.kt b/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/data/HttpClientSettings.kt index 2bf41a6..980d682 100644 --- a/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/data/HttpClientSettings.kt +++ b/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/data/HttpClientSettings.kt @@ -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, - 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()?.isDebug() ?: true + val isDebug = koin.getOrNull()?.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") + } } diff --git a/libs/android-stub/.gitignore b/libs/updater/.gitignore similarity index 100% rename from libs/android-stub/.gitignore rename to libs/updater/.gitignore diff --git a/libs/updater/build.gradle.kts b/libs/updater/build.gradle.kts new file mode 100644 index 0000000..4150377 --- /dev/null +++ b/libs/updater/build.gradle.kts @@ -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) +} diff --git a/libs/updater/src/commonMain/composeResources/values/strings.xml b/libs/updater/src/commonMain/composeResources/values/strings.xml new file mode 100644 index 0000000..1364021 --- /dev/null +++ b/libs/updater/src/commonMain/composeResources/values/strings.xml @@ -0,0 +1,15 @@ + + Доступно обновление + Скачать + Что нового: + Улучшения производительности и исправления ошибок. + Нужно разрешение + Для установки обновления приложению требуется разрешение на установку из неизвестных источников. Пожалуйста, включите его в настройках. + В настройки + Скачивание обновления… + %1$s МБ из %2$s МБ + Обновление готово к установке + Установить + Ошибка обновления + Повторить + diff --git a/libs/updater/src/commonMain/kotlin/ru/shadowsparky/updater/UpdaterModule.kt b/libs/updater/src/commonMain/kotlin/ru/shadowsparky/updater/UpdaterModule.kt new file mode 100644 index 0000000..0f33824 --- /dev/null +++ b/libs/updater/src/commonMain/kotlin/ru/shadowsparky/updater/UpdaterModule.kt @@ -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 diff --git a/libs/updater/src/commonMain/kotlin/ru/shadowsparky/updater/domain/UpdateDownloader.kt b/libs/updater/src/commonMain/kotlin/ru/shadowsparky/updater/domain/UpdateDownloader.kt new file mode 100644 index 0000000..194b169 --- /dev/null +++ b/libs/updater/src/commonMain/kotlin/ru/shadowsparky/updater/domain/UpdateDownloader.kt @@ -0,0 +1,13 @@ +package ru.shadowsparky.updater.domain + +import kotlinx.coroutines.flow.Flow + +interface UpdateDownloader { + fun download(url: String): Flow +} + +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 +} diff --git a/libs/updater/src/commonMain/kotlin/ru/shadowsparky/updater/domain/UpdateFetcher.kt b/libs/updater/src/commonMain/kotlin/ru/shadowsparky/updater/domain/UpdateFetcher.kt new file mode 100644 index 0000000..8f3ee22 --- /dev/null +++ b/libs/updater/src/commonMain/kotlin/ru/shadowsparky/updater/domain/UpdateFetcher.kt @@ -0,0 +1,5 @@ +package ru.shadowsparky.updater.domain + +interface UpdateFetcher { + suspend fun fetch(): UpdateInfo +} diff --git a/libs/updater/src/commonMain/kotlin/ru/shadowsparky/updater/domain/UpdateInfo.kt b/libs/updater/src/commonMain/kotlin/ru/shadowsparky/updater/domain/UpdateInfo.kt new file mode 100644 index 0000000..eaf725d --- /dev/null +++ b/libs/updater/src/commonMain/kotlin/ru/shadowsparky/updater/domain/UpdateInfo.kt @@ -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 +) diff --git a/libs/updater/src/commonMain/kotlin/ru/shadowsparky/updater/domain/UpdateInstaller.kt b/libs/updater/src/commonMain/kotlin/ru/shadowsparky/updater/domain/UpdateInstaller.kt new file mode 100644 index 0000000..a72ac37 --- /dev/null +++ b/libs/updater/src/commonMain/kotlin/ru/shadowsparky/updater/domain/UpdateInstaller.kt @@ -0,0 +1,7 @@ +package ru.shadowsparky.updater.domain + +interface UpdateInstaller { + suspend fun hasInstallPermission(): Boolean + suspend fun requestInstallPermission() + suspend fun install(path: String) +} diff --git a/libs/updater/src/commonMain/kotlin/ru/shadowsparky/updater/domain/UpdateInteractor.kt b/libs/updater/src/commonMain/kotlin/ru/shadowsparky/updater/domain/UpdateInteractor.kt new file mode 100644 index 0000000..1d8191f --- /dev/null +++ b/libs/updater/src/commonMain/kotlin/ru/shadowsparky/updater/domain/UpdateInteractor.kt @@ -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 = 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 = 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 + } +} diff --git a/libs/updater/src/commonMain/kotlin/ru/shadowsparky/updater/domain/UpdateState.kt b/libs/updater/src/commonMain/kotlin/ru/shadowsparky/updater/domain/UpdateState.kt new file mode 100644 index 0000000..b9570c6 --- /dev/null +++ b/libs/updater/src/commonMain/kotlin/ru/shadowsparky/updater/domain/UpdateState.kt @@ -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 +} diff --git a/libs/updater/src/commonMain/kotlin/ru/shadowsparky/updater/presentation/UpdateBottomSheet.kt b/libs/updater/src/commonMain/kotlin/ru/shadowsparky/updater/presentation/UpdateBottomSheet.kt new file mode 100644 index 0000000..20f3025 --- /dev/null +++ b/libs/updater/src/commonMain/kotlin/ru/shadowsparky/updater/presentation/UpdateBottomSheet.kt @@ -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 = {}) + } +} diff --git a/libs/updater/src/commonMain/kotlin/ru/shadowsparky/updater/presentation/UpdateComponent.kt b/libs/updater/src/commonMain/kotlin/ru/shadowsparky/updater/presentation/UpdateComponent.kt new file mode 100644 index 0000000..62f773a --- /dev/null +++ b/libs/updater/src/commonMain/kotlin/ru/shadowsparky/updater/presentation/UpdateComponent.kt @@ -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.Idle) + val state: Value = _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) } + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 21914db..f8cd5d7 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -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")