277 lines
10 KiB
Kotlin
277 lines
10 KiB
Kotlin
package com.wdtt.client
|
|
|
|
import android.util.Log
|
|
import kotlinx.coroutines.Dispatchers
|
|
import kotlinx.coroutines.withContext
|
|
import org.json.JSONArray
|
|
import org.json.JSONObject
|
|
import java.net.HttpURLConnection
|
|
import java.net.URL
|
|
|
|
const val UPDATE_CHECK_NEVER = -1
|
|
const val DEFAULT_UPDATE_CHECK_INTERVAL_HOURS = 12
|
|
const val UPDATE_DIALOG_ACTION_POSTPONED = "postponed"
|
|
const val UPDATE_DIALOG_ACTION_UPDATE = "update"
|
|
|
|
private const val UPDATE_LOG_TAG = "WDTT"
|
|
private const val GITHUB_RELEASES_URL = "https://api.github.com/repos/amurcanov/proxy-turn-vk-android/releases?per_page=30"
|
|
private const val GITHUB_LATEST_RELEASE_URL = "https://api.github.com/repos/amurcanov/proxy-turn-vk-android/releases/latest"
|
|
private const val GITHUB_LATEST_RELEASE_WEB_URL = "https://github.com/amurcanov/proxy-turn-vk-android/releases/latest"
|
|
private const val GITHUB_RELEASE_TAG_URL_PREFIX = "https://github.com/amurcanov/proxy-turn-vk-android/releases/tag/"
|
|
private const val GITHUB_TAGS_URL = "https://api.github.com/repos/amurcanov/proxy-turn-vk-android/tags?per_page=100"
|
|
private const val GITHUB_TAG_TREE_URL_PREFIX = "https://github.com/amurcanov/proxy-turn-vk-android/tree/"
|
|
private const val GITHUB_API_RATE_LIMIT_FALLBACK_MS = 30L * 60L * 1000L
|
|
private val VERSION_NUMBER_REGEX = Regex("\\d+(?:\\.\\d+)*")
|
|
|
|
@Volatile
|
|
private var githubApiCooldownUntilMs = 0L
|
|
|
|
fun updateIntervalHoursToMillis(hours: Int): Long? = when {
|
|
hours <= 0 -> null
|
|
else -> hours * 60L * 60L * 1000L
|
|
}
|
|
|
|
data class AppReleaseInfo(
|
|
val versionTag: String,
|
|
val releaseUrl: String,
|
|
val source: RemoteVersionSource
|
|
)
|
|
|
|
enum class RemoteVersionSource {
|
|
Release,
|
|
Tag
|
|
}
|
|
|
|
suspend fun fetchLatestReleaseInfo(localVersion: String? = null): AppReleaseInfo? = withContext(Dispatchers.IO) {
|
|
val latestRelease = fetchReleaseFromLatestWebRedirect()
|
|
?: fetchReleaseFromLatestEndpoint()
|
|
?: fetchLatestStableReleaseFromList()
|
|
val latestTag = fetchLatestTagFromList()
|
|
|
|
when {
|
|
latestRelease == null -> latestTag
|
|
latestTag == null -> latestRelease
|
|
isNewerVersion(latestRelease.versionTag, latestTag.versionTag) -> latestTag
|
|
else -> latestRelease
|
|
}
|
|
}
|
|
|
|
fun isNewerVersion(local: String, remote: String): Boolean {
|
|
val localParts = versionParts(local)
|
|
val remoteParts = versionParts(remote)
|
|
if (remoteParts.isEmpty()) return false
|
|
|
|
val maxLen = maxOf(localParts.size, remoteParts.size)
|
|
for (i in 0 until maxLen) {
|
|
val localPart = localParts.getOrElse(i) { 0 }
|
|
val remotePart = remoteParts.getOrElse(i) { 0 }
|
|
if (remotePart > localPart) return true
|
|
if (remotePart < localPart) return false
|
|
}
|
|
return false
|
|
}
|
|
|
|
private fun fetchLatestStableReleaseFromList(): AppReleaseInfo? {
|
|
val response = fetchGitHubApi(GITHUB_RELEASES_URL) ?: return null
|
|
val releases = try {
|
|
JSONArray(response)
|
|
} catch (e: Exception) {
|
|
Log.w(UPDATE_LOG_TAG, "[WARN] Update check: failed to parse releases list", e)
|
|
return null
|
|
}
|
|
|
|
var bestRelease: AppReleaseInfo? = null
|
|
for (i in 0 until releases.length()) {
|
|
val json = releases.optJSONObject(i) ?: continue
|
|
if (json.optBoolean("draft") || json.optBoolean("prerelease")) continue
|
|
val release = json.toAppReleaseInfo() ?: continue
|
|
if (bestRelease == null || isNewerVersion(bestRelease.versionTag, release.versionTag)) {
|
|
bestRelease = release
|
|
}
|
|
}
|
|
return bestRelease
|
|
}
|
|
|
|
private fun fetchLatestTagFromList(): AppReleaseInfo? {
|
|
val response = fetchGitHubApi(GITHUB_TAGS_URL) ?: return null
|
|
val tags = try {
|
|
JSONArray(response)
|
|
} catch (e: Exception) {
|
|
Log.w(UPDATE_LOG_TAG, "[WARN] Update check: failed to parse tags list", e)
|
|
return null
|
|
}
|
|
|
|
var bestTag: AppReleaseInfo? = null
|
|
for (i in 0 until tags.length()) {
|
|
val json = tags.optJSONObject(i) ?: continue
|
|
val tagName = normalizeVersionTag(json.optString("name"))
|
|
if (tagName.isBlank()) continue
|
|
val tag = AppReleaseInfo(
|
|
versionTag = tagName,
|
|
releaseUrl = "$GITHUB_TAG_TREE_URL_PREFIX$tagName",
|
|
source = RemoteVersionSource.Tag
|
|
)
|
|
if (bestTag == null || isNewerVersion(bestTag.versionTag, tag.versionTag)) {
|
|
bestTag = tag
|
|
}
|
|
}
|
|
return bestTag
|
|
}
|
|
|
|
private fun fetchReleaseFromLatestEndpoint(): AppReleaseInfo? {
|
|
val response = fetchGitHubApi(GITHUB_LATEST_RELEASE_URL) ?: return null
|
|
val json = try {
|
|
JSONObject(response)
|
|
} catch (e: Exception) {
|
|
Log.w(UPDATE_LOG_TAG, "[WARN] Update check: failed to parse latest release", e)
|
|
return null
|
|
}
|
|
return json.toAppReleaseInfo()
|
|
}
|
|
|
|
private fun fetchReleaseFromLatestWebRedirect(): AppReleaseInfo? {
|
|
var conn: HttpURLConnection? = null
|
|
return try {
|
|
conn = URL(GITHUB_LATEST_RELEASE_WEB_URL).openConnection() as HttpURLConnection
|
|
applyNoCacheHeaders(conn)
|
|
conn.instanceFollowRedirects = false
|
|
conn.requestMethod = "GET"
|
|
conn.setRequestProperty("Accept", "text/html,*/*")
|
|
conn.setRequestProperty("User-Agent", "WDTTAndroid/${BuildConfig.VERSION_NAME}")
|
|
conn.connectTimeout = 8_000
|
|
conn.readTimeout = 8_000
|
|
|
|
val responseCode = conn.responseCode
|
|
val location = conn.getHeaderField("Location")
|
|
if (!location.isNullOrBlank()) {
|
|
val releaseUrl = URL(URL(GITHUB_LATEST_RELEASE_WEB_URL), location).toString()
|
|
val versionTag = extractTagFromReleaseUrl(releaseUrl)
|
|
if (!versionTag.isNullOrBlank()) {
|
|
return AppReleaseInfo(versionTag, releaseUrl, RemoteVersionSource.Release)
|
|
}
|
|
}
|
|
|
|
if (responseCode in 200..299) {
|
|
val response = conn.inputStream.bufferedReader().use { it.readText() }
|
|
val versionTag = Regex("/releases/tag/([^\"?#<]+)").find(response)?.groupValues?.getOrNull(1)
|
|
if (!versionTag.isNullOrBlank()) {
|
|
return AppReleaseInfo(versionTag, "$GITHUB_RELEASE_TAG_URL_PREFIX$versionTag", RemoteVersionSource.Release)
|
|
}
|
|
}
|
|
|
|
Log.w(UPDATE_LOG_TAG, "[WARN] Update check: GitHub web fallback returned $responseCode")
|
|
null
|
|
} catch (e: Exception) {
|
|
Log.w(UPDATE_LOG_TAG, "[WARN] Update check: GitHub web fallback failed", e)
|
|
null
|
|
} finally {
|
|
conn?.disconnect()
|
|
}
|
|
}
|
|
|
|
private fun fetchGitHubApi(url: String): String? {
|
|
val now = System.currentTimeMillis()
|
|
if (now < githubApiCooldownUntilMs) return null
|
|
return fetchHttpText(
|
|
url = url,
|
|
sourceLabel = "GitHub API",
|
|
accept = "application/vnd.github+json",
|
|
isGitHubApi = true
|
|
)
|
|
}
|
|
|
|
private fun fetchHttpText(
|
|
url: String,
|
|
sourceLabel: String,
|
|
accept: String,
|
|
isGitHubApi: Boolean = false
|
|
): String? {
|
|
var conn: HttpURLConnection? = null
|
|
return try {
|
|
conn = URL(url).openConnection() as HttpURLConnection
|
|
applyNoCacheHeaders(conn)
|
|
conn.requestMethod = "GET"
|
|
conn.setRequestProperty("Accept", accept)
|
|
if (isGitHubApi) {
|
|
conn.setRequestProperty("X-GitHub-Api-Version", "2022-11-28")
|
|
}
|
|
conn.setRequestProperty("User-Agent", "WDTTAndroid/${BuildConfig.VERSION_NAME}")
|
|
conn.connectTimeout = 8_000
|
|
conn.readTimeout = 8_000
|
|
|
|
val responseCode = conn.responseCode
|
|
val stream = if (responseCode in 200..299) conn.inputStream else conn.errorStream
|
|
val response = stream?.bufferedReader()?.use { it.readText() }.orEmpty()
|
|
|
|
if (responseCode in 200..299) {
|
|
if (isGitHubApi) githubApiCooldownUntilMs = 0L
|
|
response
|
|
} else {
|
|
if (isGitHubApi) noteGitHubApiCooldown(conn, responseCode, response)
|
|
Log.w(
|
|
UPDATE_LOG_TAG,
|
|
"[WARN] Update check: $sourceLabel returned $responseCode ${response.take(300)}"
|
|
)
|
|
null
|
|
}
|
|
} catch (e: Exception) {
|
|
Log.w(UPDATE_LOG_TAG, "[WARN] Update check: $sourceLabel request failed", e)
|
|
null
|
|
} finally {
|
|
conn?.disconnect()
|
|
}
|
|
}
|
|
|
|
private fun applyNoCacheHeaders(conn: HttpURLConnection) {
|
|
conn.useCaches = false
|
|
conn.setRequestProperty("Cache-Control", "no-cache, no-store, max-age=0")
|
|
conn.setRequestProperty("Pragma", "no-cache")
|
|
conn.setRequestProperty("Expires", "0")
|
|
}
|
|
|
|
private fun noteGitHubApiCooldown(conn: HttpURLConnection, responseCode: Int, response: String) {
|
|
if (responseCode != HttpURLConnection.HTTP_FORBIDDEN && responseCode != 429) return
|
|
val now = System.currentTimeMillis()
|
|
val retryAfterUntil = conn.getHeaderField("Retry-After")?.trim()?.toLongOrNull()?.takeIf { it > 0L }?.let { now + it * 1000L }
|
|
val rateLimitResetUntil = conn.getHeaderField("X-RateLimit-Reset")?.trim()?.toLongOrNull()?.takeIf { it > 0L }?.let { it * 1000L }
|
|
val fallbackUntil = now + if (response.contains("rate limit", ignoreCase = true)) GITHUB_API_RATE_LIMIT_FALLBACK_MS else 5L * 60L * 1000L
|
|
val cooldownUntil = listOfNotNull(retryAfterUntil, rateLimitResetUntil).filter { it > now }.minOrNull() ?: fallbackUntil
|
|
if (cooldownUntil > githubApiCooldownUntilMs) {
|
|
githubApiCooldownUntilMs = cooldownUntil
|
|
Log.w(
|
|
UPDATE_LOG_TAG,
|
|
"[WARN] Update check: GitHub API cooldown ${(cooldownUntil - now) / 1000}s after HTTP $responseCode"
|
|
)
|
|
}
|
|
}
|
|
|
|
private fun JSONObject.toAppReleaseInfo(): AppReleaseInfo? {
|
|
val versionTag = normalizeVersionTag(optString("tag_name"))
|
|
val releaseUrl = optString("html_url").trim()
|
|
if (versionTag.isBlank() || releaseUrl.isBlank()) return null
|
|
return AppReleaseInfo(versionTag, releaseUrl, RemoteVersionSource.Release)
|
|
}
|
|
|
|
private fun versionParts(version: String): List<Int> {
|
|
val normalized = VERSION_NUMBER_REGEX.find(version.trim())?.value ?: return emptyList()
|
|
return normalized.split(".").mapNotNull { it.toIntOrNull() }
|
|
}
|
|
|
|
private fun normalizeVersionTag(version: String): String {
|
|
val trimmed = version.trim()
|
|
if (trimmed.isBlank()) return ""
|
|
return if (trimmed.startsWith("v", ignoreCase = true)) trimmed else "v$trimmed"
|
|
}
|
|
|
|
private fun extractTagFromReleaseUrl(releaseUrl: String): String? {
|
|
val marker = "/releases/tag/"
|
|
val index = releaseUrl.indexOf(marker)
|
|
if (index < 0) return null
|
|
return releaseUrl.substring(index + marker.length)
|
|
.substringBefore("?")
|
|
.substringBefore("#")
|
|
.substringBefore("/")
|
|
.takeIf { it.isNotBlank() }
|
|
?.let(::normalizeVersionTag)
|
|
}
|