diff --git a/.gitea/workflows/release.yaml b/.gitea/workflows/release.yaml
index 855975b..4ecb6a5 100644
--- a/.gitea/workflows/release.yaml
+++ b/.gitea/workflows/release.yaml
@@ -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
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 3197298..d674e5d 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -3,6 +3,7 @@
+
+
+
+
+
+ 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"
+ }
+}
diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/ui/home/HomeScreen.kt b/app/src/main/java/eu/brassepc/fitnessdroid/ui/home/HomeScreen.kt
index 97ea4f6..23276d7 100644
--- a/app/src/main/java/eu/brassepc/fitnessdroid/ui/home/HomeScreen.kt
+++ b/app/src/main/java/eu/brassepc/fitnessdroid/ui/home/HomeScreen.kt
@@ -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
diff --git a/app/src/main/java/eu/brassepc/fitnessdroid/ui/home/HomeViewModel.kt b/app/src/main/java/eu/brassepc/fitnessdroid/ui/home/HomeViewModel.kt
index fdc0ebf..4808ad7 100644
--- a/app/src/main/java/eu/brassepc/fitnessdroid/ui/home/HomeViewModel.kt
+++ b/app/src/main/java/eu/brassepc/fitnessdroid/ui/home/HomeViewModel.kt
@@ -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(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)
}
}
}
diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml
new file mode 100644
index 0000000..c9cc257
--- /dev/null
+++ b/app/src/main/res/xml/file_paths.xml
@@ -0,0 +1,4 @@
+
+
+
+