feat(update): авто-обновление APK (как в nkVPN)

UpdateManager: на старте дёргает /app-version, при version_code > BuildConfig.
VERSION_CODE показывает UpdateDialog. Скачивает APK во внутр. Download, сверяет
SHA-256 (защита от подмены по HTTP/битой загрузки), ставит через системный
установщик (FileProvider). force_update делает диалог необкрываемым. versionCode
1→2, versionName 1.0→1.1. Добавлено право REQUEST_INSTALL_PACKAGES, путь в file_paths.
This commit is contained in:
nk
2026-06-06 20:21:55 +03:00
parent ed926e0a9d
commit 0c01eaab2d
6 changed files with 323 additions and 2 deletions

View File

@@ -15,8 +15,8 @@ android {
applicationId = "com.radiola"
minSdk = 26
targetSdk = 34
versionCode = 1
versionName = "1.0"
versionCode = 2
versionName = "1.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {

View File

@@ -11,6 +11,8 @@
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<uses-permission android:name="android.permission.USE_EXACT_ALARM" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<!-- Авто-обновление: установка скачанного APK -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application
android:name=".RadiolaApplication"

View File

@@ -76,6 +76,18 @@ class MainActivity : ComponentActivity() {
val isRecording by playerViewModel.isRecording.collectAsState()
val isLoggedIn by tokenDataStore.isLoggedIn.collectAsState(initial = false)
// --- Авто-обновление: проверяем версию на старте, показываем диалог ---
var pendingUpdate by remember { mutableStateOf<com.radiola.update.VersionInfo?>(null) }
var updateState by remember { mutableStateOf(com.radiola.ui.update.UpdateState.IDLE) }
var updateProgress by remember { mutableIntStateOf(0) }
var updateError by remember { mutableStateOf<String?>(null) }
var downloadedApk by remember { mutableStateOf<java.io.File?>(null) }
val updateScope = rememberCoroutineScope()
LaunchedEffect(Unit) {
val info = com.radiola.update.UpdateManager.checkUpdate() ?: return@LaunchedEffect
if (info.versionCode > BuildConfig.VERSION_CODE) pendingUpdate = info
}
// Авторизация необязательна — всегда стартуем со станций.
// Вход доступен из Настроек.
val startDestination = NavDestinations.Stations.route
@@ -222,6 +234,48 @@ class MainActivity : ComponentActivity() {
)
}
}
pendingUpdate?.let { update ->
com.radiola.ui.update.UpdateDialog(
versionName = update.versionName,
notes = update.notes,
isForce = update.forceUpdate,
state = updateState,
progress = updateProgress,
errorMsg = updateError,
onPrimary = {
when (updateState) {
com.radiola.ui.update.UpdateState.DOWNLOADED ->
downloadedApk?.let {
com.radiola.update.UpdateManager.installApk(this@MainActivity, it)
}
com.radiola.ui.update.UpdateState.DOWNLOADING -> {}
else -> {
updateState = com.radiola.ui.update.UpdateState.DOWNLOADING
updateProgress = 0
updateError = null
updateScope.launch {
val f = com.radiola.update.UpdateManager.downloadApk(
this@MainActivity, update.downloadUrl
) { updateProgress = it }
if (f != null &&
com.radiola.update.UpdateManager.verifySha256(f, update.sha256)
) {
downloadedApk = f
updateState = com.radiola.ui.update.UpdateState.DOWNLOADED
com.radiola.update.UpdateManager.installApk(this@MainActivity, f)
} else {
updateError = if (f == null) "Не удалось загрузить обновление"
else "Контрольная сумма не совпала"
updateState = com.radiola.ui.update.UpdateState.ERROR
}
}
}
}
},
onDismiss = { if (!update.forceUpdate) pendingUpdate = null }
)
}
}
}
}

View File

