forked from agzam/vyatsu-schedule
210 lines
7.1 KiB
Kotlin
210 lines
7.1 KiB
Kotlin
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
|
||
}
|
||
}
|