From ac70f0aac950ddffaa491eb9acb36781d8a1beec Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Sat, 14 Mar 2026 21:57:21 +0700 Subject: [PATCH 1/2] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B0=20=D1=84=D0=BE=D0=BD=D0=BE=D0=B2=D0=B0=D1=8F?= =?UTF-8?q?=20=D0=B7=D0=B0=D0=B3=D1=80=D1=83=D0=B7=D0=BA=D0=B0=20=D0=BE?= =?UTF-8?q?=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B9:=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=B4=D0=BE=D0=BB=D0=B6=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20=D0=B2=D1=8B=D1=85?= =?UTF-8?q?=D0=BE=D0=B4=D0=B0=20=D0=B8=D0=B7=20=D0=BF=D1=80=D0=B8=D0=BB?= =?UTF-8?q?=D0=BE=D0=B6=D0=B5=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../rosetta/messenger/RosettaApplication.kt | 2 +- .../rosetta/messenger/update/UpdateManager.kt | 349 ++++++++++++++---- 2 files changed, 281 insertions(+), 70 deletions(-) diff --git a/app/src/main/java/com/rosetta/messenger/RosettaApplication.kt b/app/src/main/java/com/rosetta/messenger/RosettaApplication.kt index f4a7081..d146805 100644 --- a/app/src/main/java/com/rosetta/messenger/RosettaApplication.kt +++ b/app/src/main/java/com/rosetta/messenger/RosettaApplication.kt @@ -32,7 +32,7 @@ class RosettaApplication : Application() { TransportManager.init(this) // Инициализируем менеджер обновлений (SDU) - UpdateManager.init() + UpdateManager.init(this) } diff --git a/app/src/main/java/com/rosetta/messenger/update/UpdateManager.kt b/app/src/main/java/com/rosetta/messenger/update/UpdateManager.kt index 4b10c83..fd3c5e2 100644 --- a/app/src/main/java/com/rosetta/messenger/update/UpdateManager.kt +++ b/app/src/main/java/com/rosetta/messenger/update/UpdateManager.kt @@ -1,9 +1,11 @@ package com.rosetta.messenger.update +import android.app.DownloadManager import android.content.Context import android.content.Intent import android.net.Uri import android.os.Build +import android.os.Environment import android.util.Log import androidx.core.content.FileProvider import com.rosetta.messenger.BuildConfig @@ -16,9 +18,9 @@ import kotlinx.coroutines.flow.asStateFlow import java.text.SimpleDateFormat import java.util.Date import java.util.Locale +import java.io.File import okhttp3.OkHttpClient import okhttp3.Request -import java.io.File import java.util.concurrent.TimeUnit /** @@ -33,8 +35,14 @@ import java.util.concurrent.TimeUnit */ object UpdateManager { private const val TAG = "UpdateManager" + private const val PREFS_NAME = "rosetta_update_state" + private const val KEY_DOWNLOAD_ID = "download_id" + private const val KEY_APK_PATH = "apk_path" + private const val KEY_APK_VERSION = "apk_version" private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + @Volatile + private var appContext: Context? = null // ═══ Debug log buffer ═══ private val _debugLogs = MutableStateFlow>(emptyList()) @@ -66,7 +74,13 @@ object UpdateManager { @Volatile private var latestUpdateInfo: UpdateInfo? = null - private var downloadJob: Job? = null + private var downloadMonitorJob: Job? = null + @Volatile + private var activeDownloadId: Long? = null + @Volatile + private var activeApkPath: String? = null + @Volatile + private var activeApkVersion: String? = null private val httpClient = OkHttpClient.Builder() .connectTimeout(30, TimeUnit.SECONDS) @@ -74,10 +88,13 @@ object UpdateManager { .build() /** - * Инициализация: регистрируем обработчик пакета 0x0A и запрашиваем SDU сервер + * Инициализация: регистрируем обработчик пакета 0x0A и восстанавливаем состояние скачивания. */ - fun init() { + fun init(context: Context? = null) { + context?.applicationContext?.let { appContext = it } sduLog("init() called, appVersion=$appVersion") + appContext?.let { restorePersistedState(it) } + sduLog("Registering waitPacket(0x0A) listener...") ProtocolManager.waitPacket(0x0A) { packet -> sduLog("Received packet 0x0A, type=${packet::class.simpleName}") @@ -125,6 +142,20 @@ object UpdateManager { return@withContext null } + if (activeDownloadId != null) { + sduLog("checkForUpdates skipped: download is already in progress") + appContext?.let { startDownloadMonitor(it) } + return@withContext latestUpdateInfo + } + val readyPath = activeApkPath + if (_updateState.value is UpdateState.ReadyToInstall && + !readyPath.isNullOrBlank() && + File(readyPath).exists() + ) { + sduLog("checkForUpdates skipped: downloaded APK is ready to install") + return@withContext latestUpdateInfo + } + _updateState.value = UpdateState.Checking sduLog("State -> Checking") @@ -175,73 +206,78 @@ object UpdateManager { } /** - * Скачать и установить обновление + * Скачать обновление или открыть установщик, если APK уже скачан. + * + * Используем системный DownloadManager, чтобы загрузка продолжалась, + * даже если пользователь свернул или закрыл приложение. */ fun downloadAndInstall(context: Context) { + val appCtx = context.applicationContext + appContext = appCtx + restorePersistedState(appCtx) + + val currentState = _updateState.value + val readyPath = activeApkPath + if (currentState is UpdateState.ReadyToInstall && !readyPath.isNullOrBlank()) { + val readyFile = File(readyPath) + if (readyFile.exists()) { + installApk(appCtx, readyFile) + return + } + activeApkPath = null + activeApkVersion = null + persistState(appCtx) + } + + if (activeDownloadId != null) { + startDownloadMonitor(appCtx) + return + } + val info = latestUpdateInfo ?: return val sdu = sduServerUrl ?: return val url = info.servicePackUrl ?: return + val fullUrl = if (url.startsWith("http")) url else "$sdu$url" + val apkFile = buildApkFile(appCtx, info.version) + if (apkFile.exists()) { + apkFile.delete() + } - downloadJob?.cancel() - downloadJob = scope.launch { - _updateState.value = UpdateState.Downloading(0) - _downloadProgress.value = 0 - - try { - val fullUrl = if (url.startsWith("http")) url else "$sdu$url" - Log.i(TAG, "Downloading update from: $fullUrl") - - val request = Request.Builder().url(fullUrl).get().build() - val response = httpClient.newCall(request).execute() - - if (!response.isSuccessful) { - _updateState.value = UpdateState.Error("Download failed: ${response.code}") - return@launch - } - - val responseBody = response.body ?: run { - _updateState.value = UpdateState.Error("Empty download response") - return@launch - } - - val totalBytes = responseBody.contentLength() - val apkFile = File(context.cacheDir, "Rosetta-${info.version}.apk") - - responseBody.byteStream().use { input -> - apkFile.outputStream().use { output -> - val buffer = ByteArray(8192) - var bytesRead: Long = 0 - var read: Int - - while (input.read(buffer).also { read = it } != -1) { - if (!isActive) { - apkFile.delete() - return@launch - } - output.write(buffer, 0, read) - bytesRead += read - if (totalBytes > 0) { - val progress = ((bytesRead * 100) / totalBytes).toInt() - _downloadProgress.value = progress - _updateState.value = UpdateState.Downloading(progress) - } - } - } - } - - Log.i(TAG, "Download complete: ${apkFile.absolutePath}") - _updateState.value = UpdateState.ReadyToInstall(apkFile.absolutePath) - - // Запускаем установку APK - installApk(context, apkFile) - - } catch (e: CancellationException) { - Log.i(TAG, "Download cancelled") - _updateState.value = UpdateState.UpdateAvailable(info.version) - } catch (e: Exception) { - Log.e(TAG, "Download failed", e) - _updateState.value = UpdateState.Error("Download failed: ${e.message}") + try { + val request = + DownloadManager.Request(Uri.parse(fullUrl)) + .setTitle("Rosetta update ${info.version}") + .setDescription("Downloading update") + .setMimeType("application/vnd.android.package-archive") + .setAllowedOverMetered(true) + .setAllowedOverRoaming(true) + .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE) + if (appCtx.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) != null) { + request.setDestinationInExternalFilesDir( + appCtx, + Environment.DIRECTORY_DOWNLOADS, + apkFile.name + ) + } else { + request.setDestinationUri(Uri.fromFile(apkFile)) } + + val downloadManager = + appCtx.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager + val downloadId = downloadManager.enqueue(request) + activeDownloadId = downloadId + activeApkPath = apkFile.absolutePath + activeApkVersion = info.version + persistState(appCtx) + + _downloadProgress.value = 0 + _updateState.value = UpdateState.Downloading(0) + sduLog("DownloadManager enqueue id=$downloadId, file=${apkFile.absolutePath}") + startDownloadMonitor(appCtx) + } catch (e: Exception) { + Log.e(TAG, "Failed to enqueue update download", e) + sduLog("EXCEPTION enqueue: ${e::class.simpleName}: ${e.message}") + _updateState.value = UpdateState.Error("Failed to start download: ${e.message}") } } @@ -272,11 +308,32 @@ object UpdateManager { * Отменить текущее скачивание */ fun cancelDownload() { - downloadJob?.cancel() - downloadJob = null - val info = latestUpdateInfo - _updateState.value = if (info?.servicePackUrl != null) - UpdateState.UpdateAvailable(info.version) else UpdateState.Idle + val ctx = appContext + val id = activeDownloadId + if (ctx != null && id != null) { + runCatching { + val dm = ctx.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager + dm.remove(id) + } + } + + downloadMonitorJob?.cancel() + downloadMonitorJob = null + activeDownloadId = null + activeApkPath?.let { path -> + runCatching { + val file = File(path) + if (file.exists()) file.delete() + } + } + activeApkPath = null + activeApkVersion = null + if (ctx != null) { + persistState(ctx) + } + + val version = latestUpdateInfo?.version + _updateState.value = if (version != null) UpdateState.UpdateAvailable(version) else UpdateState.Idle _downloadProgress.value = 0 } @@ -284,11 +341,165 @@ object UpdateManager { * Сбросить состояние */ fun reset() { + cancelDownload() _updateState.value = UpdateState.Idle _downloadProgress.value = 0 latestUpdateInfo = null } + private fun prefs(context: Context) = + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + private fun persistState(context: Context) { + prefs(context) + .edit() + .putLong(KEY_DOWNLOAD_ID, activeDownloadId ?: -1L) + .putString(KEY_APK_PATH, activeApkPath) + .putString(KEY_APK_VERSION, activeApkVersion) + .apply() + } + + private fun setUpdateAvailableOrIdleState() { + val version = latestUpdateInfo?.version ?: activeApkVersion + _updateState.value = if (!version.isNullOrBlank()) { + UpdateState.UpdateAvailable(version) + } else { + UpdateState.Idle + } + } + + private fun restorePersistedState(context: Context) { + val preferences = prefs(context) + val restoredId = preferences.getLong(KEY_DOWNLOAD_ID, -1L).takeIf { it >= 0L } + val restoredPath = preferences.getString(KEY_APK_PATH, null)?.takeIf { it.isNotBlank() } + val restoredVersion = preferences.getString(KEY_APK_VERSION, null)?.takeIf { it.isNotBlank() } + + activeDownloadId = restoredId + activeApkPath = restoredPath + activeApkVersion = restoredVersion + + if (restoredId != null) { + sduLog("Restored active update download id=$restoredId") + if (_updateState.value !is UpdateState.Downloading) { + _updateState.value = UpdateState.Downloading(_downloadProgress.value.coerceIn(0, 100)) + } + startDownloadMonitor(context) + return + } + + if (!restoredPath.isNullOrBlank()) { + val apk = File(restoredPath) + if (apk.exists()) { + _downloadProgress.value = 100 + _updateState.value = UpdateState.ReadyToInstall(apk.absolutePath) + } else { + activeApkPath = null + activeApkVersion = null + persistState(context) + } + } + } + + private fun buildApkFile(context: Context, version: String): File { + val updatesDir = + context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) + ?: File(context.filesDir, "updates") + if (!updatesDir.exists()) { + updatesDir.mkdirs() + } + return File(updatesDir, "Rosetta-$version.apk") + } + + private fun startDownloadMonitor(context: Context) { + val downloadId = activeDownloadId ?: return + val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager + downloadMonitorJob?.cancel() + downloadMonitorJob = + scope.launch { + while (isActive) { + val query = DownloadManager.Query().setFilterById(downloadId) + downloadManager.query(query).use { cursor -> + if (cursor == null || !cursor.moveToFirst()) { + activeDownloadId = null + persistState(context) + setUpdateAvailableOrIdleState() + _downloadProgress.value = 0 + return@launch + } + + val status = + cursor.getInt(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS)) + val downloadedBytes = + cursor.getLong( + cursor.getColumnIndexOrThrow( + DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR + ) + ) + val totalBytes = + cursor.getLong( + cursor.getColumnIndexOrThrow( + DownloadManager.COLUMN_TOTAL_SIZE_BYTES + ) + ) + + when (status) { + DownloadManager.STATUS_PENDING, + DownloadManager.STATUS_RUNNING, + DownloadManager.STATUS_PAUSED -> { + val progress = + if (totalBytes > 0L) { + ((downloadedBytes * 100L) / totalBytes).toInt() + .coerceIn(0, 100) + } else { + _downloadProgress.value.coerceIn(0, 99) + } + _downloadProgress.value = progress + _updateState.value = UpdateState.Downloading(progress) + } + + DownloadManager.STATUS_SUCCESSFUL -> { + val localUri = + cursor.getString( + cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_LOCAL_URI) + ) + val resolvedPath = + runCatching { Uri.parse(localUri).path }.getOrNull() + ?.takeIf { !it.isNullOrBlank() } + ?: activeApkPath + if (!resolvedPath.isNullOrBlank() && File(resolvedPath).exists()) { + activeApkPath = resolvedPath + activeDownloadId = null + persistState(context) + _downloadProgress.value = 100 + _updateState.value = UpdateState.ReadyToInstall(resolvedPath) + } else { + activeDownloadId = null + persistState(context) + _updateState.value = + UpdateState.Error("Downloaded file is missing") + } + return@launch + } + + DownloadManager.STATUS_FAILED -> { + val reason = + cursor.getInt( + cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_REASON) + ) + activeDownloadId = null + persistState(context) + _updateState.value = + UpdateState.Error("Download failed (reason=$reason)") + return@launch + } + } + } + + delay(500L) + } + } + } + /** * Парсинг JSON ответа от SDU /updates/all * Формат: {"items":[{"platform":"android","arch":"x64","version":"1.0.7","downloadUrl":"/kernel/android/x64/Rosetta-1.0.7.apk"}, ...]} From 618b9d720e26faaf63fe02e919820322775a8417 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Sat, 14 Mar 2026 22:04:38 +0700 Subject: [PATCH 2/2] =?UTF-8?q?=D0=9F=D0=BE=D0=B4=D0=BD=D1=8F=D1=82=20?= =?UTF-8?q?=D1=80=D0=B5=D0=BB=D0=B8=D0=B7=20=D0=B4=D0=BE=201.1.9=20=D0=B8?= =?UTF-8?q?=20=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B=20rel?= =?UTF-8?q?ease=20notes=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=201.1.8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 4 +-- .../rosetta/messenger/data/ReleaseNotes.kt | 29 ++++++++++--------- 2 files changed, 17 insertions(+), 16 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 128e30f..0c3f303 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -23,8 +23,8 @@ val gitShortSha = safeGitOutput("rev-parse", "--short", "HEAD") ?: "unknown" // ═══════════════════════════════════════════════════════════ // Rosetta versioning — bump here on each release // ═══════════════════════════════════════════════════════════ -val rosettaVersionName = "1.1.8" -val rosettaVersionCode = 20 // Increment on each release +val rosettaVersionName = "1.1.9" +val rosettaVersionCode = 21 // Increment on each release android { namespace = "com.rosetta.messenger" diff --git a/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt b/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt index e476866..8e3a393 100644 --- a/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt +++ b/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt @@ -17,22 +17,23 @@ object ReleaseNotes { val RELEASE_NOTICE = """ Update v$VERSION_PLACEHOLDER - Полноэкранное фото из медиапикера - - Переработан fullscreen-оверлей: фото открывается поверх чата и перекрывает интерфейс - - Добавлены свайпы влево/вправо для перехода по фото внутри выбранной галереи - - Добавлено закрытие свайпом вверх/вниз с плавной анимацией - - Убраны рывки, мигание и лишнее уменьшение фото при перелистывании + Интерфейс + - Исправлен цвет галочки верификации в сайдбаре в зависимости от темы + - Исправлен цвет галочки верификации в профилях и попапах в светлой теме + - Исправлен черный gesture navigation bar при fullscreen фото - Редактирование и отправка - - Инструменты редактирования фото перенесены в полноэкранный оверлей медиапикера - - Улучшена пересылка фото через optimistic UI: сообщение отображается сразу - - Исправлена множественная пересылка сообщений, включая сценарий после смены forwarding options - - Исправлено копирование пересланных сообщений: теперь корректно копируется текст forward/reply + Фото и редактор + - При рисовании на фото теперь скрываются инпут и лишние оверлеи + - Синхронизировано поведение системных баров в полноэкранном фото-режиме - Группы - - В списках участников групп отображается только статус online/offline - - На экране создания группы у текущего пользователя статус отображается как online - - Поиск участников по username сохранен + Сообщения + - Логика read-индикаторов внутри диалога приведена к логике чат-листа + - Убраны неверные переходы статусов сообщений при read/delivered событиях + + Обновления приложения + - Загрузка апдейта переведена на системный DownloadManager + - Скачивание обновления продолжается после сворачивания/выхода из приложения + - Прогресс и состояние установки апдейта восстанавливаются после повторного запуска """.trimIndent() fun getNotice(version: String): String =