Initial v1.1.8 Commits

This commit is contained in:
amurcanov
2026-05-23 22:18:08 +03:00
commit ac86caaf8b
76 changed files with 15693 additions and 0 deletions
@@ -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)
}