base updater classes
This commit is contained in:
@@ -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"))
|
||||
|
||||
@@ -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"))
|
||||
|
||||
+4
-1
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+10
-5
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+250
@@ -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 = {})
|
||||
}
|
||||
}
|
||||
+66
@@ -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
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user