diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4dfe631..51c2b82 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 = 23 - versionName = "1.19.2" + versionCode = 24 + versionName = "1.20.0" } signingConfigs { diff --git a/app/src/main/java/com/smbfast/app/App.kt b/app/src/main/java/com/smbfast/app/App.kt index 7440529..b90484a 100644 --- a/app/src/main/java/com/smbfast/app/App.kt +++ b/app/src/main/java/com/smbfast/app/App.kt @@ -3,6 +3,7 @@ package com.smbfast.app import android.app.Application import com.smbfast.app.smb.SmbClientPool import com.smbfast.app.transfer.TransferManager +import com.smbfast.app.transfer.TransferService import com.smbfast.app.util.CrashLog import org.bouncycastle.jce.provider.BouncyCastleProvider import java.security.Security @@ -21,6 +22,10 @@ class App : Application() { Security.addProvider(BouncyCastleProvider()) } } + // 常驻前台通知:应用一开就显示(含灵动岛自检),无任务时保持"就绪", + // 不再要求必须有传输任务才能看到岛。后台进程被拉起时启动前台服务会被 + // 系统拒绝,静默忽略即可(用户打开应用时自然会成功)。 + runCatching { TransferService.startPersistent(this) } } override fun onTerminate() { diff --git a/app/src/main/java/com/smbfast/app/transfer/TransferService.kt b/app/src/main/java/com/smbfast/app/transfer/TransferService.kt index a583bc7..559b69d 100644 --- a/app/src/main/java/com/smbfast/app/transfer/TransferService.kt +++ b/app/src/main/java/com/smbfast/app/transfer/TransferService.kt @@ -32,7 +32,8 @@ import org.json.JSONObject /** * 传输前台服务:应用退到后台时保活进程并持有 WiFi/CPU 锁, * 避免系统省电机制掐断 SMB 连接(SocketException: Software caused connection abort)。 - * 有任务时由 TransferManager 调用方 start(),全部结束(或取消)后自动 stopSelf()。 + * 常驻模式(startPersistent,应用启动即拉起):无任务时显示"就绪"通知并保持服务, + * 用于常显灵动岛/通知自检;普通模式(start)全部任务结束后自动 stopSelf()。 */ class TransferService : Service() { @@ -43,24 +44,37 @@ class TransferService : Service() { private var lastNotify = 0L private var firstIslandNotify = true + /** 常驻模式:无任务时保留前台通知(灵动岛/通知自检用),不自动停服 */ + @Volatile private var persistent = false + private var locksHeld = false + override fun onCreate() { super.onCreate() createChannel() val type = if (Build.VERSION.SDK_INT >= 29) ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC else 0 - ServiceCompat.startForeground(this, NOTIF_ID, buildNotification(null), type) + val initial = App.transferManager.items.value.filter { it.status == Status.RUNNING } + ServiceCompat.startForeground(this, NOTIF_ID, buildNotification(initial.ifEmpty { null }), type) acquireLocks() val tm = App.transferManager scope.launch { tm.items.collect { items -> val running = items.filter { it.status == Status.RUNNING } + val now = System.currentTimeMillis() if (running.isEmpty()) { firstIslandNotify = true releaseLocks() - stopSelf() + if (!persistent) { + stopSelf() + return@collect + } + if (now - lastNotify >= NOTIFY_INTERVAL_MS) { + lastNotify = now + notify(buildNotification(null)) + } return@collect } + acquireLocks() // 限频刷新:高频 notify 会不停触发横幅/岛动画 - val now = System.currentTimeMillis() if (now - lastNotify >= NOTIFY_INTERVAL_MS) { lastNotify = now notify(buildNotification(running)) @@ -70,8 +84,14 @@ class TransferService : Service() { } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - if (intent?.action == ACTION_CANCEL_ALL) { - App.transferManager.cancelAllRunning() + when (intent?.action) { + ACTION_PERSISTENT -> { + persistent = true + // 用当前设置立即重发一条,便于开关岛后即时验证 + val running = App.transferManager.items.value.filter { it.status == Status.RUNNING } + notify(buildNotification(running.ifEmpty { null })) + } + ACTION_CANCEL_ALL -> App.transferManager.cancelAllRunning() } return START_STICKY } @@ -107,13 +127,13 @@ class TransferService : Service() { val down = running?.count { it.direction == Direction.DOWNLOAD } ?: 0 val speed = running?.sumOf { it.speedBps } ?: 0L val title = when { - running.isNullOrEmpty() -> "正在准备传输…" + running.isNullOrEmpty() -> "SMBFast 常驻服务" up > 0 && down > 0 -> "后台传输中(↑$up ↓$down)" up > 0 -> "后台上传中 ×$up" else -> "后台下载中 ×$down" } val text = when { - running.isNullOrEmpty() -> "SMB 传输服务已就绪" + running.isNullOrEmpty() -> "灵动岛/通知自检 · 等待传输任务" running.size == 1 -> "${running[0].name} · ${human(speed)}" else -> "${running[0].name} 等 ${running.size} 个任务 · ${human(speed)}" } @@ -138,18 +158,19 @@ class TransferService : Service() { PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, ) b.addAction(NotificationCompat.Action(android.R.drawable.ic_delete, "全部停止", stopIntent)) - if (islandOn()) { - 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() && islandOn()) { + if (islandOn()) { // 小米 HyperOS 焦点通知/超级岛:客户端本地上岛(官方 extras 协议)。 - // 非小米系统会直接忽略该字段,不影响普通通知展示。 + // 注意:HyperOS 对焦点通知有系统级白名单,侧载应用携带岛参数时可能被 + // 系统整体过滤(岛和通知都不显示)——在设置里关岛开关即可恢复普通通知。 // islandFirstFloat 只在第一条通知为 true,否则每次更新都会重新弹出岛。 val first = firstIslandNotify runCatching { n.extras.putString("miui.focus.param", islandParam(running, first)) } @@ -173,29 +194,34 @@ class TransferService : Service() { * 超级岛/焦点通知参数(miui.focus.param): * - HyperOS 3 读 param_v2.param_island(屏幕顶部大/小岛) * - HyperOS 2 读 param.baseInfo(状态栏焦点) - * 双轨根节点同时携带,由系统按版本自行取用。 + * 空闲(无任务)时展示"就绪"岛,让常驻通知不传文件也能上岛。 * 每次更新同一通知 id + sequence 递增即可刷新岛内容。 */ - private fun islandParam(running: List, firstFloat: Boolean): String { + private fun islandParam(running: List?, firstFloat: Boolean): String { islandSequence += 1 - val up = running.count { it.direction == Direction.UPLOAD } - val down = running.count { it.direction == Direction.DOWNLOAD } - val speed = running.sumOf { it.speedBps } - val total = running.sumOf { it.total } - val done = running.sumOf { it.done } + val idle = running.isNullOrEmpty() + val up = running?.count { it.direction == Direction.UPLOAD } ?: 0 + val down = running?.count { it.direction == Direction.DOWNLOAD } ?: 0 + val speed = running?.sumOf { it.speedBps } ?: 0L + val total = running?.sumOf { it.total } ?: 0L + val done = running?.sumOf { it.done } ?: 0L + val size = running?.size ?: 0 val pct = if (total > 0) ((done.toDouble() / total).coerceIn(0.0, 1.0) * 100).toInt() else 0 val dirText = when { + idle -> "SMBFast" up > 0 && down > 0 -> "SMB 传输中" up > 0 -> "SMB 上传中" else -> "SMB 下载中" } val speedText = human(speed) - val first = running.first().name + val islandTitle = if (idle) "就绪" else "$pct%" + val islandContent = if (idle) "后台就绪 · 等待任务" else "$speedText · $size 个任务" + val first = running?.firstOrNull()?.name ?: "SMBFast" val textInfo = JSONObject() .put("frontTitle", dirText) - .put("title", "$pct%") - .put("content", "$speedText · ${running.size} 个任务") + .put("title", islandTitle) + .put("content", islandContent) .put("useHighLight", false) val picInfo = JSONObject().put("type", 1).put("pic", "miui.focus.pic_imageText") @@ -211,7 +237,7 @@ class TransferService : Service() { val baseInfo = JSONObject() .put("title", dirText) - .put("content", "$first · $pct% · $speedText") + .put("content", if (idle) "后台就绪" else "$first · $pct% · $speedText") .put("colorTitle", "#22D3EE") .put("type", 2) @@ -219,11 +245,11 @@ class TransferService : Service() { .put("protocol", 1) .put("business", "smbfast_transfer") .put("updatable", true) - .put("enableFloat", false) // 进度高频刷新,不自动展开,避免打扰 + .put("enableFloat", true) // 启用浮动(胶囊/浮窗形态);false 会压住岛不展示 .put("islandFirstFloat", firstFloat) // 仅首条通知展开一次 .put("sequence", islandSequence) - .put("ticker", "$dirText $pct% · $speedText") - .put("aodTitle", "$dirText $pct% · $speedText") + .put("ticker", if (idle) "SMBFast 就绪" else "$dirText $pct% · $speedText") + .put("aodTitle", if (idle) "SMBFast 就绪" else "$dirText $pct% · $speedText") .put("tickerPic", "miui.focus.pic_ticker") .put("aodPic", "miui.focus.pic_ticker") .put("param_island", paramIsland) @@ -235,6 +261,8 @@ class TransferService : Service() { } private fun acquireLocks() { + if (locksHeld) return + locksHeld = true runCatching { val pm = getSystemService(Context.POWER_SERVICE) as PowerManager wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "smbfast:transfer").apply { @@ -254,6 +282,7 @@ class TransferService : Service() { } private fun releaseLocks() { + locksHeld = false runCatching { wakeLock?.release() } wakeLock = null runCatching { wifiLock?.release() } @@ -277,10 +306,19 @@ class TransferService : Service() { private const val NOTIF_ID = 1001 private const val NOTIFY_INTERVAL_MS = 1500L const val ACTION_CANCEL_ALL = "com.smbfast.app.transfer.CANCEL_ALL" + const val ACTION_PERSISTENT = "com.smbfast.app.transfer.PERSISTENT" /** 有任务入队时调用;须在应用处于前台时启动(前台服务启动限制) */ fun start(ctx: Context) { ContextCompat.startForegroundService(ctx, Intent(ctx, TransferService::class.java)) } + + /** 应用启动即拉起常驻前台通知(含灵动岛自检),无需有传输任务 */ + fun startPersistent(ctx: Context) { + ContextCompat.startForegroundService( + ctx, + Intent(ctx, TransferService::class.java).setAction(ACTION_PERSISTENT), + ) + } } } diff --git a/app/src/main/java/com/smbfast/app/ui/screens/SettingsScreen.kt b/app/src/main/java/com/smbfast/app/ui/screens/SettingsScreen.kt index 6dfa838..88a6f6d 100644 --- a/app/src/main/java/com/smbfast/app/ui/screens/SettingsScreen.kt +++ b/app/src/main/java/com/smbfast/app/ui/screens/SettingsScreen.kt @@ -76,6 +76,7 @@ import java.io.File fun SettingsScreen(vm: AppViewModel) { val s by vm.settings.collectAsStateWithLifecycle() var showLog by remember { mutableStateOf(false) } + val ctx = LocalContext.current AuroraBackground { Scaffold( @@ -162,7 +163,9 @@ fun SettingsScreen(vm: AppViewModel) { icon = Icons.Default.Notifications, brush = Neon.Primary, title = "灵动岛 / 焦点通知", - subtitle = "传输时在小米 HyperOS 岛区域显示进度(其他品牌无影响)。若岛不出现或通知异常,可关闭后退回普通通知", + subtitle = "在小米 HyperOS 岛区域显示进度(其他品牌无影响)。注意:HyperOS 对焦点通知" + + "有系统级白名单,侧载应用带岛参数时可能被系统整体过滤(岛和通知都不显示)——" + + "遇到这种情况关闭本开关即可恢复普通通知", ) { Spacer(Modifier.height(6.dp)) Row(verticalAlignment = Alignment.CenterVertically) { @@ -170,6 +173,10 @@ fun SettingsScreen(vm: AppViewModel) { checked = s.islandOn, onCheckedChange = { checked -> vm.updateSettings { it.copy(islandOn = checked) } + // 用新设置立即重发常驻通知,便于当场验证岛参数是否被系统过滤 + runCatching { + com.smbfast.app.transfer.TransferService.startPersistent(ctx) + } }, colors = SwitchDefaults.colors( checkedThumbColor = Color(0xFF04121E), @@ -186,6 +193,8 @@ fun SettingsScreen(vm: AppViewModel) { } } + NotificationDiagCard() + UpdateSection(vm = vm, currentName = currentVersionName(), currentCode = currentVersionCode()) SectionCard( @@ -271,6 +280,60 @@ private fun currentVersionCode(): Long { } } +/** 通知系统自检:应用通知权限/通道被关是"通知和灵动岛全都不显示"的最常见原因 */ +@Composable +private fun NotificationDiagCard() { + val ctx = LocalContext.current + val (enabled, hasChannel, importance) = remember { + val nm = androidx.core.app.NotificationManagerCompat.from(ctx) + val ch = nm.getNotificationChannel("smbfast_transfers") + Triple(nm.areNotificationsEnabled(), ch != null, ch?.importance ?: -1) + } + val impText = when { + !hasChannel -> "未创建" + importance == -1 -> "未创建" + importance == 0 -> "已屏蔽(不显示)" + importance == 1 -> "MIN(通知栏不显示)" + importance == 2 -> "LOW(静默显示在通知栏,无横幅)" + importance == 3 -> "DEFAULT" + else -> "HIGH(会弹横幅)" + } + SectionCard( + icon = Icons.Default.Notifications, + brush = Neon.File, + title = "通知系统自检", + subtitle = "系统通知权限:" + if (enabled) "已授予" else "已被关闭!通知与灵动岛都不会显示" + + "\n通知通道「传输进度」:" + impText, + ) { + if (!enabled) { + Spacer(Modifier.height(8.dp)) + MiniChip("去开启通知权限") { + runCatching { + ctx.startActivity( + android.content.Intent( + android.provider.Settings.ACTION_APP_NOTIFICATION_SETTINGS, + ).putExtra(android.provider.Settings.EXTRA_APP_PACKAGE, ctx.packageName), + ) + } + } + } + if (hasChannel && importance == 0) { + Spacer(Modifier.height(8.dp)) + MiniChip("去修复通知通道") { + runCatching { + ctx.startActivity( + android.content.Intent( + android.provider.Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS, + ) + .putExtra(android.provider.Settings.EXTRA_APP_PACKAGE, ctx.packageName) + .putExtra(android.provider.Settings.EXTRA_CHANNEL_ID, "smbfast_transfers"), + ) + } + } + } + } +} + @Composable private fun currentVersionName(): String { val ctx = LocalContext.current