check updates on launch, handle package updated

This commit is contained in:
2026-07-26 11:22:48 +03:00
parent 51839829bd
commit ea51196433
10 changed files with 130 additions and 46 deletions
@@ -15,6 +15,7 @@ import com.seiko.imageloader.LocalImageLoader
import ru.shadowsparky.koin import ru.shadowsparky.koin
import ru.shadowsparky.ui.components.BackButton import ru.shadowsparky.ui.components.BackButton
import ru.shadowsparky.ui.nav.Nav 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.list.VideoScreen
import ru.shadowsparky.vbox.shared.presentation.loading.LoadingScreen import ru.shadowsparky.vbox.shared.presentation.loading.LoadingScreen
import ru.shadowsparky.vbox.shared.presentation.nav.Child import ru.shadowsparky.vbox.shared.presentation.nav.Child
@@ -77,6 +78,7 @@ private fun NavInternal(rootComponent: RootComponent) {
} }
} }
} }
UpdateDialog(rootComponent.updateComponent)
} }
} }
@@ -2,15 +2,18 @@ package ru.shadowsparky.vbox.shared.presentation
import com.arkivanov.decompose.ComponentContext import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.ExperimentalDecomposeApi import com.arkivanov.decompose.ExperimentalDecomposeApi
import com.arkivanov.decompose.childContext
import com.arkivanov.decompose.router.stack.childStackWebNavigation import com.arkivanov.decompose.router.stack.childStackWebNavigation
import com.arkivanov.decompose.router.webhistory.WebNavigation import com.arkivanov.decompose.router.webhistory.WebNavigation
import com.arkivanov.decompose.router.webhistory.WebNavigationOwner import com.arkivanov.decompose.router.webhistory.WebNavigationOwner
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.serialization.KSerializer import kotlinx.serialization.KSerializer
import org.koin.core.Koin import org.koin.core.Koin
import org.koin.core.annotation.Factory import org.koin.core.annotation.Factory
import ru.shadowsparky.http.domain.TokenStorage import ru.shadowsparky.http.domain.TokenStorage
import ru.shadowsparky.ui.nav.BaseRootComponent 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.di.factory.ImageLoaderProvider
import ru.shadowsparky.vbox.shared.domain.HeathCheck import ru.shadowsparky.vbox.shared.domain.HeathCheck
import ru.shadowsparky.vbox.shared.presentation.nav.Route import ru.shadowsparky.vbox.shared.presentation.nav.Route
@@ -22,9 +25,12 @@ class RootComponent(
override val initStack: List<Route>, override val initStack: List<Route>,
private val healthCheck: HeathCheck, private val healthCheck: HeathCheck,
val imageLoaderProvider: ImageLoaderProvider, val imageLoaderProvider: ImageLoaderProvider,
private val updateComponentFactory: UpdateComponentFactory,
override val serializer: KSerializer<Route> = Route.serializer(), override val serializer: KSerializer<Route> = Route.serializer(),
override val koin: Koin = ru.shadowsparky.koin override val koin: Koin = ru.shadowsparky.koin
) : BaseRootComponent<Route>(componentContext), WebNavigationOwner { ) : BaseRootComponent<Route>(componentContext), WebNavigationOwner {
val updateComponent = updateComponentFactory.create(childContext("UpdateComponentRoot"))
private val scope = createComponentScope()
val isLoggedIn = tokenStorage.token.map { val isLoggedIn = tokenStorage.token.map {
if (it != null) { if (it != null) {
runCatching { healthCheck.check() }.getOrNull() ?: return@map false runCatching { healthCheck.check() }.getOrNull() ?: return@map false
@@ -34,6 +40,10 @@ class RootComponent(
} }
} }
init {
scope.launch { updateComponent.checkUpdates(false) }
}
override val webNavigation: WebNavigation<*> = override val webNavigation: WebNavigation<*> =
childStackWebNavigation( childStackWebNavigation(
navigator = nav, navigator = nav,
@@ -52,7 +62,8 @@ class RootComponent(
class RootFactory( class RootFactory(
private val tokenStorage: TokenStorage, private val tokenStorage: TokenStorage,
private val heathCheck: HeathCheck, private val heathCheck: HeathCheck,
private val imageLoaderProvider: ImageLoaderProvider private val imageLoaderProvider: ImageLoaderProvider,
private val updateComponentFactory: UpdateComponentFactory
) { ) {
fun create( fun create(
context: ComponentContext, context: ComponentContext,
@@ -70,7 +81,8 @@ class RootFactory(
tokenStorage, tokenStorage,
listOf(Route.Loading(next = initStack.toSet())), listOf(Route.Loading(next = initStack.toSet())),
heathCheck, heathCheck,
imageLoaderProvider imageLoaderProvider,
updateComponentFactory
) )
} }
} }
@@ -1,5 +1,7 @@
package ru.shadowsparky.ui.nav package ru.shadowsparky.ui.nav
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import com.arkivanov.decompose.ExperimentalDecomposeApi import com.arkivanov.decompose.ExperimentalDecomposeApi
import com.arkivanov.decompose.extensions.compose.stack.Children 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.androidPredictiveBackAnimatableV2
import com.arkivanov.decompose.extensions.compose.stack.animation.predictiveback.predictiveBackAnimation import com.arkivanov.decompose.extensions.compose.stack.animation.predictiveback.predictiveBackAnimation
import com.arkivanov.decompose.extensions.compose.stack.animation.stackAnimation import com.arkivanov.decompose.extensions.compose.stack.animation.stackAnimation
import ru.shadowsparky.ui.theme.getColorScheme
@OptIn(ExperimentalDecomposeApi::class) @OptIn(ExperimentalDecomposeApi::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
fun Nav( fun Nav(
rootComponent: BaseRootComponent<*>, rootComponent: BaseRootComponent<*>,
@@ -23,6 +26,8 @@ fun Nav(
selector = { backEvent, _, _ -> androidPredictiveBackAnimatableV2(backEvent) }, selector = { backEvent, _, _ -> androidPredictiveBackAnimatableV2(backEvent) },
), ),
) { ) {
MaterialTheme(getColorScheme()) {
process(it.instance, it.configuration as Route) process(it.instance, it.configuration as Route)
} }
}
} }
@@ -1,9 +1,18 @@
<manifest xmlns:tools="http://schemas.android.com/tools" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:android="http://schemas.android.com/apk/res/android"> xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"
<uses-permission
android:name="android.permission.REQUEST_INSTALL_PACKAGES"
tools:ignore="RequestInstallPackagesPolicy" /> tools:ignore="RequestInstallPackagesPolicy" />
<application> <application>
<receiver
android:name=".presentation.UpdateInstalledReceiver"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
<provider <provider
android:name="androidx.core.content.FileProvider" android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider" android:authorities="${applicationId}.provider"
@@ -0,0 +1,29 @@
package ru.shadowsparky.updater.presentation
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import ru.shadowsparky.koin
import ru.shadowsparky.updater.domain.UpdateDownloader
class UpdateInstalledReceiver : BroadcastReceiver() {
override fun onReceive(p0: Context?, p1: Intent?) {
if (p1?.action == Intent.ACTION_MY_PACKAGE_REPLACED) {
koin.getOrNull<UpdateDownloader>()?.let { downloader ->
val scope = CoroutineScope(Job() + Dispatchers.Main)
val async = goAsync()
scope.launch {
try {
downloader.clean()
} finally {
async.finish()
}
}
}
}
}
}
@@ -1,6 +1,8 @@
<resources> <resources>
<string name="updater_title_available">Доступно обновление</string> <string name="updater_title_available">Доступно обновление на версию %1$s</string>
<string name="updater_title_not_found">Обновления не найдены</string> <string name="updater_title_not_found">Обновления не найдены</string>
<string name="updater_title_completed">Обновление успешно установлено</string>
<string name="updater_btn_close">Закрыть</string> <string name="updater_btn_close">Закрыть</string>
<string name="updater_btn_download">Скачать</string> <string name="updater_btn_download">Скачать</string>
@@ -1,6 +1,7 @@
package ru.shadowsparky.updater.domain package ru.shadowsparky.updater.domain
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import org.koin.core.annotation.Factory import org.koin.core.annotation.Factory
import ru.shadowsparky.domain.AppConfig import ru.shadowsparky.domain.AppConfig
@@ -53,7 +54,7 @@ class UpdateInteractor(
return@flow return@flow
} }
emit(UpdateState.Downloading(0f, 0, 0)) emit(UpdateState.Downloading(0f, 0, 0))
downloader.download(url).collect { progress -> downloader.download(url).firstOrNull { progress ->
when (progress) { when (progress) {
is DownloadProgress.Active -> { is DownloadProgress.Active -> {
emit( emit(
@@ -63,12 +64,15 @@ class UpdateInteractor(
totalBytes = progress.totalBytes totalBytes = progress.totalBytes
) )
) )
false
} }
is DownloadProgress.Success -> { is DownloadProgress.Success -> {
emit(UpdateState.ReadyToInstall(filePath = progress.path)) emit(UpdateState.ReadyToInstall(filePath = progress.path))
true
} }
is DownloadProgress.Error -> { is DownloadProgress.Error -> {
emit(UpdateState.Error(message = progress.throwable.message ?: "Ошибка скачивания", throwable = progress.throwable)) emit(UpdateState.Error(message = progress.throwable.message ?: "Ошибка скачивания", throwable = progress.throwable))
true
} }
} }
} }
@@ -20,6 +20,8 @@ sealed interface UpdateState {
data object UpToDate : UpdateState data object UpToDate : UpdateState
data object Completed : UpdateState
data class Downloading( data class Downloading(
val progress: Float, val progress: Float,
val downloadedBytes: Long, val downloadedBytes: Long,
@@ -8,6 +8,7 @@ import com.arkivanov.essenty.lifecycle.doOnResume
import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -26,6 +27,7 @@ class UpdateComponent(
_state.value = UpdateState.Error(throwable.message ?: "$throwable", throwable) _state.value = UpdateState.Error(throwable.message ?: "$throwable", throwable)
} }
private var lastJob: Job? = null
private val scope = CoroutineScope(exceptionHandler + SupervisorJob() + Dispatchers.Main.immediate) private val scope = CoroutineScope(exceptionHandler + SupervisorJob() + Dispatchers.Main.immediate)
private val _state = MutableValue<UpdateState>(UpdateState.Idle) private val _state = MutableValue<UpdateState>(UpdateState.Idle)
@@ -47,7 +49,7 @@ class UpdateComponent(
fun checkUpdates(byUser: Boolean = true) { fun checkUpdates(byUser: Boolean = true) {
if (!hasSupport) return if (!hasSupport) return
scope.launch { lastJob = scope.launch {
interactor.checkUpdate().collect { interactor.checkUpdate().collect {
isForceUpdate = (it as? UpdateState.UpdateAvailable)?.isForceUpdate ?: false isForceUpdate = (it as? UpdateState.UpdateAvailable)?.isForceUpdate ?: false
_state.value = if (byUser) { _state.value = if (byUser) {
@@ -66,12 +68,15 @@ class UpdateComponent(
fun downloadUpdate(url: String) { fun downloadUpdate(url: String) {
pendingUrl = url pendingUrl = url
scope.launch { lastJob = scope.launch {
interactor.startDownload(url).collect { newState -> interactor.startDownload(url).collect { newState ->
_state.value = newState _state.value = newState
if (newState !is UpdateState.InstallPermissionRequired) { if (newState !is UpdateState.InstallPermissionRequired) {
pendingUrl = null pendingUrl = null
} }
if (newState is UpdateState.ReadyToInstall) {
installApk(newState.filePath)
}
} }
} }
} }
@@ -81,12 +86,17 @@ class UpdateComponent(
} }
fun installApk(path: String) { fun installApk(path: String) {
scope.launch { interactor.installApk(path) } scope.launch {
_state.value = UpdateState.Checking
interactor.installApk(path)
_state.value = UpdateState.Completed
}
} }
fun reset() { fun reset() {
scope.launch { scope.launch {
_state.value = UpdateState.Checking _state.value = UpdateState.Checking
if (lastJob?.isActive == true) lastJob?.cancel()
interactor.reset() interactor.reset()
_state.value = UpdateState.Idle _state.value = UpdateState.Idle
} }
@@ -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_desc_permission
import androiddev.libs.updater.updater_client.generated.resources.updater_download_progress 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_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_downloading
import androiddev.libs.updater.updater_client.generated.resources.updater_title_error 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_not_found
import androiddev.libs.updater.updater_client.generated.resources.updater_title_permission import androiddev.libs.updater.updater_client.generated.resources.updater_title_permission
import androiddev.libs.updater.updater_client.generated.resources.updater_title_ready 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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.BasicAlertDialog import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import com.arkivanov.decompose.extensions.compose.subscribeAsState import com.arkivanov.decompose.extensions.compose.subscribeAsState
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import ru.shadowsparky.ui.components.Loading import ru.shadowsparky.ui.components.Loading
import ru.shadowsparky.updater.domain.UpdateState import ru.shadowsparky.updater.domain.UpdateState
@@ -63,11 +63,12 @@ fun UpdateDialog(
canDismiss, canDismiss,
), ),
content = { content = {
Box( Surface(
modifier = Modifier.clip(RoundedCornerShape(28.dp)) shape = MaterialTheme.shapes.extraLarge,
.background(MaterialTheme.colorScheme.surfaceContainerHigh) color = MaterialTheme.colorScheme.surface,
.padding(16.dp) contentColor = MaterialTheme.colorScheme.onSurface
) { ) {
Box(modifier = Modifier.padding(16.dp)) {
when (val currentState = state) { when (val currentState = state) {
is UpdateState.Checking -> UpdateCheckingContent() is UpdateState.Checking -> UpdateCheckingContent()
is UpdateState.UpdateAvailable -> UpdateAvailableContent( is UpdateState.UpdateAvailable -> UpdateAvailableContent(
@@ -91,12 +92,19 @@ fun UpdateDialog(
onRetryClick = { component.checkUpdates() } onRetryClick = { component.checkUpdates() }
) )
is UpdateState.UpToDate -> { is UpdateState.UpToDate -> {
UpdateNotFoundContent(onSkip = { component.reset() }) 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 @Composable
private fun UpdateNotFoundContent( private fun UpdateFinishedContent(
text: StringResource = Res.string.updater_title_not_found,
onSkip: () -> Unit, onSkip: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
@@ -209,7 +218,7 @@ private fun UpdateNotFoundContent(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp) 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()) { Button(onClick = onSkip, modifier = Modifier.fillMaxWidth()) {
Text(stringResource(Res.string.updater_btn_close), style = MaterialTheme.typography.labelLarge) Text(stringResource(Res.string.updater_btn_close), style = MaterialTheme.typography.labelLarge)
} }