package eu.brassepc.fitnessdroid.data import android.content.Context import android.content.Intent import androidx.core.content.FileProvider import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json import kotlinx.serialization.json.intOrNull import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import okhttp3.OkHttpClient import okhttp3.Request import java.io.File import java.security.MessageDigest import java.util.concurrent.TimeUnit data class UpdateInfo( val versionCode: Long, val versionName: String, val apkUrl: String, val sha256: String?, ) /** * Uppdateringskoll mot släppsidans version.json (skrivs av CI:t). * Nedladdning till appens cache + verifiering, sedan Androids * installationsdialog — samma signeringsnyckel gör att den uppdaterar * rakt över befintlig installation. */ class UpdateChecker(private val context: Context) { private val http = OkHttpClient.Builder() .connectTimeout(10, TimeUnit.SECONDS) .readTimeout(120, TimeUnit.SECONDS) .build() private fun installedVersionCode(): Long = context.packageManager.getPackageInfo(context.packageName, 0).longVersionCode /** null = uppdaterad, nätfel eller trasig manifest — stör aldrig användaren. */ suspend fun check(): UpdateInfo? = withContext(Dispatchers.IO) { runCatching { val request = Request.Builder().url(VERSION_URL).build() http.newCall(request).execute().use { response -> if (!response.isSuccessful) return@withContext null val o = Json.parseToJsonElement(response.body!!.string()).jsonObject val info = UpdateInfo( versionCode = (o["versionCode"]?.jsonPrimitive?.intOrNull ?: 0).toLong(), versionName = o["versionName"]?.jsonPrimitive?.content ?: "?", apkUrl = o["apkUrl"]?.jsonPrimitive?.content ?: return@withContext null, sha256 = o["sha256"]?.jsonPrimitive?.content, ) if (info.versionCode > installedVersionCode()) info else null } }.getOrNull() } /** Laddar ner APK:n till cachen, verifierar sha256, returnerar filen. */ suspend fun download(info: UpdateInfo, onProgress: (Float) -> Unit): File = withContext(Dispatchers.IO) { val dir = File(context.cacheDir, "updates").apply { mkdirs() } val file = File(dir, "FitnessDroid-${info.versionCode}.apk") val request = Request.Builder().url(info.apkUrl).build() http.newCall(request).execute().use { response -> if (!response.isSuccessful) error("HTTP ${response.code}") val body = response.body!! val total = body.contentLength().takeIf { it > 0 } val digest = MessageDigest.getInstance("SHA-256") body.byteStream().use { input -> file.outputStream().use { output -> val buffer = ByteArray(64 * 1024) var read: Int var done = 0L while (input.read(buffer).also { read = it } != -1) { output.write(buffer, 0, read) digest.update(buffer, 0, read) done += read total?.let { onProgress(done.toFloat() / it) } } } } val actual = digest.digest().joinToString("") { "%02x".format(it) } if (info.sha256 != null && !actual.equals(info.sha256, ignoreCase = true)) { file.delete() error("sha256 stämmer inte — avbryter") } } file } /** Öppnar Androids installationsdialog för den nedladdade APK:n. */ fun install(file: File) { val uri = FileProvider.getUriForFile( context, "${context.packageName}.fileprovider", file, ) context.startActivity( Intent(Intent.ACTION_VIEW).apply { setDataAndType(uri, "application/vnd.android.package-archive") addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK) } ) } companion object { private const val VERSION_URL = "https://gitea.brasse-pc.eu/brasse/FitnessDroid/releases/download/latest/version.json" } }