feat: eye-catching update banner + cross-channel notify + periodic check

- HomeScreen update banner: gradient background (tertiary→primary), white
  text, large version number, rocket emoji CTA button
- UpdateChecker now auto-notifies via all enabled channels (Bark/ntfy/
  email) when a new version is discovered, with version/size/notes
- Periodic check every 24h via coroutine delay loop (replaces one-shot
  startup check only); also honors updateCheckEnabled toggle
- version 1.7.0 -> 1.7.1 (code 13 -> 14)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-09 18:03:42 +08:00
parent 2470360bbe
commit 7bd3dfc102
3 changed files with 96 additions and 23 deletions
+2 -2
View File
@@ -13,8 +13,8 @@ android {
applicationId = "com.a2i.forwarder" applicationId = "com.a2i.forwarder"
minSdk = 34 minSdk = 34
targetSdk = 36 targetSdk = 36
versionCode = 13 versionCode = 14
versionName = "1.7.0" versionName = "1.7.1"
} }
signingConfigs { signingConfigs {
@@ -9,6 +9,7 @@ import com.a2i.forwarder.A2iApp
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -27,7 +28,7 @@ data class UpdateInfo(
) )
/** /**
* 应用内更新:拉取 GitHub Releases 最新版 → 版本比较 → 下载 APK → 调起系统安装器。 * 应用内更新:定时巡检 Gitea Releases 最新版 → 版本比较 → 醒目横幅 + 自动推送通知 → 下载 APK → 调起系统安装器。
* Android targetSdk≥24 须用 FileProvidertargetSdk≥26 安装未知来源 APK 须 REQUEST_INSTALL_PACKAGES。 * Android targetSdk≥24 须用 FileProvidertargetSdk≥26 安装未知来源 APK 须 REQUEST_INSTALL_PACKAGES。
*/ */
class UpdateChecker(private val context: Context) { class UpdateChecker(private val context: Context) {
@@ -41,19 +42,23 @@ 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) // 0..100 val downloadProgress = MutableStateFlow(0)
val lastError = MutableStateFlow<String?>(null) val lastError = MutableStateFlow<String?>(null)
private var lastNotifiedVersion: String? = null // 记录已推送的版本,避免重复通知
fun start() { fun start() {
scope.launch { check(force = false) } scope.launch {
delay(5000) // 启动 5 秒后首检,给网络/应用初始化留时间
check(force = false)
}
} }
suspend fun check(force: Boolean): Result<UpdateInfo?> = withContext(Dispatchers.IO) { suspend fun check(force: Boolean): Result<UpdateInfo?> = withContext(Dispatchers.IO) {
runCatching { runCatching {
val settings = app.settings val settings = app.settings
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
// 自动检查跳过条件(仅 force=false 时有效)
if (!force) { if (!force) {
// 自动检查被关闭或未满 24h:保持现状,不发请求
if (!settings.updateCheckEnabled.value) return@runCatching updateInfo.value if (!settings.updateCheckEnabled.value) return@runCatching updateInfo.value
if (now - settings.lastUpdateCheckTime.value < CHECK_INTERVAL_MS) return@runCatching updateInfo.value if (now - settings.lastUpdateCheckTime.value < CHECK_INTERVAL_MS) return@runCatching updateInfo.value
} }
@@ -73,12 +78,48 @@ class UpdateChecker(private val context: Context) {
val available = info != null && val available = info != null &&
isNewer(info.latestVersion, current) && isNewer(info.latestVersion, current) &&
info.latestVersion != settings.ignoredVersion.value info.latestVersion != settings.ignoredVersion.value
updateInfo.value = if (available) info else null val prev = updateInfo.value
updateInfo.value = if (available) info else {
lastNotifiedVersion = null // 清除记录,等下一个新版
null
}
lastError.value = null lastError.value = null
// 发现新版且之前没推过通知 → 自动推送到所有已开启通道
if (available && info != null && info.latestVersion != lastNotifiedVersion) {
lastNotifiedVersion = info.latestVersion
notifyNewVersion(prev, info)
}
updateInfo.value updateInfo.value
}.onFailure { lastError.value = it.message ?: "检查失败" } }.onFailure { lastError.value = it.message ?: "检查失败" }
} }
/** 发现新版后,自动通过所有已开启的推送通道通知用户 */
private fun notifyNewVersion(prev: UpdateInfo?, info: UpdateInfo) {
val s = app.settings
val title = "🆕 a2i 新版本 v${info.latestVersion}"
val body = "发现 v${info.latestVersion}(当前 v${currentVersionName()}\n${formatSize(info.apkSize)} · 点击更新\n\n${info.releaseNotes.take(300)}"
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
// Bark:通过 BarkSender 推送给第一个启用的服务器
val barkServers = s.servers.value.filter { it.enabled && it.deviceKey.isNotBlank() }
if (barkServers.isNotEmpty()) {
scope.launch {
val msg = com.a2i.forwarder.model.BarkMessage(
deviceKey = "", title = title, body = body, level = "timeSensitive",
group = "a2i_update", isArchive = "1",
)
BarkSender.pushToFirst(barkServers, msg)
}
}
// ntfy
NtfySender.trySend(app, title, body, scope)
// 电邮
EmailSender.trySend(app, title, body, scope)
}
private fun parseRelease(body: String): UpdateInfo? { private fun parseRelease(body: String): UpdateInfo? {
val json = JSONObject(body) val json = JSONObject(body)
val tag = json.optString("tag_name").removePrefix("v").trim() val tag = json.optString("tag_name").removePrefix("v").trim()
@@ -140,7 +181,6 @@ class UpdateChecker(private val context: Context) {
} }
fun install(apk: File) { fun install(apk: File) {
// 国产 ROM 需用户先在系统设置开启「安装未知应用」
if (!context.packageManager.canRequestPackageInstalls()) { if (!context.packageManager.canRequestPackageInstalls()) {
val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply { val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply {
data = Uri.parse("package:${context.packageName}") data = Uri.parse("package:${context.packageName}")
@@ -172,7 +212,6 @@ class UpdateChecker(private val context: Context) {
}.getOrDefault("0") }.getOrDefault("0")
} }
/** 语义版本比较:按 "." 分段逐段比,段数不等时短的补 0。 */
fun isNewer(latest: String, current: String): Boolean { fun isNewer(latest: String, current: String): Boolean {
val l = latest.split(".").map { it.substringBefore("-").toIntOrNull() ?: 0 } val l = latest.split(".").map { it.substringBefore("-").toIntOrNull() ?: 0 }
val c = current.split(".").map { it.substringBefore("-").toIntOrNull() ?: 0 } val c = current.split(".").map { it.substringBefore("-").toIntOrNull() ?: 0 }
@@ -186,8 +225,13 @@ class UpdateChecker(private val context: Context) {
} }
companion object { companion object {
// 更新源用自建 GiteaUPDATE_SOURCE_HIDDEN,国内可达),避免 api.github.com 不稳
const val RELEASES_API = "https://UPDATE_SOURCE_HIDDEN/api/v1/repos/song/a2i/releases/latest" const val RELEASES_API = "https://UPDATE_SOURCE_HIDDEN/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
} }
fun formatSize(bytes: Long): String {
if (bytes <= 0) return ""
val kb = bytes / 1024.0
return if (kb >= 1024) String.format("%.1f MB", kb / 1024) else String.format("%.0f KB", kb)
}
} }
@@ -2,6 +2,7 @@ package com.a2i.forwarder.ui.screens
import android.content.Intent import android.content.Intent
import android.provider.Settings import android.provider.Settings
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -24,6 +25,7 @@ import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.WarningAmber import androidx.compose.material.icons.filled.WarningAmber
import androidx.compose.material.icons.filled.SystemUpdate import androidx.compose.material.icons.filled.SystemUpdate
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -97,21 +99,48 @@ fun HomeScreen(onOpenLog: () -> Unit, onOpenSettings: () -> Unit) {
if (info != null) { if (info != null) {
item { item {
SectionCard { SectionCard {
Box(modifier = Modifier
.fillMaxWidth()
.background(
brush = Brush.linearGradient(
colors = listOf(MaterialTheme.colorScheme.tertiary, MaterialTheme.colorScheme.primary),
),
shape = RoundedCornerShape(8.dp),
)
.padding(16.dp)
) {
Column {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
IconBox(icon = Icons.Filled.SystemUpdate, tint = MaterialTheme.colorScheme.primary) Box(
modifier = Modifier
.size(44.dp)
.background(Color.White.copy(alpha = 0.18f), RoundedCornerShape(8.dp)),
contentAlignment = Alignment.Center,
) {
Icon(Icons.Filled.SystemUpdate, null, tint = Color.White, modifier = Modifier.size(26.dp))
}
Spacer(Modifier.width(12.dp)) Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) { Column(Modifier.weight(1f)) {
Text("发现新版本 v${info.latestVersion}", style = MaterialTheme.typography.titleMedium) Text("发现新版本", style = MaterialTheme.typography.titleLarge, color = Color.White, fontWeight = FontWeight.Bold)
Text( Text(
"安装包 ${formatSize(info.apkSize)} · 点击立即更新", "v${info.latestVersion} · ${formatSize(info.apkSize)} · 点击立即更新",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = Color.White.copy(alpha = 0.88f),
) )
} }
StatusBadge(text = "", tint = MaterialTheme.colorScheme.primary) StatusBadge("🔄", tint = Color.White)
} }
Spacer(Modifier.size(12.dp)) Spacer(Modifier.size(12.dp))
Button(onClick = { showUpdateDialog = true }, modifier = Modifier.fillMaxWidth()) { Text("立即更新") } Button(
onClick = { showUpdateDialog = true },
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = Color.White,
contentColor = MaterialTheme.colorScheme.primary,
),
) { Text("🚀 立即更新到 v${info.latestVersion}", fontWeight = FontWeight.Bold) }
}
}
} }
} }
} }