In-app-uppdatering: koll mot version.json, nedladdning och installation
All checks were successful
release / build-release (push) Successful in 5m26s

- CI publicerar version.json (versionCode/versionName ur APK:n, sha256,
  apkUrl) som release-asset
- Appen jämför versionCode vid start; banner på hemskärmen med
  nedladdningsprogress; sha256-verifiering; Androids installationsdialog
  via FileProvider (samma signatur -> uppdaterar rakt över)
- REQUEST_INSTALL_PACKAGES + FileProvider i manifestet

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kf4MAbZ3c3B4Xy5XfuGsCP
This commit is contained in:
2026-07-25 13:08:07 +02:00
parent 7a04893294
commit df4d122562
7 changed files with 223 additions and 2 deletions

View File

@@ -0,0 +1,109 @@
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"
}
}