From 657fee0de6c4e77e9c83b2cf77999bf1c148d438 Mon Sep 17 00:00:00 2001 From: Moe Hamade <69801237+moehamade@users.noreply.github.com> Date: Tue, 28 Jul 2026 20:36:43 +0300 Subject: [PATCH 01/22] fix: stop the GitHub release check exhausting its own rate limit Opening the About sheet runs a release check, and an exhausted quota fed itself: only successes were cached, and a 403 reporting zero remaining was classed retryable, so every sheet open spent three more requests rediscovering the same limit. Unauthenticated GitHub allows 60 requests an hour per IP, and over Tor that IP is an exit node shared with every other user on it, so the ceiling arrives far sooner than per-user maths suggests. Three changes: - Conditional requests. The client now stores the release ETag and replays it as If-None-Match. GitHub does not charge a 304 against the rate limit, so revalidating an expired cache is free where an unconditional refetch costs one of the 60. This is why neither polling nor long polling is the right answer here. - A rate-limit gate. X-RateLimit-Reset and Retry-After were read only to interpolate into an error string; they now set a deadline before which no request is sent at all. While blocked, a stale cached release is served in preference to an error the user cannot act on. Clamped to an hour so a bad header cannot lock the feature out, and a reset time in the past falls back to a fixed backoff rather than unblocking a skewed clock immediately. - Rate limits are no longer retried in-loop. The gate decides when it is worth asking again. A plain 403 is a permissions failure and is no longer retried either. The gate's decision logic is pure and unit tested. The wiring around it is not: that needs a MockWebServer, which is not currently a dependency. Known gap: the cache and ETag are in memory only, so a process restart still costs one request. Persisting them needs a Context threaded into what is currently a context-free object; left as a follow-up. Co-Authored-By: Claude Opus 5 (1M context) --- .../bitchat/android/util/GitHubRateLimit.kt | 54 ++++++++ .../android/util/GitHubReleaseClient.kt | 98 +++++++++++--- .../android/util/GitHubRateLimitTest.kt | 126 ++++++++++++++++++ 3 files changed, 261 insertions(+), 17 deletions(-) create mode 100644 app/src/main/java/com/bitchat/android/util/GitHubRateLimit.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/util/GitHubRateLimitTest.kt diff --git a/app/src/main/java/com/bitchat/android/util/GitHubRateLimit.kt b/app/src/main/java/com/bitchat/android/util/GitHubRateLimit.kt new file mode 100644 index 00000000..39ddebc9 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/util/GitHubRateLimit.kt @@ -0,0 +1,54 @@ +package com.bitchat.android.util + +/** + * Reads GitHub's rate-limit rejections so the app can stop asking. + * + * Unauthenticated requests are capped at 60 an hour *per IP*, and when the app routes through Tor + * that IP belongs to an exit node shared with every other user on it, so the ceiling arrives much + * sooner than the per-user maths suggests. Retrying a rejection is pure waste, and repeating it on + * every screen open is what turns a brief limit into a permanent one. + */ +internal object GitHubRateLimit { + + /** Used when GitHub rejects a request without saying when to come back. */ + const val DEFAULT_BACKOFF_MILLIS = 10 * 60 * 1000L + + /** Never sit out longer than this, however far ahead the reset header claims to be. */ + const val MAX_BACKOFF_MILLIS = 60 * 60 * 1000L + + /** + * A 403 alone is not enough: GitHub also uses it for ordinary permission failures. Only a 403 + * that reports zero remaining quota, or an explicit 429, is a rate limit. + */ + fun isRateLimited(code: Int, remaining: String?): Boolean = + code == 429 || (code == 403 && remaining?.trim() == "0") + + /** + * Epoch millis before which no further request should be sent, or null when the response was + * not a rate-limit rejection at all. + */ + fun blockedUntilMillis( + code: Int, + remaining: String?, + resetEpochSeconds: String?, + retryAfterSeconds: String?, + nowMillis: Long, + ): Long? { + if (!isRateLimited(code, remaining)) return null + + // Retry-After is a delta and is what GitHub sends for secondary limits, which can lift + // sooner than the primary window X-RateLimit-Reset describes. + val fromRetryAfter = retryAfterSeconds?.trim()?.toLongOrNull() + ?.takeIf { it > 0 } + ?.let { nowMillis + it * 1000 } + + // Dropped when it is not in the future: a skewed device clock must not turn a genuine + // rejection into "retry immediately". + val fromReset = resetEpochSeconds?.trim()?.toLongOrNull() + ?.let { it * 1000 } + ?.takeIf { it > nowMillis } + + val target = fromRetryAfter ?: fromReset ?: (nowMillis + DEFAULT_BACKOFF_MILLIS) + return target.coerceIn(nowMillis, nowMillis + MAX_BACKOFF_MILLIS) + } +} 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..92f8e88c 100644 --- a/app/src/main/java/com/bitchat/android/util/GitHubReleaseClient.kt +++ b/app/src/main/java/com/bitchat/android/util/GitHubReleaseClient.kt @@ -23,12 +23,30 @@ object GitHubReleaseClient { private const val CACHE_TTL_MILLIS = 10 * 60 * 1000L private const val MAX_FETCH_ATTEMPTS = 3 private const val ROUTE_READY_TIMEOUT_MILLIS = 60_000L + private const val HTTP_NOT_MODIFIED = 304 private val fetchMutex = Mutex() @Volatile private var cachedRelease: CachedRelease? = null + /** + * ETag of the cached release, replayed as `If-None-Match`. GitHub does not charge a 304 + * against the rate limit, so revalidating an expired cache this way costs nothing where an + * unconditional refetch costs one of only 60 hourly requests. + */ + @Volatile + private var cachedEtag: String? = null + + /** + * Epoch millis before which GitHub has already told us it will reject anything we send. + * + * Without this, an exhausted quota fed itself: nothing cached the failure, so every screen + * that asked for release info spent three more requests discovering the same limit. + */ + @Volatile + private var blockedUntilMillis = 0L + private val client get() = OkHttpProvider.httpClient().newBuilder() // GitHub requests may travel through Tor, where a 15-second total @@ -46,10 +64,31 @@ object GitHubReleaseClient { 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) } + val now = System.currentTimeMillis() + val cached = cachedRelease + + if (!forceRefresh && + cached != null && + now - cached.fetchedAtMillis < CACHE_TTL_MILLIS + ) { + return@withLock Result.success(cached.release) + } + + // Honoured even on an explicit refresh: sending a request GitHub has already said + // it will reject helps nobody and pushes the reset further out. A stale release is + // a better answer than an error the user cannot act on. + if (now < blockedUntilMillis) { + val waitMinutes = (blockedUntilMillis - now) / 60_000 + 1 + Log.w(TAG, "Rate limited; not contacting GitHub for another ${waitMinutes}min") + cached?.let { return@withLock Result.success(it.release) } + return@withLock Result.failure( + ReleaseFetchException( + message = "GitHub API rate limit reached. Try again in " + + "$waitMinutes minute${if (waitMinutes == 1L) "" else "s"}.", + httpCode = 429, + retryable = false + ) + ) } if (!awaitSelectedNetworkRoute()) { @@ -95,6 +134,8 @@ object GitHubReleaseClient { } private fun fetchLatestReleaseOnce(): Result { + val cached = cachedRelease + val etag = cachedEtag return try { Log.d(TAG, "Fetching latest release from GitHub API") val request = Request.Builder() @@ -102,29 +143,48 @@ object GitHubReleaseClient { .addHeader("User-Agent", USER_AGENT) .addHeader("Accept", "application/vnd.github+json") .addHeader("X-GitHub-Api-Version", "2022-11-28") + .apply { + // Revalidate rather than refetch. GitHub does not charge a 304 against the + // hourly quota, so an unchanged release costs nothing to confirm. + if (cached != null && etag != null) addHeader("If-None-Match", etag) + } .build() client.newCall(request).execute().use { response -> + if (response.code == HTTP_NOT_MODIFIED && cached != null) { + Log.d(TAG, "Release unchanged; cache revalidated at no quota cost") + cachedRelease = cached.copy(fetchedAtMillis = System.currentTimeMillis()) + return Result.success(cached.release) + } + if (!response.isSuccessful) { - val 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}" + val blockedUntil = GitHubRateLimit.blockedUntilMillis( + code = response.code, + remaining = response.header("X-RateLimit-Remaining"), + resetEpochSeconds = response.header("X-RateLimit-Reset"), + retryAfterSeconds = response.header("Retry-After"), + nowMillis = System.currentTimeMillis(), + ) + if (blockedUntil != null) blockedUntilMillis = blockedUntil + + val message = if (blockedUntil != null) { + val waitMinutes = + (blockedUntil - System.currentTimeMillis()) / 60_000 + 1 + "GitHub API rate limit exceeded. Try again in " + + "$waitMinutes minute${if (waitMinutes == 1L) "" else "s"}." + } else { + "GitHub release request failed: HTTP ${response.code} ${response.message}" } Log.e(TAG, message) return Result.failure( ReleaseFetchException( message = message, httpCode = response.code, - retryable = response.code == 403 || - response.code == 408 || - response.code == 429 || - response.code >= 500 + // A rate limit is never worth an in-loop retry: the gate in + // fetchLatestRelease decides when it is worth asking again. A plain + // 403 is a permissions failure and will not fix itself either. + retryable = blockedUntil == null && + (response.code == 408 || response.code >= 500) ) ) } @@ -146,6 +206,10 @@ object GitHubReleaseClient { retryable = false ) ) + // Kept alongside the release so the pair can never drift: a stale ETag would + // revalidate to a 304 that confirms a release we no longer hold. + cachedEtag = response.header("ETag") + blockedUntilMillis = 0L Result.success(release) } } catch (e: IOException) { diff --git a/app/src/test/kotlin/com/bitchat/android/util/GitHubRateLimitTest.kt b/app/src/test/kotlin/com/bitchat/android/util/GitHubRateLimitTest.kt new file mode 100644 index 00000000..3bd7e982 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/util/GitHubRateLimitTest.kt @@ -0,0 +1,126 @@ +package com.bitchat.android.util + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Unauthenticated GitHub allows 60 requests an hour per IP, and over Tor that IP is an exit node + * shared with everyone else using it. Reading the rejection correctly is what keeps the app from + * hammering a quota it has already exhausted. + */ +class GitHubRateLimitTest { + + private val now = 1_700_000_000_000L + + @Test + fun `a 403 that still has quota is a permissions error, not a rate limit`() { + assertFalse(GitHubRateLimit.isRateLimited(code = 403, remaining = "42")) + assertNull( + GitHubRateLimit.blockedUntilMillis( + code = 403, + remaining = "42", + resetEpochSeconds = null, + retryAfterSeconds = null, + nowMillis = now, + ) + ) + } + + @Test + fun `a 403 with no quota left blocks until the advertised reset`() { + val resetSeconds = now / 1000 + 900 + + assertTrue(GitHubRateLimit.isRateLimited(code = 403, remaining = "0")) + assertEquals( + resetSeconds * 1000, + GitHubRateLimit.blockedUntilMillis( + code = 403, + remaining = "0", + resetEpochSeconds = resetSeconds.toString(), + retryAfterSeconds = null, + nowMillis = now, + ) + ) + } + + @Test + fun `a 429 is a rate limit even without a remaining header`() { + assertTrue(GitHubRateLimit.isRateLimited(code = 429, remaining = null)) + } + + @Test + fun `Retry-After takes precedence over the reset header`() { + // Retry-After is a delta and is what GitHub sends for secondary limits, which can expire + // sooner than the primary window the reset header describes. + assertEquals( + now + 30_000, + GitHubRateLimit.blockedUntilMillis( + code = 429, + remaining = "0", + resetEpochSeconds = (now / 1000 + 3_000).toString(), + retryAfterSeconds = "30", + nowMillis = now, + ) + ) + } + + @Test + fun `a rejection with no timing headers falls back to a fixed backoff`() { + assertEquals( + now + GitHubRateLimit.DEFAULT_BACKOFF_MILLIS, + GitHubRateLimit.blockedUntilMillis( + code = 429, + remaining = null, + resetEpochSeconds = null, + retryAfterSeconds = null, + nowMillis = now, + ) + ) + } + + @Test + fun `a reset time already in the past falls back rather than unblocking immediately`() { + // A skewed device clock must not turn a real rejection into "retry right now". + assertEquals( + now + GitHubRateLimit.DEFAULT_BACKOFF_MILLIS, + GitHubRateLimit.blockedUntilMillis( + code = 429, + remaining = null, + resetEpochSeconds = (now / 1000 - 500).toString(), + retryAfterSeconds = null, + nowMillis = now, + ) + ) + } + + @Test + fun `an absurd reset time is clamped so the app is never locked out for long`() { + assertEquals( + now + GitHubRateLimit.MAX_BACKOFF_MILLIS, + GitHubRateLimit.blockedUntilMillis( + code = 429, + remaining = null, + resetEpochSeconds = (now / 1000 + 86_400).toString(), + retryAfterSeconds = null, + nowMillis = now, + ) + ) + } + + @Test + fun `unparseable headers fall back instead of throwing`() { + assertEquals( + now + GitHubRateLimit.DEFAULT_BACKOFF_MILLIS, + GitHubRateLimit.blockedUntilMillis( + code = 429, + remaining = null, + resetEpochSeconds = "not-a-number", + retryAfterSeconds = "Wed, 21 Oct 2015 07:28:00 GMT", + nowMillis = now, + ) + ) + } +} From 389fbd28fe7fd3b932d103f8039049a183c0a29b Mon Sep 17 00:00:00 2001 From: Moe Hamade <69801237+moehamade@users.noreply.github.com> Date: Tue, 28 Jul 2026 20:42:51 +0300 Subject: [PATCH 02/22] feat: show what an APK download is actually doing Preparing an APK is a five-stage operation rendered as a single 0-100 bar. Two of those stages run before the first byte -- a GitHub release lookup, then awaitSelectedNetworkRoute, which blocks on Tor bootstrap -- and neither reported anything, so a download sat at 0% with no explanation for as long as Tor took. The tail had the mirror problem: a SHA-256 pass and a signature check over ~100MB, both sitting at 100%. Carry a DownloadPhase on DownloadState.Downloading, reported from UniversalApkManager through the worker's existing setProgressAsync and mapWorkInfoToState. The About sheet names the phase instead of showing a misleading percentage, and the spinner is indeterminate except while bytes are actually moving. The notification does the same, and a phase change forces a redraw so the every-5% threshold cannot suppress it. The phase crosses a WorkManager Data boundary as a string, so fromKey falls back to Transferring for an absent or unrecognised value -- work enqueued by an older build must not crash a newer one. A stop button is wired to the cancelDownload() that already existed on the downloader interface but had no UI affordance. Co-Authored-By: Claude Opus 5 (1M context) # Conflicts: # app/src/main/java/com/bitchat/android/ui/AboutSheet.kt --- .../java/com/bitchat/android/ui/AboutSheet.kt | 45 ++++++++++++++++--- .../android/ui/ApkDownloadViewModel.kt | 9 ++-- .../bitchat/android/util/ApkDownloadWorker.kt | 39 +++++++++++++--- .../com/bitchat/android/util/ApkDownloader.kt | 44 +++++++++++++++++- .../android/util/UniversalApkManager.kt | 13 +++++- .../android/util/WorkManagerApkDownloader.kt | 10 ++++- app/src/main/res/values/strings.xml | 7 +++ .../bitchat/android/util/DownloadPhaseTest.kt | 45 +++++++++++++++++++ 8 files changed, 193 insertions(+), 19 deletions(-) create mode 100644 app/src/test/kotlin/com/bitchat/android/util/DownloadPhaseTest.kt 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..355da287 100644 --- a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt @@ -37,6 +37,7 @@ 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 @@ -68,6 +69,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 @@ -702,7 +704,15 @@ fun AboutSheet( " • ${status.version} • ${status.sizeMB} MB\n$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.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 -> "Tap to resume • ${status.progressPercent}% downloaded" is ApkPreparationStatus.Error -> status.message }, @@ -720,10 +730,35 @@ fun AboutSheet( // Action buttons when (apkStatus) { is ApkPreparationStatus.Downloading -> { - CircularProgressIndicator( - modifier = Modifier.size(20.dp), - strokeWidth = 2.dp - ) + // Determinate only while bytes move. Elsewhere a + // spinner is honest about having no measure. + if (apkStatus.phase.hasMeasurableProgress && + downloadProgress > 0 + ) { + CircularProgressIndicator( + progress = { downloadProgress / 100f }, + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp + ) + } else { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp + ) + } + androidx.compose.material3.IconButton( + onClick = { + apkViewModel.onEvent(ApkUiEvent.CancelDownload) + }, + modifier = Modifier.size(32.dp) + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.prepare_apk_stop), + tint = colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp) + ) + } } is ApkPreparationStatus.Ready -> { if (apkStatus.variant == ShareableApkVariant.ARM64) { 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..d553dcbf 100644 --- a/app/src/main/java/com/bitchat/android/ui/ApkDownloadViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ApkDownloadViewModel.kt @@ -36,7 +36,10 @@ sealed class ApkPreparationStatus { val newVersion: String, val newSizeMB: Int ) : ApkPreparationStatus() - object Downloading : ApkPreparationStatus() + /** [phase] is what the operation is actually doing; only a transfer has a real percentage. */ + data class Downloading( + val phase: ApkDownloader.DownloadPhase = ApkDownloader.DownloadPhase.ResolvingRelease + ) : ApkPreparationStatus() data class Resumable(val progressPercent: Int, val message: String) : ApkPreparationStatus() data class Error(val message: String) : ApkPreparationStatus() } @@ -200,7 +203,7 @@ class ApkDownloadViewModel(application: Application) : AndroidViewModel(applicat val partial = apkManager.getPartialDownloadProgress() _state.update { it.copy( - apkStatus = ApkPreparationStatus.Downloading, + apkStatus = ApkPreparationStatus.Downloading(), downloadProgress = partial ?: 0 ) } @@ -237,7 +240,7 @@ class ApkDownloadViewModel(application: Application) : AndroidViewModel(applicat is ApkDownloader.DownloadState.Downloading -> { _state.update { it.copy( - apkStatus = ApkPreparationStatus.Downloading, + apkStatus = ApkPreparationStatus.Downloading(downloadState.phase), downloadProgress = downloadState.progressPercent ) } 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..b5739a16 100644 --- a/app/src/main/java/com/bitchat/android/util/ApkDownloadWorker.kt +++ b/app/src/main/java/com/bitchat/android/util/ApkDownloadWorker.kt @@ -33,6 +33,7 @@ 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" @@ -50,6 +51,8 @@ class ApkDownloadWorker( applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager private var lastNotifiedProgress = -NOTIFY_STEP_PERCENT + private var lastProgress = 0 + private var currentPhase = ApkDownloader.DownloadPhase.ResolvingRelease override suspend fun doWork(): Result { Log.d(TAG, "Starting APK download work") @@ -64,10 +67,20 @@ class ApkDownloadWorker( Log.w(TAG, "Could not promote download to foreground work", e) } - val result = apkManager.downloadUniversalApk { progress -> - setProgressAsync(Data.Builder().putInt(KEY_PROGRESS, progress).build()) - updateNotification(progress) - } + val result = apkManager.downloadUniversalApk( + progressCallback = { progress -> + lastProgress = progress + publishProgress(progress, currentPhase) + updateNotification(progress) + }, + phaseCallback = { phase -> + currentPhase = phase + publishProgress(lastProgress, phase) + // Forced: a phase change is exactly the moment the percentage stops meaning + // anything, so the every-5% threshold must not suppress the redraw. + updateNotification(lastProgress, force = true) + } + ) return if (result.isSuccess) { val info = apkManager.getCachedApkInfo() @@ -118,16 +131,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 +161,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)) 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..18db97d5 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,50 @@ 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() } + + /** + * What a download is actually doing. + * + * Preparing an APK is a five-stage operation that was being rendered as a single 0-100 bar, + * so it sat at 0% through a release lookup and a Tor bootstrap, then at 100% through a + * SHA-256 pass and a signature check over ~100MB. Only [Transferring] has meaningful + * percentage progress; the rest should read as indeterminate. + */ + enum class DownloadPhase { + ResolvingRelease, + AwaitingNetworkRoute, + Transferring, + VerifyingChecksum, + 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 = + entries.firstOrNull { it.name == key } ?: Transferring + } + } +} + +/** Shared by the notification and the About sheet so both name a phase identically. */ +internal fun downloadPhaseLabel(phase: ApkDownloader.DownloadPhase): Int = when (phase) { + ApkDownloader.DownloadPhase.ResolvingRelease -> + com.bitchat.android.R.string.prepare_apk_phase_resolving + ApkDownloader.DownloadPhase.AwaitingNetworkRoute -> + com.bitchat.android.R.string.prepare_apk_phase_awaiting_route + ApkDownloader.DownloadPhase.Transferring -> + com.bitchat.android.R.string.prepare_apk_phase_transferring + ApkDownloader.DownloadPhase.VerifyingChecksum -> + com.bitchat.android.R.string.prepare_apk_phase_verifying_checksum + ApkDownloader.DownloadPhase.VerifyingSignature -> + com.bitchat.android.R.string.prepare_apk_phase_verifying_signature } \ No newline at end of file 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..bb958d0d 100644 --- a/app/src/main/java/com/bitchat/android/util/UniversalApkManager.kt +++ b/app/src/main/java/com/bitchat/android/util/UniversalApkManager.kt @@ -206,7 +206,13 @@ class UniversalApkManager(private val context: Context) { * @return Result with File on success, or error message */ suspend fun downloadUniversalApk( - progressCallback: ((Int) -> Unit)? = null + progressCallback: ((Int) -> Unit)? = null, + /** + * Reports which stage the operation reached. Both stages before the transfer can block + * for a long time — a release lookup, then a Tor bootstrap — and reporting neither is why + * a download appeared stuck at 0%. + */ + phaseCallback: ((ApkDownloader.DownloadPhase) -> Unit)? = null ): Result = withContext(Dispatchers.IO) { try { Log.d(TAG, "Starting universal APK download") @@ -215,15 +221,18 @@ class UniversalApkManager(private val context: Context) { // 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. + phaseCallback?.invoke(ApkDownloader.DownloadPhase.ResolvingRelease) val release = GitHubReleaseClient.fetchLatestRelease().getOrElse { error -> return@withContext Result.failure(error) } + phaseCallback?.invoke(ApkDownloader.DownloadPhase.AwaitingNetworkRoute) if (!GitHubReleaseClient.awaitSelectedNetworkRoute()) { return@withContext Result.failure( IOException("Tor is still connecting. Try the download again when Tor is ready.") ) } + phaseCallback?.invoke(ApkDownloader.DownloadPhase.Transferring) val url = release.universalApkUrl val expectedSize = release.universalApkSize @@ -286,6 +295,7 @@ class UniversalApkManager(private val context: Context) { // Verify checksum if available if (release.universalApkSha256 != null) { Log.d(TAG, "Verifying checksum...") + phaseCallback?.invoke(ApkDownloader.DownloadPhase.VerifyingChecksum) val isValid = verifyChecksum(tempFile, release.universalApkSha256) if (!isValid) { tempFile.delete() @@ -301,6 +311,7 @@ class UniversalApkManager(private val context: Context) { // Verify the downloaded APK against trusted signing certificates. Log.d(TAG, "Verifying APK signature...") + phaseCallback?.invoke(ApkDownloader.DownloadPhase.VerifyingSignature) if (!verifyApkSignature(tempFile)) { tempFile.delete() progressFile.delete() 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..f0eded0c 100644 --- a/app/src/main/java/com/bitchat/android/util/WorkManagerApkDownloader.kt +++ b/app/src/main/java/com/bitchat/android/util/WorkManagerApkDownloader.kt @@ -61,11 +61,17 @@ class WorkManagerApkDownloader(context: Context) : ApkDownloader { WorkInfo.State.BLOCKED -> { // Waiting for constraints (network). Show existing partial progress if any. val partial = apkManager.getPartialDownloadProgress() - ApkDownloader.DownloadState.Downloading(partial ?: 0) + ApkDownloader.DownloadState.Downloading( + partial ?: 0, + ApkDownloader.DownloadPhase.ResolvingRelease + ) } 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) ?: "" diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a73b5bbb..52e0038c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -245,6 +245,13 @@ Sharing source: verified GitHub universal APK Get universal Downloading… %1$d%% + + Checking latest release… + Waiting for Tor… + Downloading… + Verifying checksum… + Verifying signature… + Stop download Update available Prepare Update 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..61673054 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/util/DownloadPhaseTest.kt @@ -0,0 +1,45 @@ +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 `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) + } + } +} From f33ce0bb983ceaff03da2c1e413583dfc36679c1 Mon Sep 17 00:00:00 2001 From: Moe Hamade <69801237+moehamade@users.noreply.github.com> Date: Wed, 29 Jul 2026 14:17:56 +0300 Subject: [PATCH 03/22] fix: gate secondary rate limits and name the Tor wait correctly Addresses review on #812. Secondary rate limits were classed as permissions failures. GitHub serves them as 403 with Retry-After while X-RateLimit-Remaining is still nonzero, because the primary hourly quota is not what was hit -- so isRateLimited() returned false, blockedUntilMillis was never set, no stale release was served, and every About sheet open kept contacting GitHub through exactly the cooldown it had been asked to observe. The predicate now also admits a 403 carrying a usable Retry-After; one that cannot be parsed is still a permissions failure. The phase reported during the Tor wait was the wrong one. fetchLatestRelease() waits on the selected route itself, so the UI read "Checking latest release..." for the whole bootstrap and only switched to AwaitingNetworkRoute afterwards, when the second route check returns immediately -- putting the wrong label on the one wait the phase exists to explain. Reordering the two calls would have made cache hits wait on Tor, since the cache returns before the route check, so the fetch now reports from the inside via onAwaitingNetworkRoute and the caller keeps its own check for the cache-hit path. Co-Authored-By: Claude Opus 5 (1M context) --- .../bitchat/android/util/GitHubRateLimit.kt | 23 +++++++---- .../android/util/GitHubReleaseClient.kt | 11 +++++- .../android/util/UniversalApkManager.kt | 10 ++++- .../android/util/GitHubRateLimitTest.kt | 38 +++++++++++++++++++ 4 files changed, 72 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/util/GitHubRateLimit.kt b/app/src/main/java/com/bitchat/android/util/GitHubRateLimit.kt index 39ddebc9..c03f25c5 100644 --- a/app/src/main/java/com/bitchat/android/util/GitHubRateLimit.kt +++ b/app/src/main/java/com/bitchat/android/util/GitHubRateLimit.kt @@ -17,11 +17,20 @@ internal object GitHubRateLimit { const val MAX_BACKOFF_MILLIS = 60 * 60 * 1000L /** - * A 403 alone is not enough: GitHub also uses it for ordinary permission failures. Only a 403 - * that reports zero remaining quota, or an explicit 429, is a rate limit. + * A 403 alone is not enough: GitHub also uses it for ordinary permission failures. + * + * Three things count as a rate limit. An explicit 429. A 403 reporting zero remaining quota, + * which is the primary hourly limit. And a 403 carrying Retry-After while quota remains, which + * is how secondary limits arrive — abuse detection rather than the hourly budget, so treating + * it as a permissions failure leaves the gate unset and keeps the app calling during exactly + * the cooldown GitHub asked for. */ - fun isRateLimited(code: Int, remaining: String?): Boolean = - code == 429 || (code == 403 && remaining?.trim() == "0") + fun isRateLimited(code: Int, remaining: String?, retryAfterSeconds: String? = null): Boolean = + code == 429 || + (code == 403 && (remaining?.trim() == "0" || retryAfterDelayMillis(retryAfterSeconds) != null)) + + private fun retryAfterDelayMillis(retryAfterSeconds: String?): Long? = + retryAfterSeconds?.trim()?.toLongOrNull()?.takeIf { it > 0 }?.let { it * 1000 } /** * Epoch millis before which no further request should be sent, or null when the response was @@ -34,13 +43,11 @@ internal object GitHubRateLimit { retryAfterSeconds: String?, nowMillis: Long, ): Long? { - if (!isRateLimited(code, remaining)) return null + if (!isRateLimited(code, remaining, retryAfterSeconds)) return null // Retry-After is a delta and is what GitHub sends for secondary limits, which can lift // sooner than the primary window X-RateLimit-Reset describes. - val fromRetryAfter = retryAfterSeconds?.trim()?.toLongOrNull() - ?.takeIf { it > 0 } - ?.let { nowMillis + it * 1000 } + val fromRetryAfter = retryAfterDelayMillis(retryAfterSeconds)?.let { nowMillis + it } // Dropped when it is not in the future: a skewed device clock must not turn a genuine // rejection into "retry immediately". 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 92f8e88c..d33c1531 100644 --- a/app/src/main/java/com/bitchat/android/util/GitHubReleaseClient.kt +++ b/app/src/main/java/com/bitchat/android/util/GitHubReleaseClient.kt @@ -61,7 +61,15 @@ object GitHubReleaseClient { * 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 = + /** + * @param onAwaitingNetworkRoute invoked if this call is about to block on the selected route + * (a Tor bootstrap can take the better part of a minute). A cache hit returns before that + * point and never invokes it, so callers can report the wait only when there is one. + */ + suspend fun fetchLatestRelease( + forceRefresh: Boolean = false, + onAwaitingNetworkRoute: (() -> Unit)? = null, + ): Result = withContext(Dispatchers.IO) { fetchMutex.withLock { val now = System.currentTimeMillis() @@ -91,6 +99,7 @@ object GitHubReleaseClient { ) } + onAwaitingNetworkRoute?.invoke() if (!awaitSelectedNetworkRoute()) { return@withLock Result.failure( ReleaseFetchException( 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 bb958d0d..47789582 100644 --- a/app/src/main/java/com/bitchat/android/util/UniversalApkManager.kt +++ b/app/src/main/java/com/bitchat/android/util/UniversalApkManager.kt @@ -222,10 +222,18 @@ class UniversalApkManager(private val context: Context) { // status check. If this worker is running after process death, the // client performs a retried network fetch instead. phaseCallback?.invoke(ApkDownloader.DownloadPhase.ResolvingRelease) - val release = GitHubReleaseClient.fetchLatestRelease().getOrElse { error -> + // The fetch waits on the route itself when it has to go to the network, so it + // reports that from the inside. Labelling the whole call "resolving release" + // would put the app's own name on the long Tor wait this phase exists to explain. + val release = GitHubReleaseClient.fetchLatestRelease( + onAwaitingNetworkRoute = { + phaseCallback?.invoke(ApkDownloader.DownloadPhase.AwaitingNetworkRoute) + } + ).getOrElse { error -> return@withContext Result.failure(error) } + // A cache hit skips the fetch's own wait, so for that path the route wait is here. phaseCallback?.invoke(ApkDownloader.DownloadPhase.AwaitingNetworkRoute) if (!GitHubReleaseClient.awaitSelectedNetworkRoute()) { return@withContext Result.failure( diff --git a/app/src/test/kotlin/com/bitchat/android/util/GitHubRateLimitTest.kt b/app/src/test/kotlin/com/bitchat/android/util/GitHubRateLimitTest.kt index 3bd7e982..9f9a7b3e 100644 --- a/app/src/test/kotlin/com/bitchat/android/util/GitHubRateLimitTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/util/GitHubRateLimitTest.kt @@ -51,6 +51,44 @@ class GitHubRateLimitTest { assertTrue(GitHubRateLimit.isRateLimited(code = 429, remaining = null)) } + @Test + fun `a secondary limit is a 403 with Retry-After while quota remains`() { + // GitHub serves secondary limits as 403 + Retry-After without exhausting the + // primary quota, so remaining is still nonzero. + assertTrue( + GitHubRateLimit.isRateLimited( + code = 403, + remaining = "42", + retryAfterSeconds = "60", + ) + ) + } + + @Test + fun `a secondary limit blocks for the Retry-After it advertises`() { + assertEquals( + now + 60_000, + GitHubRateLimit.blockedUntilMillis( + code = 403, + remaining = "42", + resetEpochSeconds = null, + retryAfterSeconds = "60", + nowMillis = now, + ) + ) + } + + @Test + fun `a 403 with an unusable Retry-After stays a permissions error`() { + assertFalse( + GitHubRateLimit.isRateLimited( + code = 403, + remaining = "42", + retryAfterSeconds = "not-a-number", + ) + ) + } + @Test fun `Retry-After takes precedence over the reset header`() { // Retry-After is a delta and is what GitHub sends for secondary limits, which can expire From b79ae8e7f1dbe9b91366b007bf056774bada9370 Mon Sep 17 00:00:00 2001 From: Moe Hamade <69801237+moehamade@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:13:45 +0300 Subject: [PATCH 04/22] fix: stop reporting a Tor wait during the fetch, and serve the cache on the rate limit that triggers the gate Addresses review on #812. The awaiting-route phase was never cleared. Reporting it before the wait fixed the label on the wait itself, but nothing restored ResolvingRelease afterwards, so the UI and notification claimed "Waiting for Tor" for the whole metadata request and its retries -- and in direct mode, where the wait returns immediately, for a wait that never happened. An onResolvingRelease callback now fires once the route is ready. A rate-limit rejection recorded the gate and returned the failure, while the stale-cache fallback sat at the top of the function and was only reached on a later call. The first About check therefore reported an error the user could not act on, and an immediate retry succeeded from metadata that was already present. The response that sets the gate now serves the cached release straight away. Co-Authored-By: Claude Opus 5 (1M context) --- .../android/util/GitHubReleaseClient.kt | 18 ++++++++++++++++++ .../android/util/UniversalApkManager.kt | 3 +++ 2 files changed, 21 insertions(+) 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 d33c1531..25922b27 100644 --- a/app/src/main/java/com/bitchat/android/util/GitHubReleaseClient.kt +++ b/app/src/main/java/com/bitchat/android/util/GitHubReleaseClient.kt @@ -69,6 +69,7 @@ object GitHubReleaseClient { suspend fun fetchLatestRelease( forceRefresh: Boolean = false, onAwaitingNetworkRoute: (() -> Unit)? = null, + onResolvingRelease: (() -> Unit)? = null, ): Result = withContext(Dispatchers.IO) { fetchMutex.withLock { @@ -108,6 +109,11 @@ object GitHubReleaseClient { ) ) } + // The wait is over, so stop saying we are waiting. In direct mode it + // returned immediately and never really started, and the fetch below + // retries -- either way the caller must not keep reporting a Tor wait + // for the whole metadata request. + onResolvingRelease?.invoke() var lastFailure: Throwable = ReleaseFetchException( "Failed to fetch the latest release from GitHub" @@ -121,6 +127,18 @@ object GitHubReleaseClient { } lastFailure = result.exceptionOrNull() ?: lastFailure + // The response that just set the gate is the one the user is waiting + // on. Reporting an error here and only serving the cache on the next + // call makes the first check fail and an immediate retry succeed from + // metadata we already had. + if (System.currentTimeMillis() < blockedUntilMillis) { + cached?.let { + Log.w(TAG, "Rate limited; serving the cached release instead of failing") + return@withLock Result.success(it.release) + } + return@withLock Result.failure(lastFailure) + } + if (!isRetryable(lastFailure) || attempt == MAX_FETCH_ATTEMPTS - 1) { return@withLock Result.failure(lastFailure) } 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 47789582..a457c8b2 100644 --- a/app/src/main/java/com/bitchat/android/util/UniversalApkManager.kt +++ b/app/src/main/java/com/bitchat/android/util/UniversalApkManager.kt @@ -228,6 +228,9 @@ class UniversalApkManager(private val context: Context) { val release = GitHubReleaseClient.fetchLatestRelease( onAwaitingNetworkRoute = { phaseCallback?.invoke(ApkDownloader.DownloadPhase.AwaitingNetworkRoute) + }, + onResolvingRelease = { + phaseCallback?.invoke(ApkDownloader.DownloadPhase.ResolvingRelease) } ).getOrElse { error -> return@withContext Result.failure(error) From 8e5bb2ea9a9ee125da1c076dfd9c0fd048f6367f Mon Sep 17 00:00:00 2001 From: Moe Hamade <69801237+moehamade@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:20:50 +0300 Subject: [PATCH 05/22] fix: clear the downloading state when a download is cancelled Addresses review on #812. Pressing the new stop button before a partial file exists -- while resolving the release, or waiting for Tor -- left the row disabled and spinning for the lifetime of the ViewModel. Two guards conspired: checkStatus() returns early while the state is Downloading, and the downloader observer deliberately ignores the Idle that WorkManager reports for a cancelled job. Both exist to stop a running job being second-guessed from cache contents, and neither anticipated a job that is no longer running. checkStatus() takes a force flag, used only by cancellation, and clears the stale progress along with the status. Co-Authored-By: Claude Opus 5 (1M context) --- .../android/ui/ApkDownloadViewModel.kt | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) 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 d553dcbf..d027d82f 100644 --- a/app/src/main/java/com/bitchat/android/ui/ApkDownloadViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ApkDownloadViewModel.kt @@ -196,7 +196,12 @@ class ApkDownloadViewModel(application: Application) : AndroidViewModel(applicat private fun onCancelDownload() { downloader.cancelDownload() - checkStatus() + + // Nothing else will move the UI off the spinner. checkStatus() refuses to + // overwrite a Downloading state, and the Idle that WorkManager reports for a + // cancelled job is ignored for the same reason -- both guards protect a job + // that is still running, which this one is not. + checkStatus(force = true) } private fun startDownload() { @@ -210,21 +215,26 @@ class ApkDownloadViewModel(application: Application) : AndroidViewModel(applicat downloader.startDownload() } - private fun checkStatus() { + /** + * @param force resolve even while the state says Downloading. Only cancellation + * should pass true: the guard below exists so a running job is never second-guessed + * from cache contents, but a cancelled one has no other route out of that state. + */ + private fun checkStatus(force: Boolean = false) { 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) { + if (!force && _state.value.apkStatus is ApkPreparationStatus.Downloading) { return@launch } val resolvedStatus = resolveApkStatus() _state.update { current -> - if (current.apkStatus is ApkPreparationStatus.Downloading) { + if (!force && current.apkStatus is ApkPreparationStatus.Downloading) { current } else { - current.copy(apkStatus = resolvedStatus) + current.copy(apkStatus = resolvedStatus, downloadProgress = 0) } } } From 77f3c0cc057e719120c1b576afceb6956447f7de Mon Sep 17 00:00:00 2001 From: Moe Hamade <69801237+moehamade@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:31:20 +0300 Subject: [PATCH 06/22] fix: free the UI on cancel, and scope the rate-limit gate to its route Addresses review on #812. Cancelling left the spinner up for as long as the status check took. Forcing checkStatus() past its guard was not enough: resolveApkStatus() calls checkForUpdate(), which reaches the network and can sit on the 60-second route timeout while Tor bootstraps. Nothing clears the state in the meantime -- the cancelled job maps to Idle, which the observer ignores -- so the stop button looked broken for the whole wait. The state now leaves Downloading immediately and the check resolves it afterwards. The rate-limit gate was process-wide. GitHub counts unauthenticated requests per IP, so a cooldown earned through a shared Tor exit was being applied to a direct connection with an entirely different quota, and vice versa -- potentially suppressing a usable route for an hour. The gate now records which route earned it and is dropped when the current route differs. Co-Authored-By: Claude Opus 5 (1M context) --- .../android/ui/ApkDownloadViewModel.kt | 15 ++++++--- .../android/util/GitHubReleaseClient.kt | 31 ++++++++++++++++++- 2 files changed, 41 insertions(+), 5 deletions(-) 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 d027d82f..ec31ab93 100644 --- a/app/src/main/java/com/bitchat/android/ui/ApkDownloadViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ApkDownloadViewModel.kt @@ -197,10 +197,17 @@ class ApkDownloadViewModel(application: Application) : AndroidViewModel(applicat private fun onCancelDownload() { downloader.cancelDownload() - // Nothing else will move the UI off the spinner. checkStatus() refuses to - // overwrite a Downloading state, and the Idle that WorkManager reports for a - // cancelled job is ignored for the same reason -- both guards protect a job - // that is still running, which this one is not. + // Leave Downloading now, not when the check returns. resolveApkStatus() reaches + // the network and can sit on the route timeout for a full minute, and nothing + // else would clear the spinner in the meantime -- the cancelled job maps to Idle, + // which the observer ignores. Without this the stop button looks broken for the + // whole wait. + _state.update { + it.copy(apkStatus = ApkPreparationStatus.Loading, downloadProgress = 0) + } + + // force, because the guards in checkStatus() protect a job that is still + // running, which this one is not. checkStatus(force = true) } 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 25922b27..e0d1ed20 100644 --- a/app/src/main/java/com/bitchat/android/util/GitHubReleaseClient.kt +++ b/app/src/main/java/com/bitchat/android/util/GitHubReleaseClient.kt @@ -47,6 +47,27 @@ object GitHubReleaseClient { @Volatile private var blockedUntilMillis = 0L + /** + * Whether the route was proxied when [blockedUntilMillis] was recorded, or null when + * no gate is set. GitHub counts unauthenticated requests per IP, so a Tor exit and a + * direct connection have separate quotas -- a cooldown earned on one must not be + * served to the other. + */ + private var blockedRouteUsedProxy: Boolean? = null + + /** Drops a gate earned on a route the app is no longer using. */ + private fun clearGateIfRouteChanged() { + val recordedRoute = blockedRouteUsedProxy ?: return + val currentRoute = runCatching { ArtiTorManager.getInstance().isProxyEnabled() } + .getOrNull() ?: return + + if (recordedRoute != currentRoute) { + Log.i(TAG, "Route changed since the rate limit was recorded; clearing the gate") + blockedUntilMillis = 0L + blockedRouteUsedProxy = null + } + } + private val client get() = OkHttpProvider.httpClient().newBuilder() // GitHub requests may travel through Tor, where a 15-second total @@ -86,6 +107,8 @@ object GitHubReleaseClient { // Honoured even on an explicit refresh: sending a request GitHub has already said // it will reject helps nobody and pushes the reset further out. A stale release is // a better answer than an error the user cannot act on. + clearGateIfRouteChanged() + if (now < blockedUntilMillis) { val waitMinutes = (blockedUntilMillis - now) / 60_000 + 1 Log.w(TAG, "Rate limited; not contacting GitHub for another ${waitMinutes}min") @@ -192,7 +215,12 @@ object GitHubReleaseClient { retryAfterSeconds = response.header("Retry-After"), nowMillis = System.currentTimeMillis(), ) - if (blockedUntil != null) blockedUntilMillis = blockedUntil + if (blockedUntil != null) { + blockedUntilMillis = blockedUntil + // Record which route earned it: the quota belongs to that IP. + blockedRouteUsedProxy = + runCatching { ArtiTorManager.getInstance().isProxyEnabled() }.getOrNull() + } val message = if (blockedUntil != null) { val waitMinutes = @@ -237,6 +265,7 @@ object GitHubReleaseClient { // revalidate to a 304 that confirms a release we no longer hold. cachedEtag = response.header("ETag") blockedUntilMillis = 0L + blockedRouteUsedProxy = null Result.success(release) } } catch (e: IOException) { From 4200871ee9ef7d07ca6e7250523014abc52d0dcc Mon Sep 17 00:00:00 2001 From: Moe Hamade <69801237+moehamade@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:41:05 +0300 Subject: [PATCH 07/22] fix: key the rate-limit gate to the selected route, and drop the force flag Addresses review on #812. isProxyEnabled() reports readiness, not route selection: it is false while Tor is bootstrapping or restarting, even though requests will still go through Tor. Using it as the route identity cleared a Tor-earned gate mid-bootstrap and applied a direct-earned one to the first Tor request -- the opposite of what scoping the gate was for. The identity is now the selected mode from statusFlow. The force flag turned out to be both unnecessary and harmful. Leaving Downloading synchronously before the check already clears the entry guard, so force only reached the completion guard -- which must stay armed. A cancellation check can take a minute on the route timeout, and WorkManager can surface Resumable meanwhile, so the user may start a new download before it returns; force let the stale result overwrite work that was running and strip the progress and stop controls. Removing it restores that protection and needs no generation counter. Co-Authored-By: Claude Opus 5 (1M context) --- .../android/ui/ApkDownloadViewModel.kt | 21 +++++----- .../android/util/GitHubReleaseClient.kt | 41 ++++++++++++------- 2 files changed, 37 insertions(+), 25 deletions(-) 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 ec31ab93..16dbdc8f 100644 --- a/app/src/main/java/com/bitchat/android/ui/ApkDownloadViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ApkDownloadViewModel.kt @@ -206,9 +206,10 @@ class ApkDownloadViewModel(application: Application) : AndroidViewModel(applicat it.copy(apkStatus = ApkPreparationStatus.Loading, downloadProgress = 0) } - // force, because the guards in checkStatus() protect a job that is still - // running, which this one is not. - checkStatus(force = true) + // No force needed, and it would be harmful: leaving Downloading above already + // clears the entry guard, while the completion guard must stay armed so a + // download the user restarts during this check is not overwritten by its result. + checkStatus() } private fun startDownload() { @@ -222,23 +223,21 @@ class ApkDownloadViewModel(application: Application) : AndroidViewModel(applicat downloader.startDownload() } - /** - * @param force resolve even while the state says Downloading. Only cancellation - * should pass true: the guard below exists so a running job is never second-guessed - * from cache contents, but a cancelled one has no other route out of that state. - */ - private fun checkStatus(force: Boolean = false) { + 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 (!force && _state.value.apkStatus is ApkPreparationStatus.Downloading) { + if (_state.value.apkStatus is ApkPreparationStatus.Downloading) { return@launch } val resolvedStatus = resolveApkStatus() _state.update { current -> - if (!force && current.apkStatus is ApkPreparationStatus.Downloading) { + // Re-checked rather than trusted from entry: this resolve reaches the + // network and can take a minute, in which time the user may have started + // a download. Its result must not overwrite work that is now running. + if (current.apkStatus is ApkPreparationStatus.Downloading) { current } else { current.copy(apkStatus = resolvedStatus, downloadProgress = 0) 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 e0d1ed20..90110ccb 100644 --- a/app/src/main/java/com/bitchat/android/util/GitHubReleaseClient.kt +++ b/app/src/main/java/com/bitchat/android/util/GitHubReleaseClient.kt @@ -3,6 +3,7 @@ package com.bitchat.android.util import android.util.Log import com.bitchat.android.net.ArtiTorManager import com.bitchat.android.net.OkHttpProvider +import com.bitchat.android.net.TorMode import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.sync.Mutex @@ -48,23 +49,36 @@ object GitHubReleaseClient { private var blockedUntilMillis = 0L /** - * Whether the route was proxied when [blockedUntilMillis] was recorded, or null when - * no gate is set. GitHub counts unauthenticated requests per IP, so a Tor exit and a - * direct connection have separate quotas -- a cooldown earned on one must not be - * served to the other. + * Whether Tor was the selected route when [blockedUntilMillis] was recorded, or null + * when no gate is set. GitHub counts unauthenticated requests per IP, so a Tor exit + * and a direct connection have separate quotas -- a cooldown earned on one must not + * be served to the other. */ - private var blockedRouteUsedProxy: Boolean? = null + @Volatile + private var blockedRouteUsedTor: Boolean? = null + + /** + * The route requests will take, which is what the quota belongs to. + * + * Deliberately the selected mode rather than `isProxyEnabled()`: that reports + * readiness, and is false while Tor is still bootstrapping or restarting even though + * requests will still go through Tor once it is up. Using it as the route identity + * would clear a Tor-earned gate mid-bootstrap and apply a direct-earned one to the + * first Tor request. + */ + private fun selectedRouteUsesTor(): Boolean? = + runCatching { ArtiTorManager.getInstance().statusFlow.value.mode != TorMode.OFF } + .getOrNull() /** Drops a gate earned on a route the app is no longer using. */ private fun clearGateIfRouteChanged() { - val recordedRoute = blockedRouteUsedProxy ?: return - val currentRoute = runCatching { ArtiTorManager.getInstance().isProxyEnabled() } - .getOrNull() ?: return + val recordedRoute = blockedRouteUsedTor ?: return + val currentRoute = selectedRouteUsesTor() ?: return if (recordedRoute != currentRoute) { Log.i(TAG, "Route changed since the rate limit was recorded; clearing the gate") blockedUntilMillis = 0L - blockedRouteUsedProxy = null + blockedRouteUsedTor = null } } @@ -104,11 +118,11 @@ object GitHubReleaseClient { return@withLock Result.success(cached.release) } + clearGateIfRouteChanged() + // Honoured even on an explicit refresh: sending a request GitHub has already said // it will reject helps nobody and pushes the reset further out. A stale release is // a better answer than an error the user cannot act on. - clearGateIfRouteChanged() - if (now < blockedUntilMillis) { val waitMinutes = (blockedUntilMillis - now) / 60_000 + 1 Log.w(TAG, "Rate limited; not contacting GitHub for another ${waitMinutes}min") @@ -218,8 +232,7 @@ object GitHubReleaseClient { if (blockedUntil != null) { blockedUntilMillis = blockedUntil // Record which route earned it: the quota belongs to that IP. - blockedRouteUsedProxy = - runCatching { ArtiTorManager.getInstance().isProxyEnabled() }.getOrNull() + blockedRouteUsedTor = selectedRouteUsesTor() } val message = if (blockedUntil != null) { @@ -265,7 +278,7 @@ object GitHubReleaseClient { // revalidate to a 304 that confirms a release we no longer hold. cachedEtag = response.header("ETag") blockedUntilMillis = 0L - blockedRouteUsedProxy = null + blockedRouteUsedTor = null Result.success(release) } } catch (e: IOException) { From 2d2954709dc3b3ab7e0b1f5f369d28a9ec72c5f6 Mon Sep 17 00:00:00 2001 From: Moe Hamade <69801237+moehamade@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:50:05 +0300 Subject: [PATCH 08/22] fix: keep a cooldown per route instead of discarding it on a switch Addresses review on #812. Scoping the gate to the route was right, but it was implemented as one deadline that moved with the route, so a switch deleted the cooldown rather than setting it aside. Rate-limited on a Tor exit, switch to direct, switch back before the reset, and the app contacts that same limited exit again with nothing left to stop it. Tor and direct now carry their own deadlines. Switching route selects the other one rather than forgetting this one, a success clears only the route that succeeded, and when the route cannot be determined the stricter of the two applies -- failing to identify a route must not release a cooldown that is still running. Co-Authored-By: Claude Opus 5 (1M context) --- .../android/util/GitHubReleaseClient.kt | 81 +++++++++++-------- 1 file changed, 49 insertions(+), 32 deletions(-) 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 90110ccb..63ee976a 100644 --- a/app/src/main/java/com/bitchat/android/util/GitHubReleaseClient.kt +++ b/app/src/main/java/com/bitchat/android/util/GitHubReleaseClient.kt @@ -40,45 +40,65 @@ object GitHubReleaseClient { private var cachedEtag: String? = null /** - * Epoch millis before which GitHub has already told us it will reject anything we send. + * Epoch millis before which GitHub has already told us it will reject anything we send, + * held per route. * - * Without this, an exhausted quota fed itself: nothing cached the failure, so every screen - * that asked for release info spent three more requests discovering the same limit. + * GitHub counts unauthenticated requests per IP, so a Tor exit and a direct connection + * have separate quotas. They are kept side by side rather than as one deadline that + * moves with the route: replacing it would mean switching away and back forgets a + * cooldown that is still running, and the app would hit the limited exit again. + * + * Without any of this, an exhausted quota fed itself: nothing cached the failure, so + * every screen that asked for release info spent three more requests rediscovering the + * same limit. */ @Volatile - private var blockedUntilMillis = 0L + private var torBlockedUntilMillis = 0L - /** - * Whether Tor was the selected route when [blockedUntilMillis] was recorded, or null - * when no gate is set. GitHub counts unauthenticated requests per IP, so a Tor exit - * and a direct connection have separate quotas -- a cooldown earned on one must not - * be served to the other. - */ @Volatile - private var blockedRouteUsedTor: Boolean? = null + private var directBlockedUntilMillis = 0L /** * The route requests will take, which is what the quota belongs to. * * Deliberately the selected mode rather than `isProxyEnabled()`: that reports * readiness, and is false while Tor is still bootstrapping or restarting even though - * requests will still go through Tor once it is up. Using it as the route identity - * would clear a Tor-earned gate mid-bootstrap and apply a direct-earned one to the - * first Tor request. + * requests will still go through Tor once it is up. */ private fun selectedRouteUsesTor(): Boolean? = runCatching { ArtiTorManager.getInstance().statusFlow.value.mode != TorMode.OFF } .getOrNull() - /** Drops a gate earned on a route the app is no longer using. */ - private fun clearGateIfRouteChanged() { - val recordedRoute = blockedRouteUsedTor ?: return - val currentRoute = selectedRouteUsesTor() ?: return + /** + * The deadline for the route about to be used. When the route cannot be determined the + * stricter of the two applies: failing to identify it must not release a real cooldown. + */ + private fun blockedUntilForCurrentRoute(): Long = when (selectedRouteUsesTor()) { + true -> torBlockedUntilMillis + false -> directBlockedUntilMillis + null -> maxOf(torBlockedUntilMillis, directBlockedUntilMillis) + } - if (recordedRoute != currentRoute) { - Log.i(TAG, "Route changed since the rate limit was recorded; clearing the gate") - blockedUntilMillis = 0L - blockedRouteUsedTor = null + private fun recordBlockedUntil(untilMillis: Long) { + when (selectedRouteUsesTor()) { + true -> torBlockedUntilMillis = untilMillis + false -> directBlockedUntilMillis = untilMillis + null -> { + torBlockedUntilMillis = untilMillis + directBlockedUntilMillis = untilMillis + } + } + } + + /** A success proves this route is clear. The other route's cooldown is left alone. */ + private fun clearBlockedForCurrentRoute() { + when (selectedRouteUsesTor()) { + true -> torBlockedUntilMillis = 0L + false -> directBlockedUntilMillis = 0L + null -> { + torBlockedUntilMillis = 0L + directBlockedUntilMillis = 0L + } } } @@ -118,13 +138,12 @@ object GitHubReleaseClient { return@withLock Result.success(cached.release) } - clearGateIfRouteChanged() - // Honoured even on an explicit refresh: sending a request GitHub has already said // it will reject helps nobody and pushes the reset further out. A stale release is // a better answer than an error the user cannot act on. - if (now < blockedUntilMillis) { - val waitMinutes = (blockedUntilMillis - now) / 60_000 + 1 + val blockedUntil = blockedUntilForCurrentRoute() + if (now < blockedUntil) { + val waitMinutes = (blockedUntil - now) / 60_000 + 1 Log.w(TAG, "Rate limited; not contacting GitHub for another ${waitMinutes}min") cached?.let { return@withLock Result.success(it.release) } return@withLock Result.failure( @@ -168,7 +187,7 @@ object GitHubReleaseClient { // on. Reporting an error here and only serving the cache on the next // call makes the first check fail and an immediate retry succeed from // metadata we already had. - if (System.currentTimeMillis() < blockedUntilMillis) { + if (System.currentTimeMillis() < blockedUntilForCurrentRoute()) { cached?.let { Log.w(TAG, "Rate limited; serving the cached release instead of failing") return@withLock Result.success(it.release) @@ -230,9 +249,8 @@ object GitHubReleaseClient { nowMillis = System.currentTimeMillis(), ) if (blockedUntil != null) { - blockedUntilMillis = blockedUntil - // Record which route earned it: the quota belongs to that IP. - blockedRouteUsedTor = selectedRouteUsesTor() + // Recorded against the route that earned it: the quota is that IP's. + recordBlockedUntil(blockedUntil) } val message = if (blockedUntil != null) { @@ -277,8 +295,7 @@ object GitHubReleaseClient { // Kept alongside the release so the pair can never drift: a stale ETag would // revalidate to a 304 that confirms a release we no longer hold. cachedEtag = response.header("ETag") - blockedUntilMillis = 0L - blockedRouteUsedTor = null + clearBlockedForCurrentRoute() Result.success(release) } } catch (e: IOException) { From 309df1ab615b681f7db87b7892bf69051a2ddcaa Mon Sep 17 00:00:00 2001 From: Moe Hamade <69801237+moehamade@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:00:23 +0300 Subject: [PATCH 09/22] fix: evaluate the rate-limit gate against the route each request takes Addresses review on #812. Both findings are the same mistake: the route was sampled at a moment that need not match the request it governs. The gate was checked once, before a route wait that can last a minute. Start with Tor selected, disable it during the wait, and the request goes direct having consulted only the Tor deadline -- contacting a direct IP whose own cooldown is still running. The gate is now re-evaluated after the wait, when the route the request will take is finally known. The cooldown was likewise recorded against the mode selected when the response arrived, not the one the call was made on. Changing the setting mid-flight filed it in the wrong bucket, freeing the limited route and suppressing the newly selected one. The route is now sampled immediately before each attempt and reused for that attempt's response and its retry decision. Co-Authored-By: Claude Opus 5 (1M context) --- .../android/util/GitHubReleaseClient.kt | 82 ++++++++++++------- 1 file changed, 54 insertions(+), 28 deletions(-) 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 63ee976a..8206d182 100644 --- a/app/src/main/java/com/bitchat/android/util/GitHubReleaseClient.kt +++ b/app/src/main/java/com/bitchat/android/util/GitHubReleaseClient.kt @@ -73,14 +73,14 @@ object GitHubReleaseClient { * The deadline for the route about to be used. When the route cannot be determined the * stricter of the two applies: failing to identify it must not release a real cooldown. */ - private fun blockedUntilForCurrentRoute(): Long = when (selectedRouteUsesTor()) { + private fun blockedUntilFor(routeUsesTor: Boolean?): Long = when (routeUsesTor) { true -> torBlockedUntilMillis false -> directBlockedUntilMillis null -> maxOf(torBlockedUntilMillis, directBlockedUntilMillis) } - private fun recordBlockedUntil(untilMillis: Long) { - when (selectedRouteUsesTor()) { + private fun recordBlockedUntil(untilMillis: Long, routeUsesTor: Boolean?) { + when (routeUsesTor) { true -> torBlockedUntilMillis = untilMillis false -> directBlockedUntilMillis = untilMillis null -> { @@ -91,8 +91,8 @@ object GitHubReleaseClient { } /** A success proves this route is clear. The other route's cooldown is left alone. */ - private fun clearBlockedForCurrentRoute() { - when (selectedRouteUsesTor()) { + private fun clearBlockedFor(routeUsesTor: Boolean?) { + when (routeUsesTor) { true -> torBlockedUntilMillis = 0L false -> directBlockedUntilMillis = 0L null -> { @@ -102,6 +102,34 @@ object GitHubReleaseClient { } } + /** + * The gate's answer for [routeUsesTor], or null when nothing blocks the request. + * + * Sending a request GitHub has already said it will reject helps nobody and pushes + * the reset further out, so a stale release is a better answer than an error the + * user cannot act on. + */ + private fun blockedResultOrNull( + nowMillis: Long, + routeUsesTor: Boolean?, + cached: CachedRelease?, + ): Result? { + val blockedUntil = blockedUntilFor(routeUsesTor) + if (nowMillis >= blockedUntil) return null + + val waitMinutes = (blockedUntil - nowMillis) / 60_000 + 1 + Log.w(TAG, "Rate limited; not contacting GitHub for another ${waitMinutes}min") + cached?.let { return Result.success(it.release) } + return Result.failure( + ReleaseFetchException( + message = "GitHub API rate limit reached. Try again in " + + "$waitMinutes minute${if (waitMinutes == 1L) "" else "s"}.", + httpCode = 429, + retryable = false + ) + ) + } + private val client get() = OkHttpProvider.httpClient().newBuilder() // GitHub requests may travel through Tor, where a 15-second total @@ -138,23 +166,9 @@ object GitHubReleaseClient { return@withLock Result.success(cached.release) } - // Honoured even on an explicit refresh: sending a request GitHub has already said - // it will reject helps nobody and pushes the reset further out. A stale release is - // a better answer than an error the user cannot act on. - val blockedUntil = blockedUntilForCurrentRoute() - if (now < blockedUntil) { - val waitMinutes = (blockedUntil - now) / 60_000 + 1 - Log.w(TAG, "Rate limited; not contacting GitHub for another ${waitMinutes}min") - cached?.let { return@withLock Result.success(it.release) } - return@withLock Result.failure( - ReleaseFetchException( - message = "GitHub API rate limit reached. Try again in " + - "$waitMinutes minute${if (waitMinutes == 1L) "" else "s"}.", - httpCode = 429, - retryable = false - ) - ) - } + // Honoured even on an explicit refresh. + blockedResultOrNull(now, selectedRouteUsesTor(), cached) + ?.let { return@withLock it } onAwaitingNetworkRoute?.invoke() if (!awaitSelectedNetworkRoute()) { @@ -171,12 +185,23 @@ object GitHubReleaseClient { // for the whole metadata request. onResolvingRelease?.invoke() + // That wait can last a minute, in which time the user may have switched + // routes. The cooldown that matters is the one for the route the request + // will actually take, which is only known now. + blockedResultOrNull(System.currentTimeMillis(), selectedRouteUsesTor(), cached) + ?.let { return@withLock it } + var lastFailure: Throwable = ReleaseFetchException( "Failed to fetch the latest release from GitHub" ) repeat(MAX_FETCH_ATTEMPTS) { attempt -> - val result = fetchLatestReleaseOnce() + // Sampled immediately before the call and reused for its response, so + // a route change mid-flight cannot file the cooldown against the route + // the request did not use. + val routeUsesTor = selectedRouteUsesTor() + + val result = fetchLatestReleaseOnce(routeUsesTor) result.onSuccess { release -> cachedRelease = CachedRelease(release, System.currentTimeMillis()) return@withLock Result.success(release) @@ -187,7 +212,7 @@ object GitHubReleaseClient { // on. Reporting an error here and only serving the cache on the next // call makes the first check fail and an immediate retry succeed from // metadata we already had. - if (System.currentTimeMillis() < blockedUntilForCurrentRoute()) { + if (System.currentTimeMillis() < blockedUntilFor(routeUsesTor)) { cached?.let { Log.w(TAG, "Rate limited; serving the cached release instead of failing") return@withLock Result.success(it.release) @@ -216,7 +241,7 @@ object GitHubReleaseClient { .awaitSelectedRoute(ROUTE_READY_TIMEOUT_MILLIS) } - private fun fetchLatestReleaseOnce(): Result { + private fun fetchLatestReleaseOnce(routeUsesTor: Boolean?): Result { val cached = cachedRelease val etag = cachedEtag return try { @@ -249,8 +274,9 @@ object GitHubReleaseClient { nowMillis = System.currentTimeMillis(), ) if (blockedUntil != null) { - // Recorded against the route that earned it: the quota is that IP's. - recordBlockedUntil(blockedUntil) + // Recorded against the route the request took, not the one + // selected now: the setting can change while a call is in flight. + recordBlockedUntil(blockedUntil, routeUsesTor) } val message = if (blockedUntil != null) { @@ -295,7 +321,7 @@ object GitHubReleaseClient { // Kept alongside the release so the pair can never drift: a stale ETag would // revalidate to a 304 that confirms a release we no longer hold. cachedEtag = response.header("ETag") - clearBlockedForCurrentRoute() + clearBlockedFor(routeUsesTor) Result.success(release) } } catch (e: IOException) { From 821ec7c5f792269f8a9328833e558d65427f285c Mon Sep 17 00:00:00 2001 From: Moe Hamade <69801237+moehamade@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:07:59 +0300 Subject: [PATCH 10/22] fix: check the cooldown on every attempt, not once before the loop Addresses review on #812. Each retry resampled the route but never rechecked the gate against it, so a route change during a request or its backoff walked straight past a cooldown. A Tor attempt failing with a 500, then the user switching to a direct connection that is already rate limited, and the next attempt contacts it regardless. The check moves inside the loop, immediately after the route is sampled, which makes it cover the first attempt too -- the separate post-wait check it replaces was only ever that first iteration. The check before the route wait stays: knowing the selected route is blocked is worth avoiding a sixty-second Tor bootstrap for. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/bitchat/android/util/GitHubReleaseClient.kt | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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 8206d182..a5acefe2 100644 --- a/app/src/main/java/com/bitchat/android/util/GitHubReleaseClient.kt +++ b/app/src/main/java/com/bitchat/android/util/GitHubReleaseClient.kt @@ -185,12 +185,6 @@ object GitHubReleaseClient { // for the whole metadata request. onResolvingRelease?.invoke() - // That wait can last a minute, in which time the user may have switched - // routes. The cooldown that matters is the one for the route the request - // will actually take, which is only known now. - blockedResultOrNull(System.currentTimeMillis(), selectedRouteUsesTor(), cached) - ?.let { return@withLock it } - var lastFailure: Throwable = ReleaseFetchException( "Failed to fetch the latest release from GitHub" ) @@ -201,6 +195,12 @@ object GitHubReleaseClient { // the request did not use. val routeUsesTor = selectedRouteUsesTor() + // Per attempt rather than once before the loop. The route can change + // during the wait above, during a request, or during a backoff, and + // the one we have just switched to may carry a cooldown of its own. + blockedResultOrNull(System.currentTimeMillis(), routeUsesTor, cached) + ?.let { return@withLock it } + val result = fetchLatestReleaseOnce(routeUsesTor) result.onSuccess { release -> cachedRelease = CachedRelease(release, System.currentTimeMillis()) From 7dab62733a5881e915c7585c9b90bc526de4d86c Mon Sep 17 00:00:00 2001 From: Moe Hamade <69801237+moehamade@users.noreply.github.com> Date: Fri, 31 Jul 2026 02:46:04 +0300 Subject: [PATCH 11/22] build: take material3 1.5.0-alpha for the wavy progress indicators The expressive wavy progress indicators are Compose-only in the 1.5.0 line, which has no stable release yet, so this overrides the BOM's 1.4.0 for that one artifact. The override sits outside the BOM, so material3's own requirements win and pull ui, runtime, foundation and animation from 1.11.4 to 1.12.0-beta01. That is the real cost of this change and the reason the lock diff is 46 components rather than one. Lock state and verification metadata regenerated for debug and both release variants per docs/reproducible-builds.md. Co-Authored-By: Claude Opus 5 (1M context) --- app/gradle.lockfile | 103 +++++----- gradle/libs.versions.toml | 5 +- gradle/verification-metadata.xml | 314 +++++++++++++++++++++++++++++++ 3 files changed, 369 insertions(+), 53 deletions(-) diff --git a/app/gradle.lockfile b/app/gradle.lockfile index bd31d677..6cb53d65 100644 --- a/app/gradle.lockfile +++ b/app/gradle.lockfile @@ -24,55 +24,57 @@ androidx.camera:camera-lifecycle:1.6.1=debugAndroidTestCompileClasspath,debugAnd androidx.collection:collection-jvm:1.5.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.collection:collection-ktx:1.5.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.collection:collection:1.5.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.animation:animation-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.animation:animation-core-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.animation:animation-core:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.animation:animation:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.foundation:foundation-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.foundation:foundation-layout-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.foundation:foundation-layout:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.foundation:foundation:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.material3:material3-android:1.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.material3:material3:1.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.animation:animation-android:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.animation:animation-core-android:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.animation:animation-core:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.animation:animation:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.foundation:foundation-android:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.foundation:foundation-layout-android:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.foundation:foundation-layout:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.foundation:foundation:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.material3:material3-android:1.5.0-alpha25=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.material3:material3-ripple-android:1.5.0-alpha25=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.material3:material3-ripple:1.5.0-alpha25=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.material3:material3:1.5.0-alpha25=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.compose.material:material-icons-core-android:1.7.8=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath androidx.compose.material:material-icons-core-desktop:1.7.8=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugUnitTestLintChecksClasspath,releaseLintChecksClasspath androidx.compose.material:material-icons-core:1.7.8=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.compose.material:material-icons-extended-android:1.7.8=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath androidx.compose.material:material-icons-extended-desktop:1.7.8=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugUnitTestLintChecksClasspath,releaseLintChecksClasspath androidx.compose.material:material-icons-extended:1.7.8=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.material:material-ripple-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.material:material-ripple:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.runtime:runtime-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.runtime:runtime-annotation-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.runtime:runtime-annotation:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.runtime:runtime-retain-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.runtime:runtime-retain:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.runtime:runtime-saveable-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.runtime:runtime-saveable:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.runtime:runtime:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.ui:ui-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.ui:ui-geometry-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.ui:ui-geometry:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.ui:ui-graphics-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.ui:ui-graphics:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.ui:ui-test-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath -androidx.compose.ui:ui-test-junit4-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath -androidx.compose.ui:ui-test-junit4:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath -androidx.compose.ui:ui-test-manifest:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath -androidx.compose.ui:ui-test:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath -androidx.compose.ui:ui-text-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.ui:ui-text:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.ui:ui-tooling-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath -androidx.compose.ui:ui-tooling-data-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath -androidx.compose.ui:ui-tooling-data:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath -androidx.compose.ui:ui-tooling-preview-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.ui:ui-tooling-preview:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.ui:ui-tooling:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath -androidx.compose.ui:ui-unit-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.ui:ui-unit:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.ui:ui-util-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.ui:ui-util:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.ui:ui:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.material:material-ripple-android:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.material:material-ripple:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.runtime:runtime-android:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.runtime:runtime-annotation-android:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.runtime:runtime-annotation:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.runtime:runtime-retain-android:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.runtime:runtime-retain:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.runtime:runtime-saveable-android:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.runtime:runtime-saveable:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.runtime:runtime:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-android:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-geometry-android:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-geometry:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-graphics-android:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-graphics:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-test-android:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath +androidx.compose.ui:ui-test-junit4-android:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath +androidx.compose.ui:ui-test-junit4:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath +androidx.compose.ui:ui-test-manifest:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath +androidx.compose.ui:ui-test:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath +androidx.compose.ui:ui-text-android:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-text:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-tooling-android:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +androidx.compose.ui:ui-tooling-data-android:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +androidx.compose.ui:ui-tooling-data:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +androidx.compose.ui:ui-tooling-preview-android:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-tooling-preview:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-tooling:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +androidx.compose.ui:ui-unit-android:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-unit:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-util-android:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-util:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.compose:compose-bom:2026.06.01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.concurrent:concurrent-futures-ktx:1.1.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.concurrent:concurrent-futures-ktx:1.2.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath @@ -85,22 +87,22 @@ androidx.core:core:1.19.0=debugAndroidTestCompileClasspath,debugAndroidTestLintC androidx.cursoradapter:cursoradapter:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.customview:customview-poolingcontainer:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.customview:customview:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.documentfile:documentfile:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.drawerlayout:drawerlayout:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.dynamicanimation:dynamicanimation:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.emoji2:emoji2-views-helper:1.4.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.emoji2:emoji2:1.4.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.exifinterface:exifinterface:1.4.2=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.fragment:fragment:1.5.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.graphics:graphics-path:1.0.1=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.graphics:graphics-shapes-android:1.0.1=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath +androidx.graphics:graphics-shapes-desktop:1.0.1=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugUnitTestLintChecksClasspath,releaseLintChecksClasspath +androidx.graphics:graphics-shapes:1.0.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.interpolator:interpolator:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.legacy:legacy-support-core-utils:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.lifecycle:lifecycle-common-java8:2.11.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.lifecycle:lifecycle-common-jvm:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.lifecycle:lifecycle-common:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.lifecycle:lifecycle-livedata-core-ktx:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-livedata-core-ktx:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.lifecycle:lifecycle-livedata-core:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.lifecycle:lifecycle-livedata:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-livedata:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.lifecycle:lifecycle-process:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.lifecycle:lifecycle-runtime-android:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.lifecycle:lifecycle-runtime-compose-android:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath @@ -116,8 +118,7 @@ androidx.lifecycle:lifecycle-viewmodel-ktx:2.11.0=debugAndroidTestCompileClasspa androidx.lifecycle:lifecycle-viewmodel-savedstate-android:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.lifecycle:lifecycle-viewmodel-savedstate:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.lifecycle:lifecycle-viewmodel:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.loader:loader:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.localbroadcastmanager:localbroadcastmanager:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.loader:loader:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.navigation:navigation-common-android:2.9.8=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.navigation:navigation-common:2.9.8=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.navigation:navigation-compose-android:2.9.8=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath @@ -128,7 +129,6 @@ androidx.navigationevent:navigationevent-android:1.0.0=debugAndroidTestCompileCl androidx.navigationevent:navigationevent-compose-android:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.navigationevent:navigationevent-compose:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.navigationevent:navigationevent:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.print:print:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.profileinstaller:profileinstaller:1.4.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.resourceinspection:resourceinspection-annotation:1.0.1=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.room:room-common:2.6.1=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath @@ -164,7 +164,6 @@ androidx.tracing:tracing-ktx:1.3.0=debugAndroidTestLintChecksClasspath,debugLint androidx.tracing:tracing:1.0.0=debugAndroidTestCompileClasspath androidx.tracing:tracing:1.1.0=debugUnitTestCompileClasspath androidx.tracing:tracing:1.3.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.transition:transition:1.6.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.vectordrawable:vectordrawable-animated:1.1.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.vectordrawable:vectordrawable:1.1.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.versionedparcelable:versionedparcelable:1.1.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index fa19edb4..965476bf 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -16,6 +16,9 @@ appcompat = "1.7.1" # Compose compose-bom = "2026.06.01" compose-icons-extended = "1.7.8" +# Overrides the BOM's 1.4.0. The expressive wavy progress indicators are Compose-only in the +# 1.5.0 line, which has no stable release yet; drop this override once 1.5.0 ships. +compose-material3 = "1.5.0-alpha25" # Navigation navigation-compose = "2.9.8" @@ -89,7 +92,7 @@ androidx-compose-ui = { module = "androidx.compose.ui:ui" } androidx-compose-ui-graphics = { module = "androidx.compose.ui:ui-graphics" } androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } -androidx-compose-material3 = { module = "androidx.compose.material3:material3" } +androidx-compose-material3 = { module = "androidx.compose.material3:material3", version.ref = "compose-material3" } androidx-compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended", version.ref = "compose-icons-extended" } # Lifecycle diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index a62cbff6..a13ea01e 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -255,6 +255,11 @@ + + + + + @@ -276,6 +281,14 @@ + + + + + + + + @@ -294,6 +307,11 @@ + + + + + @@ -320,6 +338,14 @@ + + + + + + + + @@ -364,6 +390,11 @@ + + + + + @@ -385,6 +416,14 @@ + + + + + + + + @@ -403,6 +442,11 @@ + + + + + @@ -424,6 +468,14 @@ + + + + + + + + @@ -479,6 +531,11 @@ + + + + + @@ -492,6 +549,14 @@ + + + + + + + + @@ -510,6 +575,11 @@ + + + + + @@ -526,6 +596,14 @@ + + + + + + + + @@ -534,6 +612,19 @@ + + + + + + + + + + + + + @@ -544,6 +635,11 @@ + + + + + @@ -570,6 +666,14 @@ + + + + + + + + @@ -585,6 +689,11 @@ + + + + + @@ -606,6 +715,14 @@ + + + + + + + + @@ -621,6 +738,11 @@ + + + + + @@ -637,6 +759,14 @@ + + + + + + + + @@ -647,6 +777,11 @@ + + + + + @@ -668,6 +803,14 @@ + + + + + + + + @@ -693,6 +836,16 @@ + + + + + + + + + + @@ -714,6 +867,14 @@ + + + + + + + + @@ -732,6 +893,11 @@ + + + + + @@ -753,6 +919,14 @@ + + + + + + + + @@ -771,6 +945,11 @@ + + + + + @@ -792,6 +971,14 @@ + + + + + + + + @@ -805,6 +992,11 @@ + + + + + @@ -813,11 +1005,24 @@ + + + + + + + + + + + + + @@ -826,6 +1031,14 @@ + + + + + + + + @@ -834,6 +1047,14 @@ + + + + + + + + @@ -844,6 +1065,11 @@ + + + + + @@ -865,6 +1091,14 @@ + + + + + + + + @@ -883,6 +1117,11 @@ + + + + + @@ -904,6 +1143,14 @@ + + + + + + + + @@ -922,6 +1169,11 @@ + + + + + @@ -943,6 +1195,14 @@ + + + + + + + + @@ -961,6 +1221,11 @@ + + + + + @@ -982,6 +1247,14 @@ + + + + + + + + @@ -1000,6 +1273,11 @@ + + + + + @@ -1021,6 +1299,14 @@ + + + + + + + + @@ -1039,6 +1325,11 @@ + + + + + @@ -1060,6 +1351,14 @@ + + + + + + + + @@ -1640,6 +1939,11 @@ + + + + + @@ -1755,6 +2059,11 @@ + + + + + @@ -4760,6 +5069,11 @@ + + + + + From bfb0c82ef908add1d9c9c29e30fe0648b1f2d8f1 Mon Sep 17 00:00:00 2001 From: Moe Hamade <69801237+moehamade@users.noreply.github.com> Date: Fri, 31 Jul 2026 02:46:28 +0300 Subject: [PATCH 12/22] feat: rebuild the prepare-for-sharing row and localize its failures Progress moves out of the trailing slot and under the subtitle, so every status now shows exactly one 48dp control there instead of a spinner and a button competing for the same space. The trailing slot had three different widths across states, which made the text column re-wrap on every status change; it is now one width throughout. "Get universal" and "Retry" become icon buttons. That removes the labels they were leaning on, so both gain tooltips and real content descriptions, and prepareRowTapAction() now drives the row's enabled flag and its tap handler from one mapping. Previously the row rendered as clickable in the ready state but onPrepareRowClicked ignored it, leaving the icon as the only way to reach the universal download. Resumable downloads get a progress bar for the first time, drawn flat via amplitude 0 so a stalled download does not look like a live one. Strings: every user-facing literal now lives in strings.xml. Download failures were assembled as English sentences in the util layer, which has no Context by design, so they crossed the WorkManager boundary already formatted and could never be translated. ApkDownloadException now carries a string resource and its arguments, and the ViewModel resolves them against the device locale. util/ stays Context-free and its tests stay plain JUnit. Also drops translatable="false" from seven strings that were visible prose, and stops showing raw exception text when the APK status cannot be read. Co-Authored-By: Claude Opus 5 (1M context) --- .../java/com/bitchat/android/ui/AboutSheet.kt | 186 ++-- .../android/ui/ApkDownloadViewModel.kt | 160 ++-- .../android/ui/ApkPrepareRowControls.kt | 107 +++ .../bitchat/android/util/ApkDownloadSource.kt | 232 +++++ .../bitchat/android/util/ApkDownloadWorker.kt | 48 +- .../com/bitchat/android/util/ApkDownloader.kt | 40 +- .../bitchat/android/util/GitHubRateLimit.kt | 61 -- .../android/util/GitHubReleaseClient.kt | 514 ----------- .../android/util/UniversalApkManager.kt | 814 ++++++++++-------- .../android/util/WorkManagerApkDownloader.kt | 24 +- app/src/main/res/values/strings.xml | 54 +- .../android/ui/PrepareRowTapActionTest.kt | 75 ++ .../android/util/ApkDownloadSourceTest.kt | 162 ++++ .../bitchat/android/util/DownloadPhaseTest.kt | 12 + .../android/util/GitHubRateLimitTest.kt | 164 ---- .../android/util/GitHubReleaseClientTest.kt | 99 --- 16 files changed, 1321 insertions(+), 1431 deletions(-) create mode 100644 app/src/main/java/com/bitchat/android/ui/ApkPrepareRowControls.kt create mode 100644 app/src/main/java/com/bitchat/android/util/ApkDownloadSource.kt delete mode 100644 app/src/main/java/com/bitchat/android/util/GitHubRateLimit.kt delete mode 100644 app/src/main/java/com/bitchat/android/util/GitHubReleaseClient.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/ui/PrepareRowTapActionTest.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/util/ApkDownloadSourceTest.kt delete mode 100644 app/src/test/kotlin/com/bitchat/android/util/GitHubRateLimitTest.kt delete mode 100644 app/src/test/kotlin/com/bitchat/android/util/GitHubReleaseClientTest.kt 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 355da287..7ad0b345 100644 --- a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt @@ -41,8 +41,9 @@ import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.CloudDownload import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Lock -import androidx.compose.material.icons.filled.Public import androidx.compose.material.icons.filled.Mic +import androidx.compose.material.icons.filled.Public +import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.filled.UnfoldMore import androidx.compose.material.icons.filled.Warning @@ -654,7 +655,12 @@ fun AboutSheet( Row( modifier = Modifier .fillMaxWidth() - .clickable(enabled = apkStatus !is ApkPreparationStatus.Downloading) { + // Enabled by the same mapping that decides what the tap + // does, so the row can never look tappable and do + // nothing. + .clickable( + enabled = prepareRowTapAction(apkStatus) != null + ) { apkViewModel.onEvent(ApkUiEvent.PrepareRowClicked) } .padding(horizontal = 16.dp, vertical = 14.dp), @@ -693,17 +699,20 @@ fun AboutSheet( is ApkPreparationStatus.NotDownloaded -> stringResource(R.string.prepare_apk_status_not_downloaded) is ApkPreparationStatus.Ready -> { val source = when { - status.source == UniversalApkManager.ApkSource.GITHUB -> - stringResource(R.string.prepare_apk_source_github) + status.source == UniversalApkManager.ApkSource.DOWNLOADED -> + stringResource(R.string.prepare_apk_source_downloaded) status.variant == ShareableApkVariant.ARM64 -> stringResource(R.string.prepare_apk_source_installed_arm64) else -> stringResource(R.string.prepare_apk_source_installed) } - stringResource(R.string.prepare_apk_status_ready) + - " • ${status.version} • ${status.sizeMB} MB\n$source" + stringResource( + R.string.prepare_apk_ready_detail, + status.version, + status.sizeMB, + source + ) } - is ApkPreparationStatus.UpdateAvailable -> stringResource(R.string.prepare_apk_status_update_available) + " (${status.newVersion})" is ApkPreparationStatus.Downloading -> // Only the transfer has a percentage worth // showing; the other phases are named @@ -713,138 +722,114 @@ fun AboutSheet( } else { stringResource(downloadPhaseLabel(status.phase)) } - is ApkPreparationStatus.Resumable -> "Tap to resume • ${status.progressPercent}% downloaded" + is ApkPreparationStatus.Resumable -> + stringResource( + R.string.prepare_apk_status_resumable, + status.message, + status.progressPercent + ) is ApkPreparationStatus.Error -> status.message }, style = MaterialTheme.typography.bodySmall, color = when (apkStatus) { is ApkPreparationStatus.Error -> colorScheme.error is ApkPreparationStatus.Resumable -> colorScheme.primary - is ApkPreparationStatus.UpdateAvailable -> colorScheme.primary else -> colorScheme.onSurface.copy(alpha = 0.6f) }, lineHeight = 16.sp ) + + // Progress lives in the column, not the trailing slot, + // which leaves that slot free for a single control. + ApkDownloadProgressBar( + status = apkStatus, + progressPercent = downloadProgress + ) } - // Action buttons + // One control, one width, in every state. The progress + // readout moved into the column above, so nothing else + // competes for this slot. when (apkStatus) { - is ApkPreparationStatus.Downloading -> { - // Determinate only while bytes move. Elsewhere a - // spinner is honest about having no measure. - if (apkStatus.phase.hasMeasurableProgress && - downloadProgress > 0 - ) { - CircularProgressIndicator( - progress = { downloadProgress / 100f }, - modifier = Modifier.size(20.dp), - strokeWidth = 2.dp - ) - } else { - CircularProgressIndicator( - modifier = Modifier.size(20.dp), - strokeWidth = 2.dp - ) - } - androidx.compose.material3.IconButton( + is ApkPreparationStatus.Downloading -> + ApkPrepareRowIconButton( + icon = Icons.Default.Close, + description = stringResource( + R.string.prepare_apk_stop + ), onClick = { - apkViewModel.onEvent(ApkUiEvent.CancelDownload) - }, - modifier = Modifier.size(32.dp) - ) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = stringResource(R.string.prepare_apk_stop), - tint = colorScheme.onSurfaceVariant, - modifier = Modifier.size(20.dp) - ) - } - } + apkViewModel.onEvent( + ApkUiEvent.CancelDownload + ) + } + ) is ApkPreparationStatus.Ready -> { if (apkStatus.variant == ShareableApkVariant.ARM64) { - TextButton( + ApkPrepareRowIconButton( + icon = Icons.Default.CloudDownload, + description = stringResource( + R.string.prepare_apk_get_universal + ), onClick = { apkViewModel.onEvent( ApkUiEvent.DownloadUniversalClicked ) - } - ) { - Icon( - imageVector = Icons.Default.CloudDownload, - contentDescription = null, - modifier = Modifier.size(18.dp) - ) - Spacer(modifier = Modifier.width(4.dp)) - Text( - stringResource( - R.string.prepare_apk_get_universal - ) - ) - } - } else if (apkStatus.source == UniversalApkManager.ApkSource.GITHUB) { - androidx.compose.material3.IconButton( - onClick = { apkViewModel.onEvent(ApkUiEvent.DeleteClicked) }, - modifier = Modifier.size(48.dp) - ) { - Icon( - imageVector = Icons.Default.Delete, - contentDescription = stringResource( - R.string.prepare_apk_delete_confirm - ), - tint = colorScheme.error, - modifier = Modifier.size(20.dp) - ) - } - } - } - is ApkPreparationStatus.UpdateAvailable -> { - androidx.compose.material3.IconButton( - onClick = { apkViewModel.onEvent(ApkUiEvent.DeleteClicked) }, - modifier = Modifier.size(48.dp) + }, + tint = colorScheme.primary + ) + } else if ( + apkStatus.source == + UniversalApkManager.ApkSource.DOWNLOADED ) { - Icon( - imageVector = Icons.Default.Delete, - contentDescription = stringResource( - R.string.prepare_apk_delete_confirm + ApkPrepareRowIconButton( + icon = Icons.Default.Delete, + description = stringResource( + R.string.prepare_apk_button_delete ), - tint = colorScheme.error, - modifier = Modifier.size(20.dp) + onClick = { + apkViewModel.onEvent( + ApkUiEvent.DeleteClicked + ) + }, + tint = colorScheme.error ) } } + is ApkPreparationStatus.Resumable, + is ApkPreparationStatus.Error -> + ApkPrepareRowIconButton( + icon = Icons.Default.Refresh, + description = stringResource( + R.string.prepare_apk_retry + ), + onClick = { + apkViewModel.onEvent( + ApkUiEvent.PrepareRowClicked + ) + }, + tint = colorScheme.primary + ) else -> {} } } // Prepare Dialog if (apkUiState.showPrepareDialog) { - val status = apkStatus - val sizeMB: Int? = when (status) { - is ApkPreparationStatus.NotDownloaded -> status.sizeMB - is ApkPreparationStatus.UpdateAvailable -> status.newSizeMB - else -> null - } AlertDialog( onDismissRequest = { apkViewModel.onEvent(ApkUiEvent.DismissPrepareDialog) }, title = { Text( - text = if (status is ApkPreparationStatus.UpdateAvailable) { - stringResource(R.string.prepare_apk_update_dialog_title) - } else { - stringResource(R.string.prepare_apk_dialog_title) - }, + text = stringResource( + R.string.prepare_apk_dialog_title + ), style = MaterialTheme.typography.titleLarge ) }, text = { Text( - text = if (status is ApkPreparationStatus.UpdateAvailable) { - stringResource(R.string.prepare_apk_update_dialog_message, status.newVersion, status.currentVersion) - } else if (sizeMB != null) { - stringResource(R.string.prepare_apk_dialog_message, sizeMB) - } else { - stringResource(R.string.prepare_apk_dialog_message_unknown_size) - }, + text = stringResource( + R.string.prepare_apk_dialog_message_unknown_size + ), style = MaterialTheme.typography.bodyMedium ) }, @@ -903,8 +888,7 @@ fun AboutSheet( } // Show sharing rows only when APK is ready - val canShareAPK = apkStatus is ApkPreparationStatus.Ready || - apkStatus is ApkPreparationStatus.UpdateAvailable + val canShareAPK = apkStatus is ApkPreparationStatus.Ready AnimatedVisibility( visible = canShareAPK, 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 16dbdc8f..29b066c1 100644 --- a/app/src/main/java/com/bitchat/android/ui/ApkDownloadViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ApkDownloadViewModel.kt @@ -24,21 +24,16 @@ import kotlinx.coroutines.withContext sealed class ApkPreparationStatus { object Loading : ApkPreparationStatus() - data class NotDownloaded(val sizeMB: Int?) : ApkPreparationStatus() + object NotDownloaded : ApkPreparationStatus() data class Ready( val version: String, val sizeMB: Int, val source: UniversalApkManager.ApkSource, val variant: ShareableApkVariant ) : ApkPreparationStatus() - data class UpdateAvailable( - val currentVersion: String, - val newVersion: String, - val newSizeMB: Int - ) : ApkPreparationStatus() /** [phase] is what the operation is actually doing; only a transfer has a real percentage. */ data class Downloading( - val phase: ApkDownloader.DownloadPhase = ApkDownloader.DownloadPhase.ResolvingRelease + val phase: ApkDownloader.DownloadPhase = ApkDownloader.DownloadPhase.SelectingSource ) : ApkPreparationStatus() data class Resumable(val progressPercent: Int, val message: String) : ApkPreparationStatus() data class Error(val message: String) : ApkPreparationStatus() @@ -70,6 +65,31 @@ sealed class ApkUiEvent { object CancelDownload : ApkUiEvent() } +// --- Row tap --- + +/** What tapping the body of the prepare row does. */ +internal enum class PrepareRowTapAction { + OpenPrepareDialog, + StartDownload +} + +/** + * What a tap on the prepare row means for [status], or null when the row has nothing to offer. + * + * The trailing controls are icon-only, so the row body is the discoverable half of every action + * and has to stay in step with them. Deriving both the tap handler and the row's `enabled` flag + * from this one function keeps the row from looking clickable while doing nothing. + */ +internal fun prepareRowTapAction(status: ApkPreparationStatus): PrepareRowTapAction? = when { + status is ApkPreparationStatus.NotDownloaded -> PrepareRowTapAction.OpenPrepareDialog + // Consent was already given for these; resuming straight away avoids a redundant prompt. + status is ApkPreparationStatus.Resumable -> PrepareRowTapAction.StartDownload + status is ApkPreparationStatus.Error -> PrepareRowTapAction.StartDownload + status is ApkPreparationStatus.Ready && status.variant == ShareableApkVariant.ARM64 -> + PrepareRowTapAction.OpenPrepareDialog + else -> null +} + // --- Effects (ViewModel → UI, one-shot) --- sealed class ApkUiEffect { @@ -120,16 +140,11 @@ class ApkDownloadViewModel(application: Application) : AndroidViewModel(applicat } private fun onPrepareRowClicked() { - when (_state.value.apkStatus) { - is ApkPreparationStatus.NotDownloaded, - is ApkPreparationStatus.UpdateAvailable, - is ApkPreparationStatus.Error -> { + when (prepareRowTapAction(_state.value.apkStatus)) { + PrepareRowTapAction.OpenPrepareDialog -> _state.update { it.copy(showPrepareDialog = true) } - } - is ApkPreparationStatus.Resumable -> { - startDownload() - } - else -> {} + PrepareRowTapAction.StartDownload -> startDownload() + null -> {} } } @@ -197,18 +212,12 @@ class ApkDownloadViewModel(application: Application) : AndroidViewModel(applicat private fun onCancelDownload() { downloader.cancelDownload() - // Leave Downloading now, not when the check returns. resolveApkStatus() reaches - // the network and can sit on the route timeout for a full minute, and nothing - // else would clear the spinner in the meantime -- the cancelled job maps to Idle, - // which the observer ignores. Without this the stop button looks broken for the - // whole wait. + // Leave Downloading immediately. The cancelled job maps to Idle, which + // the observer intentionally ignores because local status is resolved below. _state.update { it.copy(apkStatus = ApkPreparationStatus.Loading, downloadProgress = 0) } - // No force needed, and it would be harmful: leaving Downloading above already - // clears the entry guard, while the completion guard must stay armed so a - // download the user restarts during this check is not overwritten by its result. checkStatus() } @@ -234,9 +243,8 @@ class ApkDownloadViewModel(application: Application) : AndroidViewModel(applicat val resolvedStatus = resolveApkStatus() _state.update { current -> - // Re-checked rather than trusted from entry: this resolve reaches the - // network and can take a minute, in which time the user may have started - // a download. Its result must not overwrite work that is now running. + // Re-check in case the user started a download while the local + // artifact was being inspected or copied. if (current.apkStatus is ApkPreparationStatus.Downloading) { current } else { @@ -270,7 +278,7 @@ class ApkDownloadViewModel(application: Application) : AndroidViewModel(applicat sizeMB = info?.let { cached -> (cached.size / 1024 / 1024).toInt() } ?: downloadState.sizeMB, - source = info?.source ?: UniversalApkManager.ApkSource.GITHUB, + source = info?.source ?: UniversalApkManager.ApkSource.DOWNLOADED, variant = info?.variant ?: ShareableApkVariant.UNIVERSAL ), downloadProgress = 100 @@ -291,20 +299,21 @@ class ApkDownloadViewModel(application: Application) : AndroidViewModel(applicat ) ) } - _effect.send(ApkUiEffect.ShowToast(downloadState.message)) + _effect.send(ApkUiEffect.ShowToast(failureMessage(downloadState))) } else { + val message = failureMessage(downloadState) _state.update { if (downloadState.resumablePercent != null) { it.copy( apkStatus = ApkPreparationStatus.Resumable( progressPercent = downloadState.resumablePercent, - message = downloadState.message + message = message ), downloadProgress = downloadState.resumablePercent ) } else { it.copy( - apkStatus = ApkPreparationStatus.Error(downloadState.message) + apkStatus = ApkPreparationStatus.Error(message) ) } } @@ -325,72 +334,41 @@ class ApkDownloadViewModel(application: Application) : AndroidViewModel(applicat return getApplication().getString(resId) } + /** + * The single place a download failure turns into words. The downloader names the failure and + * this resolves it, so the message follows the device locale rather than the worker's. + */ + private fun failureMessage(state: ApkDownloader.DownloadState.Failed): String = + getApplication().getString( + state.messageRes, + *state.messageArgs.toTypedArray() + ) + private suspend fun resolveApkStatus(): ApkPreparationStatus = withContext(Dispatchers.IO) { try { - val updateStatus = apkManager.checkForUpdate() - when (updateStatus) { - is UniversalApkManager.UpdateStatus.NotDownloaded -> { - val partial = apkManager.getPartialDownloadProgress() - if (partial != null) { - ApkPreparationStatus.Resumable( - progressPercent = partial, - message = getString(R.string.prepare_apk_download_interrupted) - ) - } else { - ApkPreparationStatus.NotDownloaded( - sizeMB = (updateStatus.latestRelease.universalApkSize / 1024 / 1024).toInt() - ) - } - } - is UniversalApkManager.UpdateStatus.UpToDate -> { - val info = apkManager.getCachedApkInfo() - if (info != null) { - ApkPreparationStatus.Ready( - version = info.version, - sizeMB = (info.size / 1024 / 1024).toInt(), - source = info.source, - variant = info.variant - ) - } else { - ApkPreparationStatus.Error("Cached APK info not found") - } - } - is UniversalApkManager.UpdateStatus.UpdateAvailable -> { - ApkPreparationStatus.UpdateAvailable( - currentVersion = updateStatus.currentVersion, - newVersion = updateStatus.latestRelease.versionName, - newSizeMB = (updateStatus.latestRelease.universalApkSize / 1024 / 1024).toInt() + val info = apkManager.prepareLocalApkInfo() + if (info != null) { + ApkPreparationStatus.Ready( + version = info.version, + sizeMB = (info.size / 1024 / 1024).toInt(), + source = info.source, + variant = info.variant + ) + } else { + val partial = apkManager.getPartialDownloadProgress() + if (partial != null) { + ApkPreparationStatus.Resumable( + progressPercent = partial, + message = getString(R.string.prepare_apk_download_interrupted) ) - } - is UniversalApkManager.UpdateStatus.Error -> { - // A cached artifact stays shareable even when the update - // check fails or the release lags the installed version. - val info = apkManager.getCachedApkInfo() - if (info != null) { - ApkPreparationStatus.Ready( - version = info.version, - sizeMB = (info.size / 1024 / 1024).toInt(), - source = info.source, - variant = info.variant - ) - } else { - val partial = apkManager.getPartialDownloadProgress() - if (partial != null) { - ApkPreparationStatus.Resumable( - progressPercent = partial, - message = getString(R.string.prepare_apk_download_interrupted) - ) - } else { - ApkPreparationStatus.Error(updateStatus.message) - } - } + } else { + ApkPreparationStatus.NotDownloaded } } } catch (e: Exception) { - Log.e(TAG, "Error checking APK status", e) - ApkPreparationStatus.Error( - e.message ?: getString(R.string.prepare_apk_error_github) - ) + // The exception text is English and often internal; log it, show a translated line. + Log.e(TAG, "Error reading APK status", e) + ApkPreparationStatus.Error(getString(R.string.share_apk_error)) } } } 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..b79060a9 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/ApkPrepareRowControls.kt @@ -0,0 +1,107 @@ +package com.bitchat.android.ui + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearWavyProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.PlainTooltip +import androidx.compose.material3.Text +import androidx.compose.material3.TooltipBox +import androidx.compose.material3.TooltipDefaults +import androidx.compose.material3.rememberTooltipState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp + +/** + * The progress readout for the prepare-for-sharing row. + * + * This sits under the row's subtitle so the trailing slot is free to hold a single control. Which + * of the three renderings applies is decided entirely by [status]; the caller does not choose. + * + * The wave is not decoration. A moving wave means bytes are moving, so a stalled download draws a + * flat line at the fraction it reached rather than a bar indistinguishable from a live one. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +internal fun ApkDownloadProgressBar( + status: ApkPreparationStatus, + progressPercent: Int, + modifier: Modifier = Modifier +) { + val barModifier = modifier + .fillMaxWidth() + .padding(top = 6.dp) + + when { + // Only the transfer knows a fraction. Elsewhere an indeterminate bar is honest about + // having no measure, the same distinction the subtitle already draws. + status is ApkPreparationStatus.Downloading && + status.phase.hasMeasurableProgress && + progressPercent > 0 -> + LinearWavyProgressIndicator( + progress = { progressPercent.asProgressFraction() }, + modifier = barModifier + ) + + status is ApkPreparationStatus.Downloading -> + LinearWavyProgressIndicator(modifier = barModifier) + + // Flat: how far it got, and that it is not getting further on its own. + status is ApkPreparationStatus.Resumable -> + LinearWavyProgressIndicator( + progress = { status.progressPercent.asProgressFraction() }, + amplitude = { 0f }, + modifier = barModifier + ) + } +} + +/** Percentages arrive from a worker across a process boundary, so they are not trusted to be 0..100. */ +private fun Int.asProgressFraction(): Float = (this / 100f).coerceIn(0f, 1f) + +/** + * The single trailing control on the prepare row. + * + * Every status renders exactly one of these at the same width, so the text column beside it keeps + * its measure and stops re-wrapping each time the status changes. + * + * These buttons carry no visible label, which makes [description] load-bearing rather than + * decorative: it is both the TalkBack announcement and the long-press tooltip for a sighted user + * who does not recognise the glyph. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun ApkPrepareRowIconButton( + icon: ImageVector, + description: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + tint: Color = MaterialTheme.colorScheme.onSurfaceVariant +) { + TooltipBox( + positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(), + tooltip = { PlainTooltip { Text(description) } }, + state = rememberTooltipState(), + modifier = modifier + ) { + IconButton( + onClick = onClick, + modifier = Modifier.size(48.dp) + ) { + Icon( + imageVector = icon, + contentDescription = description, + tint = tint, + modifier = Modifier.size(20.dp) + ) + } + } +} 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..f8955e60 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/util/ApkDownloadSource.kt @@ -0,0 +1,232 @@ +package com.bitchat.android.util + +import androidx.annotation.StringRes +import com.bitchat.android.R +import java.io.IOException +import java.time.Instant +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter +import kotlin.math.ceil + +/** + * A trusted location that serves the latest signed universal BitChat APK. + * + * Sources are tried in order. A source may list compatibility filenames, which + * are only used when the preferred asset is absent. Adding a mirror should only + * require another entry; resume, retry, and verification do not depend on the host. + */ +data class ApkDownloadSource( + val id: String, + val displayName: String, + val latestApkUrls: List +) { + constructor(id: String, displayName: String, latestApkUrl: String) : this( + id = id, + displayName = displayName, + latestApkUrls = listOf(latestApkUrl) + ) + + init { + require(id.isNotBlank()) { "Download source id must not be blank" } + require(displayName.isNotBlank()) { "Download source name must not be blank" } + require(latestApkUrls.isNotEmpty()) { "Download source must have at least one URL" } + require(latestApkUrls.distinct().size == latestApkUrls.size) { + "Download source URLs must be unique" + } + require(latestApkUrls.all { it.startsWith("https://") }) { + "APK download sources must use HTTPS" + } + } +} + +internal object DefaultApkDownloadSources { + const val GITHUB_ID = "github-releases" + + val all = listOf( + ApkDownloadSource( + id = GITHUB_ID, + displayName = "GitHub Releases", + latestApkUrls = listOf( + "https://github.com/permissionlesstech/bitchat-android/releases/latest/" + + "download/bitchat-android-universal.apk", + // Releases published before the stable asset-name rollout use + // this filename. Remove when supported releases all use the primary URL. + "https://github.com/permissionlesstech/bitchat-android/releases/latest/" + + "download/app-universal-release.apk" + ) + ) + ) +} + +/** + * A host-neutral download failure that tells the worker whether backoff can help. + * + * [messageRes] and [messageArgs] name what the user should be told without saying it in any + * particular language. This layer has no Context by design — that is what keeps its tests plain + * JUnit — so the ViewModel resolves them. The inherited [message] stays English for logs and + * stack traces, and is never shown. + */ +class ApkDownloadException( + message: String, + @StringRes val messageRes: Int, + val messageArgs: List = emptyList(), + val retryable: Boolean, + val sourceId: String? = null, + val httpCode: Int? = null, + val retryAtMillis: Long? = null, + cause: Throwable? = null +) : IOException(message, cause) + +internal object ApkDownloadRetryPolicy { + const val MAX_ATTEMPTS = 3 + + fun shouldRetry(runAttemptCount: Int, error: Throwable?): Boolean { + val retryable = when (error) { + is ApkDownloadException -> error.retryable + is IOException -> true + else -> false + } + val attemptNumber = runAttemptCount + 1 + return retryable && attemptNumber < MAX_ATTEMPTS + } +} + +internal fun shouldTryNextSourceUrl( + error: ApkDownloadException, + hasMoreUrls: Boolean +): Boolean = hasMoreUrls && error.httpCode == 404 + +internal object ApkDownloadHttpErrors { + fun fromResponse( + source: ApkDownloadSource, + code: Int, + responseMessage: String, + retryAfter: String?, + rateLimitRemaining: String?, + rateLimitResetEpochSeconds: String?, + nowMillis: Long = System.currentTimeMillis() + ): ApkDownloadException { + val retryAt = retryAtMillis( + retryAfter = retryAfter, + rateLimitResetEpochSeconds = rateLimitResetEpochSeconds, + nowMillis = nowMillis + ) + val rateLimited = code == 429 || + (code == 403 && (rateLimitRemaining?.trim() == "0" || retryAt != null)) + + if (rateLimited) { + val minutes = retryAt?.let { deadline -> + ceil((deadline - nowMillis).coerceAtLeast(1L) / 60_000.0).toLong() + } + return ApkDownloadException( + message = "${source.id} rate limited: HTTP $code, retryAt=$retryAt", + messageRes = if (minutes != null) { + R.string.prepare_apk_error_rate_limited_wait + } else { + R.string.prepare_apk_error_rate_limited + }, + messageArgs = listOfNotNull(source.displayName, minutes?.toString()), + retryable = false, + sourceId = source.id, + httpCode = code, + retryAtMillis = retryAt + ) + } + + val retryable = code == 408 || code == 425 || code >= 500 + return ApkDownloadException( + message = "${source.id} failed: HTTP $code $responseMessage", + messageRes = if (code == 404) { + R.string.prepare_apk_error_no_universal + } else { + R.string.prepare_apk_error_http + }, + messageArgs = if (code == 404) { + listOf(source.displayName) + } else { + listOf(source.displayName, code.toString(), responseMessage) + }, + retryable = retryable, + sourceId = source.id, + httpCode = code + ) + } + + internal fun retryAtMillis( + retryAfter: String?, + rateLimitResetEpochSeconds: String?, + nowMillis: Long + ): Long? { + retryAfter?.trim()?.toLongOrNull() + ?.takeIf { it > 0L } + ?.let { seconds -> + runCatching { + Math.addExact(nowMillis, Math.multiplyExact(seconds, 1000L)) + }.getOrNull()?.let { return it } + } + + retryAfter?.trim()?.takeIf { it.isNotEmpty() }?.let { value -> + val parsed = runCatching { + ZonedDateTime.parse(value, DateTimeFormatter.RFC_1123_DATE_TIME) + .toInstant() + .toEpochMilli() + }.getOrNull() + if (parsed != null && parsed > nowMillis) return parsed + } + + return rateLimitResetEpochSeconds?.trim()?.toLongOrNull() + ?.let { runCatching { Instant.ofEpochSecond(it).toEpochMilli() }.getOrNull() } + ?.takeIf { it > nowMillis } + } +} + +internal object AppVersion { + fun isNewer(currentVersion: String, candidateVersion: String): Boolean { + val current = currentVersion.removePrefix("v").trim() + val candidate = candidateVersion.removePrefix("v").trim() + if (current == candidate) return false + + val currentParts = current.split(".").mapNotNull { it.toIntOrNull() } + val candidateParts = candidate.split(".").mapNotNull { it.toIntOrNull() } + val maxLength = maxOf(currentParts.size, candidateParts.size) + + for (index in 0 until maxLength) { + val currentPart = currentParts.getOrNull(index) ?: 0 + val candidatePart = candidateParts.getOrNull(index) ?: 0 + if (candidatePart != currentPart) return candidatePart > currentPart + } + return false + } +} + +internal data class ContentRange( + val start: Long, + val endInclusive: Long, + val total: Long? +) + +internal fun parseContentRange(value: String?): ContentRange? { + if (value == null) return null + val match = Regex("""bytes\s+(\d+)-(\d+)/(\d+|\*)""", RegexOption.IGNORE_CASE) + .matchEntire(value.trim()) + ?: return null + val start = match.groupValues[1].toLongOrNull() ?: return null + val end = match.groupValues[2].toLongOrNull() ?: return null + if (end < start) return null + val total = match.groupValues[3].takeUnless { it == "*" }?.toLongOrNull() + if (total != null && end >= total) return null + return ContentRange( + start = start, + endInclusive = end, + total = total + ) +} + +internal fun parseUnsatisfiedContentRangeTotal(value: String?): Long? { + if (value == null) return null + return Regex("""bytes\s+\*/(\d+)""", RegexOption.IGNORE_CASE) + .matchEntire(value.trim()) + ?.groupValues + ?.get(1) + ?.toLongOrNull() +} 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 b5739a16..710ec1c1 100644 --- a/app/src/main/java/com/bitchat/android/util/ApkDownloadWorker.kt +++ b/app/src/main/java/com/bitchat/android/util/ApkDownloadWorker.kt @@ -36,11 +36,10 @@ class ApkDownloadWorker( const val KEY_PHASE = "phase" const val KEY_VERSION = "version" const val KEY_SIZE_MB = "size_mb" - const val KEY_ERROR = "error" + const val KEY_ERROR_RES = "error_res" + const val KEY_ERROR_ARGS = "error_args" const val KEY_RESUMABLE_PERCENT = "resumable_percent" - private const val MAX_RETRIES = 3 - private const val CHANNEL_ID = "apk_download" private const val NOTIFICATION_ID = 4201 private const val NOTIFY_STEP_PERCENT = 5 @@ -52,7 +51,7 @@ class ApkDownloadWorker( private var lastNotifiedProgress = -NOTIFY_STEP_PERCENT private var lastProgress = 0 - private var currentPhase = ApkDownloader.DownloadPhase.ResolvingRelease + private var currentPhase = ApkDownloader.DownloadPhase.SelectingSource override suspend fun doWork(): Result { Log.d(TAG, "Starting APK download work") @@ -94,19 +93,30 @@ class ApkDownloadWorker( // Retry transient network errors with backoff; the partial file // is kept on disk, so the retry resumes where it left off. - val isRetryable = when (error) { - is GitHubReleaseClient.ReleaseFetchException -> error.retryable - is java.io.IOException -> true - else -> false - } - if (isRetryable && runAttemptCount < MAX_RETRIES) { - Log.w(TAG, "Transient download error (attempt $runAttemptCount), retrying", error) + val attemptNumber = runAttemptCount + 1 + if (ApkDownloadRetryPolicy.shouldRetry(runAttemptCount, error)) { + Log.w( + TAG, + "Transient download error " + + "(attempt $attemptNumber/${ApkDownloadRetryPolicy.MAX_ATTEMPTS}), retrying", + error + ) return Result.retry() } val partial = apkManager.getPartialDownloadProgress() + // Only a named failure carries a localizable message; anything else falls back to a + // generic one rather than leaking an untranslated exception string to the user. + val failure = error as? ApkDownloadException val outputData = Data.Builder() - .putString(KEY_ERROR, error?.message ?: "Download failed") + .putInt( + KEY_ERROR_RES, + failure?.messageRes ?: R.string.prepare_apk_error_generic + ) + .putStringArray( + KEY_ERROR_ARGS, + failure?.messageArgs.orEmpty().toTypedArray() + ) .putInt(KEY_RESUMABLE_PERCENT, partial ?: -1) .build() Result.failure(outputData) @@ -174,13 +184,11 @@ class ApkDownloadWorker( } private fun ensureChannel() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val channel = NotificationChannel( - CHANNEL_ID, - applicationContext.getString(R.string.apk_download_channel_name), - NotificationManager.IMPORTANCE_LOW - ) - notificationManager.createNotificationChannel(channel) - } + val channel = NotificationChannel( + CHANNEL_ID, + applicationContext.getString(R.string.apk_download_channel_name), + NotificationManager.IMPORTANCE_LOW + ) + notificationManager.createNotificationChannel(channel) } } 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 18db97d5..615e2357 100644 --- a/app/src/main/java/com/bitchat/android/util/ApkDownloader.kt +++ b/app/src/main/java/com/bitchat/android/util/ApkDownloader.kt @@ -1,5 +1,6 @@ package com.bitchat.android.util +import androidx.annotation.StringRes import kotlinx.coroutines.flow.Flow /** @@ -34,22 +35,29 @@ interface ApkDownloader { val phase: DownloadPhase = DownloadPhase.Transferring ) : DownloadState() data class Success(val version: String, val sizeMB: Int) : DownloadState() - data class Failed(val message: String, val resumablePercent: Int?) : DownloadState() + /** + * [messageRes] and [messageArgs] are resolved by the ViewModel, which has a Context. + * Carrying the ids rather than formatted text keeps the failure localizable all the way + * across the WorkManager boundary. + */ + data class Failed( + @StringRes val messageRes: Int, + val messageArgs: List, + val resumablePercent: Int? + ) : DownloadState() } /** * What a download is actually doing. * - * Preparing an APK is a five-stage operation that was being rendered as a single 0-100 bar, - * so it sat at 0% through a release lookup and a Tor bootstrap, then at 100% through a - * SHA-256 pass and a signature check over ~100MB. Only [Transferring] has meaningful - * percentage progress; the rest should read as indeterminate. + * Only [Transferring] has meaningful percentage progress; selecting a mirror, + * waiting for connectivity, and checking the signature are indeterminate. */ enum class DownloadPhase { - ResolvingRelease, + AwaitingConnectivity, + SelectingSource, AwaitingNetworkRoute, Transferring, - VerifyingChecksum, VerifyingSignature; /** A percentage is only honest while bytes are actually moving. */ @@ -57,22 +65,26 @@ interface ApkDownloader { companion object { /** Tolerates an unknown or absent key, since it crosses a WorkManager Data boundary. */ - fun fromKey(key: String?): DownloadPhase = - entries.firstOrNull { it.name == key } ?: Transferring + fun fromKey(key: String?): DownloadPhase = when (key) { + // Work created by the previous implementation may still be observable. + "ResolvingRelease" -> SelectingSource + "VerifyingChecksum" -> VerifyingSignature + else -> entries.firstOrNull { it.name == key } ?: Transferring + } } } } /** Shared by the notification and the About sheet so both name a phase identically. */ internal fun downloadPhaseLabel(phase: ApkDownloader.DownloadPhase): Int = when (phase) { - ApkDownloader.DownloadPhase.ResolvingRelease -> - com.bitchat.android.R.string.prepare_apk_phase_resolving + ApkDownloader.DownloadPhase.AwaitingConnectivity -> + com.bitchat.android.R.string.prepare_apk_phase_awaiting_connectivity + ApkDownloader.DownloadPhase.SelectingSource -> + com.bitchat.android.R.string.prepare_apk_phase_selecting_source ApkDownloader.DownloadPhase.AwaitingNetworkRoute -> com.bitchat.android.R.string.prepare_apk_phase_awaiting_route ApkDownloader.DownloadPhase.Transferring -> com.bitchat.android.R.string.prepare_apk_phase_transferring - ApkDownloader.DownloadPhase.VerifyingChecksum -> - com.bitchat.android.R.string.prepare_apk_phase_verifying_checksum ApkDownloader.DownloadPhase.VerifyingSignature -> com.bitchat.android.R.string.prepare_apk_phase_verifying_signature -} \ No newline at end of file +} diff --git a/app/src/main/java/com/bitchat/android/util/GitHubRateLimit.kt b/app/src/main/java/com/bitchat/android/util/GitHubRateLimit.kt deleted file mode 100644 index c03f25c5..00000000 --- a/app/src/main/java/com/bitchat/android/util/GitHubRateLimit.kt +++ /dev/null @@ -1,61 +0,0 @@ -package com.bitchat.android.util - -/** - * Reads GitHub's rate-limit rejections so the app can stop asking. - * - * Unauthenticated requests are capped at 60 an hour *per IP*, and when the app routes through Tor - * that IP belongs to an exit node shared with every other user on it, so the ceiling arrives much - * sooner than the per-user maths suggests. Retrying a rejection is pure waste, and repeating it on - * every screen open is what turns a brief limit into a permanent one. - */ -internal object GitHubRateLimit { - - /** Used when GitHub rejects a request without saying when to come back. */ - const val DEFAULT_BACKOFF_MILLIS = 10 * 60 * 1000L - - /** Never sit out longer than this, however far ahead the reset header claims to be. */ - const val MAX_BACKOFF_MILLIS = 60 * 60 * 1000L - - /** - * A 403 alone is not enough: GitHub also uses it for ordinary permission failures. - * - * Three things count as a rate limit. An explicit 429. A 403 reporting zero remaining quota, - * which is the primary hourly limit. And a 403 carrying Retry-After while quota remains, which - * is how secondary limits arrive — abuse detection rather than the hourly budget, so treating - * it as a permissions failure leaves the gate unset and keeps the app calling during exactly - * the cooldown GitHub asked for. - */ - fun isRateLimited(code: Int, remaining: String?, retryAfterSeconds: String? = null): Boolean = - code == 429 || - (code == 403 && (remaining?.trim() == "0" || retryAfterDelayMillis(retryAfterSeconds) != null)) - - private fun retryAfterDelayMillis(retryAfterSeconds: String?): Long? = - retryAfterSeconds?.trim()?.toLongOrNull()?.takeIf { it > 0 }?.let { it * 1000 } - - /** - * Epoch millis before which no further request should be sent, or null when the response was - * not a rate-limit rejection at all. - */ - fun blockedUntilMillis( - code: Int, - remaining: String?, - resetEpochSeconds: String?, - retryAfterSeconds: String?, - nowMillis: Long, - ): Long? { - if (!isRateLimited(code, remaining, retryAfterSeconds)) return null - - // Retry-After is a delta and is what GitHub sends for secondary limits, which can lift - // sooner than the primary window X-RateLimit-Reset describes. - val fromRetryAfter = retryAfterDelayMillis(retryAfterSeconds)?.let { nowMillis + it } - - // Dropped when it is not in the future: a skewed device clock must not turn a genuine - // rejection into "retry immediately". - val fromReset = resetEpochSeconds?.trim()?.toLongOrNull() - ?.let { it * 1000 } - ?.takeIf { it > nowMillis } - - val target = fromRetryAfter ?: fromReset ?: (nowMillis + DEFAULT_BACKOFF_MILLIS) - return target.coerceIn(nowMillis, nowMillis + MAX_BACKOFF_MILLIS) - } -} diff --git a/app/src/main/java/com/bitchat/android/util/GitHubReleaseClient.kt b/app/src/main/java/com/bitchat/android/util/GitHubReleaseClient.kt deleted file mode 100644 index a5acefe2..00000000 --- a/app/src/main/java/com/bitchat/android/util/GitHubReleaseClient.kt +++ /dev/null @@ -1,514 +0,0 @@ -package com.bitchat.android.util - -import android.util.Log -import com.bitchat.android.net.ArtiTorManager -import com.bitchat.android.net.OkHttpProvider -import com.bitchat.android.net.TorMode -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.withContext -import okhttp3.Request -import org.json.JSONObject -import java.io.IOException -import java.util.concurrent.TimeUnit - -/** - * Client for fetching BitChat release information from GitHub API. - */ -object GitHubReleaseClient { - private const val TAG = "GitHubAPI" - private const val GITHUB_API_URL = "https://api.github.com/repos/permissionlesstech/bitchat-android/releases/latest" - private const val USER_AGENT = "BitChat-Android" - private const val CACHE_TTL_MILLIS = 10 * 60 * 1000L - private const val MAX_FETCH_ATTEMPTS = 3 - private const val ROUTE_READY_TIMEOUT_MILLIS = 60_000L - private const val HTTP_NOT_MODIFIED = 304 - - private val fetchMutex = Mutex() - - @Volatile - private var cachedRelease: CachedRelease? = null - - /** - * ETag of the cached release, replayed as `If-None-Match`. GitHub does not charge a 304 - * against the rate limit, so revalidating an expired cache this way costs nothing where an - * unconditional refetch costs one of only 60 hourly requests. - */ - @Volatile - private var cachedEtag: String? = null - - /** - * Epoch millis before which GitHub has already told us it will reject anything we send, - * held per route. - * - * GitHub counts unauthenticated requests per IP, so a Tor exit and a direct connection - * have separate quotas. They are kept side by side rather than as one deadline that - * moves with the route: replacing it would mean switching away and back forgets a - * cooldown that is still running, and the app would hit the limited exit again. - * - * Without any of this, an exhausted quota fed itself: nothing cached the failure, so - * every screen that asked for release info spent three more requests rediscovering the - * same limit. - */ - @Volatile - private var torBlockedUntilMillis = 0L - - @Volatile - private var directBlockedUntilMillis = 0L - - /** - * The route requests will take, which is what the quota belongs to. - * - * Deliberately the selected mode rather than `isProxyEnabled()`: that reports - * readiness, and is false while Tor is still bootstrapping or restarting even though - * requests will still go through Tor once it is up. - */ - private fun selectedRouteUsesTor(): Boolean? = - runCatching { ArtiTorManager.getInstance().statusFlow.value.mode != TorMode.OFF } - .getOrNull() - - /** - * The deadline for the route about to be used. When the route cannot be determined the - * stricter of the two applies: failing to identify it must not release a real cooldown. - */ - private fun blockedUntilFor(routeUsesTor: Boolean?): Long = when (routeUsesTor) { - true -> torBlockedUntilMillis - false -> directBlockedUntilMillis - null -> maxOf(torBlockedUntilMillis, directBlockedUntilMillis) - } - - private fun recordBlockedUntil(untilMillis: Long, routeUsesTor: Boolean?) { - when (routeUsesTor) { - true -> torBlockedUntilMillis = untilMillis - false -> directBlockedUntilMillis = untilMillis - null -> { - torBlockedUntilMillis = untilMillis - directBlockedUntilMillis = untilMillis - } - } - } - - /** A success proves this route is clear. The other route's cooldown is left alone. */ - private fun clearBlockedFor(routeUsesTor: Boolean?) { - when (routeUsesTor) { - true -> torBlockedUntilMillis = 0L - false -> directBlockedUntilMillis = 0L - null -> { - torBlockedUntilMillis = 0L - directBlockedUntilMillis = 0L - } - } - } - - /** - * The gate's answer for [routeUsesTor], or null when nothing blocks the request. - * - * Sending a request GitHub has already said it will reject helps nobody and pushes - * the reset further out, so a stale release is a better answer than an error the - * user cannot act on. - */ - private fun blockedResultOrNull( - nowMillis: Long, - routeUsesTor: Boolean?, - cached: CachedRelease?, - ): Result? { - val blockedUntil = blockedUntilFor(routeUsesTor) - if (nowMillis >= blockedUntil) return null - - val waitMinutes = (blockedUntil - nowMillis) / 60_000 + 1 - Log.w(TAG, "Rate limited; not contacting GitHub for another ${waitMinutes}min") - cached?.let { return Result.success(it.release) } - return Result.failure( - ReleaseFetchException( - message = "GitHub API rate limit reached. Try again in " + - "$waitMinutes minute${if (waitMinutes == 1L) "" else "s"}.", - httpCode = 429, - retryable = false - ) - ) - } - - private val client - get() = OkHttpProvider.httpClient().newBuilder() - // GitHub requests may travel through Tor, where a 15-second total - // timeout is too aggressive during circuit establishment. - .callTimeout(45, TimeUnit.SECONDS) - .connectTimeout(20, TimeUnit.SECONDS) - .readTimeout(30, TimeUnit.SECONDS) - .build() - - /** - * Fetch the latest release information from GitHub. - * Successful metadata is cached briefly so the status screen and download - * worker use the same release snapshot instead of making duplicate calls. - */ - /** - * @param onAwaitingNetworkRoute invoked if this call is about to block on the selected route - * (a Tor bootstrap can take the better part of a minute). A cache hit returns before that - * point and never invokes it, so callers can report the wait only when there is one. - */ - suspend fun fetchLatestRelease( - forceRefresh: Boolean = false, - onAwaitingNetworkRoute: (() -> Unit)? = null, - onResolvingRelease: (() -> Unit)? = null, - ): Result = - withContext(Dispatchers.IO) { - fetchMutex.withLock { - val now = System.currentTimeMillis() - val cached = cachedRelease - - if (!forceRefresh && - cached != null && - now - cached.fetchedAtMillis < CACHE_TTL_MILLIS - ) { - return@withLock Result.success(cached.release) - } - - // Honoured even on an explicit refresh. - blockedResultOrNull(now, selectedRouteUsesTor(), cached) - ?.let { return@withLock it } - - onAwaitingNetworkRoute?.invoke() - if (!awaitSelectedNetworkRoute()) { - return@withLock Result.failure( - ReleaseFetchException( - message = "Tor is still connecting. Try again when Tor is ready.", - retryable = true - ) - ) - } - // The wait is over, so stop saying we are waiting. In direct mode it - // returned immediately and never really started, and the fetch below - // retries -- either way the caller must not keep reporting a Tor wait - // for the whole metadata request. - onResolvingRelease?.invoke() - - var lastFailure: Throwable = ReleaseFetchException( - "Failed to fetch the latest release from GitHub" - ) - - repeat(MAX_FETCH_ATTEMPTS) { attempt -> - // Sampled immediately before the call and reused for its response, so - // a route change mid-flight cannot file the cooldown against the route - // the request did not use. - val routeUsesTor = selectedRouteUsesTor() - - // Per attempt rather than once before the loop. The route can change - // during the wait above, during a request, or during a backoff, and - // the one we have just switched to may carry a cooldown of its own. - blockedResultOrNull(System.currentTimeMillis(), routeUsesTor, cached) - ?.let { return@withLock it } - - val result = fetchLatestReleaseOnce(routeUsesTor) - result.onSuccess { release -> - cachedRelease = CachedRelease(release, System.currentTimeMillis()) - return@withLock Result.success(release) - } - lastFailure = result.exceptionOrNull() ?: lastFailure - - // The response that just set the gate is the one the user is waiting - // on. Reporting an error here and only serving the cache on the next - // call makes the first check fail and an immediate retry succeed from - // metadata we already had. - if (System.currentTimeMillis() < blockedUntilFor(routeUsesTor)) { - cached?.let { - Log.w(TAG, "Rate limited; serving the cached release instead of failing") - return@withLock Result.success(it.release) - } - return@withLock Result.failure(lastFailure) - } - - if (!isRetryable(lastFailure) || attempt == MAX_FETCH_ATTEMPTS - 1) { - return@withLock Result.failure(lastFailure) - } - - delay(1_000L shl attempt) - } - - Result.failure(lastFailure) - } - } - - /** - * Wait for Tor when it is the selected route. This deliberately does not - * fall back to a direct connection because doing so would violate the - * user's Tor preference. - */ - suspend fun awaitSelectedNetworkRoute(): Boolean { - return ArtiTorManager.getInstance() - .awaitSelectedRoute(ROUTE_READY_TIMEOUT_MILLIS) - } - - private fun fetchLatestReleaseOnce(routeUsesTor: Boolean?): Result { - val cached = cachedRelease - val etag = cachedEtag - return try { - Log.d(TAG, "Fetching latest release from GitHub API") - val request = Request.Builder() - .url(GITHUB_API_URL) - .addHeader("User-Agent", USER_AGENT) - .addHeader("Accept", "application/vnd.github+json") - .addHeader("X-GitHub-Api-Version", "2022-11-28") - .apply { - // Revalidate rather than refetch. GitHub does not charge a 304 against the - // hourly quota, so an unchanged release costs nothing to confirm. - if (cached != null && etag != null) addHeader("If-None-Match", etag) - } - .build() - - client.newCall(request).execute().use { response -> - if (response.code == HTTP_NOT_MODIFIED && cached != null) { - Log.d(TAG, "Release unchanged; cache revalidated at no quota cost") - cachedRelease = cached.copy(fetchedAtMillis = System.currentTimeMillis()) - return Result.success(cached.release) - } - - if (!response.isSuccessful) { - val blockedUntil = GitHubRateLimit.blockedUntilMillis( - code = response.code, - remaining = response.header("X-RateLimit-Remaining"), - resetEpochSeconds = response.header("X-RateLimit-Reset"), - retryAfterSeconds = response.header("Retry-After"), - nowMillis = System.currentTimeMillis(), - ) - if (blockedUntil != null) { - // Recorded against the route the request took, not the one - // selected now: the setting can change while a call is in flight. - recordBlockedUntil(blockedUntil, routeUsesTor) - } - - val message = if (blockedUntil != null) { - val waitMinutes = - (blockedUntil - System.currentTimeMillis()) / 60_000 + 1 - "GitHub API rate limit exceeded. Try again in " + - "$waitMinutes minute${if (waitMinutes == 1L) "" else "s"}." - } else { - "GitHub release request failed: HTTP ${response.code} ${response.message}" - } - Log.e(TAG, message) - return Result.failure( - ReleaseFetchException( - message = message, - httpCode = response.code, - // A rate limit is never worth an in-loop retry: the gate in - // fetchLatestRelease decides when it is worth asking again. A plain - // 403 is a permissions failure and will not fix itself either. - retryable = blockedUntil == null && - (response.code == 408 || response.code >= 500) - ) - ) - } - - val body = response.body?.string() - if (body.isNullOrBlank()) { - return Result.failure( - ReleaseFetchException( - message = "GitHub returned an empty response", - retryable = true - ) - ) - } - - val release = parseRelease(body) - ?: return Result.failure( - ReleaseFetchException( - message = "GitHub's latest release has no universal APK asset", - retryable = false - ) - ) - // Kept alongside the release so the pair can never drift: a stale ETag would - // revalidate to a 304 that confirms a release we no longer hold. - cachedEtag = response.header("ETag") - clearBlockedFor(routeUsesTor) - Result.success(release) - } - } catch (e: IOException) { - Log.e(TAG, "Network error fetching release", e) - Result.failure( - ReleaseFetchException( - "Could not reach GitHub${e.message?.let { ": $it" } ?: ""}", - cause = e - ) - ) - } catch (e: Exception) { - Log.e(TAG, "Error fetching release", e) - Result.failure(ReleaseFetchException("Invalid GitHub release response", cause = e)) - } - } - - private fun isRetryable(error: Throwable): Boolean { - return error !is ReleaseFetchException || error.retryable - } - - /** - * Parse GitHub API JSON response into Release object. - */ - internal fun parseRelease(jsonString: String): Release? { - try { - val json = JSONObject(jsonString) - val tagName = json.optString("tag_name", "") - val versionName = tagName.removePrefix("v") // Remove "v" prefix if present - - if (versionName.isBlank()) { - Log.e(TAG, "No version tag found in release") - return null - } - - Log.d(TAG, "Found release: $versionName") - - // Parse assets array to find universal APK - val assets = json.optJSONArray("assets") - if (assets == null || assets.length() == 0) { - Log.e(TAG, "No assets found in release") - return null - } - - // Look for universal APK (usually named "app-universal-release.apk") - for (i in 0 until assets.length()) { - val asset = assets.getJSONObject(i) - val name = asset.optString("name", "") - - if (name.contains("universal", ignoreCase = true) && name.endsWith(".apk")) { - val downloadUrl = asset.optString("browser_download_url", "") - val size = asset.optLong("size", 0L) - - if (downloadUrl.isBlank()) { - Log.e(TAG, "Universal APK found but no download URL") - continue - } - - // Prefer GitHub's asset digest when available, then fall - // back to release notes used by older releases. - val body = json.optString("body", "") - val assetDigest = asset.optString("digest", "") - .takeIf { it.startsWith("sha256:", ignoreCase = true) } - ?.substringAfter(":") - ?.takeIf { it.matches(Regex("[a-fA-F0-9]{64}")) } - ?.lowercase() - val sha256 = assetDigest ?: extractSha256FromBody(body, name) - - Log.d(TAG, "Found universal APK: $name (${size / 1024 / 1024}MB)") - - return Release( - tagName = tagName, - versionName = versionName, - universalApkUrl = downloadUrl, - universalApkSha256 = sha256, - universalApkSize = size, - universalApkName = name - ) - } - } - - Log.e(TAG, "No universal APK found in release assets") - return null - - } catch (e: Exception) { - Log.e(TAG, "Error parsing release JSON", e) - return null - } - } - - /** - * Extract SHA256 checksum from release body/notes. - * Looks for patterns like: - * - sha256:abc123... - * - SHA256: abc123... - * - app-universal-release.apk: abc123... - */ - private fun extractSha256FromBody(body: String, apkName: String): String? { - if (body.isBlank()) return null - - try { - // Pattern 1: Look for "sha256:" followed by hash - val sha256Pattern = Regex("""sha256:\s*([a-fA-F0-9]{64})""", RegexOption.IGNORE_CASE) - sha256Pattern.find(body)?.let { match -> - return match.groupValues[1].lowercase() - } - - // Pattern 2: Look for APK name followed by hash - val apkPattern = Regex("""${Regex.escape(apkName)}.*?([a-fA-F0-9]{64})""", RegexOption.IGNORE_CASE) - apkPattern.find(body)?.let { match -> - return match.groupValues[1].lowercase() - } - - Log.w(TAG, "Could not extract SHA256 from release body") - return null - - } catch (e: Exception) { - Log.w(TAG, "Error extracting SHA256", e) - return null - } - } - - /** - * Check if a newer version is available. - * @param currentVersion Current installed/cached version - * @param latestRelease Latest release from GitHub - * @return true if latestRelease is newer - */ - fun isNewerVersion(currentVersion: String, latestRelease: Release): Boolean { - return isNewerVersion(currentVersion, latestRelease.versionName) - } - - internal fun isNewerVersion(currentVersion: String, candidateVersion: String): Boolean { - return try { - // Simple version comparison (assumes semantic versioning) - // Remove any non-numeric prefixes - val current = currentVersion.removePrefix("v").trim() - val latest = candidateVersion.removePrefix("v").trim() - - if (current == latest) { - return false - } - - // Split by dots and compare each part - val currentParts = current.split(".").mapNotNull { it.toIntOrNull() } - val latestParts = latest.split(".").mapNotNull { it.toIntOrNull() } - - val maxLength = maxOf(currentParts.size, latestParts.size) - - for (i in 0 until maxLength) { - val currentPart = currentParts.getOrNull(i) ?: 0 - val latestPart = latestParts.getOrNull(i) ?: 0 - - if (latestPart > currentPart) { - return true - } else if (latestPart < currentPart) { - return false - } - } - - false - } catch (e: Exception) { - Log.e(TAG, "Error comparing versions", e) - false - } - } - - /** - * Release information from GitHub. - */ - data class Release( - val tagName: String, - val versionName: String, - val universalApkUrl: String, - val universalApkSha256: String?, - val universalApkSize: Long, - val universalApkName: String - ) - - class ReleaseFetchException( - message: String, - val httpCode: Int? = null, - val retryable: Boolean = true, - cause: Throwable? = null - ) : IOException(message, cause) - - private data class CachedRelease( - val release: Release, - val fetchedAtMillis: Long - ) -} 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 a457c8b2..a50bfd8f 100644 --- a/app/src/main/java/com/bitchat/android/util/UniversalApkManager.kt +++ b/app/src/main/java/com/bitchat/android/util/UniversalApkManager.kt @@ -4,7 +4,10 @@ import android.content.Context import android.content.pm.PackageManager import android.os.Build import android.util.Log +import androidx.annotation.StringRes +import com.bitchat.android.R 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 @@ -26,7 +29,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 +45,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 @@ -64,13 +77,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 +107,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 +132,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 +144,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,261 +166,343 @@ 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, + messageRes = R.string.prepare_apk_error_storage_needed, + 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, - /** - * Reports which stage the operation reached. Both stages before the transfer can block - * for a long time — a release lookup, then a Tor bootstrap — and reporting neither is why - * a download appeared stuck at 0%. - */ 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. - phaseCallback?.invoke(ApkDownloader.DownloadPhase.ResolvingRelease) - // The fetch waits on the route itself when it has to go to the network, so it - // reports that from the inside. Labelling the whole call "resolving release" - // would put the app's own name on the long Tor wait this phase exists to explain. - val release = GitHubReleaseClient.fetchLatestRelease( - onAwaitingNetworkRoute = { - phaseCallback?.invoke(ApkDownloader.DownloadPhase.AwaitingNetworkRoute) - }, - onResolvingRelease = { - phaseCallback?.invoke(ApkDownloader.DownloadPhase.ResolvingRelease) - } - ).getOrElse { error -> - return@withContext Result.failure(error) - } - - // A cache hit skips the fetch's own wait, so for that path the route wait is here. - phaseCallback?.invoke(ApkDownloader.DownloadPhase.AwaitingNetworkRoute) - 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.", + messageRes = R.string.prepare_apk_error_no_sources, + retryable = false ) - } - phaseCallback?.invoke(ApkDownloader.DownloadPhase.Transferring) - - 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...") - phaseCallback?.invoke(ApkDownloader.DownloadPhase.VerifyingChecksum) - 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...") - phaseCallback?.invoke(ApkDownloader.DownloadPhase.VerifyingSignature) - 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.", + messageRes = R.string.prepare_apk_error_tor_connecting, + 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) + + 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) + 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.", + messageRes = R.string.prepare_apk_error_no_url, + messageArgs = listOf(source.displayName), + retryable = false + ) + } + + private suspend fun executeDownloadRequest( + source: ApkDownloadSource, + endpointUrl: String, + tempFile: File, + existingBytes: Long, + resume: ResumeInfo?, + progressCallback: ((Int) -> Unit)? + ) { + 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 = downloadClient.newCall(request), + source = source, + 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, + 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" } ?: "."), + messageRes = R.string.prepare_apk_error_unreachable, + messageArgs = listOf(source.displayName), + retryable = true, + sourceId = source.id, + cause = e + ) + ) } override fun onResponse(call: Call, response: Response) { try { response.use { - 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." + if (!response.request.url.isHttps) { + throw ApkDownloadException( + message = "${source.id} redirected to an insecure URL.", + messageRes = R.string.prepare_apk_error_insecure_redirect, + messageArgs = listOf(source.displayName), + retryable = false, + sourceId = source.id ) } - if (!response.isSuccessful && response.code != 206) { - throw IOException( - "Download failed: ${response.code} ${response.message}" + if (response.code == 416) { + 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.", + messageRes = R.string.prepare_apk_error_resume_rejected, + messageArgs = listOf(source.displayName), + retryable = true, + sourceId = source.id, + httpCode = response.code + ) + } + if (!response.isSuccessful) { + throw 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") ) } 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 } + 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 -> @@ -458,8 +510,8 @@ class UniversalApkManager(private val context: Context) { 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 } @@ -467,23 +519,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.", + messageRes = R.string.prepare_apk_error_incomplete, + messageArgs = listOf(source.displayName), + retryable = true, + sourceId = source.id + ) + } } completeSuccessfully() } catch (e: Exception) { @@ -496,6 +554,103 @@ 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.", + messageRes = R.string.prepare_apk_error_invalid_resume, + 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.", + messageRes = R.string.prepare_apk_error_untrusted_key, + messageArgs = listOf(source.displayName), + retryable = false, + sourceId = source.id + ) + } + if (!DistributionInfoProvider.isUniversalApk(tempFile)) { + clearPartialDownload() + throw ApkDownloadException( + message = "${source.id} returned an architecture-specific APK.", + messageRes = R.string.prepare_apk_error_not_universal, + messageArgs = listOf(source.displayName), + retryable = false, + sourceId = source.id + ) + } + } + + private fun downloadedVersionName(apkFile: File): String { + val packageInfo = context.packageManager.getPackageArchiveInfo(apkFile.absolutePath, 0) + ?: invalidDownloadedApk(R.string.prepare_apk_error_apk_unreadable, "unreadable APK") + if (packageInfo.packageName != context.packageName) { + invalidDownloadedApk(R.string.prepare_apk_error_not_bitchat, "wrong package") + } + return packageInfo.versionName + ?.takeIf { it.isNotBlank() } + ?: invalidDownloadedApk(R.string.prepare_apk_error_no_version, "no version name") + } + + private fun invalidDownloadedApk(@StringRes messageRes: Int, logReason: String): Nothing { + clearPartialDownload() + throw ApkDownloadException( + message = "Downloaded APK rejected: $logReason", + messageRes = messageRes, + 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" } ?: "."), + messageRes = R.string.prepare_apk_error_source_failed, + 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.", + messageRes = R.string.prepare_apk_error_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" }, + messageRes = R.string.prepare_apk_error_all_sources, + 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. @@ -524,7 +679,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 @@ -532,9 +687,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 } @@ -556,14 +711,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) @@ -583,15 +737,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 { @@ -609,6 +758,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 ) @@ -617,7 +768,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 @@ -678,39 +829,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. */ @@ -757,20 +875,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") @@ -779,12 +897,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) { @@ -792,17 +911,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 @@ -831,29 +966,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 f0eded0c..0b3e597b 100644 --- a/app/src/main/java/com/bitchat/android/util/WorkManagerApkDownloader.kt +++ b/app/src/main/java/com/bitchat/android/util/WorkManagerApkDownloader.kt @@ -63,7 +63,7 @@ class WorkManagerApkDownloader(context: Context) : ApkDownloader { val partial = apkManager.getPartialDownloadProgress() ApkDownloader.DownloadState.Downloading( partial ?: 0, - ApkDownloader.DownloadPhase.ResolvingRelease + ApkDownloader.DownloadPhase.AwaitingConnectivity ) } WorkInfo.State.RUNNING -> { @@ -79,16 +79,30 @@ class WorkManagerApkDownloader(context: Context) : ApkDownloader { ApkDownloader.DownloadState.Success(version, sizeMB) } WorkInfo.State.FAILED -> { - val error = workInfo.outputData.getString(ApkDownloadWorker.KEY_ERROR) ?: "Download failed" + // Work enqueued by an older build carries no resource id; fall back rather than + // resolve 0 and crash. + val messageRes = workInfo.outputData + .getInt(ApkDownloadWorker.KEY_ERROR_RES, 0) + .takeIf { it != 0 } + ?: R.string.prepare_apk_error_generic + val args = workInfo.outputData + .getStringArray(ApkDownloadWorker.KEY_ERROR_ARGS) + ?.toList() + .orEmpty() val resumable = workInfo.outputData.getInt(ApkDownloadWorker.KEY_RESUMABLE_PERCENT, -1) - ApkDownloader.DownloadState.Failed(error, if (resumable >= 0) resumable else null) + ApkDownloader.DownloadState.Failed( + messageRes = messageRes, + messageArgs = args, + resumablePercent = if (resumable >= 0) resumable else null + ) } WorkInfo.State.CANCELLED -> { val partial = apkManager.getPartialDownloadProgress() if (partial != null) { ApkDownloader.DownloadState.Failed( - appContext.getString(R.string.prepare_apk_download_cancelled), - partial + messageRes = R.string.prepare_apk_download_cancelled, + messageArgs = emptyList(), + resumablePercent = partial ) } else { ApkDownloader.DownloadState.Idle diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 52e0038c..f5fde380 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -236,43 +236,65 @@ 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 + + Download universal APK + Retry download Downloading… %1$d%% - Checking latest release… + Waiting for network… + Selecting download source… Waiting for Tor… Downloading… - Verifying checksum… Verifying signature… Stop download - Update available 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. + This will download a verified universal APK from a configured source. You only need to do this once. 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 in %2$s min. + %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/ui/PrepareRowTapActionTest.kt b/app/src/test/kotlin/com/bitchat/android/ui/PrepareRowTapActionTest.kt new file mode 100644 index 00000000..07b4ed23 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/ui/PrepareRowTapActionTest.kt @@ -0,0 +1,75 @@ +package com.bitchat.android.ui + +import com.bitchat.android.util.ApkDownloader +import com.bitchat.android.util.ShareableApkVariant +import com.bitchat.android.util.UniversalApkManager +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * The row body and its trailing icon button are two doors into the same actions, and the trailing + * buttons no longer carry visible labels. If this mapping is wrong the affordance simply vanishes, + * so each status is pinned down here rather than left to the composable. + */ +class PrepareRowTapActionTest { + + private fun ready( + variant: ShareableApkVariant, + source: UniversalApkManager.ApkSource = UniversalApkManager.ApkSource.INSTALLED + ) = ApkPreparationStatus.Ready( + version = "1.7.5", + sizeMB = 12, + source = source, + variant = variant + ) + + @Test + fun `an arm64-only build offers the universal download`() { + // The only entry point besides the trailing icon, which has no label to explain itself. + assertEquals( + PrepareRowTapAction.OpenPrepareDialog, + prepareRowTapAction(ready(ShareableApkVariant.ARM64)) + ) + } + + @Test + fun `nothing left to fetch means the row is inert`() { + assertNull(prepareRowTapAction(ready(ShareableApkVariant.UNIVERSAL))) + assertNull( + prepareRowTapAction( + ready(ShareableApkVariant.UNIVERSAL, UniversalApkManager.ApkSource.DOWNLOADED) + ) + ) + } + + @Test + fun `a missing apk asks before spending the bytes`() { + assertEquals( + PrepareRowTapAction.OpenPrepareDialog, + prepareRowTapAction(ApkPreparationStatus.NotDownloaded) + ) + } + + @Test + fun `an interrupted or failed download resumes without asking again`() { + // The user already consented to the download; re-prompting would be noise. + assertEquals( + PrepareRowTapAction.StartDownload, + prepareRowTapAction(ApkPreparationStatus.Resumable(43, "Download interrupted")) + ) + assertEquals( + PrepareRowTapAction.StartDownload, + prepareRowTapAction(ApkPreparationStatus.Error("Network error")) + ) + } + + @Test + fun `a download in flight is not restartable by tapping the row`() { + // Otherwise a stray tap behind the stop button would queue a second download. + ApkDownloader.DownloadPhase.entries.forEach { phase -> + assertNull(prepareRowTapAction(ApkPreparationStatus.Downloading(phase))) + } + assertNull(prepareRowTapAction(ApkPreparationStatus.Loading)) + } +} 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..8caa9c61 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/util/ApkDownloadSourceTest.kt @@ -0,0 +1,162 @@ +package com.bitchat.android.util + +import com.bitchat.android.R +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.IOException + +class ApkDownloadSourceTest { + + private val source = ApkDownloadSource( + id = "mirror-one", + displayName = "Mirror One", + latestApkUrl = "https://mirror.example/bitchat-universal.apk" + ) + private val now = 1_700_000_000_000L + + @Test + fun `default source downloads the stable latest universal asset directly`() { + assertEquals( + "https://github.com/permissionlesstech/bitchat-android/releases/latest/" + + "download/bitchat-android-universal.apk", + DefaultApkDownloadSources.all.single().latestApkUrls.first() + ) + assertEquals( + "https://github.com/permissionlesstech/bitchat-android/releases/latest/" + + "download/app-universal-release.apk", + DefaultApkDownloadSources.all.single().latestApkUrls[1] + ) + } + + @Test + fun `transient HTTP failures are retryable but ordinary client errors are not`() { + assertTrue(httpError(408).retryable) + assertTrue(httpError(500).retryable) + assertTrue(httpError(503).retryable) + assertFalse(httpError(400).retryable) + assertFalse(httpError(404).retryable) + } + + @Test + fun `compatibility URL is only tried when the preferred asset is absent`() { + assertTrue(shouldTryNextSourceUrl(httpError(404), hasMoreUrls = true)) + assertFalse(shouldTryNextSourceUrl(httpError(404), hasMoreUrls = false)) + assertFalse(shouldTryNextSourceUrl(httpError(429), hasMoreUrls = true)) + assertFalse(shouldTryNextSourceUrl(httpError(503), hasMoreUrls = true)) + } + + @Test + fun `rate limit response gives the user the advertised retry time`() { + val failure = ApkDownloadHttpErrors.fromResponse( + source = source, + code = 429, + responseMessage = "Too Many Requests", + retryAfter = "120", + rateLimitRemaining = null, + rateLimitResetEpochSeconds = null, + nowMillis = now + ) + + assertFalse(failure.retryable) + assertEquals(now + 120_000L, failure.retryAtMillis) + // The wait is carried as an argument, not baked into an English sentence. + assertEquals(R.string.prepare_apk_error_rate_limited_wait, failure.messageRes) + assertEquals(listOf(source.displayName, "2"), failure.messageArgs) + } + + @Test + fun `403 is only treated as a limit when response headers say so`() { + val permissionsFailure = ApkDownloadHttpErrors.fromResponse( + source = source, + code = 403, + responseMessage = "Forbidden", + retryAfter = "not-a-date", + rateLimitRemaining = "42", + rateLimitResetEpochSeconds = null, + nowMillis = now + ) + val quotaFailure = ApkDownloadHttpErrors.fromResponse( + source = source, + code = 403, + responseMessage = "Forbidden", + retryAfter = null, + rateLimitRemaining = "0", + rateLimitResetEpochSeconds = (now / 1000L + 300L).toString(), + nowMillis = now + ) + + assertNull(permissionsFailure.retryAtMillis) + assertEquals(R.string.prepare_apk_error_http, permissionsFailure.messageRes) + assertEquals( + listOf(source.displayName, "403", "Forbidden"), + permissionsFailure.messageArgs + ) + assertEquals(now + 300_000L, quotaFailure.retryAtMillis) + assertEquals(R.string.prepare_apk_error_rate_limited_wait, quotaFailure.messageRes) + } + + @Test + fun `invalid or overflowing retry headers never crash error mapping`() { + assertNull( + ApkDownloadHttpErrors.retryAtMillis( + retryAfter = Long.MAX_VALUE.toString(), + rateLimitResetEpochSeconds = Long.MAX_VALUE.toString(), + nowMillis = now + ) + ) + } + + @Test + fun `content ranges validate resume offsets and totals`() { + assertEquals( + ContentRange(start = 1_024L, endInclusive = 2_047L, total = 4_096L), + parseContentRange("bytes 1024-2047/4096") + ) + assertEquals(4_096L, parseUnsatisfiedContentRangeTotal("bytes */4096")) + assertNull(parseContentRange("bytes nope")) + assertNull(parseContentRange("bytes 20-10/100")) + assertNull(parseContentRange("bytes 90-100/100")) + } + + @Test + fun `version comparison is host independent`() { + assertTrue(AppVersion.isNewer("1.7.4", "1.7.5")) + assertFalse(AppVersion.isNewer("1.7.5", "1.7.4")) + assertFalse(AppVersion.isNewer("v1.7.5", "1.7.5")) + assertTrue(AppVersion.isNewer("1.7", "1.7.1")) + } + + @Test + fun `worker policy allows exactly three total attempts`() { + val transient = IOException("offline") + + assertTrue(ApkDownloadRetryPolicy.shouldRetry(runAttemptCount = 0, transient)) + assertTrue(ApkDownloadRetryPolicy.shouldRetry(runAttemptCount = 1, transient)) + assertFalse(ApkDownloadRetryPolicy.shouldRetry(runAttemptCount = 2, transient)) + assertFalse( + ApkDownloadRetryPolicy.shouldRetry( + runAttemptCount = 0, + ApkDownloadException( + message = "invalid APK", + messageRes = R.string.prepare_apk_error_generic, + retryable = false + ) + ) + ) + } + + private fun httpError(code: Int): ApkDownloadException { + return ApkDownloadHttpErrors.fromResponse( + source = source, + code = code, + responseMessage = "test", + retryAfter = null, + rateLimitRemaining = null, + rateLimitResetEpochSeconds = null, + nowMillis = now + ) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/util/DownloadPhaseTest.kt b/app/src/test/kotlin/com/bitchat/android/util/DownloadPhaseTest.kt index 61673054..f08c009c 100644 --- a/app/src/test/kotlin/com/bitchat/android/util/DownloadPhaseTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/util/DownloadPhaseTest.kt @@ -31,6 +31,18 @@ class DownloadPhaseTest { ) } + @Test + fun `phase keys from queued work created by the old downloader still map correctly`() { + assertEquals( + ApkDownloader.DownloadPhase.SelectingSource, + ApkDownloader.DownloadPhase.fromKey("ResolvingRelease") + ) + assertEquals( + ApkDownloader.DownloadPhase.VerifyingSignature, + ApkDownloader.DownloadPhase.fromKey("VerifyingChecksum") + ) + } + @Test fun `only the transfer claims measurable progress`() { assertTrue(ApkDownloader.DownloadPhase.Transferring.hasMeasurableProgress) diff --git a/app/src/test/kotlin/com/bitchat/android/util/GitHubRateLimitTest.kt b/app/src/test/kotlin/com/bitchat/android/util/GitHubRateLimitTest.kt deleted file mode 100644 index 9f9a7b3e..00000000 --- a/app/src/test/kotlin/com/bitchat/android/util/GitHubRateLimitTest.kt +++ /dev/null @@ -1,164 +0,0 @@ -package com.bitchat.android.util - -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Test - -/** - * Unauthenticated GitHub allows 60 requests an hour per IP, and over Tor that IP is an exit node - * shared with everyone else using it. Reading the rejection correctly is what keeps the app from - * hammering a quota it has already exhausted. - */ -class GitHubRateLimitTest { - - private val now = 1_700_000_000_000L - - @Test - fun `a 403 that still has quota is a permissions error, not a rate limit`() { - assertFalse(GitHubRateLimit.isRateLimited(code = 403, remaining = "42")) - assertNull( - GitHubRateLimit.blockedUntilMillis( - code = 403, - remaining = "42", - resetEpochSeconds = null, - retryAfterSeconds = null, - nowMillis = now, - ) - ) - } - - @Test - fun `a 403 with no quota left blocks until the advertised reset`() { - val resetSeconds = now / 1000 + 900 - - assertTrue(GitHubRateLimit.isRateLimited(code = 403, remaining = "0")) - assertEquals( - resetSeconds * 1000, - GitHubRateLimit.blockedUntilMillis( - code = 403, - remaining = "0", - resetEpochSeconds = resetSeconds.toString(), - retryAfterSeconds = null, - nowMillis = now, - ) - ) - } - - @Test - fun `a 429 is a rate limit even without a remaining header`() { - assertTrue(GitHubRateLimit.isRateLimited(code = 429, remaining = null)) - } - - @Test - fun `a secondary limit is a 403 with Retry-After while quota remains`() { - // GitHub serves secondary limits as 403 + Retry-After without exhausting the - // primary quota, so remaining is still nonzero. - assertTrue( - GitHubRateLimit.isRateLimited( - code = 403, - remaining = "42", - retryAfterSeconds = "60", - ) - ) - } - - @Test - fun `a secondary limit blocks for the Retry-After it advertises`() { - assertEquals( - now + 60_000, - GitHubRateLimit.blockedUntilMillis( - code = 403, - remaining = "42", - resetEpochSeconds = null, - retryAfterSeconds = "60", - nowMillis = now, - ) - ) - } - - @Test - fun `a 403 with an unusable Retry-After stays a permissions error`() { - assertFalse( - GitHubRateLimit.isRateLimited( - code = 403, - remaining = "42", - retryAfterSeconds = "not-a-number", - ) - ) - } - - @Test - fun `Retry-After takes precedence over the reset header`() { - // Retry-After is a delta and is what GitHub sends for secondary limits, which can expire - // sooner than the primary window the reset header describes. - assertEquals( - now + 30_000, - GitHubRateLimit.blockedUntilMillis( - code = 429, - remaining = "0", - resetEpochSeconds = (now / 1000 + 3_000).toString(), - retryAfterSeconds = "30", - nowMillis = now, - ) - ) - } - - @Test - fun `a rejection with no timing headers falls back to a fixed backoff`() { - assertEquals( - now + GitHubRateLimit.DEFAULT_BACKOFF_MILLIS, - GitHubRateLimit.blockedUntilMillis( - code = 429, - remaining = null, - resetEpochSeconds = null, - retryAfterSeconds = null, - nowMillis = now, - ) - ) - } - - @Test - fun `a reset time already in the past falls back rather than unblocking immediately`() { - // A skewed device clock must not turn a real rejection into "retry right now". - assertEquals( - now + GitHubRateLimit.DEFAULT_BACKOFF_MILLIS, - GitHubRateLimit.blockedUntilMillis( - code = 429, - remaining = null, - resetEpochSeconds = (now / 1000 - 500).toString(), - retryAfterSeconds = null, - nowMillis = now, - ) - ) - } - - @Test - fun `an absurd reset time is clamped so the app is never locked out for long`() { - assertEquals( - now + GitHubRateLimit.MAX_BACKOFF_MILLIS, - GitHubRateLimit.blockedUntilMillis( - code = 429, - remaining = null, - resetEpochSeconds = (now / 1000 + 86_400).toString(), - retryAfterSeconds = null, - nowMillis = now, - ) - ) - } - - @Test - fun `unparseable headers fall back instead of throwing`() { - assertEquals( - now + GitHubRateLimit.DEFAULT_BACKOFF_MILLIS, - GitHubRateLimit.blockedUntilMillis( - code = 429, - remaining = null, - resetEpochSeconds = "not-a-number", - retryAfterSeconds = "Wed, 21 Oct 2015 07:28:00 GMT", - nowMillis = now, - ) - ) - } -} diff --git a/app/src/test/kotlin/com/bitchat/android/util/GitHubReleaseClientTest.kt b/app/src/test/kotlin/com/bitchat/android/util/GitHubReleaseClientTest.kt deleted file mode 100644 index e51990d9..00000000 --- a/app/src/test/kotlin/com/bitchat/android/util/GitHubReleaseClientTest.kt +++ /dev/null @@ -1,99 +0,0 @@ -package com.bitchat.android.util - -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner - -@RunWith(RobolectricTestRunner::class) -class GitHubReleaseClientTest { - - @Test - fun `parses universal apk and GitHub asset digest`() { - val digest = "a".repeat(64) - val release = GitHubReleaseClient.parseRelease( - """ - { - "tag_name": "v1.7.6", - "body": "", - "assets": [ - { - "name": "bitchat-android-universal.apk", - "browser_download_url": "https://example.test/bitchat.apk", - "size": 49283072, - "digest": "sha256:$digest" - } - ] - } - """.trimIndent() - ) - - requireNotNull(release) - assertEquals("1.7.6", release.versionName) - assertEquals(49_283_072L, release.universalApkSize) - assertEquals(digest, release.universalApkSha256) - } - - @Test - fun `falls back to checksum in release notes`() { - val digest = "b".repeat(64) - val release = GitHubReleaseClient.parseRelease( - """ - { - "tag_name": "1.7.6", - "body": "bitchat-android-universal.apk: $digest", - "assets": [ - { - "name": "bitchat-android-universal.apk", - "browser_download_url": "https://example.test/bitchat.apk", - "size": 10 - } - ] - } - """.trimIndent() - ) - - assertEquals(digest, requireNotNull(release).universalApkSha256) - } - - @Test - fun `rejects releases without a universal apk`() { - val release = GitHubReleaseClient.parseRelease( - """ - { - "tag_name": "v1.7.6", - "assets": [ - { - "name": "bitchat-android-arm64.apk", - "browser_download_url": "https://example.test/arm64.apk", - "size": 10 - } - ] - } - """.trimIndent() - ) - - assertNull(release) - } - - @Test - fun `compares release versions`() { - val release = GitHubReleaseClient.Release( - tagName = "v1.7.6", - versionName = "1.7.6", - universalApkUrl = "https://example.test/bitchat.apk", - universalApkSha256 = null, - universalApkSize = 10, - universalApkName = "bitchat-android-universal.apk" - ) - - assertTrue(GitHubReleaseClient.isNewerVersion("1.7.5", release)) - assertFalse(GitHubReleaseClient.isNewerVersion("1.7.6", release)) - assertFalse(GitHubReleaseClient.isNewerVersion("1.8.0", release)) - assertTrue(GitHubReleaseClient.isNewerVersion("1.7.4", "1.7.5")) - assertFalse(GitHubReleaseClient.isNewerVersion("1.7.5", "1.7.4")) - } -} From 700e9aa0e50487071209520ff26cc61b9241b465 Mon Sep 17 00:00:00 2001 From: Moe Hamade <69801237+moehamade@users.noreply.github.com> Date: Fri, 31 Jul 2026 03:05:22 +0300 Subject: [PATCH 13/22] fix: carry download failures by stable name, not resource id Codex flagged this on the string-extraction change and it is right. WorkManager keeps failed records in its own database across app updates, and AAPT2 reassigns R.string ids on every build. A failure written by one build and read by the next would resolve its persisted int against a different resource table: wrong string, or NotFoundException, or IllegalFormatException when the placeholder arity no longer matches. The existing zero-check only caught an absent key, not a stale valid one. ApkDownloadFailureReason now names each failure and owns its string, and the boundary carries the enum name. This is the same treatment DownloadPhase.fromKey already gives the phase across the same boundary, including tolerating a name this build no longer has. Co-Authored-By: Claude Opus 5 (1M context) --- .../android/ui/ApkDownloadViewModel.kt | 2 +- .../bitchat/android/util/ApkDownloadSource.kt | 55 ++++++++++++++++--- .../bitchat/android/util/ApkDownloadWorker.kt | 10 ++-- .../com/bitchat/android/util/ApkDownloader.kt | 9 ++- .../android/util/UniversalApkManager.kt | 43 ++++++++------- .../android/util/WorkManagerApkDownloader.kt | 15 ++--- .../android/util/ApkDownloadSourceTest.kt | 31 +++++++++-- 7 files changed, 112 insertions(+), 53 deletions(-) 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 29b066c1..ed8de52a 100644 --- a/app/src/main/java/com/bitchat/android/ui/ApkDownloadViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ApkDownloadViewModel.kt @@ -340,7 +340,7 @@ class ApkDownloadViewModel(application: Application) : AndroidViewModel(applicat */ private fun failureMessage(state: ApkDownloader.DownloadState.Failed): String = getApplication().getString( - state.messageRes, + state.reason.messageRes, *state.messageArgs.toTypedArray() ) diff --git a/app/src/main/java/com/bitchat/android/util/ApkDownloadSource.kt b/app/src/main/java/com/bitchat/android/util/ApkDownloadSource.kt index f8955e60..98e4ca1f 100644 --- a/app/src/main/java/com/bitchat/android/util/ApkDownloadSource.kt +++ b/app/src/main/java/com/bitchat/android/util/ApkDownloadSource.kt @@ -58,17 +58,56 @@ internal object DefaultApkDownloadSources { ) } +/** + * Why a download failed, and which string says so. + * + * Crosses a WorkManager `Data` boundary by [name], never by resource id: WorkManager keeps failed + * records in its own database across app updates, and AAPT2 reassigns `R.string` ids on every + * build, so a persisted id would resolve against the wrong resource table after an update. Same + * reasoning as [ApkDownloader.DownloadPhase.fromKey]. + */ +enum class ApkDownloadFailureReason(@StringRes val messageRes: Int) { + Generic(R.string.prepare_apk_error_generic), + Cancelled(R.string.prepare_apk_download_cancelled), + RateLimitedWithWait(R.string.prepare_apk_error_rate_limited_wait), + RateLimited(R.string.prepare_apk_error_rate_limited), + NoUniversalApk(R.string.prepare_apk_error_no_universal), + HttpFailure(R.string.prepare_apk_error_http), + InsufficientStorage(R.string.prepare_apk_error_storage_needed), + NoSources(R.string.prepare_apk_error_no_sources), + TorConnecting(R.string.prepare_apk_error_tor_connecting), + NoUsableUrl(R.string.prepare_apk_error_no_url), + Unreachable(R.string.prepare_apk_error_unreachable), + InsecureRedirect(R.string.prepare_apk_error_insecure_redirect), + ResumeRejected(R.string.prepare_apk_error_resume_rejected), + Incomplete(R.string.prepare_apk_error_incomplete), + InvalidResume(R.string.prepare_apk_error_invalid_resume), + UntrustedKey(R.string.prepare_apk_error_untrusted_key), + NotUniversal(R.string.prepare_apk_error_not_universal), + ApkUnreadable(R.string.prepare_apk_error_apk_unreadable), + NotBitchat(R.string.prepare_apk_error_not_bitchat), + NoVersion(R.string.prepare_apk_error_no_version), + SourceFailed(R.string.prepare_apk_error_source_failed), + AllSourcesFailed(R.string.prepare_apk_error_all_sources); + + companion object { + /** Work enqueued by an older build may name a reason this build no longer has. */ + fun fromKey(key: String?): ApkDownloadFailureReason = + entries.firstOrNull { it.name == key } ?: Generic + } +} + /** * A host-neutral download failure that tells the worker whether backoff can help. * - * [messageRes] and [messageArgs] name what the user should be told without saying it in any + * [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, - @StringRes val messageRes: Int, + val reason: ApkDownloadFailureReason, val messageArgs: List = emptyList(), val retryable: Boolean, val sourceId: String? = null, @@ -120,10 +159,10 @@ internal object ApkDownloadHttpErrors { } return ApkDownloadException( message = "${source.id} rate limited: HTTP $code, retryAt=$retryAt", - messageRes = if (minutes != null) { - R.string.prepare_apk_error_rate_limited_wait + reason = if (minutes != null) { + ApkDownloadFailureReason.RateLimitedWithWait } else { - R.string.prepare_apk_error_rate_limited + ApkDownloadFailureReason.RateLimited }, messageArgs = listOfNotNull(source.displayName, minutes?.toString()), retryable = false, @@ -136,10 +175,10 @@ internal object ApkDownloadHttpErrors { val retryable = code == 408 || code == 425 || code >= 500 return ApkDownloadException( message = "${source.id} failed: HTTP $code $responseMessage", - messageRes = if (code == 404) { - R.string.prepare_apk_error_no_universal + reason = if (code == 404) { + ApkDownloadFailureReason.NoUniversalApk } else { - R.string.prepare_apk_error_http + ApkDownloadFailureReason.HttpFailure }, messageArgs = if (code == 404) { listOf(source.displayName) 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 710ec1c1..68c8c487 100644 --- a/app/src/main/java/com/bitchat/android/util/ApkDownloadWorker.kt +++ b/app/src/main/java/com/bitchat/android/util/ApkDownloadWorker.kt @@ -36,7 +36,7 @@ class ApkDownloadWorker( const val KEY_PHASE = "phase" const val KEY_VERSION = "version" const val KEY_SIZE_MB = "size_mb" - const val KEY_ERROR_RES = "error_res" + const val KEY_ERROR_REASON = "error_reason" const val KEY_ERROR_ARGS = "error_args" const val KEY_RESUMABLE_PERCENT = "resumable_percent" @@ -109,9 +109,11 @@ class ApkDownloadWorker( // generic one rather than leaking an untranslated exception string to the user. val failure = error as? ApkDownloadException val outputData = Data.Builder() - .putInt( - KEY_ERROR_RES, - failure?.messageRes ?: R.string.prepare_apk_error_generic + // 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, 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 615e2357..b3f342c1 100644 --- a/app/src/main/java/com/bitchat/android/util/ApkDownloader.kt +++ b/app/src/main/java/com/bitchat/android/util/ApkDownloader.kt @@ -1,6 +1,5 @@ package com.bitchat.android.util -import androidx.annotation.StringRes import kotlinx.coroutines.flow.Flow /** @@ -36,12 +35,12 @@ interface ApkDownloader { ) : DownloadState() data class Success(val version: String, val sizeMB: Int) : DownloadState() /** - * [messageRes] and [messageArgs] are resolved by the ViewModel, which has a Context. - * Carrying the ids rather than formatted text keeps the failure localizable all the way - * across the WorkManager boundary. + * [reason] and [messageArgs] are resolved by the ViewModel, which has a Context. + * Carrying the reason rather than formatted text keeps the failure localizable all the + * way across the WorkManager boundary. */ data class Failed( - @StringRes val messageRes: Int, + val reason: ApkDownloadFailureReason, val messageArgs: List, val resumablePercent: Int? ) : DownloadState() 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 a50bfd8f..a20f8419 100644 --- a/app/src/main/java/com/bitchat/android/util/UniversalApkManager.kt +++ b/app/src/main/java/com/bitchat/android/util/UniversalApkManager.kt @@ -4,8 +4,6 @@ import android.content.Context import android.content.pm.PackageManager import android.os.Build import android.util.Log -import androidx.annotation.StringRes -import com.bitchat.android.R import com.bitchat.android.BuildConfig import com.bitchat.android.net.ArtiTorManager import com.bitchat.android.net.OkHttpProvider @@ -168,7 +166,7 @@ class UniversalApkManager( Log.e(TAG, error) throw ApkDownloadException( message = error, - messageRes = R.string.prepare_apk_error_storage_needed, + reason = ApkDownloadFailureReason.InsufficientStorage, messageArgs = listOf(requiredMB.toString(), availableMB.toString()), retryable = false ) @@ -187,7 +185,7 @@ class UniversalApkManager( return@withContext Result.failure( ApkDownloadException( message = "No APK download sources are configured.", - messageRes = R.string.prepare_apk_error_no_sources, + reason = ApkDownloadFailureReason.NoSources, retryable = false ) ) @@ -199,7 +197,7 @@ class UniversalApkManager( return@withContext Result.failure( ApkDownloadException( message = "Tor is still connecting.", - messageRes = R.string.prepare_apk_error_tor_connecting, + reason = ApkDownloadFailureReason.TorConnecting, retryable = true ) ) @@ -341,7 +339,7 @@ class UniversalApkManager( throw lastFailure ?: ApkDownloadException( message = "${source.id} has no usable APK URL.", - messageRes = R.string.prepare_apk_error_no_url, + reason = ApkDownloadFailureReason.NoUsableUrl, messageArgs = listOf(source.displayName), retryable = false ) @@ -409,7 +407,7 @@ class UniversalApkManager( ApkDownloadException( message = "${source.id} could not be reached" + (e.message?.let { ": $it" } ?: "."), - messageRes = R.string.prepare_apk_error_unreachable, + reason = ApkDownloadFailureReason.Unreachable, messageArgs = listOf(source.displayName), retryable = true, sourceId = source.id, @@ -424,7 +422,7 @@ class UniversalApkManager( if (!response.request.url.isHttps) { throw ApkDownloadException( message = "${source.id} redirected to an insecure URL.", - messageRes = R.string.prepare_apk_error_insecure_redirect, + reason = ApkDownloadFailureReason.InsecureRedirect, messageArgs = listOf(source.displayName), retryable = false, sourceId = source.id @@ -441,7 +439,7 @@ class UniversalApkManager( clearPartialDownload() throw ApkDownloadException( message = "${source.id} rejected the saved download position.", - messageRes = R.string.prepare_apk_error_resume_rejected, + reason = ApkDownloadFailureReason.ResumeRejected, messageArgs = listOf(source.displayName), retryable = true, sourceId = source.id, @@ -536,7 +534,7 @@ class UniversalApkManager( if (tempFile.length() > expectedSize) clearPartialDownload() throw ApkDownloadException( message = "${source.id} download ended before all bytes arrived.", - messageRes = R.string.prepare_apk_error_incomplete, + reason = ApkDownloadFailureReason.Incomplete, messageArgs = listOf(source.displayName), retryable = true, sourceId = source.id @@ -562,7 +560,7 @@ class UniversalApkManager( progressFile.delete() return ApkDownloadException( message = "${source.id} returned an invalid resume response.", - messageRes = R.string.prepare_apk_error_invalid_resume, + reason = ApkDownloadFailureReason.InvalidResume, messageArgs = listOf(source.displayName), retryable = true, sourceId = source.id @@ -574,7 +572,7 @@ class UniversalApkManager( clearPartialDownload() throw ApkDownloadException( message = "APK from ${source.id} is not signed by a trusted BitChat release key.", - messageRes = R.string.prepare_apk_error_untrusted_key, + reason = ApkDownloadFailureReason.UntrustedKey, messageArgs = listOf(source.displayName), retryable = false, sourceId = source.id @@ -584,7 +582,7 @@ class UniversalApkManager( clearPartialDownload() throw ApkDownloadException( message = "${source.id} returned an architecture-specific APK.", - messageRes = R.string.prepare_apk_error_not_universal, + reason = ApkDownloadFailureReason.NotUniversal, messageArgs = listOf(source.displayName), retryable = false, sourceId = source.id @@ -594,20 +592,23 @@ class UniversalApkManager( private fun downloadedVersionName(apkFile: File): String { val packageInfo = context.packageManager.getPackageArchiveInfo(apkFile.absolutePath, 0) - ?: invalidDownloadedApk(R.string.prepare_apk_error_apk_unreadable, "unreadable APK") + ?: invalidDownloadedApk(ApkDownloadFailureReason.ApkUnreadable, "unreadable APK") if (packageInfo.packageName != context.packageName) { - invalidDownloadedApk(R.string.prepare_apk_error_not_bitchat, "wrong package") + invalidDownloadedApk(ApkDownloadFailureReason.NotBitchat, "wrong package") } return packageInfo.versionName ?.takeIf { it.isNotBlank() } - ?: invalidDownloadedApk(R.string.prepare_apk_error_no_version, "no version name") + ?: invalidDownloadedApk(ApkDownloadFailureReason.NoVersion, "no version name") } - private fun invalidDownloadedApk(@StringRes messageRes: Int, logReason: String): Nothing { + private fun invalidDownloadedApk( + reason: ApkDownloadFailureReason, + logReason: String + ): Nothing { clearPartialDownload() throw ApkDownloadException( message = "Downloaded APK rejected: $logReason", - messageRes = messageRes, + reason = reason, retryable = false ) } @@ -616,7 +617,7 @@ class UniversalApkManager( if (this is ApkDownloadException) return this return ApkDownloadException( message = "${source.id} download failed" + (message?.let { ": $it" } ?: "."), - messageRes = R.string.prepare_apk_error_source_failed, + reason = ApkDownloadFailureReason.SourceFailed, messageArgs = listOf(source.displayName), retryable = this is IOException, sourceId = source.id, @@ -631,7 +632,7 @@ class UniversalApkManager( if (failures.isEmpty()) { return ApkDownloadException( message = "APK download failed with no recorded source failure.", - messageRes = R.string.prepare_apk_error_generic, + reason = ApkDownloadFailureReason.Generic, retryable = false ) } @@ -640,7 +641,7 @@ class UniversalApkManager( return ApkDownloadException( message = "All configured APK sources failed: " + failures.joinToString(" • ") { it.message ?: "Unknown error" }, - messageRes = R.string.prepare_apk_error_all_sources, + reason = ApkDownloadFailureReason.AllSourcesFailed, retryable = failures.any { it.retryable }, cause = failures.last() ) 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 0b3e597b..214b8118 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 @@ -79,19 +78,17 @@ class WorkManagerApkDownloader(context: Context) : ApkDownloader { ApkDownloader.DownloadState.Success(version, sizeMB) } WorkInfo.State.FAILED -> { - // Work enqueued by an older build carries no resource id; fall back rather than - // resolve 0 and crash. - val messageRes = workInfo.outputData - .getInt(ApkDownloadWorker.KEY_ERROR_RES, 0) - .takeIf { it != 0 } - ?: R.string.prepare_apk_error_generic + // 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( - messageRes = messageRes, + reason = reason, messageArgs = args, resumablePercent = if (resumable >= 0) resumable else null ) @@ -100,7 +97,7 @@ class WorkManagerApkDownloader(context: Context) : ApkDownloader { val partial = apkManager.getPartialDownloadProgress() if (partial != null) { ApkDownloader.DownloadState.Failed( - messageRes = R.string.prepare_apk_download_cancelled, + reason = ApkDownloadFailureReason.Cancelled, messageArgs = emptyList(), resumablePercent = partial ) diff --git a/app/src/test/kotlin/com/bitchat/android/util/ApkDownloadSourceTest.kt b/app/src/test/kotlin/com/bitchat/android/util/ApkDownloadSourceTest.kt index 8caa9c61..db448d59 100644 --- a/app/src/test/kotlin/com/bitchat/android/util/ApkDownloadSourceTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/util/ApkDownloadSourceTest.kt @@ -1,6 +1,5 @@ package com.bitchat.android.util -import com.bitchat.android.R import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull @@ -63,7 +62,7 @@ class ApkDownloadSourceTest { assertFalse(failure.retryable) assertEquals(now + 120_000L, failure.retryAtMillis) // The wait is carried as an argument, not baked into an English sentence. - assertEquals(R.string.prepare_apk_error_rate_limited_wait, failure.messageRes) + assertEquals(ApkDownloadFailureReason.RateLimitedWithWait, failure.reason) assertEquals(listOf(source.displayName, "2"), failure.messageArgs) } @@ -89,13 +88,13 @@ class ApkDownloadSourceTest { ) assertNull(permissionsFailure.retryAtMillis) - assertEquals(R.string.prepare_apk_error_http, permissionsFailure.messageRes) + assertEquals(ApkDownloadFailureReason.HttpFailure, permissionsFailure.reason) assertEquals( listOf(source.displayName, "403", "Forbidden"), permissionsFailure.messageArgs ) assertEquals(now + 300_000L, quotaFailure.retryAtMillis) - assertEquals(R.string.prepare_apk_error_rate_limited_wait, quotaFailure.messageRes) + assertEquals(ApkDownloadFailureReason.RateLimitedWithWait, quotaFailure.reason) } @Test @@ -141,13 +140,35 @@ class ApkDownloadSourceTest { runAttemptCount = 0, ApkDownloadException( message = "invalid APK", - messageRes = R.string.prepare_apk_error_generic, + 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, From 408d1c760a56c070b5901c43743696a2f233f7e2 Mon Sep 17 00:00:00 2001 From: Moe Hamade <69801237+moehamade@users.noreply.github.com> Date: Sat, 1 Aug 2026 15:28:33 +0300 Subject: [PATCH 14/22] fix: name the retry backoff instead of calling it a network wait MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex caught this and it is correct. WorkManager returns a retried request to ENQUEUED for the duration of its backoff whether or not the device is online, and the mapping sent every ENQUEUED record to AwaitingConnectivity. With exponential backoff from 15s over three attempts, a fully connected device claimed "Waiting for network…" in both the row and the notification for roughly 45 seconds. ENQUEUED covers two different waits and the state alone cannot separate them; a non-zero runAttemptCount means the work already ran, so it is the backoff. Adds a Retrying phase for that case, extracted as queuedPhase() so the distinction is testable without a WorkInfo. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/bitchat/android/util/ApkDownloader.kt | 23 +++++++++++++++++++ .../android/util/WorkManagerApkDownloader.kt | 6 +++-- app/src/main/res/values/strings.xml | 1 + .../bitchat/android/util/DownloadPhaseTest.kt | 12 ++++++++++ 4 files changed, 40 insertions(+), 2 deletions(-) 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 b3f342c1..8e16620f 100644 --- a/app/src/main/java/com/bitchat/android/util/ApkDownloader.kt +++ b/app/src/main/java/com/bitchat/android/util/ApkDownloader.kt @@ -54,6 +54,13 @@ interface ApkDownloader { */ 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, @@ -74,10 +81,26 @@ interface ApkDownloader { } } +/** + * 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 -> 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 214b8118..6d482c34 100644 --- a/app/src/main/java/com/bitchat/android/util/WorkManagerApkDownloader.kt +++ b/app/src/main/java/com/bitchat/android/util/WorkManagerApkDownloader.kt @@ -58,11 +58,13 @@ 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.DownloadPhase.AwaitingConnectivity + queuedPhase(workInfo.runAttemptCount) ) } WorkInfo.State.RUNNING -> { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f5fde380..074ce540 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -252,6 +252,7 @@ Downloading… %1$d%% Waiting for network… + Retrying… Selecting download source… Waiting for Tor… Downloading… diff --git a/app/src/test/kotlin/com/bitchat/android/util/DownloadPhaseTest.kt b/app/src/test/kotlin/com/bitchat/android/util/DownloadPhaseTest.kt index f08c009c..7e5c7d6e 100644 --- a/app/src/test/kotlin/com/bitchat/android/util/DownloadPhaseTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/util/DownloadPhaseTest.kt @@ -43,6 +43,18 @@ class DownloadPhaseTest { ) } + @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) From b60b5121aa5836f3e8a7e419a93f81396562aee9 Mon Sep 17 00:00:00 2001 From: Moe Hamade <69801237+moehamade@users.noreply.github.com> Date: Sat, 1 Aug 2026 16:01:08 +0300 Subject: [PATCH 15/22] fix: observe cancellation before promoting a verified APK Codex is right about this one. Cancellation in Kotlin is cooperative, and everything from validateDownloadedApk() through saveMetadata() is plain blocking code with no suspension point. Stopping during the signature check was therefore not observed until after the temp file had been renamed and its metadata written, so the worker committed the APK while WorkManager reported the work cancelled. That also raced onCancelDownload(): its checkStatus() could read the cache before the commit and settle on NotDownloaded, after which the cancelled work maps to Idle and the observer ignores it. The row then advertised "Not ready" with a verified universal APK already in the cache, and tapping it downloaded the same bytes again. One checkpoint after validation, which is the slow step and so the most likely moment to press Stop. The verified temp file is left in place, so the next attempt resumes rather than starting over. Co-Authored-By: Claude Opus 5 (1M context) --- .../java/com/bitchat/android/util/UniversalApkManager.kt | 7 +++++++ 1 file changed, 7 insertions(+) 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 a20f8419..b296d22e 100644 --- a/app/src/main/java/com/bitchat/android/util/UniversalApkManager.kt +++ b/app/src/main/java/com/bitchat/android/util/UniversalApkManager.kt @@ -9,6 +9,7 @@ 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 @@ -217,6 +218,12 @@ class UniversalApkManager( 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" From 2c19d00d17ed41e1a411cd7dd2d3313b9ee927ad Mon Sep 17 00:00:00 2001 From: Moe Hamade <69801237+moehamade@users.noreply.github.com> Date: Sun, 2 Aug 2026 17:44:20 +0300 Subject: [PATCH 16/22] fix(apk): preserve local sharing during update checks --- app/gradle.lockfile | 104 +++---- .../com/bitchat/android/net/OkHttpProvider.kt | 36 ++- .../java/com/bitchat/android/ui/AboutSheet.kt | 118 ++++++-- .../android/ui/ApkDownloadViewModel.kt | 268 ++++++++++++++---- .../android/ui/ApkPrepareRowControls.kt | 22 +- .../bitchat/android/util/ApkDownloadSource.kt | 10 + .../bitchat/android/util/ApkDownloadWorker.kt | 2 + .../com/bitchat/android/util/ApkDownloader.kt | 3 +- .../bitchat/android/util/ApkRateLimitStore.kt | 73 +++++ .../android/util/GitHubReleaseClient.kt | 256 +++++++++++++++++ .../android/util/UniversalApkManager.kt | 62 +++- .../android/util/WorkManagerApkDownloader.kt | 6 +- app/src/main/res/values/strings.xml | 4 + .../android/ui/ApkDownloadViewModelTest.kt | 142 ++++++++++ .../android/ui/PrepareRowTapActionTest.kt | 57 +++- .../android/util/ApkDownloadSourceTest.kt | 27 ++ .../android/util/GitHubReleaseClientTest.kt | 134 +++++++++ gradle/libs.versions.toml | 9 +- gradle/verification-metadata.xml | 8 + 19 files changed, 1181 insertions(+), 160 deletions(-) create mode 100644 app/src/main/java/com/bitchat/android/util/ApkRateLimitStore.kt create mode 100644 app/src/main/java/com/bitchat/android/util/GitHubReleaseClient.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/ui/ApkDownloadViewModelTest.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/util/GitHubReleaseClientTest.kt diff --git a/app/gradle.lockfile b/app/gradle.lockfile index 6cb53d65..986979d0 100644 --- a/app/gradle.lockfile +++ b/app/gradle.lockfile @@ -24,57 +24,55 @@ androidx.camera:camera-lifecycle:1.6.1=debugAndroidTestCompileClasspath,debugAnd androidx.collection:collection-jvm:1.5.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.collection:collection-ktx:1.5.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.collection:collection:1.5.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.animation:animation-android:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.animation:animation-core-android:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.animation:animation-core:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.animation:animation:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.foundation:foundation-android:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.foundation:foundation-layout-android:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.foundation:foundation-layout:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.foundation:foundation:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.material3:material3-android:1.5.0-alpha25=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.material3:material3-ripple-android:1.5.0-alpha25=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.material3:material3-ripple:1.5.0-alpha25=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.material3:material3:1.5.0-alpha25=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.animation:animation-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.animation:animation-core-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.animation:animation-core:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.animation:animation:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.foundation:foundation-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.foundation:foundation-layout-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.foundation:foundation-layout:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.foundation:foundation:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.material3:material3-android:1.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.material3:material3:1.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.compose.material:material-icons-core-android:1.7.8=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath androidx.compose.material:material-icons-core-desktop:1.7.8=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugUnitTestLintChecksClasspath,releaseLintChecksClasspath androidx.compose.material:material-icons-core:1.7.8=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.compose.material:material-icons-extended-android:1.7.8=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath androidx.compose.material:material-icons-extended-desktop:1.7.8=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugUnitTestLintChecksClasspath,releaseLintChecksClasspath androidx.compose.material:material-icons-extended:1.7.8=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.material:material-ripple-android:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.material:material-ripple:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.runtime:runtime-android:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.runtime:runtime-annotation-android:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.runtime:runtime-annotation:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.runtime:runtime-retain-android:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.runtime:runtime-retain:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.runtime:runtime-saveable-android:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.runtime:runtime-saveable:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.runtime:runtime:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.ui:ui-android:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.ui:ui-geometry-android:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.ui:ui-geometry:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.ui:ui-graphics-android:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.ui:ui-graphics:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.ui:ui-test-android:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath -androidx.compose.ui:ui-test-junit4-android:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath -androidx.compose.ui:ui-test-junit4:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath -androidx.compose.ui:ui-test-manifest:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath -androidx.compose.ui:ui-test:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath -androidx.compose.ui:ui-text-android:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.ui:ui-text:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.ui:ui-tooling-android:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath -androidx.compose.ui:ui-tooling-data-android:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath -androidx.compose.ui:ui-tooling-data:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath -androidx.compose.ui:ui-tooling-preview-android:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.ui:ui-tooling-preview:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.ui:ui-tooling:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath -androidx.compose.ui:ui-unit-android:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.ui:ui-unit:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.ui:ui-util-android:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.ui:ui-util:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.ui:ui:1.12.0-beta01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.material:material-ripple-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.material:material-ripple:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.runtime:runtime-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.runtime:runtime-annotation-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.runtime:runtime-annotation:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.runtime:runtime-retain-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.runtime:runtime-retain:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.runtime:runtime-saveable-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.runtime:runtime-saveable:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.runtime:runtime:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-geometry-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-geometry:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-graphics-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-graphics:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-test-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath +androidx.compose.ui:ui-test-junit4-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath +androidx.compose.ui:ui-test-junit4:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath +androidx.compose.ui:ui-test-manifest:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath +androidx.compose.ui:ui-test:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath +androidx.compose.ui:ui-text-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-text:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-tooling-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +androidx.compose.ui:ui-tooling-data-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +androidx.compose.ui:ui-tooling-data:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +androidx.compose.ui:ui-tooling-preview-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-tooling-preview:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-tooling:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +androidx.compose.ui:ui-unit-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-unit:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-util-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-util:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.compose:compose-bom:2026.06.01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.concurrent:concurrent-futures-ktx:1.1.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.concurrent:concurrent-futures-ktx:1.2.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath @@ -87,22 +85,22 @@ androidx.core:core:1.19.0=debugAndroidTestCompileClasspath,debugAndroidTestLintC androidx.cursoradapter:cursoradapter:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.customview:customview-poolingcontainer:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.customview:customview:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.documentfile:documentfile:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.drawerlayout:drawerlayout:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.dynamicanimation:dynamicanimation:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.emoji2:emoji2-views-helper:1.4.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.emoji2:emoji2:1.4.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.exifinterface:exifinterface:1.4.2=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.fragment:fragment:1.5.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.graphics:graphics-path:1.0.1=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.graphics:graphics-shapes-android:1.0.1=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath -androidx.graphics:graphics-shapes-desktop:1.0.1=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugUnitTestLintChecksClasspath,releaseLintChecksClasspath -androidx.graphics:graphics-shapes:1.0.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.interpolator:interpolator:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.legacy:legacy-support-core-utils:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.lifecycle:lifecycle-common-java8:2.11.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.lifecycle:lifecycle-common-jvm:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.lifecycle:lifecycle-common:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.lifecycle:lifecycle-livedata-core-ktx:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-livedata-core-ktx:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.lifecycle:lifecycle-livedata-core:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.lifecycle:lifecycle-livedata:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-livedata:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.lifecycle:lifecycle-process:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.lifecycle:lifecycle-runtime-android:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.lifecycle:lifecycle-runtime-compose-android:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath @@ -118,7 +116,8 @@ androidx.lifecycle:lifecycle-viewmodel-ktx:2.11.0=debugAndroidTestCompileClasspa androidx.lifecycle:lifecycle-viewmodel-savedstate-android:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.lifecycle:lifecycle-viewmodel-savedstate:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.lifecycle:lifecycle-viewmodel:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.loader:loader:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.loader:loader:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.localbroadcastmanager:localbroadcastmanager:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.navigation:navigation-common-android:2.9.8=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.navigation:navigation-common:2.9.8=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.navigation:navigation-compose-android:2.9.8=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath @@ -129,6 +128,7 @@ androidx.navigationevent:navigationevent-android:1.0.0=debugAndroidTestCompileCl androidx.navigationevent:navigationevent-compose-android:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.navigationevent:navigationevent-compose:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.navigationevent:navigationevent:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.print:print:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.profileinstaller:profileinstaller:1.4.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.resourceinspection:resourceinspection-annotation:1.0.1=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.room:room-common:2.6.1=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath @@ -164,6 +164,7 @@ androidx.tracing:tracing-ktx:1.3.0=debugAndroidTestLintChecksClasspath,debugLint androidx.tracing:tracing:1.0.0=debugAndroidTestCompileClasspath androidx.tracing:tracing:1.1.0=debugUnitTestCompileClasspath androidx.tracing:tracing:1.3.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.transition:transition:1.6.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.vectordrawable:vectordrawable-animated:1.1.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.vectordrawable:vectordrawable:1.1.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.versionedparcelable:versionedparcelable:1.1.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath @@ -282,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,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..7f4d7c29 100644 --- a/app/src/main/java/com/bitchat/android/net/OkHttpProvider.kt +++ b/app/src/main/java/com/bitchat/android/net/OkHttpProvider.kt @@ -10,7 +10,17 @@ import java.util.concurrent.atomic.AtomicReference * Centralized OkHttp provider to ensure all network traffic honors Tor settings. */ object OkHttpProvider { - private val httpClientRef = AtomicReference(null) + enum class Route { + DIRECT, + TOR + } + + data class RoutedClient( + val client: OkHttpClient, + val route: Route + ) + + private val httpClientRef = AtomicReference(null) private val wsClientRef = AtomicReference(null) fun reset() { @@ -18,20 +28,30 @@ object OkHttpProvider { wsClientRef.set(null) } - fun httpClient(): OkHttpClient { + fun httpClient(): OkHttpClient = routedHttpClient().client + + /** + * Returns the client and the route it was actually built with as one snapshot. + * + * The selected Tor mode can change while an existing client is still cached. Consumers that + * key cooldowns by network identity must use this value rather than re-reading the preference. + */ + fun routedHttpClient(): RoutedClient { httpClientRef.get()?.let { return it } - val client = baseBuilderForCurrentProxy() + val (builder, route) = baseBuilderForCurrentProxy() + val client = builder .callTimeout(15, TimeUnit.SECONDS) .connectTimeout(10, TimeUnit.SECONDS) .readTimeout(15, TimeUnit.SECONDS) .build() - httpClientRef.set(client) - return client + val routedClient = RoutedClient(client, route) + httpClientRef.set(routedClient) + return routedClient } fun webSocketClient(): OkHttpClient { wsClientRef.get()?.let { return it } - val client = baseBuilderForCurrentProxy() + val client = baseBuilderForCurrentProxy().first .connectTimeout(10, TimeUnit.SECONDS) .readTimeout(0, TimeUnit.SECONDS) .writeTimeout(10, TimeUnit.SECONDS) @@ -40,7 +60,7 @@ object OkHttpProvider { return client } - private fun baseBuilderForCurrentProxy(): OkHttpClient.Builder { + private fun baseBuilderForCurrentProxy(): Pair { val builder = OkHttpClient.Builder() val torProvider = ArtiTorManager.getInstance() val socks: InetSocketAddress? = torProvider.currentSocksAddress() @@ -50,6 +70,6 @@ object OkHttpProvider { val proxy = Proxy(Proxy.Type.SOCKS, socks) builder.proxy(proxy) } - return builder + return builder to if (socks == null) Route.DIRECT else Route.TOR } } 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 7ad0b345..5db34876 100644 --- a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt @@ -620,7 +620,18 @@ fun AboutSheet( val apkViewModel: ApkDownloadViewModel = viewModel() val apkUiState by apkViewModel.state.collectAsStateWithLifecycle() val apkStatus = apkUiState.apkStatus + val releaseStatus = apkUiState.releaseStatus val downloadProgress = apkUiState.downloadProgress + val shareableApk = when (apkStatus) { + is ApkPreparationStatus.Ready -> apkStatus + is ApkPreparationStatus.Downloading -> + apkStatus.shareableFallback + else -> null + } + val availableUpdate = (releaseStatus as? ApkReleaseStatus.Known) + ?.takeIf { it.isNewerThanSharedApk } + val downloadRetryBlocked = apkUiState.downloadRetryAtMillis + ?.let { it > System.currentTimeMillis() } == true // Handle one-shot effects (navigation, toasts, share intents) LaunchedEffect(Unit) { @@ -659,7 +670,11 @@ fun AboutSheet( // does, so the row can never look tappable and do // nothing. .clickable( - enabled = prepareRowTapAction(apkStatus) != null + enabled = prepareRowTapAction( + apkStatus, + releaseStatus, + apkUiState.downloadRetryAtMillis + ) != null ) { apkViewModel.onEvent(ApkUiEvent.PrepareRowClicked) } @@ -667,7 +682,7 @@ fun AboutSheet( verticalAlignment = Alignment.CenterVertically ) { Icon( - imageVector = if (apkStatus is ApkPreparationStatus.Ready) { + imageVector = if (shareableApk != null) { Icons.Default.Share } else { Icons.Default.CloudDownload @@ -683,16 +698,53 @@ fun AboutSheet( modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp) ) { - Text( - text = if (apkStatus is ApkPreparationStatus.Ready) { - stringResource(R.string.prepare_apk_ready_title) - } else { - stringResource(R.string.prepare_apk_title) - }, - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Medium, - color = colorScheme.onSurface - ) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = if (shareableApk != null) { + stringResource(R.string.prepare_apk_ready_title) + } else { + stringResource(R.string.prepare_apk_title) + }, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = colorScheme.onSurface + ) + if (availableUpdate != null) { + TooltipBox( + positionProvider = TooltipDefaults + .rememberTooltipPositionProvider(), + tooltip = { + PlainTooltip { + Text( + stringResource( + R.string.prepare_apk_update_available, + availableUpdate.version + ) + ) + } + }, + state = rememberTooltipState() + ) { + Icon( + imageVector = Icons.Default.Warning, + contentDescription = stringResource( + R.string.prepare_apk_update_warning + ), + tint = colorScheme.tertiary, + modifier = Modifier + .padding(start = 6.dp) + .size(18.dp) + .clickable( + enabled = !downloadRetryBlocked + ) { + apkViewModel.onEvent( + ApkUiEvent.DownloadUniversalClicked + ) + } + ) + } + } + } Text( text = when (val status = apkStatus) { is ApkPreparationStatus.Loading -> stringResource(R.string.checking) @@ -764,7 +816,10 @@ fun AboutSheet( } ) is ApkPreparationStatus.Ready -> { - if (apkStatus.variant == ShareableApkVariant.ARM64) { + if ( + apkStatus.source == + UniversalApkManager.ApkSource.INSTALLED + ) { ApkPrepareRowIconButton( icon = Icons.Default.CloudDownload, description = stringResource( @@ -775,6 +830,7 @@ fun AboutSheet( ApkUiEvent.DownloadUniversalClicked ) }, + enabled = !downloadRetryBlocked, tint = colorScheme.primary ) } else if ( @@ -807,6 +863,11 @@ fun AboutSheet( ApkUiEvent.PrepareRowClicked ) }, + enabled = prepareRowTapAction( + apkStatus, + releaseStatus, + apkUiState.downloadRetryAtMillis + ) != null, tint = colorScheme.primary ) else -> {} @@ -820,16 +881,28 @@ fun AboutSheet( title = { Text( text = stringResource( - R.string.prepare_apk_dialog_title + if (availableUpdate != null) { + R.string.prepare_apk_update_dialog_title + } else { + R.string.prepare_apk_dialog_title + } ), style = MaterialTheme.typography.titleLarge ) }, text = { Text( - text = stringResource( - R.string.prepare_apk_dialog_message_unknown_size - ), + 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 + ) + }, style = MaterialTheme.typography.bodyMedium ) }, @@ -875,7 +948,11 @@ fun AboutSheet( containerColor = colorScheme.error ) ) { - Text("Delete") + Text( + stringResource( + R.string.prepare_apk_button_delete + ) + ) } }, dismissButton = { @@ -887,8 +964,9 @@ fun AboutSheet( ) } - // Show sharing rows only when APK is ready - val canShareAPK = apkStatus is ApkPreparationStatus.Ready + // 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 ed8de52a..a76b8ecb 100644 --- a/app/src/main/java/com/bitchat/android/ui/ApkDownloadViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ApkDownloadViewModel.kt @@ -7,11 +7,16 @@ import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.bitchat.android.R import com.bitchat.android.util.ApkDownloader +import com.bitchat.android.util.AppVersion +import com.bitchat.android.util.GitHubReleaseClient +import com.bitchat.android.util.LatestReleaseProvider import com.bitchat.android.util.ShareableApkVariant import com.bitchat.android.util.UniversalApkManager import com.bitchat.android.util.WorkManagerApkDownloader import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -33,14 +38,32 @@ sealed class ApkPreparationStatus { ) : ApkPreparationStatus() /** [phase] is what the operation is actually doing; only a transfer has a real percentage. */ data class Downloading( - val phase: ApkDownloader.DownloadPhase = ApkDownloader.DownloadPhase.SelectingSource + val phase: ApkDownloader.DownloadPhase = ApkDownloader.DownloadPhase.SelectingSource, + val shareableFallback: Ready? = null ) : ApkPreparationStatus() - data class Resumable(val progressPercent: Int, val message: String) : ApkPreparationStatus() - data class Error(val message: String) : ApkPreparationStatus() + data class Resumable( + val progressPercent: Int, + val message: String, + val retryAtMillis: Long? = null + ) : ApkPreparationStatus() + data class Error(val message: String, val retryAtMillis: Long? = null) : ApkPreparationStatus() +} + +sealed class ApkReleaseStatus { + object Unknown : ApkReleaseStatus() + object Checking : ApkReleaseStatus() + data class Known( + val version: String, + val sizeMB: Int, + val isNewerThanSharedApk: Boolean, + val fromStaleCache: Boolean + ) : ApkReleaseStatus() } data class ApkUiState( val apkStatus: ApkPreparationStatus = ApkPreparationStatus.Loading, + val releaseStatus: ApkReleaseStatus = ApkReleaseStatus.Unknown, + val downloadRetryAtMillis: Long? = null, val downloadProgress: Int = 0, val showPrepareDialog: Boolean = false, val showDeleteDialog: Boolean = false, @@ -80,12 +103,24 @@ internal enum class PrepareRowTapAction { * and has to stay in step with them. Deriving both the tap handler and the row's `enabled` flag * from this one function keeps the row from looking clickable while doing nothing. */ -internal fun prepareRowTapAction(status: ApkPreparationStatus): PrepareRowTapAction? = when { +internal fun prepareRowTapAction( + status: ApkPreparationStatus, + releaseStatus: ApkReleaseStatus = ApkReleaseStatus.Unknown, + downloadRetryAtMillis: Long? = null, + nowMillis: Long = System.currentTimeMillis() +): PrepareRowTapAction? = when { + downloadRetryAtMillis != null && downloadRetryAtMillis > nowMillis -> null status is ApkPreparationStatus.NotDownloaded -> PrepareRowTapAction.OpenPrepareDialog // Consent was already given for these; resuming straight away avoids a redundant prompt. - status is ApkPreparationStatus.Resumable -> PrepareRowTapAction.StartDownload - status is ApkPreparationStatus.Error -> PrepareRowTapAction.StartDownload - status is ApkPreparationStatus.Ready && status.variant == ShareableApkVariant.ARM64 -> + status is ApkPreparationStatus.Resumable && + (status.retryAtMillis == null || status.retryAtMillis <= nowMillis) -> + PrepareRowTapAction.StartDownload + status is ApkPreparationStatus.Error && + (status.retryAtMillis == null || status.retryAtMillis <= nowMillis) -> + PrepareRowTapAction.StartDownload + status is ApkPreparationStatus.Ready && + (status.source == UniversalApkManager.ApkSource.INSTALLED || + (releaseStatus as? ApkReleaseStatus.Known)?.isNewerThanSharedApk == true) -> PrepareRowTapAction.OpenPrepareDialog else -> null } @@ -102,21 +137,33 @@ sealed class ApkUiEffect { * ViewModel for APK download/status/share logic following MVI pattern. * UI sends [ApkUiEvent], observes [ApkUiState], and collects [ApkUiEffect]. */ -class ApkDownloadViewModel(application: Application) : AndroidViewModel(application) { +class ApkDownloadViewModel internal constructor( + application: Application, + private val apkManager: UniversalApkManager, + private val downloader: ApkDownloader, + private val latestReleaseProvider: LatestReleaseProvider +) : AndroidViewModel(application) { + + constructor(application: Application) : this( + application = application, + apkManager = UniversalApkManager(application), + downloader = WorkManagerApkDownloader(application), + latestReleaseProvider = GitHubReleaseClient(application) + ) companion object { private const val TAG = "ApkDownloadVM" } - private val apkManager = UniversalApkManager(application) - private val downloader: ApkDownloader = WorkManagerApkDownloader(application) - private val _state = MutableStateFlow(ApkUiState()) val state: StateFlow = _state.asStateFlow() private val _effect = Channel(Channel.BUFFERED) val effect = _effect.receiveAsFlow() + private var metadataRefreshJob: Job? = null + private var retryUnlockJob: Job? = null + init { observeDownloader() } @@ -140,7 +187,13 @@ class ApkDownloadViewModel(application: Application) : AndroidViewModel(applicat } private fun onPrepareRowClicked() { - when (prepareRowTapAction(_state.value.apkStatus)) { + when ( + prepareRowTapAction( + _state.value.apkStatus, + _state.value.releaseStatus, + _state.value.downloadRetryAtMillis + ) + ) { PrepareRowTapAction.OpenPrepareDialog -> _state.update { it.copy(showPrepareDialog = true) } PrepareRowTapAction.StartDownload -> startDownload() @@ -154,9 +207,14 @@ class ApkDownloadViewModel(application: Application) : AndroidViewModel(applicat } private fun onDownloadUniversalClicked() { + if (_state.value.downloadRetryAtMillis?.let { it > System.currentTimeMillis() } == true) { + return + } val status = _state.value.apkStatus + val hasUpdate = (_state.value.releaseStatus as? ApkReleaseStatus.Known) + ?.isNewerThanSharedApk == true if (status is ApkPreparationStatus.Ready && - status.variant == ShareableApkVariant.ARM64 + (status.source == UniversalApkManager.ApkSource.INSTALLED || hasUpdate) ) { _state.update { it.copy(showPrepareDialog = true) } } @@ -212,20 +270,39 @@ class ApkDownloadViewModel(application: Application) : AndroidViewModel(applicat private fun onCancelDownload() { downloader.cancelDownload() - // Leave Downloading immediately. The cancelled job maps to Idle, which - // the observer intentionally ignores because local status is resolved below. + val fallback = (_state.value.apkStatus as? ApkPreparationStatus.Downloading) + ?.shareableFallback _state.update { - it.copy(apkStatus = ApkPreparationStatus.Loading, downloadProgress = 0) + it.copy( + apkStatus = fallback ?: ApkPreparationStatus.Loading, + downloadProgress = 0 + ) } - - checkStatus() + if (fallback == null) checkStatus() } private fun startDownload() { + val current = _state.value.apkStatus + val stateRetryAt = _state.value.downloadRetryAtMillis + val retryAt = when (current) { + is ApkPreparationStatus.Resumable -> current.retryAtMillis + is ApkPreparationStatus.Error -> current.retryAtMillis + else -> null + } + if ((stateRetryAt ?: retryAt)?.let { it > System.currentTimeMillis() } == true) return + + val fallback = when (current) { + is ApkPreparationStatus.Ready -> current + is ApkPreparationStatus.Downloading -> current.shareableFallback + else -> null + } val partial = apkManager.getPartialDownloadProgress() _state.update { it.copy( - apkStatus = ApkPreparationStatus.Downloading(), + apkStatus = ApkPreparationStatus.Downloading( + shareableFallback = fallback + ), + downloadRetryAtMillis = null, downloadProgress = partial ?: 0 ) } @@ -251,6 +328,36 @@ class ApkDownloadViewModel(application: Application) : AndroidViewModel(applicat current.copy(apkStatus = resolvedStatus, downloadProgress = 0) } } + // Local availability is resolved and published before this independent network task + // starts. Metadata can add a freshness warning, but can never hide sharing. + refreshReleaseMetadata() + } + } + + private fun refreshReleaseMetadata() { + if (metadataRefreshJob?.isActive == true) return + metadataRefreshJob = viewModelScope.launch { + _state.update { it.copy(releaseStatus = ApkReleaseStatus.Checking) } + latestReleaseProvider.latestRelease() + .onSuccess { snapshot -> + val shared = shareableReady(_state.value.apkStatus) + _state.update { + it.copy( + releaseStatus = ApkReleaseStatus.Known( + version = snapshot.release.versionName, + sizeMB = (snapshot.release.universalApkSize / 1024 / 1024).toInt(), + isNewerThanSharedApk = shared?.let { ready -> + AppVersion.isNewer(ready.version, snapshot.release.versionName) + } ?: false, + fromStaleCache = snapshot.isStale + ) + ) + } + } + .onFailure { + // Metadata is an optional enhancement. Keep the locally resolved APK state. + _state.update { it.copy(releaseStatus = ApkReleaseStatus.Unknown) } + } } } @@ -259,12 +366,28 @@ class ApkDownloadViewModel(application: Application) : AndroidViewModel(applicat downloader.downloadState.collect { downloadState -> when (downloadState) { is ApkDownloader.DownloadState.Idle -> { - // Don't overwrite — status set by checkStatus() + val downloading = _state.value.apkStatus as? ApkPreparationStatus.Downloading + if (downloading != null) { + val fallback = downloading.shareableFallback + _state.update { + it.copy( + apkStatus = fallback ?: ApkPreparationStatus.Loading, + downloadProgress = 0 + ) + } + if (fallback == null) checkStatus() + } } is ApkDownloader.DownloadState.Downloading -> { _state.update { + val fallback = (it.apkStatus as? ApkPreparationStatus.Downloading) + ?.shareableFallback + ?: (it.apkStatus as? ApkPreparationStatus.Ready) it.copy( - apkStatus = ApkPreparationStatus.Downloading(downloadState.phase), + apkStatus = ApkPreparationStatus.Downloading( + phase = downloadState.phase, + shareableFallback = fallback + ), downloadProgress = downloadState.progressPercent ) } @@ -272,31 +395,33 @@ 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.DOWNLOADED, - variant = info?.variant ?: ShareableApkVariant.UNIVERSAL - ), + apkStatus = ready, + releaseStatus = releaseStatusFor(ready, it.releaseStatus), + downloadRetryAtMillis = null, downloadProgress = 100 ) } } is ApkDownloader.DownloadState.Failed -> { - val localArm64 = apkManager.getCachedApkInfo() - ?.takeIf { it.variant == ShareableApkVariant.ARM64 } - if (localArm64 != null) { + val fallback = (_state.value.apkStatus as? ApkPreparationStatus.Downloading) + ?.shareableFallback + ?: apkManager.getCachedApkInfo()?.toReady() + if (fallback != null) { _state.update { it.copy( - apkStatus = ApkPreparationStatus.Ready( - version = localArm64.version, - sizeMB = (localArm64.size / 1024 / 1024).toInt(), - source = localArm64.source, - variant = localArm64.variant - ) + apkStatus = fallback, + releaseStatus = releaseStatusFor(fallback, it.releaseStatus), + downloadRetryAtMillis = downloadState.retryAtMillis, + downloadProgress = 0 ) } _effect.send(ApkUiEffect.ShowToast(failureMessage(downloadState))) @@ -307,17 +432,24 @@ class ApkDownloadViewModel(application: Application) : AndroidViewModel(applicat it.copy( apkStatus = ApkPreparationStatus.Resumable( progressPercent = downloadState.resumablePercent, - message = message + message = message, + retryAtMillis = downloadState.retryAtMillis ), + downloadRetryAtMillis = downloadState.retryAtMillis, downloadProgress = downloadState.resumablePercent ) } else { it.copy( - apkStatus = ApkPreparationStatus.Error(message) + apkStatus = ApkPreparationStatus.Error( + message, + downloadState.retryAtMillis + ), + downloadRetryAtMillis = downloadState.retryAtMillis ) } } } + scheduleRetryUnlock(downloadState.retryAtMillis) } } } @@ -339,21 +471,57 @@ class ApkDownloadViewModel(application: Application) : AndroidViewModel(applicat * this resolves it, so the message follows the device locale rather than the worker's. */ private fun failureMessage(state: ApkDownloader.DownloadState.Failed): String = - getApplication().getString( - state.reason.messageRes, - *state.messageArgs.toTypedArray() - ) + runCatching { + getApplication().getString( + state.reason.messageRes, + *state.messageArgs.toTypedArray() + ) + }.getOrElse { + getString(R.string.prepare_apk_error_generic) + } + + private fun scheduleRetryUnlock(retryAtMillis: Long?) { + if (retryAtMillis == null) return + retryUnlockJob?.cancel() + retryUnlockJob = viewModelScope.launch { + delay((retryAtMillis - System.currentTimeMillis()).coerceAtLeast(0L)) + _state.update { + val unlocked = when (val status = it.apkStatus) { + is ApkPreparationStatus.Resumable -> status.copy(retryAtMillis = null) + is ApkPreparationStatus.Error -> status.copy(retryAtMillis = null) + else -> status + } + it.copy(apkStatus = unlocked, downloadRetryAtMillis = null) + } + } + } + + private fun shareableReady(status: ApkPreparationStatus): ApkPreparationStatus.Ready? = + when (status) { + is ApkPreparationStatus.Ready -> status + is ApkPreparationStatus.Downloading -> status.shareableFallback + else -> null + } + + private fun releaseStatusFor( + ready: ApkPreparationStatus.Ready, + releaseStatus: ApkReleaseStatus + ): ApkReleaseStatus = (releaseStatus as? ApkReleaseStatus.Known)?.let { + it.copy(isNewerThanSharedApk = AppVersion.isNewer(ready.version, it.version)) + } ?: releaseStatus + + private fun UniversalApkManager.ApkInfo.toReady() = ApkPreparationStatus.Ready( + version = version, + sizeMB = (size / 1024 / 1024).toInt(), + source = source, + variant = variant + ) private suspend fun resolveApkStatus(): ApkPreparationStatus = withContext(Dispatchers.IO) { try { val info = apkManager.prepareLocalApkInfo() if (info != null) { - ApkPreparationStatus.Ready( - version = info.version, - sizeMB = (info.size / 1024 / 1024).toInt(), - source = info.source, - variant = info.variant - ) + info.toReady() } else { val partial = apkManager.getPartialDownloadProgress() if (partial != null) { diff --git a/app/src/main/java/com/bitchat/android/ui/ApkPrepareRowControls.kt b/app/src/main/java/com/bitchat/android/ui/ApkPrepareRowControls.kt index b79060a9..7e20edda 100644 --- a/app/src/main/java/com/bitchat/android/ui/ApkPrepareRowControls.kt +++ b/app/src/main/java/com/bitchat/android/ui/ApkPrepareRowControls.kt @@ -4,10 +4,9 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.LinearWavyProgressIndicator +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.PlainTooltip import androidx.compose.material3.Text @@ -26,10 +25,9 @@ import androidx.compose.ui.unit.dp * This sits under the row's subtitle so the trailing slot is free to hold a single control. Which * of the three renderings applies is decided entirely by [status]; the caller does not choose. * - * The wave is not decoration. A moving wave means bytes are moving, so a stalled download draws a - * flat line at the fraction it reached rather than a bar indistinguishable from a live one. + * 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. */ -@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable internal fun ApkDownloadProgressBar( status: ApkPreparationStatus, @@ -46,19 +44,17 @@ internal fun ApkDownloadProgressBar( status is ApkPreparationStatus.Downloading && status.phase.hasMeasurableProgress && progressPercent > 0 -> - LinearWavyProgressIndicator( + LinearProgressIndicator( progress = { progressPercent.asProgressFraction() }, modifier = barModifier ) status is ApkPreparationStatus.Downloading -> - LinearWavyProgressIndicator(modifier = barModifier) + LinearProgressIndicator(modifier = barModifier) - // Flat: how far it got, and that it is not getting further on its own. status is ApkPreparationStatus.Resumable -> - LinearWavyProgressIndicator( + LinearProgressIndicator( progress = { status.progressPercent.asProgressFraction() }, - amplitude = { 0f }, modifier = barModifier ) } @@ -84,22 +80,24 @@ internal fun ApkPrepareRowIconButton( description: String, onClick: () -> Unit, modifier: Modifier = Modifier, + enabled: Boolean = true, tint: Color = MaterialTheme.colorScheme.onSurfaceVariant ) { TooltipBox( - positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(), + 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 = tint, + 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 index 98e4ca1f..cf0f5e90 100644 --- a/app/src/main/java/com/bitchat/android/util/ApkDownloadSource.kt +++ b/app/src/main/java/com/bitchat/android/util/ApkDownloadSource.kt @@ -2,6 +2,8 @@ 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 @@ -244,6 +246,14 @@ internal data class ContentRange( 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) 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 68c8c487..ade699b5 100644 --- a/app/src/main/java/com/bitchat/android/util/ApkDownloadWorker.kt +++ b/app/src/main/java/com/bitchat/android/util/ApkDownloadWorker.kt @@ -39,6 +39,7 @@ class ApkDownloadWorker( const val KEY_ERROR_REASON = "error_reason" const val KEY_ERROR_ARGS = "error_args" const val KEY_RESUMABLE_PERCENT = "resumable_percent" + const val KEY_RETRY_AT = "retry_at" private const val CHANNEL_ID = "apk_download" private const val NOTIFICATION_ID = 4201 @@ -120,6 +121,7 @@ class ApkDownloadWorker( failure?.messageArgs.orEmpty().toTypedArray() ) .putInt(KEY_RESUMABLE_PERCENT, partial ?: -1) + .apply { failure?.retryAtMillis?.let { putLong(KEY_RETRY_AT, it) } } .build() Result.failure(outputData) } 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 8e16620f..75cd6b14 100644 --- a/app/src/main/java/com/bitchat/android/util/ApkDownloader.kt +++ b/app/src/main/java/com/bitchat/android/util/ApkDownloader.kt @@ -42,7 +42,8 @@ interface ApkDownloader { data class Failed( val reason: ApkDownloadFailureReason, val messageArgs: List, - val resumablePercent: Int? + val resumablePercent: Int?, + val retryAtMillis: Long? = null ) : DownloadState() } 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..0a0f6a0f --- /dev/null +++ b/app/src/main/java/com/bitchat/android/util/ApkRateLimitStore.kt @@ -0,0 +1,73 @@ +package com.bitchat.android.util + +import android.content.Context +import androidx.core.content.edit +import com.bitchat.android.net.OkHttpProvider +import kotlin.math.ceil + +/** Persistent, route-specific cooldowns for APK-related network requests. */ +internal class ApkRateLimitStore(context: Context) { + companion object { + private const val PREFS_NAME = "apk_network_cooldowns" + private const val FALLBACK_COOLDOWN_MILLIS = 60_000L + private const val MAX_COOLDOWN_MILLIS = 60 * 60_000L + } + + private val preferences = context.applicationContext.getSharedPreferences( + PREFS_NAME, + Context.MODE_PRIVATE + ) + + fun retryAtMillis( + scope: String, + route: OkHttpProvider.Route, + nowMillis: Long = System.currentTimeMillis() + ): Long? { + val key = key(scope, route) + val deadline = preferences.getLong(key, 0L) + if (deadline <= nowMillis) { + if (deadline != 0L) preferences.edit { remove(key) } + return null + } + return deadline + } + + fun recordRateLimit( + scope: String, + route: OkHttpProvider.Route, + serverRetryAtMillis: Long?, + nowMillis: Long = System.currentTimeMillis() + ): Long { + val fallback = nowMillis + FALLBACK_COOLDOWN_MILLIS + val maximum = nowMillis + MAX_COOLDOWN_MILLIS + val deadline = (serverRetryAtMillis ?: fallback).coerceIn(nowMillis + 1_000L, maximum) + // Persist before reporting the failure so a process restart cannot bypass the cooldown. + preferences.edit(commit = true) { putLong(key(scope, route), deadline) } + return deadline + } + + fun clear(scope: String, route: OkHttpProvider.Route) { + preferences.edit { remove(key(scope, route)) } + } + + fun blockedException( + source: ApkDownloadSource, + retryAtMillis: Long, + nowMillis: Long = System.currentTimeMillis() + ): ApkDownloadException { + val minutes = ceil( + (retryAtMillis - nowMillis).coerceAtLeast(1L) / 60_000.0 + ).toLong() + return ApkDownloadException( + message = "${source.id} is in a persisted rate-limit cooldown until $retryAtMillis", + reason = ApkDownloadFailureReason.RateLimitedWithWait, + messageArgs = listOf(source.displayName, minutes.toString()), + retryable = false, + sourceId = source.id, + retryAtMillis = retryAtMillis + ) + } + + private fun key(scope: String, route: OkHttpProvider.Route): String = + "${scope}_${route.name.lowercase()}" +} diff --git a/app/src/main/java/com/bitchat/android/util/GitHubReleaseClient.kt b/app/src/main/java/com/bitchat/android/util/GitHubReleaseClient.kt new file mode 100644 index 00000000..dbb27c5d --- /dev/null +++ b/app/src/main/java/com/bitchat/android/util/GitHubReleaseClient.kt @@ -0,0 +1,256 @@ +package com.bitchat.android.util + +import android.content.Context +import android.util.Log +import androidx.core.content.edit +import com.bitchat.android.net.ArtiTorManager +import com.bitchat.android.net.OkHttpProvider +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.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 + +internal interface LatestReleaseProvider { + suspend fun latestRelease(): Result +} + +/** 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" + + 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" + ) + + internal fun parseRelease(jsonString: String): Release? = runCatching { + val json = JSONObject(jsonString) + 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( + versionName = versionName, + universalApkSize = asset.optLong("size", 0L), + universalApkUrl = url, + universalApkName = name + ) + } + } + null + }.getOrNull() + } + + private val appContext = context.applicationContext + private val preferences = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + private val mutex = Mutex() + + 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)) + } + + if (!awaitRoute()) return@withLock cached.orRouteFailure() + + val routeSnapshot = routedClient() + rateLimits.retryAtMillis(RATE_LIMIT_SCOPE, routeSnapshot.route, now)?.let { deadline -> + return@withLock cached.orFailure( + rateLimits.blockedException(SOURCE, deadline, now) + ) + } + + 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() + + try { + client.newCall(request).awaitResponse().use { response -> + if (apiUrl.startsWith("https://") && !response.request.url.isHttps) { + return@withLock cached.orFailure( + IOException("GitHub redirected release metadata to an insecure URL") + ) + } + if (response.code == 304 && cached != null) { + val refreshed = cached.copy(fetchedAtMillis = now) + writeCache(refreshed) + rateLimits.clear(RATE_LIMIT_SCOPE, routeSnapshot.route) + return@withLock Result.success( + ReleaseSnapshot(refreshed.release, isStale = false) + ) + } + if (!response.isSuccessful) { + val failure = ApkDownloadHttpErrors.fromResponse( + source = SOURCE, + code = response.code, + responseMessage = response.message, + retryAfter = response.header("Retry-After"), + rateLimitRemaining = response.header("X-RateLimit-Remaining"), + rateLimitResetEpochSeconds = response.header("X-RateLimit-Reset"), + nowMillis = now + ) + val persistedFailure = if ( + failure.reason == ApkDownloadFailureReason.RateLimited || + failure.reason == ApkDownloadFailureReason.RateLimitedWithWait + ) { + val deadline = rateLimits.recordRateLimit( + RATE_LIMIT_SCOPE, + routeSnapshot.route, + failure.retryAtMillis, + now + ) + rateLimits.blockedException(SOURCE, deadline, now) + } else { + failure + } + return@withLock cached.orFailure(persistedFailure) + } + + val rawBody = response.body.string() + val release = parseRelease(rawBody) + ?: return@withLock cached.orFailure( + IOException("GitHub's latest release has no universal APK asset") + ) + val entry = CachedRelease( + release = release, + etag = response.header("ETag"), + fetchedAtMillis = now + ) + writeCache(entry) + rateLimits.clear(RATE_LIMIT_SCOPE, routeSnapshot.route) + Result.success(ReleaseSnapshot(release, isStale = false)) + } + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + Log.w(TAG, "Could not refresh release metadata; using cache when available", error) + cached.orFailure(error) + } + } + } + + 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 versionName: String, + val universalApkSize: Long, + val universalApkUrl: String, + val universalApkName: String + ) + + 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 b296d22e..df44ab83 100644 --- a/app/src/main/java/com/bitchat/android/util/UniversalApkManager.kt +++ b/app/src/main/java/com/bitchat/android/util/UniversalApkManager.kt @@ -56,14 +56,19 @@ class UniversalApkManager( 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. @@ -228,6 +233,9 @@ class UniversalApkManager( 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() @@ -360,6 +368,13 @@ class UniversalApkManager( 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, now) + } + 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. @@ -374,8 +389,10 @@ class UniversalApkManager( .build() downloadToTempFile( - call = downloadClient.newCall(request), + call = routedClient.client.newCall(request), source = source, + rateLimitScope = rateLimitScope, + route = routedClient.route, endpointUrl = endpointUrl, tempFile = tempFile, existingBytes = existingBytes, @@ -391,6 +408,8 @@ class UniversalApkManager( private suspend fun downloadToTempFile( call: Call, source: ApkDownloadSource, + rateLimitScope: String, + route: OkHttpProvider.Route, endpointUrl: String, tempFile: File, existingBytes: Long, @@ -454,7 +473,7 @@ class UniversalApkManager( ) } if (!response.isSuccessful) { - throw ApkDownloadHttpErrors.fromResponse( + val failure = ApkDownloadHttpErrors.fromResponse( source = source, code = response.code, responseMessage = response.message, @@ -463,8 +482,23 @@ class UniversalApkManager( rateLimitResetEpochSeconds = response.header("X-RateLimit-Reset") ) + if (failure.reason == ApkDownloadFailureReason.RateLimited || + failure.reason == ApkDownloadFailureReason.RateLimitedWithWait + ) { + val now = System.currentTimeMillis() + val deadline = rateLimits.recordRateLimit( + rateLimitScope, + route, + failure.retryAtMillis, + now + ) + throw rateLimits.blockedException(source, deadline, now) + } + throw failure } + rateLimits.clear(rateLimitScope, route) + val body = response.body val range = if (response.code == 206) { parseContentRange(response.header("Content-Range")) @@ -491,6 +525,12 @@ class UniversalApkManager( 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( @@ -511,7 +551,9 @@ class UniversalApkManager( } 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 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 6d482c34..50bb2b1c 100644 --- a/app/src/main/java/com/bitchat/android/util/WorkManagerApkDownloader.kt +++ b/app/src/main/java/com/bitchat/android/util/WorkManagerApkDownloader.kt @@ -92,7 +92,11 @@ class WorkManagerApkDownloader(context: Context) : ApkDownloader { ApkDownloader.DownloadState.Failed( reason = reason, messageArgs = args, - resumablePercent = if (resumable >= 0) resumable else null + resumablePercent = if (resumable >= 0) resumable else null, + retryAtMillis = workInfo.outputData.getLong( + ApkDownloadWorker.KEY_RETRY_AT, + 0L + ).takeIf { it > 0L } ) } WorkInfo.State.CANCELLED -> { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 074ce540..18925640 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -246,6 +246,8 @@ 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 @@ -262,7 +264,9 @@ Delete Version %1$s • %2$d MB Download Universal APK? + 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… 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..6b4998c7 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/ui/ApkDownloadViewModelTest.kt @@ -0,0 +1,142 @@ +package com.bitchat.android.ui + +import android.app.Application +import androidx.test.core.app.ApplicationProvider +import com.bitchat.android.util.ApkDownloader +import com.bitchat.android.util.GitHubReleaseClient +import com.bitchat.android.util.LatestReleaseProvider +import com.bitchat.android.util.ShareableApkVariant +import com.bitchat.android.util.UniversalApkManager +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.delay +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withTimeout +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import java.io.File + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +class ApkDownloadViewModelTest { + private lateinit var application: Application + + @Before + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + application = ApplicationProvider.getApplicationContext() + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `local apk is shareable before release metadata starts`() = runTest { + val manager = managerWithLocalApk() + val downloader = FakeDownloader() + lateinit var viewModel: ApkDownloadViewModel + val statusWhenMetadataStarted = CompletableDeferred() + 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 metadata = object : LatestReleaseProvider { + override suspend fun latestRelease(): Result = + Result.failure(IllegalStateException("synthetic offline response")) + } + val viewModel = ApkDownloadViewModel(application, manager, downloader, metadata) + + viewModel.onEvent(ApkUiEvent.CheckStatus) + val originalReady = awaitReady(viewModel) + viewModel.onEvent(ApkUiEvent.PrepareRowClicked) + viewModel.onEvent(ApkUiEvent.ConfirmDownload) + + val downloading = viewModel.state.value.apkStatus as ApkPreparationStatus.Downloading + assertSame(originalReady, downloading.shareableFallback) + assertEquals(1, downloader.startCount) + + downloader.emit(ApkDownloader.DownloadState.Idle) + val restored = awaitReady(viewModel) + assertEquals(originalReady, restored) + } + + private suspend fun managerWithLocalApk(): UniversalApkManager { + val manager = mock() + whenever(manager.prepareLocalApkInfo()).thenReturn( + UniversalApkManager.ApkInfo( + version = "1.7.5", + downloadDate = 1_700_000_000_000L, + size = 12L * 1024 * 1024, + file = File(application.cacheDir, "synthetic-shareable.apk"), + source = UniversalApkManager.ApkSource.INSTALLED, + variant = ShareableApkVariant.UNIVERSAL, + downloadSourceId = null + ) + ) + whenever(manager.getPartialDownloadProgress()).thenReturn(null) + return manager + } + + private suspend fun awaitReady( + viewModel: ApkDownloadViewModel + ): ApkPreparationStatus.Ready = withTimeout(5_000L) { + while (true) { + (viewModel.state.value.apkStatus as? ApkPreparationStatus.Ready)?.let { return@withTimeout it } + delay(1L) + } + error("unreachable") + } + + private class FakeDownloader : ApkDownloader { + private val mutableState = MutableStateFlow( + ApkDownloader.DownloadState.Idle + ) + override val downloadState = mutableState.asStateFlow() + var startCount = 0 + + override fun startDownload() { + startCount += 1 + mutableState.value = ApkDownloader.DownloadState.Downloading( + progressPercent = 0, + phase = ApkDownloader.DownloadPhase.SelectingSource + ) + } + + override fun cancelDownload() = Unit + + fun emit(state: ApkDownloader.DownloadState) { + mutableState.value = state + } + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/ui/PrepareRowTapActionTest.kt b/app/src/test/kotlin/com/bitchat/android/ui/PrepareRowTapActionTest.kt index 07b4ed23..d1e3a431 100644 --- a/app/src/test/kotlin/com/bitchat/android/ui/PrepareRowTapActionTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/ui/PrepareRowTapActionTest.kt @@ -34,8 +34,15 @@ class PrepareRowTapActionTest { } @Test - fun `nothing left to fetch means the row is inert`() { - assertNull(prepareRowTapAction(ready(ShareableApkVariant.UNIVERSAL))) + 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) @@ -43,6 +50,22 @@ class PrepareRowTapActionTest { ) } + @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( @@ -72,4 +95,34 @@ class PrepareRowTapActionTest { } assertNull(prepareRowTapAction(ApkPreparationStatus.Loading)) } + + @Test + fun `a persisted rate limit disables manual retry until its deadline`() { + val retryAt = 50_000L + assertNull( + prepareRowTapAction( + ApkPreparationStatus.Error("Rate limited", retryAtMillis = retryAt), + nowMillis = retryAt - 1 + ) + ) + assertEquals( + PrepareRowTapAction.StartDownload, + prepareRowTapAction( + ApkPreparationStatus.Error("Rate limited", retryAtMillis = retryAt), + nowMillis = retryAt + ) + ) + } + + @Test + fun `rate limit also disables optional update while an apk remains shareable`() { + val retryAt = 50_000L + assertNull( + prepareRowTapAction( + ready(ShareableApkVariant.UNIVERSAL), + downloadRetryAtMillis = retryAt, + nowMillis = retryAt - 1 + ) + ) + } } diff --git a/app/src/test/kotlin/com/bitchat/android/util/ApkDownloadSourceTest.kt b/app/src/test/kotlin/com/bitchat/android/util/ApkDownloadSourceTest.kt index db448d59..0b0eaf03 100644 --- a/app/src/test/kotlin/com/bitchat/android/util/ApkDownloadSourceTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/util/ApkDownloadSourceTest.kt @@ -4,11 +4,16 @@ 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", @@ -120,6 +125,28 @@ class ApkDownloadSourceTest { 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")) diff --git a/app/src/test/kotlin/com/bitchat/android/util/GitHubReleaseClientTest.kt b/app/src/test/kotlin/com/bitchat/android/util/GitHubReleaseClientTest.kt new file mode 100644 index 00000000..399ae3a2 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/util/GitHubReleaseClientTest.kt @@ -0,0 +1,134 @@ +package com.bitchat.android.util + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.bitchat.android.net.OkHttpProvider +import kotlinx.coroutines.test.runTest +import mockwebserver3.MockResponse +import mockwebserver3.MockWebServer +import okhttp3.OkHttpClient +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.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 + + @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 `cached metadata is conditionally refreshed with its etag`() = runTest { + server.enqueue(successResponse(etag = "release-v1")) + val client = client() + + 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 `rate limit serves stale metadata and suppresses repeated requests`() = runTest { + server.enqueue(successResponse(etag = "release-v1")) + val client = client() + client.latestRelease().getOrThrow() + + nowMillis += 31 * 60_000L + server.enqueue( + MockResponse.Builder() + .code(429) + .build() + ) + val stale = client.latestRelease().getOrThrow() + val stillStale = client.latestRelease().getOrThrow() + + assertTrue(stale.isStale) + assertTrue(stillStale.isStale) + assertEquals(2, server.requestCount) + } + + @Test + fun `cooldown follows the actual client route`() = runTest { + server.enqueue( + MockResponse.Builder() + .code(429) + .addHeader("Retry-After", "120") + .build() + ) + val client = client() + assertTrue(client.latestRelease().isFailure) + assertTrue(client.latestRelease().isFailure) + assertEquals(1, server.requestCount) + + route = OkHttpProvider.Route.TOR + server.enqueue(successResponse(etag = "release-v1")) + assertTrue(client.latestRelease().isSuccess) + assertEquals(2, server.requestCount) + } + + private fun client() = GitHubReleaseClient( + context = context, + apiUrl = server.url("/releases/latest").toString(), + nowMillis = { nowMillis }, + routedClient = { + OkHttpProvider.RoutedClient( + client = OkHttpClient.Builder().build(), + route = route + ) + }, + awaitRoute = { true } + ) + + private fun successResponse(etag: String): MockResponse = MockResponse.Builder() + .code(200) + .addHeader("ETag", etag) + .body( + """ + { + "tag_name": "v1.7.6", + "assets": [ + { + "name": "bitchat-android-universal.apk", + "browser_download_url": "https://downloads.example/bitchat-universal.apk", + "size": 25165824 + } + ] + } + """.trimIndent() + ) + .build() +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 965476bf..405f6dc3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -16,9 +16,6 @@ appcompat = "1.7.1" # Compose compose-bom = "2026.06.01" compose-icons-extended = "1.7.8" -# Overrides the BOM's 1.4.0. The expressive wavy progress indicators are Compose-only in the -# 1.5.0 line, which has no stable release yet; drop this override once 1.5.0 ships. -compose-material3 = "1.5.0-alpha25" # Navigation navigation-compose = "2.9.8" @@ -92,7 +89,7 @@ androidx-compose-ui = { module = "androidx.compose.ui:ui" } androidx-compose-ui-graphics = { module = "androidx.compose.ui:ui-graphics" } androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } -androidx-compose-material3 = { module = "androidx.compose.material3:material3", version.ref = "compose-material3" } +androidx-compose-material3 = { module = "androidx.compose.material3:material3" } androidx-compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended", version.ref = "compose-icons-extended" } # Lifecycle @@ -124,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 @@ -195,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 a13ea01e..e0d0d5bd 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -3658,6 +3658,14 @@ + + + + + + + + From a898c061143832ae9f83e3729717d2cf1f89de17 Mon Sep 17 00:00:00 2001 From: Moe Hamade <69801237+moehamade@users.noreply.github.com> Date: Mon, 3 Aug 2026 18:08:52 +0300 Subject: [PATCH 17/22] build: relock :wear after mockwebserver entered the shared test bundle CI failed at ':wear:compileDebugUnitTestKotlin' with okhttp, okio and mockwebserver3 "not part of the dependency lock state". No test ran. Adding okhttp-mockwebserver to the shared test bundle put okhttp and okio on :wear's unit-test classpath as well, but only :app's lock state was regenerated, so :wear/gradle.lockfile had no entry for any of them. Regenerated lock state and verification metadata for debug and both release variants per docs/reproducible-builds.md. No new components needed trusting: the checksums already existed from the :app side, so this is lockfile scope only. Worth noting the local command that missed it was :app-scoped; CI runs testDebugUnitTest at the root, which includes :wear. Co-Authored-By: Claude Opus 5 (1M context) --- app/gradle.lockfile | 2 +- wear/gradle.lockfile | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/app/gradle.lockfile b/app/gradle.lockfile index 986979d0..c66fac1d 100644 --- a/app/gradle.lockfile +++ b/app/gradle.lockfile @@ -283,7 +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,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/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 From 99bed510de490e5949d806df14fdb5b38a610ba7 Mon Sep 17 00:00:00 2001 From: Moe Hamade <69801237+moehamade@users.noreply.github.com> Date: Mon, 3 Aug 2026 20:12:50 +0300 Subject: [PATCH 18/22] build: drop verification entries for the reverted material3 alpha The alpha bump was reverted in 2c19d00d, but its verification metadata stayed behind: 45 components for compose 1.12.0-beta01 and material3 1.5.0-alpha25 that no lockfile resolves. Regenerated from upstream's file so only artifacts this branch actually pulls are trusted. Trusting artifacts nothing resolves is the opposite of what this file is for, and it made the branch look like a Compose beta upgrade in review. gradle/verification-metadata.xml: +322 lines -> +8, one component (mockwebserver3, the dependency 2c19d00d genuinely added). Co-Authored-By: Claude Opus 5 (1M context) --- gradle/verification-metadata.xml | 314 ------------------------------- 1 file changed, 314 deletions(-) diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index e0d0d5bd..6c6f2a76 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -255,11 +255,6 @@ - - - - - @@ -281,14 +276,6 @@ - - - - - - - - @@ -307,11 +294,6 @@ - - - - - @@ -338,14 +320,6 @@ - - - - - - - - @@ -390,11 +364,6 @@ - - - - - @@ -416,14 +385,6 @@ - - - - - - - - @@ -442,11 +403,6 @@ - - - - - @@ -468,14 +424,6 @@ - - - - - - - - @@ -531,11 +479,6 @@ - - - - - @@ -549,14 +492,6 @@ - - - - - - - - @@ -575,11 +510,6 @@ - - - - - @@ -596,14 +526,6 @@ - - - - - - - - @@ -612,19 +534,6 @@ - - - - - - - - - - - - - @@ -635,11 +544,6 @@ - - - - - @@ -666,14 +570,6 @@ - - - - - - - - @@ -689,11 +585,6 @@ - - - - - @@ -715,14 +606,6 @@ - - - - - - - - @@ -738,11 +621,6 @@ - - - - - @@ -759,14 +637,6 @@ - - - - - - - - @@ -777,11 +647,6 @@ - - - - - @@ -803,14 +668,6 @@ - - - - - - - - @@ -836,16 +693,6 @@ - - - - - - - - - - @@ -867,14 +714,6 @@ - - - - - - - - @@ -893,11 +732,6 @@ - - - - - @@ -919,14 +753,6 @@ - - - - - - - - @@ -945,11 +771,6 @@ - - - - - @@ -971,14 +792,6 @@ - - - - - - - - @@ -992,11 +805,6 @@ - - - - - @@ -1005,24 +813,11 @@ - - - - - - - - - - - - - @@ -1031,14 +826,6 @@ - - - - - - - - @@ -1047,14 +834,6 @@ - - - - - - - - @@ -1065,11 +844,6 @@ - - - - - @@ -1091,14 +865,6 @@ - - - - - - - - @@ -1117,11 +883,6 @@ - - - - - @@ -1143,14 +904,6 @@ - - - - - - - - @@ -1169,11 +922,6 @@ - - - - - @@ -1195,14 +943,6 @@ - - - - - - - - @@ -1221,11 +961,6 @@ - - - - - @@ -1247,14 +982,6 @@ - - - - - - - - @@ -1273,11 +1000,6 @@ - - - - - @@ -1299,14 +1021,6 @@ - - - - - - - - @@ -1325,11 +1039,6 @@ - - - - - @@ -1351,14 +1060,6 @@ - - - - - - - - @@ -1939,11 +1640,6 @@ - - - - - @@ -2059,11 +1755,6 @@ - - - - - @@ -5077,11 +4768,6 @@ - - - - - From 000a8acdb98b22f556c5381b19cb5d293132dc29 Mon Sep 17 00:00:00 2001 From: Moe Hamade <69801237+moehamade@users.noreply.github.com> Date: Sun, 9 Aug 2026 14:53:42 +0300 Subject: [PATCH 19/22] fix: build each shared HTTP client once instead of racing to replace it routedHttpClient() and webSocketClient() both did a plain check-then-set on their AtomicReference: read, and if empty build a client and store it. Two threads arriving together each saw an empty reference, each built a full OkHttpClient, and the loser's client was dropped on the floor with its connection pool and dispatcher threads already allocated. Nothing closed it, so the leak lasted until the process died. Moves construction inside a lock and re-checks the reference there, so the second thread returns the first thread's client rather than building its own. reset() takes the same lock, which is what makes the pairing airtight: a build can no longer interleave with a reset and store a client for the route that was just discarded. The fast path stays outside the lock, so a warm client still costs a single volatile read. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/bitchat/android/net/OkHttpProvider.kt | 42 +++++++++++-------- .../bitchat/android/net/OkHttpProviderTest.kt | 26 ++++++++++++ 2 files changed, 50 insertions(+), 18 deletions(-) create mode 100644 app/src/test/kotlin/com/bitchat/android/net/OkHttpProviderTest.kt 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 7f4d7c29..f9ad16e2 100644 --- a/app/src/main/java/com/bitchat/android/net/OkHttpProvider.kt +++ b/app/src/main/java/com/bitchat/android/net/OkHttpProvider.kt @@ -22,10 +22,13 @@ object OkHttpProvider { private val httpClientRef = AtomicReference(null) private val wsClientRef = AtomicReference(null) + private val clientLock = Any() fun reset() { - httpClientRef.set(null) - wsClientRef.set(null) + synchronized(clientLock) { + httpClientRef.set(null) + wsClientRef.set(null) + } } fun httpClient(): OkHttpClient = routedHttpClient().client @@ -38,26 +41,29 @@ object OkHttpProvider { */ fun routedHttpClient(): RoutedClient { httpClientRef.get()?.let { return it } - val (builder, route) = baseBuilderForCurrentProxy() - val client = builder - .callTimeout(15, TimeUnit.SECONDS) - .connectTimeout(10, TimeUnit.SECONDS) - .readTimeout(15, TimeUnit.SECONDS) - .build() - val routedClient = RoutedClient(client, route) - httpClientRef.set(routedClient) - return routedClient + 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().first - .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(): Pair { 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) + } +} From 7b86bafbac35906aa8c6d257699d7f2fdbdef2d3 Mon Sep 17 00:00:00 2001 From: Moe Hamade <69801237+moehamade@users.noreply.github.com> Date: Sun, 9 Aug 2026 14:57:32 +0300 Subject: [PATCH 20/22] fix(apk): give the persisted store sole ownership of rate-limit cooldowns The cooldown was tracked in two places. ApkRateLimitStore persisted it per scope and per route, and the ViewModel kept a second copy in downloadRetryAtMillis plus a retryAtMillis on Resumable and Error, frozen into WorkManager output data on the way through. The copy was the weaker of the two: it lived in memory, so a cold start lost it, and the deadline it froze belonged to whichever route earned it, which is exactly the drift the per-route store exists to prevent. It also could not expire on its own. downloadRetryBlocked was computed with System.currentTimeMillis() during composition, so nothing recomposed when the deadline passed; scheduleRetryUnlock papered over that with a viewModelScope delay that died with the process. Meanwhile the disabled row and icon gave the user no countdown to read, so a tap simply did nothing. Drops the copy. The store is consulted where the request is actually made and the UI stays enabled, which costs a worker that fails in well under a tenth of a second without touching the network. RateLimitedWithWait goes with it. Its "try again in %2$s min" was computed at failure time and baked into static text that never ticked down, so it was wrong within a minute; RateLimited says "try again later" and stays true. Removing it leaves nothing pre-formatted, so Resumable and Error now carry an ApkFailureMessage of string id plus arguments and the row resolves it during composition. Failure text follows the device locale rather than the locale the worker happened to run under. Anchors the GitHub cooldown at the moment it is judged. now was sampled before awaitRoute(), which can hold a request for the full 60-second route timeout, so a relative Retry-After interpreted against it could land in the past and let the very next check reach GitHub - the loop this branch set out to close. Reads the clock again once the route is ready and once the response arrives, and uses each where it applies. Co-Authored-By: Claude Opus 5 (1M context) --- .../java/com/bitchat/android/ui/AboutSheet.kt | 29 ++-- .../android/ui/ApkDownloadViewModel.kt | 131 ++++++++---------- .../bitchat/android/util/ApkDownloadSource.kt | 13 +- .../bitchat/android/util/ApkDownloadWorker.kt | 5 +- .../com/bitchat/android/util/ApkDownloader.kt | 8 +- .../bitchat/android/util/ApkRateLimitStore.kt | 11 +- .../android/util/GitHubReleaseClient.kt | 28 ++-- .../android/util/UniversalApkManager.kt | 8 +- .../android/util/WorkManagerApkDownloader.kt | 6 +- app/src/main/res/values/strings.xml | 6 +- .../android/ui/ApkDownloadViewModelTest.kt | 87 ++++++++++-- .../android/ui/PrepareRowTapActionTest.kt | 43 ++---- .../android/util/ApkDownloadSourceTest.kt | 9 +- .../android/util/GitHubReleaseClientTest.kt | 93 ++++++++++++- 14 files changed, 289 insertions(+), 188 deletions(-) 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 5db34876..54ab0318 100644 --- a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt @@ -630,8 +630,6 @@ fun AboutSheet( } val availableUpdate = (releaseStatus as? ApkReleaseStatus.Known) ?.takeIf { it.isNewerThanSharedApk } - val downloadRetryBlocked = apkUiState.downloadRetryAtMillis - ?.let { it > System.currentTimeMillis() } == true // Handle one-shot effects (navigation, toasts, share intents) LaunchedEffect(Unit) { @@ -672,8 +670,7 @@ fun AboutSheet( .clickable( enabled = prepareRowTapAction( apkStatus, - releaseStatus, - apkUiState.downloadRetryAtMillis + releaseStatus ) != null ) { apkViewModel.onEvent(ApkUiEvent.PrepareRowClicked) @@ -734,9 +731,7 @@ fun AboutSheet( modifier = Modifier .padding(start = 6.dp) .size(18.dp) - .clickable( - enabled = !downloadRetryBlocked - ) { + .clickable { apkViewModel.onEvent( ApkUiEvent.DownloadUniversalClicked ) @@ -748,7 +743,10 @@ fun AboutSheet( 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.DOWNLOADED -> @@ -777,10 +775,15 @@ fun AboutSheet( is ApkPreparationStatus.Resumable -> stringResource( R.string.prepare_apk_status_resumable, - status.message, + context.resolveApkFailureMessage( + status.failure + ), status.progressPercent ) - is ApkPreparationStatus.Error -> status.message + is ApkPreparationStatus.Error -> + context.resolveApkFailureMessage( + status.failure + ) }, style = MaterialTheme.typography.bodySmall, color = when (apkStatus) { @@ -830,7 +833,6 @@ fun AboutSheet( ApkUiEvent.DownloadUniversalClicked ) }, - enabled = !downloadRetryBlocked, tint = colorScheme.primary ) } else if ( @@ -863,11 +865,6 @@ fun AboutSheet( ApkUiEvent.PrepareRowClicked ) }, - enabled = prepareRowTapAction( - apkStatus, - releaseStatus, - apkUiState.downloadRetryAtMillis - ) != null, tint = colorScheme.primary ) else -> {} 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 a76b8ecb..b076477f 100644 --- a/app/src/main/java/com/bitchat/android/ui/ApkDownloadViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ApkDownloadViewModel.kt @@ -1,7 +1,9 @@ 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 @@ -16,7 +18,6 @@ import com.bitchat.android.util.WorkManagerApkDownloader import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -43,10 +44,31 @@ sealed class ApkPreparationStatus { ) : ApkPreparationStatus() data class Resumable( val progressPercent: Int, - val message: String, - val retryAtMillis: Long? = null + val failure: ApkFailureMessage ) : ApkPreparationStatus() - data class Error(val message: String, val retryAtMillis: Long? = null) : 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 { @@ -63,7 +85,6 @@ sealed class ApkReleaseStatus { data class ApkUiState( val apkStatus: ApkPreparationStatus = ApkPreparationStatus.Loading, val releaseStatus: ApkReleaseStatus = ApkReleaseStatus.Unknown, - val downloadRetryAtMillis: Long? = null, val downloadProgress: Int = 0, val showPrepareDialog: Boolean = false, val showDeleteDialog: Boolean = false, @@ -105,19 +126,12 @@ internal enum class PrepareRowTapAction { */ internal fun prepareRowTapAction( status: ApkPreparationStatus, - releaseStatus: ApkReleaseStatus = ApkReleaseStatus.Unknown, - downloadRetryAtMillis: Long? = null, - nowMillis: Long = System.currentTimeMillis() + releaseStatus: ApkReleaseStatus = ApkReleaseStatus.Unknown ): PrepareRowTapAction? = when { - downloadRetryAtMillis != null && downloadRetryAtMillis > nowMillis -> null status is ApkPreparationStatus.NotDownloaded -> PrepareRowTapAction.OpenPrepareDialog // Consent was already given for these; resuming straight away avoids a redundant prompt. - status is ApkPreparationStatus.Resumable && - (status.retryAtMillis == null || status.retryAtMillis <= nowMillis) -> - PrepareRowTapAction.StartDownload - status is ApkPreparationStatus.Error && - (status.retryAtMillis == null || status.retryAtMillis <= nowMillis) -> - PrepareRowTapAction.StartDownload + status is ApkPreparationStatus.Resumable -> PrepareRowTapAction.StartDownload + status is ApkPreparationStatus.Error -> PrepareRowTapAction.StartDownload status is ApkPreparationStatus.Ready && (status.source == UniversalApkManager.ApkSource.INSTALLED || (releaseStatus as? ApkReleaseStatus.Known)?.isNewerThanSharedApk == true) -> @@ -162,7 +176,6 @@ class ApkDownloadViewModel internal constructor( val effect = _effect.receiveAsFlow() private var metadataRefreshJob: Job? = null - private var retryUnlockJob: Job? = null init { observeDownloader() @@ -190,8 +203,7 @@ class ApkDownloadViewModel internal constructor( when ( prepareRowTapAction( _state.value.apkStatus, - _state.value.releaseStatus, - _state.value.downloadRetryAtMillis + _state.value.releaseStatus ) ) { PrepareRowTapAction.OpenPrepareDialog -> @@ -207,9 +219,6 @@ class ApkDownloadViewModel internal constructor( } private fun onDownloadUniversalClicked() { - if (_state.value.downloadRetryAtMillis?.let { it > System.currentTimeMillis() } == true) { - return - } val status = _state.value.apkStatus val hasUpdate = (_state.value.releaseStatus as? ApkReleaseStatus.Known) ?.isNewerThanSharedApk == true @@ -283,14 +292,6 @@ class ApkDownloadViewModel internal constructor( private fun startDownload() { val current = _state.value.apkStatus - val stateRetryAt = _state.value.downloadRetryAtMillis - val retryAt = when (current) { - is ApkPreparationStatus.Resumable -> current.retryAtMillis - is ApkPreparationStatus.Error -> current.retryAtMillis - else -> null - } - if ((stateRetryAt ?: retryAt)?.let { it > System.currentTimeMillis() } == true) return - val fallback = when (current) { is ApkPreparationStatus.Ready -> current is ApkPreparationStatus.Downloading -> current.shareableFallback @@ -302,7 +303,6 @@ class ApkDownloadViewModel internal constructor( apkStatus = ApkPreparationStatus.Downloading( shareableFallback = fallback ), - downloadRetryAtMillis = null, downloadProgress = partial ?: 0 ) } @@ -325,7 +325,10 @@ class ApkDownloadViewModel internal constructor( if (current.apkStatus is ApkPreparationStatus.Downloading) { current } else { - current.copy(apkStatus = resolvedStatus, downloadProgress = 0) + current.copy( + apkStatus = resolvedStatus, + downloadProgress = 0 + ) } } // Local availability is resolved and published before this independent network task @@ -406,12 +409,12 @@ class ApkDownloadViewModel internal constructor( it.copy( apkStatus = ready, releaseStatus = releaseStatusFor(ready, it.releaseStatus), - downloadRetryAtMillis = null, downloadProgress = 100 ) } } is ApkDownloader.DownloadState.Failed -> { + val failure = downloadState.toFailureMessage() val fallback = (_state.value.apkStatus as? ApkPreparationStatus.Downloading) ?.shareableFallback ?: apkManager.getCachedApkInfo()?.toReady() @@ -420,36 +423,31 @@ class ApkDownloadViewModel internal constructor( it.copy( apkStatus = fallback, releaseStatus = releaseStatusFor(fallback, it.releaseStatus), - downloadRetryAtMillis = downloadState.retryAtMillis, downloadProgress = 0 ) } - _effect.send(ApkUiEffect.ShowToast(failureMessage(downloadState))) + _effect.send( + ApkUiEffect.ShowToast( + getApplication().resolveApkFailureMessage( + failure + ) + ) + ) } else { - val message = failureMessage(downloadState) _state.update { if (downloadState.resumablePercent != null) { it.copy( apkStatus = ApkPreparationStatus.Resumable( progressPercent = downloadState.resumablePercent, - message = message, - retryAtMillis = downloadState.retryAtMillis + failure = failure ), - downloadRetryAtMillis = downloadState.retryAtMillis, downloadProgress = downloadState.resumablePercent ) } else { - it.copy( - apkStatus = ApkPreparationStatus.Error( - message, - downloadState.retryAtMillis - ), - downloadRetryAtMillis = downloadState.retryAtMillis - ) + it.copy(apkStatus = ApkPreparationStatus.Error(failure)) } } } - scheduleRetryUnlock(downloadState.retryAtMillis) } } } @@ -466,35 +464,10 @@ class ApkDownloadViewModel internal constructor( return getApplication().getString(resId) } - /** - * The single place a download failure turns into words. The downloader names the failure and - * this resolves it, so the message follows the device locale rather than the worker's. - */ - private fun failureMessage(state: ApkDownloader.DownloadState.Failed): String = - runCatching { - getApplication().getString( - state.reason.messageRes, - *state.messageArgs.toTypedArray() - ) - }.getOrElse { - getString(R.string.prepare_apk_error_generic) - } - - private fun scheduleRetryUnlock(retryAtMillis: Long?) { - if (retryAtMillis == null) return - retryUnlockJob?.cancel() - retryUnlockJob = viewModelScope.launch { - delay((retryAtMillis - System.currentTimeMillis()).coerceAtLeast(0L)) - _state.update { - val unlocked = when (val status = it.apkStatus) { - is ApkPreparationStatus.Resumable -> status.copy(retryAtMillis = null) - is ApkPreparationStatus.Error -> status.copy(retryAtMillis = null) - else -> status - } - it.copy(apkStatus = unlocked, downloadRetryAtMillis = null) - } - } - } + private fun ApkDownloader.DownloadState.Failed.toFailureMessage() = ApkFailureMessage( + messageRes = reason.messageRes, + messageArgs = messageArgs + ) private fun shareableReady(status: ApkPreparationStatus): ApkPreparationStatus.Ready? = when (status) { @@ -527,7 +500,9 @@ class ApkDownloadViewModel internal constructor( if (partial != null) { ApkPreparationStatus.Resumable( progressPercent = partial, - message = getString(R.string.prepare_apk_download_interrupted) + failure = ApkFailureMessage( + messageRes = R.string.prepare_apk_download_interrupted + ) ) } else { ApkPreparationStatus.NotDownloaded @@ -536,7 +511,9 @@ class ApkDownloadViewModel internal constructor( } catch (e: Exception) { // The exception text is English and often internal; log it, show a translated line. Log.e(TAG, "Error reading APK status", e) - ApkPreparationStatus.Error(getString(R.string.share_apk_error)) + ApkPreparationStatus.Error( + ApkFailureMessage(messageRes = R.string.share_apk_error) + ) } } } diff --git a/app/src/main/java/com/bitchat/android/util/ApkDownloadSource.kt b/app/src/main/java/com/bitchat/android/util/ApkDownloadSource.kt index cf0f5e90..73f168ba 100644 --- a/app/src/main/java/com/bitchat/android/util/ApkDownloadSource.kt +++ b/app/src/main/java/com/bitchat/android/util/ApkDownloadSource.kt @@ -8,7 +8,6 @@ import java.io.IOException import java.time.Instant import java.time.ZonedDateTime import java.time.format.DateTimeFormatter -import kotlin.math.ceil /** * A trusted location that serves the latest signed universal BitChat APK. @@ -71,7 +70,6 @@ internal object DefaultApkDownloadSources { enum class ApkDownloadFailureReason(@StringRes val messageRes: Int) { Generic(R.string.prepare_apk_error_generic), Cancelled(R.string.prepare_apk_download_cancelled), - RateLimitedWithWait(R.string.prepare_apk_error_rate_limited_wait), RateLimited(R.string.prepare_apk_error_rate_limited), NoUniversalApk(R.string.prepare_apk_error_no_universal), HttpFailure(R.string.prepare_apk_error_http), @@ -156,17 +154,10 @@ internal object ApkDownloadHttpErrors { (code == 403 && (rateLimitRemaining?.trim() == "0" || retryAt != null)) if (rateLimited) { - val minutes = retryAt?.let { deadline -> - ceil((deadline - nowMillis).coerceAtLeast(1L) / 60_000.0).toLong() - } return ApkDownloadException( message = "${source.id} rate limited: HTTP $code, retryAt=$retryAt", - reason = if (minutes != null) { - ApkDownloadFailureReason.RateLimitedWithWait - } else { - ApkDownloadFailureReason.RateLimited - }, - messageArgs = listOfNotNull(source.displayName, minutes?.toString()), + reason = ApkDownloadFailureReason.RateLimited, + messageArgs = listOf(source.displayName), retryable = false, sourceId = source.id, httpCode = code, 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 ade699b5..40edde59 100644 --- a/app/src/main/java/com/bitchat/android/util/ApkDownloadWorker.kt +++ b/app/src/main/java/com/bitchat/android/util/ApkDownloadWorker.kt @@ -39,7 +39,6 @@ class ApkDownloadWorker( const val KEY_ERROR_REASON = "error_reason" const val KEY_ERROR_ARGS = "error_args" const val KEY_RESUMABLE_PERCENT = "resumable_percent" - const val KEY_RETRY_AT = "retry_at" private const val CHANNEL_ID = "apk_download" private const val NOTIFICATION_ID = 4201 @@ -121,7 +120,9 @@ class ApkDownloadWorker( failure?.messageArgs.orEmpty().toTypedArray() ) .putInt(KEY_RESUMABLE_PERCENT, partial ?: -1) - .apply { failure?.retryAtMillis?.let { putLong(KEY_RETRY_AT, it) } } + // 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) } 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 75cd6b14..01be6280 100644 --- a/app/src/main/java/com/bitchat/android/util/ApkDownloader.kt +++ b/app/src/main/java/com/bitchat/android/util/ApkDownloader.kt @@ -35,15 +35,13 @@ interface ApkDownloader { ) : DownloadState() data class Success(val version: String, val sizeMB: Int) : DownloadState() /** - * [reason] and [messageArgs] are resolved by the ViewModel, which has a Context. - * Carrying the reason rather than formatted text keeps the failure localizable all the - * way across the WorkManager boundary. + * [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?, - val retryAtMillis: Long? = null + val resumablePercent: Int? ) : DownloadState() } diff --git a/app/src/main/java/com/bitchat/android/util/ApkRateLimitStore.kt b/app/src/main/java/com/bitchat/android/util/ApkRateLimitStore.kt index 0a0f6a0f..963099b3 100644 --- a/app/src/main/java/com/bitchat/android/util/ApkRateLimitStore.kt +++ b/app/src/main/java/com/bitchat/android/util/ApkRateLimitStore.kt @@ -3,7 +3,6 @@ package com.bitchat.android.util import android.content.Context import androidx.core.content.edit import com.bitchat.android.net.OkHttpProvider -import kotlin.math.ceil /** Persistent, route-specific cooldowns for APK-related network requests. */ internal class ApkRateLimitStore(context: Context) { @@ -52,16 +51,12 @@ internal class ApkRateLimitStore(context: Context) { fun blockedException( source: ApkDownloadSource, - retryAtMillis: Long, - nowMillis: Long = System.currentTimeMillis() + retryAtMillis: Long ): ApkDownloadException { - val minutes = ceil( - (retryAtMillis - nowMillis).coerceAtLeast(1L) / 60_000.0 - ).toLong() return ApkDownloadException( message = "${source.id} is in a persisted rate-limit cooldown until $retryAtMillis", - reason = ApkDownloadFailureReason.RateLimitedWithWait, - messageArgs = listOf(source.displayName, minutes.toString()), + reason = ApkDownloadFailureReason.RateLimited, + messageArgs = listOf(source.displayName), retryable = false, sourceId = source.id, retryAtMillis = retryAtMillis 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 dbb27c5d..d00122d8 100644 --- a/app/src/main/java/com/bitchat/android/util/GitHubReleaseClient.kt +++ b/app/src/main/java/com/bitchat/android/util/GitHubReleaseClient.kt @@ -94,9 +94,16 @@ internal class GitHubReleaseClient( if (!awaitRoute()) return@withLock cached.orRouteFailure() val routeSnapshot = routedClient() - rateLimits.retryAtMillis(RATE_LIMIT_SCOPE, routeSnapshot.route, now)?.let { deadline -> + // 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, now) + rateLimits.blockedException(SOURCE, deadline) ) } @@ -115,13 +122,17 @@ internal class GitHubReleaseClient( 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 = now) + val refreshed = cached.copy(fetchedAtMillis = responseNow) writeCache(refreshed) rateLimits.clear(RATE_LIMIT_SCOPE, routeSnapshot.route) return@withLock Result.success( @@ -136,19 +147,18 @@ internal class GitHubReleaseClient( retryAfter = response.header("Retry-After"), rateLimitRemaining = response.header("X-RateLimit-Remaining"), rateLimitResetEpochSeconds = response.header("X-RateLimit-Reset"), - nowMillis = now + nowMillis = responseNow ) val persistedFailure = if ( - failure.reason == ApkDownloadFailureReason.RateLimited || - failure.reason == ApkDownloadFailureReason.RateLimitedWithWait + failure.reason == ApkDownloadFailureReason.RateLimited ) { val deadline = rateLimits.recordRateLimit( RATE_LIMIT_SCOPE, routeSnapshot.route, failure.retryAtMillis, - now + responseNow ) - rateLimits.blockedException(SOURCE, deadline, now) + rateLimits.blockedException(SOURCE, deadline) } else { failure } @@ -163,7 +173,7 @@ internal class GitHubReleaseClient( val entry = CachedRelease( release = release, etag = response.header("ETag"), - fetchedAtMillis = now + fetchedAtMillis = responseNow ) writeCache(entry) rateLimits.clear(RATE_LIMIT_SCOPE, routeSnapshot.route) 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 df44ab83..8480fc27 100644 --- a/app/src/main/java/com/bitchat/android/util/UniversalApkManager.kt +++ b/app/src/main/java/com/bitchat/android/util/UniversalApkManager.kt @@ -372,7 +372,7 @@ class UniversalApkManager( val rateLimitScope = "apk_asset_${source.id}" val now = System.currentTimeMillis() rateLimits.retryAtMillis(rateLimitScope, routedClient.route, now)?.let { deadline -> - throw rateLimits.blockedException(source, deadline, now) + throw rateLimits.blockedException(source, deadline) } val request = Request.Builder() @@ -482,9 +482,7 @@ class UniversalApkManager( rateLimitResetEpochSeconds = response.header("X-RateLimit-Reset") ) - if (failure.reason == ApkDownloadFailureReason.RateLimited || - failure.reason == ApkDownloadFailureReason.RateLimitedWithWait - ) { + if (failure.reason == ApkDownloadFailureReason.RateLimited) { val now = System.currentTimeMillis() val deadline = rateLimits.recordRateLimit( rateLimitScope, @@ -492,7 +490,7 @@ class UniversalApkManager( failure.retryAtMillis, now ) - throw rateLimits.blockedException(source, deadline, now) + throw rateLimits.blockedException(source, deadline) } throw failure } 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 50bb2b1c..6d482c34 100644 --- a/app/src/main/java/com/bitchat/android/util/WorkManagerApkDownloader.kt +++ b/app/src/main/java/com/bitchat/android/util/WorkManagerApkDownloader.kt @@ -92,11 +92,7 @@ class WorkManagerApkDownloader(context: Context) : ApkDownloader { ApkDownloader.DownloadState.Failed( reason = reason, messageArgs = args, - resumablePercent = if (resumable >= 0) resumable else null, - retryAtMillis = workInfo.outputData.getLong( - ApkDownloadWorker.KEY_RETRY_AT, - 0L - ).takeIf { it > 0L } + resumablePercent = if (resumable >= 0) resumable else null ) } WorkInfo.State.CANCELLED -> { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 18925640..a46d596f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -274,10 +274,10 @@ Network error. Check your connection. Not enough storage space. - %1$s is temporarily rate limited. Try again in %2$s min. %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 diff --git a/app/src/test/kotlin/com/bitchat/android/ui/ApkDownloadViewModelTest.kt b/app/src/test/kotlin/com/bitchat/android/ui/ApkDownloadViewModelTest.kt index 6b4998c7..6e146e6e 100644 --- a/app/src/test/kotlin/com/bitchat/android/ui/ApkDownloadViewModelTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/ui/ApkDownloadViewModelTest.kt @@ -2,7 +2,9 @@ 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 @@ -10,9 +12,9 @@ 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.delay import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest @@ -71,11 +73,12 @@ class ApkDownloadViewModelTest { fun `notification cancellation returning idle restores shareable fallback`() = runTest { val manager = managerWithLocalApk() val downloader = FakeDownloader() - val metadata = object : LatestReleaseProvider { - override suspend fun latestRelease(): Result = - Result.failure(IllegalStateException("synthetic offline response")) - } - val viewModel = ApkDownloadViewModel(application, manager, downloader, metadata) + val viewModel = ApkDownloadViewModel( + application, + manager, + downloader, + offlineMetadata() + ) viewModel.onEvent(ApkUiEvent.CheckStatus) val originalReady = awaitReady(viewModel) @@ -87,8 +90,62 @@ class ApkDownloadViewModelTest { assertEquals(1, downloader.startCount) downloader.emit(ApkDownloader.DownloadState.Idle) - val restored = awaitReady(viewModel) - assertEquals(originalReady, restored) + 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) + } + + private fun offlineMetadata() = object : LatestReleaseProvider { + override suspend fun latestRelease(): Result = + Result.failure(IllegalStateException("synthetic offline response")) } private suspend fun managerWithLocalApk(): UniversalApkManager { @@ -112,7 +169,19 @@ class ApkDownloadViewModelTest { viewModel: ApkDownloadViewModel ): ApkPreparationStatus.Ready = withTimeout(5_000L) { while (true) { - (viewModel.state.value.apkStatus as? ApkPreparationStatus.Ready)?.let { return@withTimeout it } + (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") diff --git a/app/src/test/kotlin/com/bitchat/android/ui/PrepareRowTapActionTest.kt b/app/src/test/kotlin/com/bitchat/android/ui/PrepareRowTapActionTest.kt index d1e3a431..7d8051de 100644 --- a/app/src/test/kotlin/com/bitchat/android/ui/PrepareRowTapActionTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/ui/PrepareRowTapActionTest.kt @@ -1,5 +1,6 @@ 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 @@ -24,6 +25,10 @@ class PrepareRowTapActionTest { 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. @@ -79,11 +84,16 @@ class PrepareRowTapActionTest { // The user already consented to the download; re-prompting would be noise. assertEquals( PrepareRowTapAction.StartDownload, - prepareRowTapAction(ApkPreparationStatus.Resumable(43, "Download interrupted")) + prepareRowTapAction( + ApkPreparationStatus.Resumable( + 43, + ApkFailureMessage(R.string.prepare_apk_download_interrupted) + ) + ) ) assertEquals( PrepareRowTapAction.StartDownload, - prepareRowTapAction(ApkPreparationStatus.Error("Network error")) + prepareRowTapAction(error()) ) } @@ -96,33 +106,4 @@ class PrepareRowTapActionTest { assertNull(prepareRowTapAction(ApkPreparationStatus.Loading)) } - @Test - fun `a persisted rate limit disables manual retry until its deadline`() { - val retryAt = 50_000L - assertNull( - prepareRowTapAction( - ApkPreparationStatus.Error("Rate limited", retryAtMillis = retryAt), - nowMillis = retryAt - 1 - ) - ) - assertEquals( - PrepareRowTapAction.StartDownload, - prepareRowTapAction( - ApkPreparationStatus.Error("Rate limited", retryAtMillis = retryAt), - nowMillis = retryAt - ) - ) - } - - @Test - fun `rate limit also disables optional update while an apk remains shareable`() { - val retryAt = 50_000L - assertNull( - prepareRowTapAction( - ready(ShareableApkVariant.UNIVERSAL), - downloadRetryAtMillis = retryAt, - nowMillis = retryAt - 1 - ) - ) - } } diff --git a/app/src/test/kotlin/com/bitchat/android/util/ApkDownloadSourceTest.kt b/app/src/test/kotlin/com/bitchat/android/util/ApkDownloadSourceTest.kt index 0b0eaf03..4d6702d6 100644 --- a/app/src/test/kotlin/com/bitchat/android/util/ApkDownloadSourceTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/util/ApkDownloadSourceTest.kt @@ -53,7 +53,7 @@ class ApkDownloadSourceTest { } @Test - fun `rate limit response gives the user the advertised retry time`() { + fun `rate limit response retains the server deadline without exposing a countdown`() { val failure = ApkDownloadHttpErrors.fromResponse( source = source, code = 429, @@ -66,9 +66,8 @@ class ApkDownloadSourceTest { assertFalse(failure.retryable) assertEquals(now + 120_000L, failure.retryAtMillis) - // The wait is carried as an argument, not baked into an English sentence. - assertEquals(ApkDownloadFailureReason.RateLimitedWithWait, failure.reason) - assertEquals(listOf(source.displayName, "2"), failure.messageArgs) + assertEquals(ApkDownloadFailureReason.RateLimited, failure.reason) + assertEquals(listOf(source.displayName), failure.messageArgs) } @Test @@ -99,7 +98,7 @@ class ApkDownloadSourceTest { permissionsFailure.messageArgs ) assertEquals(now + 300_000L, quotaFailure.retryAtMillis) - assertEquals(ApkDownloadFailureReason.RateLimitedWithWait, quotaFailure.reason) + assertEquals(ApkDownloadFailureReason.RateLimited, quotaFailure.reason) } @Test 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 399ae3a2..6377752d 100644 --- a/app/src/test/kotlin/com/bitchat/android/util/GitHubReleaseClientTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/util/GitHubReleaseClientTest.kt @@ -23,6 +23,12 @@ class GitHubReleaseClientTest { private var nowMillis = 1_700_000_000_000L private var route = OkHttpProvider.Route.DIRECT + /** How far the clock advances while awaitRoute() waits for Tor to finish bootstrapping. */ + private var routeWaitMillis = 0L + + /** 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() @@ -100,17 +106,100 @@ class GitHubReleaseClientTest { 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().build(), + client = OkHttpClient.Builder() + .addInterceptor { chain -> + chain.proceed(chain.request()).also { + nowMillis += responseWaitMillis + } + } + .build(), route = route ) }, - awaitRoute = { true } + awaitRoute = { + nowMillis += routeWaitMillis + true + } ) private fun successResponse(etag: String): MockResponse = MockResponse.Builder() From 52f14cc5b536a92b318c19a3b187a37033e345b4 Mon Sep 17 00:00:00 2001 From: Moe Hamade <69801237+moehamade@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:16:51 +0300 Subject: [PATCH 21/22] fix(apk): stop a reset header alone from marking a 403 rate limited GitHub sends X-RateLimit-Reset on every REST response, an ordinary 403 included, and it always points at the current window. Feeding it through retryAtMillis() therefore produced a non-null deadline for any 403, and the classifier accepted that as proof of a limit. A permissions failure with the quota untouched came back as RateLimited, so the caller persisted a cooldown on that route and served stale metadata until a reset window the failure had nothing to do with. Classification now looks only at signals that actually mean this request was the one refused: a spent quota, or an explicit Retry-After. Nothing real is lost, because GitHub marks a primary limit with X-RateLimit-Remaining: 0 and a secondary limit with Retry-After. The reset header keeps its job of supplying the deadline once a limit is established some other way. ApkDownloadSourceTest already claimed this contract - its name is "403 is only treated as a limit when response headers say so" - but its permissions case passed no reset header at all, which is the one input that hides the bug. Adds the case it was missing, which fails without this change, and pins the secondary-limit path so tightening the reset header cannot blind the client to a Retry-After. Co-Authored-By: Claude Opus 5 (1M context) --- .../bitchat/android/util/ApkDownloadSource.kt | 11 ++++- .../android/util/ApkDownloadSourceTest.kt | 41 +++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/bitchat/android/util/ApkDownloadSource.kt b/app/src/main/java/com/bitchat/android/util/ApkDownloadSource.kt index 73f168ba..226e6842 100644 --- a/app/src/main/java/com/bitchat/android/util/ApkDownloadSource.kt +++ b/app/src/main/java/com/bitchat/android/util/ApkDownloadSource.kt @@ -150,8 +150,17 @@ internal object ApkDownloadHttpErrors { 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" || retryAt != null)) + (code == 403 && (rateLimitRemaining?.trim() == "0" || retryAfterMillis != null)) if (rateLimited) { return ApkDownloadException( diff --git a/app/src/test/kotlin/com/bitchat/android/util/ApkDownloadSourceTest.kt b/app/src/test/kotlin/com/bitchat/android/util/ApkDownloadSourceTest.kt index 4d6702d6..e14a968d 100644 --- a/app/src/test/kotlin/com/bitchat/android/util/ApkDownloadSourceTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/util/ApkDownloadSourceTest.kt @@ -101,6 +101,47 @@ class ApkDownloadSourceTest { 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( From 8f21ad5be3aac64b2c102c5bdd73c9b9617e8805 Mon Sep 17 00:00:00 2001 From: Moe Hamade <69801237+moehamade@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:49:22 +0300 Subject: [PATCH 22/22] fix(apk): keep the local APK shareable across a restart mid-download Codex is right about this one. The downloader observer builds Downloading out of whatever status it replaces, reading shareableFallback from an existing Downloading or a current Ready. A ViewModel restored onto work that is already active starts from Loading, so neither cast matches and the fallback is null. WorkManager keeps a download running across process death, so this is the ordinary case: background the app during a 42 MB transfer over Tor, come back, and the row drops to "Prepare App for Sharing" while Share via Hotspot and Share via Quick Share disappear entirely. The installed APK never moved - it was on disk and shareable a moment earlier - and it stays hidden until the download ends. checkStatus() could not repair it because its guard conflated two things: not letting a resolved status overwrite active work, which is right, and not looking at local state at all during a download, which is not. Both orderings lost. If the observer arrived first the guard returned early. If checkStatus() arrived first it suspended on disk IO, the observer flipped the state underneath it, and the re-check discarded the status it had just resolved. Resolves the local artifact either way and decides inside the same state update, where the active download is visible: the download keeps the status it owns, and adopts the artifact only when it is carrying none. Metadata is still skipped while work is active, so this costs no extra API budget. Verified on a Pixel 9a by starting a download, force-stopping mid-transfer and reopening: the row holds "App Ready for Offline Sharing" with both sharing rows present, where it previously showed neither. Co-Authored-By: Claude Opus 5 (1M context) --- .../android/ui/ApkDownloadViewModel.kt | 38 ++++++---- .../android/ui/ApkDownloadViewModelTest.kt | 75 ++++++++++++++++++- 2 files changed, 96 insertions(+), 17 deletions(-) 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 b076477f..4d0c2d5b 100644 --- a/app/src/main/java/com/bitchat/android/ui/ApkDownloadViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ApkDownloadViewModel.kt @@ -311,26 +311,38 @@ class ApkDownloadViewModel internal constructor( 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 -> - // Re-check in case the user started a download while the local - // artifact was being inspected or copied. - if (current.apkStatus is ApkPreparationStatus.Downloading) { - current - } else { - current.copy( + // 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() diff --git a/app/src/test/kotlin/com/bitchat/android/ui/ApkDownloadViewModelTest.kt b/app/src/test/kotlin/com/bitchat/android/ui/ApkDownloadViewModelTest.kt index 6e146e6e..4351b7c0 100644 --- a/app/src/test/kotlin/com/bitchat/android/ui/ApkDownloadViewModelTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/ui/ApkDownloadViewModelTest.kt @@ -22,6 +22,7 @@ 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 @@ -143,6 +144,72 @@ class ApkDownloadViewModelTest { 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")) @@ -187,10 +254,10 @@ class ApkDownloadViewModelTest { error("unreachable") } - private class FakeDownloader : ApkDownloader { - private val mutableState = MutableStateFlow( - ApkDownloader.DownloadState.Idle - ) + private class FakeDownloader( + initial: ApkDownloader.DownloadState = ApkDownloader.DownloadState.Idle + ) : ApkDownloader { + private val mutableState = MutableStateFlow(initial) override val downloadState = mutableState.asStateFlow() var startCount = 0