Compare commits
3 Commits
a5ec0595ad
...
bae665f89d
| Author | SHA1 | Date | |
|---|---|---|---|
| bae665f89d | |||
| 618b9d720e | |||
| ac70f0aac9 |
@@ -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"
|
||||
|
||||
@@ -32,7 +32,7 @@ class RosettaApplication : Application() {
|
||||
TransportManager.init(this)
|
||||
|
||||
// Инициализируем менеджер обновлений (SDU)
|
||||
UpdateManager.init()
|
||||
UpdateManager.init(this)
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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"}, ...]}
|
||||
|
||||
Reference in New Issue
Block a user