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 @@ + + + +