mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-15 06:56:30 +00:00
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:
commit
fcb4562bd5
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
281
app/src/main/java/com/bitchat/android/util/ApkDownloadSource.kt
Normal file
281
app/src/main/java/com/bitchat/android/util/ApkDownloadSource.kt
Normal 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()
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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()}"
|
||||
}
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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))
|
||||
}
|
||||
|
||||
}
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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 = [
|
||||
|
||||
@ -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"/>
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user