check updates on launch, handle package updated
This commit is contained in:
@@ -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"
|
||||
|
||||
+29
@@ -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>
|
||||
|
||||
+5
-1
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -20,6 +20,8 @@ sealed interface UpdateState {
|
||||
|
||||
data object UpToDate : UpdateState
|
||||
|
||||
data object Completed : UpdateState
|
||||
|
||||
data class Downloading(
|
||||
val progress: Float,
|
||||
val downloadedBytes: Long,
|
||||
|
||||
+13
-3
@@ -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
|
||||
}
|
||||
|
||||
+43
-34
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user