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.Idle) val state: StateFlow = _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 } }