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

@@ -90,6 +90,18 @@ jobs:
mkdir -p build
cp app/build/outputs/apk/release/app-release.apk build/FitnessDroid.apk
(cd build && sha256sum FitnessDroid.apk > checksums.txt && ls -la)
# version.json för appens inbyggda uppdateringskoll
VN=$("$HOME/ci-tools/build-tools/aapt2" dump badging build/FitnessDroid.apk \
| sed -n "s/.*versionName='\([^']*\)'.*/\1/p" | head -1)
SHA=$(cut -d' ' -f1 build/checksums.txt)
jq -n \
--argjson code "${{ github.run_number }}" \
--arg name "$VN" \
--arg sha "$SHA" \
--arg url "https://gitea.brasse-pc.eu/${{ github.repository }}/releases/download/latest/FitnessDroid.apk" \
'{versionCode:$code, versionName:$name, sha256:$sha, apkUrl:$url}' \
> build/version.json
cat build/version.json
- name: Skapa/uppdatera latest-release + ladda upp APK
env:
@@ -124,7 +136,7 @@ jobs:
-d "$(jq -n --arg body "$BODY" '{body:$body}')" -o /dev/null
fi
echo "release id: $rid"
for f in FitnessDroid.apk checksums.txt; do
for f in FitnessDroid.apk checksums.txt version.json; do
aid=$(curl -s "$API/repos/$REPO/releases/$rid/assets" \
-H "Authorization: token $TOKEN" | jq -r ".[] | select(.name==\"$f\") | .id")
if [ -n "$aid" ] && [ "$aid" != "null" ]; then

View File

@@ -3,6 +3,7 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application
android:name=".FitnessDroidApplication"
@@ -11,6 +12,16 @@
android:supportsRtl="true"
android:theme="@style/Theme.FitnessDroid">
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<activity
android:name=".MainActivity"
android:exported="true"

View File

@@ -11,6 +11,7 @@ import eu.brassepc.fitnessdroid.data.RestTimerController
import eu.brassepc.fitnessdroid.data.SettingsStore
import eu.brassepc.fitnessdroid.data.SyncEngine
import eu.brassepc.fitnessdroid.data.TokenStore
import eu.brassepc.fitnessdroid.data.UpdateChecker
import eu.brassepc.fitnessdroid.data.local.AppDatabase
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -29,6 +30,7 @@ class AppContainer(context: Context) {
val syncEngine = SyncEngine(database, graphQlClient, authRepository, appScope)
val gymRepository = GymRepository(database, graphQlClient, authRepository, syncEngine, tokenStore, appScope)
val restTimer = RestTimerController(context, settingsStore, appScope)
val updateChecker = UpdateChecker(context)
}
class FitnessDroidApplication : Application() {

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"
}
}

View File

@@ -18,6 +18,7 @@ import androidx.compose.material.icons.filled.CloudOff
import androidx.compose.material.icons.filled.CloudUpload
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.filled.SystemUpdate
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Button
import androidx.compose.material3.Card
@@ -40,6 +41,7 @@ fun HomeScreen(
viewModel: HomeViewModel = viewModel(factory = HomeViewModel.Factory),
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val updateState by viewModel.updateState.collectAsStateWithLifecycle()
Column(
modifier = Modifier
@@ -60,6 +62,55 @@ fun HomeScreen(
SyncChip(status = uiState.syncStatus, pending = uiState.pendingCount)
}
updateState?.let { update ->
Card(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier.padding(14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
Icons.Default.SystemUpdate,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
Column(modifier = Modifier.weight(1f).padding(start = 10.dp)) {
when (update) {
is UpdateState.Available -> {
Text(
"Ny version: ${update.info.versionName}",
style = MaterialTheme.typography.titleSmall,
)
Text(
"Laddas ner från släppsidan och installeras ovanpå",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
is UpdateState.Downloading -> {
Text("Laddar ner …", style = MaterialTheme.typography.titleSmall)
androidx.compose.material3.LinearProgressIndicator(
progress = { update.progress },
modifier = Modifier.fillMaxWidth().padding(top = 6.dp),
)
}
is UpdateState.Failed -> {
Text(
update.message,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.error,
)
}
}
}
if (update !is UpdateState.Downloading) {
Button(onClick = viewModel::startUpdate) {
Text(if (update is UpdateState.Failed) "Igen" else "Uppdatera")
}
}
}
}
}
if (uiState.hasActiveSession) {
Card(
modifier = Modifier

View File

@@ -24,11 +24,43 @@ data class HomeUiState(
val pendingCount: Int = 0,
)
sealed interface UpdateState {
data class Available(val info: eu.brassepc.fitnessdroid.data.UpdateInfo) : UpdateState
data class Downloading(val progress: Float) : UpdateState
data class Failed(val message: String) : UpdateState
}
class HomeViewModel(
private val repo: GymRepository,
auth: AuthRepository,
private val updateChecker: eu.brassepc.fitnessdroid.data.UpdateChecker,
) : ViewModel() {
val updateState = kotlinx.coroutines.flow.MutableStateFlow<UpdateState?>(null)
init {
viewModelScope.launch {
updateChecker.check()?.let { updateState.value = UpdateState.Available(it) }
}
}
fun startUpdate() {
val info = (updateState.value as? UpdateState.Available)?.info ?: return
viewModelScope.launch {
try {
updateState.value = UpdateState.Downloading(0f)
val file = updateChecker.download(info) { p ->
updateState.value = UpdateState.Downloading(p)
}
updateChecker.install(file)
// Tillbaka till "tillgänglig" om installationen avbryts.
updateState.value = UpdateState.Available(info)
} catch (e: Exception) {
updateState.value = UpdateState.Failed("Nedladdningen misslyckades — försök igen")
}
}
}
val uiState = combine(
repo.activeSession,
repo.startCards,
@@ -63,7 +95,7 @@ class HomeViewModel(
val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer {
val c = appContainer()
HomeViewModel(c.gymRepository, c.authRepository)
HomeViewModel(c.gymRepository, c.authRepository, c.updateChecker)
}
}
}

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path name="updates" path="updates/" />
</paths>