@@ -0,0 +1,125 @@
package com.radiola.ui.update
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.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.radiola.ui.theme.RadiolaTheme
/** Стадия процесса обновления. */
enum class UpdateState { IDLE, DOWNLOADING, DOWNLOADED, ERROR }
/**
* Диалог «доступно обновление». При force_update нельзя закрыть («Позже» скрыта,
* тап мимо игнорируется). Кнопка ведёт по стадиям: Обновить → (прогресс) → Установить.
*/
@Composable
fun UpdateDialog(
versionName: String,
notes: String?,
isForce: Boolean,
state: UpdateState,
progress: Int,
errorMsg: String?,
onPrimary: () -> Unit,
onDismiss: () -> Unit
) {
val colors = RadiolaTheme.colors
val dismissable = !isForce && state != UpdateState.DOWNLOADING
AlertDialog(
onDismissRequest = { if (dismissable) onDismiss() },
containerColor = colors.elevated,
title = {
Text(
"Доступно обновление",
color = colors.textPrimary,
style = MaterialTheme.typography.titleLarge
)
},
text = {
Column {
Text(
"Версия $versionName",
color = colors.accent,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
if (!notes.isNullOrBlank()) {
Spacer(Modifier.height(8.dp))
Text(
notes,
color = colors.textSecondary,
style = MaterialTheme.typography.bodyMedium
)
}
when (state) {
UpdateState.DOWNLOADING -> {
Spacer(Modifier.height(14.dp))
LinearProgressIndicator(
progress = { progress / 100f },
modifier = Modifier.fillMaxWidth(),
color = colors.accent,
trackColor = colors.surface2
)
Spacer(Modifier.height(6.dp))
Text(
"Загрузка… $progress%",
color = colors.textMuted,
style = MaterialTheme.typography.labelMedium
)
}
UpdateState.ERROR -> {
Spacer(Modifier.height(10.dp))
Text(
errorMsg ?: "Ошибка обновления",
color = colors.live,
style = MaterialTheme.typography.bodyMedium
)
}
else -> {}
}
}
},
confirmButton = {
Button(
onClick = onPrimary,
enabled = state != UpdateState.DOWNLOADING,
colors = ButtonDefaults.buttonColors(
containerColor = colors.accent,
contentColor = colors.bgBase
)
) {
Text(
when (state) {
UpdateState.DOWNLOADED -> "Установить"
UpdateState.DOWNLOADING -> "Загрузка…"
UpdateState.ERROR -> "Повторить"
UpdateState.IDLE -> "Обновить"
},
fontWeight = FontWeight.SemiBold
)
}
},
dismissButton = {
if (!isForce) {
TextButton(
onClick = onDismiss,
colors = ButtonDefaults.textButtonColors(contentColor = colors.textSecondary)
) {
Text("Позже")
}
}
}
)
}

View File

@@ -0,0 +1,139 @@
package com.radiola.update
import android.content.Context
import android.content.Intent
import android.os.Environment
import android.util.Log
import androidx.core.content.FileProvider
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONObject
import java.io.File
import java.security.MessageDigest
import java.util.concurrent.TimeUnit
/** Описание последней версии из манифеста бэкенда (/app-version). */
data class VersionInfo(
val versionName: String,
val versionCode: Int,
val downloadUrl: String,
val forceUpdate: Boolean,
val sha256: String?,
val notes: String?
)
/**
* Авто-обновление APK (по образцу nkVPN). Бэкенд отдаёт манифест /app-version с
* version_code последней сборки и ссылкой на APK; если он новее установленного —
* скачиваем во внутреннюю папку, проверяем SHA-256 и запускаем системный установщик
* через FileProvider. Так пользователю не нужно заходить в маркет.
*/
object UpdateManager {
private const val TAG = "radiOLA/Update"
private const val BASE_URL = "http://121.127.37.212:3000"
private val client = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(120, TimeUnit.SECONDS)
.build()
/** Запрашивает манифест версии. null — сеть/парсинг не удались (молча, не мешаем работе). */
suspend fun checkUpdate(): VersionInfo? = withContext(Dispatchers.IO) {
try {
val request = Request.Builder().url("$BASE_URL/app-version").build()
client.newCall(request).execute().use { resp ->
if (!resp.isSuccessful) return@withContext null
val body = resp.body?.string() ?: return@withContext null
val a = JSONObject(body).getJSONObject("android")
VersionInfo(
versionName = a.getString("version_name"),
versionCode = a.getInt("version_code"),
downloadUrl = a.getString("download_url"),
forceUpdate = a.optBoolean("force_update", false),
sha256 = a.optString("sha256", "").ifBlank { null },
notes = a.optString("notes", "").ifBlank { null }
)
}
} catch (e: Exception) {
Log.w(TAG, "checkUpdate failed: ${e.message}")
null
}
}
/** Скачивает APK во внутреннюю Download-папку приложения, шлёт прогресс 0..100. */
suspend fun downloadApk(
context: Context,
url: String,
onProgress: (Int) -> Unit
): File? = withContext(Dispatchers.IO) {
try {
val request = Request.Builder().url(url).build()
client.newCall(request).execute().use { resp ->
if (!resp.isSuccessful) return@withContext null
val total = resp.body?.contentLength() ?: -1L
val dir = context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)
val file = File(dir, "radiola-update.apk")
var downloaded = 0L
resp.body?.byteStream()?.use { input ->
file.outputStream().use { output ->
val buffer = ByteArray(8192)
var bytes: Int
while (input.read(buffer).also { bytes = it } != -1) {
output.write(buffer, 0, bytes)
downloaded += bytes
if (total > 0) onProgress((downloaded * 100 / total).toInt())
}
}
}
file
}
} catch (e: Exception) {
Log.e(TAG, "downloadApk failed: ${e.message}")
null
}
}
/**
* Сверяет SHA-256 скачанного APK с манифестом — защита от подмены (APK тянем по
* HTTP) и битой загрузки. Если суммы в манифесте нет — пропускаем (true).
*/
fun verifySha256(file: File, expected: String?): Boolean {
if (expected.isNullOrBlank()) return true
return try {
val digest = MessageDigest.getInstance("SHA-256")
file.inputStream().use { input ->
val buffer = ByteArray(8192)
var bytes: Int
while (input.read(buffer).also { bytes = it } != -1) {
digest.update(buffer, 0, bytes)
}
}
val hex = digest.digest().joinToString("") { "%02x".format(it) }
hex.equals(expected.trim(), ignoreCase = true)
} catch (e: Exception) {
Log.e(TAG, "verifySha256 failed: ${e.message}")
false
}
}
/** Запускает системный установщик APK через FileProvider. */
fun installApk(context: Context, file: File) {
try {
val uri = FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
file
)
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "application/vnd.android.package-archive")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
} catch (e: Exception) {
Log.e(TAG, "installApk failed: ${e.message}")
}
}
}

View File

@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<external-files-path name="recordings" path="Music/radiola_recordings/" />
<external-files-path name="updates" path="Download/" />
</paths>