Files
vyatsu-schedule/app/src/main/java/agzam/vyatsu/update/AppUpdater.kt
T

210 lines
7.1 KiB
Kotlin
Raw Normal View History

2026-09-06 12:38:00 +03:00
package agzam.vyatsu.update
import agzam.vyatsu.BuildConfig
import agzam.vyatsu.data.remote.update.GiteaReleaseApi
import agzam.vyatsu.domain.repository.PreferencesRepository
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import android.util.Log
import androidx.core.content.FileProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.File
import java.io.IOException
import java.util.concurrent.TimeUnit
sealed interface UpdateState {
data object Idle : UpdateState
data object Checking : UpdateState
data object UpToDate : UpdateState
data class Available(val version: String, val notes: String?) : UpdateState
data class Downloading(val progress: Int?) : UpdateState
data object ReadyToInstall : UpdateState
data class Failed(val message: String) : UpdateState
}
class AppUpdater(
private val context: Context,
private val preferencesRepository: PreferencesRepository
) {
private val scope = CoroutineScope(Dispatchers.IO)
private val client = OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.build()
private val api = GiteaReleaseApi(client)
private val _state = MutableStateFlow<UpdateState>(UpdateState.Idle)
val state: StateFlow<UpdateState> = _state.asStateFlow()
private var job: Job? = null
private var pendingUpdate: AvailableUpdate? = null
private var downloadedApk: File? = null
fun autoCheckAndInstall() {
if (job?.isActive == true) return
job = scope.launch {
if (!preferencesRepository.autoUpdateEnabled()) return@launch
runCatching {
val update = check() ?: return@launch
install(download(update))
}.onFailure(::onUpdateFailed)
}
}
fun checkForUpdate() {
if (job?.isActive == true) return
job = scope.launch {
runCatching { check() }.onFailure(::onUpdateFailed)
}
}
fun downloadAndInstall() {
if (job?.isActive == true) return
val update = pendingUpdate ?: return
job = scope.launch {
runCatching { install(download(update)) }.onFailure(::onUpdateFailed)
}
}
fun installDownloadedApk() {
val apk = downloadedApk ?: return
install(apk)
}
fun setAutoEnabled(enabled: Boolean) {
scope.launch {
preferencesRepository.setAutoUpdateEnabled(enabled)
}
}
fun canRequestInstalls(): Boolean =
context.packageManager.canRequestPackageInstalls()
fun openUnknownSourcesSettings() {
val intent = Intent(
Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
Uri.parse("package:${context.packageName}")
).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
runCatching { context.startActivity(intent) }
}
private suspend fun check(): AvailableUpdate? {
_state.value = UpdateState.Checking
val release = api.latestRelease()
if (release == null) {
_state.value = UpdateState.UpToDate
return null
}
val remoteVersion = release.tagName?.trimStart('v', 'V')
if (remoteVersion == null || !isNewerVersion(remoteVersion, BuildConfig.VERSION_NAME)) {
_state.value = UpdateState.UpToDate
return null
}
val apkUrl = api.apkDownloadUrl(release)
?: throw IOException("В релизе $remoteVersion нет APK-файла")
val update = AvailableUpdate(version = remoteVersion, apkUrl = apkUrl)
pendingUpdate = update
_state.value = UpdateState.Available(update.version, release.body)
return update
}
private suspend fun download(update: AvailableUpdate): File {
_state.value = UpdateState.Downloading(null)
val dir = File(context.cacheDir, "updates").apply { mkdirs() }
val target = File(dir, "update-${update.version}.apk")
downloadedApk?.delete()
val response = client.newCall(Request.Builder().url(update.apkUrl).get().build()).execute()
response.use { resp ->
if (!resp.isSuccessful) throw IOException("HTTP ${resp.code} при скачивании")
val body = resp.body ?: throw IOException("Пустой ответ при скачивании")
val contentLength = body.contentLength()
body.byteStream().use { input ->
target.outputStream().use { output ->
val buffer = ByteArray(BUFFER_SIZE_BYTES)
var readTotal = 0L
while (true) {
val read = input.read(buffer)
if (read == -1) break
output.write(buffer, 0, read)
readTotal += read
if (contentLength > 0) {
_state.value = UpdateState.Downloading((readTotal * 100 / contentLength).toInt())
}
}
}
}
}
if (target.length() == 0L) {
target.delete()
throw IOException("Скачанный файл пуст")
}
downloadedApk = target
_state.value = UpdateState.ReadyToInstall
return target
}
private fun install(apk: File) {
if (!canRequestInstalls()) {
openUnknownSourcesSettings()
return
}
val uri = FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
apk
)
val intent = Intent(Intent.ACTION_INSTALL_PACKAGE).apply {
data = uri
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
runCatching { context.startActivity(intent) }
.onFailure { Log.e(TAG, "Не удалось запустить установку", it) }
}
private fun onUpdateFailed(error: Throwable) {
Log.e(TAG, "Ошибка обновления", error)
_state.value = UpdateState.Failed(error.message ?: "Неизвестная ошибка")
}
private data class AvailableUpdate(val version: String, val apkUrl: String)
private fun isNewerVersion(remote: String, local: String): Boolean {
val remoteParts = remote.split('.').map { it.toLongOrNull() ?: 0L }
val localParts = local.split('.').map { it.toLongOrNull() ?: 0L }
val size = maxOf(remoteParts.size, localParts.size)
for (i in 0 until size) {
val r = remoteParts.getOrElse(i) { 0L }
val l = localParts.getOrElse(i) { 0L }
if (r != l) return r > l
}
return false
}
private companion object {
const val TAG = "AppUpdate"
const val BUFFER_SIZE_BYTES = 8 * 1024
}
}