mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-08 06:46:11 +00:00
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:
parent
657fee0de6
commit
389fbd28fe
@ -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) {
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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()
|
||||
|
||||
@ -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) ?: ""
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user