Исправлена фоновая загрузка обновлений: продолжение после выхода из приложения

This commit is contained in:
2026-03-14 21:57:21 +07:00
parent 9568d83a08
commit ac70f0aac9
2 changed files with 281 additions and 70 deletions

View File

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

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"
downloadJob?.cancel() val apkFile = buildApkFile(appCtx, info.version)
downloadJob = scope.launch { if (apkFile.exists()) {
_updateState.value = UpdateState.Downloading(0) apkFile.delete()
_downloadProgress.value = 0 }
try { try {
val fullUrl = if (url.startsWith("http")) url else "$sdu$url" val request =
Log.i(TAG, "Downloading update from: $fullUrl") DownloadManager.Request(Uri.parse(fullUrl))
.setTitle("Rosetta update ${info.version}")
val request = Request.Builder().url(fullUrl).get().build() .setDescription("Downloading update")
val response = httpClient.newCall(request).execute() .setMimeType("application/vnd.android.package-archive")
.setAllowedOverMetered(true)
if (!response.isSuccessful) { .setAllowedOverRoaming(true)
_updateState.value = UpdateState.Error("Download failed: ${response.code}") .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE)
return@launch if (appCtx.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) != null) {
request.setDestinationInExternalFilesDir(
appCtx,
Environment.DIRECTORY_DOWNLOADS,
apkFile.name
)
} else {
request.setDestinationUri(Uri.fromFile(apkFile))
} }
val responseBody = response.body ?: run { val downloadManager =
_updateState.value = UpdateState.Error("Empty download response") appCtx.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
return@launch val downloadId = downloadManager.enqueue(request)
} activeDownloadId = downloadId
activeApkPath = apkFile.absolutePath
activeApkVersion = info.version
persistState(appCtx)
val totalBytes = responseBody.contentLength() _downloadProgress.value = 0
val apkFile = File(context.cacheDir, "Rosetta-${info.version}.apk") _updateState.value = UpdateState.Downloading(0)
sduLog("DownloadManager enqueue id=$downloadId, file=${apkFile.absolutePath}")
responseBody.byteStream().use { input -> startDownloadMonitor(appCtx)
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) { } catch (e: Exception) {
Log.e(TAG, "Download failed", e) Log.e(TAG, "Failed to enqueue update download", e)
_updateState.value = UpdateState.Error("Download failed: ${e.message}") 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"}, ...]}