diff --git a/README.md b/README.md
index 2c45b96..0bd8093 100644
--- a/README.md
+++ b/README.md
@@ -23,6 +23,7 @@
- 余额 ≤5 条自动停用,下次打开 App 提醒
- 每 5 分钟最多发 1 条,避免刷屏
- 自动解析失败时回退手动额度计数
+- **应用内更新**:每天自动检查 GitHub 新版本,发现新版后一键下载并通过系统安装器升级,无需手动去网页下载。
## 快速开始
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 6a52bac..6569a03 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 = 7
- versionName = "1.4.2"
+ versionCode = 8
+ versionName = "1.5.0"
}
signingConfigs {
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index a76d173..049b81a 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -11,6 +11,7 @@
+
+
+
+
+
diff --git a/app/src/main/java/com/a2i/forwarder/A2iApp.kt b/app/src/main/java/com/a2i/forwarder/A2iApp.kt
index d2a370d..97e4aad 100644
--- a/app/src/main/java/com/a2i/forwarder/A2iApp.kt
+++ b/app/src/main/java/com/a2i/forwarder/A2iApp.kt
@@ -7,6 +7,7 @@ import com.a2i.forwarder.core.ConnectivityMonitor
import com.a2i.forwarder.core.LogStore
import com.a2i.forwarder.core.PhoneCallMonitor
import com.a2i.forwarder.core.SmsFallbackForwarder
+import com.a2i.forwarder.core.UpdateChecker
import com.a2i.forwarder.store.AppRulesStore
import com.a2i.forwarder.store.SettingsStore
import kotlinx.coroutines.CoroutineScope
@@ -30,6 +31,8 @@ class A2iApp : Application() {
private set
lateinit var smsForwarder: SmsFallbackForwarder
private set
+ lateinit var updateChecker: UpdateChecker
+ private set
override fun onCreate() {
super.onCreate()
@@ -43,6 +46,8 @@ class A2iApp : Application() {
connectivityMonitor.start()
carrierBalanceQuery = CarrierBalanceQuery(this)
smsForwarder = SmsFallbackForwarder(this)
+ updateChecker = UpdateChecker(this)
+ updateChecker.start()
}
companion object {
diff --git a/app/src/main/java/com/a2i/forwarder/core/UpdateChecker.kt b/app/src/main/java/com/a2i/forwarder/core/UpdateChecker.kt
new file mode 100644
index 0000000..bdc8324
--- /dev/null
+++ b/app/src/main/java/com/a2i/forwarder/core/UpdateChecker.kt
@@ -0,0 +1,192 @@
+package com.a2i.forwarder.core
+
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.provider.Settings
+import androidx.core.content.FileProvider
+import com.a2i.forwarder.A2iApp
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import org.json.JSONObject
+import java.io.File
+import java.util.concurrent.TimeUnit
+
+data class UpdateInfo(
+ val latestVersion: String,
+ val apkUrl: String,
+ val apkSize: Long,
+ val releaseNotes: String,
+ val releasePageUrl: String,
+)
+
+/**
+ * 应用内更新:拉取 GitHub Releases 最新版 → 版本比较 → 下载 APK → 调起系统安装器。
+ * Android targetSdk≥24 须用 FileProvider,targetSdk≥26 安装未知来源 APK 须 REQUEST_INSTALL_PACKAGES。
+ */
+class UpdateChecker(private val context: Context) {
+ private val app get() = A2iApp.instance
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+ private val http = OkHttpClient.Builder()
+ .connectTimeout(10, TimeUnit.SECONDS)
+ .readTimeout(30, TimeUnit.SECONDS)
+ .retryOnConnectionFailure(true)
+ .build()
+
+ val updateInfo = MutableStateFlow(null)
+ val downloading = MutableStateFlow(false)
+ val downloadProgress = MutableStateFlow(0) // 0..100
+ val lastError = MutableStateFlow(null)
+
+ fun start() {
+ scope.launch { check(force = false) }
+ }
+
+ suspend fun check(force: Boolean): Result = withContext(Dispatchers.IO) {
+ runCatching {
+ val settings = app.settings
+ val now = System.currentTimeMillis()
+ if (!force) {
+ // 自动检查被关闭或未满 24h:保持现状,不发请求
+ if (!settings.updateCheckEnabled.value) return@runCatching updateInfo.value
+ if (now - settings.lastUpdateCheckTime.value < CHECK_INTERVAL_MS) return@runCatching updateInfo.value
+ }
+ val req = Request.Builder()
+ .url(RELEASES_API)
+ .header("Accept", "application/vnd.github+json")
+ .header("User-Agent", "a2i-android")
+ .get()
+ .build()
+ val info = http.newCall(req).execute().use { res ->
+ if (!res.isSuccessful) error("HTTP ${res.code}")
+ val body = res.body?.string() ?: error("空响应")
+ parseRelease(body)
+ }
+ settings.setLastUpdateCheck(now)
+ val current = currentVersionName()
+ val available = info != null &&
+ isNewer(info.latestVersion, current) &&
+ info.latestVersion != settings.ignoredVersion.value
+ updateInfo.value = if (available) info else null
+ lastError.value = null
+ updateInfo.value
+ }.onFailure { lastError.value = it.message ?: "检查失败" }
+ }
+
+ private fun parseRelease(body: String): UpdateInfo? {
+ val json = JSONObject(body)
+ val tag = json.optString("tag_name").removePrefix("v").trim()
+ if (tag.isEmpty()) return null
+ val assets = json.optJSONArray("assets") ?: return null
+ var apkUrl = ""
+ var apkSize = 0L
+ for (i in 0 until assets.length()) {
+ val a = assets.optJSONObject(i) ?: continue
+ if (a.optString("name").endsWith(".apk", ignoreCase = true)) {
+ apkUrl = a.optString("browser_download_url")
+ apkSize = a.optLong("size")
+ break
+ }
+ }
+ if (apkUrl.isEmpty()) return null
+ return UpdateInfo(
+ latestVersion = tag,
+ apkUrl = apkUrl,
+ apkSize = apkSize,
+ releaseNotes = json.optString("body").orEmpty(),
+ releasePageUrl = json.optString("html_url").orEmpty(),
+ )
+ }
+
+ suspend fun download(info: UpdateInfo): File? = withContext(Dispatchers.IO) {
+ downloading.value = true
+ downloadProgress.value = 0
+ lastError.value = null
+ try {
+ val req = Request.Builder().url(info.apkUrl).get().build()
+ http.newCall(req).execute().use { res ->
+ if (!res.isSuccessful) { lastError.value = "HTTP ${res.code}"; return@use null }
+ val body = res.body ?: run { lastError.value = "空响应"; return@use null }
+ val total = body.contentLength()
+ val dir = context.getExternalFilesDir(null) ?: context.cacheDir
+ val target = File(dir, "a2i-${info.latestVersion}.apk")
+ target.outputStream().use { sink ->
+ val input = body.byteStream()
+ val buf = ByteArray(8 * 1024)
+ var read = 0L
+ while (true) {
+ val n = input.read(buf)
+ if (n <= 0) break
+ sink.write(buf, 0, n)
+ read += n
+ if (total > 0) downloadProgress.value = (read * 100 / total).toInt()
+ }
+ }
+ downloadProgress.value = 100
+ target
+ }
+ } catch (e: Exception) {
+ lastError.value = e.message ?: "下载失败"
+ null
+ } finally {
+ downloading.value = false
+ }
+ }
+
+ 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}")
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ runCatching { context.startActivity(intent) }
+ return
+ }
+ val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", apk)
+ val intent = Intent(Intent.ACTION_VIEW).apply {
+ setDataAndType(uri, "application/vnd.android.package-archive")
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ runCatching { context.startActivity(intent) }
+ }
+
+ fun ignoreVersion(v: String) {
+ scope.launch {
+ app.settings.setIgnoredVersion(v)
+ updateInfo.value = null
+ }
+ }
+
+ private fun currentVersionName(): String {
+ return runCatching {
+ @Suppress("DEPRECATION")
+ context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "0"
+ }.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 }
+ val n = maxOf(l.size, c.size)
+ for (i in 0 until n) {
+ val lv = l.getOrElse(i) { 0 }
+ val cv = c.getOrElse(i) { 0 }
+ if (lv != cv) return lv > cv
+ }
+ return false
+ }
+
+ companion object {
+ const val RELEASES_API = "https://api.github.com/repos/lsxf/a2i/releases/latest"
+ const val CHECK_INTERVAL_MS = 24L * 60 * 60 * 1000
+ }
+}
diff --git a/app/src/main/java/com/a2i/forwarder/store/SettingsStore.kt b/app/src/main/java/com/a2i/forwarder/store/SettingsStore.kt
index 2ac4a80..6267fc5 100644
--- a/app/src/main/java/com/a2i/forwarder/store/SettingsStore.kt
+++ b/app/src/main/java/com/a2i/forwarder/store/SettingsStore.kt
@@ -47,6 +47,10 @@ class SettingsStore(
val SMS_BALANCE = intPreferencesKey("sms_balance")
val SMS_SUSPENDED = booleanPreferencesKey("sms_suspended")
val SMS_LAST_SENT = longPreferencesKey("sms_last_sent")
+ // 应用更新
+ val UPDATE_CHECK_AUTO = booleanPreferencesKey("update_check_auto")
+ val LAST_UPDATE_CHECK = longPreferencesKey("last_update_check")
+ val IGNORED_VERSION = stringPreferencesKey("ignored_version")
}
private val defaultServers = listOf(
@@ -76,6 +80,11 @@ class SettingsStore(
val smsSuspended = MutableStateFlow(false) // 余额 ≤5 自动挂起
val lastSmsSentTime = MutableStateFlow(0L) // 限频时间戳
+ // 应用更新
+ val updateCheckEnabled = MutableStateFlow(true) // 自动检查更新开关
+ val lastUpdateCheckTime = MutableStateFlow(0L) // 上次检查时间戳
+ val ignoredVersion = MutableStateFlow("") // 用户忽略的版本号
+
init {
scope.launch {
ds.data.collect { p ->
@@ -102,6 +111,10 @@ class SettingsStore(
smsBalance.value = p[K.SMS_BALANCE] ?: -1
smsSuspended.value = p[K.SMS_SUSPENDED] ?: false
lastSmsSentTime.value = p[K.SMS_LAST_SENT] ?: 0L
+ // 应用更新
+ updateCheckEnabled.value = p[K.UPDATE_CHECK_AUTO] ?: true
+ lastUpdateCheckTime.value = p[K.LAST_UPDATE_CHECK] ?: 0L
+ ignoredVersion.value = p[K.IGNORED_VERSION] ?: ""
}
}
}
@@ -163,6 +176,10 @@ class SettingsStore(
suspend fun setSmsSuspended(v: Boolean) { edit { it[K.SMS_SUSPENDED] = v }; smsSuspended.value = v }
suspend fun setLastSmsSentTime(v: Long) { edit { it[K.SMS_LAST_SENT] = v }; lastSmsSentTime.value = v }
+ suspend fun setUpdateCheckEnabled(v: Boolean) { edit { it[K.UPDATE_CHECK_AUTO] = v }; updateCheckEnabled.value = v }
+ suspend fun setLastUpdateCheck(v: Long) { edit { it[K.LAST_UPDATE_CHECK] = v }; lastUpdateCheckTime.value = v }
+ suspend fun setIgnoredVersion(v: String) { edit { it[K.IGNORED_VERSION] = v }; ignoredVersion.value = v }
+
suspend fun snapshot() = ds.data.first()
companion object {
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 4574c4b..152025b 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
@@ -22,6 +22,7 @@ import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.PowerSettingsNew
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.FilledTonalButton
import androidx.compose.material3.Icon
@@ -66,6 +67,8 @@ fun HomeScreen(onOpenLog: () -> Unit, onOpenSettings: () -> Unit) {
val filtered by app.logStore.filteredCount.collectAsState()
val failed by app.logStore.failedCount.collectAsState()
val smsSuspended by app.settings.smsSuspended.collectAsState()
+ val update by app.updateChecker.updateInfo.collectAsState()
+ var showUpdateDialog by remember { mutableStateOf(false) }
var granted by remember { mutableStateOf(isNotificationListenerEnabled(context)) }
val lifecycleOwner = LocalLifecycleOwner.current
@@ -90,6 +93,29 @@ fun HomeScreen(onOpenLog: () -> Unit, onOpenSettings: () -> Unit) {
)
}
+ val info = update
+ 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,
+ )
+ }
+ StatusBadge(text = "新", tint = MaterialTheme.colorScheme.primary)
+ }
+ Spacer(Modifier.size(12.dp))
+ Button(onClick = { showUpdateDialog = true }, modifier = Modifier.fillMaxWidth()) { Text("立即更新") }
+ }
+ }
+ }
+
if (smsSuspended) {
item {
SectionCard {
@@ -200,6 +226,11 @@ fun HomeScreen(onOpenLog: () -> Unit, onOpenSettings: () -> Unit) {
)
}
}
+
+ if (showUpdateDialog) {
+ val info = update
+ if (info != null) UpdateDialog(info, onDismiss = { showUpdateDialog = false })
+ }
}
@Composable
diff --git a/app/src/main/java/com/a2i/forwarder/ui/screens/SettingsScreen.kt b/app/src/main/java/com/a2i/forwarder/ui/screens/SettingsScreen.kt
index 53d937b..627a3ef 100644
--- a/app/src/main/java/com/a2i/forwarder/ui/screens/SettingsScreen.kt
+++ b/app/src/main/java/com/a2i/forwarder/ui/screens/SettingsScreen.kt
@@ -26,6 +26,8 @@ import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.RestartAlt
import androidx.compose.material.icons.filled.Settings
+import androidx.compose.material.icons.filled.SystemUpdateAlt
+import androidx.compose.material.icons.filled.Update
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
@@ -60,6 +62,7 @@ import com.a2i.forwarder.model.BarkServer
import com.a2i.forwarder.model.OFFICIAL_BARK_SERVER
import com.a2i.forwarder.ui.ClickRow
import com.a2i.forwarder.ui.ScreenTitle
+import com.a2i.forwarder.ui.appVersionName
import com.a2i.forwarder.ui.SectionCard
import com.a2i.forwarder.ui.StatusBadge
import kotlinx.coroutines.launch
@@ -80,6 +83,12 @@ fun SettingsScreen(onOpenLog: () -> Unit) {
var showAdd by remember { mutableStateOf(false) }
var editing by remember { mutableStateOf(null) }
+ // 应用更新状态
+ val updateAuto by app.settings.updateCheckEnabled.collectAsState()
+ val updateInfo by app.updateChecker.updateInfo.collectAsState()
+ var checking by remember { mutableStateOf(false) }
+ var checkMsg by remember { mutableStateOf(null) }
+
// 短信兜底状态
val smsFallback by app.settings.smsFallbackEnabled.collectAsState()
val smsTarget by app.settings.smsTargetNumber.collectAsState()
@@ -283,6 +292,56 @@ fun SettingsScreen(onOpenLog: () -> Unit) {
}
}
+ item {
+ SectionCard("关于与更新", subtitle = "版本检查(数据来自 GitHub Releases)") {
+ com.a2i.forwarder.ui.SwitchRow(
+ title = "自动检查更新",
+ subtitle = "启动时检查,且距上次满 24 小时再次检查",
+ checked = updateAuto,
+ icon = Icons.Filled.Update,
+ ) { v -> app.appScope.launch { app.settings.setUpdateCheckEnabled(v) } }
+
+ ClickRow(
+ title = "检查更新",
+ subtitle = "当前 v${appVersionName(context)}" +
+ (updateInfo?.let { " · 已发现 v${it.latestVersion}" } ?: " · 点击立即检查"),
+ icon = Icons.Filled.SystemUpdateAlt,
+ onClick = {
+ app.appScope.launch {
+ checking = true
+ checkMsg = null
+ val r = app.updateChecker.check(force = true)
+ checking = false
+ checkMsg = when {
+ !r.isSuccess -> "检查失败:${r.exceptionOrNull()?.message}"
+ r.getOrNull() != null -> "发现新版本,请到首页更新"
+ else -> "已是最新版本"
+ }
+ }
+ },
+ )
+
+ if (checking) {
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp)
+ Spacer(Modifier.width(8.dp))
+ Text("正在检查…", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ }
+ checkMsg?.let {
+ Text(
+ it,
+ style = MaterialTheme.typography.bodySmall,
+ color = if (it.startsWith("已是")) MaterialTheme.colorScheme.tertiary else MaterialTheme.colorScheme.error,
+ modifier = Modifier.padding(start = 50.dp, top = 2.dp, bottom = 4.dp),
+ )
+ }
+ }
+ }
+
item {
SectionCard("维护", subtitle = "日志查看和本地统计清理") {
ClickRow("转发日志", "查看 / 清空最近记录", icon = Icons.Filled.History, onClick = onOpenLog)
diff --git a/app/src/main/java/com/a2i/forwarder/ui/screens/UpdateDialog.kt b/app/src/main/java/com/a2i/forwarder/ui/screens/UpdateDialog.kt
new file mode 100644
index 0000000..c2e1407
--- /dev/null
+++ b/app/src/main/java/com/a2i/forwarder/ui/screens/UpdateDialog.kt
@@ -0,0 +1,121 @@
+package com.a2i.forwarder.ui.screens
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import com.a2i.forwarder.A2iApp
+import com.a2i.forwarder.core.UpdateInfo
+import kotlinx.coroutines.launch
+import java.io.File
+import java.util.Locale
+
+@Composable
+fun UpdateDialog(info: UpdateInfo, onDismiss: () -> Unit) {
+ val app = A2iApp.instance
+ val downloading by app.updateChecker.downloading.collectAsState()
+ val progress by app.updateChecker.downloadProgress.collectAsState()
+ var downloadedFile by remember { mutableStateOf(null) }
+
+ AlertDialog(
+ onDismissRequest = { if (!downloading) onDismiss() },
+ title = { Text("发现新版本 v${info.latestVersion}") },
+ text = {
+ Column {
+ Text(
+ "安装包大小:${formatSize(info.apkSize)}",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Spacer(Modifier.size(8.dp))
+ Column(
+ modifier = Modifier
+ .heightIn(max = 240.dp)
+ .verticalScroll(rememberScrollState()),
+ ) {
+ val notes = info.releaseNotes.ifBlank { "(作者未提供更新说明)" }
+ Text(notes, style = MaterialTheme.typography.bodyMedium)
+ }
+ if (downloading) {
+ Spacer(Modifier.size(12.dp))
+ LinearProgressIndicator(
+ progress = { progress / 100f },
+ modifier = Modifier.fillMaxWidth(),
+ )
+ Spacer(Modifier.size(4.dp))
+ Text(
+ "$progress%",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ downloadedFile?.let {
+ Spacer(Modifier.size(8.dp))
+ Text(
+ "下载完成,点击「安装」继续",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.tertiary,
+ )
+ }
+ }
+ },
+ confirmButton = {
+ if (downloadedFile == null) {
+ Button(
+ onClick = {
+ app.appScope.launch {
+ val f = app.updateChecker.download(info)
+ if (f != null) downloadedFile = f
+ }
+ },
+ enabled = !downloading,
+ ) {
+ if (downloading) {
+ CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp)
+ } else {
+ Text("下载并安装")
+ }
+ }
+ } else {
+ Button(onClick = { app.updateChecker.install(downloadedFile!!) }) { Text("安装") }
+ }
+ },
+ dismissButton = {
+ Row {
+ TextButton(
+ onClick = { app.updateChecker.ignoreVersion(info.latestVersion); onDismiss() },
+ enabled = !downloading,
+ ) { Text("忽略此版本") }
+ TextButton(onClick = onDismiss, enabled = !downloading) { Text("稍后") }
+ }
+ },
+ )
+}
+
+fun formatSize(bytes: Long): String {
+ if (bytes <= 0) return "未知"
+ val kb = bytes / 1024.0
+ return if (kb >= 1024) String.format(Locale.US, "%.1f MB", kb / 1024) else String.format(Locale.US, "%.0f KB", kb)
+}
diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml
new file mode 100644
index 0000000..962f661
--- /dev/null
+++ b/app/src/main/res/xml/file_paths.xml
@@ -0,0 +1,5 @@
+
+
+
+
+