backend implementation

This commit is contained in:
2026-07-25 21:54:24 +03:00
parent c49a5ed087
commit 9254829c5d
28 changed files with 130 additions and 35 deletions
@@ -0,0 +1,5 @@
<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"
tools:ignore="RequestInstallPackagesPolicy" />
</manifest>
@@ -0,0 +1,43 @@
package ru.shadowsparky.updater.data
import android.app.Application
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import androidx.core.content.FileProvider
import androidx.core.net.toUri
import org.koin.core.annotation.Factory
import ru.shadowsparky.updater.domain.UpdateInstaller
import java.io.File
@Factory
class AndroidUpdateInstaller(private val app: Application) : UpdateInstaller {
override suspend fun hasInstallPermission(): Boolean {
return app.packageManager.canRequestPackageInstalls()
}
override suspend fun requestInstallPermission() {
val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply {
data = "package:${app.packageName}".toUri()
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
app.startActivity(intent)
}
override suspend fun install(path: String) {
val file = File(path)
if (!file.exists()) return
val intent = Intent(Intent.ACTION_VIEW).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
val uri: Uri = FileProvider.getUriForFile(
app,
"${app.packageName}.provider",
file
)
intent.setDataAndType(uri, "application/vnd.android.package-archive")
app.startActivity(intent)
}
}
@@ -0,0 +1,72 @@
package ru.shadowsparky.updater.data
import android.app.Application
import io.ktor.client.HttpClient
import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsChannel
import io.ktor.http.contentLength
import io.ktor.utils.io.ByteReadChannel
import io.ktor.utils.io.readAvailable
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import org.koin.core.annotation.Factory
import ru.shadowsparky.updater.domain.DownloadProgress
import ru.shadowsparky.updater.domain.UpdateDownloader
import java.io.File
@Factory
class KtorUpdateDownloader(
private val httpClient: HttpClient,
app: Application,
) : UpdateDownloader {
private val destinationPath = "${app.cacheDir.absolutePath}/update"
override fun download(url: String): Flow<DownloadProgress> = flow {
try {
val response = httpClient.get(url)
val contentLength = response.contentLength() ?: -1L
val channel: ByteReadChannel = response.bodyAsChannel()
val directory = File(destinationPath)
if (!directory.exists()) {
directory.mkdirs()
}
val fileName = "update.apk"
val fullPath = "$destinationPath/$fileName"
val file = File(fullPath)
if (file.exists()) file.delete()
val bufferSize = 8192
val buffer = ByteArray(bufferSize)
var downloadedBytes = 0L
file.outputStream().use { outputStream ->
while (!channel.isClosedForRead) {
val read = channel.readAvailable(buffer, 0, buffer.size)
if (read <= 0) break
outputStream.write(buffer, 0, read)
downloadedBytes += read
if (contentLength > 0) {
val progress = downloadedBytes.toFloat() / contentLength.toFloat()
emit(
DownloadProgress.Active(
progress = progress,
downloadedBytes = downloadedBytes,
totalBytes = contentLength
)
)
}
}
}
emit(DownloadProgress.Success(path = file.absolutePath))
} catch (e: Exception) {
emit(DownloadProgress.Error(e))
}
}
}
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path name="updater_isolated_cache" path="update/" />
</paths>
@@ -0,0 +1,18 @@
<resources>
<string name="updater_title_available">Доступно обновление</string>
<string name="updater_title_not_found">Обновления не найдены</string>
<string name="updater_btn_close">Закрыть</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,7 @@
package ru.shadowsparky.updater.domain
interface UpdateInstaller {
suspend fun hasInstallPermission(): Boolean
suspend fun requestInstallPermission()
suspend fun install(path: String)
}
@@ -0,0 +1,87 @@
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?
) {
val hasSupport = fetcher != null && downloader != null && installer != null &&
appConfig?.versionCode != null
fun checkUpdate(): Flow<UpdateState> = flow {
if (appConfig?.versionCode == null || fetcher == null) {
emit(UpdateState.UpToDate)
return@flow
}
emit(UpdateState.Checking)
try {
val currentVersion = appConfig.versionCode ?: 0L
val updateInfo = fetcher.fetch(currentVersion)
val serverVersion = updateInfo.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
}
@@ -0,0 +1,273 @@
package ru.shadowsparky.updater.presentation
import androiddev.libs.updater.updater_client.generated.resources.Res
import androiddev.libs.updater.updater_client.generated.resources.updater_btn_close
import androiddev.libs.updater.updater_client.generated.resources.updater_btn_download
import androiddev.libs.updater.updater_client.generated.resources.updater_btn_grant
import androiddev.libs.updater.updater_client.generated.resources.updater_btn_install
import androiddev.libs.updater.updater_client.generated.resources.updater_btn_retry
import androiddev.libs.updater.updater_client.generated.resources.updater_changelog_empty
import androiddev.libs.updater.updater_client.generated.resources.updater_changelog_title
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_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.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.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.ui.components.Loading
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 }
if (isVisible) {
ModalBottomSheet(
onDismissRequest = { component.reset() },
sheetState = sheetState,
modifier = modifier
) {
AnimatedContent(
targetState = state,
label = "UpdateStateAnimation",
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.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() }
)
is UpdateState.UpToDate -> {
UpdateNotFoundContent(onSkip = { component.reset() })
}
else -> Unit
}
}
}
}
}
@Composable
private fun UpdateCheckingContent() {
Loading(Modifier.fillMaxWidth())
}
@Composable
private 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
private 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
private 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
private 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
private fun UpdateNotFoundContent(
onSkip: () -> Unit,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(text = stringResource(Res.string.updater_title_not_found), style = MaterialTheme.typography.titleLarge)
Button(onClick = onSkip, modifier = Modifier.fillMaxWidth()) {
Text(stringResource(Res.string.updater_btn_close), style = MaterialTheme.typography.labelLarge)
}
}
}
@Composable
private 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 = {})
}
}
@@ -0,0 +1,79 @@
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 org.koin.core.annotation.Factory
import ru.shadowsparky.updater.domain.UpdateInteractor
import ru.shadowsparky.updater.domain.UpdateState
class UpdateComponent(
componentContext: ComponentContext,
private val interactor: UpdateInteractor
) : ComponentContext by componentContext {
val hasSupport = interactor.hasSupport
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) }
}
fun reset() {
_state.value = UpdateState.Idle
}
}
@Factory
class UpdateComponentFactory(private val interactor: UpdateInteractor) {
fun create(componentContext: ComponentContext): UpdateComponent {
return UpdateComponent(componentContext, interactor)
}
}