diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt index 7fed197f..b31d5774 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt @@ -600,6 +600,23 @@ fun ChatScreen( ?: error("Application not available") AppUpdateViewModel(app) } + val appUpdateState by appUpdateViewModel.state.collectAsStateWithLifecycle() + + val updateLifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current + androidx.compose.runtime.DisposableEffect(updateLifecycleOwner) { + val observer = + androidx.lifecycle.LifecycleEventObserver { _, event -> + if (event == androidx.lifecycle.Lifecycle.Event.ON_RESUME) { + if (appUpdateState is AppUpdateState.NeedsUnknownSourcesPermission) { + appUpdateViewModel.resumeInstallAfterPermission() + } + } + } + updateLifecycleOwner.lifecycle.addObserver(observer) + onDispose { + updateLifecycleOwner.lifecycle.removeObserver(observer) + } + } if (UpdateNoticeManager.enabled && !AppUpdateCache.dismissed) { val noticeTag = @@ -616,8 +633,14 @@ fun ChatScreen( if (AppUpdateCache.isDialogVisible) { val context = androidx.compose.ui.platform.LocalContext.current + val dialogState = + if (appUpdateState !is AppUpdateState.Idle) { + appUpdateState + } else { + updateNotice + } AppUpdateDialog( - state = updateNotice, + state = dialogState, onDismiss = { AppUpdateCache.hideDialog() }, onStartUpdate = { appUpdateViewModel.startUpdate() }, onCancelDownload = { appUpdateViewModel.cancelDownload() }, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/common/AppUpdateDialog.kt b/app/src/main/java/com/m57/hermescontrol/ui/common/AppUpdateDialog.kt index f8a84bad..e69c0c21 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/common/AppUpdateDialog.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/common/AppUpdateDialog.kt @@ -29,6 +29,10 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -49,14 +53,19 @@ fun AppUpdateDialog( onNeverAskAgain: () -> Unit, onOpenSettings: () -> Unit, ) { - val tag = state.releaseTag().orEmpty() val available = state as? AppUpdateState.UpdateAvailable + var cachedAvailable by remember { mutableStateOf(available) } + if (available != null) { + cachedAvailable = available + } + val currentAvailable = available ?: cachedAvailable + val tag = state.releaseTag() ?: currentAvailable?.latestTag.orEmpty() val sizeMb = - available?.let { + currentAvailable?.let { val mb = it.sizeBytes / (1024 * 1024.0) "%.1f MB".format(mb) } - val releaseNotes = available?.releaseNotes?.trim().orEmpty() + val releaseNotes = currentAvailable?.releaseNotes?.trim().orEmpty() Dialog(onDismissRequest = onDismiss) { Surface( @@ -156,6 +165,7 @@ fun AppUpdateDialog( Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, ) { val progressPercent = (state.progress * 100).toInt() Text( @@ -167,16 +177,35 @@ fun AppUpdateDialog( style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) + if (currentAvailable != null && currentAvailable.sizeBytes > 0) { + val downloadedMb = (state.progress * currentAvailable.sizeBytes) / (1024 * 1024.0) + val totalMb = currentAvailable.sizeBytes / (1024 * 1024.0) + Text( + text = "%.1f / %.1f MB".format(downloadedMb, totalMb), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + Spacer(modifier = Modifier.height(8.dp)) + if (state.progress > 0f) { + LinearProgressIndicator( + progress = { state.progress }, + modifier = + Modifier + .fillMaxWidth() + .height(8.dp) + .clip(RoundedCornerShape(4.dp)), + ) + } else { + LinearProgressIndicator( + modifier = + Modifier + .fillMaxWidth() + .height(8.dp) + .clip(RoundedCornerShape(4.dp)), + ) } - Spacer(modifier = Modifier.height(6.dp)) - LinearProgressIndicator( - progress = { state.progress }, - modifier = - Modifier - .fillMaxWidth() - .height(8.dp) - .clip(RoundedCornerShape(4.dp)), - ) Spacer(modifier = Modifier.height(12.dp)) OutlinedButton( onClick = onCancelDownload, @@ -188,11 +217,21 @@ fun AppUpdateDialog( } is AppUpdateState.Installing -> { - Text( - text = stringResource(R.string.settings_about_update_installing, tag), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary, - ) + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = stringResource(R.string.settings_about_update_installing, tag), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.height(8.dp)) + LinearProgressIndicator( + modifier = + Modifier + .fillMaxWidth() + .height(8.dp) + .clip(RoundedCornerShape(4.dp)), + ) + } } is AppUpdateState.NeedsUnknownSourcesPermission -> { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/AppUpdateViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/AppUpdateViewModel.kt index 35e2a3d6..14f6568d 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/AppUpdateViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/AppUpdateViewModel.kt @@ -43,10 +43,14 @@ class AppUpdateViewModel( val state: StateFlow = _state.asStateFlow() private var downloadJob: kotlinx.coroutines.Job? = null + private var lastAvailable: AppUpdateState.UpdateAvailable? = null init { val cached = AppUpdateCache.state.value - if (cached is AppUpdateState.UpdateAvailable || cached is AppUpdateState.UpToDate) { + if (cached is AppUpdateState.UpdateAvailable) { + lastAvailable = cached + _state.value = cached + } else if (cached is AppUpdateState.UpToDate) { _state.value = cached } else { checkForUpdate() @@ -83,12 +87,13 @@ class AppUpdateViewModel( } _state.value = if (isNewerVersion(info.tagName, currentVersion)) { - AppUpdateState.UpdateAvailable( - latestTag = info.tagName, - apkUrl = apk.browserDownloadUrl, - sizeBytes = apk.size, - releaseNotes = info.body, - ) + AppUpdateState + .UpdateAvailable( + latestTag = info.tagName, + apkUrl = apk.browserDownloadUrl, + sizeBytes = apk.size, + releaseNotes = info.body, + ).also { lastAvailable = it } } else { AppUpdateState.UpToDate(latestTag = info.tagName) } @@ -100,7 +105,12 @@ class AppUpdateViewModel( /** Download the release APK and launch the system installer. */ fun startUpdate() { - val available = _state.value as? AppUpdateState.UpdateAvailable ?: return + val available = + (_state.value as? AppUpdateState.UpdateAvailable) + ?: lastAvailable + ?: (AppUpdateCache.state.value as? AppUpdateState.UpdateAvailable) + ?: return + lastAvailable = available if (!canRequestInstalls()) { _state.value = AppUpdateState.NeedsUnknownSourcesPermission return @@ -129,7 +139,9 @@ class AppUpdateViewModel( fun cancelDownload() { downloadJob?.cancel() downloadJob = null - val available = AppUpdateCache.state.value as? AppUpdateState.UpdateAvailable + val available = + lastAvailable + ?: (AppUpdateCache.state.value as? AppUpdateState.UpdateAvailable) if (available != null) { _state.value = available } else { @@ -139,7 +151,10 @@ class AppUpdateViewModel( /** Dismiss the current update tag so it stops nagging. */ fun dismissCurrentUpdate() { - val tag = _state.value.releaseTag() + val tag = + _state.value.releaseTag() + ?: lastAvailable?.latestTag + ?: AppUpdateCache.state.value.releaseTag() if (tag != null) { AuthManager.setDismissedUpdateTag(tag) } @@ -152,8 +167,9 @@ class AppUpdateViewModel( if (canRequestInstalls()) { val dest = File(getApplication().cacheDir, APK_FILE_NAME) val available = - _state.value as? AppUpdateState.UpdateAvailable - ?: AppUpdateCache.state.value as? AppUpdateState.UpdateAvailable + (_state.value as? AppUpdateState.UpdateAvailable) + ?: lastAvailable + ?: (AppUpdateCache.state.value as? AppUpdateState.UpdateAvailable) if (dest.exists() && dest.length() > 0 && available != null) { _state.value = AppUpdateState.Installing(available.latestTag) launchInstaller(dest) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AboutSection.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AboutSection.kt index e05eba67..2ab8ab54 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AboutSection.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AboutSection.kt @@ -178,10 +178,16 @@ private fun UpdateRow( } is AppUpdateState.Downloading -> { - LinearProgressIndicator( - progress = { state.progress }, - modifier = Modifier.fillMaxWidth().padding(top = 8.dp), - ) + if (state.progress > 0f) { + LinearProgressIndicator( + progress = { state.progress }, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + ) + } else { + LinearProgressIndicator( + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + ) + } } is AppUpdateState.NeedsUnknownSourcesPermission -> { diff --git a/app/src/test/java/com/m57/hermescontrol/ui/settings/AppUpdateViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/settings/AppUpdateViewModelTest.kt index 8c9d72be..cb64e244 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/settings/AppUpdateViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/settings/AppUpdateViewModelTest.kt @@ -333,4 +333,53 @@ class AppUpdateViewModelTest { assertTrue(AppUpdateCache.dismissed) assertFalse(AppUpdateCache.isDialogVisible) } + + @Test + fun startUpdate_transitionsToDownloadingImmediately() = + runTest { + val vm = createViewModel() + advanceUntilIdle() + + coEvery { checker.downloadApk(any(), any(), any()) } coAnswers { + kotlinx.coroutines.delay(5000) + true + } + + vm.startUpdate() + // Immediately upon startUpdate call (before dispatcher advances coroutine time), + // state must be Downloading(0f) to give immediate visual feedback. + val state = vm.state.value + assertTrue("State must immediately be Downloading", state is AppUpdateState.Downloading) + assertEquals(0f, (state as AppUpdateState.Downloading).progress, 0.001f) + } + + @Test + fun startUpdate_fromIdleAdoptsCachedAvailableAndStartsDownloading() = + runTest { + // Suppose VM is created when no check had run yet + every { AuthManager.getUpdateCheckDoneForVersion() } returns currentVersion + coEvery { checker.fetchLatestRelease() } returns null + + val vm = createViewModel() + advanceUntilIdle() + assertEquals(AppUpdateState.Idle, vm.state.value) + + // Simulate launch check finishing and posting to AppUpdateCache + val available = + AppUpdateState.UpdateAvailable( + latestTag = "v1.23.0", + apkUrl = "https://example.com/apk", + sizeBytes = 5000L, + ) + AppUpdateCache.update(available) + + coEvery { checker.downloadApk(any(), any(), any()) } coAnswers { + kotlinx.coroutines.delay(5000) + true + } + + vm.startUpdate() + val state = vm.state.value + assertTrue("Must transition to Downloading using cached update", state is AppUpdateState.Downloading) + } }