Merge branch 'dev'
All checks were successful
Android Kernel Build / build (push) Successful in 16h26m43s

This commit is contained in:
2026-03-14 22:04:49 +07:00
4 changed files with 298 additions and 86 deletions

View File

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

View File

@@ -32,7 +32,7 @@ class RosettaApplication : Application() {
TransportManager.init(this)
// Инициализируем менеджер обновлений (SDU)
UpdateManager.init()
UpdateManager.init(this)
}

View File

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

View File

@@ -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<List<String>>(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"}, ...]}