diff --git a/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/BaseScreen.kt b/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/BaseScreen.kt index a1dcf07..fc462dd 100644 --- a/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/BaseScreen.kt +++ b/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/BaseScreen.kt @@ -15,6 +15,7 @@ import com.seiko.imageloader.LocalImageLoader import ru.shadowsparky.koin import ru.shadowsparky.ui.components.BackButton import ru.shadowsparky.ui.nav.Nav +import ru.shadowsparky.updater.presentation.UpdateDialog import ru.shadowsparky.vbox.shared.presentation.list.VideoScreen import ru.shadowsparky.vbox.shared.presentation.loading.LoadingScreen import ru.shadowsparky.vbox.shared.presentation.nav.Child @@ -77,6 +78,7 @@ private fun NavInternal(rootComponent: RootComponent) { } } } + UpdateDialog(rootComponent.updateComponent) } } diff --git a/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/RootComponent.kt b/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/RootComponent.kt index f5405f9..611d1de 100644 --- a/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/RootComponent.kt +++ b/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/RootComponent.kt @@ -2,15 +2,18 @@ package ru.shadowsparky.vbox.shared.presentation import com.arkivanov.decompose.ComponentContext import com.arkivanov.decompose.ExperimentalDecomposeApi +import com.arkivanov.decompose.childContext import com.arkivanov.decompose.router.stack.childStackWebNavigation import com.arkivanov.decompose.router.webhistory.WebNavigation import com.arkivanov.decompose.router.webhistory.WebNavigationOwner import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch import kotlinx.serialization.KSerializer import org.koin.core.Koin import org.koin.core.annotation.Factory import ru.shadowsparky.http.domain.TokenStorage import ru.shadowsparky.ui.nav.BaseRootComponent +import ru.shadowsparky.updater.presentation.UpdateComponentFactory import ru.shadowsparky.vbox.shared.di.factory.ImageLoaderProvider import ru.shadowsparky.vbox.shared.domain.HeathCheck import ru.shadowsparky.vbox.shared.presentation.nav.Route @@ -22,9 +25,12 @@ class RootComponent( override val initStack: List, private val healthCheck: HeathCheck, val imageLoaderProvider: ImageLoaderProvider, + private val updateComponentFactory: UpdateComponentFactory, override val serializer: KSerializer = Route.serializer(), override val koin: Koin = ru.shadowsparky.koin ) : BaseRootComponent(componentContext), WebNavigationOwner { + val updateComponent = updateComponentFactory.create(childContext("UpdateComponentRoot")) + private val scope = createComponentScope() val isLoggedIn = tokenStorage.token.map { if (it != null) { runCatching { healthCheck.check() }.getOrNull() ?: return@map false @@ -34,6 +40,10 @@ class RootComponent( } } + init { + scope.launch { updateComponent.checkUpdates(false) } + } + override val webNavigation: WebNavigation<*> = childStackWebNavigation( navigator = nav, @@ -52,7 +62,8 @@ class RootComponent( class RootFactory( private val tokenStorage: TokenStorage, private val heathCheck: HeathCheck, - private val imageLoaderProvider: ImageLoaderProvider + private val imageLoaderProvider: ImageLoaderProvider, + private val updateComponentFactory: UpdateComponentFactory ) { fun create( context: ComponentContext, @@ -70,7 +81,8 @@ class RootFactory( tokenStorage, listOf(Route.Loading(next = initStack.toSet())), heathCheck, - imageLoaderProvider + imageLoaderProvider, + updateComponentFactory ) } } diff --git a/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/nav/Nav.kt b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/nav/Nav.kt index 3977ae8..9fdea6e 100644 --- a/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/nav/Nav.kt +++ b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/nav/Nav.kt @@ -1,5 +1,7 @@ package ru.shadowsparky.ui.nav +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import com.arkivanov.decompose.ExperimentalDecomposeApi import com.arkivanov.decompose.extensions.compose.stack.Children @@ -7,8 +9,9 @@ import com.arkivanov.decompose.extensions.compose.stack.animation.fade import com.arkivanov.decompose.extensions.compose.stack.animation.predictiveback.androidPredictiveBackAnimatableV2 import com.arkivanov.decompose.extensions.compose.stack.animation.predictiveback.predictiveBackAnimation import com.arkivanov.decompose.extensions.compose.stack.animation.stackAnimation +import ru.shadowsparky.ui.theme.getColorScheme -@OptIn(ExperimentalDecomposeApi::class) +@OptIn(ExperimentalDecomposeApi::class, ExperimentalMaterial3ExpressiveApi::class) @Composable fun Nav( rootComponent: BaseRootComponent<*>, @@ -23,6 +26,8 @@ fun Nav( selector = { backEvent, _, _ -> androidPredictiveBackAnimatableV2(backEvent) }, ), ) { - process(it.instance, it.configuration as Route) + MaterialTheme(getColorScheme()) { + process(it.instance, it.configuration as Route) + } } } diff --git a/libs/updater/updater-client/src/androidMain/AndroidManifest.xml b/libs/updater/updater-client/src/androidMain/AndroidManifest.xml index 107e904..d4d3665 100644 --- a/libs/updater/updater-client/src/androidMain/AndroidManifest.xml +++ b/libs/updater/updater-client/src/androidMain/AndroidManifest.xml @@ -1,9 +1,18 @@ - - + + + + + + + ()?.let { downloader -> + val scope = CoroutineScope(Job() + Dispatchers.Main) + val async = goAsync() + scope.launch { + try { + downloader.clean() + } finally { + async.finish() + } + } + } + } + } +} diff --git a/libs/updater/updater-client/src/commonMain/composeResources/values/strings.xml b/libs/updater/updater-client/src/commonMain/composeResources/values/strings.xml index 1e65687..caa44b8 100644 --- a/libs/updater/updater-client/src/commonMain/composeResources/values/strings.xml +++ b/libs/updater/updater-client/src/commonMain/composeResources/values/strings.xml @@ -1,6 +1,8 @@ - Доступно обновление + Доступно обновление на версию %1$s Обновления не найдены + Обновление успешно установлено + Закрыть Скачать diff --git a/libs/updater/updater-client/src/commonMain/kotlin/ru/shadowsparky/updater/domain/UpdateInteractor.kt b/libs/updater/updater-client/src/commonMain/kotlin/ru/shadowsparky/updater/domain/UpdateInteractor.kt index 354fb33..8dcfd93 100644 --- a/libs/updater/updater-client/src/commonMain/kotlin/ru/shadowsparky/updater/domain/UpdateInteractor.kt +++ b/libs/updater/updater-client/src/commonMain/kotlin/ru/shadowsparky/updater/domain/UpdateInteractor.kt @@ -1,6 +1,7 @@ package ru.shadowsparky.updater.domain import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.flow import org.koin.core.annotation.Factory import ru.shadowsparky.domain.AppConfig @@ -53,7 +54,7 @@ class UpdateInteractor( return@flow } emit(UpdateState.Downloading(0f, 0, 0)) - downloader.download(url).collect { progress -> + downloader.download(url).firstOrNull { progress -> when (progress) { is DownloadProgress.Active -> { emit( @@ -63,12 +64,15 @@ class UpdateInteractor( totalBytes = progress.totalBytes ) ) + false } is DownloadProgress.Success -> { emit(UpdateState.ReadyToInstall(filePath = progress.path)) + true } is DownloadProgress.Error -> { emit(UpdateState.Error(message = progress.throwable.message ?: "Ошибка скачивания", throwable = progress.throwable)) + true } } } diff --git a/libs/updater/updater-client/src/commonMain/kotlin/ru/shadowsparky/updater/domain/UpdateState.kt b/libs/updater/updater-client/src/commonMain/kotlin/ru/shadowsparky/updater/domain/UpdateState.kt index 049035e..d201f2d 100644 --- a/libs/updater/updater-client/src/commonMain/kotlin/ru/shadowsparky/updater/domain/UpdateState.kt +++ b/libs/updater/updater-client/src/commonMain/kotlin/ru/shadowsparky/updater/domain/UpdateState.kt @@ -20,6 +20,8 @@ sealed interface UpdateState { data object UpToDate : UpdateState + data object Completed : UpdateState + data class Downloading( val progress: Float, val downloadedBytes: Long, diff --git a/libs/updater/updater-client/src/commonMain/kotlin/ru/shadowsparky/updater/presentation/UpdateComponent.kt b/libs/updater/updater-client/src/commonMain/kotlin/ru/shadowsparky/updater/presentation/UpdateComponent.kt index 17bc7b7..114b281 100644 --- a/libs/updater/updater-client/src/commonMain/kotlin/ru/shadowsparky/updater/presentation/UpdateComponent.kt +++ b/libs/updater/updater-client/src/commonMain/kotlin/ru/shadowsparky/updater/presentation/UpdateComponent.kt @@ -8,6 +8,7 @@ import com.arkivanov.essenty.lifecycle.doOnResume import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.launch @@ -26,6 +27,7 @@ class UpdateComponent( _state.value = UpdateState.Error(throwable.message ?: "$throwable", throwable) } + private var lastJob: Job? = null private val scope = CoroutineScope(exceptionHandler + SupervisorJob() + Dispatchers.Main.immediate) private val _state = MutableValue(UpdateState.Idle) @@ -47,7 +49,7 @@ class UpdateComponent( fun checkUpdates(byUser: Boolean = true) { if (!hasSupport) return - scope.launch { + lastJob = scope.launch { interactor.checkUpdate().collect { isForceUpdate = (it as? UpdateState.UpdateAvailable)?.isForceUpdate ?: false _state.value = if (byUser) { @@ -66,12 +68,15 @@ class UpdateComponent( fun downloadUpdate(url: String) { pendingUrl = url - scope.launch { + lastJob = scope.launch { interactor.startDownload(url).collect { newState -> _state.value = newState if (newState !is UpdateState.InstallPermissionRequired) { pendingUrl = null } + if (newState is UpdateState.ReadyToInstall) { + installApk(newState.filePath) + } } } } @@ -81,12 +86,17 @@ class UpdateComponent( } fun installApk(path: String) { - scope.launch { interactor.installApk(path) } + scope.launch { + _state.value = UpdateState.Checking + interactor.installApk(path) + _state.value = UpdateState.Completed + } } fun reset() { scope.launch { _state.value = UpdateState.Checking + if (lastJob?.isActive == true) lastJob?.cancel() interactor.reset() _state.value = UpdateState.Idle } diff --git a/libs/updater/updater-client/src/commonMain/kotlin/ru/shadowsparky/updater/presentation/UpdateDialog.kt b/libs/updater/updater-client/src/commonMain/kotlin/ru/shadowsparky/updater/presentation/UpdateDialog.kt index 5189bf0..3d563e8 100644 --- a/libs/updater/updater-client/src/commonMain/kotlin/ru/shadowsparky/updater/presentation/UpdateDialog.kt +++ b/libs/updater/updater-client/src/commonMain/kotlin/ru/shadowsparky/updater/presentation/UpdateDialog.kt @@ -11,12 +11,12 @@ import androiddev.libs.updater.updater_client.generated.resources.updater_change import androiddev.libs.updater.updater_client.generated.resources.updater_desc_permission import androiddev.libs.updater.updater_client.generated.resources.updater_download_progress import androiddev.libs.updater.updater_client.generated.resources.updater_title_available +import androiddev.libs.updater.updater_client.generated.resources.updater_title_completed import androiddev.libs.updater.updater_client.generated.resources.updater_title_downloading import androiddev.libs.updater.updater_client.generated.resources.updater_title_error import androiddev.libs.updater.updater_client.generated.resources.updater_title_not_found import androiddev.libs.updater.updater_client.generated.resources.updater_title_permission import androiddev.libs.updater.updater_client.generated.resources.updater_title_ready -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -24,23 +24,23 @@ 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.foundation.shape.RoundedCornerShape import androidx.compose.material3.BasicAlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text 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.draw.clip import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.window.DialogProperties import com.arkivanov.decompose.extensions.compose.subscribeAsState +import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.stringResource import ru.shadowsparky.ui.components.Loading import ru.shadowsparky.updater.domain.UpdateState @@ -63,37 +63,45 @@ fun UpdateDialog( canDismiss, ), content = { - Box( - modifier = Modifier.clip(RoundedCornerShape(28.dp)) - .background(MaterialTheme.colorScheme.surfaceContainerHigh) - .padding(16.dp) + Surface( + shape = MaterialTheme.shapes.extraLarge, + color = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.onSurface ) { - when (val currentState = state) { - is UpdateState.Checking -> UpdateCheckingContent() - is UpdateState.UpdateAvailable -> UpdateAvailableContent( - versionName = currentState.versionName, - changeLog = currentState.changeLog, - onDownloadClick = { component.downloadUpdate(currentState.url) } - ) - 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() } - ) - is UpdateState.UpToDate -> { - UpdateNotFoundContent(onSkip = { component.reset() }) + Box(modifier = Modifier.padding(16.dp)) { + when (val currentState = state) { + is UpdateState.Checking -> UpdateCheckingContent() + is UpdateState.UpdateAvailable -> UpdateAvailableContent( + versionName = currentState.versionName, + changeLog = currentState.changeLog, + onDownloadClick = { component.downloadUpdate(currentState.url) } + ) + 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() } + ) + is UpdateState.UpToDate -> { + UpdateFinishedContent(onSkip = { component.reset() }) + } + is UpdateState.Completed -> { + UpdateFinishedContent( + Res.string.updater_title_completed, + onSkip = { component.reset() } + ) + } + else -> Unit } - else -> Unit } } } @@ -200,7 +208,8 @@ private fun UpdateReadyContent( } @Composable -private fun UpdateNotFoundContent( +private fun UpdateFinishedContent( + text: StringResource = Res.string.updater_title_not_found, onSkip: () -> Unit, modifier: Modifier = Modifier ) { @@ -209,7 +218,7 @@ private fun UpdateNotFoundContent( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp) ) { - Text(text = stringResource(Res.string.updater_title_not_found), style = MaterialTheme.typography.titleLarge) + Text(text = stringResource(text), style = MaterialTheme.typography.titleLarge) Button(onClick = onSkip, modifier = Modifier.fillMaxWidth()) { Text(stringResource(Res.string.updater_btn_close), style = MaterialTheme.typography.labelLarge) }