Merge pull request #812 from moehamade/fix/apk-download-and-rate-limit

fix: make APK sharing local-first and rate-limit safe
This commit is contained in:
callebtc 2026-08-11 09:51:39 +02:00 committed by GitHub
commit fcb4562bd5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 2858 additions and 971 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,37 +10,63 @@ 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)
private val wsClientRef = AtomicReference<OkHttpClient?>(null)
fun reset() {
httpClientRef.set(null)
wsClientRef.set(null)
enum class Route {
DIRECT,
TOR
}
fun httpClient(): OkHttpClient {
data class RoutedClient(
val client: OkHttpClient,
val route: Route
)
private val httpClientRef = AtomicReference<RoutedClient?>(null)
private val wsClientRef = AtomicReference<OkHttpClient?>(null)
private val clientLock = Any()
fun reset() {
synchronized(clientLock) {
httpClientRef.set(null)
wsClientRef.set(null)
}
}
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()
.callTimeout(15, TimeUnit.SECONDS)
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.build()
httpClientRef.set(client)
return client
return synchronized(clientLock) {
httpClientRef.get() ?: run {
val (builder, route) = baseBuilderForCurrentProxy()
val client = builder
.callTimeout(15, TimeUnit.SECONDS)
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.build()
RoutedClient(client, route).also(httpClientRef::set)
}
}
}
fun webSocketClient(): OkHttpClient {
wsClientRef.get()?.let { return it }
val client = baseBuilderForCurrentProxy()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(0, TimeUnit.SECONDS)
.writeTimeout(10, TimeUnit.SECONDS)
.build()
wsClientRef.set(client)
return client
return synchronized(clientLock) {
wsClientRef.get() ?: baseBuilderForCurrentProxy().first
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(0, TimeUnit.SECONDS)
.writeTimeout(10, TimeUnit.SECONDS)
.build()
.also(wsClientRef::set)
}
}
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 +76,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,16 @@ 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 }
// Handle one-shot effects (navigation, toasts, share intents)
LaunchedEffect(Unit) {
@ -652,14 +664,22 @@ 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
) != 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 +695,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 {
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.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.Error -> status.message
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,
context.resolveApkFailureMessage(
status.failure
),
status.progressPercent
)
is ApkPreparationStatus.Error ->
context.resolveApkFailureMessage(
status.failure
)
},
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)
},
tint = colorScheme.primary
)
} else if (
apkStatus.source ==
UniversalApkManager.ApkSource.DOWNLOADED
) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = stringResource(
R.string.prepare_apk_delete_confirm
ApkPrepareRowIconButton(
icon = Icons.Default.Delete,
description = stringResource(
R.string.prepare_apk_button_delete
),
tint = colorScheme.error,
modifier = Modifier.size(20.dp)
onClick = {
apkViewModel.onEvent(
ApkUiEvent.DeleteClicked
)
},
tint = colorScheme.error
)
}
}
is ApkPreparationStatus.Resumable,
is ApkPreparationStatus.Error ->
ApkPrepareRowIconButton(
icon = Icons.Default.Refresh,
description = stringResource(
R.string.prepare_apk_retry
),
onClick = {
apkViewModel.onEvent(
ApkUiEvent.PrepareRowClicked
)
},
tint = colorScheme.primary
)
else -> {}
}
}
// Prepare Dialog
if (apkUiState.showPrepareDialog) {
val status = apkStatus
val sizeMB: Int? = when (status) {
is ApkPreparationStatus.NotDownloaded -> status.sizeMB
is ApkPreparationStatus.UpdateAvailable -> status.newSizeMB
else -> null
}
AlertDialog(
onDismissRequest = { apkViewModel.onEvent(ApkUiEvent.DismissPrepareDialog) },
title = {
Text(
text = if (status is ApkPreparationStatus.UpdateAvailable) {
stringResource(R.string.prepare_apk_update_dialog_title)
} else {
stringResource(R.string.prepare_apk_dialog_title)
},
text = stringResource(
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 +945,11 @@ fun AboutSheet(
containerColor = colorScheme.error
)
) {
Text("Delete")
Text(
stringResource(
R.string.prepare_apk_button_delete
)
)
}
},
dismissButton = {
@ -867,9 +961,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

@ -1,16 +1,22 @@
package com.bitchat.android.ui
import android.app.Application
import android.content.Context
import android.util.Log
import androidx.annotation.StringRes
import androidx.core.content.FileProvider
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.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@ -24,25 +30,61 @@ 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 failure: ApkFailureMessage
) : ApkPreparationStatus()
data class Error(val failure: ApkFailureMessage) : ApkPreparationStatus()
}
/** A localizable failure kept as data until the UI or a one-shot effect renders it. */
data class ApkFailureMessage(
@StringRes val messageRes: Int,
val messageArgs: List<String> = emptyList()
)
/**
* Resolves a failure defensively. The reason and its arguments cross the WorkManager boundary
* independently, so an argument list that does not match the format string is possible; a row
* showing generic text beats one that throws while formatting.
*/
internal fun Context.resolveApkFailureMessage(failure: ApkFailureMessage): String {
return runCatching {
getString(
failure.messageRes,
*failure.messageArgs.toTypedArray()
)
}.getOrElse {
getString(R.string.prepare_apk_error_generic)
}
}
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 downloadProgress: Int = 0,
val showPrepareDialog: Boolean = false,
val showDeleteDialog: Boolean = false,
@ -67,6 +109,36 @@ 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
): PrepareRowTapAction? = when {
status is ApkPreparationStatus.NotDownloaded -> PrepareRowTapAction.OpenPrepareDialog
// Consent was already given for these; resuming straight away avoids a redundant prompt.
status is ApkPreparationStatus.Resumable -> PrepareRowTapAction.StartDownload
status is ApkPreparationStatus.Error -> PrepareRowTapAction.StartDownload
status is ApkPreparationStatus.Ready &&
(status.source == UniversalApkManager.ApkSource.INSTALLED ||
(releaseStatus as? ApkReleaseStatus.Known)?.isNewerThanSharedApk == true) ->
PrepareRowTapAction.OpenPrepareDialog
else -> null
}
// --- Effects (ViewModel → UI, one-shot) ---
sealed class ApkUiEffect {
@ -79,21 +151,32 @@ 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
init {
observeDownloader()
}
@ -117,16 +200,16 @@ 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
)
) {
PrepareRowTapAction.OpenPrepareDialog ->
_state.update { it.copy(showPrepareDialog = true) }
}
is ApkPreparationStatus.Resumable -> {
startDownload()
}
else -> {}
PrepareRowTapAction.StartDownload -> startDownload()
null -> {}
}
}
@ -137,8 +220,10 @@ class ApkDownloadViewModel(application: Application) : AndroidViewModel(applicat
private fun onDownloadUniversalClicked() {
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 +278,31 @@ 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 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
),
downloadProgress = partial ?: 0
)
}
@ -209,21 +311,68 @@ class ApkDownloadViewModel(application: Application) : AndroidViewModel(applicat
private fun checkStatus() {
viewModelScope.launch {
// WorkManager is the source of truth for active work. A queued or
// newly started job legitimately has no partial file yet, so never
// infer that it is orphaned from cache contents.
if (_state.value.apkStatus is ApkPreparationStatus.Downloading) {
return@launch
}
val resolvedStatus = resolveApkStatus()
_state.update { current ->
if (current.apkStatus is ApkPreparationStatus.Downloading) {
current
} else {
current.copy(apkStatus = resolvedStatus)
// WorkManager is the source of truth for active work. A queued or newly started
// job legitimately has no partial file yet, so never infer that it is orphaned
// from cache contents, and never let a resolved status overwrite it - the user
// may have started a download while the local artifact was being inspected.
when (val active = current.apkStatus) {
is ApkPreparationStatus.Downloading ->
// Active work still adopts a local artifact it was created without. A
// ViewModel restored onto a running download starts from Loading, so the
// observer had no Ready to carry into shareableFallback, and an installed
// APK - with both sharing actions - would stay hidden for the whole
// transfer. Deciding here covers the observer arriving before this runs
// and during the resolve above, which are different orderings.
if (active.shareableFallback == null) {
current.copy(
apkStatus = active.copy(
shareableFallback = shareableReady(resolvedStatus)
)
)
} else {
current
}
else -> current.copy(
apkStatus = resolvedStatus,
downloadProgress = 0
)
}
}
// Metadata is skipped while work is active, as it was before: an in-flight download
// has no use for a freshness check and the API budget is scarce.
if (_state.value.apkStatus is ApkPreparationStatus.Downloading) return@launch
// 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 +381,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,48 +410,53 @@ 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),
downloadProgress = 100
)
}
}
is ApkDownloader.DownloadState.Failed -> {
val localArm64 = apkManager.getCachedApkInfo()
?.takeIf { it.variant == ShareableApkVariant.ARM64 }
if (localArm64 != null) {
val failure = downloadState.toFailureMessage()
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),
downloadProgress = 0
)
}
_effect.send(ApkUiEffect.ShowToast(downloadState.message))
_effect.send(
ApkUiEffect.ShowToast(
getApplication<Application>().resolveApkFailureMessage(
failure
)
)
)
} else {
_state.update {
if (downloadState.resumablePercent != null) {
it.copy(
apkStatus = ApkPreparationStatus.Resumable(
progressPercent = downloadState.resumablePercent,
message = downloadState.message
failure = failure
),
downloadProgress = downloadState.resumablePercent
)
} else {
it.copy(
apkStatus = ApkPreparationStatus.Error(downloadState.message)
)
it.copy(apkStatus = ApkPreparationStatus.Error(failure))
}
}
}
@ -306,71 +476,55 @@ class ApkDownloadViewModel(application: Application) : AndroidViewModel(applicat
return getApplication<Application>().getString(resId)
}
private fun ApkDownloader.DownloadState.Failed.toFailureMessage() = ApkFailureMessage(
messageRes = reason.messageRes,
messageArgs = messageArgs
)
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)
val info = apkManager.prepareLocalApkInfo()
if (info != null) {
info.toReady()
} else {
val partial = apkManager.getPartialDownloadProgress()
if (partial != null) {
ApkPreparationStatus.Resumable(
progressPercent = partial,
failure = ApkFailureMessage(
messageRes = 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()
)
}
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)
// The exception text is English and often internal; log it, show a translated line.
Log.e(TAG, "Error reading APK status", e)
ApkPreparationStatus.Error(
e.message ?: getString(R.string.prepare_apk_error_github)
ApkFailureMessage(messageRes = 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
/**
* 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),
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
)
// X-RateLimit-Reset rides on every GitHub response, an ordinary 403 included, so it
// cannot tell an exhausted quota from a permissions failure. Only a spent quota or an
// explicit Retry-After says this request was the one that got limited. The reset header
// still supplies the deadline below, once being limited is established some other way.
val retryAfterMillis = retryAtMillis(
retryAfter = retryAfter,
rateLimitResetEpochSeconds = null,
nowMillis = nowMillis
)
val rateLimited = code == 429 ||
(code == 403 && (rateLimitRemaining?.trim() == "0" || retryAfterMillis != null))
if (rateLimited) {
return ApkDownloadException(
message = "${source.id} rate limited: HTTP $code, retryAt=$retryAt",
reason = ApkDownloadFailureReason.RateLimited,
messageArgs = listOf(source.displayName),
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,13 +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
private const val CHANNEL_ID = "apk_download"
private const val NOTIFICATION_ID = 4201
private const val NOTIFY_STEP_PERCENT = 5
@ -50,6 +50,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 +66,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 +93,36 @@ 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)
// No retry deadline is recorded: a rate-limit cooldown belongs to the route it was
// earned on, and ApkRateLimitStore already keeps it that way. A copy frozen here
// would outlive both the route and the cooldown.
.build()
Result.failure(outputData)
}
@ -118,16 +146,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 +176,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 +189,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,83 @@ 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] stay structured until the presentation boundary. Carrying
* them instead of formatted text keeps the failure localizable across WorkManager.
*/
data class Failed(
val reason: ApkDownloadFailureReason,
val messageArgs: List<String>,
val resumablePercent: Int?
) : 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,68 @@
package com.bitchat.android.util
import android.content.Context
import androidx.core.content.edit
import com.bitchat.android.net.OkHttpProvider
/** 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
): ApkDownloadException {
return ApkDownloadException(
message = "${source.id} is in a persisted rate-limit cooldown until $retryAtMillis",
reason = ApkDownloadFailureReason.RateLimited,
messageArgs = listOf(source.displayName),
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,266 @@
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()
// Route readiness can take longer than a cooldown. Judge an existing deadline at the
// point where the request can actually start, not with the pre-wait cache timestamp.
val routeReadyNow = nowMillis()
rateLimits.retryAtMillis(
RATE_LIMIT_SCOPE,
routeSnapshot.route,
routeReadyNow
)?.let { deadline ->
return@withLock cached.orFailure(
rateLimits.blockedException(SOURCE, deadline)
)
}
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 ->
// The route wait and the call itself can each take a minute, so `now` is too
// old to interpret a relative Retry-After: anchoring the cooldown there can
// date it into the past and let the very next check reach GitHub.
val responseNow = nowMillis()
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 = responseNow)
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 = responseNow
)
val persistedFailure = if (
failure.reason == ApkDownloadFailureReason.RateLimited
) {
val deadline = rateLimits.recordRateLimit(
RATE_LIMIT_SCOPE,
routeSnapshot.route,
failure.retryAtMillis,
responseNow
)
rateLimits.blockedException(SOURCE, deadline)
} 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 = responseNow
)
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,28 @@ 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
)
}
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 at
the presentation boundary: the row resolves its own status text, the ViewModel resolves the
toast shown when an APK is still shareable.
-->
<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,26 @@
package com.bitchat.android.net
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotSame
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class OkHttpProviderTest {
@Test
fun `reset clears cached clients without changing the route`() {
OkHttpProvider.reset()
val cachedHttp = OkHttpProvider.routedHttpClient()
val cachedWebSocket = OkHttpProvider.webSocketClient()
OkHttpProvider.reset()
val rebuiltHttp = OkHttpProvider.routedHttpClient()
val rebuiltWebSocket = OkHttpProvider.webSocketClient()
assertEquals(cachedHttp.route, rebuiltHttp.route)
assertNotSame(cachedHttp.client, rebuiltHttp.client)
assertNotSame(cachedWebSocket, rebuiltWebSocket)
}
}

View File

@ -0,0 +1,278 @@
package com.bitchat.android.ui
import android.app.Application
import androidx.test.core.app.ApplicationProvider
import com.bitchat.android.R
import com.bitchat.android.util.ApkDownloader
import com.bitchat.android.util.ApkDownloadFailureReason
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.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
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.assertNull
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 viewModel = ApkDownloadViewModel(
application,
manager,
downloader,
offlineMetadata()
)
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)
assertEquals(originalReady, awaitReady(viewModel))
}
@Test
fun `rate limits remain stable failures without countdowns`() = runTest {
val manager = mock<UniversalApkManager>()
whenever(manager.getCachedApkInfo()).thenReturn(null)
val downloader = FakeDownloader()
val viewModel = ApkDownloadViewModel(
application,
manager,
downloader,
offlineMetadata()
)
downloader.emit(
ApkDownloader.DownloadState.Failed(
reason = ApkDownloadFailureReason.RateLimited,
messageArgs = listOf("GitHub Releases"),
resumablePercent = null
)
)
val failure = awaitError(viewModel).failure
assertEquals(R.string.prepare_apk_error_rate_limited, failure.messageRes)
assertEquals(listOf("GitHub Releases"), failure.messageArgs)
}
@Test
fun `non-rate failures keep their own message`() = runTest {
val manager = mock<UniversalApkManager>()
whenever(manager.getCachedApkInfo()).thenReturn(null)
val downloader = FakeDownloader()
val viewModel = ApkDownloadViewModel(
application,
manager,
downloader,
offlineMetadata()
)
downloader.emit(
ApkDownloader.DownloadState.Failed(
reason = ApkDownloadFailureReason.AllSourcesFailed,
messageArgs = emptyList(),
resumablePercent = null
)
)
val failure = awaitError(viewModel).failure
assertEquals(R.string.prepare_apk_error_all_sources, failure.messageRes)
assertEquals(emptyList<String>(), failure.messageArgs)
}
@Test
fun `a download already running when the ViewModel starts still exposes the local apk`() =
runTest {
// Process death during a transfer leaves WorkManager running and the ViewModel fresh,
// so the observer builds Downloading out of Loading and has no Ready to carry. Without
// a fallback the row and both sharing actions vanish for the rest of the download.
val manager = managerWithLocalApk()
val downloader = FakeDownloader(
ApkDownloader.DownloadState.Downloading(
progressPercent = 30,
phase = ApkDownloader.DownloadPhase.Transferring
)
)
val viewModel = ApkDownloadViewModel(
application,
manager,
downloader,
offlineMetadata()
)
val restored = viewModel.state.value.apkStatus as ApkPreparationStatus.Downloading
assertNull(restored.shareableFallback)
viewModel.onEvent(ApkUiEvent.CheckStatus)
val adopted = awaitFallback(viewModel)
assertEquals("1.7.5", adopted.version)
assertEquals(UniversalApkManager.ApkSource.INSTALLED, adopted.source)
}
@Test
fun `adopting a local apk never displaces the fallback a download already carries`() = runTest {
val manager = managerWithLocalApk()
val downloader = FakeDownloader()
val viewModel = ApkDownloadViewModel(
application,
manager,
downloader,
offlineMetadata()
)
viewModel.onEvent(ApkUiEvent.CheckStatus)
val originalReady = awaitReady(viewModel)
viewModel.onEvent(ApkUiEvent.PrepareRowClicked)
viewModel.onEvent(ApkUiEvent.ConfirmDownload)
viewModel.onEvent(ApkUiEvent.CheckStatus)
val downloading = viewModel.state.value.apkStatus as ApkPreparationStatus.Downloading
assertSame(originalReady, downloading.shareableFallback)
}
private suspend fun awaitFallback(
viewModel: ApkDownloadViewModel
): ApkPreparationStatus.Ready = withTimeout(5_000L) {
while (true) {
(viewModel.state.value.apkStatus as? ApkPreparationStatus.Downloading)
?.shareableFallback
?.let { return@withTimeout it }
delay(1L)
}
error("unreachable")
}
private fun offlineMetadata() = object : LatestReleaseProvider {
override suspend fun latestRelease(): Result<GitHubReleaseClient.ReleaseSnapshot> =
Result.failure(IllegalStateException("synthetic offline response"))
}
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 suspend fun awaitError(
viewModel: ApkDownloadViewModel
): ApkPreparationStatus.Error = withTimeout(5_000L) {
while (true) {
(viewModel.state.value.apkStatus as? ApkPreparationStatus.Error)
?.let { return@withTimeout it }
delay(1L)
}
error("unreachable")
}
private class FakeDownloader(
initial: ApkDownloader.DownloadState = ApkDownloader.DownloadState.Idle
) : ApkDownloader {
private val mutableState = MutableStateFlow(initial)
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,109 @@
package com.bitchat.android.ui
import com.bitchat.android.R
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
)
private fun error() = ApkPreparationStatus.Error(
ApkFailureMessage(messageRes = R.string.prepare_apk_error_generic)
)
@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,
ApkFailureMessage(R.string.prepare_apk_download_interrupted)
)
)
)
assertEquals(
PrepareRowTapAction.StartDownload,
prepareRowTapAction(error())
)
}
@Test
fun `a download in flight is not restartable by tapping the row`() {
// Otherwise a stray tap behind the stop button would queue a second download.
ApkDownloader.DownloadPhase.entries.forEach { phase ->
assertNull(prepareRowTapAction(ApkPreparationStatus.Downloading(phase)))
}
assertNull(prepareRowTapAction(ApkPreparationStatus.Loading))
}
}

View File

@ -0,0 +1,250 @@
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 retains the server deadline without exposing a countdown`() {
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)
assertEquals(ApkDownloadFailureReason.RateLimited, failure.reason)
assertEquals(listOf(source.displayName), 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.RateLimited, quotaFailure.reason)
}
@Test
fun `a reset header alone does not make a 403 a rate limit`() {
// GitHub sends X-RateLimit-Reset on every response, so a permissions failure carries one
// while the quota is untouched. Reading it as a limit would park the route in a cooldown
// and serve stale metadata until a window the failure has nothing to do with.
val failure = ApkDownloadHttpErrors.fromResponse(
source = source,
code = 403,
responseMessage = "Forbidden",
retryAfter = null,
rateLimitRemaining = "4999",
rateLimitResetEpochSeconds = (now / 1000L + 1_800L).toString(),
nowMillis = now
)
assertEquals(ApkDownloadFailureReason.HttpFailure, failure.reason)
assertNull(failure.retryAtMillis)
assertEquals(
listOf(source.displayName, "403", "Forbidden"),
failure.messageArgs
)
}
@Test
fun `a secondary limit is still caught by its Retry-After`() {
// The quota is intact, so only Retry-After marks this one. It has to keep working, or
// tightening the reset-header case would blind the client to secondary limits.
val failure = ApkDownloadHttpErrors.fromResponse(
source = source,
code = 403,
responseMessage = "Forbidden",
retryAfter = "90",
rateLimitRemaining = "4999",
rateLimitResetEpochSeconds = (now / 1000L + 1_800L).toString(),
nowMillis = now
)
assertEquals(ApkDownloadFailureReason.RateLimited, failure.reason)
assertEquals(now + 90_000L, failure.retryAtMillis)
}
@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,223 @@
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()
)
/** How far the clock advances while awaitRoute() waits for Tor to finish bootstrapping. */
private var routeWaitMillis = 0L
requireNotNull(release)
assertEquals("1.7.6", release.versionName)
assertEquals(49_283_072L, release.universalApkSize)
assertEquals(digest, release.universalApkSha256)
/** How far the clock advances after the server responds but before the client observes it. */
private var responseWaitMillis = 0L
@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()
}
@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)
}
/**
* A Tor cold start can hold the request for the full 60-second route timeout, which is longer
* than the relative delay GitHub asks for. The cooldown has to outlast the wait that preceded
* it, so it is anchored at the response rather than at the start of the attempt.
*/
@Test
fun `a slow route wait does not shorten a relative retry-after cooldown`() = runTest {
routeWaitMillis = 90_000L
server.enqueue(
MockResponse.Builder()
.code(429)
.addHeader("Retry-After", "60")
.build()
)
val client = client()
assertTrue(client.latestRelease().isFailure)
routeWaitMillis = 0L
assertTrue(client.latestRelease().isFailure)
assertEquals(1, server.requestCount)
}
@Test
fun `a slow route wait does not shorten the header-less fallback cooldown`() = runTest {
routeWaitMillis = 90_000L
server.enqueue(MockResponse.Builder().code(429).build())
val client = client()
assertTrue(client.latestRelease().isFailure)
routeWaitMillis = 0L
assertTrue(client.latestRelease().isFailure)
assertEquals(1, server.requestCount)
}
@Test
fun `a slow response does not shorten a relative retry-after cooldown`() = runTest {
responseWaitMillis = 90_000L
server.enqueue(
MockResponse.Builder()
.code(429)
.addHeader("Retry-After", "60")
.build()
)
val client = client()
assertTrue(client.latestRelease().isFailure)
responseWaitMillis = 0L
assertTrue(client.latestRelease().isFailure)
assertEquals(1, server.requestCount)
}
@Test
fun `a cooldown that expires while the route becomes ready does not suppress the request`() =
runTest {
server.enqueue(
MockResponse.Builder()
.code(429)
.addHeader("Retry-After", "60")
.build()
)
val client = client()
assertTrue(client.latestRelease().isFailure)
routeWaitMillis = 90_000L
server.enqueue(successResponse(etag = "release-after-cooldown"))
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()
.addInterceptor { chain ->
chain.proceed(chain.request()).also {
nowMillis += responseWaitMillis
}
}
.build(),
route = route
)
},
awaitRoute = {
nowMillis += routeWaitMillis
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