mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-08 06:46:11 +00:00
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) <noreply@anthropic.com>
472 lines
19 KiB
Kotlin
472 lines
19 KiB
Kotlin
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.
|
|
*
|
|
* 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
|
|
|
|
/**
|
|
* 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
|
|
|
|
/**
|
|
* 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 = 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
|
|
blockedRouteUsedTor = null
|
|
}
|
|
}
|
|
|
|
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<Release> =
|
|
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)
|
|
}
|
|
|
|
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
|
|
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
|
|
)
|
|
)
|
|
}
|
|
|
|
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 ->
|
|
val result = fetchLatestReleaseOnce()
|
|
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() < 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)
|
|
}
|
|
|
|
delay(1_000L shl attempt)
|
|
}
|
|
|
|
Result.failure(lastFailure)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Wait for Tor when it is the selected route. This deliberately does not
|
|
* fall back to a direct connection because doing so would violate the
|
|
* user's Tor preference.
|
|
*/
|
|
suspend fun awaitSelectedNetworkRoute(): Boolean {
|
|
return ArtiTorManager.getInstance()
|
|
.awaitSelectedRoute(ROUTE_READY_TIMEOUT_MILLIS)
|
|
}
|
|
|
|
private fun fetchLatestReleaseOnce(): Result<Release> {
|
|
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) {
|
|
blockedUntilMillis = blockedUntil
|
|
// Record which route earned it: the quota belongs to that IP.
|
|
blockedRouteUsedTor = selectedRouteUsesTor()
|
|
}
|
|
|
|
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")
|
|
blockedUntilMillis = 0L
|
|
blockedRouteUsedTor = null
|
|
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
|
|
)
|
|
}
|