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:
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
125
app/src/main/java/com/radiola/ui/update/UpdateDialog.kt
Normal file
125
app/src/main/java/com/radiola/ui/update/UpdateDialog.kt
Normal 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("Позже")
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
139
app/src/main/java/com/radiola/update/UpdateManager.kt
Normal file
139
app/src/main/java/com/radiola/update/UpdateManager.kt
Normal 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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user