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:
@@ -90,6 +90,18 @@ jobs:
|
|||||||
mkdir -p build
|
mkdir -p build
|
||||||
cp app/build/outputs/apk/release/app-release.apk build/FitnessDroid.apk
|
cp app/build/outputs/apk/release/app-release.apk build/FitnessDroid.apk
|
||||||
(cd build && sha256sum FitnessDroid.apk > checksums.txt && ls -la)
|
(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
|
- name: Skapa/uppdatera latest-release + ladda upp APK
|
||||||
env:
|
env:
|
||||||
@@ -124,7 +136,7 @@ jobs:
|
|||||||
-d "$(jq -n --arg body "$BODY" '{body:$body}')" -o /dev/null
|
-d "$(jq -n --arg body "$BODY" '{body:$body}')" -o /dev/null
|
||||||
fi
|
fi
|
||||||
echo "release id: $rid"
|
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" \
|
aid=$(curl -s "$API/repos/$REPO/releases/$rid/assets" \
|
||||||
-H "Authorization: token $TOKEN" | jq -r ".[] | select(.name==\"$f\") | .id")
|
-H "Authorization: token $TOKEN" | jq -r ".[] | select(.name==\"$f\") | .id")
|
||||||
if [ -n "$aid" ] && [ "$aid" != "null" ]; then
|
if [ -n "$aid" ] && [ "$aid" != "null" ]; then
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.VIBRATE" />
|
<uses-permission android:name="android.permission.VIBRATE" />
|
||||||
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".FitnessDroidApplication"
|
android:name=".FitnessDroidApplication"
|
||||||
@@ -11,6 +12,16 @@
|
|||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.FitnessDroid">
|
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
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import eu.brassepc.fitnessdroid.data.RestTimerController
|
|||||||
import eu.brassepc.fitnessdroid.data.SettingsStore
|
import eu.brassepc.fitnessdroid.data.SettingsStore
|
||||||
import eu.brassepc.fitnessdroid.data.SyncEngine
|
import eu.brassepc.fitnessdroid.data.SyncEngine
|
||||||
import eu.brassepc.fitnessdroid.data.TokenStore
|
import eu.brassepc.fitnessdroid.data.TokenStore
|
||||||
|
import eu.brassepc.fitnessdroid.data.UpdateChecker
|
||||||
import eu.brassepc.fitnessdroid.data.local.AppDatabase
|
import eu.brassepc.fitnessdroid.data.local.AppDatabase
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -29,6 +30,7 @@ class AppContainer(context: Context) {
|
|||||||
val syncEngine = SyncEngine(database, graphQlClient, authRepository, appScope)
|
val syncEngine = SyncEngine(database, graphQlClient, authRepository, appScope)
|
||||||
val gymRepository = GymRepository(database, graphQlClient, authRepository, syncEngine, tokenStore, appScope)
|
val gymRepository = GymRepository(database, graphQlClient, authRepository, syncEngine, tokenStore, appScope)
|
||||||
val restTimer = RestTimerController(context, settingsStore, appScope)
|
val restTimer = RestTimerController(context, settingsStore, appScope)
|
||||||
|
val updateChecker = UpdateChecker(context)
|
||||||
}
|
}
|
||||||
|
|
||||||
class FitnessDroidApplication : Application() {
|
class FitnessDroidApplication : Application() {
|
||||||
|
|||||||
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import androidx.compose.material.icons.filled.CloudOff
|
|||||||
import androidx.compose.material.icons.filled.CloudUpload
|
import androidx.compose.material.icons.filled.CloudUpload
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
import androidx.compose.material.icons.filled.Star
|
import androidx.compose.material.icons.filled.Star
|
||||||
|
import androidx.compose.material.icons.filled.SystemUpdate
|
||||||
import androidx.compose.material3.AssistChip
|
import androidx.compose.material3.AssistChip
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
@@ -40,6 +41,7 @@ fun HomeScreen(
|
|||||||
viewModel: HomeViewModel = viewModel(factory = HomeViewModel.Factory),
|
viewModel: HomeViewModel = viewModel(factory = HomeViewModel.Factory),
|
||||||
) {
|
) {
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
val updateState by viewModel.updateState.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -60,6 +62,55 @@ fun HomeScreen(
|
|||||||
SyncChip(status = uiState.syncStatus, pending = uiState.pendingCount)
|
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) {
|
if (uiState.hasActiveSession) {
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|||||||
@@ -24,11 +24,43 @@ data class HomeUiState(
|
|||||||
val pendingCount: Int = 0,
|
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(
|
class HomeViewModel(
|
||||||
private val repo: GymRepository,
|
private val repo: GymRepository,
|
||||||
auth: AuthRepository,
|
auth: AuthRepository,
|
||||||
|
private val updateChecker: eu.brassepc.fitnessdroid.data.UpdateChecker,
|
||||||
) : ViewModel() {
|
) : 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(
|
val uiState = combine(
|
||||||
repo.activeSession,
|
repo.activeSession,
|
||||||
repo.startCards,
|
repo.startCards,
|
||||||
@@ -63,7 +95,7 @@ class HomeViewModel(
|
|||||||
val Factory: ViewModelProvider.Factory = viewModelFactory {
|
val Factory: ViewModelProvider.Factory = viewModelFactory {
|
||||||
initializer {
|
initializer {
|
||||||
val c = appContainer()
|
val c = appContainer()
|
||||||
HomeViewModel(c.gymRepository, c.authRepository)
|
HomeViewModel(c.gymRepository, c.authRepository, c.updateChecker)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
4
app/src/main/res/xml/file_paths.xml
Normal file
4
app/src/main/res/xml/file_paths.xml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<paths>
|
||||||
|
<cache-path name="updates" path="updates/" />
|
||||||
|
</paths>
|
||||||
Reference in New Issue
Block a user