diff --git a/app/gradle.lockfile b/app/gradle.lockfile index bd31d677..c66fac1d 100644 --- a/app/gradle.lockfile +++ b/app/gradle.lockfile @@ -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 diff --git a/app/src/main/java/com/bitchat/android/net/OkHttpProvider.kt b/app/src/main/java/com/bitchat/android/net/OkHttpProvider.kt index aafc0d10..f9ad16e2 100644 --- a/app/src/main/java/com/bitchat/android/net/OkHttpProvider.kt +++ b/app/src/main/java/com/bitchat/android/net/OkHttpProvider.kt @@ -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(null) - private val wsClientRef = AtomicReference(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(null) + private val wsClientRef = AtomicReference(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 { 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 } } diff --git a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt index 845f59ca..54ab0318 100644 --- a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt @@ -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, diff --git a/app/src/main/java/com/bitchat/android/ui/ApkDownloadViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ApkDownloadViewModel.kt index aea1afac..4d0c2d5b 100644 --- a/app/src/main/java/com/bitchat/android/ui/ApkDownloadViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ApkDownloadViewModel.kt @@ -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 = 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 = _state.asStateFlow() private val _effect = Channel(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().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().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) ) } } diff --git a/app/src/main/java/com/bitchat/android/ui/ApkPrepareRowControls.kt b/app/src/main/java/com/bitchat/android/ui/ApkPrepareRowControls.kt new file mode 100644 index 00000000..7e20edda --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/ApkPrepareRowControls.kt @@ -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) + ) + } + } +} diff --git a/app/src/main/java/com/bitchat/android/util/ApkDownloadSource.kt b/app/src/main/java/com/bitchat/android/util/ApkDownloadSource.kt new file mode 100644 index 00000000..226e6842 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/util/ApkDownloadSource.kt @@ -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 +) { + 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 = 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() +} diff --git a/app/src/main/java/com/bitchat/android/util/ApkDownloadWorker.kt b/app/src/main/java/com/bitchat/android/util/ApkDownloadWorker.kt index b8285321..40edde59 100644 --- a/app/src/main/java/com/bitchat/android/util/ApkDownloadWorker.kt +++ b/app/src/main/java/com/bitchat/android/util/ApkDownloadWorker.kt @@ -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) } } diff --git a/app/src/main/java/com/bitchat/android/util/ApkDownloader.kt b/app/src/main/java/com/bitchat/android/util/ApkDownloader.kt index 3bf234ae..01be6280 100644 --- a/app/src/main/java/com/bitchat/android/util/ApkDownloader.kt +++ b/app/src/main/java/com/bitchat/android/util/ApkDownloader.kt @@ -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, + val resumablePercent: Int? + ) : DownloadState() } -} \ No newline at end of file + + /** + * 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 +} diff --git a/app/src/main/java/com/bitchat/android/util/ApkRateLimitStore.kt b/app/src/main/java/com/bitchat/android/util/ApkRateLimitStore.kt new file mode 100644 index 00000000..963099b3 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/util/ApkRateLimitStore.kt @@ -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()}" +} diff --git a/app/src/main/java/com/bitchat/android/util/GitHubReleaseClient.kt b/app/src/main/java/com/bitchat/android/util/GitHubReleaseClient.kt index 699637bd..d00122d8 100644 --- a/app/src/main/java/com/bitchat/android/util/GitHubReleaseClient.kt +++ b/app/src/main/java/com/bitchat/android/util/GitHubReleaseClient.kt @@ -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 +} - 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 = - 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 { - 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 = 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 = orFailure( + IOException("The selected network route is not ready") + ) + + private fun CachedRelease?.orFailure(error: Throwable): Result = + 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 ) } diff --git a/app/src/main/java/com/bitchat/android/util/UniversalApkManager.kt b/app/src/main/java/com/bitchat/android/util/UniversalApkManager.kt index 1427e0e0..8480fc27 100644 --- a/app/src/main/java/com/bitchat/android/util/UniversalApkManager.kt +++ b/app/src/main/java/com/bitchat/android/util/UniversalApkManager.kt @@ -5,9 +5,11 @@ import android.content.pm.PackageManager import android.os.Build import android.util.Log import com.bitchat.android.BuildConfig +import com.bitchat.android.net.ArtiTorManager import com.bitchat.android.net.OkHttpProvider import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext import okhttp3.Call @@ -26,7 +28,15 @@ import java.security.MessageDigest /** * Manages local and downloaded APK artifacts for offline sharing. */ -class UniversalApkManager(private val context: Context) { +class UniversalApkManager( + private val context: Context, + private val downloadSources: List = DefaultApkDownloadSources.all +) { + init { + require(downloadSources.map { it.id }.distinct().size == downloadSources.size) { + "APK download source ids must be unique" + } + } companion object { private const val TAG = "UniversalApk" @@ -34,6 +44,8 @@ class UniversalApkManager(private val context: Context) { private const val METADATA_FILE_NAME = "universal_apk_info.json" private const val PROGRESS_FILE_NAME = "download_progress.json" private const val APK_FILE_PREFIX = "bitchat-universal-" + private const val TEMP_FILE_NAME = "download_temp.apk" + private const val ROUTE_READY_TIMEOUT_MILLIS = 60_000L // Download buffer size (128KB) private const val BUFFER_SIZE = 128 * 1024 @@ -44,14 +56,19 @@ class UniversalApkManager(private val context: Context) { private val metadataFile: File get() = File(cacheDir, METADATA_FILE_NAME) private val progressFile: File get() = File(cacheDir, PROGRESS_FILE_NAME) + private val rateLimits = ApkRateLimitStore(context) - // Download client: inherits Tor proxy settings but with no call timeout - // for large file downloads that can take minutes - private val downloadClient - get() = OkHttpProvider.httpClient().newBuilder() - .callTimeout(0, java.util.concurrent.TimeUnit.SECONDS) - .readTimeout(60, java.util.concurrent.TimeUnit.SECONDS) - .build() + // Download client: inherits the current client's actual route but has no call timeout for + // large files that can take minutes. Keep the route attached for route-specific cooldowns. + private fun downloadClient(): OkHttpProvider.RoutedClient { + val routed = OkHttpProvider.routedHttpClient() + return routed.copy( + client = routed.client.newBuilder() + .callTimeout(0, java.util.concurrent.TimeUnit.SECONDS) + .readTimeout(60, java.util.concurrent.TimeUnit.SECONDS) + .build() + ) + } /** * Get information about the cached sharing APK, if it exists. @@ -64,13 +81,16 @@ class UniversalApkManager(private val context: Context) { val json = JSONObject(metadataFile.readText()) val version = json.optString("version", "") - val checksum = json.optString("checksum", "") val downloadDate = json.optLong("downloadDate", 0L) val size = json.optLong("size", 0L) val fileName = json.optString("fileName", "") - val source = runCatching { - ApkSource.valueOf(json.optString("source", ApkSource.GITHUB.name)) - }.getOrDefault(ApkSource.GITHUB) + val source = when (json.optString("source")) { + ApkSource.INSTALLED.name -> ApkSource.INSTALLED + // Migrate metadata written before downloads became mirror-agnostic. + else -> ApkSource.DOWNLOADED + } + val downloadSourceId = json.optString("downloadSourceId") + .takeIf { it.isNotBlank() } if (version.isBlank() || fileName.isBlank()) { return null @@ -91,12 +111,12 @@ class UniversalApkManager(private val context: Context) { ApkInfo( version = version, - checksum = checksum, downloadDate = downloadDate, size = size, file = apkFile, source = source, - variant = variant + variant = variant, + downloadSourceId = downloadSourceId ) } catch (e: Exception) { Log.e(TAG, "Error reading cached APK info", e) @@ -116,10 +136,10 @@ class UniversalApkManager(private val context: Context) { * Returns the progress percentage (0-100) or null if no partial download. */ fun getPartialDownloadProgress(): Int? { - val tempFile = File(cacheDir, "download_temp.apk") + val tempFile = File(cacheDir, TEMP_FILE_NAME) val resumeInfo = loadResumeInfo() if (tempFile.exists() && resumeInfo != null) { - val expectedSize = resumeInfo.optLong("expectedSize", 0L) + val expectedSize = resumeInfo.expectedSize if (expectedSize > 0) { return ((tempFile.length() * 100) / expectedSize).toInt().coerceIn(0, 99) } @@ -128,58 +148,12 @@ class UniversalApkManager(private val context: Context) { } /** - * Check for updates from GitHub. - * @return UpdateStatus indicating if update is available, current version, etc. + * Prepare or read the best local sharing artifact. This never performs a + * network request, so opening the About sheet cannot consume API quota or + * wait for Tor. */ - suspend fun checkForUpdate(): UpdateStatus = withContext(Dispatchers.IO) { - try { - // A supported standalone APK is already an installable sharing - // artifact. Split installs still need the universal GitHub artifact. - val installedApkInfo = cacheInstalledApkIfPreferred() - if (installedApkInfo?.source == ApkSource.INSTALLED) { - return@withContext UpdateStatus.UpToDate(installedApkInfo.version) - } - - val cachedInfo = getCachedApkInfo() - val latestRelease = GitHubReleaseClient.fetchLatestRelease().getOrElse { error -> - return@withContext UpdateStatus.Error( - error.message ?: "Failed to fetch latest release from GitHub" - ) - } - // The GitHub release may briefly lag behind the installed version - // (upstream bumps versionName in main before tagging the release). - // An older release is still a genuine, signed, universal artifact — - // recipients with a newer install can't be downgraded by Android - // anyway — so share it rather than disabling the feature. - if (isOlderThanInstalledVersion(latestRelease.versionName)) { - Log.i( - TAG, - "GitHub universal APK ${latestRelease.versionName} is older than installed " + - "app ${installedVersionName()}; sharing it until the matching release ships" - ) - } - - if (cachedInfo == null) { - // No cached APK - return@withContext UpdateStatus.NotDownloaded(latestRelease) - } - - // Compare versions - val isNewer = GitHubReleaseClient.isNewerVersion(cachedInfo.version, latestRelease) - - if (isNewer) { - UpdateStatus.UpdateAvailable( - currentVersion = cachedInfo.version, - latestRelease = latestRelease - ) - } else { - UpdateStatus.UpToDate(cachedInfo.version) - } - - } catch (e: Exception) { - Log.e(TAG, "Error checking for update", e) - UpdateStatus.Error(e.message ?: "Unknown error") - } + suspend fun prepareLocalApkInfo(): ApkInfo? = withContext(Dispatchers.IO) { + cacheInstalledApkIfPreferred() ?: getCachedApkInfo() } /** @@ -196,248 +170,393 @@ class UniversalApkManager(private val context: Context) { val availableMB = availableSpace / 1024 / 1024 val error = "Insufficient storage: need ${requiredMB}MB, have ${availableMB}MB" Log.e(TAG, error) - throw IOException(error) + throw ApkDownloadException( + message = error, + reason = ApkDownloadFailureReason.InsufficientStorage, + messageArgs = listOf(requiredMB.toString(), availableMB.toString()), + retryable = false + ) } } /** - * Download the universal APK from GitHub with resume support. - * @param progressCallback Called with progress percentage (0-100) - * @return Result with File on success, or error message + * Download from the configured sources. Each source gets one attempt in this + * worker run; WorkManager owns retry/backoff across runs. */ suspend fun downloadUniversalApk( - progressCallback: ((Int) -> Unit)? = null + progressCallback: ((Int) -> Unit)? = null, + phaseCallback: ((ApkDownloader.DownloadPhase) -> Unit)? = null ): Result = withContext(Dispatchers.IO) { - try { - Log.d(TAG, "Starting universal APK download") - - // Fetch latest release info - // Reuses the short-lived release metadata cache populated by the - // status check. If this worker is running after process death, the - // client performs a retried network fetch instead. - val release = GitHubReleaseClient.fetchLatestRelease().getOrElse { error -> - return@withContext Result.failure(error) - } - - if (!GitHubReleaseClient.awaitSelectedNetworkRoute()) { - return@withContext Result.failure( - IOException("Tor is still connecting. Try the download again when Tor is ready.") + if (downloadSources.isEmpty()) { + return@withContext Result.failure( + ApkDownloadException( + message = "No APK download sources are configured.", + reason = ApkDownloadFailureReason.NoSources, + retryable = false ) - } - - val url = release.universalApkUrl - val expectedSize = release.universalApkSize - - Log.d(TAG, "Downloading from: $url") - Log.d(TAG, "Expected size: ${expectedSize / 1024 / 1024}MB") - - val tempFile = File(cacheDir, "download_temp.apk") - - // Check for resumable download - var existingBytes = 0L - if (tempFile.exists()) { - val resumeInfo = loadResumeInfo() - if (resumeInfo != null && - resumeInfo.optString("url") == url && - resumeInfo.optString("versionName") == release.versionName - ) { - existingBytes = tempFile.length() - Log.d(TAG, "Resuming download from $existingBytes bytes") - } else { - Log.d(TAG, "Stale temp file found, starting fresh") - tempFile.delete() - progressFile.delete() - } - } - - // Bytes already in the temp file have already consumed storage, so - // a resume only needs room for the remaining tail. Promotion is a - // rename and needs no extra space. - checkDiskSpace((expectedSize - existingBytes).coerceAtLeast(0)) - - // A temp file that already holds the full asset means the process - // died between download and verification. Requesting - // "Range: bytes=-" for it would get HTTP 416 forever, so skip - // the network and let checksum/signature verification decide its fate. - if (expectedSize > 0 && existingBytes >= expectedSize) { - Log.d(TAG, "Temp file already complete ($existingBytes bytes), skipping to verification") - } else { - val requestBuilder = Request.Builder() - .url(url) - .addHeader("User-Agent", "BitChat-Android") - - if (existingBytes > 0) { - requestBuilder.addHeader("Range", "bytes=$existingBytes-") - Log.d(TAG, "Added Range header: bytes=$existingBytes-") - } - - val request = requestBuilder.build() - downloadToTempFile( - call = downloadClient.newCall(request), - tempFile = tempFile, - url = url, - expectedSize = expectedSize, - versionName = release.versionName, - existingBytes = existingBytes, - progressCallback = progressCallback - ) - } - - // Verify checksum if available - if (release.universalApkSha256 != null) { - Log.d(TAG, "Verifying checksum...") - val isValid = verifyChecksum(tempFile, release.universalApkSha256) - if (!isValid) { - tempFile.delete() - progressFile.delete() - return@withContext Result.failure( - Exception("Checksum verification failed. Downloaded file may be corrupted.") - ) - } - Log.d(TAG, "Checksum verified successfully") - } else { - Log.w(TAG, "No checksum available for verification") - } - - // Verify the downloaded APK against trusted signing certificates. - Log.d(TAG, "Verifying APK signature...") - if (!verifyApkSignature(tempFile)) { - tempFile.delete() - progressFile.delete() - return@withContext Result.failure( - Exception("APK signature verification failed. The downloaded APK is not signed by a trusted BitChat release key.") - ) - } - Log.d(TAG, "Signature verified successfully") - - if (!DistributionInfoProvider.isUniversalApk(tempFile)) { - tempFile.delete() - progressFile.delete() - return@withContext Result.failure( - Exception( - "GitHub asset is architecture-specific, not universal. " + - "Release packaging must be corrected." - ) - ) - } - - // Move to final location without deleting the currently usable APK - // first. Old versions are removed only after the replacement and - // metadata have both been committed. - val finalFileName = "$APK_FILE_PREFIX${release.versionName}.apk" - val finalFile = File(cacheDir, finalFileName) - replaceFileSafely(tempFile, finalFile) - - // Clean up resume metadata on success - progressFile.delete() - - // Save metadata - saveMetadata( - version = release.versionName, - checksum = release.universalApkSha256 ?: "", - size = finalFile.length(), - fileName = finalFileName, - source = ApkSource.GITHUB, - variant = ShareableApkVariant.UNIVERSAL ) - cleanupOldApks(except = finalFile) + } - Log.d(TAG, "Universal APK downloaded successfully: ${finalFile.path}") - Result.success(finalFile) + try { + phaseCallback?.invoke(ApkDownloader.DownloadPhase.AwaitingNetworkRoute) + if (!ArtiTorManager.getInstance().awaitSelectedRoute(ROUTE_READY_TIMEOUT_MILLIS)) { + return@withContext Result.failure( + ApkDownloadException( + message = "Tor is still connecting.", + reason = ApkDownloadFailureReason.TorConnecting, + retryable = true + ) + ) + } + val failures = mutableListOf() + val sources = sourcesWithResumeFirst() + for ((index, source) in sources.withIndex()) { + phaseCallback?.invoke(ApkDownloader.DownloadPhase.SelectingSource) + if (index > 0) clearPartialDownload() + + try { + Log.d(TAG, "Downloading universal APK from ${source.displayName}") + phaseCallback?.invoke(ApkDownloader.DownloadPhase.Transferring) + val tempFile = downloadFromSource(source, progressCallback) + + phaseCallback?.invoke(ApkDownloader.DownloadPhase.VerifyingSignature) + validateDownloadedApk(tempFile, source) + + // Everything from here to the metadata write is plain blocking code, so a + // cancellation arriving during the (slow) signature check would otherwise go + // unobserved and commit the APK anyway. The verified temp file survives for + // resume; only the promotion is abandoned. + ensureActive() + + val version = downloadedVersionName(tempFile) + val safeVersion = version.replace(Regex("[^A-Za-z0-9._-]"), "_") + val finalFileName = "$APK_FILE_PREFIX$safeVersion.apk" + val finalFile = File(cacheDir, finalFileName) + // Package parsing above is blocking. A cancellation arriving while it runs + // must not promote the verified temporary file into the shareable slot. + ensureActive() + replaceFileSafely(tempFile, finalFile) + progressFile.delete() + + saveMetadata( + version = version, + size = finalFile.length(), + fileName = finalFileName, + source = ApkSource.DOWNLOADED, + variant = ShareableApkVariant.UNIVERSAL, + downloadSourceId = source.id + ) + cleanupOldApks(except = finalFile) + Log.d(TAG, "Universal APK downloaded successfully from ${source.displayName}") + return@withContext Result.success(finalFile) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + val failure = e.asDownloadException(source) + failures += failure + Log.w(TAG, "${source.displayName} download failed", failure) + if (index < sources.lastIndex) { + Log.i(TAG, "Trying the next configured APK source") + } + } + } + + Result.failure(combineSourceFailures(failures)) } catch (e: CancellationException) { throw e - } catch (e: IOException) { - Log.e(TAG, "Network error downloading APK", e) - Result.failure(e) } catch (e: Exception) { Log.e(TAG, "Error downloading APK", e) Result.failure(e) } } + private fun sourcesWithResumeFirst(): List { + val tempFile = File(cacheDir, TEMP_FILE_NAME) + val resume = loadResumeInfo() + if (!tempFile.exists() || resume == null) return downloadSources + + val resumedSource = downloadSources.firstOrNull { it.id == resume.sourceId } + if (resumedSource == null) { + clearPartialDownload() + return downloadSources + } + return listOf(resumedSource) + downloadSources.filterNot { it.id == resumedSource.id } + } + + private suspend fun downloadFromSource( + source: ApkDownloadSource, + progressCallback: ((Int) -> Unit)? + ): File { + val tempFile = File(cacheDir, TEMP_FILE_NAME) + var resume = loadResumeInfo() + if (resume?.sourceId != source.id) { + clearPartialDownload() + resume = null + } + + var existingBytes = if (resume != null && tempFile.exists()) tempFile.length() else 0L + if (resume != null && resume.expectedSize > 0L && existingBytes == resume.expectedSize) { + Log.d(TAG, "Partial file is complete; continuing with APK verification") + return tempFile + } + if (resume != null && resume.expectedSize > 0L && existingBytes > resume.expectedSize) { + clearPartialDownload() + resume = null + existingBytes = 0L + } + + val resumeUrl = resume?.endpointUrl?.takeIf { it in source.latestApkUrls } + if (resume != null && resumeUrl == null) { + clearPartialDownload() + resume = null + existingBytes = 0L + } + + val endpoints = listOfNotNull(resumeUrl) + + source.latestApkUrls.filterNot { it == resumeUrl } + var lastFailure: ApkDownloadException? = null + for ((index, endpointUrl) in endpoints.withIndex()) { + if (index > 0) { + clearPartialDownload() + resume = null + existingBytes = 0L + } + + // A Range request is only safe with a validator. Without If-Range, a + // newly published release could be appended to bytes from the old one. + if (existingBytes > 0L && resume?.validator == null) { + clearPartialDownload() + resume = null + existingBytes = 0L + } + + try { + executeDownloadRequest( + source = source, + endpointUrl = endpointUrl, + tempFile = tempFile, + existingBytes = existingBytes, + resume = resume, + progressCallback = progressCallback + ) + return tempFile + } catch (e: ApkDownloadException) { + lastFailure = e + val assetNameFallback = shouldTryNextSourceUrl( + error = e, + hasMoreUrls = index < endpoints.lastIndex + ) + if (!assetNameFallback) throw e + Log.i(TAG, "APK filename not found; trying ${source.displayName}'s fallback URL") + } + } + throw lastFailure + ?: ApkDownloadException( + message = "${source.id} has no usable APK URL.", + reason = ApkDownloadFailureReason.NoUsableUrl, + messageArgs = listOf(source.displayName), + retryable = false + ) + } + + private suspend fun executeDownloadRequest( + source: ApkDownloadSource, + endpointUrl: String, + tempFile: File, + existingBytes: Long, + resume: ResumeInfo?, + progressCallback: ((Int) -> Unit)? + ) { + val routedClient = downloadClient() + val rateLimitScope = "apk_asset_${source.id}" + val now = System.currentTimeMillis() + rateLimits.retryAtMillis(rateLimitScope, routedClient.route, now)?.let { deadline -> + throw rateLimits.blockedException(source, deadline) + } + + val request = Request.Builder() + // Always start from the configured source endpoint. If a release changed, + // If-Range makes the server return 200 and we overwrite the partial. + .url(endpointUrl) + .addHeader("User-Agent", "BitChat-Android") + .apply { + if (existingBytes > 0L) { + addHeader("Range", "bytes=$existingBytes-") + resume?.validator?.let { addHeader("If-Range", it) } + } + } + .build() + + downloadToTempFile( + call = routedClient.client.newCall(request), + source = source, + rateLimitScope = rateLimitScope, + route = routedClient.route, + endpointUrl = endpointUrl, + tempFile = tempFile, + existingBytes = existingBytes, + previousResume = resume, + progressCallback = progressCallback + ) + } + /** - * Streams an HTTP response into [tempFile] while keeping the coroutine - * suspended for the lifetime of the response body. Cancelling the worker - * therefore cancels the OkHttp call and promptly unblocks a pending read. + * Streams an HTTP response into [tempFile]. Cancellation cancels the OkHttp + * call, and resume metadata is committed before bytes are appended. */ private suspend fun downloadToTempFile( call: Call, + source: ApkDownloadSource, + rateLimitScope: String, + route: OkHttpProvider.Route, + endpointUrl: String, tempFile: File, - url: String, - expectedSize: Long, - versionName: String, existingBytes: Long, + previousResume: ResumeInfo?, progressCallback: ((Int) -> Unit)? ) = suspendCancellableCoroutine { continuation -> fun completeSuccessfully() { - continuation.resumeWith(Result.success(Unit)) + if (continuation.isActive) continuation.resumeWith(Result.success(Unit)) } fun completeWithError(error: Throwable) { - continuation.resumeWith(Result.failure(error)) + if (continuation.isActive) continuation.resumeWith(Result.failure(error)) } - continuation.invokeOnCancellation { - call.cancel() - } + continuation.invokeOnCancellation { call.cancel() } try { call.enqueue(object : Callback { override fun onFailure(call: Call, e: IOException) { - completeWithError(e) + completeWithError( + ApkDownloadException( + message = "${source.id} could not be reached" + + (e.message?.let { ": $it" } ?: "."), + reason = ApkDownloadFailureReason.Unreachable, + messageArgs = listOf(source.displayName), + retryable = true, + sourceId = source.id, + cause = e + ) + ) } override fun onResponse(call: Call, response: Response) { try { response.use { + if (!response.request.url.isHttps) { + throw ApkDownloadException( + message = "${source.id} redirected to an insecure URL.", + reason = ApkDownloadFailureReason.InsecureRedirect, + messageArgs = listOf(source.displayName), + retryable = false, + sourceId = source.id + ) + } if (response.code == 416) { - // Our offset is no longer valid for this asset; discard - // the partial state so the retry starts from scratch. - Log.w(TAG, "Server rejected resume range, restarting download") - tempFile.delete() - progressFile.delete() - throw IOException( - "Resume rejected by server. Download will restart." + val total = parseUnsatisfiedContentRangeTotal( + response.header("Content-Range") + ) + if (total != null && total == existingBytes && tempFile.length() == total) { + completeSuccessfully() + return + } + clearPartialDownload() + throw ApkDownloadException( + message = "${source.id} rejected the saved download position.", + reason = ApkDownloadFailureReason.ResumeRejected, + messageArgs = listOf(source.displayName), + retryable = true, + sourceId = source.id, + httpCode = response.code ) } - if (!response.isSuccessful && response.code != 206) { - throw IOException( - "Download failed: ${response.code} ${response.message}" + 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") ) + if (failure.reason == ApkDownloadFailureReason.RateLimited) { + val now = System.currentTimeMillis() + val deadline = rateLimits.recordRateLimit( + rateLimitScope, + route, + failure.retryAtMillis, + now + ) + throw rateLimits.blockedException(source, deadline) + } + throw failure } + rateLimits.clear(rateLimitScope, route) + val body = response.body - ?: throw IOException("Empty response body") - - // Handle resume: 206 = partial content (append), 200 = full - // content (overwrite). - val append = response.code == 206 - val resumedBytes = if (!append && existingBytes > 0) { - Log.d( - TAG, - "Server didn't honor Range request, starting from scratch" - ) - 0L + val range = if (response.code == 206) { + parseContentRange(response.header("Content-Range")) + ?: throw invalidResumeResponse(source, tempFile) } else { - existingBytes + null + } + if (range != null && range.start != existingBytes) { + throw invalidResumeResponse(source, tempFile) } - saveResumeInfo(url, expectedSize, versionName) + val append = range != null + val resumedBytes = if (append) existingBytes else 0L + val expectedSize = range?.total + ?: body.contentLength().takeIf { it >= 0L }?.let { length -> + resumedBytes + length + } + ?: previousResume?.expectedSize?.takeIf { append } + ?: 0L + if (expectedSize > 0L) { + checkDiskSpace((expectedSize - resumedBytes).coerceAtLeast(0L)) + } - if (resumedBytes > 0 && expectedSize > 0) { - val initialProgress = - ((resumedBytes * 100) / expectedSize).toInt() - progressCallback?.invoke(initialProgress) + val validator = response.header("ETag") + ?: response.header("Last-Modified") + ?: previousResume?.validator?.takeIf { append } + + // A server may ignore Range and return a replacement 200 response. + // Remove the old bytes before recording the new validator. If the + // process dies at any later point, old and new release bytes cannot be + // combined on the next resume. + prepareApkTempFileForResponse(tempFile, append) + if (validator != null) { + saveResumeInfo( + ResumeInfo( + sourceId = source.id, + endpointUrl = endpointUrl, + expectedSize = expectedSize, + validator = validator + ) + ) + } else { + progressFile.delete() + } + + if (resumedBytes > 0L && expectedSize > 0L) { + progressCallback?.invoke( + ((resumedBytes * 100L) / expectedSize).toInt() + ) } body.byteStream().use { input -> - FileOutputStream(tempFile, append).use { output -> + // Non-resume responses were already truncated above; always append + // after resume metadata is safely committed. + FileOutputStream(tempFile, true).use { output -> val buffer = ByteArray(BUFFER_SIZE) var bytesRead: Int var totalBytesRead = resumedBytes - var lastProgress = if (expectedSize > 0) { - ((resumedBytes * 100) / expectedSize).toInt() + var lastProgress = if (expectedSize > 0L) { + ((resumedBytes * 100L) / expectedSize).toInt() } else { 0 } @@ -445,23 +564,29 @@ class UniversalApkManager(private val context: Context) { while (input.read(buffer).also { bytesRead = it } != -1) { output.write(buffer, 0, bytesRead) totalBytesRead += bytesRead - - if (expectedSize > 0) { - val progress = - ((totalBytesRead * 100) / expectedSize).toInt() + if (expectedSize > 0L) { + val progress = ( + (totalBytesRead * 100L) / expectedSize + ).toInt().coerceIn(0, 100) if (progress != lastProgress) { lastProgress = progress progressCallback?.invoke(progress) } } } - - Log.d( - TAG, - "Download complete: ${totalBytesRead / 1024 / 1024}MB" - ) } } + + if (expectedSize > 0L && tempFile.length() != expectedSize) { + if (tempFile.length() > expectedSize) clearPartialDownload() + throw ApkDownloadException( + message = "${source.id} download ended before all bytes arrived.", + reason = ApkDownloadFailureReason.Incomplete, + messageArgs = listOf(source.displayName), + retryable = true, + sourceId = source.id + ) + } } completeSuccessfully() } catch (e: Exception) { @@ -474,6 +599,106 @@ class UniversalApkManager(private val context: Context) { } } + private fun invalidResumeResponse( + source: ApkDownloadSource, + tempFile: File + ): ApkDownloadException { + tempFile.delete() + progressFile.delete() + return ApkDownloadException( + message = "${source.id} returned an invalid resume response.", + reason = ApkDownloadFailureReason.InvalidResume, + messageArgs = listOf(source.displayName), + retryable = true, + sourceId = source.id + ) + } + + private fun validateDownloadedApk(tempFile: File, source: ApkDownloadSource) { + if (!verifyApkSignature(tempFile)) { + clearPartialDownload() + throw ApkDownloadException( + message = "APK from ${source.id} is not signed by a trusted BitChat release key.", + reason = ApkDownloadFailureReason.UntrustedKey, + messageArgs = listOf(source.displayName), + retryable = false, + sourceId = source.id + ) + } + if (!DistributionInfoProvider.isUniversalApk(tempFile)) { + clearPartialDownload() + throw ApkDownloadException( + message = "${source.id} returned an architecture-specific APK.", + reason = ApkDownloadFailureReason.NotUniversal, + messageArgs = listOf(source.displayName), + retryable = false, + sourceId = source.id + ) + } + } + + private fun downloadedVersionName(apkFile: File): String { + val packageInfo = context.packageManager.getPackageArchiveInfo(apkFile.absolutePath, 0) + ?: invalidDownloadedApk(ApkDownloadFailureReason.ApkUnreadable, "unreadable APK") + if (packageInfo.packageName != context.packageName) { + invalidDownloadedApk(ApkDownloadFailureReason.NotBitchat, "wrong package") + } + return packageInfo.versionName + ?.takeIf { it.isNotBlank() } + ?: invalidDownloadedApk(ApkDownloadFailureReason.NoVersion, "no version name") + } + + private fun invalidDownloadedApk( + reason: ApkDownloadFailureReason, + logReason: String + ): Nothing { + clearPartialDownload() + throw ApkDownloadException( + message = "Downloaded APK rejected: $logReason", + reason = reason, + retryable = false + ) + } + + private fun Exception.asDownloadException(source: ApkDownloadSource): ApkDownloadException { + if (this is ApkDownloadException) return this + return ApkDownloadException( + message = "${source.id} download failed" + (message?.let { ": $it" } ?: "."), + reason = ApkDownloadFailureReason.SourceFailed, + messageArgs = listOf(source.displayName), + retryable = this is IOException, + sourceId = source.id, + cause = this + ) + } + + private fun combineSourceFailures( + failures: List + ): ApkDownloadException { + if (failures.size == 1) return failures.single() + if (failures.isEmpty()) { + return ApkDownloadException( + message = "APK download failed with no recorded source failure.", + reason = ApkDownloadFailureReason.Generic, + retryable = false + ) + } + // The per-source detail stays in the log line. Concatenating each source's sentence would + // mean re-assembling localized text here, where there is no Context to resolve it with. + return ApkDownloadException( + message = "All configured APK sources failed: " + + failures.joinToString(" • ") { it.message ?: "Unknown error" }, + reason = ApkDownloadFailureReason.AllSourcesFailed, + retryable = failures.any { it.retryable }, + cause = failures.last() + ) + } + + private fun clearPartialDownload() { + File(cacheDir, TEMP_FILE_NAME).delete() + progressFile.delete() + } + /** * Cache the APK this process was installed from when it is a standalone * universal or ARM64 artifact. A base APK from a split install is incomplete. @@ -502,7 +727,7 @@ class UniversalApkManager(private val context: Context) { // choice. Keep it even when the running ARM64 build is newer; the // user can delete it from the UI to return to the local artifact. if (installedVariant == ShareableApkVariant.ARM64 && - cachedInfo?.source == ApkSource.GITHUB && + cachedInfo?.source == ApkSource.DOWNLOADED && cachedInfo.variant == ShareableApkVariant.UNIVERSAL ) { return cachedInfo @@ -510,9 +735,9 @@ class UniversalApkManager(private val context: Context) { // Keep an already cached artifact if it is the same version or // newer. Otherwise prefer the running build so sharing cannot - // silently downgrade recipients to an older GitHub release. + // silently downgrade recipients to an older downloadable release. if (cachedInfo != null && - !GitHubReleaseClient.isNewerVersion(cachedInfo.version, installedVersion) + !AppVersion.isNewer(cachedInfo.version, installedVersion) ) { return cachedInfo } @@ -534,14 +759,13 @@ class UniversalApkManager(private val context: Context) { } replaceFileSafely(pendingFile, finalFile) - val checksum = calculateChecksum(finalFile) saveMetadata( version = installedVersion, - checksum = checksum, size = finalFile.length(), fileName = finalFileName, source = ApkSource.INSTALLED, - variant = installedVariant + variant = installedVariant, + downloadSourceId = null ) cleanupOldApks(except = finalFile) @@ -561,15 +785,10 @@ class UniversalApkManager(private val context: Context) { ?: BuildConfig.VERSION_NAME } - private fun isOlderThanInstalledVersion(candidateVersion: String): Boolean { - return GitHubReleaseClient.isNewerVersion(candidateVersion, installedVersionName()) - } - /** * Verify the downloaded APK against either the running app's signing lineage - * or the pinned GitHub release certificate. The latter supports Play installs - * when GitHub distribution uses a separate, explicitly trusted release key. - * Debug builds without a configured pin accept any signed (never unsigned) APK. + * or the pinned release certificate. The latter supports Play installs when + * downloadable artifacts use a separate, explicitly trusted release key. */ private fun verifyApkSignature(apkFile: File): Boolean { return try { @@ -587,6 +806,8 @@ class UniversalApkManager(private val context: Context) { val ownCerts = signatureDigests( context.packageManager.getPackageInfo(context.packageName, signingFlags()) ) + // Every mirror must serve the same official release-signed APK. + // The BuildConfig field keeps its historical name for configuration compatibility. val pinnedReleaseCert = normalizeCertificateDigest( BuildConfig.GITHUB_RELEASE_CERT_SHA256 ) @@ -595,7 +816,7 @@ class UniversalApkManager(private val context: Context) { // Debug builds may use a different local signing key, but still // require the downloaded artifact itself to be signed. Production // builds must match either this installation's signing lineage or - // the explicitly pinned GitHub release certificate. + // the explicitly pinned release certificate. if (BuildConfig.DEBUG && pinnedReleaseCert == null) { Log.w(TAG, "Debug build has no pinned release certificate; accepting signed APK") return true @@ -656,39 +877,6 @@ class UniversalApkManager(private val context: Context) { .takeIf { it.matches(Regex("[a-f0-9]{64}")) } } - /** - * Verify the SHA256 checksum of a file. - */ - suspend fun verifyChecksum(file: File, expectedSha256: String): Boolean = withContext(Dispatchers.IO) { - try { - val checksum = calculateChecksum(file) - val matches = checksum.equals(expectedSha256, ignoreCase = true) - - if (!matches) { - Log.e(TAG, "Checksum mismatch!") - Log.e(TAG, "Expected: $expectedSha256") - Log.e(TAG, "Actual: $checksum") - } - - matches - } catch (e: Exception) { - Log.e(TAG, "Error verifying checksum", e) - false - } - } - - private fun calculateChecksum(file: File): String { - val digest = MessageDigest.getInstance("SHA-256") - file.inputStream().use { input -> - val buffer = ByteArray(BUFFER_SIZE) - var bytesRead: Int - while (input.read(buffer).also { bytesRead = it } != -1) { - digest.update(buffer, 0, bytesRead) - } - } - return digest.digest().joinToString("") { "%02x".format(it) } - } - /** * Delete the cached universal APK. */ @@ -735,20 +923,20 @@ class UniversalApkManager(private val context: Context) { */ private fun saveMetadata( version: String, - checksum: String, size: Long, fileName: String, source: ApkSource, - variant: ShareableApkVariant + variant: ShareableApkVariant, + downloadSourceId: String? ) { val json = JSONObject().apply { put("version", version) - put("checksum", checksum) put("downloadDate", System.currentTimeMillis()) put("size", size) put("fileName", fileName) put("source", source.name) put("variant", variant.name) + downloadSourceId?.let { put("downloadSourceId", it) } } val pendingMetadata = File(cacheDir, "$METADATA_FILE_NAME.new") @@ -757,12 +945,13 @@ class UniversalApkManager(private val context: Context) { Log.d(TAG, "Saved metadata: $version") } - private fun saveResumeInfo(url: String, expectedSize: Long, versionName: String) { + private fun saveResumeInfo(info: ResumeInfo) { try { val json = JSONObject().apply { - put("url", url) - put("expectedSize", expectedSize) - put("versionName", versionName) + put("sourceId", info.sourceId) + put("endpointUrl", info.endpointUrl) + put("expectedSize", info.expectedSize) + info.validator?.let { put("validator", it) } } progressFile.writeText(json.toString()) } catch (e: Exception) { @@ -770,17 +959,33 @@ class UniversalApkManager(private val context: Context) { } } - private fun loadResumeInfo(): JSONObject? { + private fun loadResumeInfo(): ResumeInfo? { return try { - if (progressFile.exists()) { - JSONObject(progressFile.readText()) - } else null + if (!progressFile.exists()) return null + val json = JSONObject(progressFile.readText()) + val endpointUrl = json.optString("endpointUrl") + .ifBlank { json.optString("url") } + if (endpointUrl.isBlank()) return null + ResumeInfo( + sourceId = json.optString("sourceId") + .ifBlank { DefaultApkDownloadSources.GITHUB_ID }, + endpointUrl = endpointUrl, + expectedSize = json.optLong("expectedSize", 0L), + validator = json.optString("validator").takeIf { it.isNotBlank() } + ) } catch (e: Exception) { Log.e(TAG, "Error loading resume info", e) null } } + private data class ResumeInfo( + val sourceId: String, + val endpointUrl: String, + val expectedSize: Long, + val validator: String? + ) + /** * Commit [source] to [target] without removing a valid target first. * Both files live in the same cache directory, so this is a rename, not a @@ -809,29 +1014,16 @@ class UniversalApkManager(private val context: Context) { */ data class ApkInfo( val version: String, - val checksum: String, val downloadDate: Long, val size: Long, val file: File, val source: ApkSource, - val variant: ShareableApkVariant + val variant: ShareableApkVariant, + val downloadSourceId: String? ) enum class ApkSource { INSTALLED, - GITHUB - } - - /** - * Update check status. - */ - sealed class UpdateStatus { - data class NotDownloaded(val latestRelease: GitHubReleaseClient.Release) : UpdateStatus() - data class UpToDate(val currentVersion: String) : UpdateStatus() - data class UpdateAvailable( - val currentVersion: String, - val latestRelease: GitHubReleaseClient.Release - ) : UpdateStatus() - data class Error(val message: String) : UpdateStatus() + DOWNLOADED } } diff --git a/app/src/main/java/com/bitchat/android/util/WorkManagerApkDownloader.kt b/app/src/main/java/com/bitchat/android/util/WorkManagerApkDownloader.kt index feb31172..6d482c34 100644 --- a/app/src/main/java/com/bitchat/android/util/WorkManagerApkDownloader.kt +++ b/app/src/main/java/com/bitchat/android/util/WorkManagerApkDownloader.kt @@ -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 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a73b5bbb..a46d596f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -236,36 +236,70 @@ Prepare App for Sharing - App Ready for Offline Sharing + App Ready for Offline Sharing Download universal APK for offline sharing Not ready • Tap to download Ready to share - Sharing source: this installed APK - Sharing source: this installed APK • ARM64 devices only - Sharing source: verified GitHub universal APK - Get universal + + Ready to share • %1$s • %2$d MB\n%3$s + %1$s • %2$d%% downloaded + Sharing source: this installed APK + Sharing source: this installed APK • ARM64 devices only + Sharing source: verified downloaded universal APK + Version %1$s is available. You can keep sharing this APK or download the update. + New version available + + Download universal APK + Retry download Downloading… %1$d%% - Update available + + Waiting for network… + Retrying… + Selecting download source… + Waiting for Tor… + Downloading… + Verifying signature… + Stop download Prepare - Update Delete Version %1$s • %2$d MB Download Universal APK? - This will download the universal APK (~%1$d MB) from GitHub releases. You only need to do this once. - The release size is temporarily unavailable. BitChat will retry the GitHub request before downloading. + Download Newer Universal APK? + This will download a verified universal APK from a configured source. You only need to do this once. + Version %1$s is available from GitHub (%2$d MB). Your current APK remains shareable during the download. Download Downloading Universal APK Downloading %1$d MB… - Verifying checksum… Universal APK ready! Network error. Check your connection. - Checksum verification failed. Please try again. Not enough storage space. - Failed to fetch release info from GitHub. + + %1$s is temporarily rate limited. Try again later. + %1$s does not currently have a universal APK. + %1$s download failed: HTTP %2$s %3$s + Download failed. Please try again. + Not enough storage: %1$s MB needed, %2$s MB free. + No APK download sources are configured. + Tor is still connecting. Try again when Tor is ready. + %1$s has no usable APK URL. + %1$s could not be reached. + %1$s redirected to an insecure URL. + %1$s rejected the saved download position. The next attempt will restart the download. + %1$s download ended before all bytes arrived. It can be resumed. + %1$s returned an invalid resume response. The next attempt will restart the download. + The APK from %1$s is not signed by a trusted BitChat release key. + %1$s returned an architecture-specific APK, not the required universal APK. + The downloaded APK could not be read. + The downloaded file is not a BitChat APK. + The downloaded APK has no version information. + %1$s download failed. + All configured APK sources failed. Delete cached APK? This will free up ~%1$d MB of storage. - Update Available - A newer version (%1$s) is available. Current: %2$s Please prepare the app for sharing first. Download interrupted Download cancelled diff --git a/app/src/test/kotlin/com/bitchat/android/net/OkHttpProviderTest.kt b/app/src/test/kotlin/com/bitchat/android/net/OkHttpProviderTest.kt new file mode 100644 index 00000000..95117435 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/net/OkHttpProviderTest.kt @@ -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) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/ui/ApkDownloadViewModelTest.kt b/app/src/test/kotlin/com/bitchat/android/ui/ApkDownloadViewModelTest.kt new file mode 100644 index 00000000..4351b7c0 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/ui/ApkDownloadViewModelTest.kt @@ -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() + val metadata = object : LatestReleaseProvider { + override suspend fun latestRelease(): Result { + 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() + 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() + 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(), 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 = + Result.failure(IllegalStateException("synthetic offline response")) + } + + private suspend fun managerWithLocalApk(): UniversalApkManager { + val manager = mock() + 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 + } + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/ui/PrepareRowTapActionTest.kt b/app/src/test/kotlin/com/bitchat/android/ui/PrepareRowTapActionTest.kt new file mode 100644 index 00000000..7d8051de --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/ui/PrepareRowTapActionTest.kt @@ -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)) + } + +} diff --git a/app/src/test/kotlin/com/bitchat/android/util/ApkDownloadSourceTest.kt b/app/src/test/kotlin/com/bitchat/android/util/ApkDownloadSourceTest.kt new file mode 100644 index 00000000..e14a968d --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/util/ApkDownloadSourceTest.kt @@ -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 + ) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/util/DownloadPhaseTest.kt b/app/src/test/kotlin/com/bitchat/android/util/DownloadPhaseTest.kt new file mode 100644 index 00000000..7e5c7d6e --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/util/DownloadPhaseTest.kt @@ -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) + } + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/util/GitHubReleaseClientTest.kt b/app/src/test/kotlin/com/bitchat/android/util/GitHubReleaseClientTest.kt index e51990d9..6377752d 100644 --- a/app/src/test/kotlin/com/bitchat/android/util/GitHubReleaseClientTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/util/GitHubReleaseClientTest.kt @@ -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() } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index fa19edb4..405f6dc3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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 = [ diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index a62cbff6..6c6f2a76 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -3349,6 +3349,14 @@ + + + + + + + + diff --git a/wear/gradle.lockfile b/wear/gradle.lockfile index 2f0a7566..8b9ed0e2 100644 --- a/wear/gradle.lockfile +++ b/wear/gradle.lockfile @@ -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