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)") } }