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")) - } -}