refactor: 通知与前台服务解耦——Notifier 直发进度/常驻通知,FGS 仅保活(规避 dataSync 配额/侧载限制导致通知全灭)
This commit is contained in:
@@ -13,8 +13,8 @@ android {
|
|||||||
applicationId = "com.smbfast.app"
|
applicationId = "com.smbfast.app"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 26
|
versionCode = 27
|
||||||
versionName = "1.21.1"
|
versionName = "1.22.0"
|
||||||
}
|
}
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ package com.smbfast.app
|
|||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import com.smbfast.app.smb.SmbClientPool
|
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.TransferManager
|
||||||
import com.smbfast.app.transfer.TransferService
|
|
||||||
import com.smbfast.app.util.CrashLog
|
import com.smbfast.app.util.CrashLog
|
||||||
import org.bouncycastle.jce.provider.BouncyCastleProvider
|
import org.bouncycastle.jce.provider.BouncyCastleProvider
|
||||||
import java.security.Security
|
import java.security.Security
|
||||||
@@ -22,10 +23,12 @@ class App : Application() {
|
|||||||
Security.addProvider(BouncyCastleProvider())
|
Security.addProvider(BouncyCastleProvider())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 常驻前台通知:应用一开就显示(含灵动岛自检),无任务时保持"就绪",
|
// 常驻"就绪"通知(含灵动岛自检):普通通知直发,不启动前台服务——
|
||||||
// 不再要求必须有传输任务才能看到岛。后台进程被拉起时启动前台服务会被
|
// 避免在 FGS 被系统限制(dataSync 配额/侧载策略)时整条链路静默失效
|
||||||
// 系统拒绝,静默忽略即可(用户打开应用时自然会成功)。
|
runCatching {
|
||||||
runCatching { TransferService.startPersistent(this) }
|
val running = transferManager.items.value.filter { it.status == Status.RUNNING }
|
||||||
|
Notifier.notifyState(this, running)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onTerminate() {
|
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) }
|
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(
|
fun enqueueDownload(
|
||||||
profile: ConnectionProfile,
|
profile: ConnectionProfile,
|
||||||
shareName: String,
|
shareName: String,
|
||||||
|
|||||||
@@ -1,97 +1,65 @@
|
|||||||
package com.smbfast.app.transfer
|
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.app.Service
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.pm.ServiceInfo
|
import android.content.pm.ServiceInfo
|
||||||
import android.graphics.drawable.Icon
|
|
||||||
import android.net.wifi.WifiManager
|
import android.net.wifi.WifiManager
|
||||||
import android.net.wifi.WifiManager.WifiLock
|
import android.net.wifi.WifiManager.WifiLock
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
import android.os.PowerManager
|
import android.os.PowerManager
|
||||||
import androidx.core.app.NotificationCompat
|
|
||||||
import androidx.core.app.NotificationManagerCompat
|
|
||||||
import androidx.core.app.ServiceCompat
|
import androidx.core.app.ServiceCompat
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import com.smbfast.app.App
|
import com.smbfast.app.App
|
||||||
import com.smbfast.app.MainActivity
|
import com.smbfast.app.util.CrashLog
|
||||||
import com.smbfast.app.R
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.json.JSONObject
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 传输前台服务:应用退到后台时保活进程并持有 WiFi/CPU 锁,
|
* 传输保活前台服务:应用退到后台时持有 WiFi/CPU 锁,避免系统省电机制掐断 SMB 连接。
|
||||||
* 避免系统省电机制掐断 SMB 连接(SocketException: Software caused connection abort)。
|
*
|
||||||
* 常驻模式(startPersistent,应用启动即拉起):无任务时显示"就绪"通知并保持服务,
|
* 注意:通知不在这里发——进度/空闲通知统一由 [Notifier] 直发(与前台服务解耦)。
|
||||||
* 用于常显灵动岛/通知自检;普通模式(start)全部任务结束后自动 stopSelf()。
|
* Android 15 对 dataSync 类型前台服务有 24h 累计 6h 的系统配额,侧载应用也可能被
|
||||||
|
* HyperOS 拒绝启动;这些情况下传输与通知照常,只是没有保活。
|
||||||
*/
|
*/
|
||||||
class TransferService : Service() {
|
class TransferService : Service() {
|
||||||
|
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||||
private var wifiLock: WifiLock? = null
|
private var wifiLock: WifiLock? = null
|
||||||
private var wakeLock: PowerManager.WakeLock? = 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
|
private var locksHeld = false
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.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 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 }
|
try {
|
||||||
ServiceCompat.startForeground(this, NOTIF_ID, buildNotification(initial.ifEmpty { null }), type)
|
ServiceCompat.startForeground(this, Notifier.NOTIF_ID, Notifier.build(this, running), type)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// 配额耗尽/类型被禁:放弃保活,传输与通知由 TransferManager 继续
|
||||||
|
CrashLog.append("startForeground", e)
|
||||||
|
stopSelf()
|
||||||
|
return
|
||||||
|
}
|
||||||
acquireLocks()
|
acquireLocks()
|
||||||
val tm = App.transferManager
|
|
||||||
scope.launch {
|
scope.launch {
|
||||||
tm.items.collect { items ->
|
App.transferManager.items.collect { items ->
|
||||||
val running = items.filter { it.status == Status.RUNNING }
|
if (items.none { it.status == Status.RUNNING }) {
|
||||||
val now = System.currentTimeMillis()
|
|
||||||
if (running.isEmpty()) {
|
|
||||||
firstIslandNotify = true
|
|
||||||
releaseLocks()
|
releaseLocks()
|
||||||
if (!persistent) {
|
stopSelf()
|
||||||
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))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
when (intent?.action) {
|
if (intent?.action == ACTION_CANCEL_ALL) {
|
||||||
ACTION_PERSISTENT -> {
|
App.transferManager.cancelAllRunning()
|
||||||
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
|
return START_STICKY
|
||||||
}
|
}
|
||||||
@@ -104,168 +72,6 @@ class TransferService : Service() {
|
|||||||
|
|
||||||
override fun onBind(intent: Intent?): IBinder? = null
|
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() {
|
private fun acquireLocks() {
|
||||||
if (locksHeld) return
|
if (locksHeld) return
|
||||||
locksHeld = true
|
locksHeld = true
|
||||||
@@ -295,79 +101,14 @@ class TransferService : Service() {
|
|||||||
wifiLock = null
|
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 {
|
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_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) {
|
fun start(ctx: Context) {
|
||||||
ContextCompat.startForegroundService(ctx, Intent(ctx, TransferService::class.java))
|
runCatching {
|
||||||
}
|
ContextCompat.startForegroundService(ctx, Intent(ctx, TransferService::class.java))
|
||||||
|
}.onFailure { CrashLog.append("startFgs", it) }
|
||||||
/** 应用启动即拉起常驻前台通知(含灵动岛自检),无需有传输任务 */
|
|
||||||
fun startPersistent(ctx: Context) {
|
|
||||||
ContextCompat.startForegroundService(
|
|
||||||
ctx,
|
|
||||||
Intent(ctx, TransferService::class.java).setAction(ACTION_PERSISTENT),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ fun SettingsScreen(vm: AppViewModel) {
|
|||||||
vm.updateSettings { it.copy(islandOn = checked) }
|
vm.updateSettings { it.copy(islandOn = checked) }
|
||||||
// 用新设置立即重发常驻通知,便于当场验证岛参数是否被系统过滤
|
// 用新设置立即重发常驻通知,便于当场验证岛参数是否被系统过滤
|
||||||
runCatching {
|
runCatching {
|
||||||
com.smbfast.app.transfer.TransferService.startPersistent(ctx)
|
com.smbfast.app.transfer.Notifier.refresh(ctx)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
colors = SwitchDefaults.colors(
|
colors = SwitchDefaults.colors(
|
||||||
@@ -205,7 +205,7 @@ fun SettingsScreen(vm: AppViewModel) {
|
|||||||
onClick = {
|
onClick = {
|
||||||
vm.updateSettings { it.copy(islandMode = mode) }
|
vm.updateSettings { it.copy(islandMode = mode) }
|
||||||
runCatching {
|
runCatching {
|
||||||
com.smbfast.app.transfer.TransferService.startPersistent(ctx)
|
com.smbfast.app.transfer.Notifier.refresh(ctx)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
label = { Text(label) },
|
label = { Text(label) },
|
||||||
@@ -347,7 +347,7 @@ private fun NotificationDiagCard() {
|
|||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
MiniChip("发测试通知") {
|
MiniChip("发测试通知") {
|
||||||
msg = runCatching {
|
msg = runCatching {
|
||||||
com.smbfast.app.transfer.TransferService.sendTestNotification(ctx)
|
com.smbfast.app.transfer.Notifier.sendTestNotification(ctx)
|
||||||
"已发出,看状态栏/通知栏是否出现(时间戳区分新旧)"
|
"已发出,看状态栏/通知栏是否出现(时间戳区分新旧)"
|
||||||
}.getOrElse { "发送失败:${it.message}" }
|
}.getOrElse { "发送失败:${it.message}" }
|
||||||
refresh++
|
refresh++
|
||||||
@@ -355,7 +355,7 @@ private fun NotificationDiagCard() {
|
|||||||
if (hasChannel && importance <= 1) {
|
if (hasChannel && importance <= 1) {
|
||||||
MiniChip("修复通知通道") {
|
MiniChip("修复通知通道") {
|
||||||
msg = runCatching {
|
msg = runCatching {
|
||||||
com.smbfast.app.transfer.TransferService.repairChannel(ctx)
|
com.smbfast.app.transfer.Notifier.repairChannel(ctx)
|
||||||
"通道已删除并按默认重建,请再点「发测试通知」验证"
|
"通道已删除并按默认重建,请再点「发测试通知」验证"
|
||||||
}.getOrElse { "修复失败:${it.message}" }
|
}.getOrElse { "修复失败:${it.message}" }
|
||||||
refresh++
|
refresh++
|
||||||
|
|||||||
Reference in New Issue
Block a user