|
@@ -10,10 +10,23 @@ import io.legado.app.help.http.text
|
|
|
import io.legado.app.utils.GSON
|
|
import io.legado.app.utils.GSON
|
|
|
import io.legado.app.utils.fromJsonArray
|
|
import io.legado.app.utils.fromJsonArray
|
|
|
import io.legado.app.utils.fromJsonObject
|
|
import io.legado.app.utils.fromJsonObject
|
|
|
|
|
+import kotlinx.coroutines.CancellationException
|
|
|
import kotlinx.coroutines.CoroutineScope
|
|
import kotlinx.coroutines.CoroutineScope
|
|
|
|
|
+import kotlinx.coroutines.withTimeoutOrNull
|
|
|
|
|
+import okhttp3.Headers
|
|
|
|
|
+import okhttp3.Request
|
|
|
|
|
+import java.time.Instant
|
|
|
|
|
+import java.time.ZoneId
|
|
|
|
|
+import java.time.format.DateTimeFormatter
|
|
|
|
|
|
|
|
object AppUpdateGitHub : AppUpdate.AppUpdateInterface {
|
|
object AppUpdateGitHub : AppUpdate.AppUpdateInterface {
|
|
|
|
|
|
|
|
|
|
+ private const val repoPath = "HapeLee/legado-with-MD3"
|
|
|
|
|
+ private const val githubApiBaseUrl = "https://api.github.com/repos/$repoPath/releases"
|
|
|
|
|
+ private const val updateManifestBaseUrl =
|
|
|
|
|
+ "https://raw.githubusercontent.com/$repoPath/update-manifests"
|
|
|
|
|
+ private const val manifestTimeoutMillis = 2500L
|
|
|
|
|
+
|
|
|
private val checkVariant: AppVariant
|
|
private val checkVariant: AppVariant
|
|
|
get() = when (AppConfig.updateToVariant) {
|
|
get() = when (AppConfig.updateToVariant) {
|
|
|
"official_version" -> AppVariant.OFFICIAL
|
|
"official_version" -> AppVariant.OFFICIAL
|
|
@@ -22,55 +35,96 @@ object AppUpdateGitHub : AppUpdate.AppUpdateInterface {
|
|
|
else -> AppConst.appInfo.appVariant
|
|
else -> AppConst.appInfo.appVariant
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- private suspend fun getLatestRelease(): List<AppReleaseInfo> {
|
|
|
|
|
- val url = if (checkVariant == AppVariant.OFFICIAL)
|
|
|
|
|
- "https://api.github.com/repos/HapeLee/legado-with-MD3/releases/latest"
|
|
|
|
|
- else
|
|
|
|
|
- "https://api.github.com/repos/HapeLee/legado-with-MD3/releases"
|
|
|
|
|
-
|
|
|
|
|
- val res = okHttpClient.newCallResponse { url(url) }
|
|
|
|
|
- if (!res.isSuccessful) throw NoStackTraceException("获取新版本出错(${res.code})")
|
|
|
|
|
-
|
|
|
|
|
- val body = res.body.text()
|
|
|
|
|
- if (body.isBlank()) throw NoStackTraceException("获取新版本出错")
|
|
|
|
|
|
|
+ private suspend fun getLatestRelease(variant: AppVariant): List<AppReleaseInfo> {
|
|
|
|
|
+ val releases = getManifestReleases(variant) ?: getApiReleases(variant)
|
|
|
|
|
+ return releases
|
|
|
|
|
+ .flatMap { it.gitReleaseToAppReleaseInfo() }
|
|
|
|
|
+ .sortedByDescending { it.createdAt }
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- return when (checkVariant) {
|
|
|
|
|
- AppVariant.BETA_RELEASE -> {
|
|
|
|
|
- val releases = GSON.fromJsonArray<GithubRelease>(body)
|
|
|
|
|
- .getOrElse { throw NoStackTraceException("解析失败 ${it.localizedMessage}") }
|
|
|
|
|
|
|
+ private suspend fun getManifestReleases(variant: AppVariant): List<GithubRelease>? {
|
|
|
|
|
+ val releases = when (variant) {
|
|
|
|
|
+ AppVariant.OFFICIAL -> listOfNotNull(getManifestRelease(AppVariant.OFFICIAL))
|
|
|
|
|
+ AppVariant.BETA_RELEASE -> listOfNotNull(getManifestRelease(AppVariant.BETA_RELEASE))
|
|
|
|
|
+ AppVariant.ALL -> listOfNotNull(
|
|
|
|
|
+ getManifestRelease(AppVariant.OFFICIAL),
|
|
|
|
|
+ getManifestRelease(AppVariant.BETA_RELEASE)
|
|
|
|
|
+ )
|
|
|
|
|
+ else -> emptyList()
|
|
|
|
|
+ }
|
|
|
|
|
+ val expectedCount = if (variant == AppVariant.ALL) 2 else 1
|
|
|
|
|
+ return releases.takeIf { it.size == expectedCount }
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- releases.filter { it.isPreRelease }
|
|
|
|
|
- .flatMap { it.gitReleaseToAppReleaseInfo() }
|
|
|
|
|
- .sortedByDescending { it.createdAt }
|
|
|
|
|
|
|
+ private suspend fun getManifestRelease(variant: AppVariant): GithubRelease? {
|
|
|
|
|
+ val channel = when (variant) {
|
|
|
|
|
+ AppVariant.OFFICIAL -> "official"
|
|
|
|
|
+ AppVariant.BETA_RELEASE -> "beta"
|
|
|
|
|
+ else -> return null
|
|
|
|
|
+ }
|
|
|
|
|
+ return try {
|
|
|
|
|
+ withTimeoutOrNull(manifestTimeoutMillis) {
|
|
|
|
|
+ val response = okHttpClient.newCallResponse {
|
|
|
|
|
+ url("$updateManifestBaseUrl/$channel.json")
|
|
|
|
|
+ }
|
|
|
|
|
+ response.use {
|
|
|
|
|
+ if (!it.isSuccessful) return@withTimeoutOrNull null
|
|
|
|
|
+ val release = GSON.fromJsonObject<GithubRelease>(it.body.text()).getOrNull()
|
|
|
|
|
+ ?: return@withTimeoutOrNull null
|
|
|
|
|
+ val expectedPreRelease = variant == AppVariant.BETA_RELEASE
|
|
|
|
|
+ release.takeIf {
|
|
|
|
|
+ it.isPreRelease == expectedPreRelease &&
|
|
|
|
|
+ it.assets.orEmpty().any { asset -> asset.isValid }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
+ } catch (error: CancellationException) {
|
|
|
|
|
+ throw error
|
|
|
|
|
+ } catch (_: Exception) {
|
|
|
|
|
+ null
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- AppVariant.OFFICIAL -> {
|
|
|
|
|
- val release = GSON.fromJsonObject<GithubRelease>(body)
|
|
|
|
|
- .getOrElse { throw NoStackTraceException("解析失败 ${it.localizedMessage}") }
|
|
|
|
|
-
|
|
|
|
|
- release.gitReleaseToAppReleaseInfo()
|
|
|
|
|
- .sortedByDescending { it.createdAt }
|
|
|
|
|
|
|
+ private suspend fun getApiReleases(variant: AppVariant): List<GithubRelease> {
|
|
|
|
|
+ if (variant == AppVariant.ALL) {
|
|
|
|
|
+ val releases = parseReleaseList(getGithubApiBody(githubApiBaseUrl))
|
|
|
|
|
+ val latestOfficial = try {
|
|
|
|
|
+ GSON.fromJsonObject<GithubRelease>(
|
|
|
|
|
+ getGithubApiBody("$githubApiBaseUrl/latest")
|
|
|
|
|
+ ).getOrThrow()
|
|
|
|
|
+ } catch (error: CancellationException) {
|
|
|
|
|
+ throw error
|
|
|
|
|
+ } catch (_: Exception) {
|
|
|
|
|
+ null
|
|
|
}
|
|
}
|
|
|
|
|
+ return (listOfNotNull(latestOfficial) + releases).distinctBy { it.tagName }
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- AppVariant.ALL -> {
|
|
|
|
|
- val releases = GSON.fromJsonArray<GithubRelease>(body)
|
|
|
|
|
|
|
+ val url = if (variant == AppVariant.OFFICIAL) {
|
|
|
|
|
+ "$githubApiBaseUrl/latest"
|
|
|
|
|
+ } else {
|
|
|
|
|
+ githubApiBaseUrl
|
|
|
|
|
+ }
|
|
|
|
|
+ val body = getGithubApiBody(url)
|
|
|
|
|
+ return when (variant) {
|
|
|
|
|
+ AppVariant.OFFICIAL -> listOf(
|
|
|
|
|
+ GSON.fromJsonObject<GithubRelease>(body)
|
|
|
.getOrElse { throw NoStackTraceException("解析失败 ${it.localizedMessage}") }
|
|
.getOrElse { throw NoStackTraceException("解析失败 ${it.localizedMessage}") }
|
|
|
-
|
|
|
|
|
- releases.flatMap { it.gitReleaseToAppReleaseInfo() }
|
|
|
|
|
- .sortedByDescending { it.createdAt }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
|
|
+ )
|
|
|
|
|
+ AppVariant.BETA_RELEASE -> parseReleaseList(body)
|
|
|
|
|
+ .filter { it.isPreRelease }
|
|
|
else -> emptyList()
|
|
else -> emptyList()
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- suspend fun getReleaseByTag(tag: String): AppUpdate.UpdateInfo? {
|
|
|
|
|
- val url = "https://api.github.com/repos/HapeLee/legado-with-MD3/releases/tags/$tag"
|
|
|
|
|
- val res = okHttpClient.newCallResponse { url(url) }
|
|
|
|
|
- if (!res.isSuccessful) return null
|
|
|
|
|
|
|
+ private fun parseReleaseList(body: String): List<GithubRelease> {
|
|
|
|
|
+ return GSON.fromJsonArray<GithubRelease>(body)
|
|
|
|
|
+ .getOrElse { throw NoStackTraceException("解析失败 ${it.localizedMessage}") }
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- val body = res.body.text()
|
|
|
|
|
- val release = GSON.fromJsonObject<GithubRelease>(body).getOrElse { return null }
|
|
|
|
|
|
|
+ suspend fun getReleaseByTag(tag: String): AppUpdate.UpdateInfo? {
|
|
|
|
|
+ val manifestRelease = findManifestRelease(tag)
|
|
|
|
|
+ val release = manifestRelease ?: getApiReleaseByTag(tag) ?: return null
|
|
|
val info = release.gitReleaseToAppReleaseInfo().firstOrNull() ?: return null
|
|
val info = release.gitReleaseToAppReleaseInfo().firstOrNull() ?: return null
|
|
|
|
|
|
|
|
return AppUpdate.UpdateInfo(
|
|
return AppUpdate.UpdateInfo(
|
|
@@ -81,15 +135,61 @@ object AppUpdateGitHub : AppUpdate.AppUpdateInterface {
|
|
|
)
|
|
)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ private suspend fun findManifestRelease(tag: String): GithubRelease? {
|
|
|
|
|
+ val variants = if (tag.contains("beta", ignoreCase = true)) {
|
|
|
|
|
+ listOf(AppVariant.BETA_RELEASE, AppVariant.OFFICIAL)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ listOf(AppVariant.OFFICIAL, AppVariant.BETA_RELEASE)
|
|
|
|
|
+ }
|
|
|
|
|
+ variants.forEach { variant ->
|
|
|
|
|
+ getManifestRelease(variant)?.let { release ->
|
|
|
|
|
+ if (release.tagName == tag) return release
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return null
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private suspend fun getApiReleaseByTag(tag: String): GithubRelease? {
|
|
|
|
|
+ val response = okHttpClient.newCallResponse {
|
|
|
|
|
+ url("$githubApiBaseUrl/tags/$tag")
|
|
|
|
|
+ addGithubApiHeaders()
|
|
|
|
|
+ }
|
|
|
|
|
+ response.use {
|
|
|
|
|
+ if (it.code == 404) return null
|
|
|
|
|
+ val body = it.body.text()
|
|
|
|
|
+ if (!it.isSuccessful) throw githubApiException(it.code, it.headers, body)
|
|
|
|
|
+ return GSON.fromJsonObject<GithubRelease>(body).getOrNull()
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private suspend fun getGithubApiBody(url: String): String {
|
|
|
|
|
+ val response = okHttpClient.newCallResponse {
|
|
|
|
|
+ url(url)
|
|
|
|
|
+ addGithubApiHeaders()
|
|
|
|
|
+ }
|
|
|
|
|
+ response.use {
|
|
|
|
|
+ val body = it.body.text()
|
|
|
|
|
+ if (!it.isSuccessful) throw githubApiException(it.code, it.headers, body)
|
|
|
|
|
+ if (body.isBlank()) throw NoStackTraceException("获取新版本出错")
|
|
|
|
|
+ return body
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private fun Request.Builder.addGithubApiHeaders() {
|
|
|
|
|
+ header("Accept", "application/vnd.github+json")
|
|
|
|
|
+ header("X-GitHub-Api-Version", "2026-03-10")
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
override fun check(scope: CoroutineScope): Coroutine<AppUpdate.UpdateInfo> {
|
|
override fun check(scope: CoroutineScope): Coroutine<AppUpdate.UpdateInfo> {
|
|
|
return Coroutine.async(scope) {
|
|
return Coroutine.async(scope) {
|
|
|
val currentVersion = AppConst.appInfo.versionName
|
|
val currentVersion = AppConst.appInfo.versionName
|
|
|
- val releases = getLatestRelease()
|
|
|
|
|
|
|
+ val variant = checkVariant
|
|
|
|
|
+ val releases = getLatestRelease(variant)
|
|
|
|
|
|
|
|
- val filtered = if (checkVariant == AppVariant.ALL) {
|
|
|
|
|
|
|
+ val filtered = if (variant == AppVariant.ALL) {
|
|
|
releases
|
|
releases
|
|
|
} else {
|
|
} else {
|
|
|
- releases.filter { it.appVariant == checkVariant }
|
|
|
|
|
|
|
+ releases.filter { it.appVariant == variant }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
val latest = filtered.firstOrNull { r ->
|
|
val latest = filtered.firstOrNull { r ->
|
|
@@ -165,4 +265,40 @@ object AppUpdateGitHub : AppUpdate.AppUpdateInterface {
|
|
|
fun String.versionCompare(other: String): Int {
|
|
fun String.versionCompare(other: String): Int {
|
|
|
return SemVer.parse(this).compareTo(SemVer.parse(other))
|
|
return SemVer.parse(this).compareTo(SemVer.parse(other))
|
|
|
}
|
|
}
|
|
|
-}
|
|
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+internal fun githubApiException(
|
|
|
|
|
+ code: Int,
|
|
|
|
|
+ headers: Headers,
|
|
|
|
|
+ body: String,
|
|
|
|
|
+ zoneId: ZoneId = ZoneId.systemDefault()
|
|
|
|
|
+): NoStackTraceException {
|
|
|
|
|
+ if (code == 403 || code == 429) {
|
|
|
|
|
+ val retryAfter = headers["Retry-After"]?.toLongOrNull()
|
|
|
|
|
+ if (retryAfter != null) {
|
|
|
|
|
+ return NoStackTraceException("GitHub API 请求受限,请在 $retryAfter 秒后重试")
|
|
|
|
|
+ }
|
|
|
|
|
+ if (headers["X-RateLimit-Remaining"] == "0") {
|
|
|
|
|
+ val resetAt = formatRateLimitReset(headers["X-RateLimit-Reset"], zoneId)
|
|
|
|
|
+ val message = resetAt?.let { "GitHub API 请求额度已用完,请在 $it 后重试" }
|
|
|
|
|
+ ?: "GitHub API 请求额度已用完,请稍后重试"
|
|
|
|
|
+ return NoStackTraceException(message)
|
|
|
|
|
+ }
|
|
|
|
|
+ return NoStackTraceException("GitHub API 暂时限制请求,请稍后重试")
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ val detail = GSON.fromJsonObject<Map<String, String>>(body)
|
|
|
|
|
+ .getOrNull()
|
|
|
|
|
+ ?.get("message")
|
|
|
|
|
+ ?.takeIf { it.isNotBlank() }
|
|
|
|
|
+ return NoStackTraceException(
|
|
|
|
|
+ detail?.let { "获取新版本出错($code): $it" }
|
|
|
|
|
+ ?: "获取新版本出错($code)"
|
|
|
|
|
+ )
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+private fun formatRateLimitReset(value: String?, zoneId: ZoneId): String? {
|
|
|
|
|
+ val epochSeconds = value?.toLongOrNull() ?: return null
|
|
|
|
|
+ return DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")
|
|
|
|
|
+ .format(Instant.ofEpochSecond(epochSeconds).atZone(zoneId))
|
|
|
|
|
+}
|