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:
@@ -15,8 +15,8 @@ android {
|
|||||||
applicationId = "com.radiola"
|
applicationId = "com.radiola"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 34
|
targetSdk = 34
|
||||||
versionCode = 1
|
versionCode = 2
|
||||||
versionName = "1.0"
|
versionName = "1.1"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
|
|||||||
@@ -11,6 +11,8 @@
|
|||||||
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
|
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
|
||||||
<uses-permission android:name="android.permission.USE_EXACT_ALARM" />
|
<uses-permission android:name="android.permission.USE_EXACT_ALARM" />
|
||||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||||
|
<!-- Авто-обновление: установка скачанного APK -->
|
||||||
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".RadiolaApplication"
|
android:name=".RadiolaApplication"
|
||||||
|
|||||||
@@ -76,6 +76,18 @@ class MainActivity : ComponentActivity() {
|
|||||||
val isRecording by playerViewModel.isRecording.collectAsState()
|
val isRecording by playerViewModel.isRecording.collectAsState()
|
||||||
val isLoggedIn by tokenDataStore.isLoggedIn.collectAsState(initial = false)
|
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
|
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}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<paths>
|
<paths>
|
||||||
<external-files-path name="recordings" path="Music/radiola_recordings/" />
|
<external-files-path name="recordings" path="Music/radiola_recordings/" />
|
||||||
|
<external-files-path name="updates" path="Download/" />
|
||||||
</paths>
|
</paths>
|
||||||
|
|||||||
Reference in New Issue
Block a user