From bfb0c82ef908add1d9c9c29e30fe0648b1f2d8f1 Mon Sep 17 00:00:00 2001 From: Moe Hamade <69801237+moehamade@users.noreply.github.com> Date: Fri, 31 Jul 2026 02:46:28 +0300 Subject: [PATCH] feat: rebuild the prepare-for-sharing row and localize its failures Progress moves out of the trailing slot and under the subtitle, so every status now shows exactly one 48dp control there instead of a spinner and a button competing for the same space. The trailing slot had three different widths across states, which made the text column re-wrap on every status change; it is now one width throughout. "Get universal" and "Retry" become icon buttons. That removes the labels they were leaning on, so both gain tooltips and real content descriptions, and prepareRowTapAction() now drives the row's enabled flag and its tap handler from one mapping. Previously the row rendered as clickable in the ready state but onPrepareRowClicked ignored it, leaving the icon as the only way to reach the universal download. Resumable downloads get a progress bar for the first time, drawn flat via amplitude 0 so a stalled download does not look like a live one. Strings: every user-facing literal now lives in strings.xml. Download failures were assembled as English sentences in the util layer, which has no Context by design, so they crossed the WorkManager boundary already formatted and could never be translated. ApkDownloadException now carries a string resource and its arguments, and the ViewModel resolves them against the device locale. util/ stays Context-free and its tests stay plain JUnit. Also drops translatable="false" from seven strings that were visible prose, and stops showing raw exception text when the APK status cannot be read. Co-Authored-By: Claude Opus 5 (1M context) --- .../java/com/bitchat/android/ui/AboutSheet.kt | 186 ++-- .../android/ui/ApkDownloadViewModel.kt | 160 ++-- .../android/ui/ApkPrepareRowControls.kt | 107 +++ .../bitchat/android/util/ApkDownloadSource.kt | 232 +++++ .../bitchat/android/util/ApkDownloadWorker.kt | 48 +- .../com/bitchat/android/util/ApkDownloader.kt | 40 +- .../bitchat/android/util/GitHubRateLimit.kt | 61 -- .../android/util/GitHubReleaseClient.kt | 514 ----------- .../android/util/UniversalApkManager.kt | 814 ++++++++++-------- .../android/util/WorkManagerApkDownloader.kt | 24 +- app/src/main/res/values/strings.xml | 54 +- .../android/ui/PrepareRowTapActionTest.kt | 75 ++ .../android/util/ApkDownloadSourceTest.kt | 162 ++++ .../bitchat/android/util/DownloadPhaseTest.kt | 12 + .../android/util/GitHubRateLimitTest.kt | 164 ---- .../android/util/GitHubReleaseClientTest.kt | 99 --- 16 files changed, 1321 insertions(+), 1431 deletions(-) create mode 100644 app/src/main/java/com/bitchat/android/ui/ApkPrepareRowControls.kt create mode 100644 app/src/main/java/com/bitchat/android/util/ApkDownloadSource.kt delete mode 100644 app/src/main/java/com/bitchat/android/util/GitHubRateLimit.kt delete mode 100644 app/src/main/java/com/bitchat/android/util/GitHubReleaseClient.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/ui/PrepareRowTapActionTest.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/util/ApkDownloadSourceTest.kt delete mode 100644 app/src/test/kotlin/com/bitchat/android/util/GitHubRateLimitTest.kt delete mode 100644 app/src/test/kotlin/com/bitchat/android/util/GitHubReleaseClientTest.kt 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 355da287..7ad0b345 100644 --- a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt @@ -41,8 +41,9 @@ 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 -import androidx.compose.material.icons.filled.Public import androidx.compose.material.icons.filled.Mic +import androidx.compose.material.icons.filled.Public +import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.filled.UnfoldMore import androidx.compose.material.icons.filled.Warning @@ -654,7 +655,12 @@ fun AboutSheet( Row( modifier = Modifier .fillMaxWidth() - .clickable(enabled = apkStatus !is ApkPreparationStatus.Downloading) { + // Enabled by the same mapping that decides what the tap + // does, so the row can never look tappable and do + // nothing. + .clickable( + enabled = prepareRowTapAction(apkStatus) != null + ) { apkViewModel.onEvent(ApkUiEvent.PrepareRowClicked) } .padding(horizontal = 16.dp, vertical = 14.dp), @@ -693,17 +699,20 @@ fun AboutSheet( is ApkPreparationStatus.NotDownloaded -> stringResource(R.string.prepare_apk_status_not_downloaded) is ApkPreparationStatus.Ready -> { val source = when { - status.source == UniversalApkManager.ApkSource.GITHUB -> - stringResource(R.string.prepare_apk_source_github) + status.source == UniversalApkManager.ApkSource.DOWNLOADED -> + stringResource(R.string.prepare_apk_source_downloaded) status.variant == ShareableApkVariant.ARM64 -> stringResource(R.string.prepare_apk_source_installed_arm64) else -> stringResource(R.string.prepare_apk_source_installed) } - stringResource(R.string.prepare_apk_status_ready) + - " • ${status.version} • ${status.sizeMB} MB\n$source" + stringResource( + R.string.prepare_apk_ready_detail, + status.version, + status.sizeMB, + source + ) } - is ApkPreparationStatus.UpdateAvailable -> stringResource(R.string.prepare_apk_status_update_available) + " (${status.newVersion})" is ApkPreparationStatus.Downloading -> // Only the transfer has a percentage worth // showing; the other phases are named @@ -713,138 +722,114 @@ fun AboutSheet( } else { stringResource(downloadPhaseLabel(status.phase)) } - is ApkPreparationStatus.Resumable -> "Tap to resume • ${status.progressPercent}% downloaded" + is ApkPreparationStatus.Resumable -> + stringResource( + R.string.prepare_apk_status_resumable, + status.message, + status.progressPercent + ) is ApkPreparationStatus.Error -> status.message }, style = MaterialTheme.typography.bodySmall, color = when (apkStatus) { is ApkPreparationStatus.Error -> colorScheme.error is ApkPreparationStatus.Resumable -> colorScheme.primary - is ApkPreparationStatus.UpdateAvailable -> colorScheme.primary else -> colorScheme.onSurface.copy(alpha = 0.6f) }, lineHeight = 16.sp ) + + // Progress lives in the column, not the trailing slot, + // which leaves that slot free for a single control. + ApkDownloadProgressBar( + status = apkStatus, + progressPercent = downloadProgress + ) } - // Action buttons + // One control, one width, in every state. The progress + // readout moved into the column above, so nothing else + // competes for this slot. when (apkStatus) { - is ApkPreparationStatus.Downloading -> { - // 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( + is ApkPreparationStatus.Downloading -> + ApkPrepareRowIconButton( + icon = Icons.Default.Close, + description = stringResource( + R.string.prepare_apk_stop + ), 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) - ) - } - } + apkViewModel.onEvent( + ApkUiEvent.CancelDownload + ) + } + ) is ApkPreparationStatus.Ready -> { if (apkStatus.variant == ShareableApkVariant.ARM64) { - TextButton( + ApkPrepareRowIconButton( + icon = Icons.Default.CloudDownload, + description = stringResource( + R.string.prepare_apk_get_universal + ), onClick = { apkViewModel.onEvent( ApkUiEvent.DownloadUniversalClicked ) - } - ) { - Icon( - imageVector = Icons.Default.CloudDownload, - contentDescription = null, - modifier = Modifier.size(18.dp) - ) - Spacer(modifier = Modifier.width(4.dp)) - Text( - stringResource( - R.string.prepare_apk_get_universal - ) - ) - } - } else if (apkStatus.source == UniversalApkManager.ApkSource.GITHUB) { - androidx.compose.material3.IconButton( - onClick = { apkViewModel.onEvent(ApkUiEvent.DeleteClicked) }, - modifier = Modifier.size(48.dp) - ) { - Icon( - imageVector = Icons.Default.Delete, - contentDescription = stringResource( - R.string.prepare_apk_delete_confirm - ), - tint = colorScheme.error, - modifier = Modifier.size(20.dp) - ) - } - } - } - is ApkPreparationStatus.UpdateAvailable -> { - androidx.compose.material3.IconButton( - onClick = { apkViewModel.onEvent(ApkUiEvent.DeleteClicked) }, - modifier = Modifier.size(48.dp) + }, + tint = colorScheme.primary + ) + } else if ( + apkStatus.source == + UniversalApkManager.ApkSource.DOWNLOADED ) { - Icon( - imageVector = Icons.Default.Delete, - contentDescription = stringResource( - R.string.prepare_apk_delete_confirm + ApkPrepareRowIconButton( + icon = Icons.Default.Delete, + description = stringResource( + R.string.prepare_apk_button_delete ), - tint = colorScheme.error, - modifier = Modifier.size(20.dp) + onClick = { + apkViewModel.onEvent( + ApkUiEvent.DeleteClicked + ) + }, + tint = colorScheme.error ) } } + is ApkPreparationStatus.Resumable, + is ApkPreparationStatus.Error -> + ApkPrepareRowIconButton( + icon = Icons.Default.Refresh, + description = stringResource( + R.string.prepare_apk_retry + ), + onClick = { + apkViewModel.onEvent( + ApkUiEvent.PrepareRowClicked + ) + }, + tint = colorScheme.primary + ) else -> {} } } // Prepare Dialog if (apkUiState.showPrepareDialog) { - val status = apkStatus - val sizeMB: Int? = when (status) { - is ApkPreparationStatus.NotDownloaded -> status.sizeMB - is ApkPreparationStatus.UpdateAvailable -> status.newSizeMB - else -> null - } AlertDialog( onDismissRequest = { apkViewModel.onEvent(ApkUiEvent.DismissPrepareDialog) }, title = { Text( - text = if (status is ApkPreparationStatus.UpdateAvailable) { - stringResource(R.string.prepare_apk_update_dialog_title) - } else { - stringResource(R.string.prepare_apk_dialog_title) - }, + text = stringResource( + R.string.prepare_apk_dialog_title + ), style = MaterialTheme.typography.titleLarge ) }, text = { Text( - text = if (status is ApkPreparationStatus.UpdateAvailable) { - stringResource(R.string.prepare_apk_update_dialog_message, status.newVersion, status.currentVersion) - } else if (sizeMB != null) { - stringResource(R.string.prepare_apk_dialog_message, sizeMB) - } else { - stringResource(R.string.prepare_apk_dialog_message_unknown_size) - }, + text = stringResource( + R.string.prepare_apk_dialog_message_unknown_size + ), style = MaterialTheme.typography.bodyMedium ) }, @@ -903,8 +888,7 @@ fun AboutSheet( } // Show sharing rows only when APK is ready - val canShareAPK = apkStatus is ApkPreparationStatus.Ready || - apkStatus is ApkPreparationStatus.UpdateAvailable + val canShareAPK = apkStatus is ApkPreparationStatus.Ready AnimatedVisibility( visible = canShareAPK, 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 16dbdc8f..29b066c1 100644 --- a/app/src/main/java/com/bitchat/android/ui/ApkDownloadViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ApkDownloadViewModel.kt @@ -24,21 +24,16 @@ import kotlinx.coroutines.withContext sealed class ApkPreparationStatus { object Loading : ApkPreparationStatus() - data class NotDownloaded(val sizeMB: Int?) : ApkPreparationStatus() + object NotDownloaded : ApkPreparationStatus() data class Ready( val version: String, val sizeMB: Int, val source: UniversalApkManager.ApkSource, val variant: ShareableApkVariant ) : ApkPreparationStatus() - data class UpdateAvailable( - val currentVersion: String, - val newVersion: String, - val newSizeMB: Int - ) : 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 + val phase: ApkDownloader.DownloadPhase = ApkDownloader.DownloadPhase.SelectingSource ) : ApkPreparationStatus() data class Resumable(val progressPercent: Int, val message: String) : ApkPreparationStatus() data class Error(val message: String) : ApkPreparationStatus() @@ -70,6 +65,31 @@ sealed class ApkUiEvent { object CancelDownload : ApkUiEvent() } +// --- Row tap --- + +/** What tapping the body of the prepare row does. */ +internal enum class PrepareRowTapAction { + OpenPrepareDialog, + StartDownload +} + +/** + * What a tap on the prepare row means for [status], or null when the row has nothing to offer. + * + * The trailing controls are icon-only, so the row body is the discoverable half of every action + * and has to stay in step with them. Deriving both the tap handler and the row's `enabled` flag + * from this one function keeps the row from looking clickable while doing nothing. + */ +internal fun prepareRowTapAction(status: ApkPreparationStatus): PrepareRowTapAction? = when { + status is ApkPreparationStatus.NotDownloaded -> PrepareRowTapAction.OpenPrepareDialog + // Consent was already given for these; resuming straight away avoids a redundant prompt. + status is ApkPreparationStatus.Resumable -> PrepareRowTapAction.StartDownload + status is ApkPreparationStatus.Error -> PrepareRowTapAction.StartDownload + status is ApkPreparationStatus.Ready && status.variant == ShareableApkVariant.ARM64 -> + PrepareRowTapAction.OpenPrepareDialog + else -> null +} + // --- Effects (ViewModel → UI, one-shot) --- sealed class ApkUiEffect { @@ -120,16 +140,11 @@ class ApkDownloadViewModel(application: Application) : AndroidViewModel(applicat } private fun onPrepareRowClicked() { - when (_state.value.apkStatus) { - is ApkPreparationStatus.NotDownloaded, - is ApkPreparationStatus.UpdateAvailable, - is ApkPreparationStatus.Error -> { + when (prepareRowTapAction(_state.value.apkStatus)) { + PrepareRowTapAction.OpenPrepareDialog -> _state.update { it.copy(showPrepareDialog = true) } - } - is ApkPreparationStatus.Resumable -> { - startDownload() - } - else -> {} + PrepareRowTapAction.StartDownload -> startDownload() + null -> {} } } @@ -197,18 +212,12 @@ class ApkDownloadViewModel(application: Application) : AndroidViewModel(applicat private fun onCancelDownload() { downloader.cancelDownload() - // Leave Downloading now, not when the check returns. resolveApkStatus() reaches - // the network and can sit on the route timeout for a full minute, and nothing - // else would clear the spinner in the meantime -- the cancelled job maps to Idle, - // which the observer ignores. Without this the stop button looks broken for the - // whole wait. + // Leave Downloading immediately. The cancelled job maps to Idle, which + // the observer intentionally ignores because local status is resolved below. _state.update { it.copy(apkStatus = ApkPreparationStatus.Loading, downloadProgress = 0) } - // No force needed, and it would be harmful: leaving Downloading above already - // clears the entry guard, while the completion guard must stay armed so a - // download the user restarts during this check is not overwritten by its result. checkStatus() } @@ -234,9 +243,8 @@ class ApkDownloadViewModel(application: Application) : AndroidViewModel(applicat val resolvedStatus = resolveApkStatus() _state.update { current -> - // Re-checked rather than trusted from entry: this resolve reaches the - // network and can take a minute, in which time the user may have started - // a download. Its result must not overwrite work that is now running. + // Re-check in case the user started a download while the local + // artifact was being inspected or copied. if (current.apkStatus is ApkPreparationStatus.Downloading) { current } else { @@ -270,7 +278,7 @@ class ApkDownloadViewModel(application: Application) : AndroidViewModel(applicat sizeMB = info?.let { cached -> (cached.size / 1024 / 1024).toInt() } ?: downloadState.sizeMB, - source = info?.source ?: UniversalApkManager.ApkSource.GITHUB, + source = info?.source ?: UniversalApkManager.ApkSource.DOWNLOADED, variant = info?.variant ?: ShareableApkVariant.UNIVERSAL ), downloadProgress = 100 @@ -291,20 +299,21 @@ class ApkDownloadViewModel(application: Application) : AndroidViewModel(applicat ) ) } - _effect.send(ApkUiEffect.ShowToast(downloadState.message)) + _effect.send(ApkUiEffect.ShowToast(failureMessage(downloadState))) } else { + val message = failureMessage(downloadState) _state.update { if (downloadState.resumablePercent != null) { it.copy( apkStatus = ApkPreparationStatus.Resumable( progressPercent = downloadState.resumablePercent, - message = downloadState.message + message = message ), downloadProgress = downloadState.resumablePercent ) } else { it.copy( - apkStatus = ApkPreparationStatus.Error(downloadState.message) + apkStatus = ApkPreparationStatus.Error(message) ) } } @@ -325,72 +334,41 @@ class ApkDownloadViewModel(application: Application) : AndroidViewModel(applicat return getApplication().getString(resId) } + /** + * The single place a download failure turns into words. The downloader names the failure and + * this resolves it, so the message follows the device locale rather than the worker's. + */ + private fun failureMessage(state: ApkDownloader.DownloadState.Failed): String = + getApplication().getString( + state.messageRes, + *state.messageArgs.toTypedArray() + ) + private suspend fun resolveApkStatus(): ApkPreparationStatus = withContext(Dispatchers.IO) { try { - val updateStatus = apkManager.checkForUpdate() - when (updateStatus) { - is UniversalApkManager.UpdateStatus.NotDownloaded -> { - val partial = apkManager.getPartialDownloadProgress() - if (partial != null) { - ApkPreparationStatus.Resumable( - progressPercent = partial, - message = getString(R.string.prepare_apk_download_interrupted) - ) - } else { - ApkPreparationStatus.NotDownloaded( - sizeMB = (updateStatus.latestRelease.universalApkSize / 1024 / 1024).toInt() - ) - } - } - is UniversalApkManager.UpdateStatus.UpToDate -> { - val info = apkManager.getCachedApkInfo() - if (info != null) { - ApkPreparationStatus.Ready( - version = info.version, - sizeMB = (info.size / 1024 / 1024).toInt(), - source = info.source, - variant = info.variant - ) - } else { - ApkPreparationStatus.Error("Cached APK info not found") - } - } - is UniversalApkManager.UpdateStatus.UpdateAvailable -> { - ApkPreparationStatus.UpdateAvailable( - currentVersion = updateStatus.currentVersion, - newVersion = updateStatus.latestRelease.versionName, - newSizeMB = (updateStatus.latestRelease.universalApkSize / 1024 / 1024).toInt() + val info = apkManager.prepareLocalApkInfo() + if (info != null) { + ApkPreparationStatus.Ready( + version = info.version, + sizeMB = (info.size / 1024 / 1024).toInt(), + source = info.source, + variant = info.variant + ) + } else { + val partial = apkManager.getPartialDownloadProgress() + if (partial != null) { + ApkPreparationStatus.Resumable( + progressPercent = partial, + message = getString(R.string.prepare_apk_download_interrupted) ) - } - is UniversalApkManager.UpdateStatus.Error -> { - // A cached artifact stays shareable even when the update - // check fails or the release lags the installed version. - val info = apkManager.getCachedApkInfo() - if (info != null) { - ApkPreparationStatus.Ready( - version = info.version, - sizeMB = (info.size / 1024 / 1024).toInt(), - source = info.source, - variant = info.variant - ) - } else { - val partial = apkManager.getPartialDownloadProgress() - if (partial != null) { - ApkPreparationStatus.Resumable( - progressPercent = partial, - message = getString(R.string.prepare_apk_download_interrupted) - ) - } else { - ApkPreparationStatus.Error(updateStatus.message) - } - } + } else { + ApkPreparationStatus.NotDownloaded } } } catch (e: Exception) { - Log.e(TAG, "Error checking APK status", e) - ApkPreparationStatus.Error( - e.message ?: getString(R.string.prepare_apk_error_github) - ) + // The exception text is English and often internal; log it, show a translated line. + Log.e(TAG, "Error reading APK status", e) + ApkPreparationStatus.Error(getString(R.string.share_apk_error)) } } } diff --git a/app/src/main/java/com/bitchat/android/ui/ApkPrepareRowControls.kt b/app/src/main/java/com/bitchat/android/ui/ApkPrepareRowControls.kt new file mode 100644 index 00000000..b79060a9 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/ApkPrepareRowControls.kt @@ -0,0 +1,107 @@ +package com.bitchat.android.ui + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearWavyProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.PlainTooltip +import androidx.compose.material3.Text +import androidx.compose.material3.TooltipBox +import androidx.compose.material3.TooltipDefaults +import androidx.compose.material3.rememberTooltipState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp + +/** + * The progress readout for the prepare-for-sharing row. + * + * This sits under the row's subtitle so the trailing slot is free to hold a single control. Which + * of the three renderings applies is decided entirely by [status]; the caller does not choose. + * + * The wave is not decoration. A moving wave means bytes are moving, so a stalled download draws a + * flat line at the fraction it reached rather than a bar indistinguishable from a live one. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +internal fun ApkDownloadProgressBar( + status: ApkPreparationStatus, + progressPercent: Int, + modifier: Modifier = Modifier +) { + val barModifier = modifier + .fillMaxWidth() + .padding(top = 6.dp) + + when { + // Only the transfer knows a fraction. Elsewhere an indeterminate bar is honest about + // having no measure, the same distinction the subtitle already draws. + status is ApkPreparationStatus.Downloading && + status.phase.hasMeasurableProgress && + progressPercent > 0 -> + LinearWavyProgressIndicator( + progress = { progressPercent.asProgressFraction() }, + modifier = barModifier + ) + + status is ApkPreparationStatus.Downloading -> + LinearWavyProgressIndicator(modifier = barModifier) + + // Flat: how far it got, and that it is not getting further on its own. + status is ApkPreparationStatus.Resumable -> + LinearWavyProgressIndicator( + progress = { status.progressPercent.asProgressFraction() }, + amplitude = { 0f }, + modifier = barModifier + ) + } +} + +/** Percentages arrive from a worker across a process boundary, so they are not trusted to be 0..100. */ +private fun Int.asProgressFraction(): Float = (this / 100f).coerceIn(0f, 1f) + +/** + * The single trailing control on the prepare row. + * + * Every status renders exactly one of these at the same width, so the text column beside it keeps + * its measure and stops re-wrapping each time the status changes. + * + * These buttons carry no visible label, which makes [description] load-bearing rather than + * decorative: it is both the TalkBack announcement and the long-press tooltip for a sighted user + * who does not recognise the glyph. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun ApkPrepareRowIconButton( + icon: ImageVector, + description: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + tint: Color = MaterialTheme.colorScheme.onSurfaceVariant +) { + TooltipBox( + positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(), + tooltip = { PlainTooltip { Text(description) } }, + state = rememberTooltipState(), + modifier = modifier + ) { + IconButton( + onClick = onClick, + modifier = Modifier.size(48.dp) + ) { + Icon( + imageVector = icon, + contentDescription = description, + tint = tint, + modifier = Modifier.size(20.dp) + ) + } + } +} diff --git a/app/src/main/java/com/bitchat/android/util/ApkDownloadSource.kt b/app/src/main/java/com/bitchat/android/util/ApkDownloadSource.kt new file mode 100644 index 00000000..f8955e60 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/util/ApkDownloadSource.kt @@ -0,0 +1,232 @@ +package com.bitchat.android.util + +import androidx.annotation.StringRes +import com.bitchat.android.R +import java.io.IOException +import java.time.Instant +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter +import kotlin.math.ceil + +/** + * A trusted location that serves the latest signed universal BitChat APK. + * + * Sources are tried in order. A source may list compatibility filenames, which + * are only used when the preferred asset is absent. Adding a mirror should only + * require another entry; resume, retry, and verification do not depend on the host. + */ +data class ApkDownloadSource( + val id: String, + val displayName: String, + val latestApkUrls: List +) { + constructor(id: String, displayName: String, latestApkUrl: String) : this( + id = id, + displayName = displayName, + latestApkUrls = listOf(latestApkUrl) + ) + + init { + require(id.isNotBlank()) { "Download source id must not be blank" } + require(displayName.isNotBlank()) { "Download source name must not be blank" } + require(latestApkUrls.isNotEmpty()) { "Download source must have at least one URL" } + require(latestApkUrls.distinct().size == latestApkUrls.size) { + "Download source URLs must be unique" + } + require(latestApkUrls.all { it.startsWith("https://") }) { + "APK download sources must use HTTPS" + } + } +} + +internal object DefaultApkDownloadSources { + const val GITHUB_ID = "github-releases" + + val all = listOf( + ApkDownloadSource( + id = GITHUB_ID, + displayName = "GitHub Releases", + latestApkUrls = listOf( + "https://github.com/permissionlesstech/bitchat-android/releases/latest/" + + "download/bitchat-android-universal.apk", + // Releases published before the stable asset-name rollout use + // this filename. Remove when supported releases all use the primary URL. + "https://github.com/permissionlesstech/bitchat-android/releases/latest/" + + "download/app-universal-release.apk" + ) + ) + ) +} + +/** + * A host-neutral download failure that tells the worker whether backoff can help. + * + * [messageRes] and [messageArgs] name what the user should be told without saying it in any + * particular language. This layer has no Context by design — that is what keeps its tests plain + * JUnit — so the ViewModel resolves them. The inherited [message] stays English for logs and + * stack traces, and is never shown. + */ +class ApkDownloadException( + message: String, + @StringRes val messageRes: Int, + val messageArgs: List = emptyList(), + val retryable: Boolean, + val sourceId: String? = null, + val httpCode: Int? = null, + val retryAtMillis: Long? = null, + cause: Throwable? = null +) : IOException(message, cause) + +internal object ApkDownloadRetryPolicy { + const val MAX_ATTEMPTS = 3 + + fun shouldRetry(runAttemptCount: Int, error: Throwable?): Boolean { + val retryable = when (error) { + is ApkDownloadException -> error.retryable + is IOException -> true + else -> false + } + val attemptNumber = runAttemptCount + 1 + return retryable && attemptNumber < MAX_ATTEMPTS + } +} + +internal fun shouldTryNextSourceUrl( + error: ApkDownloadException, + hasMoreUrls: Boolean +): Boolean = hasMoreUrls && error.httpCode == 404 + +internal object ApkDownloadHttpErrors { + fun fromResponse( + source: ApkDownloadSource, + code: Int, + responseMessage: String, + retryAfter: String?, + rateLimitRemaining: String?, + rateLimitResetEpochSeconds: String?, + nowMillis: Long = System.currentTimeMillis() + ): ApkDownloadException { + val retryAt = retryAtMillis( + retryAfter = retryAfter, + rateLimitResetEpochSeconds = rateLimitResetEpochSeconds, + nowMillis = nowMillis + ) + val rateLimited = code == 429 || + (code == 403 && (rateLimitRemaining?.trim() == "0" || retryAt != null)) + + if (rateLimited) { + val minutes = retryAt?.let { deadline -> + ceil((deadline - nowMillis).coerceAtLeast(1L) / 60_000.0).toLong() + } + return ApkDownloadException( + message = "${source.id} rate limited: HTTP $code, retryAt=$retryAt", + messageRes = if (minutes != null) { + R.string.prepare_apk_error_rate_limited_wait + } else { + R.string.prepare_apk_error_rate_limited + }, + messageArgs = listOfNotNull(source.displayName, minutes?.toString()), + retryable = false, + sourceId = source.id, + httpCode = code, + retryAtMillis = retryAt + ) + } + + val retryable = code == 408 || code == 425 || code >= 500 + return ApkDownloadException( + message = "${source.id} failed: HTTP $code $responseMessage", + messageRes = if (code == 404) { + R.string.prepare_apk_error_no_universal + } else { + R.string.prepare_apk_error_http + }, + messageArgs = if (code == 404) { + listOf(source.displayName) + } else { + listOf(source.displayName, code.toString(), responseMessage) + }, + retryable = retryable, + sourceId = source.id, + httpCode = code + ) + } + + internal fun retryAtMillis( + retryAfter: String?, + rateLimitResetEpochSeconds: String?, + nowMillis: Long + ): Long? { + retryAfter?.trim()?.toLongOrNull() + ?.takeIf { it > 0L } + ?.let { seconds -> + runCatching { + Math.addExact(nowMillis, Math.multiplyExact(seconds, 1000L)) + }.getOrNull()?.let { return it } + } + + retryAfter?.trim()?.takeIf { it.isNotEmpty() }?.let { value -> + val parsed = runCatching { + ZonedDateTime.parse(value, DateTimeFormatter.RFC_1123_DATE_TIME) + .toInstant() + .toEpochMilli() + }.getOrNull() + if (parsed != null && parsed > nowMillis) return parsed + } + + return rateLimitResetEpochSeconds?.trim()?.toLongOrNull() + ?.let { runCatching { Instant.ofEpochSecond(it).toEpochMilli() }.getOrNull() } + ?.takeIf { it > nowMillis } + } +} + +internal object AppVersion { + fun isNewer(currentVersion: String, candidateVersion: String): Boolean { + val current = currentVersion.removePrefix("v").trim() + val candidate = candidateVersion.removePrefix("v").trim() + if (current == candidate) return false + + val currentParts = current.split(".").mapNotNull { it.toIntOrNull() } + val candidateParts = candidate.split(".").mapNotNull { it.toIntOrNull() } + val maxLength = maxOf(currentParts.size, candidateParts.size) + + for (index in 0 until maxLength) { + val currentPart = currentParts.getOrNull(index) ?: 0 + val candidatePart = candidateParts.getOrNull(index) ?: 0 + if (candidatePart != currentPart) return candidatePart > currentPart + } + return false + } +} + +internal data class ContentRange( + val start: Long, + val endInclusive: Long, + val total: Long? +) + +internal fun parseContentRange(value: String?): ContentRange? { + if (value == null) return null + val match = Regex("""bytes\s+(\d+)-(\d+)/(\d+|\*)""", RegexOption.IGNORE_CASE) + .matchEntire(value.trim()) + ?: return null + val start = match.groupValues[1].toLongOrNull() ?: return null + val end = match.groupValues[2].toLongOrNull() ?: return null + if (end < start) return null + val total = match.groupValues[3].takeUnless { it == "*" }?.toLongOrNull() + if (total != null && end >= total) return null + return ContentRange( + start = start, + endInclusive = end, + total = total + ) +} + +internal fun parseUnsatisfiedContentRangeTotal(value: String?): Long? { + if (value == null) return null + return Regex("""bytes\s+\*/(\d+)""", RegexOption.IGNORE_CASE) + .matchEntire(value.trim()) + ?.groupValues + ?.get(1) + ?.toLongOrNull() +} 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 b5739a16..710ec1c1 100644 --- a/app/src/main/java/com/bitchat/android/util/ApkDownloadWorker.kt +++ b/app/src/main/java/com/bitchat/android/util/ApkDownloadWorker.kt @@ -36,11 +36,10 @@ class ApkDownloadWorker( const val KEY_PHASE = "phase" const val KEY_VERSION = "version" const val KEY_SIZE_MB = "size_mb" - const val KEY_ERROR = "error" + const val KEY_ERROR_RES = "error_res" + const val KEY_ERROR_ARGS = "error_args" const val KEY_RESUMABLE_PERCENT = "resumable_percent" - private const val MAX_RETRIES = 3 - private const val CHANNEL_ID = "apk_download" private const val NOTIFICATION_ID = 4201 private const val NOTIFY_STEP_PERCENT = 5 @@ -52,7 +51,7 @@ class ApkDownloadWorker( private var lastNotifiedProgress = -NOTIFY_STEP_PERCENT private var lastProgress = 0 - private var currentPhase = ApkDownloader.DownloadPhase.ResolvingRelease + private var currentPhase = ApkDownloader.DownloadPhase.SelectingSource override suspend fun doWork(): Result { Log.d(TAG, "Starting APK download work") @@ -94,19 +93,30 @@ class ApkDownloadWorker( // Retry transient network errors with backoff; the partial file // is kept on disk, so the retry resumes where it left off. - val isRetryable = when (error) { - is GitHubReleaseClient.ReleaseFetchException -> error.retryable - is java.io.IOException -> true - else -> false - } - if (isRetryable && runAttemptCount < MAX_RETRIES) { - Log.w(TAG, "Transient download error (attempt $runAttemptCount), retrying", error) + val attemptNumber = runAttemptCount + 1 + if (ApkDownloadRetryPolicy.shouldRetry(runAttemptCount, error)) { + Log.w( + TAG, + "Transient download error " + + "(attempt $attemptNumber/${ApkDownloadRetryPolicy.MAX_ATTEMPTS}), retrying", + error + ) return Result.retry() } val partial = apkManager.getPartialDownloadProgress() + // Only a named failure carries a localizable message; anything else falls back to a + // generic one rather than leaking an untranslated exception string to the user. + val failure = error as? ApkDownloadException val outputData = Data.Builder() - .putString(KEY_ERROR, error?.message ?: "Download failed") + .putInt( + KEY_ERROR_RES, + failure?.messageRes ?: R.string.prepare_apk_error_generic + ) + .putStringArray( + KEY_ERROR_ARGS, + failure?.messageArgs.orEmpty().toTypedArray() + ) .putInt(KEY_RESUMABLE_PERCENT, partial ?: -1) .build() Result.failure(outputData) @@ -174,13 +184,11 @@ class ApkDownloadWorker( } private fun ensureChannel() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val channel = NotificationChannel( - CHANNEL_ID, - applicationContext.getString(R.string.apk_download_channel_name), - NotificationManager.IMPORTANCE_LOW - ) - notificationManager.createNotificationChannel(channel) - } + val channel = NotificationChannel( + CHANNEL_ID, + applicationContext.getString(R.string.apk_download_channel_name), + NotificationManager.IMPORTANCE_LOW + ) + notificationManager.createNotificationChannel(channel) } } 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 18db97d5..615e2357 100644 --- a/app/src/main/java/com/bitchat/android/util/ApkDownloader.kt +++ b/app/src/main/java/com/bitchat/android/util/ApkDownloader.kt @@ -1,5 +1,6 @@ package com.bitchat.android.util +import androidx.annotation.StringRes import kotlinx.coroutines.flow.Flow /** @@ -34,22 +35,29 @@ interface ApkDownloader { 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() + /** + * [messageRes] and [messageArgs] are resolved by the ViewModel, which has a Context. + * Carrying the ids rather than formatted text keeps the failure localizable all the way + * across the WorkManager boundary. + */ + data class Failed( + @StringRes val messageRes: Int, + val messageArgs: List, + 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. + * Only [Transferring] has meaningful percentage progress; selecting a mirror, + * waiting for connectivity, and checking the signature are indeterminate. */ enum class DownloadPhase { - ResolvingRelease, + AwaitingConnectivity, + SelectingSource, AwaitingNetworkRoute, Transferring, - VerifyingChecksum, VerifyingSignature; /** A percentage is only honest while bytes are actually moving. */ @@ -57,22 +65,26 @@ interface ApkDownloader { 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 + fun fromKey(key: String?): DownloadPhase = when (key) { + // Work created by the previous implementation may still be observable. + "ResolvingRelease" -> SelectingSource + "VerifyingChecksum" -> VerifyingSignature + else -> 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.AwaitingConnectivity -> + com.bitchat.android.R.string.prepare_apk_phase_awaiting_connectivity + ApkDownloader.DownloadPhase.SelectingSource -> + com.bitchat.android.R.string.prepare_apk_phase_selecting_source 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/GitHubRateLimit.kt b/app/src/main/java/com/bitchat/android/util/GitHubRateLimit.kt deleted file mode 100644 index c03f25c5..00000000 --- a/app/src/main/java/com/bitchat/android/util/GitHubRateLimit.kt +++ /dev/null @@ -1,61 +0,0 @@ -package com.bitchat.android.util - -/** - * Reads GitHub's rate-limit rejections so the app can stop asking. - * - * Unauthenticated requests are capped at 60 an hour *per IP*, and when the app routes through Tor - * that IP belongs to an exit node shared with every other user on it, so the ceiling arrives much - * sooner than the per-user maths suggests. Retrying a rejection is pure waste, and repeating it on - * every screen open is what turns a brief limit into a permanent one. - */ -internal object GitHubRateLimit { - - /** Used when GitHub rejects a request without saying when to come back. */ - const val DEFAULT_BACKOFF_MILLIS = 10 * 60 * 1000L - - /** Never sit out longer than this, however far ahead the reset header claims to be. */ - const val MAX_BACKOFF_MILLIS = 60 * 60 * 1000L - - /** - * A 403 alone is not enough: GitHub also uses it for ordinary permission failures. - * - * Three things count as a rate limit. An explicit 429. A 403 reporting zero remaining quota, - * which is the primary hourly limit. And a 403 carrying Retry-After while quota remains, which - * is how secondary limits arrive — abuse detection rather than the hourly budget, so treating - * it as a permissions failure leaves the gate unset and keeps the app calling during exactly - * the cooldown GitHub asked for. - */ - fun isRateLimited(code: Int, remaining: String?, retryAfterSeconds: String? = null): Boolean = - code == 429 || - (code == 403 && (remaining?.trim() == "0" || retryAfterDelayMillis(retryAfterSeconds) != null)) - - private fun retryAfterDelayMillis(retryAfterSeconds: String?): Long? = - retryAfterSeconds?.trim()?.toLongOrNull()?.takeIf { it > 0 }?.let { it * 1000 } - - /** - * Epoch millis before which no further request should be sent, or null when the response was - * not a rate-limit rejection at all. - */ - fun blockedUntilMillis( - code: Int, - remaining: String?, - resetEpochSeconds: String?, - retryAfterSeconds: String?, - nowMillis: Long, - ): Long? { - if (!isRateLimited(code, remaining, retryAfterSeconds)) return null - - // Retry-After is a delta and is what GitHub sends for secondary limits, which can lift - // sooner than the primary window X-RateLimit-Reset describes. - val fromRetryAfter = retryAfterDelayMillis(retryAfterSeconds)?.let { nowMillis + it } - - // Dropped when it is not in the future: a skewed device clock must not turn a genuine - // rejection into "retry immediately". - val fromReset = resetEpochSeconds?.trim()?.toLongOrNull() - ?.let { it * 1000 } - ?.takeIf { it > nowMillis } - - val target = fromRetryAfter ?: fromReset ?: (nowMillis + DEFAULT_BACKOFF_MILLIS) - return target.coerceIn(nowMillis, nowMillis + MAX_BACKOFF_MILLIS) - } -} diff --git a/app/src/main/java/com/bitchat/android/util/GitHubReleaseClient.kt b/app/src/main/java/com/bitchat/android/util/GitHubReleaseClient.kt deleted file mode 100644 index a5acefe2..00000000 --- a/app/src/main/java/com/bitchat/android/util/GitHubReleaseClient.kt +++ /dev/null @@ -1,514 +0,0 @@ -package com.bitchat.android.util - -import android.util.Log -import com.bitchat.android.net.ArtiTorManager -import com.bitchat.android.net.OkHttpProvider -import com.bitchat.android.net.TorMode -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.withContext -import okhttp3.Request -import org.json.JSONObject -import java.io.IOException -import java.util.concurrent.TimeUnit - -/** - * Client for fetching BitChat release information from GitHub API. - */ -object GitHubReleaseClient { - private const val TAG = "GitHubAPI" - private const val GITHUB_API_URL = "https://api.github.com/repos/permissionlesstech/bitchat-android/releases/latest" - private const val USER_AGENT = "BitChat-Android" - private const val CACHE_TTL_MILLIS = 10 * 60 * 1000L - private const val MAX_FETCH_ATTEMPTS = 3 - private const val ROUTE_READY_TIMEOUT_MILLIS = 60_000L - private const val HTTP_NOT_MODIFIED = 304 - - private val fetchMutex = Mutex() - - @Volatile - private var cachedRelease: CachedRelease? = null - - /** - * ETag of the cached release, replayed as `If-None-Match`. GitHub does not charge a 304 - * against the rate limit, so revalidating an expired cache this way costs nothing where an - * unconditional refetch costs one of only 60 hourly requests. - */ - @Volatile - private var cachedEtag: String? = null - - /** - * Epoch millis before which GitHub has already told us it will reject anything we send, - * held per route. - * - * GitHub counts unauthenticated requests per IP, so a Tor exit and a direct connection - * have separate quotas. They are kept side by side rather than as one deadline that - * moves with the route: replacing it would mean switching away and back forgets a - * cooldown that is still running, and the app would hit the limited exit again. - * - * Without any of this, an exhausted quota fed itself: nothing cached the failure, so - * every screen that asked for release info spent three more requests rediscovering the - * same limit. - */ - @Volatile - private var torBlockedUntilMillis = 0L - - @Volatile - private var directBlockedUntilMillis = 0L - - /** - * The route requests will take, which is what the quota belongs to. - * - * Deliberately the selected mode rather than `isProxyEnabled()`: that reports - * readiness, and is false while Tor is still bootstrapping or restarting even though - * requests will still go through Tor once it is up. - */ - private fun selectedRouteUsesTor(): Boolean? = - runCatching { ArtiTorManager.getInstance().statusFlow.value.mode != TorMode.OFF } - .getOrNull() - - /** - * The deadline for the route about to be used. When the route cannot be determined the - * stricter of the two applies: failing to identify it must not release a real cooldown. - */ - private fun blockedUntilFor(routeUsesTor: Boolean?): Long = when (routeUsesTor) { - true -> torBlockedUntilMillis - false -> directBlockedUntilMillis - null -> maxOf(torBlockedUntilMillis, directBlockedUntilMillis) - } - - private fun recordBlockedUntil(untilMillis: Long, routeUsesTor: Boolean?) { - when (routeUsesTor) { - true -> torBlockedUntilMillis = untilMillis - false -> directBlockedUntilMillis = untilMillis - null -> { - torBlockedUntilMillis = untilMillis - directBlockedUntilMillis = untilMillis - } - } - } - - /** A success proves this route is clear. The other route's cooldown is left alone. */ - private fun clearBlockedFor(routeUsesTor: Boolean?) { - when (routeUsesTor) { - true -> torBlockedUntilMillis = 0L - false -> directBlockedUntilMillis = 0L - null -> { - torBlockedUntilMillis = 0L - directBlockedUntilMillis = 0L - } - } - } - - /** - * The gate's answer for [routeUsesTor], or null when nothing blocks the request. - * - * Sending a request GitHub has already said it will reject helps nobody and pushes - * the reset further out, so a stale release is a better answer than an error the - * user cannot act on. - */ - private fun blockedResultOrNull( - nowMillis: Long, - routeUsesTor: Boolean?, - cached: CachedRelease?, - ): Result? { - val blockedUntil = blockedUntilFor(routeUsesTor) - if (nowMillis >= blockedUntil) return null - - val waitMinutes = (blockedUntil - nowMillis) / 60_000 + 1 - Log.w(TAG, "Rate limited; not contacting GitHub for another ${waitMinutes}min") - cached?.let { return Result.success(it.release) } - return Result.failure( - ReleaseFetchException( - message = "GitHub API rate limit reached. Try again in " + - "$waitMinutes minute${if (waitMinutes == 1L) "" else "s"}.", - httpCode = 429, - retryable = false - ) - ) - } - - private val client - get() = OkHttpProvider.httpClient().newBuilder() - // GitHub requests may travel through Tor, where a 15-second total - // timeout is too aggressive during circuit establishment. - .callTimeout(45, TimeUnit.SECONDS) - .connectTimeout(20, TimeUnit.SECONDS) - .readTimeout(30, TimeUnit.SECONDS) - .build() - - /** - * Fetch the latest release information from GitHub. - * Successful metadata is cached briefly so the status screen and download - * worker use the same release snapshot instead of making duplicate calls. - */ - /** - * @param onAwaitingNetworkRoute invoked if this call is about to block on the selected route - * (a Tor bootstrap can take the better part of a minute). A cache hit returns before that - * point and never invokes it, so callers can report the wait only when there is one. - */ - suspend fun fetchLatestRelease( - forceRefresh: Boolean = false, - onAwaitingNetworkRoute: (() -> Unit)? = null, - onResolvingRelease: (() -> Unit)? = null, - ): Result = - withContext(Dispatchers.IO) { - fetchMutex.withLock { - val now = System.currentTimeMillis() - val cached = cachedRelease - - if (!forceRefresh && - cached != null && - now - cached.fetchedAtMillis < CACHE_TTL_MILLIS - ) { - return@withLock Result.success(cached.release) - } - - // Honoured even on an explicit refresh. - blockedResultOrNull(now, selectedRouteUsesTor(), cached) - ?.let { return@withLock it } - - onAwaitingNetworkRoute?.invoke() - if (!awaitSelectedNetworkRoute()) { - return@withLock Result.failure( - ReleaseFetchException( - message = "Tor is still connecting. Try again when Tor is ready.", - retryable = true - ) - ) - } - // The wait is over, so stop saying we are waiting. In direct mode it - // returned immediately and never really started, and the fetch below - // retries -- either way the caller must not keep reporting a Tor wait - // for the whole metadata request. - onResolvingRelease?.invoke() - - var lastFailure: Throwable = ReleaseFetchException( - "Failed to fetch the latest release from GitHub" - ) - - repeat(MAX_FETCH_ATTEMPTS) { attempt -> - // Sampled immediately before the call and reused for its response, so - // a route change mid-flight cannot file the cooldown against the route - // the request did not use. - val routeUsesTor = selectedRouteUsesTor() - - // Per attempt rather than once before the loop. The route can change - // during the wait above, during a request, or during a backoff, and - // the one we have just switched to may carry a cooldown of its own. - blockedResultOrNull(System.currentTimeMillis(), routeUsesTor, cached) - ?.let { return@withLock it } - - val result = fetchLatestReleaseOnce(routeUsesTor) - result.onSuccess { release -> - cachedRelease = CachedRelease(release, System.currentTimeMillis()) - return@withLock Result.success(release) - } - lastFailure = result.exceptionOrNull() ?: lastFailure - - // The response that just set the gate is the one the user is waiting - // on. Reporting an error here and only serving the cache on the next - // call makes the first check fail and an immediate retry succeed from - // metadata we already had. - if (System.currentTimeMillis() < blockedUntilFor(routeUsesTor)) { - cached?.let { - Log.w(TAG, "Rate limited; serving the cached release instead of failing") - return@withLock Result.success(it.release) - } - return@withLock Result.failure(lastFailure) - } - - if (!isRetryable(lastFailure) || attempt == MAX_FETCH_ATTEMPTS - 1) { - return@withLock Result.failure(lastFailure) - } - - delay(1_000L shl attempt) - } - - Result.failure(lastFailure) - } - } - - /** - * Wait for Tor when it is the selected route. This deliberately does not - * fall back to a direct connection because doing so would violate the - * user's Tor preference. - */ - suspend fun awaitSelectedNetworkRoute(): Boolean { - return ArtiTorManager.getInstance() - .awaitSelectedRoute(ROUTE_READY_TIMEOUT_MILLIS) - } - - private fun fetchLatestReleaseOnce(routeUsesTor: Boolean?): Result { - val cached = cachedRelease - val etag = cachedEtag - return try { - Log.d(TAG, "Fetching latest release from GitHub API") - val request = Request.Builder() - .url(GITHUB_API_URL) - .addHeader("User-Agent", USER_AGENT) - .addHeader("Accept", "application/vnd.github+json") - .addHeader("X-GitHub-Api-Version", "2022-11-28") - .apply { - // Revalidate rather than refetch. GitHub does not charge a 304 against the - // hourly quota, so an unchanged release costs nothing to confirm. - if (cached != null && etag != null) addHeader("If-None-Match", etag) - } - .build() - - client.newCall(request).execute().use { response -> - if (response.code == HTTP_NOT_MODIFIED && cached != null) { - Log.d(TAG, "Release unchanged; cache revalidated at no quota cost") - cachedRelease = cached.copy(fetchedAtMillis = System.currentTimeMillis()) - return Result.success(cached.release) - } - - if (!response.isSuccessful) { - val blockedUntil = GitHubRateLimit.blockedUntilMillis( - code = response.code, - remaining = response.header("X-RateLimit-Remaining"), - resetEpochSeconds = response.header("X-RateLimit-Reset"), - retryAfterSeconds = response.header("Retry-After"), - nowMillis = System.currentTimeMillis(), - ) - if (blockedUntil != null) { - // Recorded against the route the request took, not the one - // selected now: the setting can change while a call is in flight. - recordBlockedUntil(blockedUntil, routeUsesTor) - } - - val message = if (blockedUntil != null) { - val waitMinutes = - (blockedUntil - System.currentTimeMillis()) / 60_000 + 1 - "GitHub API rate limit exceeded. Try again in " + - "$waitMinutes minute${if (waitMinutes == 1L) "" else "s"}." - } else { - "GitHub release request failed: HTTP ${response.code} ${response.message}" - } - Log.e(TAG, message) - return Result.failure( - ReleaseFetchException( - message = message, - httpCode = response.code, - // A rate limit is never worth an in-loop retry: the gate in - // fetchLatestRelease decides when it is worth asking again. A plain - // 403 is a permissions failure and will not fix itself either. - retryable = blockedUntil == null && - (response.code == 408 || response.code >= 500) - ) - ) - } - - val body = response.body?.string() - if (body.isNullOrBlank()) { - return Result.failure( - ReleaseFetchException( - message = "GitHub returned an empty response", - retryable = true - ) - ) - } - - val release = parseRelease(body) - ?: return Result.failure( - ReleaseFetchException( - message = "GitHub's latest release has no universal APK asset", - retryable = false - ) - ) - // Kept alongside the release so the pair can never drift: a stale ETag would - // revalidate to a 304 that confirms a release we no longer hold. - cachedEtag = response.header("ETag") - clearBlockedFor(routeUsesTor) - Result.success(release) - } - } catch (e: IOException) { - Log.e(TAG, "Network error fetching release", e) - Result.failure( - ReleaseFetchException( - "Could not reach GitHub${e.message?.let { ": $it" } ?: ""}", - cause = e - ) - ) - } catch (e: Exception) { - Log.e(TAG, "Error fetching release", e) - Result.failure(ReleaseFetchException("Invalid GitHub release response", cause = e)) - } - } - - private fun isRetryable(error: Throwable): Boolean { - return error !is ReleaseFetchException || error.retryable - } - - /** - * Parse GitHub API JSON response into Release object. - */ - internal fun parseRelease(jsonString: String): Release? { - try { - val json = JSONObject(jsonString) - val tagName = json.optString("tag_name", "") - val versionName = tagName.removePrefix("v") // Remove "v" prefix if present - - if (versionName.isBlank()) { - Log.e(TAG, "No version tag found in release") - return null - } - - Log.d(TAG, "Found release: $versionName") - - // Parse assets array to find universal APK - val assets = json.optJSONArray("assets") - if (assets == null || assets.length() == 0) { - Log.e(TAG, "No assets found in release") - return null - } - - // Look for universal APK (usually named "app-universal-release.apk") - for (i in 0 until assets.length()) { - val asset = assets.getJSONObject(i) - val name = asset.optString("name", "") - - if (name.contains("universal", ignoreCase = true) && name.endsWith(".apk")) { - val downloadUrl = asset.optString("browser_download_url", "") - val size = asset.optLong("size", 0L) - - if (downloadUrl.isBlank()) { - Log.e(TAG, "Universal APK found but no download URL") - continue - } - - // Prefer GitHub's asset digest when available, then fall - // back to release notes used by older releases. - val body = json.optString("body", "") - val assetDigest = asset.optString("digest", "") - .takeIf { it.startsWith("sha256:", ignoreCase = true) } - ?.substringAfter(":") - ?.takeIf { it.matches(Regex("[a-fA-F0-9]{64}")) } - ?.lowercase() - val sha256 = assetDigest ?: extractSha256FromBody(body, name) - - Log.d(TAG, "Found universal APK: $name (${size / 1024 / 1024}MB)") - - return Release( - tagName = tagName, - versionName = versionName, - universalApkUrl = downloadUrl, - universalApkSha256 = sha256, - universalApkSize = size, - universalApkName = name - ) - } - } - - Log.e(TAG, "No universal APK found in release assets") - return null - - } catch (e: Exception) { - Log.e(TAG, "Error parsing release JSON", e) - return null - } - } - - /** - * Extract SHA256 checksum from release body/notes. - * Looks for patterns like: - * - sha256:abc123... - * - SHA256: abc123... - * - app-universal-release.apk: abc123... - */ - private fun extractSha256FromBody(body: String, apkName: String): String? { - if (body.isBlank()) return null - - try { - // Pattern 1: Look for "sha256:" followed by hash - val sha256Pattern = Regex("""sha256:\s*([a-fA-F0-9]{64})""", RegexOption.IGNORE_CASE) - sha256Pattern.find(body)?.let { match -> - return match.groupValues[1].lowercase() - } - - // Pattern 2: Look for APK name followed by hash - val apkPattern = Regex("""${Regex.escape(apkName)}.*?([a-fA-F0-9]{64})""", RegexOption.IGNORE_CASE) - apkPattern.find(body)?.let { match -> - return match.groupValues[1].lowercase() - } - - Log.w(TAG, "Could not extract SHA256 from release body") - return null - - } catch (e: Exception) { - Log.w(TAG, "Error extracting SHA256", e) - return null - } - } - - /** - * Check if a newer version is available. - * @param currentVersion Current installed/cached version - * @param latestRelease Latest release from GitHub - * @return true if latestRelease is newer - */ - fun isNewerVersion(currentVersion: String, latestRelease: Release): Boolean { - return isNewerVersion(currentVersion, latestRelease.versionName) - } - - internal fun isNewerVersion(currentVersion: String, candidateVersion: String): Boolean { - return try { - // Simple version comparison (assumes semantic versioning) - // Remove any non-numeric prefixes - val current = currentVersion.removePrefix("v").trim() - val latest = candidateVersion.removePrefix("v").trim() - - if (current == latest) { - return false - } - - // Split by dots and compare each part - val currentParts = current.split(".").mapNotNull { it.toIntOrNull() } - val latestParts = latest.split(".").mapNotNull { it.toIntOrNull() } - - val maxLength = maxOf(currentParts.size, latestParts.size) - - for (i in 0 until maxLength) { - val currentPart = currentParts.getOrNull(i) ?: 0 - val latestPart = latestParts.getOrNull(i) ?: 0 - - if (latestPart > currentPart) { - return true - } else if (latestPart < currentPart) { - return false - } - } - - false - } catch (e: Exception) { - Log.e(TAG, "Error comparing versions", e) - false - } - } - - /** - * Release information from GitHub. - */ - data class Release( - val tagName: String, - val versionName: String, - val universalApkUrl: String, - val universalApkSha256: String?, - val universalApkSize: Long, - val universalApkName: String - ) - - class ReleaseFetchException( - message: String, - val httpCode: Int? = null, - val retryable: Boolean = true, - cause: Throwable? = null - ) : IOException(message, cause) - - private data class CachedRelease( - val release: Release, - val fetchedAtMillis: Long - ) -} 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 a457c8b2..a50bfd8f 100644 --- a/app/src/main/java/com/bitchat/android/util/UniversalApkManager.kt +++ b/app/src/main/java/com/bitchat/android/util/UniversalApkManager.kt @@ -4,7 +4,10 @@ import android.content.Context import android.content.pm.PackageManager import android.os.Build import android.util.Log +import androidx.annotation.StringRes +import com.bitchat.android.R import com.bitchat.android.BuildConfig +import com.bitchat.android.net.ArtiTorManager import com.bitchat.android.net.OkHttpProvider import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers @@ -26,7 +29,15 @@ import java.security.MessageDigest /** * Manages local and downloaded APK artifacts for offline sharing. */ -class UniversalApkManager(private val context: Context) { +class UniversalApkManager( + private val context: Context, + private val downloadSources: List = DefaultApkDownloadSources.all +) { + init { + require(downloadSources.map { it.id }.distinct().size == downloadSources.size) { + "APK download source ids must be unique" + } + } companion object { private const val TAG = "UniversalApk" @@ -34,6 +45,8 @@ class UniversalApkManager(private val context: Context) { private const val METADATA_FILE_NAME = "universal_apk_info.json" private const val PROGRESS_FILE_NAME = "download_progress.json" private const val APK_FILE_PREFIX = "bitchat-universal-" + private const val TEMP_FILE_NAME = "download_temp.apk" + private const val ROUTE_READY_TIMEOUT_MILLIS = 60_000L // Download buffer size (128KB) private const val BUFFER_SIZE = 128 * 1024 @@ -64,13 +77,16 @@ class UniversalApkManager(private val context: Context) { val json = JSONObject(metadataFile.readText()) val version = json.optString("version", "") - val checksum = json.optString("checksum", "") val downloadDate = json.optLong("downloadDate", 0L) val size = json.optLong("size", 0L) val fileName = json.optString("fileName", "") - val source = runCatching { - ApkSource.valueOf(json.optString("source", ApkSource.GITHUB.name)) - }.getOrDefault(ApkSource.GITHUB) + val source = when (json.optString("source")) { + ApkSource.INSTALLED.name -> ApkSource.INSTALLED + // Migrate metadata written before downloads became mirror-agnostic. + else -> ApkSource.DOWNLOADED + } + val downloadSourceId = json.optString("downloadSourceId") + .takeIf { it.isNotBlank() } if (version.isBlank() || fileName.isBlank()) { return null @@ -91,12 +107,12 @@ class UniversalApkManager(private val context: Context) { ApkInfo( version = version, - checksum = checksum, downloadDate = downloadDate, size = size, file = apkFile, source = source, - variant = variant + variant = variant, + downloadSourceId = downloadSourceId ) } catch (e: Exception) { Log.e(TAG, "Error reading cached APK info", e) @@ -116,10 +132,10 @@ class UniversalApkManager(private val context: Context) { * Returns the progress percentage (0-100) or null if no partial download. */ fun getPartialDownloadProgress(): Int? { - val tempFile = File(cacheDir, "download_temp.apk") + val tempFile = File(cacheDir, TEMP_FILE_NAME) val resumeInfo = loadResumeInfo() if (tempFile.exists() && resumeInfo != null) { - val expectedSize = resumeInfo.optLong("expectedSize", 0L) + val expectedSize = resumeInfo.expectedSize if (expectedSize > 0) { return ((tempFile.length() * 100) / expectedSize).toInt().coerceIn(0, 99) } @@ -128,58 +144,12 @@ class UniversalApkManager(private val context: Context) { } /** - * Check for updates from GitHub. - * @return UpdateStatus indicating if update is available, current version, etc. + * Prepare or read the best local sharing artifact. This never performs a + * network request, so opening the About sheet cannot consume API quota or + * wait for Tor. */ - suspend fun checkForUpdate(): UpdateStatus = withContext(Dispatchers.IO) { - try { - // A supported standalone APK is already an installable sharing - // artifact. Split installs still need the universal GitHub artifact. - val installedApkInfo = cacheInstalledApkIfPreferred() - if (installedApkInfo?.source == ApkSource.INSTALLED) { - return@withContext UpdateStatus.UpToDate(installedApkInfo.version) - } - - val cachedInfo = getCachedApkInfo() - val latestRelease = GitHubReleaseClient.fetchLatestRelease().getOrElse { error -> - return@withContext UpdateStatus.Error( - error.message ?: "Failed to fetch latest release from GitHub" - ) - } - // The GitHub release may briefly lag behind the installed version - // (upstream bumps versionName in main before tagging the release). - // An older release is still a genuine, signed, universal artifact — - // recipients with a newer install can't be downgraded by Android - // anyway — so share it rather than disabling the feature. - if (isOlderThanInstalledVersion(latestRelease.versionName)) { - Log.i( - TAG, - "GitHub universal APK ${latestRelease.versionName} is older than installed " + - "app ${installedVersionName()}; sharing it until the matching release ships" - ) - } - - if (cachedInfo == null) { - // No cached APK - return@withContext UpdateStatus.NotDownloaded(latestRelease) - } - - // Compare versions - val isNewer = GitHubReleaseClient.isNewerVersion(cachedInfo.version, latestRelease) - - if (isNewer) { - UpdateStatus.UpdateAvailable( - currentVersion = cachedInfo.version, - latestRelease = latestRelease - ) - } else { - UpdateStatus.UpToDate(cachedInfo.version) - } - - } catch (e: Exception) { - Log.e(TAG, "Error checking for update", e) - UpdateStatus.Error(e.message ?: "Unknown error") - } + suspend fun prepareLocalApkInfo(): ApkInfo? = withContext(Dispatchers.IO) { + cacheInstalledApkIfPreferred() ?: getCachedApkInfo() } /** @@ -196,261 +166,343 @@ class UniversalApkManager(private val context: Context) { val availableMB = availableSpace / 1024 / 1024 val error = "Insufficient storage: need ${requiredMB}MB, have ${availableMB}MB" Log.e(TAG, error) - throw IOException(error) + throw ApkDownloadException( + message = error, + messageRes = R.string.prepare_apk_error_storage_needed, + messageArgs = listOf(requiredMB.toString(), availableMB.toString()), + retryable = false + ) } } /** - * Download the universal APK from GitHub with resume support. - * @param progressCallback Called with progress percentage (0-100) - * @return Result with File on success, or error message + * Download from the configured sources. Each source gets one attempt in this + * worker run; WorkManager owns retry/backoff across runs. */ suspend fun downloadUniversalApk( 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") - - // Fetch latest release info - // 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) - // The fetch waits on the route itself when it has to go to the network, so it - // reports that from the inside. Labelling the whole call "resolving release" - // would put the app's own name on the long Tor wait this phase exists to explain. - val release = GitHubReleaseClient.fetchLatestRelease( - onAwaitingNetworkRoute = { - phaseCallback?.invoke(ApkDownloader.DownloadPhase.AwaitingNetworkRoute) - }, - onResolvingRelease = { - phaseCallback?.invoke(ApkDownloader.DownloadPhase.ResolvingRelease) - } - ).getOrElse { error -> - return@withContext Result.failure(error) - } - - // A cache hit skips the fetch's own wait, so for that path the route wait is here. - 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.") + if (downloadSources.isEmpty()) { + return@withContext Result.failure( + ApkDownloadException( + message = "No APK download sources are configured.", + messageRes = R.string.prepare_apk_error_no_sources, + retryable = false ) - } - phaseCallback?.invoke(ApkDownloader.DownloadPhase.Transferring) - - val url = release.universalApkUrl - val expectedSize = release.universalApkSize - - Log.d(TAG, "Downloading from: $url") - Log.d(TAG, "Expected size: ${expectedSize / 1024 / 1024}MB") - - val tempFile = File(cacheDir, "download_temp.apk") - - // Check for resumable download - var existingBytes = 0L - if (tempFile.exists()) { - val resumeInfo = loadResumeInfo() - if (resumeInfo != null && - resumeInfo.optString("url") == url && - resumeInfo.optString("versionName") == release.versionName - ) { - existingBytes = tempFile.length() - Log.d(TAG, "Resuming download from $existingBytes bytes") - } else { - Log.d(TAG, "Stale temp file found, starting fresh") - tempFile.delete() - progressFile.delete() - } - } - - // Bytes already in the temp file have already consumed storage, so - // a resume only needs room for the remaining tail. Promotion is a - // rename and needs no extra space. - checkDiskSpace((expectedSize - existingBytes).coerceAtLeast(0)) - - // A temp file that already holds the full asset means the process - // died between download and verification. Requesting - // "Range: bytes=-" for it would get HTTP 416 forever, so skip - // the network and let checksum/signature verification decide its fate. - if (expectedSize > 0 && existingBytes >= expectedSize) { - Log.d(TAG, "Temp file already complete ($existingBytes bytes), skipping to verification") - } else { - val requestBuilder = Request.Builder() - .url(url) - .addHeader("User-Agent", "BitChat-Android") - - if (existingBytes > 0) { - requestBuilder.addHeader("Range", "bytes=$existingBytes-") - Log.d(TAG, "Added Range header: bytes=$existingBytes-") - } - - val request = requestBuilder.build() - downloadToTempFile( - call = downloadClient.newCall(request), - tempFile = tempFile, - url = url, - expectedSize = expectedSize, - versionName = release.versionName, - existingBytes = existingBytes, - progressCallback = progressCallback - ) - } - - // 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() - progressFile.delete() - return@withContext Result.failure( - Exception("Checksum verification failed. Downloaded file may be corrupted.") - ) - } - Log.d(TAG, "Checksum verified successfully") - } else { - Log.w(TAG, "No checksum available for verification") - } - - // 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() - return@withContext Result.failure( - Exception("APK signature verification failed. The downloaded APK is not signed by a trusted BitChat release key.") - ) - } - Log.d(TAG, "Signature verified successfully") - - if (!DistributionInfoProvider.isUniversalApk(tempFile)) { - tempFile.delete() - progressFile.delete() - return@withContext Result.failure( - Exception( - "GitHub asset is architecture-specific, not universal. " + - "Release packaging must be corrected." - ) - ) - } - - // Move to final location without deleting the currently usable APK - // first. Old versions are removed only after the replacement and - // metadata have both been committed. - val finalFileName = "$APK_FILE_PREFIX${release.versionName}.apk" - val finalFile = File(cacheDir, finalFileName) - replaceFileSafely(tempFile, finalFile) - - // Clean up resume metadata on success - progressFile.delete() - - // Save metadata - saveMetadata( - version = release.versionName, - checksum = release.universalApkSha256 ?: "", - size = finalFile.length(), - fileName = finalFileName, - source = ApkSource.GITHUB, - variant = ShareableApkVariant.UNIVERSAL ) - cleanupOldApks(except = finalFile) + } - Log.d(TAG, "Universal APK downloaded successfully: ${finalFile.path}") - Result.success(finalFile) + try { + phaseCallback?.invoke(ApkDownloader.DownloadPhase.AwaitingNetworkRoute) + if (!ArtiTorManager.getInstance().awaitSelectedRoute(ROUTE_READY_TIMEOUT_MILLIS)) { + return@withContext Result.failure( + ApkDownloadException( + message = "Tor is still connecting.", + messageRes = R.string.prepare_apk_error_tor_connecting, + retryable = true + ) + ) + } + val failures = mutableListOf() + val sources = sourcesWithResumeFirst() + for ((index, source) in sources.withIndex()) { + phaseCallback?.invoke(ApkDownloader.DownloadPhase.SelectingSource) + if (index > 0) clearPartialDownload() + + try { + Log.d(TAG, "Downloading universal APK from ${source.displayName}") + phaseCallback?.invoke(ApkDownloader.DownloadPhase.Transferring) + val tempFile = downloadFromSource(source, progressCallback) + + phaseCallback?.invoke(ApkDownloader.DownloadPhase.VerifyingSignature) + validateDownloadedApk(tempFile, source) + + val version = downloadedVersionName(tempFile) + val safeVersion = version.replace(Regex("[^A-Za-z0-9._-]"), "_") + val finalFileName = "$APK_FILE_PREFIX$safeVersion.apk" + val finalFile = File(cacheDir, finalFileName) + replaceFileSafely(tempFile, finalFile) + progressFile.delete() + + saveMetadata( + version = version, + size = finalFile.length(), + fileName = finalFileName, + source = ApkSource.DOWNLOADED, + variant = ShareableApkVariant.UNIVERSAL, + downloadSourceId = source.id + ) + cleanupOldApks(except = finalFile) + Log.d(TAG, "Universal APK downloaded successfully from ${source.displayName}") + return@withContext Result.success(finalFile) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + val failure = e.asDownloadException(source) + failures += failure + Log.w(TAG, "${source.displayName} download failed", failure) + if (index < sources.lastIndex) { + Log.i(TAG, "Trying the next configured APK source") + } + } + } + + Result.failure(combineSourceFailures(failures)) } catch (e: CancellationException) { throw e - } catch (e: IOException) { - Log.e(TAG, "Network error downloading APK", e) - Result.failure(e) } catch (e: Exception) { Log.e(TAG, "Error downloading APK", e) Result.failure(e) } } + private fun sourcesWithResumeFirst(): List { + val tempFile = File(cacheDir, TEMP_FILE_NAME) + val resume = loadResumeInfo() + if (!tempFile.exists() || resume == null) return downloadSources + + val resumedSource = downloadSources.firstOrNull { it.id == resume.sourceId } + if (resumedSource == null) { + clearPartialDownload() + return downloadSources + } + return listOf(resumedSource) + downloadSources.filterNot { it.id == resumedSource.id } + } + + private suspend fun downloadFromSource( + source: ApkDownloadSource, + progressCallback: ((Int) -> Unit)? + ): File { + val tempFile = File(cacheDir, TEMP_FILE_NAME) + var resume = loadResumeInfo() + if (resume?.sourceId != source.id) { + clearPartialDownload() + resume = null + } + + var existingBytes = if (resume != null && tempFile.exists()) tempFile.length() else 0L + if (resume != null && resume.expectedSize > 0L && existingBytes == resume.expectedSize) { + Log.d(TAG, "Partial file is complete; continuing with APK verification") + return tempFile + } + if (resume != null && resume.expectedSize > 0L && existingBytes > resume.expectedSize) { + clearPartialDownload() + resume = null + existingBytes = 0L + } + + val resumeUrl = resume?.endpointUrl?.takeIf { it in source.latestApkUrls } + if (resume != null && resumeUrl == null) { + clearPartialDownload() + resume = null + existingBytes = 0L + } + + val endpoints = listOfNotNull(resumeUrl) + + source.latestApkUrls.filterNot { it == resumeUrl } + var lastFailure: ApkDownloadException? = null + for ((index, endpointUrl) in endpoints.withIndex()) { + if (index > 0) { + clearPartialDownload() + resume = null + existingBytes = 0L + } + + // A Range request is only safe with a validator. Without If-Range, a + // newly published release could be appended to bytes from the old one. + if (existingBytes > 0L && resume?.validator == null) { + clearPartialDownload() + resume = null + existingBytes = 0L + } + + try { + executeDownloadRequest( + source = source, + endpointUrl = endpointUrl, + tempFile = tempFile, + existingBytes = existingBytes, + resume = resume, + progressCallback = progressCallback + ) + return tempFile + } catch (e: ApkDownloadException) { + lastFailure = e + val assetNameFallback = shouldTryNextSourceUrl( + error = e, + hasMoreUrls = index < endpoints.lastIndex + ) + if (!assetNameFallback) throw e + Log.i(TAG, "APK filename not found; trying ${source.displayName}'s fallback URL") + } + } + throw lastFailure + ?: ApkDownloadException( + message = "${source.id} has no usable APK URL.", + messageRes = R.string.prepare_apk_error_no_url, + messageArgs = listOf(source.displayName), + retryable = false + ) + } + + private suspend fun executeDownloadRequest( + source: ApkDownloadSource, + endpointUrl: String, + tempFile: File, + existingBytes: Long, + resume: ResumeInfo?, + progressCallback: ((Int) -> Unit)? + ) { + val request = Request.Builder() + // Always start from the configured source endpoint. If a release changed, + // If-Range makes the server return 200 and we overwrite the partial. + .url(endpointUrl) + .addHeader("User-Agent", "BitChat-Android") + .apply { + if (existingBytes > 0L) { + addHeader("Range", "bytes=$existingBytes-") + resume?.validator?.let { addHeader("If-Range", it) } + } + } + .build() + + downloadToTempFile( + call = downloadClient.newCall(request), + source = source, + endpointUrl = endpointUrl, + tempFile = tempFile, + existingBytes = existingBytes, + previousResume = resume, + progressCallback = progressCallback + ) + } + /** - * Streams an HTTP response into [tempFile] while keeping the coroutine - * suspended for the lifetime of the response body. Cancelling the worker - * therefore cancels the OkHttp call and promptly unblocks a pending read. + * Streams an HTTP response into [tempFile]. Cancellation cancels the OkHttp + * call, and resume metadata is committed before bytes are appended. */ private suspend fun downloadToTempFile( call: Call, + source: ApkDownloadSource, + endpointUrl: String, tempFile: File, - url: String, - expectedSize: Long, - versionName: String, existingBytes: Long, + previousResume: ResumeInfo?, progressCallback: ((Int) -> Unit)? ) = suspendCancellableCoroutine { continuation -> fun completeSuccessfully() { - continuation.resumeWith(Result.success(Unit)) + if (continuation.isActive) continuation.resumeWith(Result.success(Unit)) } fun completeWithError(error: Throwable) { - continuation.resumeWith(Result.failure(error)) + if (continuation.isActive) continuation.resumeWith(Result.failure(error)) } - continuation.invokeOnCancellation { - call.cancel() - } + continuation.invokeOnCancellation { call.cancel() } try { call.enqueue(object : Callback { override fun onFailure(call: Call, e: IOException) { - completeWithError(e) + completeWithError( + ApkDownloadException( + message = "${source.id} could not be reached" + + (e.message?.let { ": $it" } ?: "."), + messageRes = R.string.prepare_apk_error_unreachable, + messageArgs = listOf(source.displayName), + retryable = true, + sourceId = source.id, + cause = e + ) + ) } override fun onResponse(call: Call, response: Response) { try { response.use { - if (response.code == 416) { - // Our offset is no longer valid for this asset; discard - // the partial state so the retry starts from scratch. - Log.w(TAG, "Server rejected resume range, restarting download") - tempFile.delete() - progressFile.delete() - throw IOException( - "Resume rejected by server. Download will restart." + if (!response.request.url.isHttps) { + throw ApkDownloadException( + message = "${source.id} redirected to an insecure URL.", + messageRes = R.string.prepare_apk_error_insecure_redirect, + messageArgs = listOf(source.displayName), + retryable = false, + sourceId = source.id ) } - if (!response.isSuccessful && response.code != 206) { - throw IOException( - "Download failed: ${response.code} ${response.message}" + if (response.code == 416) { + val total = parseUnsatisfiedContentRangeTotal( + response.header("Content-Range") + ) + if (total != null && total == existingBytes && tempFile.length() == total) { + completeSuccessfully() + return + } + clearPartialDownload() + throw ApkDownloadException( + message = "${source.id} rejected the saved download position.", + messageRes = R.string.prepare_apk_error_resume_rejected, + messageArgs = listOf(source.displayName), + retryable = true, + sourceId = source.id, + httpCode = response.code + ) + } + if (!response.isSuccessful) { + throw ApkDownloadHttpErrors.fromResponse( + source = source, + code = response.code, + responseMessage = response.message, + retryAfter = response.header("Retry-After"), + rateLimitRemaining = response.header("X-RateLimit-Remaining"), + rateLimitResetEpochSeconds = + response.header("X-RateLimit-Reset") ) } val body = response.body - ?: throw IOException("Empty response body") - - // Handle resume: 206 = partial content (append), 200 = full - // content (overwrite). - val append = response.code == 206 - val resumedBytes = if (!append && existingBytes > 0) { - Log.d( - TAG, - "Server didn't honor Range request, starting from scratch" - ) - 0L + val range = if (response.code == 206) { + parseContentRange(response.header("Content-Range")) + ?: throw invalidResumeResponse(source, tempFile) } else { - existingBytes + null + } + if (range != null && range.start != existingBytes) { + throw invalidResumeResponse(source, tempFile) } - saveResumeInfo(url, expectedSize, versionName) + val append = range != null + val resumedBytes = if (append) existingBytes else 0L + val expectedSize = range?.total + ?: body.contentLength().takeIf { it >= 0L }?.let { length -> + resumedBytes + length + } + ?: previousResume?.expectedSize?.takeIf { append } + ?: 0L + if (expectedSize > 0L) { + checkDiskSpace((expectedSize - resumedBytes).coerceAtLeast(0L)) + } - if (resumedBytes > 0 && expectedSize > 0) { - val initialProgress = - ((resumedBytes * 100) / expectedSize).toInt() - progressCallback?.invoke(initialProgress) + val validator = response.header("ETag") + ?: response.header("Last-Modified") + ?: previousResume?.validator?.takeIf { append } + if (validator != null) { + saveResumeInfo( + ResumeInfo( + sourceId = source.id, + endpointUrl = endpointUrl, + expectedSize = expectedSize, + validator = validator + ) + ) + } else { + progressFile.delete() + } + + if (resumedBytes > 0L && expectedSize > 0L) { + progressCallback?.invoke( + ((resumedBytes * 100L) / expectedSize).toInt() + ) } body.byteStream().use { input -> @@ -458,8 +510,8 @@ class UniversalApkManager(private val context: Context) { val buffer = ByteArray(BUFFER_SIZE) var bytesRead: Int var totalBytesRead = resumedBytes - var lastProgress = if (expectedSize > 0) { - ((resumedBytes * 100) / expectedSize).toInt() + var lastProgress = if (expectedSize > 0L) { + ((resumedBytes * 100L) / expectedSize).toInt() } else { 0 } @@ -467,23 +519,29 @@ class UniversalApkManager(private val context: Context) { while (input.read(buffer).also { bytesRead = it } != -1) { output.write(buffer, 0, bytesRead) totalBytesRead += bytesRead - - if (expectedSize > 0) { - val progress = - ((totalBytesRead * 100) / expectedSize).toInt() + if (expectedSize > 0L) { + val progress = ( + (totalBytesRead * 100L) / expectedSize + ).toInt().coerceIn(0, 100) if (progress != lastProgress) { lastProgress = progress progressCallback?.invoke(progress) } } } - - Log.d( - TAG, - "Download complete: ${totalBytesRead / 1024 / 1024}MB" - ) } } + + if (expectedSize > 0L && tempFile.length() != expectedSize) { + if (tempFile.length() > expectedSize) clearPartialDownload() + throw ApkDownloadException( + message = "${source.id} download ended before all bytes arrived.", + messageRes = R.string.prepare_apk_error_incomplete, + messageArgs = listOf(source.displayName), + retryable = true, + sourceId = source.id + ) + } } completeSuccessfully() } catch (e: Exception) { @@ -496,6 +554,103 @@ class UniversalApkManager(private val context: Context) { } } + private fun invalidResumeResponse( + source: ApkDownloadSource, + tempFile: File + ): ApkDownloadException { + tempFile.delete() + progressFile.delete() + return ApkDownloadException( + message = "${source.id} returned an invalid resume response.", + messageRes = R.string.prepare_apk_error_invalid_resume, + messageArgs = listOf(source.displayName), + retryable = true, + sourceId = source.id + ) + } + + private fun validateDownloadedApk(tempFile: File, source: ApkDownloadSource) { + if (!verifyApkSignature(tempFile)) { + clearPartialDownload() + throw ApkDownloadException( + message = "APK from ${source.id} is not signed by a trusted BitChat release key.", + messageRes = R.string.prepare_apk_error_untrusted_key, + messageArgs = listOf(source.displayName), + retryable = false, + sourceId = source.id + ) + } + if (!DistributionInfoProvider.isUniversalApk(tempFile)) { + clearPartialDownload() + throw ApkDownloadException( + message = "${source.id} returned an architecture-specific APK.", + messageRes = R.string.prepare_apk_error_not_universal, + messageArgs = listOf(source.displayName), + retryable = false, + sourceId = source.id + ) + } + } + + private fun downloadedVersionName(apkFile: File): String { + val packageInfo = context.packageManager.getPackageArchiveInfo(apkFile.absolutePath, 0) + ?: invalidDownloadedApk(R.string.prepare_apk_error_apk_unreadable, "unreadable APK") + if (packageInfo.packageName != context.packageName) { + invalidDownloadedApk(R.string.prepare_apk_error_not_bitchat, "wrong package") + } + return packageInfo.versionName + ?.takeIf { it.isNotBlank() } + ?: invalidDownloadedApk(R.string.prepare_apk_error_no_version, "no version name") + } + + private fun invalidDownloadedApk(@StringRes messageRes: Int, logReason: String): Nothing { + clearPartialDownload() + throw ApkDownloadException( + message = "Downloaded APK rejected: $logReason", + messageRes = messageRes, + retryable = false + ) + } + + private fun Exception.asDownloadException(source: ApkDownloadSource): ApkDownloadException { + if (this is ApkDownloadException) return this + return ApkDownloadException( + message = "${source.id} download failed" + (message?.let { ": $it" } ?: "."), + messageRes = R.string.prepare_apk_error_source_failed, + messageArgs = listOf(source.displayName), + retryable = this is IOException, + sourceId = source.id, + cause = this + ) + } + + private fun combineSourceFailures( + failures: List + ): ApkDownloadException { + if (failures.size == 1) return failures.single() + if (failures.isEmpty()) { + return ApkDownloadException( + message = "APK download failed with no recorded source failure.", + messageRes = R.string.prepare_apk_error_generic, + retryable = false + ) + } + // The per-source detail stays in the log line. Concatenating each source's sentence would + // mean re-assembling localized text here, where there is no Context to resolve it with. + return ApkDownloadException( + message = "All configured APK sources failed: " + + failures.joinToString(" • ") { it.message ?: "Unknown error" }, + messageRes = R.string.prepare_apk_error_all_sources, + retryable = failures.any { it.retryable }, + cause = failures.last() + ) + } + + private fun clearPartialDownload() { + File(cacheDir, TEMP_FILE_NAME).delete() + progressFile.delete() + } + /** * Cache the APK this process was installed from when it is a standalone * universal or ARM64 artifact. A base APK from a split install is incomplete. @@ -524,7 +679,7 @@ class UniversalApkManager(private val context: Context) { // choice. Keep it even when the running ARM64 build is newer; the // user can delete it from the UI to return to the local artifact. if (installedVariant == ShareableApkVariant.ARM64 && - cachedInfo?.source == ApkSource.GITHUB && + cachedInfo?.source == ApkSource.DOWNLOADED && cachedInfo.variant == ShareableApkVariant.UNIVERSAL ) { return cachedInfo @@ -532,9 +687,9 @@ class UniversalApkManager(private val context: Context) { // Keep an already cached artifact if it is the same version or // newer. Otherwise prefer the running build so sharing cannot - // silently downgrade recipients to an older GitHub release. + // silently downgrade recipients to an older downloadable release. if (cachedInfo != null && - !GitHubReleaseClient.isNewerVersion(cachedInfo.version, installedVersion) + !AppVersion.isNewer(cachedInfo.version, installedVersion) ) { return cachedInfo } @@ -556,14 +711,13 @@ class UniversalApkManager(private val context: Context) { } replaceFileSafely(pendingFile, finalFile) - val checksum = calculateChecksum(finalFile) saveMetadata( version = installedVersion, - checksum = checksum, size = finalFile.length(), fileName = finalFileName, source = ApkSource.INSTALLED, - variant = installedVariant + variant = installedVariant, + downloadSourceId = null ) cleanupOldApks(except = finalFile) @@ -583,15 +737,10 @@ class UniversalApkManager(private val context: Context) { ?: BuildConfig.VERSION_NAME } - private fun isOlderThanInstalledVersion(candidateVersion: String): Boolean { - return GitHubReleaseClient.isNewerVersion(candidateVersion, installedVersionName()) - } - /** * Verify the downloaded APK against either the running app's signing lineage - * or the pinned GitHub release certificate. The latter supports Play installs - * when GitHub distribution uses a separate, explicitly trusted release key. - * Debug builds without a configured pin accept any signed (never unsigned) APK. + * or the pinned release certificate. The latter supports Play installs when + * downloadable artifacts use a separate, explicitly trusted release key. */ private fun verifyApkSignature(apkFile: File): Boolean { return try { @@ -609,6 +758,8 @@ class UniversalApkManager(private val context: Context) { val ownCerts = signatureDigests( context.packageManager.getPackageInfo(context.packageName, signingFlags()) ) + // Every mirror must serve the same official release-signed APK. + // The BuildConfig field keeps its historical name for configuration compatibility. val pinnedReleaseCert = normalizeCertificateDigest( BuildConfig.GITHUB_RELEASE_CERT_SHA256 ) @@ -617,7 +768,7 @@ class UniversalApkManager(private val context: Context) { // Debug builds may use a different local signing key, but still // require the downloaded artifact itself to be signed. Production // builds must match either this installation's signing lineage or - // the explicitly pinned GitHub release certificate. + // the explicitly pinned release certificate. if (BuildConfig.DEBUG && pinnedReleaseCert == null) { Log.w(TAG, "Debug build has no pinned release certificate; accepting signed APK") return true @@ -678,39 +829,6 @@ class UniversalApkManager(private val context: Context) { .takeIf { it.matches(Regex("[a-f0-9]{64}")) } } - /** - * Verify the SHA256 checksum of a file. - */ - suspend fun verifyChecksum(file: File, expectedSha256: String): Boolean = withContext(Dispatchers.IO) { - try { - val checksum = calculateChecksum(file) - val matches = checksum.equals(expectedSha256, ignoreCase = true) - - if (!matches) { - Log.e(TAG, "Checksum mismatch!") - Log.e(TAG, "Expected: $expectedSha256") - Log.e(TAG, "Actual: $checksum") - } - - matches - } catch (e: Exception) { - Log.e(TAG, "Error verifying checksum", e) - false - } - } - - private fun calculateChecksum(file: File): String { - val digest = MessageDigest.getInstance("SHA-256") - file.inputStream().use { input -> - val buffer = ByteArray(BUFFER_SIZE) - var bytesRead: Int - while (input.read(buffer).also { bytesRead = it } != -1) { - digest.update(buffer, 0, bytesRead) - } - } - return digest.digest().joinToString("") { "%02x".format(it) } - } - /** * Delete the cached universal APK. */ @@ -757,20 +875,20 @@ class UniversalApkManager(private val context: Context) { */ private fun saveMetadata( version: String, - checksum: String, size: Long, fileName: String, source: ApkSource, - variant: ShareableApkVariant + variant: ShareableApkVariant, + downloadSourceId: String? ) { val json = JSONObject().apply { put("version", version) - put("checksum", checksum) put("downloadDate", System.currentTimeMillis()) put("size", size) put("fileName", fileName) put("source", source.name) put("variant", variant.name) + downloadSourceId?.let { put("downloadSourceId", it) } } val pendingMetadata = File(cacheDir, "$METADATA_FILE_NAME.new") @@ -779,12 +897,13 @@ class UniversalApkManager(private val context: Context) { Log.d(TAG, "Saved metadata: $version") } - private fun saveResumeInfo(url: String, expectedSize: Long, versionName: String) { + private fun saveResumeInfo(info: ResumeInfo) { try { val json = JSONObject().apply { - put("url", url) - put("expectedSize", expectedSize) - put("versionName", versionName) + put("sourceId", info.sourceId) + put("endpointUrl", info.endpointUrl) + put("expectedSize", info.expectedSize) + info.validator?.let { put("validator", it) } } progressFile.writeText(json.toString()) } catch (e: Exception) { @@ -792,17 +911,33 @@ class UniversalApkManager(private val context: Context) { } } - private fun loadResumeInfo(): JSONObject? { + private fun loadResumeInfo(): ResumeInfo? { return try { - if (progressFile.exists()) { - JSONObject(progressFile.readText()) - } else null + if (!progressFile.exists()) return null + val json = JSONObject(progressFile.readText()) + val endpointUrl = json.optString("endpointUrl") + .ifBlank { json.optString("url") } + if (endpointUrl.isBlank()) return null + ResumeInfo( + sourceId = json.optString("sourceId") + .ifBlank { DefaultApkDownloadSources.GITHUB_ID }, + endpointUrl = endpointUrl, + expectedSize = json.optLong("expectedSize", 0L), + validator = json.optString("validator").takeIf { it.isNotBlank() } + ) } catch (e: Exception) { Log.e(TAG, "Error loading resume info", e) null } } + private data class ResumeInfo( + val sourceId: String, + val endpointUrl: String, + val expectedSize: Long, + val validator: String? + ) + /** * Commit [source] to [target] without removing a valid target first. * Both files live in the same cache directory, so this is a rename, not a @@ -831,29 +966,16 @@ class UniversalApkManager(private val context: Context) { */ data class ApkInfo( val version: String, - val checksum: String, val downloadDate: Long, val size: Long, val file: File, val source: ApkSource, - val variant: ShareableApkVariant + val variant: ShareableApkVariant, + val downloadSourceId: String? ) enum class ApkSource { INSTALLED, - GITHUB - } - - /** - * Update check status. - */ - sealed class UpdateStatus { - data class NotDownloaded(val latestRelease: GitHubReleaseClient.Release) : UpdateStatus() - data class UpToDate(val currentVersion: String) : UpdateStatus() - data class UpdateAvailable( - val currentVersion: String, - val latestRelease: GitHubReleaseClient.Release - ) : UpdateStatus() - data class Error(val message: String) : UpdateStatus() + DOWNLOADED } } 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 f0eded0c..0b3e597b 100644 --- a/app/src/main/java/com/bitchat/android/util/WorkManagerApkDownloader.kt +++ b/app/src/main/java/com/bitchat/android/util/WorkManagerApkDownloader.kt @@ -63,7 +63,7 @@ class WorkManagerApkDownloader(context: Context) : ApkDownloader { val partial = apkManager.getPartialDownloadProgress() ApkDownloader.DownloadState.Downloading( partial ?: 0, - ApkDownloader.DownloadPhase.ResolvingRelease + ApkDownloader.DownloadPhase.AwaitingConnectivity ) } WorkInfo.State.RUNNING -> { @@ -79,16 +79,30 @@ class WorkManagerApkDownloader(context: Context) : ApkDownloader { ApkDownloader.DownloadState.Success(version, sizeMB) } WorkInfo.State.FAILED -> { - val error = workInfo.outputData.getString(ApkDownloadWorker.KEY_ERROR) ?: "Download failed" + // Work enqueued by an older build carries no resource id; fall back rather than + // resolve 0 and crash. + val messageRes = workInfo.outputData + .getInt(ApkDownloadWorker.KEY_ERROR_RES, 0) + .takeIf { it != 0 } + ?: R.string.prepare_apk_error_generic + val args = workInfo.outputData + .getStringArray(ApkDownloadWorker.KEY_ERROR_ARGS) + ?.toList() + .orEmpty() val resumable = workInfo.outputData.getInt(ApkDownloadWorker.KEY_RESUMABLE_PERCENT, -1) - ApkDownloader.DownloadState.Failed(error, if (resumable >= 0) resumable else null) + ApkDownloader.DownloadState.Failed( + messageRes = messageRes, + messageArgs = args, + resumablePercent = if (resumable >= 0) resumable else null + ) } WorkInfo.State.CANCELLED -> { val partial = apkManager.getPartialDownloadProgress() if (partial != null) { ApkDownloader.DownloadState.Failed( - appContext.getString(R.string.prepare_apk_download_cancelled), - partial + messageRes = R.string.prepare_apk_download_cancelled, + messageArgs = emptyList(), + resumablePercent = partial ) } else { ApkDownloader.DownloadState.Idle diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 52e0038c..f5fde380 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -236,43 +236,65 @@ Prepare App for Sharing - App Ready for Offline Sharing + App Ready for Offline Sharing Download universal APK for offline sharing Not ready • Tap to download Ready to share - Sharing source: this installed APK - Sharing source: this installed APK • ARM64 devices only - Sharing source: verified GitHub universal APK - Get universal + + Ready to share • %1$s • %2$d MB\n%3$s + %1$s • %2$d%% downloaded + Sharing source: this installed APK + Sharing source: this installed APK • ARM64 devices only + Sharing source: verified downloaded universal APK + + Download universal APK + Retry download Downloading… %1$d%% - Checking latest release… + Waiting for network… + Selecting download source… Waiting for Tor… Downloading… - Verifying checksum… Verifying signature… Stop download - Update available Prepare - Update Delete Version %1$s • %2$d MB Download Universal APK? - This will download the universal APK (~%1$d MB) from GitHub releases. You only need to do this once. - The release size is temporarily unavailable. BitChat will retry the GitHub request before downloading. + This will download a verified universal APK from a configured source. You only need to do this once. Download Downloading Universal APK Downloading %1$d MB… - Verifying checksum… Universal APK ready! Network error. Check your connection. - Checksum verification failed. Please try again. Not enough storage space. - Failed to fetch release info from GitHub. + + %1$s is temporarily rate limited. Try again in %2$s min. + %1$s is temporarily rate limited. Try again later. + %1$s does not currently have a universal APK. + %1$s download failed: HTTP %2$s %3$s + Download failed. Please try again. + Not enough storage: %1$s MB needed, %2$s MB free. + No APK download sources are configured. + Tor is still connecting. Try again when Tor is ready. + %1$s has no usable APK URL. + %1$s could not be reached. + %1$s redirected to an insecure URL. + %1$s rejected the saved download position. The next attempt will restart the download. + %1$s download ended before all bytes arrived. It can be resumed. + %1$s returned an invalid resume response. The next attempt will restart the download. + The APK from %1$s is not signed by a trusted BitChat release key. + %1$s returned an architecture-specific APK, not the required universal APK. + The downloaded APK could not be read. + The downloaded file is not a BitChat APK. + The downloaded APK has no version information. + %1$s download failed. + All configured APK sources failed. Delete cached APK? This will free up ~%1$d MB of storage. - Update Available - A newer version (%1$s) is available. Current: %2$s Please prepare the app for sharing first. Download interrupted Download cancelled diff --git a/app/src/test/kotlin/com/bitchat/android/ui/PrepareRowTapActionTest.kt b/app/src/test/kotlin/com/bitchat/android/ui/PrepareRowTapActionTest.kt new file mode 100644 index 00000000..07b4ed23 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/ui/PrepareRowTapActionTest.kt @@ -0,0 +1,75 @@ +package com.bitchat.android.ui + +import com.bitchat.android.util.ApkDownloader +import com.bitchat.android.util.ShareableApkVariant +import com.bitchat.android.util.UniversalApkManager +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * The row body and its trailing icon button are two doors into the same actions, and the trailing + * buttons no longer carry visible labels. If this mapping is wrong the affordance simply vanishes, + * so each status is pinned down here rather than left to the composable. + */ +class PrepareRowTapActionTest { + + private fun ready( + variant: ShareableApkVariant, + source: UniversalApkManager.ApkSource = UniversalApkManager.ApkSource.INSTALLED + ) = ApkPreparationStatus.Ready( + version = "1.7.5", + sizeMB = 12, + source = source, + variant = variant + ) + + @Test + fun `an arm64-only build offers the universal download`() { + // The only entry point besides the trailing icon, which has no label to explain itself. + assertEquals( + PrepareRowTapAction.OpenPrepareDialog, + prepareRowTapAction(ready(ShareableApkVariant.ARM64)) + ) + } + + @Test + fun `nothing left to fetch means the row is inert`() { + assertNull(prepareRowTapAction(ready(ShareableApkVariant.UNIVERSAL))) + assertNull( + prepareRowTapAction( + ready(ShareableApkVariant.UNIVERSAL, UniversalApkManager.ApkSource.DOWNLOADED) + ) + ) + } + + @Test + fun `a missing apk asks before spending the bytes`() { + assertEquals( + PrepareRowTapAction.OpenPrepareDialog, + prepareRowTapAction(ApkPreparationStatus.NotDownloaded) + ) + } + + @Test + fun `an interrupted or failed download resumes without asking again`() { + // The user already consented to the download; re-prompting would be noise. + assertEquals( + PrepareRowTapAction.StartDownload, + prepareRowTapAction(ApkPreparationStatus.Resumable(43, "Download interrupted")) + ) + assertEquals( + PrepareRowTapAction.StartDownload, + prepareRowTapAction(ApkPreparationStatus.Error("Network error")) + ) + } + + @Test + fun `a download in flight is not restartable by tapping the row`() { + // Otherwise a stray tap behind the stop button would queue a second download. + ApkDownloader.DownloadPhase.entries.forEach { phase -> + assertNull(prepareRowTapAction(ApkPreparationStatus.Downloading(phase))) + } + assertNull(prepareRowTapAction(ApkPreparationStatus.Loading)) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/util/ApkDownloadSourceTest.kt b/app/src/test/kotlin/com/bitchat/android/util/ApkDownloadSourceTest.kt new file mode 100644 index 00000000..8caa9c61 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/util/ApkDownloadSourceTest.kt @@ -0,0 +1,162 @@ +package com.bitchat.android.util + +import com.bitchat.android.R +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.IOException + +class ApkDownloadSourceTest { + + private val source = ApkDownloadSource( + id = "mirror-one", + displayName = "Mirror One", + latestApkUrl = "https://mirror.example/bitchat-universal.apk" + ) + private val now = 1_700_000_000_000L + + @Test + fun `default source downloads the stable latest universal asset directly`() { + assertEquals( + "https://github.com/permissionlesstech/bitchat-android/releases/latest/" + + "download/bitchat-android-universal.apk", + DefaultApkDownloadSources.all.single().latestApkUrls.first() + ) + assertEquals( + "https://github.com/permissionlesstech/bitchat-android/releases/latest/" + + "download/app-universal-release.apk", + DefaultApkDownloadSources.all.single().latestApkUrls[1] + ) + } + + @Test + fun `transient HTTP failures are retryable but ordinary client errors are not`() { + assertTrue(httpError(408).retryable) + assertTrue(httpError(500).retryable) + assertTrue(httpError(503).retryable) + assertFalse(httpError(400).retryable) + assertFalse(httpError(404).retryable) + } + + @Test + fun `compatibility URL is only tried when the preferred asset is absent`() { + assertTrue(shouldTryNextSourceUrl(httpError(404), hasMoreUrls = true)) + assertFalse(shouldTryNextSourceUrl(httpError(404), hasMoreUrls = false)) + assertFalse(shouldTryNextSourceUrl(httpError(429), hasMoreUrls = true)) + assertFalse(shouldTryNextSourceUrl(httpError(503), hasMoreUrls = true)) + } + + @Test + fun `rate limit response gives the user the advertised retry time`() { + val failure = ApkDownloadHttpErrors.fromResponse( + source = source, + code = 429, + responseMessage = "Too Many Requests", + retryAfter = "120", + rateLimitRemaining = null, + rateLimitResetEpochSeconds = null, + nowMillis = now + ) + + assertFalse(failure.retryable) + assertEquals(now + 120_000L, failure.retryAtMillis) + // The wait is carried as an argument, not baked into an English sentence. + assertEquals(R.string.prepare_apk_error_rate_limited_wait, failure.messageRes) + assertEquals(listOf(source.displayName, "2"), failure.messageArgs) + } + + @Test + fun `403 is only treated as a limit when response headers say so`() { + val permissionsFailure = ApkDownloadHttpErrors.fromResponse( + source = source, + code = 403, + responseMessage = "Forbidden", + retryAfter = "not-a-date", + rateLimitRemaining = "42", + rateLimitResetEpochSeconds = null, + nowMillis = now + ) + val quotaFailure = ApkDownloadHttpErrors.fromResponse( + source = source, + code = 403, + responseMessage = "Forbidden", + retryAfter = null, + rateLimitRemaining = "0", + rateLimitResetEpochSeconds = (now / 1000L + 300L).toString(), + nowMillis = now + ) + + assertNull(permissionsFailure.retryAtMillis) + assertEquals(R.string.prepare_apk_error_http, permissionsFailure.messageRes) + assertEquals( + listOf(source.displayName, "403", "Forbidden"), + permissionsFailure.messageArgs + ) + assertEquals(now + 300_000L, quotaFailure.retryAtMillis) + assertEquals(R.string.prepare_apk_error_rate_limited_wait, quotaFailure.messageRes) + } + + @Test + fun `invalid or overflowing retry headers never crash error mapping`() { + assertNull( + ApkDownloadHttpErrors.retryAtMillis( + retryAfter = Long.MAX_VALUE.toString(), + rateLimitResetEpochSeconds = Long.MAX_VALUE.toString(), + nowMillis = now + ) + ) + } + + @Test + fun `content ranges validate resume offsets and totals`() { + assertEquals( + ContentRange(start = 1_024L, endInclusive = 2_047L, total = 4_096L), + parseContentRange("bytes 1024-2047/4096") + ) + assertEquals(4_096L, parseUnsatisfiedContentRangeTotal("bytes */4096")) + assertNull(parseContentRange("bytes nope")) + assertNull(parseContentRange("bytes 20-10/100")) + assertNull(parseContentRange("bytes 90-100/100")) + } + + @Test + fun `version comparison is host independent`() { + assertTrue(AppVersion.isNewer("1.7.4", "1.7.5")) + assertFalse(AppVersion.isNewer("1.7.5", "1.7.4")) + assertFalse(AppVersion.isNewer("v1.7.5", "1.7.5")) + assertTrue(AppVersion.isNewer("1.7", "1.7.1")) + } + + @Test + fun `worker policy allows exactly three total attempts`() { + val transient = IOException("offline") + + assertTrue(ApkDownloadRetryPolicy.shouldRetry(runAttemptCount = 0, transient)) + assertTrue(ApkDownloadRetryPolicy.shouldRetry(runAttemptCount = 1, transient)) + assertFalse(ApkDownloadRetryPolicy.shouldRetry(runAttemptCount = 2, transient)) + assertFalse( + ApkDownloadRetryPolicy.shouldRetry( + runAttemptCount = 0, + ApkDownloadException( + message = "invalid APK", + messageRes = R.string.prepare_apk_error_generic, + retryable = false + ) + ) + ) + } + + private fun httpError(code: Int): ApkDownloadException { + return ApkDownloadHttpErrors.fromResponse( + source = source, + code = code, + responseMessage = "test", + retryAfter = null, + rateLimitRemaining = null, + rateLimitResetEpochSeconds = null, + nowMillis = now + ) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/util/DownloadPhaseTest.kt b/app/src/test/kotlin/com/bitchat/android/util/DownloadPhaseTest.kt index 61673054..f08c009c 100644 --- a/app/src/test/kotlin/com/bitchat/android/util/DownloadPhaseTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/util/DownloadPhaseTest.kt @@ -31,6 +31,18 @@ class DownloadPhaseTest { ) } + @Test + fun `phase keys from queued work created by the old downloader still map correctly`() { + assertEquals( + ApkDownloader.DownloadPhase.SelectingSource, + ApkDownloader.DownloadPhase.fromKey("ResolvingRelease") + ) + assertEquals( + ApkDownloader.DownloadPhase.VerifyingSignature, + ApkDownloader.DownloadPhase.fromKey("VerifyingChecksum") + ) + } + @Test fun `only the transfer claims measurable progress`() { assertTrue(ApkDownloader.DownloadPhase.Transferring.hasMeasurableProgress) diff --git a/app/src/test/kotlin/com/bitchat/android/util/GitHubRateLimitTest.kt b/app/src/test/kotlin/com/bitchat/android/util/GitHubRateLimitTest.kt deleted file mode 100644 index 9f9a7b3e..00000000 --- a/app/src/test/kotlin/com/bitchat/android/util/GitHubRateLimitTest.kt +++ /dev/null @@ -1,164 +0,0 @@ -package com.bitchat.android.util - -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Test - -/** - * Unauthenticated GitHub allows 60 requests an hour per IP, and over Tor that IP is an exit node - * shared with everyone else using it. Reading the rejection correctly is what keeps the app from - * hammering a quota it has already exhausted. - */ -class GitHubRateLimitTest { - - private val now = 1_700_000_000_000L - - @Test - fun `a 403 that still has quota is a permissions error, not a rate limit`() { - assertFalse(GitHubRateLimit.isRateLimited(code = 403, remaining = "42")) - assertNull( - GitHubRateLimit.blockedUntilMillis( - code = 403, - remaining = "42", - resetEpochSeconds = null, - retryAfterSeconds = null, - nowMillis = now, - ) - ) - } - - @Test - fun `a 403 with no quota left blocks until the advertised reset`() { - val resetSeconds = now / 1000 + 900 - - assertTrue(GitHubRateLimit.isRateLimited(code = 403, remaining = "0")) - assertEquals( - resetSeconds * 1000, - GitHubRateLimit.blockedUntilMillis( - code = 403, - remaining = "0", - resetEpochSeconds = resetSeconds.toString(), - retryAfterSeconds = null, - nowMillis = now, - ) - ) - } - - @Test - fun `a 429 is a rate limit even without a remaining header`() { - assertTrue(GitHubRateLimit.isRateLimited(code = 429, remaining = null)) - } - - @Test - fun `a secondary limit is a 403 with Retry-After while quota remains`() { - // GitHub serves secondary limits as 403 + Retry-After without exhausting the - // primary quota, so remaining is still nonzero. - assertTrue( - GitHubRateLimit.isRateLimited( - code = 403, - remaining = "42", - retryAfterSeconds = "60", - ) - ) - } - - @Test - fun `a secondary limit blocks for the Retry-After it advertises`() { - assertEquals( - now + 60_000, - GitHubRateLimit.blockedUntilMillis( - code = 403, - remaining = "42", - resetEpochSeconds = null, - retryAfterSeconds = "60", - nowMillis = now, - ) - ) - } - - @Test - fun `a 403 with an unusable Retry-After stays a permissions error`() { - assertFalse( - GitHubRateLimit.isRateLimited( - code = 403, - remaining = "42", - retryAfterSeconds = "not-a-number", - ) - ) - } - - @Test - fun `Retry-After takes precedence over the reset header`() { - // Retry-After is a delta and is what GitHub sends for secondary limits, which can expire - // sooner than the primary window the reset header describes. - assertEquals( - now + 30_000, - GitHubRateLimit.blockedUntilMillis( - code = 429, - remaining = "0", - resetEpochSeconds = (now / 1000 + 3_000).toString(), - retryAfterSeconds = "30", - nowMillis = now, - ) - ) - } - - @Test - fun `a rejection with no timing headers falls back to a fixed backoff`() { - assertEquals( - now + GitHubRateLimit.DEFAULT_BACKOFF_MILLIS, - GitHubRateLimit.blockedUntilMillis( - code = 429, - remaining = null, - resetEpochSeconds = null, - retryAfterSeconds = null, - nowMillis = now, - ) - ) - } - - @Test - fun `a reset time already in the past falls back rather than unblocking immediately`() { - // A skewed device clock must not turn a real rejection into "retry right now". - assertEquals( - now + GitHubRateLimit.DEFAULT_BACKOFF_MILLIS, - GitHubRateLimit.blockedUntilMillis( - code = 429, - remaining = null, - resetEpochSeconds = (now / 1000 - 500).toString(), - retryAfterSeconds = null, - nowMillis = now, - ) - ) - } - - @Test - fun `an absurd reset time is clamped so the app is never locked out for long`() { - assertEquals( - now + GitHubRateLimit.MAX_BACKOFF_MILLIS, - GitHubRateLimit.blockedUntilMillis( - code = 429, - remaining = null, - resetEpochSeconds = (now / 1000 + 86_400).toString(), - retryAfterSeconds = null, - nowMillis = now, - ) - ) - } - - @Test - fun `unparseable headers fall back instead of throwing`() { - assertEquals( - now + GitHubRateLimit.DEFAULT_BACKOFF_MILLIS, - GitHubRateLimit.blockedUntilMillis( - code = 429, - remaining = null, - resetEpochSeconds = "not-a-number", - retryAfterSeconds = "Wed, 21 Oct 2015 07:28:00 GMT", - nowMillis = now, - ) - ) - } -} diff --git a/app/src/test/kotlin/com/bitchat/android/util/GitHubReleaseClientTest.kt b/app/src/test/kotlin/com/bitchat/android/util/GitHubReleaseClientTest.kt deleted file mode 100644 index e51990d9..00000000 --- a/app/src/test/kotlin/com/bitchat/android/util/GitHubReleaseClientTest.kt +++ /dev/null @@ -1,99 +0,0 @@ -package com.bitchat.android.util - -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner - -@RunWith(RobolectricTestRunner::class) -class GitHubReleaseClientTest { - - @Test - fun `parses universal apk and GitHub asset digest`() { - val digest = "a".repeat(64) - val release = GitHubReleaseClient.parseRelease( - """ - { - "tag_name": "v1.7.6", - "body": "", - "assets": [ - { - "name": "bitchat-android-universal.apk", - "browser_download_url": "https://example.test/bitchat.apk", - "size": 49283072, - "digest": "sha256:$digest" - } - ] - } - """.trimIndent() - ) - - requireNotNull(release) - assertEquals("1.7.6", release.versionName) - assertEquals(49_283_072L, release.universalApkSize) - assertEquals(digest, release.universalApkSha256) - } - - @Test - fun `falls back to checksum in release notes`() { - val digest = "b".repeat(64) - val release = GitHubReleaseClient.parseRelease( - """ - { - "tag_name": "1.7.6", - "body": "bitchat-android-universal.apk: $digest", - "assets": [ - { - "name": "bitchat-android-universal.apk", - "browser_download_url": "https://example.test/bitchat.apk", - "size": 10 - } - ] - } - """.trimIndent() - ) - - assertEquals(digest, requireNotNull(release).universalApkSha256) - } - - @Test - fun `rejects releases without a universal apk`() { - val release = GitHubReleaseClient.parseRelease( - """ - { - "tag_name": "v1.7.6", - "assets": [ - { - "name": "bitchat-android-arm64.apk", - "browser_download_url": "https://example.test/arm64.apk", - "size": 10 - } - ] - } - """.trimIndent() - ) - - assertNull(release) - } - - @Test - fun `compares release versions`() { - val release = GitHubReleaseClient.Release( - tagName = "v1.7.6", - versionName = "1.7.6", - universalApkUrl = "https://example.test/bitchat.apk", - universalApkSha256 = null, - universalApkSize = 10, - universalApkName = "bitchat-android-universal.apk" - ) - - assertTrue(GitHubReleaseClient.isNewerVersion("1.7.5", release)) - assertFalse(GitHubReleaseClient.isNewerVersion("1.7.6", release)) - assertFalse(GitHubReleaseClient.isNewerVersion("1.8.0", release)) - assertTrue(GitHubReleaseClient.isNewerVersion("1.7.4", "1.7.5")) - assertFalse(GitHubReleaseClient.isNewerVersion("1.7.5", "1.7.4")) - } -}