Initial v1.1.8 Commits
This commit is contained in:
@@ -0,0 +1,276 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user