From 7bd3dfc1026175b508e6054d8b80eb651bf03469 Mon Sep 17 00:00:00 2001 From: Song <168455@qq.com> Date: Thu, 9 Jul 2026 18:03:42 +0800 Subject: [PATCH] feat: eye-catching update banner + cross-channel notify + periodic check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- app/build.gradle.kts | 4 +- .../com/a2i/forwarder/core/UpdateChecker.kt | 60 ++++++++++++++++--- .../a2i/forwarder/ui/screens/HomeScreen.kt | 55 +++++++++++++---- 3 files changed, 96 insertions(+), 23 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f50213d..0e40644 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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 { diff --git a/app/src/main/java/com/a2i/forwarder/core/UpdateChecker.kt b/app/src/main/java/com/a2i/forwarder/core/UpdateChecker.kt index 64bfc42..7f1c891 100644 --- a/app/src/main/java/com/a2i/forwarder/core/UpdateChecker.kt +++ b/app/src/main/java/com/a2i/forwarder/core/UpdateChecker.kt @@ -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(null) val downloading = MutableStateFlow(false) - val downloadProgress = MutableStateFlow(0) // 0..100 + val downloadProgress = MutableStateFlow(0) val lastError = MutableStateFlow(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 = 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(UPDATE_SOURCE_HIDDEN,国内可达),避免 api.github.com 不稳 const val RELEASES_API = "https://UPDATE_SOURCE_HIDDEN/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) + } } diff --git a/app/src/main/java/com/a2i/forwarder/ui/screens/HomeScreen.kt b/app/src/main/java/com/a2i/forwarder/ui/screens/HomeScreen.kt index 6d6f3d4..21614f5 100644 --- a/app/src/main/java/com/a2i/forwarder/ui/screens/HomeScreen.kt +++ b/app/src/main/java/com/a2i/forwarder/ui/screens/HomeScreen.kt @@ -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("立即更新") } } } }