Compare commits
3 Commits
v1.9.2
...
648c63bfbc
| Author | SHA1 | Date | |
|---|---|---|---|
| 648c63bfbc | |||
| 00bdbe2d1e | |||
| a3f67a0a7c |
@@ -13,8 +13,8 @@ android {
|
|||||||
applicationId = "com.a2i.forwarder"
|
applicationId = "com.a2i.forwarder"
|
||||||
minSdk = 34
|
minSdk = 34
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 30
|
versionCode = 34
|
||||||
versionName = "1.9.1"
|
versionName = "1.9.5"
|
||||||
}
|
}
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ class UpdateChecker(private val context: Context) {
|
|||||||
val updateInfo = MutableStateFlow<UpdateInfo?>(null)
|
val updateInfo = MutableStateFlow<UpdateInfo?>(null)
|
||||||
val downloading = MutableStateFlow(false)
|
val downloading = MutableStateFlow(false)
|
||||||
val downloadProgress = MutableStateFlow(0)
|
val downloadProgress = MutableStateFlow(0)
|
||||||
|
val downloadSource = MutableStateFlow<String?>(null) // "Gitea" / "GitHub" / null
|
||||||
val lastError = MutableStateFlow<String?>(null)
|
val lastError = MutableStateFlow<String?>(null)
|
||||||
private var lastNotifiedVersion: String? = null // 记录已推送的版本,避免重复通知
|
private var lastNotifiedVersion: String? = null // 记录已推送的版本,避免重复通知
|
||||||
|
|
||||||
@@ -69,19 +70,15 @@ class UpdateChecker(private val context: Context) {
|
|||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
if (throttle && now - settings.lastUpdateCheckTime.value < CHECK_INTERVAL_MS) return@runCatching updateInfo.value
|
if (throttle && now - settings.lastUpdateCheckTime.value < CHECK_INTERVAL_MS) return@runCatching updateInfo.value
|
||||||
|
|
||||||
// 先查 GitHub(公开默认源),从其 release notes 动态发现额外源(如自建 Gitea),取最高版本
|
// 双源查询:Gitea(国内优先)+ GitHub(备用),取最高版本
|
||||||
// 同版本时 Gitea 优先(国内可达,GitHub 可能被墙导致下载失败)
|
// 同版本时 Gitea 优先(国内可达,GitHub 可能被墙导致下载失败)
|
||||||
val infos = mutableListOf<UpdateInfo>()
|
val infos = mutableListOf<UpdateInfo>()
|
||||||
var ghInfo: UpdateInfo? = null
|
var ghInfo: UpdateInfo? = null
|
||||||
var giteaInfo: UpdateInfo? = null
|
var giteaInfo: UpdateInfo? = null
|
||||||
val gh = runCatching { fetchRelease(GITHUB_API) }.getOrNull()
|
giteaInfo = runCatching { fetchRelease(GITEA_API) }.getOrNull()
|
||||||
if (gh != null) {
|
if (giteaInfo != null) infos.add(giteaInfo)
|
||||||
ghInfo = gh
|
ghInfo = runCatching { fetchRelease(GITHUB_API) }.getOrNull()
|
||||||
giteaInfo = parseUpdateSource(gh.releaseNotes)?.let { runCatching { fetchRelease(it) }.getOrNull() }
|
if (ghInfo != null) infos.add(ghInfo)
|
||||||
// Gitea 放前面,同版本时优先用它下载(国内可达)
|
|
||||||
if (giteaInfo != null) infos.add(giteaInfo)
|
|
||||||
infos.add(gh)
|
|
||||||
}
|
|
||||||
settings.setLastUpdateCheck(now)
|
settings.setLastUpdateCheck(now)
|
||||||
if (infos.isEmpty()) {
|
if (infos.isEmpty()) {
|
||||||
lastError.value = "所有更新源不可达"
|
lastError.value = "所有更新源不可达"
|
||||||
@@ -184,33 +181,49 @@ class UpdateChecker(private val context: Context) {
|
|||||||
suspend fun download(info: UpdateInfo): File? = withContext(Dispatchers.IO) {
|
suspend fun download(info: UpdateInfo): File? = withContext(Dispatchers.IO) {
|
||||||
downloading.value = true
|
downloading.value = true
|
||||||
downloadProgress.value = 0
|
downloadProgress.value = 0
|
||||||
|
downloadSource.value = null
|
||||||
lastError.value = null
|
lastError.value = null
|
||||||
// 主 URL 失败时自动尝试备选 URL
|
// 主 URL (Gitea) 失败时自动尝试备选 URL (GitHub)
|
||||||
val urls = listOf(info.apkUrl) + (info.fallbackUrl?.let { listOf(it) } ?: emptyList())
|
val sources = listOf(
|
||||||
for ((idx, url) in urls.withIndex()) {
|
info.apkUrl to sourceLabel(info.apkUrl),
|
||||||
if (idx > 0) downloadProgress.value = 0 // 重试时重置进度
|
) + (info.fallbackUrl?.let { listOf(it to sourceLabel(it)) } ?: emptyList())
|
||||||
val file = tryDownload(url, info.latestVersion)
|
for ((idx, pair) in sources.withIndex()) {
|
||||||
|
val (url, label) = pair
|
||||||
|
if (idx > 0) { downloadProgress.value = 0; downloadSource.value = null }
|
||||||
|
downloadSource.value = label
|
||||||
|
val file = tryDownload(url, info.latestVersion, info.apkSize)
|
||||||
if (file != null) {
|
if (file != null) {
|
||||||
downloading.value = false
|
downloading.value = false
|
||||||
|
downloadSource.value = null
|
||||||
return@withContext file
|
return@withContext file
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
downloading.value = false
|
downloading.value = false
|
||||||
|
downloadSource.value = null
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun tryDownload(url: String, version: String): File? {
|
private fun sourceLabel(url: String): String =
|
||||||
|
if (url.contains("songer.eu.org") || url.contains("sunlunfan")) "Gitea" else "GitHub"
|
||||||
|
|
||||||
|
private suspend fun tryDownload(url: String, version: String, expectedSize: Long): File? {
|
||||||
|
// 专用下载 client:更长超时(120s),避免大文件慢网超时截断
|
||||||
|
val dlClient = OkHttpClient.Builder()
|
||||||
|
.connectTimeout(15, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(120, TimeUnit.SECONDS)
|
||||||
|
.retryOnConnectionFailure(true)
|
||||||
|
.build()
|
||||||
return try {
|
return try {
|
||||||
val req = Request.Builder().url(url).get().build()
|
val req = Request.Builder().url(url).get().build()
|
||||||
http.newCall(req).execute().use { res ->
|
dlClient.newCall(req).execute().use { res ->
|
||||||
if (!res.isSuccessful) { lastError.value = "HTTP ${res.code}"; return@tryDownload null }
|
if (!res.isSuccessful) { lastError.value = "HTTP ${res.code}"; return null }
|
||||||
val body = res.body ?: run { lastError.value = "空响应"; return@tryDownload null }
|
val body = res.body ?: run { lastError.value = "空响应"; return null }
|
||||||
val total = body.contentLength()
|
val total = body.contentLength()
|
||||||
val dir = context.getExternalFilesDir(null) ?: context.cacheDir
|
val dir = context.getExternalFilesDir(null) ?: context.cacheDir
|
||||||
val target = File(dir, "gotmsg-${version}.apk")
|
val target = File(dir, "gotmsg-${version}.apk")
|
||||||
target.outputStream().use { sink ->
|
target.outputStream().use { sink ->
|
||||||
val input = body.byteStream()
|
val input = body.byteStream()
|
||||||
val buf = ByteArray(8 * 1024)
|
val buf = ByteArray(64 * 1024) // 64KB buffer 提升大文件速度
|
||||||
var read = 0L
|
var read = 0L
|
||||||
while (true) {
|
while (true) {
|
||||||
val n = input.read(buf)
|
val n = input.read(buf)
|
||||||
@@ -220,6 +233,19 @@ class UpdateChecker(private val context: Context) {
|
|||||||
if (total > 0) downloadProgress.value = (read * 100 / total).toInt()
|
if (total > 0) downloadProgress.value = (read * 100 / total).toInt()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 校验文件大小:Content-Length 或 release 声明的大小
|
||||||
|
val actualSize = target.length()
|
||||||
|
val refSize = if (total > 0) total else expectedSize
|
||||||
|
if (refSize > 0 && actualSize < refSize) {
|
||||||
|
lastError.value = "文件不完整(${actualSize}/${refSize} 字节),请重试"
|
||||||
|
target.delete()
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (actualSize < 1_000_000) {
|
||||||
|
lastError.value = "文件过小,可能下载了错误页面"
|
||||||
|
target.delete()
|
||||||
|
return null
|
||||||
|
}
|
||||||
downloadProgress.value = 100
|
downloadProgress.value = 100
|
||||||
target
|
target
|
||||||
}
|
}
|
||||||
@@ -273,26 +299,10 @@ class UpdateChecker(private val context: Context) {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 从 release notes 的 HTML 注释里解析额外更新源(加扰编码,兼容 Base64/明文)。 */
|
|
||||||
private fun parseUpdateSource(body: String): String? {
|
|
||||||
val raw = Regex("""update-source:\s*(\S+)""").find(body)?.groupValues?.getOrNull(1) ?: return null
|
|
||||||
// 加扰格式:Base64(反转(XOR(明文, key)))。尝试完整解扰,失败则依次尝试 Base64/明文。
|
|
||||||
val b64 = runCatching { java.util.Base64.getDecoder().decode(raw) }.getOrNull() ?: return raw
|
|
||||||
val unscrambled = unscramble(b64)
|
|
||||||
return unscrambled ?: runCatching { String(b64, Charsets.UTF_8) }.getOrNull() ?: raw
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 解扰:反转字节 → XOR 每字节 → UTF-8 字符串。 */
|
|
||||||
private fun unscramble(data: ByteArray): String? = runCatching {
|
|
||||||
val key = SCRAMBLE_KEY
|
|
||||||
val reversed = data.reversedArray()
|
|
||||||
String(ByteArray(reversed.size) { i -> ((reversed[i].toInt() and 0xFF) xor (key[i % key.size].toInt() and 0xFF)).toByte() }, Charsets.UTF_8)
|
|
||||||
}.getOrNull()
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val GITHUB_API = "https://api.github.com/repos/lsxf/a2i/releases/latest"
|
const val GITHUB_API = "https://api.github.com/repos/lsxf/a2i/releases/latest"
|
||||||
|
const val GITEA_API = "https://gitea.songer.eu.org/api/v1/repos/song/a2i/releases/latest"
|
||||||
const val CHECK_INTERVAL_MS = 24L * 60 * 60 * 1000
|
const val CHECK_INTERVAL_MS = 24L * 60 * 60 * 1000
|
||||||
private val SCRAMBLE_KEY = byteArrayOf(0x4B, 0x6F, 0x72, 0x6E) // "Korn"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun formatSize(bytes: Long): String {
|
fun formatSize(bytes: Long): String {
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ fun UpdateDialog(info: UpdateInfo, onDismiss: () -> Unit) {
|
|||||||
val app = A2iApp.instance
|
val app = A2iApp.instance
|
||||||
val downloading by app.updateChecker.downloading.collectAsState()
|
val downloading by app.updateChecker.downloading.collectAsState()
|
||||||
val progress by app.updateChecker.downloadProgress.collectAsState()
|
val progress by app.updateChecker.downloadProgress.collectAsState()
|
||||||
|
val downloadSource by app.updateChecker.downloadSource.collectAsState()
|
||||||
var downloadedFile by remember { mutableStateOf<File?>(null) }
|
var downloadedFile by remember { mutableStateOf<File?>(null) }
|
||||||
|
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
@@ -70,7 +71,7 @@ fun UpdateDialog(info: UpdateInfo, onDismiss: () -> Unit) {
|
|||||||
)
|
)
|
||||||
Spacer(Modifier.size(4.dp))
|
Spacer(Modifier.size(4.dp))
|
||||||
Text(
|
Text(
|
||||||
"正在下载更新…",
|
"正在下载更新…${downloadSource?.let { "(来自 $it)" } ?: ""}",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user