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
|
||||
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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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() {
|
||||
|
||||
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.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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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