feat: Update ChatDetailScreen and ChatsListScreen for improved UI responsiveness and consistency; add custom verified badge icon
This commit is contained in:
424
app/src/main/java/com/rosetta/messenger/update/UpdateManager.kt
Normal file
424
app/src/main/java/com/rosetta/messenger/update/UpdateManager.kt
Normal file
@@ -0,0 +1,424 @@
|
||||
package com.rosetta.messenger.update
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.core.content.FileProvider
|
||||
import com.rosetta.messenger.BuildConfig
|
||||
import com.rosetta.messenger.network.PacketRequestUpdate
|
||||
import com.rosetta.messenger.network.ProtocolManager
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.io.File
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Менеджер обновлений приложения через SDU (Software Distribution Unit).
|
||||
*
|
||||
* Аналог UpdateProvider из desktop-приложения.
|
||||
* Поток:
|
||||
* 1. После аутентификации отправляем PacketRequestUpdate (0x0A) для получения URL SDU сервера
|
||||
* 2. Запрашиваем /updates/get?kernel={version}&platform=android&arch={arch} на SDU
|
||||
* 3. Если есть обновление — показываем пользователю
|
||||
* 4. Скачиваем APK и открываем установщик
|
||||
*/
|
||||
object UpdateManager {
|
||||
private const val TAG = "UpdateManager"
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
// ═══ Debug log buffer ═══
|
||||
private val _debugLogs = MutableStateFlow<List<String>>(emptyList())
|
||||
val debugLogs: StateFlow<List<String>> = _debugLogs.asStateFlow()
|
||||
private val timeFmt = SimpleDateFormat("HH:mm:ss.SSS", Locale.US)
|
||||
|
||||
private fun sduLog(msg: String) {
|
||||
Log.i(TAG, msg)
|
||||
val ts = timeFmt.format(Date())
|
||||
val line = "[$ts] $msg"
|
||||
_debugLogs.value = (_debugLogs.value + line).takeLast(200)
|
||||
}
|
||||
|
||||
// SDU server URL, получаем от основного сервера через PacketRequestUpdate
|
||||
@Volatile
|
||||
private var sduServerUrl: String? = null
|
||||
|
||||
// Текущая версия приложения
|
||||
val appVersion: String = BuildConfig.VERSION_NAME
|
||||
|
||||
// Состояния обновления
|
||||
private val _updateState = MutableStateFlow<UpdateState>(UpdateState.Idle)
|
||||
val updateState: StateFlow<UpdateState> = _updateState.asStateFlow()
|
||||
|
||||
private val _downloadProgress = MutableStateFlow(0)
|
||||
val downloadProgress: StateFlow<Int> = _downloadProgress.asStateFlow()
|
||||
|
||||
// Информация о доступном обновлении
|
||||
@Volatile
|
||||
private var latestUpdateInfo: UpdateInfo? = null
|
||||
|
||||
private var downloadJob: Job? = null
|
||||
|
||||
private val httpClient = OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(60, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
/**
|
||||
* Инициализация: регистрируем обработчик пакета 0x0A и запрашиваем SDU сервер
|
||||
*/
|
||||
fun init() {
|
||||
sduLog("init() called, appVersion=$appVersion")
|
||||
sduLog("Registering waitPacket(0x0A) listener...")
|
||||
ProtocolManager.waitPacket(0x0A) { packet ->
|
||||
sduLog("Received packet 0x0A, type=${packet::class.simpleName}")
|
||||
if (packet is PacketRequestUpdate) {
|
||||
val server = packet.updateServer
|
||||
sduLog("PacketRequestUpdate.updateServer='$server'")
|
||||
if (server.isNotEmpty()) {
|
||||
sduServerUrl = server
|
||||
sduLog("SDU server set: $server")
|
||||
scope.launch {
|
||||
sduLog("Auto-checking for updates...")
|
||||
checkForUpdates()
|
||||
}
|
||||
} else {
|
||||
sduLog("WARNING: updateServer is empty!")
|
||||
}
|
||||
} else {
|
||||
sduLog("WARNING: packet is not PacketRequestUpdate, got ${packet::class.simpleName}")
|
||||
}
|
||||
}
|
||||
sduLog("init() complete")
|
||||
}
|
||||
|
||||
/**
|
||||
* Запросить URL SDU сервера у основного сервера
|
||||
*/
|
||||
fun requestSduServer() {
|
||||
sduLog("requestSduServer() — sending PacketRequestUpdate with empty updateServer")
|
||||
val packet = PacketRequestUpdate().apply {
|
||||
updateServer = ""
|
||||
}
|
||||
ProtocolManager.sendPacket(packet)
|
||||
sduLog("PacketRequestUpdate sent")
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверить наличие обновлений на SDU сервере
|
||||
*/
|
||||
suspend fun checkForUpdates(): UpdateInfo? = withContext(Dispatchers.IO) {
|
||||
sduLog("checkForUpdates() called")
|
||||
val sdu = sduServerUrl
|
||||
if (sdu == null) {
|
||||
sduLog("ERROR: sduServerUrl is null! Requesting again...")
|
||||
requestSduServer()
|
||||
return@withContext null
|
||||
}
|
||||
|
||||
_updateState.value = UpdateState.Checking
|
||||
sduLog("State -> Checking")
|
||||
|
||||
try {
|
||||
val arch = Build.SUPPORTED_ABIS.firstOrNull() ?: "arm64-v8a"
|
||||
val url = "$sdu/updates/all?app=$appVersion&kernel=$appVersion&platform=android&arch=$arch"
|
||||
sduLog("HTTP GET: $url")
|
||||
|
||||
val request = Request.Builder().url(url).get().build()
|
||||
val response = httpClient.newCall(request).execute()
|
||||
|
||||
sduLog("HTTP response: ${response.code} ${response.message}")
|
||||
|
||||
if (!response.isSuccessful) {
|
||||
sduLog("ERROR: SDU returned ${response.code}")
|
||||
_updateState.value = UpdateState.Error("Server returned ${response.code}")
|
||||
return@withContext null
|
||||
}
|
||||
|
||||
val body = response.body?.string() ?: run {
|
||||
sduLog("ERROR: Empty response body")
|
||||
_updateState.value = UpdateState.Error("Empty response")
|
||||
return@withContext null
|
||||
}
|
||||
|
||||
sduLog("Response body: $body")
|
||||
val info = parseUpdateResponse(body)
|
||||
latestUpdateInfo = info
|
||||
sduLog("Parsed: version=${info.version}, servicePackUrl=${info.servicePackUrl}, kernelUpdateRequired=${info.kernelUpdateRequired}")
|
||||
|
||||
when {
|
||||
info.servicePackUrl != null -> {
|
||||
sduLog("State -> UpdateAvailable(${info.version})")
|
||||
_updateState.value = UpdateState.UpdateAvailable(info.version)
|
||||
}
|
||||
else -> {
|
||||
sduLog("State -> UpToDate")
|
||||
_updateState.value = UpdateState.UpToDate
|
||||
}
|
||||
}
|
||||
|
||||
return@withContext info
|
||||
} catch (e: Exception) {
|
||||
sduLog("EXCEPTION: ${e::class.simpleName}: ${e.message}")
|
||||
_updateState.value = UpdateState.Error(e.message ?: "Unknown error")
|
||||
return@withContext null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Скачать и установить обновление
|
||||
*/
|
||||
fun downloadAndInstall(context: Context) {
|
||||
val info = latestUpdateInfo ?: return
|
||||
val sdu = sduServerUrl ?: return
|
||||
val url = info.servicePackUrl ?: return
|
||||
|
||||
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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Открыть системный установщик APK
|
||||
*/
|
||||
private fun installApk(context: Context, apkFile: File) {
|
||||
try {
|
||||
val uri: Uri = FileProvider.getUriForFile(
|
||||
context,
|
||||
"${context.packageName}.provider",
|
||||
apkFile
|
||||
)
|
||||
|
||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(uri, "application/vnd.android.package-archive")
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
}
|
||||
|
||||
context.startActivity(intent)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to launch installer", e)
|
||||
_updateState.value = UpdateState.Error("Failed to open installer: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Отменить текущее скачивание
|
||||
*/
|
||||
fun cancelDownload() {
|
||||
downloadJob?.cancel()
|
||||
downloadJob = null
|
||||
val info = latestUpdateInfo
|
||||
_updateState.value = if (info?.servicePackUrl != null)
|
||||
UpdateState.UpdateAvailable(info.version) else UpdateState.Idle
|
||||
_downloadProgress.value = 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Сбросить состояние
|
||||
*/
|
||||
fun reset() {
|
||||
_updateState.value = UpdateState.Idle
|
||||
_downloadProgress.value = 0
|
||||
latestUpdateInfo = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Парсинг JSON ответа от SDU /updates/all
|
||||
* Формат: {"items":[{"platform":"android","arch":"x64","version":"1.0.7","downloadUrl":"/kernel/android/x64/Rosetta-1.0.7.apk"}, ...]}
|
||||
* Ищем запись с platform=android, берём самую новую версию
|
||||
*/
|
||||
private fun parseUpdateResponse(json: String): UpdateInfo {
|
||||
val obj = org.json.JSONObject(json)
|
||||
val items = obj.optJSONArray("items")
|
||||
|
||||
if (items == null || items.length() == 0) {
|
||||
sduLog("No items in response, trying legacy format...")
|
||||
// Fallback на старый формат /updates/get
|
||||
return UpdateInfo(
|
||||
version = obj.optString("version", ""),
|
||||
platform = obj.optString("platform", ""),
|
||||
arch = obj.optString("arch", ""),
|
||||
kernelUpdateRequired = obj.optBoolean("kernel_update_required", false),
|
||||
servicePackUrl = obj.optString("service_pack_url", "").takeIf { it.isNotEmpty() && it != "null" },
|
||||
kernelUrl = obj.optString("kernel_url", "").takeIf { it.isNotEmpty() && it != "null" }
|
||||
)
|
||||
}
|
||||
|
||||
// Ищем все android-записи
|
||||
var bestVersion: String? = null
|
||||
var bestDownloadUrl: String? = null
|
||||
var bestArch: String? = null
|
||||
|
||||
val deviceArch = Build.SUPPORTED_ABIS.firstOrNull() ?: "arm64-v8a"
|
||||
// Маппинг ABI -> SDU arch
|
||||
val sduArch = when {
|
||||
deviceArch.contains("arm64") -> "arm64"
|
||||
deviceArch.contains("arm") -> "arm"
|
||||
deviceArch.contains("x86_64") -> "x64"
|
||||
deviceArch.contains("x86") -> "x86"
|
||||
else -> deviceArch
|
||||
}
|
||||
|
||||
sduLog("Looking for platform=android, preferring arch=$sduArch (device ABI=$deviceArch)")
|
||||
|
||||
for (i in 0 until items.length()) {
|
||||
val item = items.getJSONObject(i)
|
||||
val platform = item.optString("platform", "")
|
||||
if (platform != "android") continue
|
||||
|
||||
val itemArch = item.optString("arch", "")
|
||||
val itemVersion = item.optString("version", "")
|
||||
val itemUrl = item.optString("downloadUrl", "")
|
||||
|
||||
sduLog("Found android entry: arch=$itemArch, version=$itemVersion, url=$itemUrl")
|
||||
|
||||
// Берём самую новую версию (предпочитаем совпадение по arch)
|
||||
if (bestVersion == null || compareVersions(itemVersion, bestVersion) > 0 ||
|
||||
(compareVersions(itemVersion, bestVersion) == 0 && itemArch == sduArch)) {
|
||||
bestVersion = itemVersion
|
||||
bestDownloadUrl = itemUrl
|
||||
bestArch = itemArch
|
||||
}
|
||||
}
|
||||
|
||||
if (bestVersion == null) {
|
||||
sduLog("No android entries found in items")
|
||||
return UpdateInfo("", "android", "", false, null, null)
|
||||
}
|
||||
|
||||
sduLog("Best android update: version=$bestVersion, arch=$bestArch, url=$bestDownloadUrl")
|
||||
|
||||
// Сравниваем с текущей версией
|
||||
val isNewer = compareVersions(bestVersion, appVersion) > 0
|
||||
sduLog("Current=$appVersion, available=$bestVersion, isNewer=$isNewer")
|
||||
|
||||
return UpdateInfo(
|
||||
version = bestVersion,
|
||||
platform = "android",
|
||||
arch = bestArch ?: "",
|
||||
kernelUpdateRequired = false,
|
||||
servicePackUrl = if (isNewer) bestDownloadUrl else null,
|
||||
kernelUrl = null
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Сравнение версий: "1.0.7" vs "1.0.6" -> 1 (первая больше)
|
||||
*/
|
||||
private fun compareVersions(v1: String, v2: String): Int {
|
||||
val parts1 = v1.split(".").map { it.toIntOrNull() ?: 0 }
|
||||
val parts2 = v2.split(".").map { it.toIntOrNull() ?: 0 }
|
||||
val maxLen = maxOf(parts1.size, parts2.size)
|
||||
for (i in 0 until maxLen) {
|
||||
val p1 = parts1.getOrElse(i) { 0 }
|
||||
val p2 = parts2.getOrElse(i) { 0 }
|
||||
if (p1 != p2) return p1.compareTo(p2)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Информация об обновлении от SDU сервера
|
||||
*/
|
||||
data class UpdateInfo(
|
||||
val version: String,
|
||||
val platform: String,
|
||||
val arch: String,
|
||||
val kernelUpdateRequired: Boolean,
|
||||
val servicePackUrl: String?,
|
||||
val kernelUrl: String?
|
||||
)
|
||||
|
||||
/**
|
||||
* Состояния процесса обновления
|
||||
*/
|
||||
sealed class UpdateState {
|
||||
/** Начальное состояние */
|
||||
data object Idle : UpdateState()
|
||||
|
||||
/** Проверяем наличие обновлений */
|
||||
data object Checking : UpdateState()
|
||||
|
||||
/** Приложение обновлено */
|
||||
data object UpToDate : UpdateState()
|
||||
|
||||
/** Доступно обновление */
|
||||
data class UpdateAvailable(val version: String) : UpdateState()
|
||||
|
||||
/** Скачивание APK */
|
||||
data class Downloading(val progress: Int) : UpdateState()
|
||||
|
||||
/** Готово к установке */
|
||||
data class ReadyToInstall(val apkPath: String) : UpdateState()
|
||||
|
||||
/** Ошибка */
|
||||
data class Error(val message: String) : UpdateState()
|
||||
}
|
||||
Reference in New Issue
Block a user