From 344d1b83fe5ab85940befb36262185b82c679592 Mon Sep 17 00:00:00 2001 From: Fredrik Liljegren Date: Mon, 23 Feb 2026 11:10:58 +0100 Subject: [PATCH] Android: track download state to avoid duplicate update prompts When reopening the app, check if an update download is already in progress or completed before showing the update dialog. Show an "Install" prompt for completed downloads instead of re-downloading. Co-Authored-By: Claude Opus 4.6 --- .../src/main/java/net/aurboda/MainActivity.kt | 37 ++++++++- .../java/net/aurboda/update/UpdateDialog.kt | 33 ++++++++ .../net/aurboda/update/UpdateDownloader.kt | 79 +++++++++++++++++++ 3 files changed, 147 insertions(+), 2 deletions(-) diff --git a/apps/android/app/src/main/java/net/aurboda/MainActivity.kt b/apps/android/app/src/main/java/net/aurboda/MainActivity.kt index b958052..ef43644 100644 --- a/apps/android/app/src/main/java/net/aurboda/MainActivity.kt +++ b/apps/android/app/src/main/java/net/aurboda/MainActivity.kt @@ -66,16 +66,20 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.serialization.KSerializer import net.aurboda.ui.theme.AurbodaAppTheme +import net.aurboda.update.DownloadState import net.aurboda.update.UpdateAvailableDialog import net.aurboda.update.UpdateCheckResult import net.aurboda.update.UpdateDownloadingDialog import net.aurboda.update.UpdateErrorDialog +import net.aurboda.update.UpdateReadyToInstallDialog import net.aurboda.update.VersionInfo import net.aurboda.update.checkForUpdate import net.aurboda.update.downloadUpdate +import net.aurboda.update.getExistingDownloadState import net.aurboda.update.installApk // Import allRecordTypes from HealthDataModels import net.aurboda.allRecordTypes +import java.io.File import java.time.Instant import java.time.ZonedDateTime import kotlin.reflect.KClass @@ -282,6 +286,8 @@ fun AurbodaApp(initialTab: MainTab? = null) { var updateAvailable by remember { mutableStateOf(null) } var showUpdateDialog by remember { mutableStateOf(false) } var showDownloadingDialog by remember { mutableStateOf(false) } + var showInstallDialog by remember { mutableStateOf(false) } + var downloadedApkFile by remember { mutableStateOf(null) } var updateError by remember { mutableStateOf(null) } // Check for updates on app launch @@ -292,7 +298,22 @@ fun AurbodaApp(initialTab: MainTab? = null) { is UpdateCheckResult.UpdateAvailable -> { Log.d("UpdateChecker", "Update available: ${result.versionInfo.versionName}") updateAvailable = result.versionInfo - showUpdateDialog = true + + // Check if we already have this download in progress or finished + when (val downloadState = getExistingDownloadState(context, result.versionInfo.versionName)) { + is DownloadState.Downloaded -> { + Log.d("UpdateChecker", "APK already downloaded: ${downloadState.apkFile.name}") + downloadedApkFile = downloadState.apkFile + showInstallDialog = true + } + is DownloadState.InProgress -> { + Log.d("UpdateChecker", "Download already in progress") + showDownloadingDialog = true + } + is DownloadState.None -> { + showUpdateDialog = true + } + } } is UpdateCheckResult.NoUpdate -> { Log.d("UpdateChecker", "No update available") @@ -317,7 +338,8 @@ fun AurbodaApp(initialTab: MainTab? = null) { onDownloadComplete = { apkFile -> scope.launch { showDownloadingDialog = false - installApk(context, apkFile) + downloadedApkFile = apkFile + showInstallDialog = true } }, onDownloadFailed = { error -> @@ -332,6 +354,17 @@ fun AurbodaApp(initialTab: MainTab? = null) { ) } + if (showInstallDialog && updateAvailable != null && downloadedApkFile != null) { + UpdateReadyToInstallDialog( + versionInfo = updateAvailable!!, + onInstall = { + showInstallDialog = false + installApk(context, downloadedApkFile!!) + }, + onDismiss = { showInstallDialog = false } + ) + } + if (showDownloadingDialog) { UpdateDownloadingDialog( onDismiss = { showDownloadingDialog = false } diff --git a/apps/android/app/src/main/java/net/aurboda/update/UpdateDialog.kt b/apps/android/app/src/main/java/net/aurboda/update/UpdateDialog.kt index f61ec42..1a653d3 100644 --- a/apps/android/app/src/main/java/net/aurboda/update/UpdateDialog.kt +++ b/apps/android/app/src/main/java/net/aurboda/update/UpdateDialog.kt @@ -46,6 +46,39 @@ fun UpdateAvailableDialog( ) } +@Composable +fun UpdateReadyToInstallDialog( + versionInfo: VersionInfo, + onInstall: () -> Unit, + onDismiss: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Update Ready") }, + text = { + Column { + Text("Version ${versionInfo.versionName} has been downloaded and is ready to install.") + versionInfo.releaseNotes?.let { notes -> + if (notes.isNotBlank()) { + Spacer(modifier = Modifier.height(8.dp)) + Text(notes) + } + } + } + }, + confirmButton = { + Button(onClick = onInstall) { + Text("Install") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Later") + } + } + ) +} + @Composable fun UpdateDownloadingDialog( onDismiss: () -> Unit diff --git a/apps/android/app/src/main/java/net/aurboda/update/UpdateDownloader.kt b/apps/android/app/src/main/java/net/aurboda/update/UpdateDownloader.kt index 96acebe..83f15c4 100644 --- a/apps/android/app/src/main/java/net/aurboda/update/UpdateDownloader.kt +++ b/apps/android/app/src/main/java/net/aurboda/update/UpdateDownloader.kt @@ -13,6 +13,83 @@ import androidx.core.content.FileProvider import java.io.File private const val TAG = "UpdateDownloader" +private const val UPDATE_PREFS = "AurbodaUpdatePrefs" +private const val KEY_DOWNLOAD_ID = "pendingDownloadId" +private const val KEY_DOWNLOAD_VERSION = "pendingDownloadVersion" + +sealed class DownloadState { + data class Downloaded(val apkFile: File) : DownloadState() + data object InProgress : DownloadState() + data object None : DownloadState() +} + +fun getExistingDownloadState(context: Context, versionName: String): DownloadState { + val prefs = context.getSharedPreferences(UPDATE_PREFS, Context.MODE_PRIVATE) + val savedVersion = prefs.getString(KEY_DOWNLOAD_VERSION, null) + val savedDownloadId = prefs.getLong(KEY_DOWNLOAD_ID, -1) + + // Check if we have a saved download for this version + if (savedVersion == versionName && savedDownloadId != -1L) { + val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager + val query = DownloadManager.Query().setFilterById(savedDownloadId) + val cursor = downloadManager.query(query) + if (cursor.moveToFirst()) { + val statusIndex = cursor.getColumnIndex(DownloadManager.COLUMN_STATUS) + val status = cursor.getInt(statusIndex) + cursor.close() + return when (status) { + DownloadManager.STATUS_SUCCESSFUL -> { + val apkFile = findDownloadedApk(context, versionName) + if (apkFile != null) DownloadState.Downloaded(apkFile) + else { + clearSavedDownload(context) + DownloadState.None + } + } + DownloadManager.STATUS_RUNNING, DownloadManager.STATUS_PENDING -> { + DownloadState.InProgress + } + else -> { + // Failed or paused — clear and let user retry + clearSavedDownload(context) + DownloadState.None + } + } + } + cursor.close() + // Download ID not found in DownloadManager (e.g., cleared by system) + clearSavedDownload(context) + } else if (savedVersion != versionName) { + // Different version — clear stale download state + clearSavedDownload(context) + } + + // No saved download — check if APK file happens to exist + val apkFile = findDownloadedApk(context, versionName) + if (apkFile != null) return DownloadState.Downloaded(apkFile) + + return DownloadState.None +} + +private fun findDownloadedApk(context: Context, versionName: String): File? { + val downloadDir = context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) ?: return null + val apkFile = File(downloadDir, "aurboda-$versionName.apk") + return if (apkFile.exists() && apkFile.length() > 0) apkFile else null +} + +private fun saveDownload(context: Context, downloadId: Long, versionName: String) { + context.getSharedPreferences(UPDATE_PREFS, Context.MODE_PRIVATE).edit() + .putLong(KEY_DOWNLOAD_ID, downloadId) + .putString(KEY_DOWNLOAD_VERSION, versionName) + .apply() +} + +private fun clearSavedDownload(context: Context) { + context.getSharedPreferences(UPDATE_PREFS, Context.MODE_PRIVATE).edit() + .remove(KEY_DOWNLOAD_ID) + .remove(KEY_DOWNLOAD_VERSION) + .apply() +} fun downloadUpdate( context: Context, @@ -41,6 +118,7 @@ fun downloadUpdate( val downloadId = downloadManager.enqueue(request) Log.d(TAG, "Started download with ID: $downloadId") + saveDownload(context, downloadId, versionName) val receiver = object : BroadcastReceiver() { override fun onReceive(ctx: Context, intent: Intent) { @@ -60,6 +138,7 @@ fun downloadUpdate( val reasonIndex = cursor.getColumnIndex(DownloadManager.COLUMN_REASON) val reason = cursor.getInt(reasonIndex) Log.e(TAG, "Download failed with status $status, reason $reason") + clearSavedDownload(context) onDownloadFailed("Download failed (reason: $reason)") } }