feat: rebuild the prepare-for-sharing row and localize its failures

Progress moves out of the trailing slot and under the subtitle, so every
status now shows exactly one 48dp control there instead of a spinner and
a button competing for the same space. The trailing slot had three
different widths across states, which made the text column re-wrap on
every status change; it is now one width throughout.

"Get universal" and "Retry" become icon buttons. That removes the labels
they were leaning on, so both gain tooltips and real content
descriptions, and prepareRowTapAction() now drives the row's enabled flag
and its tap handler from one mapping. Previously the row rendered as
clickable in the ready state but onPrepareRowClicked ignored it, leaving
the icon as the only way to reach the universal download.

Resumable downloads get a progress bar for the first time, drawn flat via
amplitude 0 so a stalled download does not look like a live one.

Strings: every user-facing literal now lives in strings.xml. Download
failures were assembled as English sentences in the util layer, which has
no Context by design, so they crossed the WorkManager boundary already
formatted and could never be translated. ApkDownloadException now carries
a string resource and its arguments, and the ViewModel resolves them
against the device locale. util/ stays Context-free and its tests stay
plain JUnit.

Also drops translatable="false" from seven strings that were visible
prose, and stops showing raw exception text when the APK status cannot be
read.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Moe Hamade 2026-07-31 02:46:28 +03:00
parent 7dab62733a
commit bfb0c82ef9
16 changed files with 1321 additions and 1431 deletions

View File

@ -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,

View File

@ -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<Application>().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<Application>().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))
}
}
}

View File

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

View File

@ -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<String>
) {
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<String> = 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()
}

View File

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

View File

