diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 4c63578..4819f23 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -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 {
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index f22d796..548f059 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -11,6 +11,8 @@
+
+
(null) }
+ var updateState by remember { mutableStateOf(com.radiola.ui.update.UpdateState.IDLE) }
+ var updateProgress by remember { mutableIntStateOf(0) }
+ var updateError by remember { mutableStateOf(null) }
+ var downloadedApk by remember { mutableStateOf(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 }
+ )
+ }
}
}
}
diff --git a/app/src/main/java/com/radiola/ui/update/UpdateDialog.kt b/app/src/main/java/com/radiola/ui/update/UpdateDialog.kt
new file mode 100644
index 0000000..f94a843
--- /dev/null
+++ b/app/src/main/java/com/radiola/ui/update/UpdateDialog.kt
@@ -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("Позже")
+ }
+ }
+ }
+ )
+}
diff --git a/app/src/main/java/com/radiola/update/UpdateManager.kt b/app/src/main/java/com/radiola/update/UpdateManager.kt
new file mode 100644
index 0000000..680c32f
--- /dev/null
+++ b/app/src/main/java/com/radiola/update/UpdateManager.kt
@@ -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}")
+ }
+ }
+}
diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml
index e2ae1f3..f08d7a4 100644
--- a/app/src/main/res/xml/file_paths.xml
+++ b/app/src/main/res/xml/file_paths.xml
@@ -1,4 +1,5 @@
+