Merge 99bed510de490e5949d806df14fdb5b38a610ba7 into 094657efa0aabbb6f71c9050149d1d01aee96400

This commit is contained in:
Moe Hamade 2026-08-03 17:12:56 +00:00 committed by GitHub
commit cee3ed1529
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 2570 additions and 945 deletions

View File

@ -283,6 +283,7 @@ com.google.testing.platform:launcher:0.0.9-alpha04=unified-test-platform-gradle-
com.google.testparameterinjector:test-parameter-injector:1.18=debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
com.google.zxing:core:3.5.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
com.ibm.icu:icu4j:77.1=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
com.squareup.okhttp3:mockwebserver3:5.4.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
com.squareup.okhttp3:okhttp-android:5.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
com.squareup.okhttp3:okhttp:5.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
com.squareup.okio:okio-jvm:3.17.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath

View File

@ -10,7 +10,17 @@ import java.util.concurrent.atomic.AtomicReference
* Centralized OkHttp provider to ensure all network traffic honors Tor settings.
*/
object OkHttpProvider {
private val httpClientRef = AtomicReference<OkHttpClient?>(null)
enum class Route {
DIRECT,
TOR
}
data class RoutedClient(
val client: OkHttpClient,
val route: Route
)
private val httpClientRef = AtomicReference<RoutedClient?>(null)
private val wsClientRef = AtomicReference<OkHttpClient?>(null)
fun reset() {
@ -18,20 +28,30 @@ object OkHttpProvider {
wsClientRef.set(null)
}
fun httpClient(): OkHttpClient {
fun httpClient(): OkHttpClient = routedHttpClient().client
/**
* Returns the client and the route it was actually built with as one snapshot.
*
* The selected Tor mode can change while an existing client is still cached. Consumers that
* key cooldowns by network identity must use this value rather than re-reading the preference.
*/
fun routedHttpClient(): RoutedClient {
httpClientRef.get()?.let { return it }
val client = baseBuilderForCurrentProxy()
val (builder, route) = baseBuilderForCurrentProxy()
val client = builder
.callTimeout(15, TimeUnit.SECONDS)
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.build()
httpClientRef.set(client)
return client
val routedClient = RoutedClient(client, route)
httpClientRef.set(routedClient)
return routedClient
}
fun webSocketClient(): OkHttpClient {
wsClientRef.get()?.let { return it }
val client = baseBuilderForCurrentProxy()
val client = baseBuilderForCurrentProxy().first
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(0, TimeUnit.SECONDS)
.writeTimeout(10, TimeUnit.SECONDS)
@ -40,7 +60,7 @@ object OkHttpProvider {
return client
}
private fun baseBuilderForCurrentProxy(): OkHttpClient.Builder {
private fun baseBuilderForCurrentProxy(): Pair<OkHttpClient.Builder, Route> {
val builder = OkHttpClient.Builder()
val torProvider = ArtiTorManager.getInstance()
val socks: InetSocketAddress? = torProvider.currentSocksAddress()
@ -50,6 +70,6 @@ object OkHttpProvider {
val proxy = Proxy(Proxy.Type.SOCKS, socks)
builder.proxy(proxy)
}
return builder
return builder to if (socks == null) Route.DIRECT else Route.TOR
}
}

View File

@ -37,11 +37,13 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.CloudDownload
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Lock
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
@ -68,6 +70,7 @@ import com.bitchat.android.R
import com.bitchat.android.core.ui.component.button.CloseButton
import com.bitchat.android.core.ui.component.sheet.LocalSheetDismiss
import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet
import com.bitchat.android.util.downloadPhaseLabel
import com.bitchat.android.hotspot.HotspotActivity
import com.bitchat.android.net.ArtiTorManager
import com.bitchat.android.net.TorMode
@ -617,7 +620,18 @@ fun AboutSheet(
val apkViewModel: ApkDownloadViewModel = viewModel()
val apkUiState by apkViewModel.state.collectAsStateWithLifecycle()
val apkStatus = apkUiState.apkStatus
val releaseStatus = apkUiState.releaseStatus
val downloadProgress = apkUiState.downloadProgress
val shareableApk = when (apkStatus) {
is ApkPreparationStatus.Ready -> apkStatus
is ApkPreparationStatus.Downloading ->
apkStatus.shareableFallback
else -> null
}
val availableUpdate = (releaseStatus as? ApkReleaseStatus.Known)
?.takeIf { it.isNewerThanSharedApk }
val downloadRetryBlocked = apkUiState.downloadRetryAtMillis
?.let { it > System.currentTimeMillis() } == true
// Handle one-shot effects (navigation, toasts, share intents)
LaunchedEffect(Unit) {
@ -652,14 +666,23 @@ 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,
releaseStatus,
apkUiState.downloadRetryAtMillis
) != null
) {
apkViewModel.onEvent(ApkUiEvent.PrepareRowClicked)
}
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = if (apkStatus is ApkPreparationStatus.Ready) {
imageVector = if (shareableApk != null) {
Icons.Default.Share
} else {
Icons.Default.CloudDownload
@ -675,140 +698,210 @@ fun AboutSheet(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Text(
text = if (apkStatus is ApkPreparationStatus.Ready) {
stringResource(R.string.prepare_apk_ready_title)
} else {
stringResource(R.string.prepare_apk_title)
},
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
color = colorScheme.onSurface
)
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = if (shareableApk != null) {
stringResource(R.string.prepare_apk_ready_title)
} else {
stringResource(R.string.prepare_apk_title)
},
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
color = colorScheme.onSurface
)
if (availableUpdate != null) {
TooltipBox(
positionProvider = TooltipDefaults
.rememberTooltipPositionProvider(),
tooltip = {
PlainTooltip {
Text(
stringResource(
R.string.prepare_apk_update_available,
availableUpdate.version
)
)
}
},
state = rememberTooltipState()
) {
Icon(
imageVector = Icons.Default.Warning,
contentDescription = stringResource(
R.string.prepare_apk_update_warning
),
tint = colorScheme.tertiary,
modifier = Modifier
.padding(start = 6.dp)
.size(18.dp)
.clickable(
enabled = !downloadRetryBlocked
) {
apkViewModel.onEvent(
ApkUiEvent.DownloadUniversalClicked
)
}
)
}
}
}
Text(
text = when (val status = apkStatus) {
is ApkPreparationStatus.Loading -> stringResource(R.string.checking)
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 -> stringResource(R.string.prepare_apk_status_downloading, downloadProgress)
is ApkPreparationStatus.Resumable -> "Tap to resume • ${status.progressPercent}% downloaded"
is ApkPreparationStatus.Downloading ->
// Only the transfer has a percentage worth
// showing; the other phases are named
// instead of pretending to be at 0%.
if (status.phase.hasMeasurableProgress) {
stringResource(R.string.prepare_apk_status_downloading, downloadProgress)
} else {
stringResource(downloadPhaseLabel(status.phase))
}
is ApkPreparationStatus.Resumable ->
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 -> {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
strokeWidth = 2.dp
is ApkPreparationStatus.Downloading ->
ApkPrepareRowIconButton(
icon = Icons.Default.Close,
description = stringResource(
R.string.prepare_apk_stop
),
onClick = {
apkViewModel.onEvent(
ApkUiEvent.CancelDownload
)
}
)
}
is ApkPreparationStatus.Ready -> {
if (apkStatus.variant == ShareableApkVariant.ARM64) {
TextButton(
if (
apkStatus.source ==
UniversalApkManager.ApkSource.INSTALLED
) {
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)
},
enabled = !downloadRetryBlocked,
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
)
},
enabled = prepareRowTapAction(
apkStatus,
releaseStatus,
apkUiState.downloadRetryAtMillis
) != null,
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(
if (availableUpdate != null) {
R.string.prepare_apk_update_dialog_title
} else {
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)
text = if (availableUpdate != null) {
stringResource(
R.string.prepare_apk_update_dialog_message,
availableUpdate.version,
availableUpdate.sizeMB
)
} else {
stringResource(R.string.prepare_apk_dialog_message_unknown_size)
stringResource(
R.string.prepare_apk_dialog_message_unknown_size
)
},
style = MaterialTheme.typography.bodyMedium
)
@ -855,7 +948,11 @@ fun AboutSheet(
containerColor = colorScheme.error
)
) {
Text("Delete")
Text(
stringResource(
R.string.prepare_apk_button_delete
)
)
}
},
dismissButton = {
@ -867,9 +964,9 @@ fun AboutSheet(
)
}
// Show sharing rows only when APK is ready
val canShareAPK = apkStatus is ApkPreparationStatus.Ready ||
apkStatus is ApkPreparationStatus.UpdateAvailable
// A GitHub update is optional. Keep sharing visible while the
// replacement downloads or while metadata refreshes.
val canShareAPK = shareableApk != null
AnimatedVisibility(
visible = canShareAPK,

View File

@ -7,11 +7,16 @@ import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.bitchat.android.R
import com.bitchat.android.util.ApkDownloader
import com.bitchat.android.util.AppVersion
import com.bitchat.android.util.GitHubReleaseClient
import com.bitchat.android.util.LatestReleaseProvider
import com.bitchat.android.util.ShareableApkVariant
import com.bitchat.android.util.UniversalApkManager
import com.bitchat.android.util.WorkManagerApkDownloader
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@ -24,25 +29,41 @@ 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
/** [phase] is what the operation is actually doing; only a transfer has a real percentage. */
data class Downloading(
val phase: ApkDownloader.DownloadPhase = ApkDownloader.DownloadPhase.SelectingSource,
val shareableFallback: Ready? = null
) : ApkPreparationStatus()
object Downloading : ApkPreparationStatus()
data class Resumable(val progressPercent: Int, val message: String) : ApkPreparationStatus()
data class Error(val message: String) : ApkPreparationStatus()
data class Resumable(
val progressPercent: Int,
val message: String,
val retryAtMillis: Long? = null
) : ApkPreparationStatus()
data class Error(val message: String, val retryAtMillis: Long? = null) : ApkPreparationStatus()
}
sealed class ApkReleaseStatus {
object Unknown : ApkReleaseStatus()
object Checking : ApkReleaseStatus()
data class Known(
val version: String,
val sizeMB: Int,
val isNewerThanSharedApk: Boolean,
val fromStaleCache: Boolean
) : ApkReleaseStatus()
}
data class ApkUiState(
val apkStatus: ApkPreparationStatus = ApkPreparationStatus.Loading,
val releaseStatus: ApkReleaseStatus = ApkReleaseStatus.Unknown,
val downloadRetryAtMillis: Long? = null,
val downloadProgress: Int = 0,
val showPrepareDialog: Boolean = false,
val showDeleteDialog: Boolean = false,
@ -67,6 +88,43 @@ 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,
releaseStatus: ApkReleaseStatus = ApkReleaseStatus.Unknown,
downloadRetryAtMillis: Long? = null,
nowMillis: Long = System.currentTimeMillis()
): PrepareRowTapAction? = when {
downloadRetryAtMillis != null && downloadRetryAtMillis > nowMillis -> null
status is ApkPreparationStatus.NotDownloaded -> PrepareRowTapAction.OpenPrepareDialog
// Consent was already given for these; resuming straight away avoids a redundant prompt.
status is ApkPreparationStatus.Resumable &&
(status.retryAtMillis == null || status.retryAtMillis <= nowMillis) ->
PrepareRowTapAction.StartDownload
status is ApkPreparationStatus.Error &&
(status.retryAtMillis == null || status.retryAtMillis <= nowMillis) ->
PrepareRowTapAction.StartDownload
status is ApkPreparationStatus.Ready &&
(status.source == UniversalApkManager.ApkSource.INSTALLED ||
(releaseStatus as? ApkReleaseStatus.Known)?.isNewerThanSharedApk == true) ->
PrepareRowTapAction.OpenPrepareDialog
else -> null
}
// --- Effects (ViewModel → UI, one-shot) ---
sealed class ApkUiEffect {
@ -79,21 +137,33 @@ sealed class ApkUiEffect {
* ViewModel for APK download/status/share logic following MVI pattern.
* UI sends [ApkUiEvent], observes [ApkUiState], and collects [ApkUiEffect].
*/
class ApkDownloadViewModel(application: Application) : AndroidViewModel(application) {
class ApkDownloadViewModel internal constructor(
application: Application,
private val apkManager: UniversalApkManager,
private val downloader: ApkDownloader,
private val latestReleaseProvider: LatestReleaseProvider
) : AndroidViewModel(application) {
constructor(application: Application) : this(
application = application,
apkManager = UniversalApkManager(application),
downloader = WorkManagerApkDownloader(application),
latestReleaseProvider = GitHubReleaseClient(application)
)
companion object {
private const val TAG = "ApkDownloadVM"
}
private val apkManager = UniversalApkManager(application)
private val downloader: ApkDownloader = WorkManagerApkDownloader(application)
private val _state = MutableStateFlow(ApkUiState())
val state: StateFlow<ApkUiState> = _state.asStateFlow()
private val _effect = Channel<ApkUiEffect>(Channel.BUFFERED)
val effect = _effect.receiveAsFlow()
private var metadataRefreshJob: Job? = null
private var retryUnlockJob: Job? = null
init {
observeDownloader()
}
@ -117,16 +187,17 @@ 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,
_state.value.releaseStatus,
_state.value.downloadRetryAtMillis
)
) {
PrepareRowTapAction.OpenPrepareDialog ->
_state.update { it.copy(showPrepareDialog = true) }
}
is ApkPreparationStatus.Resumable -> {
startDownload()
}
else -> {}
PrepareRowTapAction.StartDownload -> startDownload()
null -> {}
}
}
@ -136,9 +207,14 @@ class ApkDownloadViewModel(application: Application) : AndroidViewModel(applicat
}
private fun onDownloadUniversalClicked() {
if (_state.value.downloadRetryAtMillis?.let { it > System.currentTimeMillis() } == true) {
return
}
val status = _state.value.apkStatus
val hasUpdate = (_state.value.releaseStatus as? ApkReleaseStatus.Known)
?.isNewerThanSharedApk == true
if (status is ApkPreparationStatus.Ready &&
status.variant == ShareableApkVariant.ARM64
(status.source == UniversalApkManager.ApkSource.INSTALLED || hasUpdate)
) {
_state.update { it.copy(showPrepareDialog = true) }
}
@ -193,14 +269,40 @@ class ApkDownloadViewModel(application: Application) : AndroidViewModel(applicat
private fun onCancelDownload() {
downloader.cancelDownload()
checkStatus()
val fallback = (_state.value.apkStatus as? ApkPreparationStatus.Downloading)
?.shareableFallback
_state.update {
it.copy(
apkStatus = fallback ?: ApkPreparationStatus.Loading,
downloadProgress = 0
)
}
if (fallback == null) checkStatus()
}
private fun startDownload() {
val current = _state.value.apkStatus
val stateRetryAt = _state.value.downloadRetryAtMillis
val retryAt = when (current) {
is ApkPreparationStatus.Resumable -> current.retryAtMillis
is ApkPreparationStatus.Error -> current.retryAtMillis
else -> null
}
if ((stateRetryAt ?: retryAt)?.let { it > System.currentTimeMillis() } == true) return
val fallback = when (current) {
is ApkPreparationStatus.Ready -> current
is ApkPreparationStatus.Downloading -> current.shareableFallback
else -> null
}
val partial = apkManager.getPartialDownloadProgress()
_state.update {
it.copy(
apkStatus = ApkPreparationStatus.Downloading,
apkStatus = ApkPreparationStatus.Downloading(
shareableFallback = fallback
),
downloadRetryAtMillis = null,
downloadProgress = partial ?: 0
)
}
@ -218,12 +320,44 @@ class ApkDownloadViewModel(application: Application) : AndroidViewModel(applicat
val resolvedStatus = resolveApkStatus()
_state.update { current ->
// 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 {
current.copy(apkStatus = resolvedStatus)
current.copy(apkStatus = resolvedStatus, downloadProgress = 0)
}
}
// Local availability is resolved and published before this independent network task
// starts. Metadata can add a freshness warning, but can never hide sharing.
refreshReleaseMetadata()
}
}
private fun refreshReleaseMetadata() {
if (metadataRefreshJob?.isActive == true) return
metadataRefreshJob = viewModelScope.launch {
_state.update { it.copy(releaseStatus = ApkReleaseStatus.Checking) }
latestReleaseProvider.latestRelease()
.onSuccess { snapshot ->
val shared = shareableReady(_state.value.apkStatus)
_state.update {
it.copy(
releaseStatus = ApkReleaseStatus.Known(
version = snapshot.release.versionName,
sizeMB = (snapshot.release.universalApkSize / 1024 / 1024).toInt(),
isNewerThanSharedApk = shared?.let { ready ->
AppVersion.isNewer(ready.version, snapshot.release.versionName)
} ?: false,
fromStaleCache = snapshot.isStale
)
)
}
}
.onFailure {
// Metadata is an optional enhancement. Keep the locally resolved APK state.
_state.update { it.copy(releaseStatus = ApkReleaseStatus.Unknown) }
}
}
}
@ -232,12 +366,28 @@ class ApkDownloadViewModel(application: Application) : AndroidViewModel(applicat
downloader.downloadState.collect { downloadState ->
when (downloadState) {
is ApkDownloader.DownloadState.Idle -> {
// Don't overwrite — status set by checkStatus()
val downloading = _state.value.apkStatus as? ApkPreparationStatus.Downloading
if (downloading != null) {
val fallback = downloading.shareableFallback
_state.update {
it.copy(
apkStatus = fallback ?: ApkPreparationStatus.Loading,
downloadProgress = 0
)
}
if (fallback == null) checkStatus()
}
}
is ApkDownloader.DownloadState.Downloading -> {
_state.update {
val fallback = (it.apkStatus as? ApkPreparationStatus.Downloading)
?.shareableFallback
?: (it.apkStatus as? ApkPreparationStatus.Ready)
it.copy(
apkStatus = ApkPreparationStatus.Downloading,
apkStatus = ApkPreparationStatus.Downloading(
phase = downloadState.phase,
shareableFallback = fallback
),
downloadProgress = downloadState.progressPercent
)
}
@ -245,51 +395,61 @@ class ApkDownloadViewModel(application: Application) : AndroidViewModel(applicat
is ApkDownloader.DownloadState.Success -> {
val info = apkManager.getCachedApkInfo()
_state.update {
val ready = ApkPreparationStatus.Ready(
version = info?.version ?: downloadState.version,
sizeMB = info?.let { cached ->
(cached.size / 1024 / 1024).toInt()
} ?: downloadState.sizeMB,
source = info?.source ?: UniversalApkManager.ApkSource.DOWNLOADED,
variant = info?.variant ?: ShareableApkVariant.UNIVERSAL
)
it.copy(
apkStatus = ApkPreparationStatus.Ready(
version = info?.version ?: downloadState.version,
sizeMB = info?.let { cached ->
(cached.size / 1024 / 1024).toInt()
} ?: downloadState.sizeMB,
source = info?.source ?: UniversalApkManager.ApkSource.GITHUB,
variant = info?.variant ?: ShareableApkVariant.UNIVERSAL
),
apkStatus = ready,
releaseStatus = releaseStatusFor(ready, it.releaseStatus),
downloadRetryAtMillis = null,
downloadProgress = 100
)
}
}
is ApkDownloader.DownloadState.Failed -> {
val localArm64 = apkManager.getCachedApkInfo()
?.takeIf { it.variant == ShareableApkVariant.ARM64 }
if (localArm64 != null) {
val fallback = (_state.value.apkStatus as? ApkPreparationStatus.Downloading)
?.shareableFallback
?: apkManager.getCachedApkInfo()?.toReady()
if (fallback != null) {
_state.update {
it.copy(
apkStatus = ApkPreparationStatus.Ready(
version = localArm64.version,
sizeMB = (localArm64.size / 1024 / 1024).toInt(),
source = localArm64.source,
variant = localArm64.variant
)
apkStatus = fallback,
releaseStatus = releaseStatusFor(fallback, it.releaseStatus),
downloadRetryAtMillis = downloadState.retryAtMillis,
downloadProgress = 0
)
}
_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,
retryAtMillis = downloadState.retryAtMillis
),
downloadRetryAtMillis = downloadState.retryAtMillis,
downloadProgress = downloadState.resumablePercent
)
} else {
it.copy(
apkStatus = ApkPreparationStatus.Error(downloadState.message)
apkStatus = ApkPreparationStatus.Error(
message,
downloadState.retryAtMillis
),
downloadRetryAtMillis = downloadState.retryAtMillis
)
}
}
}
scheduleRetryUnlock(downloadState.retryAtMillis)
}
}
}
@ -306,72 +466,77 @@ 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 =
runCatching {
getApplication<Application>().getString(
state.reason.messageRes,
*state.messageArgs.toTypedArray()
)
}.getOrElse {
getString(R.string.prepare_apk_error_generic)
}
private fun scheduleRetryUnlock(retryAtMillis: Long?) {
if (retryAtMillis == null) return
retryUnlockJob?.cancel()
retryUnlockJob = viewModelScope.launch {
delay((retryAtMillis - System.currentTimeMillis()).coerceAtLeast(0L))
_state.update {
val unlocked = when (val status = it.apkStatus) {
is ApkPreparationStatus.Resumable -> status.copy(retryAtMillis = null)
is ApkPreparationStatus.Error -> status.copy(retryAtMillis = null)
else -> status
}
it.copy(apkStatus = unlocked, downloadRetryAtMillis = null)
}
}
}
private fun shareableReady(status: ApkPreparationStatus): ApkPreparationStatus.Ready? =
when (status) {
is ApkPreparationStatus.Ready -> status
is ApkPreparationStatus.Downloading -> status.shareableFallback
else -> null
}
private fun releaseStatusFor(
ready: ApkPreparationStatus.Ready,
releaseStatus: ApkReleaseStatus
): ApkReleaseStatus = (releaseStatus as? ApkReleaseStatus.Known)?.let {
it.copy(isNewerThanSharedApk = AppVersion.isNewer(ready.version, it.version))
} ?: releaseStatus
private fun UniversalApkManager.ApkInfo.toReady() = ApkPreparationStatus.Ready(
version = version,
sizeMB = (size / 1024 / 1024).toInt(),
source = source,
variant = variant
)
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) {
info.toReady()
} 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,105 @@
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.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
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.
*
* Determinate transfer/resume progress and indeterminate non-transfer phases use the stable
* Material 3 progress API. The expressive wavy variant can be introduced independently later.
*/
@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 ->
LinearProgressIndicator(
progress = { progressPercent.asProgressFraction() },
modifier = barModifier
)
status is ApkPreparationStatus.Downloading ->
LinearProgressIndicator(modifier = barModifier)
status is ApkPreparationStatus.Resumable ->
LinearProgressIndicator(
progress = { status.progressPercent.asProgressFraction() },
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,
enabled: Boolean = true,
tint: Color = MaterialTheme.colorScheme.onSurfaceVariant
) {
TooltipBox(
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(),
tooltip = { PlainTooltip { Text(description) } },
state = rememberTooltipState(),
modifier = modifier
) {
IconButton(
onClick = onClick,
enabled = enabled,
modifier = Modifier.size(48.dp)
) {
Icon(
imageVector = icon,
contentDescription = description,
tint = if (enabled) tint else tint.copy(alpha = 0.38f),
modifier = Modifier.size(20.dp)
)
}
}
}

View File

@ -0,0 +1,281 @@
package com.bitchat.android.util
import androidx.annotation.StringRes
import com.bitchat.android.R
import java.io.File
import java.io.FileOutputStream
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"
)
)
)
}
/**
* Why a download failed, and which string says so.
*
* Crosses a WorkManager `Data` boundary by [name], never by resource id: WorkManager keeps failed
* records in its own database across app updates, and AAPT2 reassigns `R.string` ids on every
* build, so a persisted id would resolve against the wrong resource table after an update. Same
* reasoning as [ApkDownloader.DownloadPhase.fromKey].
*/
enum class ApkDownloadFailureReason(@StringRes val messageRes: Int) {
Generic(R.string.prepare_apk_error_generic),
Cancelled(R.string.prepare_apk_download_cancelled),
RateLimitedWithWait(R.string.prepare_apk_error_rate_limited_wait),
RateLimited(R.string.prepare_apk_error_rate_limited),
NoUniversalApk(R.string.prepare_apk_error_no_universal),
HttpFailure(R.string.prepare_apk_error_http),
InsufficientStorage(R.string.prepare_apk_error_storage_needed),
NoSources(R.string.prepare_apk_error_no_sources),
TorConnecting(R.string.prepare_apk_error_tor_connecting),
NoUsableUrl(R.string.prepare_apk_error_no_url),
Unreachable(R.string.prepare_apk_error_unreachable),
InsecureRedirect(R.string.prepare_apk_error_insecure_redirect),
ResumeRejected(R.string.prepare_apk_error_resume_rejected),
Incomplete(R.string.prepare_apk_error_incomplete),
InvalidResume(R.string.prepare_apk_error_invalid_resume),
UntrustedKey(R.string.prepare_apk_error_untrusted_key),
NotUniversal(R.string.prepare_apk_error_not_universal),
ApkUnreadable(R.string.prepare_apk_error_apk_unreadable),
NotBitchat(R.string.prepare_apk_error_not_bitchat),
NoVersion(R.string.prepare_apk_error_no_version),
SourceFailed(R.string.prepare_apk_error_source_failed),
AllSourcesFailed(R.string.prepare_apk_error_all_sources);
companion object {
/** Work enqueued by an older build may name a reason this build no longer has. */
fun fromKey(key: String?): ApkDownloadFailureReason =
entries.firstOrNull { it.name == key } ?: Generic
}
}
/**
* A host-neutral download failure that tells the worker whether backoff can help.
*
* [reason] 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,
val reason: ApkDownloadFailureReason,
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",
reason = if (minutes != null) {
ApkDownloadFailureReason.RateLimitedWithWait
} else {
ApkDownloadFailureReason.RateLimited
},
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",
reason = if (code == 404) {
ApkDownloadFailureReason.NoUniversalApk
} else {
ApkDownloadFailureReason.HttpFailure
},
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?
)
/**
* Makes a response body safe to append after resume metadata is updated.
* A full 200 replacement must discard bytes from the release that supplied the Range request.
*/
internal fun prepareApkTempFileForResponse(tempFile: File, appendResponse: Boolean) {
if (!appendResponse) FileOutputStream(tempFile, false).use { }
}
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

@ -33,12 +33,13 @@ class ApkDownloadWorker(
// Progress keys
const val KEY_PROGRESS = "progress"
const val KEY_PHASE = "phase"
const val KEY_VERSION = "version"
const val KEY_SIZE_MB = "size_mb"
const val KEY_ERROR = "error"
const val KEY_ERROR_REASON = "error_reason"
const val KEY_ERROR_ARGS = "error_args"
const val KEY_RESUMABLE_PERCENT = "resumable_percent"
private const val MAX_RETRIES = 3
const val KEY_RETRY_AT = "retry_at"
private const val CHANNEL_ID = "apk_download"
private const val NOTIFICATION_ID = 4201
@ -50,6 +51,8 @@ class ApkDownloadWorker(
applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
private var lastNotifiedProgress = -NOTIFY_STEP_PERCENT
private var lastProgress = 0
private var currentPhase = ApkDownloader.DownloadPhase.SelectingSource
override suspend fun doWork(): Result {
Log.d(TAG, "Starting APK download work")
@ -64,10 +67,20 @@ class ApkDownloadWorker(
Log.w(TAG, "Could not promote download to foreground work", e)
}
val result = apkManager.downloadUniversalApk { progress ->
setProgressAsync(Data.Builder().putInt(KEY_PROGRESS, progress).build())
updateNotification(progress)
}
val result = apkManager.downloadUniversalApk(
progressCallback = { progress ->
lastProgress = progress
publishProgress(progress, currentPhase)
updateNotification(progress)
},
phaseCallback = { phase ->
currentPhase = phase
publishProgress(lastProgress, phase)
// Forced: a phase change is exactly the moment the percentage stops meaning
// anything, so the every-5% threshold must not suppress the redraw.
updateNotification(lastProgress, force = true)
}
)
return if (result.isSuccess) {
val info = apkManager.getCachedApkInfo()
@ -81,20 +94,34 @@ 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")
// The reason's name, never its resource id: this record can outlive the build
// that wrote it, and resource ids are reassigned on every build.
.putString(
KEY_ERROR_REASON,
(failure?.reason ?: ApkDownloadFailureReason.Generic).name
)
.putStringArray(
KEY_ERROR_ARGS,
failure?.messageArgs.orEmpty().toTypedArray()
)
.putInt(KEY_RESUMABLE_PERCENT, partial ?: -1)
.apply { failure?.retryAtMillis?.let { putLong(KEY_RETRY_AT, it) } }
.build()
Result.failure(outputData)
}
@ -118,16 +145,28 @@ class ApkDownloadWorker(
}
}
private fun publishProgress(progress: Int, phase: ApkDownloader.DownloadPhase) {
setProgressAsync(
Data.Builder()
.putInt(KEY_PROGRESS, progress)
.putString(KEY_PHASE, phase.name)
.build()
)
}
private fun buildNotification(progress: Int): android.app.Notification {
val cancelIntent = WorkManager.getInstance(applicationContext)
.createCancelPendingIntent(id)
return NotificationCompat.Builder(applicationContext, CHANNEL_ID)
.setContentTitle(applicationContext.getString(R.string.apk_download_notification_title))
.setContentText(applicationContext.getString(downloadPhaseLabel(currentPhase)))
.setSmallIcon(R.drawable.ic_notification)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setProgress(100, progress, progress <= 0)
// A percentage is a lie outside the transfer: the release lookup, the Tor bootstrap
// and both verification passes have no measurable progress at all.
.setProgress(100, progress, !currentPhase.hasMeasurableProgress || progress <= 0)
.addAction(
android.R.drawable.ic_delete,
applicationContext.getString(android.R.string.cancel),
@ -136,8 +175,8 @@ class ApkDownloadWorker(
.build()
}
private fun updateNotification(progress: Int) {
if (progress - lastNotifiedProgress < NOTIFY_STEP_PERCENT) return
private fun updateNotification(progress: Int, force: Boolean = false) {
if (!force && progress - lastNotifiedProgress < NOTIFY_STEP_PERCENT) return
lastNotifiedProgress = progress
try {
notificationManager.notify(NOTIFICATION_ID, buildNotification(progress))
@ -149,13 +188,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

@ -29,8 +29,85 @@ interface ApkDownloader {
*/
sealed class DownloadState {
object Idle : DownloadState()
data class Downloading(val progressPercent: Int) : DownloadState()
data class Downloading(
val progressPercent: Int,
val phase: DownloadPhase = DownloadPhase.Transferring
) : DownloadState()
data class Success(val version: String, val sizeMB: Int) : DownloadState()
data class Failed(val message: String, val resumablePercent: Int?) : DownloadState()
/**
* [reason] and [messageArgs] are resolved by the ViewModel, which has a Context.
* Carrying the reason rather than formatted text keeps the failure localizable all the
* way across the WorkManager boundary.
*/
data class Failed(
val reason: ApkDownloadFailureReason,
val messageArgs: List<String>,
val resumablePercent: Int?,
val retryAtMillis: Long? = null
) : DownloadState()
}
}
/**
* What a download is actually doing.
*
* Only [Transferring] has meaningful percentage progress; selecting a mirror,
* waiting for connectivity, and checking the signature are indeterminate.
*/
enum class DownloadPhase {
AwaitingConnectivity,
/**
* Waiting out the backoff before another attempt. Distinct from
* [AwaitingConnectivity] because WorkManager parks a retry in ENQUEUED
* whether or not the device is online, and claiming a network wait there
* would be false on a connected device.
*/
Retrying,
SelectingSource,
AwaitingNetworkRoute,
Transferring,
VerifyingSignature;
/** A percentage is only honest while bytes are actually moving. */
val hasMeasurableProgress: Boolean get() = this == Transferring
companion object {
/** Tolerates an unknown or absent key, since it crosses a WorkManager Data boundary. */
fun fromKey(key: String?): DownloadPhase = when (key) {
// Work created by the previous implementation may still be observable.
"ResolvingRelease" -> SelectingSource
"VerifyingChecksum" -> VerifyingSignature
else -> entries.firstOrNull { it.name == key } ?: Transferring
}
}
}
}
/**
* What a queued work record is actually waiting for.
*
* WorkManager parks both cases in ENQUEUED, so the state alone cannot tell them apart. A non-zero
* [runAttemptCount] means the work already ran and failed, which makes this the retry backoff
* rather than an unmet network constraint.
*/
internal fun queuedPhase(runAttemptCount: Int): ApkDownloader.DownloadPhase =
if (runAttemptCount > 0) {
ApkDownloader.DownloadPhase.Retrying
} else {
ApkDownloader.DownloadPhase.AwaitingConnectivity
}
/** 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.AwaitingConnectivity ->
com.bitchat.android.R.string.prepare_apk_phase_awaiting_connectivity
ApkDownloader.DownloadPhase.Retrying ->
com.bitchat.android.R.string.prepare_apk_phase_retrying
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.VerifyingSignature ->
com.bitchat.android.R.string.prepare_apk_phase_verifying_signature
}

View File

@ -0,0 +1,73 @@
package com.bitchat.android.util
import android.content.Context
import androidx.core.content.edit
import com.bitchat.android.net.OkHttpProvider
import kotlin.math.ceil
/** Persistent, route-specific cooldowns for APK-related network requests. */
internal class ApkRateLimitStore(context: Context) {
companion object {
private const val PREFS_NAME = "apk_network_cooldowns"
private const val FALLBACK_COOLDOWN_MILLIS = 60_000L
private const val MAX_COOLDOWN_MILLIS = 60 * 60_000L
}
private val preferences = context.applicationContext.getSharedPreferences(
PREFS_NAME,
Context.MODE_PRIVATE
)
fun retryAtMillis(
scope: String,
route: OkHttpProvider.Route,
nowMillis: Long = System.currentTimeMillis()
): Long? {
val key = key(scope, route)
val deadline = preferences.getLong(key, 0L)
if (deadline <= nowMillis) {
if (deadline != 0L) preferences.edit { remove(key) }
return null
}
return deadline
}
fun recordRateLimit(
scope: String,
route: OkHttpProvider.Route,
serverRetryAtMillis: Long?,
nowMillis: Long = System.currentTimeMillis()
): Long {
val fallback = nowMillis + FALLBACK_COOLDOWN_MILLIS
val maximum = nowMillis + MAX_COOLDOWN_MILLIS
val deadline = (serverRetryAtMillis ?: fallback).coerceIn(nowMillis + 1_000L, maximum)
// Persist before reporting the failure so a process restart cannot bypass the cooldown.
preferences.edit(commit = true) { putLong(key(scope, route), deadline) }
return deadline
}
fun clear(scope: String, route: OkHttpProvider.Route) {
preferences.edit { remove(key(scope, route)) }
}
fun blockedException(
source: ApkDownloadSource,
retryAtMillis: Long,
nowMillis: Long = System.currentTimeMillis()
): ApkDownloadException {
val minutes = ceil(
(retryAtMillis - nowMillis).coerceAtLeast(1L) / 60_000.0
).toLong()
return ApkDownloadException(
message = "${source.id} is in a persisted rate-limit cooldown until $retryAtMillis",
reason = ApkDownloadFailureReason.RateLimitedWithWait,
messageArgs = listOf(source.displayName, minutes.toString()),
retryable = false,
sourceId = source.id,
retryAtMillis = retryAtMillis
)
}
private fun key(scope: String, route: OkHttpProvider.Route): String =
"${scope}_${route.name.lowercase()}"
}

View File

@ -1,338 +1,256 @@
package com.bitchat.android.util
import android.content.Context
import android.util.Log
import androidx.core.content.edit
import com.bitchat.android.net.ArtiTorManager
import com.bitchat.android.net.OkHttpProvider
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import okhttp3.Call
import okhttp3.Callback
import okhttp3.Request
import okhttp3.Response
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
internal interface LatestReleaseProvider {
suspend fun latestRelease(): Result<GitHubReleaseClient.ReleaseSnapshot>
}
private val fetchMutex = Mutex()
/** Fetches GitHub release metadata without participating in APK availability. */
internal class GitHubReleaseClient(
context: Context,
private val apiUrl: String = GITHUB_API_URL,
private val nowMillis: () -> Long = System::currentTimeMillis,
private val routedClient: () -> OkHttpProvider.RoutedClient = OkHttpProvider::routedHttpClient,
private val awaitRoute: suspend () -> Boolean = {
ArtiTorManager.getInstance().awaitSelectedRoute(ROUTE_READY_TIMEOUT_MILLIS)
},
private val rateLimits: ApkRateLimitStore = ApkRateLimitStore(context)
) : LatestReleaseProvider {
companion object {
private const val TAG = "GitHubRelease"
private const val GITHUB_API_URL =
"https://api.github.com/repos/permissionlesstech/bitchat-android/releases/latest"
private const val ROUTE_READY_TIMEOUT_MILLIS = 60_000L
private const val CACHE_TTL_MILLIS = 30 * 60_000L
private const val PREFS_NAME = "apk_release_metadata"
private const val RATE_LIMIT_SCOPE = "github_release_metadata"
private const val USER_AGENT = "BitChat-Android"
@Volatile
private var cachedRelease: CachedRelease? = null
private val SOURCE = ApkDownloadSource(
id = DefaultApkDownloadSources.GITHUB_ID,
displayName = "GitHub Releases",
latestApkUrl = "https://github.com/permissionlesstech/bitchat-android/releases/latest/" +
"download/bitchat-android-universal.apk"
)
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.
*/
suspend fun fetchLatestRelease(forceRefresh: Boolean = false): Result<Release> =
withContext(Dispatchers.IO) {
fetchMutex.withLock {
if (!forceRefresh) {
cachedRelease
?.takeIf { System.currentTimeMillis() - it.fetchedAtMillis < CACHE_TTL_MILLIS }
?.let { return@withLock Result.success(it.release) }
}
if (!awaitSelectedNetworkRoute()) {
return@withLock Result.failure(
ReleaseFetchException(
message = "Tor is still connecting. Try again when Tor is ready.",
retryable = true
)
)
}
var lastFailure: Throwable = ReleaseFetchException(
"Failed to fetch the latest release from GitHub"
)
repeat(MAX_FETCH_ATTEMPTS) { attempt ->
val result = fetchLatestReleaseOnce()
result.onSuccess { release ->
cachedRelease = CachedRelease(release, System.currentTimeMillis())
return@withLock Result.success(release)
}
lastFailure = result.exceptionOrNull() ?: 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(): Result<Release> {
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")
.build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
val remaining = response.header("X-RateLimit-Remaining")
val resetAt = response.header("X-RateLimit-Reset")
val message = when {
response.code == 403 && remaining == "0" ->
"GitHub API rate limit exceeded. Try again after reset time $resetAt."
response.code == 429 ->
"GitHub API rate limit exceeded. Please try again later."
else ->
"GitHub release request failed: HTTP ${response.code} ${response.message}"
}
Log.e(TAG, message)
return Result.failure(
ReleaseFetchException(
message = message,
httpCode = response.code,
retryable = response.code == 403 ||
response.code == 408 ||
response.code == 429 ||
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
)
)
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 {
internal fun parseRelease(jsonString: String): Release? = runCatching {
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)")
val tagName = json.optString("tag_name")
val versionName = tagName.removePrefix("v").trim()
if (versionName.isBlank()) return null
val assets = json.optJSONArray("assets") ?: return null
for (index in 0 until assets.length()) {
val asset = assets.getJSONObject(index)
val name = asset.optString("name")
val url = asset.optString("browser_download_url")
if (name.contains("universal", ignoreCase = true) &&
name.endsWith(".apk", ignoreCase = true) &&
url.startsWith("https://")
) {
return Release(
tagName = tagName,
versionName = versionName,
universalApkUrl = downloadUrl,
universalApkSha256 = sha256,
universalApkSize = size,
universalApkSize = asset.optLong("size", 0L),
universalApkUrl = url,
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
}
null
}.getOrNull()
}
/**
* 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
private val appContext = context.applicationContext
private val preferences = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
private val mutex = Mutex()
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()
override suspend fun latestRelease(): Result<ReleaseSnapshot> = withContext(Dispatchers.IO) {
mutex.withLock {
val cached = readCache()
val now = nowMillis()
val cacheAge = cached?.let { now - it.fetchedAtMillis }
if (cached != null && cacheAge != null && cacheAge in 0 until CACHE_TTL_MILLIS) {
return@withLock Result.success(ReleaseSnapshot(cached.release, isStale = false))
}
// 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()
if (!awaitRoute()) return@withLock cached.orRouteFailure()
val routeSnapshot = routedClient()
rateLimits.retryAtMillis(RATE_LIMIT_SCOPE, routeSnapshot.route, now)?.let { deadline ->
return@withLock cached.orFailure(
rateLimits.blockedException(SOURCE, deadline, now)
)
}
Log.w(TAG, "Could not extract SHA256 from release body")
return null
val request = Request.Builder()
.url(apiUrl)
.addHeader("User-Agent", USER_AGENT)
.addHeader("Accept", "application/vnd.github+json")
.addHeader("X-GitHub-Api-Version", "2022-11-28")
.apply { cached?.etag?.let { addHeader("If-None-Match", it) } }
.build()
val client = routeSnapshot.client.newBuilder()
.callTimeout(45, TimeUnit.SECONDS)
.connectTimeout(20, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
} catch (e: Exception) {
Log.w(TAG, "Error extracting SHA256", e)
return null
}
}
try {
client.newCall(request).awaitResponse().use { response ->
if (apiUrl.startsWith("https://") && !response.request.url.isHttps) {
return@withLock cached.orFailure(
IOException("GitHub redirected release metadata to an insecure URL")
)
}
if (response.code == 304 && cached != null) {
val refreshed = cached.copy(fetchedAtMillis = now)
writeCache(refreshed)
rateLimits.clear(RATE_LIMIT_SCOPE, routeSnapshot.route)
return@withLock Result.success(
ReleaseSnapshot(refreshed.release, isStale = false)
)
}
if (!response.isSuccessful) {
val failure = 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"),
nowMillis = now
)
val persistedFailure = if (
failure.reason == ApkDownloadFailureReason.RateLimited ||
failure.reason == ApkDownloadFailureReason.RateLimitedWithWait
) {
val deadline = rateLimits.recordRateLimit(
RATE_LIMIT_SCOPE,
routeSnapshot.route,
failure.retryAtMillis,
now
)
rateLimits.blockedException(SOURCE, deadline, now)
} else {
failure
}
return@withLock cached.orFailure(persistedFailure)
}
/**
* 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
val rawBody = response.body.string()
val release = parseRelease(rawBody)
?: return@withLock cached.orFailure(
IOException("GitHub's latest release has no universal APK asset")
)
val entry = CachedRelease(
release = release,
etag = response.header("ETag"),
fetchedAtMillis = now
)
writeCache(entry)
rateLimits.clear(RATE_LIMIT_SCOPE, routeSnapshot.route)
Result.success(ReleaseSnapshot(release, isStale = false))
}
} catch (error: CancellationException) {
throw error
} catch (error: Exception) {
Log.w(TAG, "Could not refresh release metadata; using cache when available", error)
cached.orFailure(error)
}
false
} catch (e: Exception) {
Log.e(TAG, "Error comparing versions", e)
false
}
}
/**
* Release information from GitHub.
*/
private fun CachedRelease?.orRouteFailure(): Result<ReleaseSnapshot> = orFailure(
IOException("The selected network route is not ready")
)
private fun CachedRelease?.orFailure(error: Throwable): Result<ReleaseSnapshot> =
if (this != null) {
Result.success(ReleaseSnapshot(release, isStale = true))
} else {
Result.failure(error)
}
private fun readCache(): CachedRelease? = runCatching {
val version = preferences.getString("version", null)?.takeIf { it.isNotBlank() } ?: return null
val url = preferences.getString("url", null)?.takeIf { it.startsWith("https://") } ?: return null
val name = preferences.getString("name", null)?.takeIf { it.isNotBlank() } ?: return null
CachedRelease(
release = Release(
versionName = version,
universalApkSize = preferences.getLong("size", 0L),
universalApkUrl = url,
universalApkName = name
),
etag = preferences.getString("etag", null),
fetchedAtMillis = preferences.getLong("fetched_at", 0L)
)
}.getOrNull()
private fun writeCache(entry: CachedRelease) {
preferences.edit(commit = true) {
putString("version", entry.release.versionName)
putLong("size", entry.release.universalApkSize)
putString("url", entry.release.universalApkUrl)
putString("name", entry.release.universalApkName)
putString("etag", entry.etag)
putLong("fetched_at", entry.fetchedAtMillis)
}
}
private suspend fun Call.awaitResponse(): Response =
suspendCancellableCoroutine { continuation ->
continuation.invokeOnCancellation { cancel() }
enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
if (continuation.isActive) {
continuation.resumeWith(Result.failure(e))
}
}
override fun onResponse(call: Call, response: Response) {
if (continuation.isActive) {
continuation.resumeWith(Result.success(response))
} else {
response.close()
}
}
})
}
data class Release(
val tagName: String,
val versionName: String,
val universalApkUrl: String,
val universalApkSha256: String?,
val universalApkSize: Long,
val universalApkUrl: String,
val universalApkName: String
)
class ReleaseFetchException(
message: String,
val httpCode: Int? = null,
val retryable: Boolean = true,
cause: Throwable? = null
) : IOException(message, cause)
data class ReleaseSnapshot(
val release: Release,
val isStale: Boolean
)
private data class CachedRelease(
val release: Release,
val etag: String?,
val fetchedAtMillis: Long
)
}

View File

@ -3,7 +3,6 @@ package com.bitchat.android.util
import android.content.Context
import androidx.work.Constraints
import androidx.work.BackoffPolicy
import com.bitchat.android.R
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
@ -59,13 +58,21 @@ class WorkManagerApkDownloader(context: Context) : ApkDownloader {
return when (workInfo.state) {
WorkInfo.State.ENQUEUED,
WorkInfo.State.BLOCKED -> {
// Waiting for constraints (network). Show existing partial progress if any.
// ENQUEUED covers two different waits. A non-zero attempt count means the work
// already ran and failed, so this is the retry backoff rather than a missing
// network — saying "waiting for network" there would be false while online.
val partial = apkManager.getPartialDownloadProgress()
ApkDownloader.DownloadState.Downloading(partial ?: 0)
ApkDownloader.DownloadState.Downloading(
partial ?: 0,
queuedPhase(workInfo.runAttemptCount)
)
}
WorkInfo.State.RUNNING -> {
val progress = workInfo.progress.getInt(ApkDownloadWorker.KEY_PROGRESS, 0)
ApkDownloader.DownloadState.Downloading(progress)
val phase = ApkDownloader.DownloadPhase.fromKey(
workInfo.progress.getString(ApkDownloadWorker.KEY_PHASE)
)
ApkDownloader.DownloadState.Downloading(progress, phase)
}
WorkInfo.State.SUCCEEDED -> {
val version = workInfo.outputData.getString(ApkDownloadWorker.KEY_VERSION) ?: ""
@ -73,16 +80,32 @@ class WorkManagerApkDownloader(context: Context) : ApkDownloader {
ApkDownloader.DownloadState.Success(version, sizeMB)
}
WorkInfo.State.FAILED -> {
val error = workInfo.outputData.getString(ApkDownloadWorker.KEY_ERROR) ?: "Download failed"
// Tolerates a missing or retired reason from an older build's record.
val reason = ApkDownloadFailureReason.fromKey(
workInfo.outputData.getString(ApkDownloadWorker.KEY_ERROR_REASON)
)
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(
reason = reason,
messageArgs = args,
resumablePercent = if (resumable >= 0) resumable else null,
retryAtMillis = workInfo.outputData.getLong(
ApkDownloadWorker.KEY_RETRY_AT,
0L
).takeIf { it > 0L }
)
}
WorkInfo.State.CANCELLED -> {
val partial = apkManager.getPartialDownloadProgress()
if (partial != null) {
ApkDownloader.DownloadState.Failed(
appContext.getString(R.string.prepare_apk_download_cancelled),
partial
reason = ApkDownloadFailureReason.Cancelled,
messageArgs = emptyList(),
resumablePercent = partial
)
} else {
ApkDownloader.DownloadState.Idle

View File

@ -236,36 +236,70 @@
<!-- 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>
<string name="prepare_apk_update_available">Version %1$s is available. You can keep sharing this APK or download the update.</string>
<string name="prepare_apk_update_warning">New version available</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>
<string name="prepare_apk_status_update_available">Update available</string>
<!-- Stages of preparing the APK. Only the transfer has a meaningful percentage. -->
<string name="prepare_apk_phase_awaiting_connectivity">Waiting for network…</string>
<string name="prepare_apk_phase_retrying">Retrying…</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_signature">Verifying signature…</string>
<string name="prepare_apk_stop">Stop download</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_update_dialog_title">Download Newer Universal APK?</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_update_dialog_message">Version %1$s is available from GitHub (%2$d MB). Your current APK remains shareable during the download.</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,142 @@
package com.bitchat.android.ui
import android.app.Application
import androidx.test.core.app.ApplicationProvider
import com.bitchat.android.util.ApkDownloader
import com.bitchat.android.util.GitHubReleaseClient
import com.bitchat.android.util.LatestReleaseProvider
import com.bitchat.android.util.ShareableApkVariant
import com.bitchat.android.util.UniversalApkManager
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.delay
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import kotlinx.coroutines.withTimeout
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.kotlin.mock
import org.mockito.kotlin.whenever
import org.robolectric.RobolectricTestRunner
import java.io.File
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(RobolectricTestRunner::class)
class ApkDownloadViewModelTest {
private lateinit var application: Application
@Before
fun setUp() {
Dispatchers.setMain(UnconfinedTestDispatcher())
application = ApplicationProvider.getApplicationContext()
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun `local apk is shareable before release metadata starts`() = runTest {
val manager = managerWithLocalApk()
val downloader = FakeDownloader()
lateinit var viewModel: ApkDownloadViewModel
val statusWhenMetadataStarted = CompletableDeferred<ApkPreparationStatus>()
val metadata = object : LatestReleaseProvider {
override suspend fun latestRelease(): Result<GitHubReleaseClient.ReleaseSnapshot> {
statusWhenMetadataStarted.complete(viewModel.state.value.apkStatus)
return Result.failure(IllegalStateException("synthetic offline response"))
}
}
viewModel = ApkDownloadViewModel(application, manager, downloader, metadata)
viewModel.onEvent(ApkUiEvent.CheckStatus)
val ready = awaitReady(viewModel)
assertEquals("1.7.5", ready.version)
assertTrue(statusWhenMetadataStarted.await() is ApkPreparationStatus.Ready)
}
@Test
fun `notification cancellation returning idle restores shareable fallback`() = runTest {
val manager = managerWithLocalApk()
val downloader = FakeDownloader()
val metadata = object : LatestReleaseProvider {
override suspend fun latestRelease(): Result<GitHubReleaseClient.ReleaseSnapshot> =
Result.failure(IllegalStateException("synthetic offline response"))
}
val viewModel = ApkDownloadViewModel(application, manager, downloader, metadata)
viewModel.onEvent(ApkUiEvent.CheckStatus)
val originalReady = awaitReady(viewModel)
viewModel.onEvent(ApkUiEvent.PrepareRowClicked)
viewModel.onEvent(ApkUiEvent.ConfirmDownload)
val downloading = viewModel.state.value.apkStatus as ApkPreparationStatus.Downloading
assertSame(originalReady, downloading.shareableFallback)
assertEquals(1, downloader.startCount)
downloader.emit(ApkDownloader.DownloadState.Idle)
val restored = awaitReady(viewModel)
assertEquals(originalReady, restored)
}
private suspend fun managerWithLocalApk(): UniversalApkManager {
val manager = mock<UniversalApkManager>()
whenever(manager.prepareLocalApkInfo()).thenReturn(
UniversalApkManager.ApkInfo(
version = "1.7.5",
downloadDate = 1_700_000_000_000L,
size = 12L * 1024 * 1024,
file = File(application.cacheDir, "synthetic-shareable.apk"),
source = UniversalApkManager.ApkSource.INSTALLED,
variant = ShareableApkVariant.UNIVERSAL,
downloadSourceId = null
)
)
whenever(manager.getPartialDownloadProgress()).thenReturn(null)
return manager
}
private suspend fun awaitReady(
viewModel: ApkDownloadViewModel
): ApkPreparationStatus.Ready = withTimeout(5_000L) {
while (true) {
(viewModel.state.value.apkStatus as? ApkPreparationStatus.Ready)?.let { return@withTimeout it }
delay(1L)
}
error("unreachable")
}
private class FakeDownloader : ApkDownloader {
private val mutableState = MutableStateFlow<ApkDownloader.DownloadState>(
ApkDownloader.DownloadState.Idle
)
override val downloadState = mutableState.asStateFlow()
var startCount = 0
override fun startDownload() {
startCount += 1
mutableState.value = ApkDownloader.DownloadState.Downloading(
progressPercent = 0,
phase = ApkDownloader.DownloadPhase.SelectingSource
)
}
override fun cancelDownload() = Unit
fun emit(state: ApkDownloader.DownloadState) {
mutableState.value = state
}
}
}

View File

@ -0,0 +1,128 @@
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 `a standalone installed universal apk can optionally be replaced from github`() {
assertEquals(
PrepareRowTapAction.OpenPrepareDialog,
prepareRowTapAction(ready(ShareableApkVariant.UNIVERSAL))
)
}
@Test
fun `a current downloaded universal apk leaves the row inert`() {
assertNull(
prepareRowTapAction(
ready(ShareableApkVariant.UNIVERSAL, UniversalApkManager.ApkSource.DOWNLOADED)
)
)
}
@Test
fun `a stale downloaded apk opens the update dialog without blocking sharing`() {
assertEquals(
PrepareRowTapAction.OpenPrepareDialog,
prepareRowTapAction(
ready(ShareableApkVariant.UNIVERSAL, UniversalApkManager.ApkSource.DOWNLOADED),
ApkReleaseStatus.Known(
version = "1.7.6",
sizeMB = 24,
isNewerThanSharedApk = true,
fromStaleCache = false
)
)
)
}
@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))
}
@Test
fun `a persisted rate limit disables manual retry until its deadline`() {
val retryAt = 50_000L
assertNull(
prepareRowTapAction(
ApkPreparationStatus.Error("Rate limited", retryAtMillis = retryAt),
nowMillis = retryAt - 1
)
)
assertEquals(
PrepareRowTapAction.StartDownload,
prepareRowTapAction(
ApkPreparationStatus.Error("Rate limited", retryAtMillis = retryAt),
nowMillis = retryAt
)
)
}
@Test
fun `rate limit also disables optional update while an apk remains shareable`() {
val retryAt = 50_000L
assertNull(
prepareRowTapAction(
ready(ShareableApkVariant.UNIVERSAL),
downloadRetryAtMillis = retryAt,
nowMillis = retryAt - 1
)
)
}
}

View File

@ -0,0 +1,210 @@
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.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import java.io.IOException
class ApkDownloadSourceTest {
@get:Rule
val temporaryFolder = TemporaryFolder()
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(ApkDownloadFailureReason.RateLimitedWithWait, failure.reason)
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(ApkDownloadFailureReason.HttpFailure, permissionsFailure.reason)
assertEquals(
listOf(source.displayName, "403", "Forbidden"),
permissionsFailure.messageArgs
)
assertEquals(now + 300_000L, quotaFailure.retryAtMillis)
assertEquals(ApkDownloadFailureReason.RateLimitedWithWait, quotaFailure.reason)
}
@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 `a full response discards bytes from the release that was being resumed`() {
val tempFile = temporaryFolder.newFile("download-temp.apk")
tempFile.writeBytes("old-release-prefix".toByteArray())
prepareApkTempFileForResponse(tempFile, appendResponse = false)
tempFile.appendBytes("new-release".toByteArray())
assertEquals("new-release", tempFile.readText())
}
@Test
fun `a valid partial response keeps resumable bytes`() {
val tempFile = temporaryFolder.newFile("download-temp.apk")
tempFile.writeBytes("first-".toByteArray())
prepareApkTempFileForResponse(tempFile, appendResponse = true)
tempFile.appendBytes("second".toByteArray())
assertEquals("first-second", tempFile.readText())
}
@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",
reason = ApkDownloadFailureReason.Generic,
retryable = false
)
)
)
}
@Test
fun `a failure reason survives the round trip through its key`() {
// WorkManager keeps failed records across app updates, so the key written by one build is
// read by the next. Resource ids are reassigned per build and would resolve to the wrong
// string; the name does not move.
ApkDownloadFailureReason.entries.forEach { reason ->
assertEquals(reason, ApkDownloadFailureReason.fromKey(reason.name))
}
}
@Test
fun `an absent or retired reason falls back instead of resolving nothing`() {
assertEquals(
ApkDownloadFailureReason.Generic,
ApkDownloadFailureReason.fromKey(null)
)
assertEquals(
ApkDownloadFailureReason.Generic,
ApkDownloadFailureReason.fromKey("ReasonFromAFutureBuild")
)
}
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

@ -0,0 +1,69 @@
package com.bitchat.android.util
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The phase crosses a WorkManager `Data` boundary as a plain string, so it has to survive a
* round trip and degrade sensibly when it does not.
*/
class DownloadPhaseTest {
@Test
fun `every phase survives the round trip through its key`() {
ApkDownloader.DownloadPhase.entries.forEach { phase ->
assertEquals(phase, ApkDownloader.DownloadPhase.fromKey(phase.name))
}
}
@Test
fun `an absent or unrecognised key falls back to the transfer`() {
// Work enqueued by an older build, or progress read before the first phase is published.
assertEquals(
ApkDownloader.DownloadPhase.Transferring,
ApkDownloader.DownloadPhase.fromKey(null)
)
assertEquals(
ApkDownloader.DownloadPhase.Transferring,
ApkDownloader.DownloadPhase.fromKey("SomePhaseFromAFutureBuild")
)
}
@Test
fun `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 `a queued retry is not reported as a connectivity wait`() {
// WorkManager returns a retry to ENQUEUED for the backoff even while the device is online,
// so attempt count is the only thing separating the two waits.
assertEquals(
ApkDownloader.DownloadPhase.AwaitingConnectivity,
queuedPhase(runAttemptCount = 0)
)
assertEquals(ApkDownloader.DownloadPhase.Retrying, queuedPhase(runAttemptCount = 1))
assertEquals(ApkDownloader.DownloadPhase.Retrying, queuedPhase(runAttemptCount = 2))
}
@Test
fun `only the transfer claims measurable progress`() {
assertTrue(ApkDownloader.DownloadPhase.Transferring.hasMeasurableProgress)
val unmeasurable = ApkDownloader.DownloadPhase.entries
.filterNot { it == ApkDownloader.DownloadPhase.Transferring }
assertFalse(unmeasurable.isEmpty())
unmeasurable.forEach {
assertFalse("$it has no percentage to report", it.hasMeasurableProgress)
}
}
}

View File

@ -1,99 +1,134 @@
package com.bitchat.android.util
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import com.bitchat.android.net.OkHttpProvider
import kotlinx.coroutines.test.runTest
import mockwebserver3.MockResponse
import mockwebserver3.MockWebServer
import okhttp3.OkHttpClient
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class GitHubReleaseClientTest {
private lateinit var context: Context
private lateinit var server: MockWebServer
private var nowMillis = 1_700_000_000_000L
private var route = OkHttpProvider.Route.DIRECT
@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()
)
@Before
fun setUp() {
context = ApplicationProvider.getApplicationContext()
context.getSharedPreferences("apk_release_metadata", Context.MODE_PRIVATE)
.edit().clear().commit()
context.getSharedPreferences("apk_network_cooldowns", Context.MODE_PRIVATE)
.edit().clear().commit()
server = MockWebServer()
server.start()
}
requireNotNull(release)
assertEquals("1.7.6", release.versionName)
assertEquals(49_283_072L, release.universalApkSize)
assertEquals(digest, release.universalApkSha256)
@After
fun tearDown() {
server.close()
}
@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()
)
fun `cached metadata is conditionally refreshed with its etag`() = runTest {
server.enqueue(successResponse(etag = "release-v1"))
val client = client()
assertEquals(digest, requireNotNull(release).universalApkSha256)
val first = client.latestRelease().getOrThrow()
assertEquals("1.7.6", first.release.versionName)
assertFalse(first.isStale)
nowMillis += 31 * 60_000L
server.enqueue(
MockResponse.Builder()
.code(304)
.build()
)
val refreshed = client.latestRelease().getOrThrow()
assertFalse(refreshed.isStale)
server.takeRequest()
assertEquals("release-v1", server.takeRequest().headers["If-None-Match"])
}
@Test
fun `rejects releases without a universal apk`() {
val release = GitHubReleaseClient.parseRelease(
fun `rate limit serves stale metadata and suppresses repeated requests`() = runTest {
server.enqueue(successResponse(etag = "release-v1"))
val client = client()
client.latestRelease().getOrThrow()
nowMillis += 31 * 60_000L
server.enqueue(
MockResponse.Builder()
.code(429)
.build()
)
val stale = client.latestRelease().getOrThrow()
val stillStale = client.latestRelease().getOrThrow()
assertTrue(stale.isStale)
assertTrue(stillStale.isStale)
assertEquals(2, server.requestCount)
}
@Test
fun `cooldown follows the actual client route`() = runTest {
server.enqueue(
MockResponse.Builder()
.code(429)
.addHeader("Retry-After", "120")
.build()
)
val client = client()
assertTrue(client.latestRelease().isFailure)
assertTrue(client.latestRelease().isFailure)
assertEquals(1, server.requestCount)
route = OkHttpProvider.Route.TOR
server.enqueue(successResponse(etag = "release-v1"))
assertTrue(client.latestRelease().isSuccess)
assertEquals(2, server.requestCount)
}
private fun client() = GitHubReleaseClient(
context = context,
apiUrl = server.url("/releases/latest").toString(),
nowMillis = { nowMillis },
routedClient = {
OkHttpProvider.RoutedClient(
client = OkHttpClient.Builder().build(),
route = route
)
},
awaitRoute = { true }
)
private fun successResponse(etag: String): MockResponse = MockResponse.Builder()
.code(200)
.addHeader("ETag", etag)
.body(
"""
{
"tag_name": "v1.7.6",
"assets": [
{
"name": "bitchat-android-arm64.apk",
"browser_download_url": "https://example.test/arm64.apk",
"size": 10
"name": "bitchat-android-universal.apk",
"browser_download_url": "https://downloads.example/bitchat-universal.apk",
"size": 25165824
}
]
}
""".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"))
}
.build()
}

View File

@ -121,6 +121,7 @@ nordic-ble = { module = "no.nordicsemi.android:ble", version.ref = "nordic-ble"
# WebSocket
okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
okhttp-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver3", version.ref = "okhttp" }
tor-android-binary = { module = "org.torproject:tor-android-binary", version.ref = "tor-android-binary" }
# Tor (embed) intentionally not pinned yet; add once repo is chosen
@ -192,7 +193,8 @@ testing = [
"mockito-kotlin",
"mockito-core",
"roboelectric",
"kotlinx-coroutines-test"
"kotlinx-coroutines-test",
"okhttp-mockwebserver"
]
compose-testing = [

View File

@ -3349,6 +3349,14 @@
<sha256 value="e1abd7f1116cf5e0c59947693e2189208ec94296b2a3394c959e3511d399a7b0" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.squareup.okhttp3" name="mockwebserver3" version="5.4.0">
<artifact name="mockwebserver3-5.4.0.jar">
<sha256 value="e1e1d51a57567d6db834f93d98831ab10bb0220b91a67580894d9ab36cc9f065" origin="Generated by Gradle"/>
</artifact>
<artifact name="mockwebserver3-5.4.0.module">
<sha256 value="1161099d541033926bc0886a0bc2b4fb92a99a4fe9410180baf377159a996bbb" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.squareup.okhttp3" name="okhttp" version="5.4.0">
<artifact name="okhttp-5.4.0.module">
<sha256 value="63973ba755ba6c77de1e82f5efa4195569800be00c93c0130ce4238a9b56c93c" origin="Generated by Gradle"/>

View File

@ -139,7 +139,8 @@ androidx.savedstate:savedstate-compose:1.4.0=debugAndroidTestCompileClasspath,de
androidx.savedstate:savedstate-ktx:1.4.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.savedstate:savedstate:1.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.security:security-crypto:1.1.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.startup:startup-runtime:1.1.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.startup:startup-runtime:1.1.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.startup:startup-runtime:1.2.0=debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
androidx.test.espresso:espresso-core:3.7.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
androidx.test.espresso:espresso-idling-resource:3.7.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
androidx.test.ext:junit:1.3.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
@ -245,6 +246,11 @@ com.google.testing.platform:core:0.0.9-alpha04=unified-test-platform-core
com.google.testing.platform:launcher:0.0.9-alpha04=unified-test-platform-gradle-work-action,unified-test-platform-launcher
com.google.testparameterinjector:test-parameter-injector:1.18=debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
com.ibm.icu:icu4j:77.1=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
com.squareup.okhttp3:mockwebserver3:5.4.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
com.squareup.okhttp3:okhttp-android:5.4.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
com.squareup.okhttp3:okhttp:5.4.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
com.squareup.okio:okio-jvm:3.17.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
com.squareup.okio:okio:3.17.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
com.sun.istack:istack-commons-runtime:3.0.8=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
com.sun.xml.fastinfoset:FastInfoset:1.2.16=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
commons-codec:commons-codec:1.17.1=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle