Skip to content

Commit 22e9772

Browse files
committed
refactor(details): Improve download and downgrade UX
This commit enhances the user experience on the details screen by improving the download and version management logic. Downloads are now cached within a screen session. If a user dismisses an install prompt and retries the installation, the app will reuse the already-downloaded file instead of re-downloading it. The download progress indicator now displays the downloaded size (e.g., "14.2 MB / 25.0 MB") for better feedback. Temporary files from canceled or completed downloads are now reliably cleaned up when the user navigates away from the screen. The downgrade detection logic has also been made more robust. It now uses the release list order as the primary method to determine if a selected version is older, falling back to semantic version comparison only when necessary. This provides more accurate downgrade warnings. Finally, ProGuard/R8 is now enabled for release builds to optimize and shrink the application size. - **feat(download)**: Reuse an already-downloaded file if the user cancels the install prompt and then re-initiates it in the same session. - **feat(download)**: Display download progress with file sizes (e.g., "X MB / Y MB") instead of just a percentage. - **fix(download)**: Ensure partially downloaded and cached files are properly cleaned up when the user leaves the details screen.
1 parent a69978d commit 22e9772

5 files changed

Lines changed: 135 additions & 38 deletions

File tree

-4.56 KB
Binary file not shown.
-4.6 KB
Binary file not shown.

feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsState.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ data class DetailsState(
3333

3434
val isDownloading: Boolean = false,
3535
val downloadProgressPercent: Int? = null,
36+
val downloadedBytes: Long = 0L,
37+
val totalBytes: Long? = null,
3638
val isInstalling: Boolean = false,
3739
val downloadError: String? = null,
3840
val installError: String? = null,

feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt

Lines changed: 114 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,9 @@ class DetailsViewModel(
6666
private var hasLoadedInitialData = false
6767
private var currentDownloadJob: Job? = null
6868
private var currentAssetName: String? = null
69+
// Tracks the most recently downloaded file so it can be reused if the user
70+
// dismisses the install dialog and re-clicks install on the same screen session.
71+
private var cachedDownloadAssetName: String? = null
6972

7073
private val _state = MutableStateFlow(DetailsState())
7174
val state = _state
@@ -314,23 +317,19 @@ class DetailsViewModel(
314317
val installedApp = _state.value.installedApp
315318

316319
if (primary != null && release != null) {
320+
// Downgrade detection: only warn when the user picks an OLDER version
317321
if (installedApp != null &&
318322
!installedApp.isPendingInstall &&
319-
!installedApp.isUpdateAvailable &&
320323
normalizeVersion(release.tagName) != normalizeVersion(installedApp.installedVersion) &&
321324
platform == Platform.ANDROID
322325
) {
323-
val isConfirmedDowngrade = if (
324-
normalizeVersion(release.tagName) == normalizeVersion(installedApp.latestVersion) &&
325-
(installedApp.latestVersionCode ?: 0L) > 0
326-
) {
327-
installedApp.installedVersionCode > (installedApp.latestVersionCode
328-
?: 0L)
329-
} else {
330-
true
331-
}
326+
val isDowngrade = isDowngradeVersion(
327+
candidate = release.tagName,
328+
current = installedApp.installedVersion,
329+
allReleases = _state.value.allReleases
330+
)
332331

333-
if (isConfirmedDowngrade) {
332+
if (isDowngrade) {
334333
viewModelScope.launch {
335334
_events.send(
336335
DetailsEvent.ShowDowngradeWarning(
@@ -369,21 +368,17 @@ class DetailsViewModel(
369368

370369
val assetName = currentAssetName
371370
if (assetName != null) {
372-
viewModelScope.launch {
373-
try {
374-
val deleted = downloader.cancelDownload(assetName)
375-
logger.debug("Cancel download - file deleted: $deleted")
376-
377-
appendLog(
378-
assetName = assetName,
379-
size = 0L,
380-
tag = _state.value.selectedRelease?.tagName ?: "",
381-
result = LogResult.Cancelled
382-
)
383-
} catch (t: Throwable) {
384-
logger.error("Failed to cancel download: ${t.message}")
385-
}
386-
}
371+
// Keep the partially/fully downloaded file so the user can resume
372+
// without re-downloading. It will be cleaned up in onCleared().
373+
cachedDownloadAssetName = assetName
374+
val releaseTag = _state.value.selectedRelease?.tagName ?: ""
375+
appendLog(
376+
assetName = assetName,
377+
size = _state.value.downloadedBytes,
378+
tag = releaseTag,
379+
result = LogResult.Cancelled
380+
)
381+
logger.debug("Download cancelled – keeping file for potential reuse: $assetName")
387382
}
388383

389384
currentAssetName = null
@@ -723,16 +718,43 @@ class DetailsViewModel(
723718
extOrMime = assetName.substringAfterLast('.', "").lowercase()
724719
)
725720

726-
_state.value = _state.value.copy(downloadStage = DownloadStage.DOWNLOADING)
727-
downloader.download(downloadUrl, assetName).collect { p ->
728-
_state.value = _state.value.copy(downloadProgressPercent = p.percent)
729-
if (p.percent == 100) {
730-
_state.value = _state.value.copy(downloadStage = DownloadStage.VERIFYING)
721+
// Check if file was already downloaded (e.g. user dismissed install dialog)
722+
val existingPath = downloader.getDownloadedFilePath(assetName)
723+
val filePath: String
724+
725+
if (existingPath != null && java.io.File(existingPath).exists()) {
726+
logger.debug("Reusing already downloaded file: $assetName")
727+
filePath = existingPath
728+
_state.value = _state.value.copy(
729+
downloadProgressPercent = 100,
730+
downloadedBytes = sizeBytes,
731+
totalBytes = sizeBytes,
732+
downloadStage = DownloadStage.VERIFYING
733+
)
734+
} else {
735+
_state.value = _state.value.copy(
736+
downloadStage = DownloadStage.DOWNLOADING,
737+
downloadedBytes = 0L,
738+
totalBytes = sizeBytes
739+
)
740+
downloader.download(downloadUrl, assetName).collect { p ->
741+
_state.value = _state.value.copy(
742+
downloadProgressPercent = p.percent,
743+
downloadedBytes = p.bytesDownloaded,
744+
totalBytes = p.totalBytes ?: sizeBytes
745+
)
746+
if (p.percent == 100) {
747+
_state.value =
748+
_state.value.copy(downloadStage = DownloadStage.VERIFYING)
749+
}
731750
}
732-
}
733751

734-
val filePath = downloader.getDownloadedFilePath(assetName)
735-
?: throw IllegalStateException("Downloaded file not found")
752+
filePath = downloader.getDownloadedFilePath(assetName)
753+
?: throw IllegalStateException("Downloaded file not found")
754+
755+
// Cache asset name so it persists for reuse until screen is left
756+
cachedDownloadAssetName = assetName
757+
}
736758

737759
appendLog(
738760
assetName = assetName,
@@ -983,9 +1005,19 @@ class DetailsViewModel(
9831005
super.onCleared()
9841006
currentDownloadJob?.cancel()
9851007

986-
currentAssetName?.let { assetName ->
1008+
// When the screen is actually left (ViewModel destroyed), clean up any
1009+
// in-progress or cached download file so we don't accumulate stale files.
1010+
val assetsToClean = listOfNotNull(currentAssetName, cachedDownloadAssetName).distinct()
1011+
if (assetsToClean.isNotEmpty()) {
9871012
viewModelScope.launch {
988-
downloader.cancelDownload(assetName)
1013+
for (asset in assetsToClean) {
1014+
try {
1015+
downloader.cancelDownload(asset)
1016+
logger.debug("Cleaned up download on screen leave: $asset")
1017+
} catch (t: Throwable) {
1018+
logger.error("Failed to clean download on leave: ${t.message}")
1019+
}
1020+
}
9891021
}
9901022
}
9911023
}
@@ -994,6 +1026,52 @@ class DetailsViewModel(
9941026
return version?.removePrefix("v")?.removePrefix("V")?.trim() ?: ""
9951027
}
9961028

1029+
/**
1030+
* Returns true if [candidate] is strictly older than [current].
1031+
* Uses list-index order as primary heuristic (releases are newest-first),
1032+
* and falls back to semantic version comparison when list lookup fails.
1033+
*/
1034+
private fun isDowngradeVersion(
1035+
candidate: String,
1036+
current: String,
1037+
allReleases: List<GithubRelease>
1038+
): Boolean {
1039+
val normalizedCandidate = normalizeVersion(candidate)
1040+
val normalizedCurrent = normalizeVersion(current)
1041+
1042+
if (normalizedCandidate == normalizedCurrent) return false
1043+
1044+
val candidateIndex = allReleases.indexOfFirst {
1045+
normalizeVersion(it.tagName) == normalizedCandidate
1046+
}
1047+
val currentIndex = allReleases.indexOfFirst {
1048+
normalizeVersion(it.tagName) == normalizedCurrent
1049+
}
1050+
1051+
// Both found in list → use list order (newer = lower index)
1052+
if (candidateIndex != -1 && currentIndex != -1) {
1053+
return candidateIndex > currentIndex
1054+
}
1055+
1056+
// Fallback: semantic version comparison
1057+
return compareSemanticVersions(normalizedCandidate, normalizedCurrent) < 0
1058+
}
1059+
1060+
/**
1061+
* Compares two semantic version strings. Returns positive if a > b, negative if a < b, 0 if equal.
1062+
*/
1063+
private fun compareSemanticVersions(a: String, b: String): Int {
1064+
val aParts = a.split("[.\\-]".toRegex())
1065+
val bParts = b.split("[.\\-]".toRegex())
1066+
val maxLen = maxOf(aParts.size, bParts.size)
1067+
for (i in 0 until maxLen) {
1068+
val aPart = aParts.getOrNull(i)?.filter { it.isDigit() }?.toLongOrNull() ?: 0L
1069+
val bPart = bParts.getOrNull(i)?.filter { it.isDigit() }?.toLongOrNull() ?: 0L
1070+
if (aPart != bPart) return aPart.compareTo(bPart)
1071+
}
1072+
return 0
1073+
}
1074+
9971075
private companion object {
9981076
const val OBTAINIUM_REPO_ID: Long = 523534328
9991077
const val APP_MANAGER_REPO_ID: Long = 268006778

feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/SmartInstallButton.kt

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -251,8 +251,13 @@ fun SmartInstallButton(
251251
fontWeight = FontWeight.Bold
252252
)
253253

254+
val progressText = if (state.totalBytes != null && state.totalBytes > 0) {
255+
"${formatFileSize(state.downloadedBytes)} / ${formatFileSize(state.totalBytes)}"
256+
} else {
257+
"${progress ?: 0}%"
258+
}
254259
Text(
255-
text = "${progress ?: 0}%",
260+
text = progressText,
256261
style = MaterialTheme.typography.bodySmall,
257262
color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.8f)
258263
)
@@ -323,6 +328,9 @@ fun SmartInstallButton(
323328
if (primaryAsset != null) {
324329
val assetArch = extractArchitectureFromName(primaryAsset.name)
325330
val systemArch = state.systemArchitecture
331+
val sizeText = formatFileSize(primaryAsset.size)
332+
val archLabel = assetArch ?: systemArch.name.lowercase()
333+
val subtitle = "$archLabel \u2022 $sizeText"
326334

327335
Spacer(modifier = Modifier.height(2.dp))
328336

@@ -331,7 +339,7 @@ fun SmartInstallButton(
331339
verticalAlignment = Alignment.CenterVertically
332340
) {
333341
Text(
334-
text = assetArch ?: systemArch.name.lowercase(),
342+
text = subtitle,
335343
color = if (enabled) {
336344
when {
337345
isUpdateAvailable -> MaterialTheme.colorScheme.onTertiary.copy(
@@ -449,6 +457,15 @@ private fun normalizeVersion(version: String): String {
449457
return version.removePrefix("v").removePrefix("V").trim()
450458
}
451459

460+
private fun formatFileSize(bytes: Long): String {
461+
return when {
462+
bytes >= 1_073_741_824 -> "%.1f GB".format(bytes / 1_073_741_824.0)
463+
bytes >= 1_048_576 -> "%.1f MB".format(bytes / 1_048_576.0)
464+
bytes >= 1_024 -> "%.1f KB".format(bytes / 1_024.0)
465+
else -> "$bytes B"
466+
}
467+
}
468+
452469
@Preview
453470
@Composable
454471
fun SmartInstallButtonDownloadingPreview() {

0 commit comments

Comments
 (0)