@ -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<String>,
val resumablePercent: Int?
) : DownloadState()
}
/**
* What a download is actually doing.
*
* Preparing an APK is a five-stage operation that was being rendered as a single 0-100 bar,
* so it sat at 0% through a release lookup and a Tor bootstrap, then at 100% through a
* SHA-256 pass and a signature check over ~100MB. Only [Transferring] has meaningful
* percentage progress; the rest should read as indeterminate.
* 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
}
}

View File

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

View File

@ -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<Release>? {
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<Release> =
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<Release> {
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
)
}

View File

@ -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

View File

@ -236,43 +236,65 @@
<!-- Universal APK Preparation -->
<string name="prepare_apk_title">Prepare App for Sharing</string>
<string name="prepare_apk_ready_title" translatable="false">App Ready for Offline Sharing</string>
<string name="prepare_apk_ready_title">App Ready for Offline Sharing</string>
<string name="prepare_apk_subtitle">Download universal APK for offline sharing</string>
<string name="prepare_apk_status_not_downloaded">Not ready • Tap to download</string>
<string name="prepare_apk_status_ready">Ready to share</string>
<string name="prepare_apk_source_installed" translatable="false">Sharing source: this installed APK</string>
<string name="prepare_apk_source_installed_arm64" translatable="false">Sharing source: this installed APK • ARM64 devices only</string>
<string name="prepare_apk_source_github" translatable="false">Sharing source: verified GitHub universal APK</string>
<string name="prepare_apk_get_universal" translatable="false">Get universal</string>
<!-- Assembled as one string so translators control separator and line order, not Kotlin. -->
<string name="prepare_apk_ready_detail">Ready to share • %1$s • %2$d MB\n%3$s</string>
<string name="prepare_apk_status_resumable">%1$s • %2$d%% downloaded</string>
<string name="prepare_apk_source_installed">Sharing source: this installed APK</string>
<string name="prepare_apk_source_installed_arm64">Sharing source: this installed APK • ARM64 devices only</string>
<string name="prepare_apk_source_downloaded">Sharing source: verified downloaded universal APK</string>
<!-- Now the accessibility label and tooltip for an icon-only button, not a visible label. -->
<string name="prepare_apk_get_universal">Download universal APK</string>
<string name="prepare_apk_retry">Retry download</string>
<string name="prepare_apk_status_downloading">Downloading… %1$d%%</string>
<!-- Stages of preparing the APK. Only the transfer has a meaningful percentage. -->
<string name="prepare_apk_phase_resolving">Checking latest release…</string>
<string name="prepare_apk_phase_awaiting_connectivity">Waiting for network…</string>
<string name="prepare_apk_phase_selecting_source">Selecting download source…</string>
<string name="prepare_apk_phase_awaiting_route">Waiting for Tor…</string>
<string name="prepare_apk_phase_transferring">Downloading…</string>
<string name="prepare_apk_phase_verifying_checksum">Verifying checksum…</string>
<string name="prepare_apk_phase_verifying_signature">Verifying signature…</string>
<string name="prepare_apk_stop">Stop download</string>
<string name="prepare_apk_status_update_available">Update available</string>
<string name="prepare_apk_button_prepare">Prepare</string>
<string name="prepare_apk_button_update">Update</string>
<string name="prepare_apk_button_delete">Delete</string>
<string name="prepare_apk_info">Version %1$s • %2$d MB</string>
<string name="prepare_apk_dialog_title">Download Universal APK?</string>
<string name="prepare_apk_dialog_message">This will download the universal APK (~%1$d MB) from GitHub releases. You only need to do this once.</string>
<string name="prepare_apk_dialog_message_unknown_size" translatable="false">The release size is temporarily unavailable. BitChat will retry the GitHub request before downloading.</string>
<string name="prepare_apk_dialog_message_unknown_size">This will download a verified universal APK from a configured source. You only need to do this once.</string>
<string name="prepare_apk_dialog_confirm">Download</string>
<string name="prepare_apk_downloading_title">Downloading Universal APK</string>
<string name="prepare_apk_downloading_message">Downloading %1$d MB…</string>
<string name="prepare_apk_verifying">Verifying checksum…</string>
<string name="prepare_apk_success">Universal APK ready!</string>
<string name="prepare_apk_error_network">Network error. Check your connection.</string>
<string name="prepare_apk_error_checksum">Checksum verification failed. Please try again.</string>
<string name="prepare_apk_error_storage">Not enough storage space.</string>
<string name="prepare_apk_error_github">Failed to fetch release info from GitHub.</string>
<!--
Download failures. These are named in the util layer, which has no Context, and resolved by
the ViewModel. A short "min" unit keeps the wait quantity-neutral so no plural form is needed.
-->
<string name="prepare_apk_error_rate_limited_wait">%1$s is temporarily rate limited. Try again in %2$s min.</string>
<string name="prepare_apk_error_rate_limited">%1$s is temporarily rate limited. Try again later.</string>
<string name="prepare_apk_error_no_universal">%1$s does not currently have a universal APK.</string>
<string name="prepare_apk_error_http">%1$s download failed: HTTP %2$s %3$s</string>
<string name="prepare_apk_error_generic">Download failed. Please try again.</string>
<string name="prepare_apk_error_storage_needed">Not enough storage: %1$s MB needed, %2$s MB free.</string>
<string name="prepare_apk_error_no_sources">No APK download sources are configured.</string>
<string name="prepare_apk_error_tor_connecting">Tor is still connecting. Try again when Tor is ready.</string>
<string name="prepare_apk_error_no_url">%1$s has no usable APK URL.</string>
<string name="prepare_apk_error_unreachable">%1$s could not be reached.</string>
<string name="prepare_apk_error_insecure_redirect">%1$s redirected to an insecure URL.</string>
<string name="prepare_apk_error_resume_rejected">%1$s rejected the saved download position. The next attempt will restart the download.</string>
<string name="prepare_apk_error_incomplete">%1$s download ended before all bytes arrived. It can be resumed.</string>
<string name="prepare_apk_error_invalid_resume">%1$s returned an invalid resume response. The next attempt will restart the download.</string>
<string name="prepare_apk_error_untrusted_key">The APK from %1$s is not signed by a trusted BitChat release key.</string>
<string name="prepare_apk_error_not_universal">%1$s returned an architecture-specific APK, not the required universal APK.</string>
<string name="prepare_apk_error_apk_unreadable">The downloaded APK could not be read.</string>
<string name="prepare_apk_error_not_bitchat">The downloaded file is not a BitChat APK.</string>
<string name="prepare_apk_error_no_version">The downloaded APK has no version information.</string>
<string name="prepare_apk_error_source_failed">%1$s download failed.</string>
<string name="prepare_apk_error_all_sources">All configured APK sources failed.</string>
<string name="prepare_apk_delete_confirm">Delete cached APK?</string>
<string name="prepare_apk_delete_message">This will free up ~%1$d MB of storage.</string>
<string name="prepare_apk_update_dialog_title">Update Available</string>
<string name="prepare_apk_update_dialog_message">A newer version (%1$s) is available. Current: %2$s</string>
<string name="prepare_apk_required">Please prepare the app for sharing first.</string>
<string name="prepare_apk_download_interrupted">Download interrupted</string>
<string name="prepare_apk_download_cancelled">Download cancelled</string>

View File

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

View File

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

View File

@ -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)

View File

@ -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,
)
)
}
}

View File

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