refactor: 通知与前台服务解耦——Notifier 直发进度/常驻通知,FGS 仅保活(规避 dataSync 配额/侧载限制导致通知全灭)

This commit is contained in:
cbb
2026-10-12 02:08:50 +08:00
parent 893e613562
commit 5d2944ac26
6 changed files with 308 additions and 294 deletions
+2 -2
View File
@@ -13,8 +13,8 @@ android {
applicationId = "com.smbfast.app"
minSdk = 26
targetSdk = 35
versionCode = 26
versionName = "1.21.1"
versionCode = 27
versionName = "1.22.0"
}
signingConfigs {
+8 -5
View File
@@ -2,8 +2,9 @@ package com.smbfast.app
import android.app.Application
import com.smbfast.app.smb.SmbClientPool
import com.smbfast.app.transfer.Notifier
import com.smbfast.app.transfer.Status
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
@@ -22,10 +23,12 @@ class App : Application() {
Security.addProvider(BouncyCastleProvider())
}
}
// 常驻前台通知:应用一开就显示(含灵动岛自检),无任务时保持"就绪",
// 不再要求必须有传输任务才能看到岛。后台进程被拉起时启动前台服务会被
// 系统拒绝,静默忽略即可(用户打开应用时自然会成功)。
runCatching { TransferService.startPersistent(this) }
// 常驻"就绪"通知(含灵动岛自检):普通通知直发,不启动前台服务——
// 避免在 FGS 被系统限制(dataSync 配额/侧载策略)时整条链路静默失效
runCatching {
val running = transferManager.items.value.filter { it.status == Status.RUNNING }
Notifier.notifyState(this, running)
}
}
override fun onTerminate() {
@@ -0,0 +1,244 @@
package com.smbfast.app.transfer
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.graphics.drawable.Icon
import android.os.Build
import android.os.Bundle
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import com.smbfast.app.MainActivity
import com.smbfast.app.R
import com.smbfast.app.data.Prefs
import org.json.JSONObject
/**
* 传输通知中心:进度/空闲通知统一由这里构建与发送,不依赖前台服务存活。
* 这样即使 HyperOS 拒绝启动前台服务(如 dataSync 24h 配额耗尽、侧载限制),
* 下载/上传进度通知依然可见;服务只负责保活。
*/
object Notifier {
const val CHANNEL_ID = "smbfast_transfers"
const val NOTIF_ID = 1001
const val NOTIF_ID_TEST = 1002
/** 岛参数模式:完整岛结构(默认,会被白名单过滤) */
const val MODE_FULL = "full"
/** 岛参数模式:极简(仅 baseInfo/ticker,无岛结构,白名单实验二) */
const val MODE_MINIMAL = "minimal"
private var islandSequence = 0L
fun createChannel(ctx: Context) {
val ch = NotificationChannel(CHANNEL_ID, "传输进度", NotificationManager.IMPORTANCE_LOW)
ch.description = "SMB 后台传输进行中"
ch.setShowBadge(false)
ctx.getSystemService(NotificationManager::class.java).createNotificationChannel(ch)
}
/** (岛开关, 参数模式);读取失败视为关岛 */
fun islandSettings(ctx: Context): Pair<Boolean, String>? = runCatching {
val s = Prefs(ctx).loadSettings()
s.islandOn to (s.islandMode ?: MODE_FULL)
}.getOrNull()
/** 发送当前状态的通知:running 为空即"就绪"常驻态 */
fun notifyState(ctx: Context, running: List<TransferItem>) {
if (Build.VERSION.SDK_INT >= 26) createChannel(ctx)
val n = build(ctx, running)
runCatching { NotificationManagerCompat.from(ctx).notify(NOTIF_ID, n) }
}
/** 按当前任务状态立即重发一条通知(设置切换岛开关/模式后调用) */
fun refresh(ctx: Context) {
val running = runCatching {
(ctx.applicationContext as? com.smbfast.app.App)
?.transferManager?.items?.value
?.filter { it.status == Status.RUNNING }
}.getOrNull() ?: emptyList()
notifyState(ctx, running)
}
/** 发一条独立 id 的测试通知,验证通道可见性。返回是否成功 */
fun sendTestNotification(ctx: Context): Boolean {
createChannel(ctx)
val time = java.text.SimpleDateFormat("HH:mm:ss", java.util.Locale.CHINA)
.format(java.util.Date())
val n = NotificationCompat.Builder(ctx, CHANNEL_ID)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle("SMBFast 测试通知")
.setContentText("看到这条即通道可见 · $time")
.setOngoing(false)
.setAutoCancel(true)
.build()
return runCatching {
NotificationManagerCompat.from(ctx).notify(NOTIF_ID_TEST, n)
true
}.getOrDefault(false)
}
/** 通道一旦被用户/系统屏蔽,应用无法改回;删除重建可恢复默认重要性 */
fun repairChannel(ctx: Context) {
val nm = ctx.getSystemService(NotificationManager::class.java)
runCatching { nm.deleteNotificationChannel(CHANNEL_ID) }
createChannel(ctx)
}
fun build(ctx: Context, running: List<TransferItem>): Notification {
val contentIntent = PendingIntent.getActivity(
ctx, 0, Intent(ctx, MainActivity::class.java),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
val up = running.count { it.direction == Direction.UPLOAD }
val down = running.count { it.direction == Direction.DOWNLOAD }
val speed = running.sumOf { it.speedBps }
val title = when {
running.isEmpty() -> "SMBFast 常驻服务"
up > 0 && down > 0 -> "后台传输中(↑$up ↓$down)"
up > 0 -> "后台上传中 ×$up"
else -> "后台下载中 ×$down"
}
val text = when {
running.isEmpty() -> "灵动岛/通知自检 · 等待传输任务"
running.size == 1 -> "${running[0].name} · ${human(speed)}"
else -> "${running[0].name} 等 ${running.size} 个任务 · ${human(speed)}"
}
val b = NotificationCompat.Builder(ctx, CHANNEL_ID)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle(title)
.setContentText(text)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setContentIntent(contentIntent)
if (running.isNotEmpty()) {
val total = running.sumOf { it.total }
val done = running.sumOf { it.done }
b.setProgress(
100,
if (total > 0) ((done.toDouble() / total).coerceIn(0.0, 1.0) * 100).toInt() else 0,
total <= 0,
)
val stopIntent = PendingIntent.getService(
ctx, 1,
Intent(ctx, TransferService::class.java).setAction(TransferService.ACTION_CANCEL_ALL),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
b.addAction(NotificationCompat.Action(android.R.drawable.ic_delete, "全部停止", stopIntent))
}
val isl = islandSettings(ctx)
if (isl != null && isl.second != MODE_MINIMAL) {
runCatching {
val extras = Bundle()
extras.putBundle("miui.focus.pics", islandPics(ctx))
b.addExtras(extras)
}
}
val n = b.build()
if (isl != null) {
// 小米 HyperOS 焦点通知/超级岛:客户端本地上岛(官方 extras 协议)。
// 注意:HyperOS 对焦点通知有系统级白名单,侧载应用携带岛参数时可能被
// 系统整体过滤(岛和通知都不显示)——在设置里关岛开关即可恢复普通通知。
runCatching { n.extras.putString("miui.focus.param", islandParam(running, isl.second)) }
}
return n
}
/** 岛内图标:直接复用应用图标 */
private fun islandPics(ctx: Context): Bundle = Bundle().apply {
val icon = Icon.createWithResource(ctx, R.mipmap.ic_launcher)
putParcelable("miui.focus.pic_ticker", icon)
putParcelable("miui.focus.pic_imageText", icon)
}
/**
* 超级岛/焦点通知参数(miui.focus.param):
* - MODE_FULL:param_v2 全量(param_island 大/小岛 + pics + baseInfo),HyperOS 3 超级岛;
* 实测会被白名单整体过滤(通知都消失)
* - MODE_MINIMAL:param_v2 仅 baseInfo/ticker/aodTitle,无任何岛结构与图片引用,
* 用于验证白名单校验是否只在完整岛结构时触发(实验二)
*/
private fun islandParam(running: List<TransferItem>, mode: String): String {
islandSequence += 1
val idle = running.isEmpty()
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 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 islandTitle = if (idle) "就绪" else "$pct%"
val islandContent = if (idle) "后台就绪 · 等待任务" else "$speedText · ${running.size} 个任务"
val first = running.firstOrNull()?.name ?: "SMBFast"
val baseInfo = JSONObject()
.put("title", dirText)
.put("content", if (idle) "后台就绪" else "$first · $pct% · $speedText")
.put("colorTitle", "#22D3EE")
.put("type", 2)
val tickerText = if (idle) "SMBFast 就绪" else "$dirText $pct% · $speedText"
val paramV2 = JSONObject()
.put("protocol", 1)
.put("business", "smbfast_transfer")
.put("updatable", true)
.put("sequence", islandSequence)
.put("ticker", tickerText)
.put("aodTitle", tickerText)
.put("baseInfo", baseInfo)
if (mode != MODE_MINIMAL) {
val textInfo = JSONObject()
.put("frontTitle", dirText)
.put("title", islandTitle)
.put("content", islandContent)
.put("useHighLight", false)
val picInfo = JSONObject().put("type", 1).put("pic", "miui.focus.pic_imageText")
val bigLeft = JSONObject()
.put("type", 1)
.put("picInfo", picInfo)
.put("textInfo", textInfo)
val paramIsland = JSONObject()
.put("islandProperty", 1)
.put("bigIslandArea", JSONObject().put("imageTextInfoLeft", bigLeft).put("picInfo", picInfo))
.put("smallIslandArea", JSONObject().put("picInfo", picInfo))
paramV2
.put("enableFloat", true) // 启用浮动(胶囊/浮窗形态);false 会压住岛不展示
.put("islandFirstFloat", true) // 每次全新通知展开一次
.put("tickerPic", "miui.focus.pic_ticker")
.put("aodPic", "miui.focus.pic_ticker")
.put("param_island", paramIsland)
}
// 单一根节点 param_v2:曾测试过 param+param_v2 双轨,HyperOS 解析异常会把
// 整条通知吞掉(比不用岛更糟),故保持单一根节点
return JSONObject().put("param_v2", paramV2).toString()
}
private fun human(bps: Long): String {
val kb = bps / 1024.0
val mb = kb / 1024.0
val gb = mb / 1024.0
return when {
gb >= 1 -> String.format("%.1f GB/s", gb)
mb >= 1 -> String.format("%.1f MB/s", mb)
kb >= 1 -> String.format("%.0f KB/s", kb)
else -> "$bps B/s"
}
}
}
@@ -62,6 +62,32 @@ class TransferManager(private val appContext: Context) {
val downloadDir: File by lazy { Files.resolveDownloadDir(appContext) }
init {
// 进度通知与前台服务解耦:即使系统拒绝启动 FGS(dataSync 配额/侧载限制),
// 通知依然由这里直发,下载照常进行
scope.launch {
var lastNotify = 0L
var hadRunning = false
items.collect { list ->
val running = list.filter { it.status == Status.RUNNING }
if (running.isNotEmpty()) {
hadRunning = true
val now = System.currentTimeMillis()
if (now - lastNotify >= 1500) {
lastNotify = now
runCatching { Notifier.notifyState(appContext, running) }
.onFailure { com.smbfast.app.util.CrashLog.append("notify", it) }
}
} else if (hadRunning) {
// 全部结束:恢复"就绪"常驻态
hadRunning = false
lastNotify = 0
runCatching { Notifier.notifyState(appContext, emptyList()) }
}
}
}
}
fun enqueueDownload(
profile: ConnectionProfile,
shareName: String,
@@ -1,97 +1,65 @@
package com.smbfast.app.transfer
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.graphics.drawable.Icon
import android.net.wifi.WifiManager
import android.net.wifi.WifiManager.WifiLock
import android.os.Build
import android.os.Bundle
import android.os.IBinder
import android.os.PowerManager
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
import com.smbfast.app.App
import com.smbfast.app.MainActivity
import com.smbfast.app.R
import com.smbfast.app.util.CrashLog
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import org.json.JSONObject
/**
* 传输前台服务:应用退到后台时保活进程并持有 WiFi/CPU 锁,
* 避免系统省电机制掐断 SMB 连接(SocketException: Software caused connection abort)。
* 常驻模式(startPersistent,应用启动即拉起):无任务时显示"就绪"通知并保持服务,
* 用于常显灵动岛/通知自检;普通模式(start)全部任务结束后自动 stopSelf()。
* 传输保活前台服务:应用退到后台时持有 WiFi/CPU 锁,避免系统省电机制掐断 SMB 连接。
*
* 注意:通知不在这里发——进度/空闲通知统一由 [Notifier] 直发(与前台服务解耦)。
* Android 15 对 dataSync 类型前台服务有 24h 累计 6h 的系统配额,侧载应用也可能被
* HyperOS 拒绝启动;这些情况下传输与通知照常,只是没有保活。
*/
class TransferService : Service() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private var wifiLock: WifiLock? = null
private var wakeLock: PowerManager.WakeLock? = null
private var islandSequence = 0L
private var lastNotify = 0L
private var firstIslandNotify = true
/** 常驻模式:无任务时保留前台通知(灵动岛/通知自检用),不自动停服 */
@Volatile private var persistent = false
private var locksHeld = false
override fun onCreate() {
super.onCreate()
createChannel()
Notifier.createChannel(this)
val running = App.transferManager.items.value.filter { it.status == Status.RUNNING }
val type = if (Build.VERSION.SDK_INT >= 29) ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC else 0
val initial = App.transferManager.items.value.filter { it.status == Status.RUNNING }
ServiceCompat.startForeground(this, NOTIF_ID, buildNotification(initial.ifEmpty { null }), type)
try {
ServiceCompat.startForeground(this, Notifier.NOTIF_ID, Notifier.build(this, running), type)
} catch (e: Exception) {
// 配额耗尽/类型被禁:放弃保活,传输与通知由 TransferManager 继续
CrashLog.append("startForeground", e)
stopSelf()
return
}
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
App.transferManager.items.collect { items ->
if (items.none { it.status == Status.RUNNING }) {
releaseLocks()
if (!persistent) {
stopSelf()
return@collect
}
if (now - lastNotify >= NOTIFY_INTERVAL_MS) {
lastNotify = now
notify(buildNotification(null))
}
return@collect
}
acquireLocks()
// 限频刷新:高频 notify 会不停触发横幅/岛动画
if (now - lastNotify >= NOTIFY_INTERVAL_MS) {
lastNotify = now
notify(buildNotification(running))
stopSelf()
}
}
}
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
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()
if (intent?.action == ACTION_CANCEL_ALL) {
App.transferManager.cancelAllRunning()
}
return START_STICKY
}
@@ -104,168 +72,6 @@ class TransferService : Service() {
override fun onBind(intent: Intent?): IBinder? = null
private fun createChannel() {
if (Build.VERSION.SDK_INT >= 26) {
createChannelStatic(this)
}
}
private fun notify(n: Notification) {
// 未授予通知权限时任务照跑,只是通知条不显示
runCatching { NotificationManagerCompat.from(this).notify(NOTIF_ID, n) }
}
private fun buildNotification(running: List<TransferItem>?): Notification {
val contentIntent = PendingIntent.getActivity(
this, 0, Intent(this, MainActivity::class.java),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
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 title = when {
running.isNullOrEmpty() -> "SMBFast 常驻服务"
up > 0 && down > 0 -> "后台传输中(↑$up ↓$down)"
up > 0 -> "后台上传中 ×$up"
else -> "后台下载中 ×$down"
}
val text = when {
running.isNullOrEmpty() -> "灵动岛/通知自检 · 等待传输任务"
running.size == 1 -> "${running[0].name} · ${human(speed)}"
else -> "${running[0].name} 等 ${running.size} 个任务 · ${human(speed)}"
}
val b = NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle(title)
.setContentText(text)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setContentIntent(contentIntent)
if (!running.isNullOrEmpty()) {
val total = running.sumOf { it.total }
val done = running.sumOf { it.done }
b.setProgress(
100,
if (total > 0) ((done.toDouble() / total).coerceIn(0.0, 1.0) * 100).toInt() else 0,
total <= 0,
)
val stopIntent = PendingIntent.getService(
this, 1,
Intent(this, TransferService::class.java).setAction(ACTION_CANCEL_ALL),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
b.addAction(NotificationCompat.Action(android.R.drawable.ic_delete, "全部停止", stopIntent))
}
val isl = islandSettings()
if (isl != null && isl.second != MODE_MINIMAL) {
runCatching {
val extras = Bundle()
extras.putBundle("miui.focus.pics", islandPics())
b.addExtras(extras)
}
}
val n = b.build()
if (isl != null) {
// 小米 HyperOS 焦点通知/超级岛:客户端本地上岛(官方 extras 协议)。
// 注意:HyperOS 对焦点通知有系统级白名单,侧载应用携带岛参数时可能被
// 系统整体过滤(岛和通知都不显示)——在设置里关岛开关即可恢复普通通知。
// islandFirstFloat 只在第一条通知为 true,否则每次更新都会重新弹出岛。
val first = firstIslandNotify
runCatching { n.extras.putString("miui.focus.param", islandParam(running, first, isl.second)) }
firstIslandNotify = false
}
return n
}
/** 读取岛设置:(开关, 参数模式);读取失败返回 null 表示不带岛参数 */
private fun islandSettings(): Pair<Boolean, String>? = runCatching {
val s = com.smbfast.app.data.Prefs(applicationContext).loadSettings()
s.islandOn to (s.islandMode ?: MODE_FULL)
}.getOrNull()
/** 岛内图标:直接复用应用图标 */
private fun islandPics(): Bundle = Bundle().apply {
val icon = Icon.createWithResource(applicationContext, R.mipmap.ic_launcher)
putParcelable("miui.focus.pic_ticker", icon)
putParcelable("miui.focus.pic_imageText", icon)
}
/**
* 超级岛/焦点通知参数(miui.focus.param):
* - MODE_FULL:param_v2 全量(param_island 大/小岛 + pics + baseInfo),HyperOS 3 超级岛;
* 实测会被白名单整体过滤(通知都消失)
* - MODE_MINIMAL:param_v2 仅 baseInfo/ticker/aodTitle,无任何岛结构与图片引用,
* 用于验证白名单校验是否只在完整岛结构时触发(实验二)
* 空闲(无任务)时展示"就绪"内容。每次更新同一通知 id + sequence 递增即可刷新。
*/
private fun islandParam(running: List<TransferItem>?, firstFloat: Boolean, mode: String): String {
islandSequence += 1
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 islandTitle = if (idle) "就绪" else "$pct%"
val islandContent = if (idle) "后台就绪 · 等待任务" else "$speedText · $size 个任务"
val first = running?.firstOrNull()?.name ?: "SMBFast"
val baseInfo = JSONObject()
.put("title", dirText)
.put("content", if (idle) "后台就绪" else "$first · $pct% · $speedText")
.put("colorTitle", "#22D3EE")
.put("type", 2)
val tickerText = if (idle) "SMBFast 就绪" else "$dirText $pct% · $speedText"
val paramV2 = JSONObject()
.put("protocol", 1)
.put("business", "smbfast_transfer")
.put("updatable", true)
.put("sequence", islandSequence)
.put("ticker", tickerText)
.put("aodTitle", tickerText)
.put("baseInfo", baseInfo)
if (mode != MODE_MINIMAL) {
val textInfo = JSONObject()
.put("frontTitle", dirText)
.put("title", islandTitle)
.put("content", islandContent)
.put("useHighLight", false)
val picInfo = JSONObject().put("type", 1).put("pic", "miui.focus.pic_imageText")
val bigLeft = JSONObject()
.put("type", 1)
.put("picInfo", picInfo)
.put("textInfo", textInfo)
val paramIsland = JSONObject()
.put("islandProperty", 1)
.put("bigIslandArea", JSONObject().put("imageTextInfoLeft", bigLeft).put("picInfo", picInfo))
.put("smallIslandArea", JSONObject().put("picInfo", picInfo))
paramV2
.put("enableFloat", true) // 启用浮动(胶囊/浮窗形态);false 会压住岛不展示
.put("islandFirstFloat", firstFloat) // 仅首条通知展开一次
.put("tickerPic", "miui.focus.pic_ticker")
.put("aodPic", "miui.focus.pic_ticker")
.put("param_island", paramIsland)
}
// 单一根节点 param_v2:曾测试过 param+param_v2 双轨,HyperOS 解析异常会把
// 整条通知吞掉(比不用岛更糟),故保持单一根节点
return JSONObject().put("param_v2", paramV2).toString()
}
private fun acquireLocks() {
if (locksHeld) return
locksHeld = true
@@ -295,79 +101,14 @@ class TransferService : Service() {
wifiLock = null
}
private fun human(bps: Long): String {
val kb = bps / 1024.0
val mb = kb / 1024.0
val gb = mb / 1024.0
return when {
gb >= 1 -> String.format("%.1f GB/s", gb)
mb >= 1 -> String.format("%.1f MB/s", mb)
kb >= 1 -> String.format("%.0f KB/s", kb)
else -> "$bps B/s"
}
}
companion object {
private const val CHANNEL_ID = "smbfast_transfers"
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"
/** 岛参数模式:完整岛结构(默认,会被白名单过滤) */
const val MODE_FULL = "full"
/** 岛参数模式:极简(仅 baseInfo/ticker,无岛结构,白名单实验二) */
const val MODE_MINIMAL = "minimal"
private const val NOTIF_ID_TEST = 1002
private fun createChannelStatic(ctx: Context) {
val ch = NotificationChannel(CHANNEL_ID, "传输进度", NotificationManager.IMPORTANCE_LOW)
ch.description = "SMB 后台传输进行中"
ch.setShowBadge(false)
ctx.getSystemService(NotificationManager::class.java).createNotificationChannel(ch)
}
/**
* 发一条独立 id 的测试通知:验证通道级可见性(应用权限"已授予"不代表通道没被系统屏蔽)。
* 返回是否发送成功。
*/
fun sendTestNotification(ctx: Context): Boolean {
if (Build.VERSION.SDK_INT >= 26) createChannelStatic(ctx)
val time = java.text.SimpleDateFormat("HH:mm:ss", java.util.Locale.CHINA)
.format(java.util.Date())
val n = NotificationCompat.Builder(ctx, CHANNEL_ID)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle("SMBFast 测试通知")
.setContentText("看到这条即通道可见 · $time")
.setOngoing(false)
.setAutoCancel(true)
.build()
return runCatching {
NotificationManagerCompat.from(ctx).notify(NOTIF_ID_TEST, n)
true
}.getOrDefault(false)
}
/** 通道一旦被用户/系统屏蔽,应用无法改回;删除重建可恢复默认重要性 */
fun repairChannel(ctx: Context) {
val nm = ctx.getSystemService(NotificationManager::class.java)
runCatching { nm.deleteNotificationChannel(CHANNEL_ID) }
if (Build.VERSION.SDK_INT >= 26) createChannelStatic(ctx)
}
/** 有任务入队时调用;须在应用处于前台时启动(前台服务启动限制) */
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),
)
runCatching {
ContextCompat.startForegroundService(ctx, Intent(ctx, TransferService::class.java))
}.onFailure { CrashLog.append("startFgs", it) }
}
}
}
@@ -175,7 +175,7 @@ fun SettingsScreen(vm: AppViewModel) {
vm.updateSettings { it.copy(islandOn = checked) }
// 用新设置立即重发常驻通知,便于当场验证岛参数是否被系统过滤
runCatching {
com.smbfast.app.transfer.TransferService.startPersistent(ctx)
com.smbfast.app.transfer.Notifier.refresh(ctx)
}
},
colors = SwitchDefaults.colors(
@@ -205,7 +205,7 @@ fun SettingsScreen(vm: AppViewModel) {
onClick = {
vm.updateSettings { it.copy(islandMode = mode) }
runCatching {
com.smbfast.app.transfer.TransferService.startPersistent(ctx)
com.smbfast.app.transfer.Notifier.refresh(ctx)
}
},
label = { Text(label) },
@@ -347,7 +347,7 @@ private fun NotificationDiagCard() {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
MiniChip("发测试通知") {
msg = runCatching {
com.smbfast.app.transfer.TransferService.sendTestNotification(ctx)
com.smbfast.app.transfer.Notifier.sendTestNotification(ctx)
"已发出,看状态栏/通知栏是否出现(时间戳区分新旧)"
}.getOrElse { "发送失败:${it.message}" }
refresh++
@@ -355,7 +355,7 @@ private fun NotificationDiagCard() {
if (hasChannel && importance <= 1) {
MiniChip("修复通知通道") {
msg = runCatching {
com.smbfast.app.transfer.TransferService.repairChannel(ctx)
com.smbfast.app.transfer.Notifier.repairChannel(ctx)
"通道已删除并按默认重建,请再点「发测试通知」验证"
}.getOrElse { "修复失败:${it.message}" }
refresh++