In-app-uppdatering: koll mot version.json, nedladdning och installation
All checks were successful
release / build-release (push) Successful in 5m26s
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:
109
app/src/main/java/eu/brassepc/fitnessdroid/data/UpdateChecker.kt
Normal file
109
app/src/main/java/eu/brassepc/fitnessdroid/data/UpdateChecker.kt
Normal 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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user