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
@@ -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)
}
}
}
@@ -1,9 +1,18 @@
<manifest xmlns:tools="http://schemas.android.com/tools"
xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"
<manifest 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"
tools:ignore="RequestInstallPackagesPolicy" />
<application>
<receiver
android:name=".presentation.UpdateInstalledReceiver"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
<provider
android:name="androidx.core.content.FileProvider"
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>
<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_completed">Обновление успешно установлено</string>
<string name="updater_btn_close">Закрыть</string>
<string name="updater_btn_download">Скачать</string>
@@ -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
}
}
}
@@ -20,6 +20,8 @@ sealed interface UpdateState {
data object UpToDate : UpdateState
data object Completed : UpdateState
data class Downloading(
val progress: Float,
val downloadedBytes: Long,
@@ -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>(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
}
@@ -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)
}