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"
minSdk = 26
targetSdk = 35
versionCode = 20
versionName = "1.18.0"
versionCode = 21
versionName = "1.19.0"
}
signingConfigs {
+1
View File
@@ -8,6 +8,7 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application
android:name=".App"
@@ -17,6 +17,8 @@ import javax.crypto.spec.GCMParameterSpec
data class SpeedSettings(
var streams: Int = 4, // 并行流数(每个流独立 SMB 句柄,分块并行下载)
var chunkKB: Int = 1024, // 每个请求的分块大小 KB
var islandOn: Boolean = true, // HyperOS 灵动岛/焦点通知
var updateUrl: String = "http://192.168.31.3:8080/", // 应用内快捷更新的分发服务器
)
class Prefs(context: Context) {
@@ -138,14 +138,16 @@ class TransferService : Service() {
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
b.addAction(NotificationCompat.Action(android.R.drawable.ic_delete, "全部停止", stopIntent))
runCatching {
val extras = Bundle()
extras.putBundle("miui.focus.pics", islandPics())
b.addExtras(extras)
if (islandOn()) {
runCatching {
val extras = Bundle()
extras.putBundle("miui.focus.pics", islandPics())
b.addExtras(extras)
}
}
}
val n = b.build()
if (!running.isNullOrEmpty()) {
if (!running.isNullOrEmpty() && islandOn()) {
// 小米 HyperOS 焦点通知/超级岛:客户端本地上岛(官方 extras 协议)。
// 非小米系统会直接忽略该字段,不影响普通通知展示。
// islandFirstFloat 只在第一条通知为 true,否则每次更新都会重新弹出岛。
@@ -156,6 +158,10 @@ class TransferService : Service() {
return n
}
private fun islandOn(): Boolean =
runCatching { com.smbfast.app.data.Prefs(applicationContext).loadSettings().islandOn }
.getOrDefault(true)
/** 岛内图标:直接复用应用图标 */
private fun islandPics(): Bundle = Bundle().apply {
val icon = Icon.createWithResource(applicationContext, R.mipmap.ic_launcher)
@@ -223,12 +229,9 @@ class TransferService : Service() {
.put("param_island", paramIsland)
.put("baseInfo", baseInfo)
val paramOs2 = JSONObject().put("baseInfo", baseInfo)
return JSONObject()
.put("param", paramOs2)
.put("param_v2", paramV2)
.toString()
// 单一根节点 param_v2:曾测试过 param+param_v2 双轨,HyperOS 解析异常会把
// 整条通知吞掉(比不用岛更糟),故保持单一根节点
return JSONObject().put("param_v2", paramV2).toString()
}
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.filled.Bolt
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.Info
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Speed
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -33,6 +35,8 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
@@ -41,6 +45,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
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.Color
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.FontWeight
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.components.AuroraBackground
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.NeonIconBox
import com.smbfast.app.ui.components.NeonProgress
import com.smbfast.app.ui.theme.Neon
import com.smbfast.app.util.Updater
import kotlinx.coroutines.launch
import java.io.File
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -147,6 +158,36 @@ fun SettingsScreen(vm: AppViewModel) {
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(
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<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
private fun MiniChip(text: String, onClick: () -> Unit) {
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-files-path name="app_download" path="SMBFast/" />
<files-path name="internal_files" path="SMBFast/" />
<external-files-path name="update" path="update/" />
</paths>