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:
@@ -13,8 +13,8 @@ android {
|
||||
applicationId = "com.a2i.forwarder"
|
||||
minSdk = 34
|
||||
targetSdk = 36
|
||||
versionCode = 13
|
||||
versionName = "1.7.0"
|
||||
versionCode = 14
|
||||
versionName = "1.7.1"
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
|
||||
@@ -9,6 +9,7 @@ import com.a2i.forwarder.A2iApp
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -27,7 +28,7 @@ data class UpdateInfo(
|
||||
)
|
||||
|
||||
/**
|
||||
* 应用内更新:拉取 GitHub Releases 最新版 → 版本比较 → 下载 APK → 调起系统安装器。
|
||||
* 应用内更新:定时巡检 Gitea Releases 最新版 → 版本比较 → 醒目横幅 + 自动推送通知 → 下载 APK → 调起系统安装器。
|
||||
* Android targetSdk≥24 须用 FileProvider,targetSdk≥26 安装未知来源 APK 须 REQUEST_INSTALL_PACKAGES。
|
||||
*/
|
||||
class UpdateChecker(private val context: Context) {
|
||||
@@ -41,19 +42,23 @@ class UpdateChecker(private val context: Context) {
|
||||
|
||||
val updateInfo = MutableStateFlow<UpdateInfo?>(null)
|
||||
val downloading = MutableStateFlow(false)
|
||||
val downloadProgress = MutableStateFlow(0) // 0..100
|
||||
val downloadProgress = MutableStateFlow(0)
|
||||
val lastError = MutableStateFlow<String?>(null)
|
||||
private var lastNotifiedVersion: String? = null // 记录已推送的版本,避免重复通知
|
||||
|
||||
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) {
|
||||
runCatching {
|
||||
val settings = app.settings
|
||||
val now = System.currentTimeMillis()
|
||||
// 自动检查跳过条件(仅 force=false 时有效)
|
||||
if (!force) {
|
||||
// 自动检查被关闭或未满 24h:保持现状,不发请求
|
||||
if (!settings.updateCheckEnabled.value) 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 &&
|
||||
isNewer(info.latestVersion, current) &&
|
||||
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
|
||||
|
||||
// 发现新版且之前没推过通知 → 自动推送到所有已开启通道
|
||||
if (available && info != null && info.latestVersion != lastNotifiedVersion) {
|
||||
lastNotifiedVersion = info.latestVersion
|
||||
notifyNewVersion(prev, info)
|
||||
}
|
||||
updateInfo.value
|
||||
}.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? {
|
||||
val json = JSONObject(body)
|
||||
val tag = json.optString("tag_name").removePrefix("v").trim()
|
||||
@@ -140,7 +181,6 @@ class UpdateChecker(private val context: Context) {
|
||||
}
|
||||
|
||||
fun install(apk: File) {
|
||||
// 国产 ROM 需用户先在系统设置开启「安装未知应用」
|
||||
if (!context.packageManager.canRequestPackageInstalls()) {
|
||||
val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply {
|
||||
data = Uri.parse("package:${context.packageName}")
|
||||
@@ -172,7 +212,6 @@ class UpdateChecker(private val context: Context) {
|
||||
}.getOrDefault("0")
|
||||
}
|
||||
|
||||
/** 语义版本比较:按 "." 分段逐段比,段数不等时短的补 0。 */
|
||||
fun isNewer(latest: String, current: String): Boolean {
|
||||
val l = latest.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 {
|
||||
// 更新源用自建 Gitea(git.sunlunfan.com,国内可达),避免 api.github.com 不稳
|
||||
const val RELEASES_API = "https://git.sunlunfan.com/api/v1/repos/song/a2i/releases/latest"
|
||||
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.provider.Settings
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
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.SystemUpdate
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -97,21 +99,48 @@ fun HomeScreen(onOpenLog: () -> Unit, onOpenSettings: () -> Unit) {
|
||||
if (info != null) {
|
||||
item {
|
||||
SectionCard {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
IconBox(icon = Icons.Filled.SystemUpdate, tint = MaterialTheme.colorScheme.primary)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text("发现新版本 v${info.latestVersion}", style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
"安装包 ${formatSize(info.apkSize)} · 点击立即更新",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
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) {
|
||||
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))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text("发现新版本", style = MaterialTheme.typography.titleLarge, color = Color.White, fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
"v${info.latestVersion} · ${formatSize(info.apkSize)} · 点击立即更新",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = Color.White.copy(alpha = 0.88f),
|
||||
)
|
||||
}
|
||||
StatusBadge("🔄", tint = Color.White)
|
||||
}
|
||||
Spacer(Modifier.size(12.dp))
|
||||
Button(
|
||||
onClick = { showUpdateDialog = true },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color.White,
|
||||
contentColor = MaterialTheme.colorScheme.primary,
|
||||
),
|
||||
) { Text("🚀 立即更新到 v${info.latestVersion}", fontWeight = FontWeight.Bold) }
|
||||
}
|
||||
StatusBadge(text = "新", tint = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
Spacer(Modifier.size(12.dp))
|
||||
Button(onClick = { showUpdateDialog = true }, modifier = Modifier.fillMaxWidth()) { Text("立即更新") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user