fix(update): bind live download state to dialog and show real-time progress

This commit is contained in:
M57
2026-09-06 20:59:39 +03:00
parent fb509c3cae
commit 850d01ca89
5 changed files with 167 additions and 34 deletions
@@ -600,6 +600,23 @@ fun ChatScreen(
?: error("Application not available") ?: error("Application not available")
AppUpdateViewModel(app) 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) { if (UpdateNoticeManager.enabled && !AppUpdateCache.dismissed) {
val noticeTag = val noticeTag =
@@ -616,8 +633,14 @@ fun ChatScreen(
if (AppUpdateCache.isDialogVisible) { if (AppUpdateCache.isDialogVisible) {
val context = androidx.compose.ui.platform.LocalContext.current val context = androidx.compose.ui.platform.LocalContext.current
val dialogState =
if (appUpdateState !is AppUpdateState.Idle) {
appUpdateState
} else {
updateNotice
}
AppUpdateDialog( AppUpdateDialog(
state = updateNotice, state = dialogState,
onDismiss = { AppUpdateCache.hideDialog() }, onDismiss = { AppUpdateCache.hideDialog() },
onStartUpdate = { appUpdateViewModel.startUpdate() }, onStartUpdate = { appUpdateViewModel.startUpdate() },
onCancelDownload = { appUpdateViewModel.cancelDownload() }, onCancelDownload = { appUpdateViewModel.cancelDownload() },
@@ -29,6 +29,10 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@@ -49,14 +53,19 @@ fun AppUpdateDialog(
onNeverAskAgain: () -> Unit, onNeverAskAgain: () -> Unit,
onOpenSettings: () -> Unit, onOpenSettings: () -> Unit,
) { ) {
val tag = state.releaseTag().orEmpty()
val available = state as? AppUpdateState.UpdateAvailable 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 = val sizeMb =
available?.let { currentAvailable?.let {
val mb = it.sizeBytes / (1024 * 1024.0) val mb = it.sizeBytes / (1024 * 1024.0)
"%.1f MB".format(mb) "%.1f MB".format(mb)
} }
val releaseNotes = available?.releaseNotes?.trim().orEmpty() val releaseNotes = currentAvailable?.releaseNotes?.trim().orEmpty()
Dialog(onDismissRequest = onDismiss) { Dialog(onDismissRequest = onDismiss) {
Surface( Surface(
@@ -156,6 +165,7 @@ fun AppUpdateDialog(
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) { ) {
val progressPercent = (state.progress * 100).toInt() val progressPercent = (state.progress * 100).toInt()
Text( Text(
@@ -167,8 +177,18 @@ fun AppUpdateDialog(
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, 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(6.dp)) }
Spacer(modifier = Modifier.height(8.dp))
if (state.progress > 0f) {
LinearProgressIndicator( LinearProgressIndicator(
progress = { state.progress }, progress = { state.progress },
modifier = modifier =
@@ -177,6 +197,15 @@ fun AppUpdateDialog(
.height(8.dp) .height(8.dp)
.clip(RoundedCornerShape(4.dp)), .clip(RoundedCornerShape(4.dp)),
) )
} else {
LinearProgressIndicator(
modifier =
Modifier
.fillMaxWidth()
.height(8.dp)
.clip(RoundedCornerShape(4.dp)),
)
}
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
OutlinedButton( OutlinedButton(
onClick = onCancelDownload, onClick = onCancelDownload,
@@ -188,11 +217,21 @@ fun AppUpdateDialog(
} }
is AppUpdateState.Installing -> { is AppUpdateState.Installing -> {
Column(modifier = Modifier.fillMaxWidth()) {
Text( Text(
text = stringResource(R.string.settings_about_update_installing, tag), text = stringResource(R.string.settings_about_update_installing, tag),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
) )
Spacer(modifier = Modifier.height(8.dp))
LinearProgressIndicator(
modifier =
Modifier
.fillMaxWidth()
.height(8.dp)
.clip(RoundedCornerShape(4.dp)),
)
}
} }
is AppUpdateState.NeedsUnknownSourcesPermission -> { is AppUpdateState.NeedsUnknownSourcesPermission -> {
@@ -43,10 +43,14 @@ class AppUpdateViewModel(
val state: StateFlow<AppUpdateState> = _state.asStateFlow() val state: StateFlow<AppUpdateState> = _state.asStateFlow()
private var downloadJob: kotlinx.coroutines.Job? = null private var downloadJob: kotlinx.coroutines.Job? = null
private var lastAvailable: AppUpdateState.UpdateAvailable? = null
init { init {
val cached = AppUpdateCache.state.value 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 _state.value = cached
} else { } else {
checkForUpdate() checkForUpdate()
@@ -83,12 +87,13 @@ class AppUpdateViewModel(
} }
_state.value = _state.value =
if (isNewerVersion(info.tagName, currentVersion)) { if (isNewerVersion(info.tagName, currentVersion)) {
AppUpdateState.UpdateAvailable( AppUpdateState
.UpdateAvailable(
latestTag = info.tagName, latestTag = info.tagName,
apkUrl = apk.browserDownloadUrl, apkUrl = apk.browserDownloadUrl,
sizeBytes = apk.size, sizeBytes = apk.size,
releaseNotes = info.body, releaseNotes = info.body,
) ).also { lastAvailable = it }
} else { } else {
AppUpdateState.UpToDate(latestTag = info.tagName) AppUpdateState.UpToDate(latestTag = info.tagName)
} }
@@ -100,7 +105,12 @@ class AppUpdateViewModel(
/** Download the release APK and launch the system installer. */ /** Download the release APK and launch the system installer. */
fun startUpdate() { 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()) { if (!canRequestInstalls()) {
_state.value = AppUpdateState.NeedsUnknownSourcesPermission _state.value = AppUpdateState.NeedsUnknownSourcesPermission
return return
@@ -129,7 +139,9 @@ class AppUpdateViewModel(
fun cancelDownload() { fun cancelDownload() {
downloadJob?.cancel() downloadJob?.cancel()
downloadJob = null downloadJob = null
val available = AppUpdateCache.state.value as? AppUpdateState.UpdateAvailable val available =
lastAvailable
?: (AppUpdateCache.state.value as? AppUpdateState.UpdateAvailable)
if (available != null) { if (available != null) {
_state.value = available _state.value = available
} else { } else {
@@ -139,7 +151,10 @@ class AppUpdateViewModel(
/** Dismiss the current update tag so it stops nagging. */ /** Dismiss the current update tag so it stops nagging. */
fun dismissCurrentUpdate() { fun dismissCurrentUpdate() {
val tag = _state.value.releaseTag() val tag =
_state.value.releaseTag()
?: lastAvailable?.latestTag
?: AppUpdateCache.state.value.releaseTag()
if (tag != null) { if (tag != null) {
AuthManager.setDismissedUpdateTag(tag) AuthManager.setDismissedUpdateTag(tag)
} }
@@ -152,8 +167,9 @@ class AppUpdateViewModel(
if (canRequestInstalls()) { if (canRequestInstalls()) {
val dest = File(getApplication<Application>().cacheDir, APK_FILE_NAME) val dest = File(getApplication<Application>().cacheDir, APK_FILE_NAME)
val available = val available =
_state.value as? AppUpdateState.UpdateAvailable (_state.value as? AppUpdateState.UpdateAvailable)
?: AppUpdateCache.state.value as? AppUpdateState.UpdateAvailable ?: lastAvailable
?: (AppUpdateCache.state.value as? AppUpdateState.UpdateAvailable)
if (dest.exists() && dest.length() > 0 && available != null) { if (dest.exists() && dest.length() > 0 && available != null) {
_state.value = AppUpdateState.Installing(available.latestTag) _state.value = AppUpdateState.Installing(available.latestTag)
launchInstaller(dest) launchInstaller(dest)
@@ -178,10 +178,16 @@ private fun UpdateRow(
} }
is AppUpdateState.Downloading -> { is AppUpdateState.Downloading -> {
if (state.progress > 0f) {
LinearProgressIndicator( LinearProgressIndicator(
progress = { state.progress }, progress = { state.progress },
modifier = Modifier.fillMaxWidth().padding(top = 8.dp), modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
) )
} else {
LinearProgressIndicator(
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
)
}
} }
is AppUpdateState.NeedsUnknownSourcesPermission -> { is AppUpdateState.NeedsUnknownSourcesPermission -> {
@@ -333,4 +333,53 @@ class AppUpdateViewModelTest {
assertTrue(AppUpdateCache.dismissed) assertTrue(AppUpdateCache.dismissed)
assertFalse(AppUpdateCache.isDialogVisible) 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)
}
} }