diff --git a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt index 845f59ca..355da287 100644 --- a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt @@ -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) { diff --git a/app/src/main/java/com/bitchat/android/ui/ApkDownloadViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ApkDownloadViewModel.kt index aea1afac..d553dcbf 100644 --- a/app/src/main/java/com/bitchat/android/ui/ApkDownloadViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ApkDownloadViewModel.kt @@ -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 ) } diff --git a/app/src/main/java/com/bitchat/android/util/ApkDownloadWorker.kt b/app/src/main/java/com/bitchat/android/util/ApkDownloadWorker.kt index b8285321..b5739a16 100644 --- a/app/src/main/java/com/bitchat/android/util/ApkDownloadWorker.kt +++ b/app/src/main/java/com/bitchat/android/util/ApkDownloadWorker.kt @@ -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)) diff --git a/app/src/main/java/com/bitchat/android/util/ApkDownloader.kt b/app/src/main/java/com/bitchat/android/util/ApkDownloader.kt index 3bf234ae..18db97d5 100644 --- a/app/src/main/java/com/bitchat/android/util/ApkDownloader.kt +++ b/app/src/main/java/com/bitchat/android/util/ApkDownloader.kt @@ -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 } \ No newline at end of file diff --git a/app/src/main/java/com/bitchat/android/util/UniversalApkManager.kt b/app/src/main/java/com/bitchat/android/util/UniversalApkManager.kt index 1427e0e0..bb958d0d 100644 --- a/app/src/main/java/com/bitchat/android/util/UniversalApkManager.kt +++ b/app/src/main/java/com/bitchat/android/util/UniversalApkManager.kt @@ -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 = 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() diff --git a/app/src/main/java/com/bitchat/android/util/WorkManagerApkDownloader.kt b/app/src/main/java/com/bitchat/android/util/WorkManagerApkDownloader.kt index feb31172..f0eded0c 100644 --- a/app/src/main/java/com/bitchat/android/util/WorkManagerApkDownloader.kt +++ b/app/src/main/java/com/bitchat/android/util/WorkManagerApkDownloader.kt @@ -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) ?: "" diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a73b5bbb..52e0038c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -245,6 +245,13 @@ Sharing source: verified GitHub universal APK Get universal Downloading… %1$d%% + + Checking latest release… + Waiting for Tor… + Downloading… + Verifying checksum… + Verifying signature… + Stop download Update available Prepare Update diff --git a/app/src/test/kotlin/com/bitchat/android/util/DownloadPhaseTest.kt b/app/src/test/kotlin/com/bitchat/android/util/DownloadPhaseTest.kt new file mode 100644 index 00000000..61673054 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/util/DownloadPhaseTest.kt @@ -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) + } + } +}