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) {