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 @@ +