feat: show what an APK download is actually doing

Preparing an APK is a five-stage operation rendered as a single 0-100
bar. Two of those stages run before the first byte -- a GitHub release
lookup, then awaitSelectedNetworkRoute, which blocks on Tor bootstrap --
and neither reported anything, so a download sat at 0% with no
explanation for as long as Tor took. The tail had the mirror problem: a
SHA-256 pass and a signature check over ~100MB, both sitting at 100%.

Carry a DownloadPhase on DownloadState.Downloading, reported from
UniversalApkManager through the worker's existing setProgressAsync and
mapWorkInfoToState. The About sheet names the phase instead of showing a
misleading percentage, and the spinner is indeterminate except while
bytes are actually moving. The notification does the same, and a phase
change forces a redraw so the every-5% threshold cannot suppress it.

The phase crosses a WorkManager Data boundary as a string, so fromKey
falls back to Transferring for an absent or unrecognised value -- work
enqueued by an older build must not crash a newer one.

A stop button is wired to the cancelDownload() that already existed on
the downloader interface but had no UI affordance.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

# Conflicts:
#	app/src/main/java/com/bitchat/android/ui/AboutSheet.kt
This commit is contained in:
Moe Hamade 2026-07-28 20:42:51 +03:00
parent 657fee0de6
commit 389fbd28fe
8 changed files with 193 additions and 19 deletions

View File

