From a34e9f11699dc30a6f6b455579c8df20e6187880 Mon Sep 17 00:00:00 2001
From: cbb <2625274797@qq.com>
Date: Mon, 12 Oct 2026 00:18:39 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E7=81=B5=E5=8A=A8=E5=B2=9B=E5=8F=82?=
=?UTF-8?q?=E6=95=B0=E5=9B=9E=E9=80=80=E5=8D=95=E4=B8=80=E6=A0=B9=E8=8A=82?=
=?UTF-8?q?=E7=82=B9+=E5=B2=9B=E5=BC=80=E5=85=B3=EF=BC=9B=E6=96=B0?=
=?UTF-8?q?=E5=A2=9E=E5=BA=94=E7=94=A8=E5=86=85=E5=BF=AB=E6=8D=B7=E6=9B=B4?=
=?UTF-8?q?=E6=96=B0=EF=BC=88=E6=A3=80=E6=9F=A5/=E4=B8=8B=E8=BD=BD/?=
=?UTF-8?q?=E5=AE=89=E8=A3=85=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
app/build.gradle.kts | 4 +-
app/src/main/AndroidManifest.xml | 1 +
.../main/java/com/smbfast/app/data/Prefs.kt | 2 +
.../smbfast/app/transfer/TransferService.kt | 25 +--
.../smbfast/app/ui/screens/SettingsScreen.kt | 161 +++++++++++++++++-
.../main/java/com/smbfast/app/util/Updater.kt | 82 +++++++++
app/src/main/res/xml/file_paths.xml | 1 +
7 files changed, 262 insertions(+), 14 deletions(-)
create mode 100644 app/src/main/java/com/smbfast/app/util/Updater.kt
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index db166d3..4c0bf63 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -13,8 +13,8 @@ android {
applicationId = "com.smbfast.app"
minSdk = 26
targetSdk = 35
- versionCode = 20
- versionName = "1.18.0"
+ versionCode = 21
+ versionName = "1.19.0"
}
signingConfigs {
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index c3af654..83140fa 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -8,6 +8,7 @@
+
+ vm.updateSettings { it.copy(islandOn = checked) }
+ },
+ colors = SwitchDefaults.colors(
+ checkedThumbColor = Color(0xFF04121E),
+ checkedTrackColor = Neon.Cyan,
+ uncheckedTrackColor = Color.White.copy(alpha = 0.12f),
+ ),
+ )
+ Spacer(Modifier.width(12.dp))
+ Text(
+ if (s.islandOn) "已开启" else "已关闭(仅普通通知)",
+ color = Neon.TextDim,
+ fontSize = 13.sp,
+ )
+ }
+ }
+
+ UpdateSection(vm = vm, currentName = currentVersionName(), currentCode = currentVersionCode())
+
SectionCard(
icon = Icons.Default.Speed,
brush = Neon.Sunset,
@@ -161,7 +202,7 @@ fun SettingsScreen(vm: AppViewModel) {
icon = Icons.Default.Info,
brush = Neon.File,
title = "隐私提示",
- subtitle = "连接密码保存在本机应用私有数据中(未加密)。请勿在共享设备上保存敏感凭据。",
+ subtitle = "连接密码使用 AndroidKeyStore 加密后保存在本机应用私有数据中,root/备份不可直接读取。",
) {}
SectionCard(
@@ -220,6 +261,124 @@ fun SettingsScreen(vm: AppViewModel) {
}
}
+@Composable
+private fun currentVersionCode(): Long {
+ val ctx = LocalContext.current
+ return remember {
+ val pi = ctx.packageManager.getPackageInfo(ctx.packageName, 0)
+ if (android.os.Build.VERSION.SDK_INT >= 28) pi.longVersionCode
+ else @Suppress("DEPRECATION") pi.versionCode.toLong()
+ }
+}
+
+@Composable
+private fun currentVersionName(): String {
+ val ctx = LocalContext.current
+ return remember {
+ ctx.packageManager.getPackageInfo(ctx.packageName, 0).versionName ?: "?"
+ }
+}
+
+/** 应用内快捷更新:检查 version.json → 下载 APK → 拉起系统安装器 */
+@Composable
+private fun UpdateSection(vm: AppViewModel, currentName: String, currentCode: Long) {
+ val s by vm.settings.collectAsStateWithLifecycle()
+ val ctx = LocalContext.current
+ val scope = rememberCoroutineScope()
+
+ var checking by remember { mutableStateOf(false) }
+ var info by remember { mutableStateOf(null) }
+ var msg by remember { mutableStateOf(null) }
+ var progress by remember { mutableStateOf(null) }
+
+ SectionCard(
+ icon = Icons.Default.CloudDownload,
+ brush = Neon.Sunset,
+ title = "应用更新",
+ subtitle = "当前版本 $currentName ($currentCode) · 从分发服务器一键检查、下载并安装新版本",
+ ) {
+ Spacer(Modifier.height(8.dp))
+ GlassTextField(
+ value = s.updateUrl,
+ onValueChange = { v -> vm.updateSettings { it.copy(updateUrl = v) } },
+ label = "分发服务器地址",
+ )
+ Spacer(Modifier.height(10.dp))
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ MiniChip(if (checking) "检查中…" else "检查更新") {
+ if (checking || progress != null) return@MiniChip
+ checking = true
+ info = null
+ msg = null
+ scope.launch {
+ Updater.check(s.updateUrl, currentCode).fold(
+ onSuccess = { found ->
+ checking = false
+ if (found == null) {
+ msg = "已是最新版本($currentName / $currentCode)"
+ } else {
+ info = found
+ msg = "发现新版本 ${found.versionName} (${found.versionCode})" +
+ (found.note?.let { " · $it" } ?: "")
+ }
+ },
+ onFailure = {
+ checking = false
+ msg = "检查失败:${it.message ?: "网络不可达"}"
+ },
+ )
+ }
+ }
+ if (info != null) {
+ MiniChip(
+ if (progress != null) "下载中 ${((progress ?: 0f) * 100).toInt()}%"
+ else "下载并安装",
+ ) {
+ val cur = info ?: return@MiniChip
+ if (progress != null) return@MiniChip
+ progress = 0f
+ scope.launch {
+ val dir = ctx.getExternalFilesDir("update") ?: ctx.filesDir
+ val apk = File(dir, "SmbFast-update.apk")
+ Updater.download(Updater.resolveUrl(s.updateUrl, cur), apk) { p -> progress = p }
+ .fold(
+ onSuccess = { f ->
+ progress = null
+ runCatching {
+ val uri = androidx.core.content.FileProvider.getUriForFile(
+ ctx, "${ctx.packageName}.fileprovider", f,
+ )
+ val i = android.content.Intent(android.content.Intent.ACTION_VIEW)
+ .setDataAndType(uri, "application/vnd.android.package-archive")
+ .addFlags(
+ android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION
+ or android.content.Intent.FLAG_ACTIVITY_NEW_TASK,
+ )
+ ctx.startActivity(i)
+ }.onFailure { msg = "无法启动安装器:${it.message}" }
+ },
+ onFailure = {
+ progress = null
+ msg = "下载失败:${it.message ?: "网络错误"}"
+ },
+ )
+ }
+ }
+ }
+ }
+ val m = msg
+ if (m != null) {
+ Spacer(Modifier.height(8.dp))
+ Text(m, color = Neon.TextDim, fontSize = 11.sp, lineHeight = 15.sp)
+ }
+ val p = progress
+ if (p != null) {
+ Spacer(Modifier.height(8.dp))
+ NeonProgress(fraction = p, modifier = Modifier.fillMaxWidth(), brush = Neon.Primary)
+ }
+ }
+}
+
@Composable
private fun MiniChip(text: String, onClick: () -> Unit) {
Box(
diff --git a/app/src/main/java/com/smbfast/app/util/Updater.kt b/app/src/main/java/com/smbfast/app/util/Updater.kt
new file mode 100644
index 0000000..2a98b42
--- /dev/null
+++ b/app/src/main/java/com/smbfast/app/util/Updater.kt
@@ -0,0 +1,82 @@
+package com.smbfast.app.util
+
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import org.json.JSONObject
+import java.io.File
+import java.net.HttpURLConnection
+import java.net.URL
+
+/**
+ * 应用内快捷更新:从分发服务器(dist/ 目录的 HTTP 服务)检查并下载新版本。
+ * 服务器需提供 version.json:{"versionCode":21,"versionName":"1.19.0","file":"SmbFast-release-v21.apk","note":"..."}
+ */
+object Updater {
+
+ data class Info(
+ val versionCode: Long,
+ val versionName: String,
+ val file: String,
+ val note: String?,
+ )
+
+ /** 拉取 {base}/version.json;服务器版本比本地新时返回信息,否则返回 null */
+ suspend fun check(base: String, currentCode: Long): Result = withContext(Dispatchers.IO) {
+ runCatching {
+ val u = if (base.endsWith("/")) base else "$base/"
+ val conn = URL(u + "version.json").openConnection() as HttpURLConnection
+ conn.connectTimeout = 5000
+ conn.readTimeout = 5000
+ val body = try {
+ conn.inputStream.use { it.readBytes().decodeToString() }
+ } finally {
+ conn.disconnect()
+ }
+ val o = JSONObject(body)
+ val info = Info(
+ versionCode = o.getLong("versionCode"),
+ versionName = o.optString("versionName", ""),
+ file = o.getString("file"),
+ note = o.optString("note", "").ifBlank { null },
+ )
+ if (info.versionCode > currentCode) info else null
+ }
+ }
+
+ /** 拼出 APK 下载地址(file 为相对名时基于 base) */
+ fun resolveUrl(base: String, info: Info): String =
+ if (info.file.startsWith("http")) info.file
+ else (if (base.endsWith("/")) base else "$base/") + info.file
+
+ /** 下载 APK 到 dest,onProgress 回调 0~1 */
+ suspend fun download(url: String, dest: File, onProgress: (Float) -> Unit): Result =
+ withContext(Dispatchers.IO) {
+ runCatching {
+ val conn = URL(url).openConnection() as HttpURLConnection
+ conn.connectTimeout = 5000
+ conn.readTimeout = 15000
+ val total = conn.contentLengthLong
+ dest.parentFile?.mkdirs()
+ try {
+ dest.outputStream().use { out ->
+ conn.inputStream.use { input ->
+ val buf = ByteArray(256 * 1024)
+ var done = 0L
+ while (true) {
+ val n = input.read(buf)
+ if (n < 0) break
+ out.write(buf, 0, n)
+ done += n
+ if (total > 0) {
+ onProgress((done.toDouble() / total).coerceIn(0.0, 1.0).toFloat())
+ }
+ }
+ }
+ }
+ } finally {
+ conn.disconnect()
+ }
+ dest
+ }
+ }
+}
diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml
index e1f3dc3..d557bbe 100644
--- a/app/src/main/res/xml/file_paths.xml
+++ b/app/src/main/res/xml/file_paths.xml
@@ -3,4 +3,5 @@
+