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 // Rosetta versioning — bump here on each release
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
val rosettaVersionName = "1.1.8" val rosettaVersionName = "1.1.9"
val rosettaVersionCode = 20 // Increment on each release val rosettaVersionCode = 21 // Increment on each release
android { android {
namespace = "com.rosetta.messenger" namespace = "com.rosetta.messenger"

View File

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

View File

@@ -17,22 +17,23 @@ object ReleaseNotes {
val RELEASE_NOTICE = """ val RELEASE_NOTICE = """
Update v$VERSION_PLACEHOLDER Update v$VERSION_PLACEHOLDER
Полноэкранное фото из медиапикера Интерфейс
- Переработан fullscreen-оверлей: фото открывается поверх чата и перекрывает интерфейс - Исправлен цвет галочки верификации в сайдбаре в зависимости от темы
- Добавлены свайпы влево/вправо для перехода по фото внутри выбранной галереи - Исправлен цвет галочки верификации в профилях и попапах в светлой теме
- Добавлено закрытие свайпом вверх/вниз с плавной анимацией - Исправлен черный gesture navigation bar при fullscreen фото
- Убраны рывки, мигание и лишнее уменьшение фото при перелистывании
Редактирование и отправка Фото и редактор
- Инструменты редактирования фото перенесены в полноэкранный оверлей медиапикера - При рисовании на фото теперь скрываются инпут и лишние оверлеи
- Улучшена пересылка фото через optimistic UI: сообщение отображается сразу - Синхронизировано поведение системных баров в полноэкранном фото-режиме
- Исправлена множественная пересылка сообщений, включая сценарий после смены forwarding options
- Исправлено копирование пересланных сообщений: теперь корректно копируется текст forward/reply
Группы Сообщения
- В списках участников групп отображается только статус online/offline - Логика read-индикаторов внутри диалога приведена к логике чат-листа
- На экране создания группы у текущего пользователя статус отображается как online - Убраны неверные переходы статусов сообщений при read/delivered событиях
- Поиск участников по username сохранен
Обновления приложения
- Загрузка апдейта переведена на системный DownloadManager
- Скачивание обновления продолжается после сворачивания/выхода из приложения
- Прогресс и состояние установки апдейта восстанавливаются после повторного запуска
""".trimIndent() """.trimIndent()
fun getNotice(version: String): String = fun getNotice(version: String): String =

View File

@@ -1,9 +1,11 @@
package com.rosetta.messenger.update package com.rosetta.messenger.update
import android.app.DownloadManager
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Environment
import android.util.Log import android.util.Log
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import com.rosetta.messenger.BuildConfig import com.rosetta.messenger.BuildConfig
@@ -16,9 +18,9 @@ import kotlinx.coroutines.flow.asStateFlow
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
import java.io.File
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import java.io.File
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
/** /**
@@ -33,8 +35,14 @@ import java.util.concurrent.TimeUnit
*/ */
object UpdateManager { object UpdateManager {
private const val TAG = "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()) private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
@Volatile
private var appContext: Context? = null
// ═══ Debug log buffer ═══ // ═══ Debug log buffer ═══
private val _debugLogs = MutableStateFlow<List<String>>(emptyList()) private val _debugLogs = MutableStateFlow<List<String>>(emptyList())
@@ -66,7 +74,13 @@ object UpdateManager {
@Volatile @Volatile
private var latestUpdateInfo: UpdateInfo? = null 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() private val httpClient = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS) .connectTimeout(30, TimeUnit.SECONDS)
@@ -74,10 +88,13 @@ object UpdateManager {
.build() .build()
/** /**
* Инициализация: регистрируем обработчик пакета 0x0A и запрашиваем SDU сервер * Инициализация: регистрируем обработчик пакета 0x0A и восстанавливаем состояние скачивания.
*/ */
fun init() { fun init(context: Context? = null) {
context?.applicationContext?.let { appContext = it }
sduLog("init() called, appVersion=$appVersion") sduLog("init() called, appVersion=$appVersion")
appContext?.let { restorePersistedState(it) }
sduLog("Registering waitPacket(0x0A) listener...") sduLog("Registering waitPacket(0x0A) listener...")
ProtocolManager.waitPacket(0x0A) { packet -> ProtocolManager.waitPacket(0x0A) { packet ->
sduLog("Received packet 0x0A, type=${packet::class.simpleName}") sduLog("Received packet 0x0A, type=${packet::class.simpleName}")
@@ -125,6 +142,20 @@ object UpdateManager {
return@withContext null 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 _updateState.value = UpdateState.Checking
sduLog("State -> Checking") sduLog("State -> Checking")
@@ -175,73 +206,78 @@ object UpdateManager {
} }
/** /**
* Скачать и установить обновление * Скачать обновление или открыть установщик, если APK уже скачан.
*
* Используем системный DownloadManager, чтобы загрузка продолжалась,
* даже если пользователь свернул или закрыл приложение.
*/ */
fun downloadAndInstall(context: Context) { 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 info = latestUpdateInfo ?: return
val sdu = sduServerUrl ?: return val sdu = sduServerUrl ?: return
val url = info.servicePackUrl ?: 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() try {
downloadJob = scope.launch { val request =
_updateState.value = UpdateState.Downloading(0) DownloadManager.Request(Uri.parse(fullUrl))
_downloadProgress.value = 0 .setTitle("Rosetta update ${info.version}")
.setDescription("Downloading update")
try { .setMimeType("application/vnd.android.package-archive")
val fullUrl = if (url.startsWith("http")) url else "$sdu$url" .setAllowedOverMetered(true)
Log.i(TAG, "Downloading update from: $fullUrl") .setAllowedOverRoaming(true)
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE)
val request = Request.Builder().url(fullUrl).get().build() if (appCtx.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) != null) {
val response = httpClient.newCall(request).execute() request.setDestinationInExternalFilesDir(
appCtx,
if (!response.isSuccessful) { Environment.DIRECTORY_DOWNLOADS,
_updateState.value = UpdateState.Error("Download failed: ${response.code}") apkFile.name
return@launch )
} } else {
request.setDestinationUri(Uri.fromFile(apkFile))
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}")
} }
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() { fun cancelDownload() {
downloadJob?.cancel() val ctx = appContext
downloadJob = null val id = activeDownloadId
val info = latestUpdateInfo if (ctx != null && id != null) {
_updateState.value = if (info?.servicePackUrl != null) runCatching {
UpdateState.UpdateAvailable(info.version) else UpdateState.Idle 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 _downloadProgress.value = 0
} }
@@ -284,11 +341,165 @@ object UpdateManager {
* Сбросить состояние * Сбросить состояние
*/ */
fun reset() { fun reset() {
cancelDownload()
_updateState.value = UpdateState.Idle _updateState.value = UpdateState.Idle
_downloadProgress.value = 0 _downloadProgress.value = 0
latestUpdateInfo = null 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 * Парсинг JSON ответа от SDU /updates/all
* Формат: {"items":[{"platform":"android","arch":"x64","version":"1.0.7","downloadUrl":"/kernel/android/x64/Rosetta-1.0.7.apk"}, ...]} * Формат: {"items":[{"platform":"android","arch":"x64","version":"1.0.7","downloadUrl":"/kernel/android/x64/Rosetta-1.0.7.apk"}, ...]}