@ -37,6 +37,7 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.CloudDownload
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Lock
@ -68,6 +69,7 @@ import com.bitchat.android.R
import com.bitchat.android.core.ui.component.button.CloseButton
import com.bitchat.android.core.ui.component.sheet.LocalSheetDismiss
import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet
import com.bitchat.android.util.downloadPhaseLabel
import com.bitchat.android.hotspot.HotspotActivity
import com.bitchat.android.net.ArtiTorManager
import com.bitchat.android.net.TorMode
@ -702,7 +704,15 @@ fun AboutSheet(
"${status.version}${status.sizeMB} MB\n$source"
}
is ApkPreparationStatus.UpdateAvailable -> stringResource(R.string.prepare_apk_status_update_available) + " (${status.newVersion})"
is ApkPreparationStatus.Downloading -> stringResource(R.string.prepare_apk_status_downloading, downloadProgress)
is ApkPreparationStatus.Downloading ->
// Only the transfer has a percentage worth
// showing; the other phases are named
// instead of pretending to be at 0%.
if (status.phase.hasMeasurableProgress) {
stringResource(R.string.prepare_apk_status_downloading, downloadProgress)
} else {
stringResource(downloadPhaseLabel(status.phase))
}
is ApkPreparationStatus.Resumable -> "Tap to resume • ${status.progressPercent}% downloaded"
is ApkPreparationStatus.Error -> status.message
},
@ -720,10 +730,35 @@ fun AboutSheet(
// Action buttons
when (apkStatus) {
is ApkPreparationStatus.Downloading -> {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
strokeWidth = 2.dp
)
// Determinate only while bytes move. Elsewhere a
// spinner is honest about having no measure.
if (apkStatus.phase.hasMeasurableProgress &&
downloadProgress > 0
) {
CircularProgressIndicator(
progress = { downloadProgress / 100f },
modifier = Modifier.size(20.dp),
strokeWidth = 2.dp
)
} else {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
strokeWidth = 2.dp
)
}
androidx.compose.material3.IconButton(
onClick = {
apkViewModel.onEvent(ApkUiEvent.CancelDownload)
},
modifier = Modifier.size(32.dp)
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringResource(R.string.prepare_apk_stop),
tint = colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp)
)
}
}
is ApkPreparationStatus.Ready -> {
if (apkStatus.variant == ShareableApkVariant.ARM64) {

View File

@ -36,7 +36,10 @@ sealed class ApkPreparationStatus {
val newVersion: String,
val newSizeMB: Int
) : ApkPreparationStatus()
object Downloading : ApkPreparationStatus()
/** [phase] is what the operation is actually doing; only a transfer has a real percentage. */
data class Downloading(
val phase: ApkDownloader.DownloadPhase = ApkDownloader.DownloadPhase.ResolvingRelease
) : ApkPreparationStatus()
data class Resumable(val progressPercent: Int, val message: String) : ApkPreparationStatus()
data class Error(val message: String) : ApkPreparationStatus()
}
@ -200,7 +203,7 @@ class ApkDownloadViewModel(application: Application) : AndroidViewModel(applicat
val partial = apkManager.getPartialDownloadProgress()
_state.update {
it.copy(
apkStatus = ApkPreparationStatus.Downloading,
apkStatus = ApkPreparationStatus.Downloading(),
downloadProgress = partial ?: 0
)
}
@ -237,7 +240,7 @@ class ApkDownloadViewModel(application: Application) : AndroidViewModel(applicat
is ApkDownloader.DownloadState.Downloading -> {
_state.update {
it.copy(
apkStatus = ApkPreparationStatus.Downloading,
apkStatus = ApkPreparationStatus.Downloading(downloadState.phase),
downloadProgress = downloadState.progressPercent
)
}

View File

@ -33,6 +33,7 @@ class ApkDownloadWorker(
// Progress keys
const val KEY_PROGRESS = "progress"
const val KEY_PHASE = "phase"
const val KEY_VERSION = "version"
const val KEY_SIZE_MB = "size_mb"
const val KEY_ERROR = "error"
@ -50,6 +51,8 @@ class ApkDownloadWorker(
applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
private var lastNotifiedProgress = -NOTIFY_STEP_PERCENT
private var lastProgress = 0
private var currentPhase = ApkDownloader.DownloadPhase.ResolvingRelease
override suspend fun doWork(): Result {
Log.d(TAG, "Starting APK download work")
@ -64,10 +67,20 @@ class ApkDownloadWorker(
Log.w(TAG, "Could not promote download to foreground work", e)
}
val result = apkManager.downloadUniversalApk { progress ->
setProgressAsync(Data.Builder().putInt(KEY_PROGRESS, progress).build())
updateNotification(progress)
}
val result = apkManager.downloadUniversalApk(
progressCallback = { progress ->
lastProgress = progress
publishProgress(progress, currentPhase)
updateNotification(progress)
},
phaseCallback = { phase ->
currentPhase = phase
publishProgress(lastProgress, phase)
// Forced: a phase change is exactly the moment the percentage stops meaning
// anything, so the every-5% threshold must not suppress the redraw.
updateNotification(lastProgress, force = true)
}
)
return if (result.isSuccess) {
val info = apkManager.getCachedApkInfo()
@ -118,16 +131,28 @@ class ApkDownloadWorker(
}
}
private fun publishProgress(progress: Int, phase: ApkDownloader.DownloadPhase) {
setProgressAsync(
Data.Builder()
.putInt(KEY_PROGRESS, progress)
.putString(KEY_PHASE, phase.name)
.build()
)
}
private fun buildNotification(progress: Int): android.app.Notification {
val cancelIntent = WorkManager.getInstance(applicationContext)
.createCancelPendingIntent(id)
return NotificationCompat.Builder(applicationContext, CHANNEL_ID)
.setContentTitle(applicationContext.getString(R.string.apk_download_notification_title))
.setContentText(applicationContext.getString(downloadPhaseLabel(currentPhase)))
.setSmallIcon(R.drawable.ic_notification)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setProgress(100, progress, progress <= 0)
// A percentage is a lie outside the transfer: the release lookup, the Tor bootstrap
// and both verification passes have no measurable progress at all.
.setProgress(100, progress, !currentPhase.hasMeasurableProgress || progress <= 0)
.addAction(
android.R.drawable.ic_delete,
applicationContext.getString(android.R.string.cancel),
@ -136,8 +161,8 @@ class ApkDownloadWorker(
.build()
}
private fun updateNotification(progress: Int) {
if (progress - lastNotifiedProgress < NOTIFY_STEP_PERCENT) return
private fun updateNotification(progress: Int, force: Boolean = false) {
if (!force && progress - lastNotifiedProgress < NOTIFY_STEP_PERCENT) return
lastNotifiedProgress = progress
try {
notificationManager.notify(NOTIFICATION_ID, buildNotification(progress))

View File

@ -29,8 +29,50 @@ interface ApkDownloader {
*/
sealed class DownloadState {
object Idle : DownloadState()
data class Downloading(val progressPercent: Int) : DownloadState()
data class Downloading(
val progressPercent: Int,
val phase: DownloadPhase = DownloadPhase.Transferring
) : DownloadState()
data class Success(val version: String, val sizeMB: Int) : DownloadState()
data class Failed(val message: String, val resumablePercent: Int?) : DownloadState()
}
/**
* What a download is actually doing.
*
* Preparing an APK is a five-stage operation that was being rendered as a single 0-100 bar,
* so it sat at 0% through a release lookup and a Tor bootstrap, then at 100% through a
* SHA-256 pass and a signature check over ~100MB. Only [Transferring] has meaningful
* percentage progress; the rest should read as indeterminate.
*/
enum class DownloadPhase {
ResolvingRelease,
AwaitingNetworkRoute,
Transferring,
VerifyingChecksum,
VerifyingSignature;
/** A percentage is only honest while bytes are actually moving. */
val hasMeasurableProgress: Boolean get() = this == Transferring
companion object {
/** Tolerates an unknown or absent key, since it crosses a WorkManager Data boundary. */
fun fromKey(key: String?): DownloadPhase =
entries.firstOrNull { it.name == key } ?: Transferring
}
}
}
/** Shared by the notification and the About sheet so both name a phase identically. */
internal fun downloadPhaseLabel(phase: ApkDownloader.DownloadPhase): Int = when (phase) {
ApkDownloader.DownloadPhase.ResolvingRelease ->
com.bitchat.android.R.string.prepare_apk_phase_resolving
ApkDownloader.DownloadPhase.AwaitingNetworkRoute ->
com.bitchat.android.R.string.prepare_apk_phase_awaiting_route
ApkDownloader.DownloadPhase.Transferring ->
com.bitchat.android.R.string.prepare_apk_phase_transferring
ApkDownloader.DownloadPhase.VerifyingChecksum ->
com.bitchat.android.R.string.prepare_apk_phase_verifying_checksum
ApkDownloader.DownloadPhase.VerifyingSignature ->
com.bitchat.android.R.string.prepare_apk_phase_verifying_signature
}

View File

@ -206,7 +206,13 @@ class UniversalApkManager(private val context: Context) {
* @return Result with File on success, or error message
*/
suspend fun downloadUniversalApk(
progressCallback: ((Int) -> Unit)? = null
progressCallback: ((Int) -> Unit)? = null,
/**
* Reports which stage the operation reached. Both stages before the transfer can block
* for a long time a release lookup, then a Tor bootstrap and reporting neither is why
* a download appeared stuck at 0%.
*/
phaseCallback: ((ApkDownloader.DownloadPhase) -> Unit)? = null
): Result<File> = withContext(Dispatchers.IO) {
try {
Log.d(TAG, "Starting universal APK download")
@ -215,15 +221,18 @@ class UniversalApkManager(private val context: Context) {
// Reuses the short-lived release metadata cache populated by the
// status check. If this worker is running after process death, the
// client performs a retried network fetch instead.
phaseCallback?.invoke(ApkDownloader.DownloadPhase.ResolvingRelease)
val release = GitHubReleaseClient.fetchLatestRelease().getOrElse { error ->
return@withContext Result.failure(error)
}
phaseCallback?.invoke(ApkDownloader.DownloadPhase.AwaitingNetworkRoute)
if (!GitHubReleaseClient.awaitSelectedNetworkRoute()) {
return@withContext Result.failure(
IOException("Tor is still connecting. Try the download again when Tor is ready.")
)
}
phaseCallback?.invoke(ApkDownloader.DownloadPhase.Transferring)
val url = release.universalApkUrl
val expectedSize = release.universalApkSize
@ -286,6 +295,7 @@ class UniversalApkManager(private val context: Context) {
// Verify checksum if available
if (release.universalApkSha256 != null) {
Log.d(TAG, "Verifying checksum...")
phaseCallback?.invoke(ApkDownloader.DownloadPhase.VerifyingChecksum)
val isValid = verifyChecksum(tempFile, release.universalApkSha256)
if (!isValid) {
tempFile.delete()
@ -301,6 +311,7 @@ class UniversalApkManager(private val context: Context) {
// Verify the downloaded APK against trusted signing certificates.
Log.d(TAG, "Verifying APK signature...")
phaseCallback?.invoke(ApkDownloader.DownloadPhase.VerifyingSignature)
if (!verifyApkSignature(tempFile)) {
tempFile.delete()
progressFile.delete()

View File

@ -61,11 +61,17 @@ class WorkManagerApkDownloader(context: Context) : ApkDownloader {
WorkInfo.State.BLOCKED -> {
// Waiting for constraints (network). Show existing partial progress if any.
val partial = apkManager.getPartialDownloadProgress()
ApkDownloader.DownloadState.Downloading(partial ?: 0)
ApkDownloader.DownloadState.Downloading(
partial ?: 0,
ApkDownloader.DownloadPhase.ResolvingRelease
)
}
WorkInfo.State.RUNNING -> {
val progress = workInfo.progress.getInt(ApkDownloadWorker.KEY_PROGRESS, 0)
ApkDownloader.DownloadState.Downloading(progress)
val phase = ApkDownloader.DownloadPhase.fromKey(
workInfo.progress.getString(ApkDownloadWorker.KEY_PHASE)
)
ApkDownloader.DownloadState.Downloading(progress, phase)
}
WorkInfo.State.SUCCEEDED -> {
val version = workInfo.outputData.getString(ApkDownloadWorker.KEY_VERSION) ?: ""

View File

@ -245,6 +245,13 @@
<string name="prepare_apk_source_github" translatable="false">Sharing source: verified GitHub universal APK</string>
<string name="prepare_apk_get_universal" translatable="false">Get universal</string>
<string name="prepare_apk_status_downloading">Downloading… %1$d%%</string>
<!-- Stages of preparing the APK. Only the transfer has a meaningful percentage. -->
<string name="prepare_apk_phase_resolving">Checking latest release…</string>
<string name="prepare_apk_phase_awaiting_route">Waiting for Tor…</string>
<string name="prepare_apk_phase_transferring">Downloading…</string>
<string name="prepare_apk_phase_verifying_checksum">Verifying checksum…</string>
<string name="prepare_apk_phase_verifying_signature">Verifying signature…</string>
<string name="prepare_apk_stop">Stop download</string>
<string name="prepare_apk_status_update_available">Update available</string>
<string name="prepare_apk_button_prepare">Prepare</string>
<string name="prepare_apk_button_update">Update</string>

View File

@ -0,0 +1,45 @@
package com.bitchat.android.util
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The phase crosses a WorkManager `Data` boundary as a plain string, so it has to survive a
* round trip and degrade sensibly when it does not.
*/
class DownloadPhaseTest {
@Test
fun `every phase survives the round trip through its key`() {
ApkDownloader.DownloadPhase.entries.forEach { phase ->
assertEquals(phase, ApkDownloader.DownloadPhase.fromKey(phase.name))
}
}
@Test
fun `an absent or unrecognised key falls back to the transfer`() {
// Work enqueued by an older build, or progress read before the first phase is published.
assertEquals(
ApkDownloader.DownloadPhase.Transferring,
ApkDownloader.DownloadPhase.fromKey(null)
)
assertEquals(
ApkDownloader.DownloadPhase.Transferring,
ApkDownloader.DownloadPhase.fromKey("SomePhaseFromAFutureBuild")
)
}
@Test
fun `only the transfer claims measurable progress`() {
assertTrue(ApkDownloader.DownloadPhase.Transferring.hasMeasurableProgress)
val unmeasurable = ApkDownloader.DownloadPhase.entries
.filterNot { it == ApkDownloader.DownloadPhase.Transferring }
assertFalse(unmeasurable.isEmpty())
unmeasurable.forEach {
assertFalse("$it has no percentage to report", it.hasMeasurableProgress)
}
}
}