feat: 灵动岛参数回退单一根节点+岛开关;新增应用内快捷更新(检查/下载/安装)

This commit is contained in:
cbb
2026-10-12 00:18:39 +08:00
parent b6b1b712a4
commit a34e9f1169
7 changed files with 262 additions and 14 deletions
+2 -2
View File
@@ -13,8 +13,8 @@ android {
applicationId = "com.smbfast.app" applicationId = "com.smbfast.app"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 20 versionCode = 21
versionName = "1.18.0" versionName = "1.19.0"
} }
signingConfigs { signingConfigs {
+1
View File
@@ -8,6 +8,7 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application <application
android:name=".App" android:name=".App"
@@ -17,6 +17,8 @@ import javax.crypto.spec.GCMParameterSpec
data class SpeedSettings( data class SpeedSettings(
var streams: Int = 4, // 并行流数(每个流独立 SMB 句柄,分块并行下载) var streams: Int = 4, // 并行流数(每个流独立 SMB 句柄,分块并行下载)
var chunkKB: Int = 1024, // 每个请求的分块大小 KB var chunkKB: Int = 1024, // 每个请求的分块大小 KB
var islandOn: Boolean = true, // HyperOS 灵动岛/焦点通知
var updateUrl: String = "http://192.168.31.3:8080/", // 应用内快捷更新的分发服务器
) )
class Prefs(context: Context) { class Prefs(context: Context) {
@@ -138,14 +138,16 @@ class TransferService : Service() {
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
) )
b.addAction(NotificationCompat.Action(android.R.drawable.ic_delete, "全部停止", stopIntent)) b.addAction(NotificationCompat.Action(android.R.drawable.ic_delete, "全部停止", stopIntent))
runCatching { if (islandOn()) {
val extras = Bundle() runCatching {
extras.putBundle("miui.focus.pics", islandPics()) val extras = Bundle()
b.addExtras(extras) extras.putBundle("miui.focus.pics", islandPics())
b.addExtras(extras)
}
} }
} }
val n = b.build() val n = b.build()
if (!running.isNullOrEmpty()) { if (!running.isNullOrEmpty() && islandOn()) {
// 小米 HyperOS 焦点通知/超级岛:客户端本地上岛(官方 extras 协议)。 // 小米 HyperOS 焦点通知/超级岛:客户端本地上岛(官方 extras 协议)。
// 非小米系统会直接忽略该字段,不影响普通通知展示。 // 非小米系统会直接忽略该字段,不影响普通通知展示。
// islandFirstFloat 只在第一条通知为 true,否则每次更新都会重新弹出岛。 // islandFirstFloat 只在第一条通知为 true,否则每次更新都会重新弹出岛。
@@ -156,6 +158,10 @@ class TransferService : Service() {
return n return n
} }
private fun islandOn(): Boolean =
runCatching { com.smbfast.app.data.Prefs(applicationContext).loadSettings().islandOn }
.getOrDefault(true)
/** 岛内图标:直接复用应用图标 */ /** 岛内图标:直接复用应用图标 */
private fun islandPics(): Bundle = Bundle().apply { private fun islandPics(): Bundle = Bundle().apply {
val icon = Icon.createWithResource(applicationContext, R.mipmap.ic_launcher) val icon = Icon.createWithResource(applicationContext, R.mipmap.ic_launcher)
@@ -223,12 +229,9 @@ class TransferService : Service() {
.put("param_island", paramIsland) .put("param_island", paramIsland)
.put("baseInfo", baseInfo) .put("baseInfo", baseInfo)
val paramOs2 = JSONObject().put("baseInfo", baseInfo) // 单一根节点 param_v2:曾测试过 param+param_v2 双轨,HyperOS 解析异常会把
// 整条通知吞掉(比不用岛更糟),故保持单一根节点
return JSONObject() return JSONObject().put("param_v2", paramV2).toString()
.put("param", paramOs2)
.put("param_v2", paramV2)
.toString()
} }
private fun acquireLocks() { private fun acquireLocks() {
@@ -21,8 +21,10 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Bolt import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.BugReport import androidx.compose.material.icons.filled.BugReport
import androidx.compose.material.icons.filled.CloudDownload
import androidx.compose.material.icons.filled.Folder import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Speed import androidx.compose.material.icons.filled.Speed
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@@ -33,6 +35,8 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
@@ -41,6 +45,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -48,6 +53,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -56,9 +62,14 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.smbfast.app.ui.AppViewModel import com.smbfast.app.ui.AppViewModel
import com.smbfast.app.ui.components.AuroraBackground import com.smbfast.app.ui.components.AuroraBackground
import com.smbfast.app.ui.components.GlassCard import com.smbfast.app.ui.components.GlassCard
import com.smbfast.app.ui.components.GlassTextField
import com.smbfast.app.ui.components.GradientText import com.smbfast.app.ui.components.GradientText
import com.smbfast.app.ui.components.NeonIconBox import com.smbfast.app.ui.components.NeonIconBox
import com.smbfast.app.ui.components.NeonProgress
import com.smbfast.app.ui.theme.Neon import com.smbfast.app.ui.theme.Neon
import com.smbfast.app.util.Updater
import kotlinx.coroutines.launch
import java.io.File
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -147,6 +158,36 @@ fun SettingsScreen(vm: AppViewModel) {
subtitle = vm.downloadDir.absolutePath, subtitle = vm.downloadDir.absolutePath,
) {} ) {}
SectionCard(
icon = Icons.Default.Notifications,
brush = Neon.Primary,
title = "灵动岛 / 焦点通知",
subtitle = "传输时在小米 HyperOS 岛区域显示进度(其他品牌无影响)。若岛不出现或通知异常,可关闭后退回普通通知",
) {
Spacer(Modifier.height(6.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Switch(
checked = s.islandOn,
onCheckedChange = { checked ->
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( SectionCard(
icon = Icons.Default.Speed, icon = Icons.Default.Speed,
brush = Neon.Sunset, brush = Neon.Sunset,
@@ -161,7 +202,7 @@ fun SettingsScreen(vm: AppViewModel) {
icon = Icons.Default.Info, icon = Icons.Default.Info,
brush = Neon.File, brush = Neon.File,
title = "隐私提示", title = "隐私提示",
subtitle = "连接密码保存在本机应用私有数据中(未加密)。请勿在共享设备上保存敏感凭据。", subtitle = "连接密码使用 AndroidKeyStore 加密后保存在本机应用私有数据中,root/备份不可直接读取。",
) {} ) {}
SectionCard( 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<Updater.Info?>(null) }
var msg by remember { mutableStateOf<String?>(null) }
var progress by remember { mutableStateOf<Float?>(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 @Composable
private fun MiniChip(text: String, onClick: () -> Unit) { private fun MiniChip(text: String, onClick: () -> Unit) {
Box( Box(
@@ -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<Info?> = 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<File> =
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
}
}
}
+1
View File
@@ -3,4 +3,5 @@
<external-path name="external_download" path="Download/SMBFast/" /> <external-path name="external_download" path="Download/SMBFast/" />
<external-files-path name="app_download" path="SMBFast/" /> <external-files-path name="app_download" path="SMBFast/" />
<files-path name="internal_files" path="SMBFast/" /> <files-path name="internal_files" path="SMBFast/" />
<external-files-path name="update" path="update/" />
</paths> </paths>