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"
|
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 须用 FileProvider,targetSdk≥26 安装未知来源 APK 须 REQUEST_INSTALL_PACKAGES。
|
* Android targetSdk≥24 须用 FileProvider,targetSdk≥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 {
|
||||||
// 更新源用自建 Gitea(git.sunlunfan.com,国内可达),避免 api.github.com 不稳
|
|
||||||
const val RELEASES_API = "https://git.sunlunfan.com/api/v1/repos/song/a2i/releases/latest"
|
const val RELEASES_API = "https://git.sunlunfan.com/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 {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Box(modifier = Modifier
|
||||||
IconBox(icon = Icons.Filled.SystemUpdate, tint = MaterialTheme.colorScheme.primary)
|
.fillMaxWidth()
|
||||||
Spacer(Modifier.width(12.dp))
|
.background(
|
||||||
Column(Modifier.weight(1f)) {
|
brush = Brush.linearGradient(
|
||||||
Text("发现新版本 v${info.latestVersion}", style = MaterialTheme.typography.titleMedium)
|
colors = listOf(MaterialTheme.colorScheme.tertiary, MaterialTheme.colorScheme.primary),
|
||||||
Text(
|
),
|
||||||
"安装包 ${formatSize(info.apkSize)} · 点击立即更新",
|
shape = RoundedCornerShape(8.dp),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
)
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
.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