From 4c08f6bf417c9e7c9814fc46326e2b263191341c Mon Sep 17 00:00:00 2001 From: Ashurinnn <77261372+Ashurinnn@users.noreply.github.com> Date: Thu, 15 Oct 2026 03:19:55 +0100 Subject: [PATCH] fix: clean up app volume lifecycle and defer root probing to UI --- .../java/cn/ianzb/hyperrefine/MainActivity.kt | 1 + .../cn/ianzb/hyperrefine/xposed/RootHelper.kt | 11 +- .../cn/ianzb/hyperrefine/xposed/RootProbe.kt | 30 ++ .../xposed/XposedServiceManager.kt | 8 +- .../ianzb/hyperrefine/xposed/RootProbeTest.kt | 66 +++ changelog.md | 4 + docs/API.md | 9 +- docs/APP_VOLUME_LIFECYCLE.md | 33 ++ .../hyperrefine/hook/misound/AppVolumeKeys.kt | 3 + .../hook/misound/MiSoundAppVolumeHook.kt | 69 +-- .../hook/systemui/AppVolumeEntryHook.kt | 37 +- .../hook/systemui/AppVolumePanel.kt | 396 ++++++++++-------- .../hook/systemui/OfficialVolumeColumn.kt | 13 +- .../hook/volume/AppVolumeDelivery.kt | 17 + .../hook/volume/LatestValueDispatcher.kt | 44 ++ .../hyperrefine/hook/volume/OwnedResources.kt | 27 ++ .../hook/volume/AppVolumeDeliveryTest.kt | 60 +++ .../hook/volume/LatestValueDispatcherTest.kt | 89 ++++ .../hook/volume/OwnedResourcesTest.kt | 58 +++ 19 files changed, 738 insertions(+), 237 deletions(-) create mode 100644 app/src/main/java/cn/ianzb/hyperrefine/xposed/RootProbe.kt create mode 100644 app/src/test/java/cn/ianzb/hyperrefine/xposed/RootProbeTest.kt create mode 100644 docs/APP_VOLUME_LIFECYCLE.md create mode 100644 hook/src/main/java/cn/ianzb/hyperrefine/hook/volume/AppVolumeDelivery.kt create mode 100644 hook/src/main/java/cn/ianzb/hyperrefine/hook/volume/LatestValueDispatcher.kt create mode 100644 hook/src/main/java/cn/ianzb/hyperrefine/hook/volume/OwnedResources.kt create mode 100644 hook/src/test/java/cn/ianzb/hyperrefine/hook/volume/AppVolumeDeliveryTest.kt create mode 100644 hook/src/test/java/cn/ianzb/hyperrefine/hook/volume/LatestValueDispatcherTest.kt create mode 100644 hook/src/test/java/cn/ianzb/hyperrefine/hook/volume/OwnedResourcesTest.kt diff --git a/app/src/main/java/cn/ianzb/hyperrefine/MainActivity.kt b/app/src/main/java/cn/ianzb/hyperrefine/MainActivity.kt index bb16c03..6356bd6 100644 --- a/app/src/main/java/cn/ianzb/hyperrefine/MainActivity.kt +++ b/app/src/main/java/cn/ianzb/hyperrefine/MainActivity.kt @@ -97,6 +97,7 @@ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + XposedServiceManager.checkRootOnUiLaunch() enableEdgeToEdge() window.isNavigationBarContrastEnforced = false diff --git a/app/src/main/java/cn/ianzb/hyperrefine/xposed/RootHelper.kt b/app/src/main/java/cn/ianzb/hyperrefine/xposed/RootHelper.kt index dacb99d..706eab0 100644 --- a/app/src/main/java/cn/ianzb/hyperrefine/xposed/RootHelper.kt +++ b/app/src/main/java/cn/ianzb/hyperrefine/xposed/RootHelper.kt @@ -1,20 +1,15 @@ package cn.ianzb.hyperrefine.xposed -import java.io.BufferedReader - /** * 简易 root 执行工具,用于直接删除各目标应用的 DexKit 缓存目录。 */ object RootHelper { - fun isRootAvailable(): Boolean = runCatching { - val process = ProcessBuilder("su", "-c", "id") + fun isRootAvailable(): Boolean = RootProbe.check(start = { + ProcessBuilder("su", "-c", "id") .redirectErrorStream(true) .start() - val output = process.inputStream.bufferedReader().use { it.readText() } - process.waitFor() - output.contains("uid=0") - }.getOrDefault(false) + }) fun exec(command: String): Boolean = runCatching { val process = ProcessBuilder("su", "-c", command) diff --git a/app/src/main/java/cn/ianzb/hyperrefine/xposed/RootProbe.kt b/app/src/main/java/cn/ianzb/hyperrefine/xposed/RootProbe.kt new file mode 100644 index 0000000..3212063 --- /dev/null +++ b/app/src/main/java/cn/ianzb/hyperrefine/xposed/RootProbe.kt @@ -0,0 +1,30 @@ +package cn.ianzb.hyperrefine.xposed + +import java.util.concurrent.TimeUnit + +/** A permission prompt cannot keep a background root probe waiting indefinitely. */ +internal object RootProbe { + private const val TIMEOUT_MS = 30_000L + + fun check(start: () -> Process, timeoutMs: Long = TIMEOUT_MS): Boolean { + var process: Process? = null + return try { + val current = start().also { process = it } + if (!current.waitFor(timeoutMs, TimeUnit.MILLISECONDS)) return false + val output = current.inputStream.bufferedReader().use { it.readText() } + current.exitValue() == 0 && output.contains("uid=0") + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + false + } catch (_: Exception) { + false + } finally { + process?.let { current -> + runCatching { if (current.isAlive) current.destroyForcibly() } + runCatching { current.inputStream.close() } + runCatching { current.errorStream.close() } + runCatching { current.outputStream.close() } + } + } + } +} diff --git a/app/src/main/java/cn/ianzb/hyperrefine/xposed/XposedServiceManager.kt b/app/src/main/java/cn/ianzb/hyperrefine/xposed/XposedServiceManager.kt index 1403d9b..d444e83 100644 --- a/app/src/main/java/cn/ianzb/hyperrefine/xposed/XposedServiceManager.kt +++ b/app/src/main/java/cn/ianzb/hyperrefine/xposed/XposedServiceManager.kt @@ -51,7 +51,11 @@ object XposedServiceManager { fun init() { XposedServiceHelper.registerListener(listener) - checkRoot() + } + + /** Receiver cold starts bind services only; check Root once when the user actually opens the UI. */ + fun checkRootOnUiLaunch() { + if (!rootChecked) checkRoot() } /** @@ -59,7 +63,7 @@ object XposedServiceManager { * * 检测会真正拉起一次 `su`,在部分 Root 管理器上会弹出授权 / 使用提示, * 因此只在两个时机调用: - * 1. 应用启动时([init]); + * 1. 首次进入应用界面([checkRootOnUiLaunch]); * 2. 用户在主页手动点按状态卡片。 * 其余场景(回到前台、定时轮询等)一律复用上一次结果。 */ diff --git a/app/src/test/java/cn/ianzb/hyperrefine/xposed/RootProbeTest.kt b/app/src/test/java/cn/ianzb/hyperrefine/xposed/RootProbeTest.kt new file mode 100644 index 0000000..176f645 --- /dev/null +++ b/app/src/test/java/cn/ianzb/hyperrefine/xposed/RootProbeTest.kt @@ -0,0 +1,66 @@ +package cn.ianzb.hyperrefine.xposed + +import org.junit.Assert.* +import org.junit.Test +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.InputStream +import java.io.OutputStream +import java.util.concurrent.TimeUnit + +class RootProbeTest { + private class ProbeProcess(val text: String, var completed: Boolean = true, val exit: Int = 0) : Process() { + var destroyed = false + var inputClosed = false + var outputClosed = false + var timeoutMs = 0L + val input = object : ByteArrayInputStream(text.toByteArray()) { + override fun close() { inputClosed = true; super.close() } + } + val output = object : ByteArrayOutputStream() { + override fun close() { outputClosed = true; super.close() } + } + override fun getInputStream(): InputStream = input + override fun getErrorStream(): InputStream = ByteArrayInputStream(byteArrayOf()) + override fun getOutputStream(): OutputStream = output + override fun waitFor(): Int = exit + override fun waitFor(timeout: Long, unit: TimeUnit): Boolean { + timeoutMs = unit.toMillis(timeout) + return completed + } + override fun exitValue(): Int = if (completed) exit else throw IllegalThreadStateException() + override fun isAlive(): Boolean = !completed + override fun destroy() { destroyed = true; completed = true } + override fun destroyForcibly(): Process { destroy(); return this } + } + + @Test fun grantedRootRequiresSuccessfulUidZeroOutputAndClosesStreams() { + val process = ProbeProcess("uid=0(root) gid=0(root)\n") + assertTrue(RootProbe.check({ process }, 250)) + assertEquals(250, process.timeoutMs) + assertTrue(process.inputClosed) + assertTrue(process.outputClosed) + assertFalse(process.destroyed) + } + + @Test fun denialAndNonRootOutputRemainUnavailable() { + assertFalse(RootProbe.check({ ProbeProcess("permission denied", exit = 1) })) + assertFalse(RootProbe.check({ ProbeProcess("uid=2000(shell) gid=2000(shell)") })) + assertFalse(RootProbe.check({ ProbeProcess("uid=0(root)", exit = 1) })) + } + + @Test fun permissionTimeoutDestroysTheProbeAndClosesItsStreams() { + val process = ProbeProcess("", completed = false) + assertFalse(RootProbe.check({ process }, 10)) + assertEquals(10, process.timeoutMs) + assertTrue(process.destroyed) + assertTrue(process.inputClosed) + assertTrue(process.outputClosed) + } + + @Test fun rootResultIsNotPersistedAcrossSeparateProbes() { + assertTrue(RootProbe.check({ ProbeProcess("uid=0(root)") })) + assertFalse(RootProbe.check({ ProbeProcess("root revoked", exit = 1) })) + assertFalse(RootProbe.check({ throw java.io.IOException("su missing") })) + } +} diff --git a/changelog.md b/changelog.md index 8cb4793..d3a720b 100644 --- a/changelog.md +++ b/changelog.md @@ -14,6 +14,10 @@ ### 修复 +- **多应用音量 · 视图生命周期**:入口状态由视图自身持有,百分比视图缓存和后台回调使用弱引用;面板正常关闭、动画取消、窗口脱离和替换时统一释放官方音量列,避免保留旧窗口;关闭后过期的播放探测不会重新弹出面板。 +- **多应用音量 · 高频写入**:每个完整 UID 合并为最新待处理音量,滑动同一百分比不重复提交。系统界面直接写入成功后音质音效只同步原生镜像,失败时保留转发写入兜底,继续兼容旧广播、共享 UID 和同 UID 多播放器。 +- **Root 检测 · 后台状态上报**:状态广播冷启动不再附带 Root 检测;首次打开界面与手动点按仍可检测,权限等待超时后可重试,授权结果只保留在本进程。 + - **壁纸 · 模糊背景未跟随深色压暗**(目标 `com.android.systemui` / `com.miui.personalassistant`):控制中心、负一屏取壁纸做模糊的背景此前停在压暗前的亮度(压暗改的是壁纸图层的 alpha,模糊读的是壁纸纹理);现在给这类模糊容器补上同浓度的压暗层,背后是应用而不是壁纸时自动不加。重启系统界面生效。 - **安全模式 · 快速重启被误判为崩溃循环**:系统界面等进程短时间内被重启两次就会被计为崩溃循环、自动跳过全部 Hook(表现为多应用音量入口消失、系统界面侧功能全部无效,真机日志已确认该链路)。现在阈值提高到连续 3 次,应用内主动发起的重启后 30 秒内的启动与子进程上报均不计入;误触发后可在「设置 → 安全模式」一键恢复。 diff --git a/docs/API.md b/docs/API.md index a7a050a..bb99761 100644 --- a/docs/API.md +++ b/docs/API.md @@ -554,7 +554,8 @@ object XposedServiceManager { var rootChecked: Boolean // 是否已完成 Root 检测 var scope: List // Compose 可观察 - fun init() // 在 Application.onCreate 调用(含 Root 检测) + fun init() // 在 Application.onCreate 调用,仅绑定服务 + fun checkRootOnUiLaunch() // 首次进入 MainActivity 时检测,重建页面不重复检测 fun checkRoot() // 异步检测 Root 权限(会真正拉起一次 su) fun getService(): XposedService? fun refreshScope() @@ -565,8 +566,10 @@ object XposedServiceManager { ``` **Root 检测时机**:`checkRoot()` 会真正拉起一次 `su`,在部分 Root 管理器上会弹出授权 / 使用 -提示,因此只在两个时机调用——应用启动(`init()`)与用户在主页手动点按状态卡片;不要做定时轮询 -或每次回到前台都检测,其余场景复用上一次结果(`isRootAvailable` / `rootChecked`)。 +提示,因此只在两个时机调用——首次进入应用界面(`checkRootOnUiLaunch()`)与用户在主页手动点按状态卡片; +后台状态广播冷启动仅绑定服务,不检测 Root。不要做定时轮询或每次回到前台都检测,其余场景复用本进程 +的上一次结果(`isRootAvailable` / `rootChecked`),不持久化授权状态。检测等待授权最长 30 秒,超时按不可用处理, +用户可点按状态卡片重新检测。多应用音量资源与写入契约见 [生命周期与音量写入说明](APP_VOLUME_LIFECYCLE.md)。 ### 4.2 `RootHelper` diff --git a/docs/APP_VOLUME_LIFECYCLE.md b/docs/APP_VOLUME_LIFECYCLE.md new file mode 100644 index 0000000..5a247d8 --- /dev/null +++ b/docs/APP_VOLUME_LIFECYCLE.md @@ -0,0 +1,33 @@ +# 多应用音量生命周期与写入契约 + +本次修复覆盖音量入口的对象所有权、系统界面面板的资源释放、滑动期间的音量任务积压,以及后台 Hook 状态广播附带的 Root 检测。共享 UID 播放身份解析继续使用 PR #36 的 `AppVolumePolicy` 与 `AppVolumePlayback`:完整 UID 用于关联播放器,真实包名用于图标,原生 volumeKey 用于读写音量镜像。 + +## 视图与面板资源 + +入口的 RingerButtonHelper 放在入口 View 自身的 tag 状态中,原生按钮的展开状态回调按该入口查找 helper。模块不再通过全局表强引用入口与 parent;控制器引用和异步更新使用弱引用。MiSound 的百分比文本仍是宿主视图树的子节点,模块索引仅保留弱引用,轮询回调不强引用卡片和控制器。 + +每次面板显示使用独立的官方音量列资源组。普通淡出、避让淡出、动画取消、窗口 detach、显示替换均可释放该组,重复回调只释放一次。旧面板的延迟结束不能释放新面板的列。播放探测返回时还检查显示代次和窗口状态,关闭后的过期结果不能重新弹出面板。 + +普通开关与关闭仍使用原有动画时长和布局计算,高度自动、百分比样式与原生音量列保持同一调用链。 + +## 音量写入 + +系统界面、MiSound 各有一个后台消费者。待处理值按完整 UID 合并,同 UID 的连续拖动最多保留一个最新待处理值;正在执行的 IPC 结束后继续处理最新值和其他 UID,不取消松手的最终值。旧的仅包名广播在 MiSound 按包名暂存,随后仍通过包名归属解析完整 UID。 + +系统界面首先调用 `AppVolumePlayback.applyVolume`。有播放器成功写入时,广播增加 `EXTRA_ALREADY_APPLIED=true`,MiSound 校验包名与 UID 后只调用官方镜像接口,不重复调用音频服务。没有成功写入或直接接口抛异常时,标记为 false,MiSound 保留原来的真实写入兜底。没有该标记的旧广播等同 false。 + +因此两个目标进程应加载同一模块构建,才能消除双路重复写入。旧 MiSound 进程不识别新标记时仍可调节音量,但会暂时继续重复写入;不以兼容性为代价强制杀进程或整机重启。 + +原有 `STARTED`、`USAGE_MEDIA` / `STREAM_MUSIC` 筛选、同 UID 全部活跃播放器写入、暂停 / 停止过滤、多用户完整 UID、共享 UID 图标及 volumeKey 兼容不变。音频与包管理 IPC 继续在后台执行。 + +## Root 检测 + +Application 冷启动仍初始化配置、状态接收与 LSPosed 服务绑定。Root 检测由首次进入 MainActivity 触发;用户手动点按状态卡片仍可重新检测。页面重建和回到前台不轮询,不把 Root 授权结果写入持久化配置。临时 KernelSU 权限消失后,可手动重新检测。 + +授权等待上限为 30 秒;拒绝、超时、su 不可用时显示不可用,并清理检测子进程。Hook 状态广播和安全模式启动计数保留原有行为。 + +## 验证范围 + +自动回归覆盖共享 UID 与同 UID 多播放器、慢写入时的最终值、不同 UID 的独立更新、旧广播与直接写入失败兜底、取消 / detach 的重复清理、旧资源组和新资源组隔离、Root 拒绝与超时。构建结果以本次交付记录为准,自动测试不能代替真实 ROM 的反射接口和交互验证。 + +真机验收可由用户手动完成:Apple Music、Bilibili、QQ 音乐、小米音乐分别调节;同 UID 多播放器;入口复用、连续开关、音量条超时收回、窗口重建;高度自动与百分比;打开模块后的 Root 状态与手动重检。所有设备命令、安装和目标进程重新加载遵守 [模块开发工作流](WORKFLOW.md) 的授权边界,临时 Root 下不默认整机重启。 diff --git a/hook/src/main/java/cn/ianzb/hyperrefine/hook/misound/AppVolumeKeys.kt b/hook/src/main/java/cn/ianzb/hyperrefine/hook/misound/AppVolumeKeys.kt index 7faccd2..491be42 100644 --- a/hook/src/main/java/cn/ianzb/hyperrefine/hook/misound/AppVolumeKeys.kt +++ b/hook/src/main/java/cn/ianzb/hyperrefine/hook/misound/AppVolumeKeys.kt @@ -53,6 +53,9 @@ object AppVolumeKeys { /** [ACTION_SET_VOLUME]:目标音量(0f..1f)。 */ const val EXTRA_VOLUME = "hyperrefine_app_volume_value" + /** New senders set this only after a successful direct write; old broadcasts still write audio. */ + const val EXTRA_ALREADY_APPLIED = "hyperrefine_app_volume_already_applied" + /** MiSound 多应用音量控制器(混淆类)。 */ const val CONTROLLER_CLASS = "com.miui.misound.playervolume.a" diff --git a/hook/src/main/java/cn/ianzb/hyperrefine/hook/misound/MiSoundAppVolumeHook.kt b/hook/src/main/java/cn/ianzb/hyperrefine/hook/misound/MiSoundAppVolumeHook.kt index 710f0a4..6d649ae 100644 --- a/hook/src/main/java/cn/ianzb/hyperrefine/hook/misound/MiSoundAppVolumeHook.kt +++ b/hook/src/main/java/cn/ianzb/hyperrefine/hook/misound/MiSoundAppVolumeHook.kt @@ -22,6 +22,8 @@ import cn.ianzb.hyperrefine.hook.base.BaseHook import cn.ianzb.hyperrefine.hook.prefs.HookPrefs import cn.ianzb.hyperrefine.hook.status.HookStatusReporter import cn.ianzb.hyperrefine.hook.systemui.AppVolumePlayback +import cn.ianzb.hyperrefine.hook.volume.AppVolumeDelivery +import cn.ianzb.hyperrefine.hook.volume.LatestValueDispatcher import cn.ianzb.hyperrefine.hook.systemui.radius.CcRadiusKeys import cn.ianzb.hyperrefine.hook.xposed.HookHelper import cn.ianzb.hyperrefine.hook.xposed.Reflect @@ -48,7 +50,10 @@ class MiSoundAppVolumeHook : BaseHook() { /** 音量写入后台执行器:音频 / 包管理 IPC 在通话等场景可能阻塞数秒,不能占用主线程。 */ private val applyExecutor = java.util.concurrent.Executors.newSingleThreadExecutor { task -> - Thread(task, "HyperRefine-AppVolumeApply") + Thread(task, "HyperRefine-AppVolumeApply").apply { isDaemon = true } + } + private val volumeUpdates = LatestValueDispatcher Unit>(applyExecutor, { it() }) { + HookHelper.log("$tag: forwarded volume update failed", it) } /** 本次打开是否已播放进入动画。 */ @@ -821,14 +826,14 @@ class MiSoundAppVolumeHook : BaseHook() { // ---------------- 模块百分比功能(外观统一,跟随开关) ---------------- - private val percentTexts = java.util.WeakHashMap() + private val percentTexts = java.util.WeakHashMap>() private val percentPolls = java.util.WeakHashMap() private val lastProgress = java.util.WeakHashMap() /** 为每个音量条添加(或复用)一个百分比文本视图,样式对齐侧边音量条。 */ private fun ensurePercentText(slider: View) { val parent = slider.parent as? ViewGroup ?: return - val existing = percentTexts[slider] + val existing = percentTexts[slider]?.get() if (existing != null && existing.parent === parent) return val tv = android.widget.TextView(slider.context).apply { setTextColor(0xFFFFFFFF.toInt()) @@ -844,13 +849,13 @@ class MiSoundAppVolumeHook : BaseHook() { topMargin = (PERCENT_TOP_MARGIN_DP * slider.resources.displayMetrics.density).toInt() } runCatching { parent.addView(tv, lp) } - percentTexts[slider] = tv + percentTexts[slider] = WeakReference(tv) } /** 单个音量条的百分比文本:跟随模块「侧边音量条百分比」开关与样式。 */ private fun renderPercent(slider: View) { val seek = slider as? android.widget.SeekBar ?: return - val tv = percentTexts[slider] ?: return + val tv = percentTexts[slider]?.get() ?: return val enabled = HookPrefs.getBoolean(PERCENT_MASTER_KEY, false) if (!enabled) { if (tv.visibility != View.GONE) tv.visibility = View.GONE @@ -979,9 +984,13 @@ class MiSoundAppVolumeHook : BaseHook() { private fun startPercentPoll(card: View, controller: Any) { if (percentPolls.containsKey(card)) return openStartedAt[card] = SystemClock.uptimeMillis() + val cardRef = WeakReference(card) + val controllerRef = WeakReference(controller) val runnable = object : Runnable { override fun run() { - if (!card.isAttachedToWindow) { + val card = cardRef.get() ?: return + val controller = controllerRef.get() + if (!card.isAttachedToWindow || controller == null) { percentPolls.remove(card) openStartedAt.remove(card) pendingContentRefresh.remove(card) @@ -1276,7 +1285,8 @@ class MiSoundAppVolumeHook : BaseHook() { val uid = intent.getIntExtra(AppVolumeKeys.EXTRA_UID, -1) val volume = intent.getFloatExtra(AppVolumeKeys.EXTRA_VOLUME, -1f) if (volume.isFinite() && volume >= 0f) { - applyForwardedVolume(c?.applicationContext ?: context, pkg, uid, volume) + val alreadyApplied = intent.getBooleanExtra(AppVolumeKeys.EXTRA_ALREADY_APPLIED, false) + applyForwardedVolume(c?.applicationContext ?: context, pkg, uid, volume, alreadyApplied) } } } @@ -1297,34 +1307,31 @@ class MiSoundAppVolumeHook : BaseHook() { * 「系统界面模式」转发落地:用官方同款机制(`AudioManager.setPlayerVolume`)生效, * 并把数值写回官方面板镜像(控制器 `w`),使「音质音效」面板数值与系统界面同步。 */ - private fun applyForwardedVolume(context: Context, pkg: String, uid: Int, volume: Float) { + private fun applyForwardedVolume(context: Context, pkg: String, uid: Int, volume: Float, alreadyApplied: Boolean) { // 广播在 Misound 主线程派发;音频 / 包管理 IPC 在通话等场景可能阻塞数秒, // 探测与写入放到后台线程,仅镜像刷新(View 操作)回主线程执行。 val appContext = context.applicationContext ?: context - runCatching { - applyExecutor.execute { - val app = AppVolumePlayback.targetForPackage(appContext, pkg, uid) ?: run { - HookHelper.log("$tag: invalid volume target $pkg uid=$uid") - return@execute - } - val v = volume.coerceIn(0f, 1f) - val applied = AppVolumePlayback.applyVolume(appContext, app, v) - if (applied == 0) { - HookHelper.log("$tag: no active player updated for $pkg uid=${app.uid}") - return@execute - } - HookHelper.log("$tag: forwarded setPlayerVolume $pkg uid=${app.uid} players=$applied -> $v") - // 写回镜像 + 刷新面板(若控制器可达)。 - android.os.Handler(android.os.Looper.getMainLooper()).post { - val controller = controllerRef?.get() ?: resolveController(appContext) - if (controller != null) { - runCatching { Reflect.callMethod(controller, "w", app.volumeKey, v) } - .onFailure { HookHelper.log("$tag: mirror volume failed", it) } - runCatching { Reflect.callMethod(controller, "E") } - } - } + val identity = if (uid >= 10_000) "uid:$uid" else "package:$pkg" + volumeUpdates.submit(identity) { + val app = AppVolumePlayback.targetForPackage(appContext, pkg, uid) ?: run { + HookHelper.log("$tag: invalid volume target $pkg uid=$uid") + return@submit } - }.onFailure { HookHelper.log("$tag: forward apply enqueue failed", it) } + // 写回镜像 + 刷新面板(若控制器可达)。 + AppVolumeDelivery.receive(volume, alreadyApplied, + write = { AppVolumePlayback.applyVolume(appContext, app, it) }, + mirror = { v -> + android.os.Handler(android.os.Looper.getMainLooper()).post { + val controller = controllerRef?.get() ?: resolveController(appContext) + if (controller != null) { + runCatching { Reflect.callMethod(controller, "w", app.volumeKey, v) } + .onFailure { HookHelper.log("$tag: mirror volume failed", it) } + runCatching { Reflect.callMethod(controller, "E") } + } + } + }, + ) + } } companion object { diff --git a/hook/src/main/java/cn/ianzb/hyperrefine/hook/systemui/AppVolumeEntryHook.kt b/hook/src/main/java/cn/ianzb/hyperrefine/hook/systemui/AppVolumeEntryHook.kt index c958bb7..8c9f525 100644 --- a/hook/src/main/java/cn/ianzb/hyperrefine/hook/systemui/AppVolumeEntryHook.kt +++ b/hook/src/main/java/cn/ianzb/hyperrefine/hook/systemui/AppVolumeEntryHook.kt @@ -15,6 +15,7 @@ import cn.ianzb.hyperrefine.hook.prefs.HookPrefs import cn.ianzb.hyperrefine.hook.status.HookStatusReporter import cn.ianzb.hyperrefine.hook.xposed.HookHelper import cn.ianzb.hyperrefine.hook.xposed.Reflect +import java.lang.ref.WeakReference import java.util.WeakHashMap /** @@ -25,17 +26,14 @@ import java.util.WeakHashMap * 再把它接进 `VolumeShowHideAnimator` 的 `mRingerBtnLayouts`,使其拥有与原生按钮完全一致 * 的外观与「自上而下递增延迟」的出现动画。 * - * 点击后拉起 MiSound 的 `VolumeUIService` 打开原生多应用音量面板。 + * 点击后在系统界面显示原生音量列面板,音量镜像与 MiSound 同步。 */ class AppVolumeEntryHook : BaseHook() { override val key: String = AppVolumeKeys.ENTRY - /** 每个 ringer 父容器对应的入口视图。 */ - private val entries = WeakHashMap() - - /** 每个官方 `MiuiRingerModeLayout` 对应的入口 RingerButtonHelper。 */ - private val helpers = WeakHashMap() + /** The entry owns its helper; the global lookup must not keep an old view tree alive. */ + private class EntryState(val helper: Any) /** 入口最近一次的显示状态,避免重复播放透明度动画。 */ private val lastVisible = WeakHashMap() @@ -68,7 +66,7 @@ class AppVolumeEntryHook : BaseHook() { /** 最近的音量面板控制器,供注入后立即刷新显隐。 */ @Volatile - private var lastController: Any? = null + private var lastController: WeakReference? = null override fun init() { // 本 hook 同时实现「入口常显」,据实上报其生效状态。 @@ -170,14 +168,13 @@ class AppVolumeEntryHook : BaseHook() { inflated.isClickable = true inflated.isFocusable = true parent.addView(inflated, insertAt.coerceIn(0, parent.childCount)) - entries[parent] = inflated setupEntryBehavior(inflated) HookHelper.log("$tag: entry injected into ${parent.javaClass.simpleName}") attachDividerSync(parent) inflated } joinAnimator(animator, entry) - lastController?.let { updateVisibility(it, entry) } + lastController?.get()?.let { updateVisibility(it, entry) } } /** 仅当入口尚未加入动画数组时,把入口与对应的 X 记录一起追加。 */ @@ -204,7 +201,7 @@ class AppVolumeEntryHook : BaseHook() { ?: return@runCatching ctor.isAccessible = true val helper = ctor.newInstance(outer, entry, false, false) - helpers[outer] = helper + entry.tag = EntryState(helper) helperUpdateState(helper) }.onFailure { HookHelper.log("$tag: create RingerButtonHelper failed", it) } @@ -254,8 +251,8 @@ class AppVolumeEntryHook : BaseHook() { method.isAccessible = true runCatching { HookHelper.hookAfter(method) { param -> - val outer = param.thisObject ?: return@hookAfter - val helper = helpers[outer] ?: return@hookAfter + val outer = param.thisObject as? View ?: return@hookAfter + val helper = (findEntry(outer)?.tag as? EntryState)?.helper ?: return@hookAfter runCatching { when (method.name) { "updateExpandedH" -> helperOnExpanded( @@ -392,7 +389,7 @@ class AppVolumeEntryHook : BaseHook() { runCatching { HookHelper.hookAfter(method) { param -> param.thisObject?.let { - lastController = it + lastController = WeakReference(it) onController(it, method.name) } } @@ -437,7 +434,7 @@ class AppVolumeEntryHook : BaseHook() { } private fun findEntry(root: View): View? { - if (root.tag == TAG_ENTRY) return root + if (isEntry(root)) return root if (root is ViewGroup) { for (i in 0 until root.childCount) { findEntry(root.getChildAt(i))?.let { return it } @@ -531,7 +528,7 @@ class AppVolumeEntryHook : BaseHook() { private fun findDndRoot(root: View): View? { var lastParent: View? = null fun visit(v: View) { - if (v.tag == TAG_ENTRY) return + if (isEntry(v)) return if (v.javaClass.name.contains("VolumeBlurFrameLayout")) { lastParent = v.parent as? View return @@ -679,7 +676,7 @@ class AppVolumeEntryHook : BaseHook() { // 清掉上一次淡出挂的结束动作,避免复用时在本次动画结束时误收起入口。 entry.animate().alpha(1f).withEndAction(null).setDuration(FADE_DURATION_MS).start() shiftPanel(entry, true) - if (!autoRefreshing) lastController?.let { maybeAutoShow(it, entry) } + if (!autoRefreshing) lastController?.get()?.let { maybeAutoShow(it, entry) } } private fun updateVisibility(controller: Any, entry: View) { @@ -690,7 +687,11 @@ class AppVolumeEntryHook : BaseHook() { AppVolumePlayback.snapshot().hasActive) applyEntryState(entry, visible, dodge = expanded) // 快照过期时请一次后台刷新;结果回来(播放状态可能已变化)再重算一次入口状态。 - AppVolumePlayback.refresh(entry.context) { lastController?.let { updateVisibility(it, entry) } } + val entryRef = WeakReference(entry) + AppVolumePlayback.refresh(entry.context) { + val currentEntry = entryRef.get() ?: return@refresh + lastController?.get()?.let { updateVisibility(it, currentEntry) } + } } /** @@ -745,6 +746,8 @@ class AppVolumeEntryHook : BaseHook() { // ---------------- 资源 ---------------- + private fun isEntry(view: View): Boolean = view.tag == TAG_ENTRY || view.tag is EntryState + private fun loadModuleIcon(context: Context): android.graphics.drawable.Drawable? = runCatching { val module = context.createPackageContext( AppVolumeKeys.MODULE_PACKAGE, diff --git a/hook/src/main/java/cn/ianzb/hyperrefine/hook/systemui/AppVolumePanel.kt b/hook/src/main/java/cn/ianzb/hyperrefine/hook/systemui/AppVolumePanel.kt index 5f2c51e..36193de 100644 --- a/hook/src/main/java/cn/ianzb/hyperrefine/hook/systemui/AppVolumePanel.kt +++ b/hook/src/main/java/cn/ianzb/hyperrefine/hook/systemui/AppVolumePanel.kt @@ -1,5 +1,7 @@ package cn.ianzb.hyperrefine.hook.systemui +import android.animation.Animator +import android.animation.AnimatorListenerAdapter import android.content.Context import android.content.Intent import android.graphics.Color @@ -25,9 +27,13 @@ import cn.ianzb.hyperrefine.hook.systemui.glass.CcGlassKeys import cn.ianzb.hyperrefine.hook.systemui.glass.OfficialExpandedMaterial import cn.ianzb.hyperrefine.hook.systemui.radius.CcRadiusKeys import cn.ianzb.hyperrefine.hook.volume.AppVolumeTarget +import cn.ianzb.hyperrefine.hook.volume.AppVolumeDelivery +import cn.ianzb.hyperrefine.hook.volume.LatestValueDispatcher +import cn.ianzb.hyperrefine.hook.volume.OwnedResources import cn.ianzb.hyperrefine.hook.xposed.HookHelper import cn.ianzb.hyperrefine.hook.xposed.Reflect import java.lang.reflect.Proxy +import java.lang.ref.WeakReference /** * 「系统界面模式」的多应用音量面板 —— 原生竖向列样式。 @@ -59,6 +65,9 @@ object AppVolumePanel { private val applyExecutor = java.util.concurrent.Executors.newSingleThreadExecutor { task -> Thread(task, "HyperRefine-AppVolumeApply").apply { isDaemon = true } } + private val volumeUpdates = LatestValueDispatcher Unit>(applyExecutor, { it() }) { + HookHelper.log("$TAG: volume apply failed", it) + } private const val OPEN_DURATION_MS = 260L private const val CLOSE_DURATION_MS = 250L @@ -72,14 +81,20 @@ object AppVolumePanel { private var preDrawContainer: View? = null private var preDrawListener: ViewTreeObserver.OnPreDrawListener? = null private var windowAttrsOriginal: Pair? = null - private val officialColumns = mutableListOf() + private var officialColumns = newColumnResources() + private var showGeneration = 0L + + private fun newColumnResources() = OwnedResources( + release = { it.release() }, + onFailure = { HookHelper.log("$TAG: column disposal failed", it) }, + ) /** 面板是否正在显示。 */ fun isShowing(): Boolean = host != null /** 记录其它模块对「侧边一级音量列」子视图的框架玻璃调用(按子视图路径),供面板列创建时回放。 */ fun recordSideGlass(sourceView: View, methodName: String, args: Array) { - if (officialColumns.any { isDescendant(sourceView, it.view) }) return + if (officialColumns.values.any { isDescendant(sourceView, it.view) }) return val root = columnRoot(sourceView) ?: return val path = indexPath(sourceView, root) ?: return SideGlassStore.record(path, methodName, args) @@ -132,10 +147,10 @@ object AppVolumePanel { fun hideByExpand() = close(dodge = true) private fun close(dodge: Boolean) { + showGeneration++ // A late playback probe must not reopen a dismissed volume window. val currentHost = host ?: return - // 先摘出本面板的列:动画结束后再释放,期间保留音量条一起淡出。 - val toRelease = ArrayList(officialColumns) - officialColumns.clear() + if (closing) return + val columns = officialColumns val currentCard = card if (currentCard != null && currentCard.isAttachedToWindow) { closing = true @@ -145,13 +160,17 @@ object AppVolumePanel { if (dodge) { anim.translationX(currentCard.translationX + slideOutOffset(currentCard, dialogRef)) } - anim.withEndAction { - toRelease.forEach { it.release() } - cleanup(currentHost) - }.start() + runCatching { + anim.withEndAction(null).setListener(object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) = cleanup(currentHost, columns) + override fun onAnimationCancel(animation: Animator) = cleanup(currentHost, columns) + }).start() + }.onFailure { + cleanup(currentHost, columns) + HookHelper.log("$TAG: close animation failed", it) + } } else { - toRelease.forEach { it.release() } - cleanup(currentHost) + cleanup(currentHost, columns) } } @@ -170,11 +189,16 @@ object AppVolumePanel { return (if (toLeft) -1f else 1f) * panel.width * 0.25f } - private fun cleanup(currentHost: View) { - if (host !== currentHost) return + private fun cleanup(currentHost: View, columns: OwnedResources) { + if (host !== currentHost) { + columns.dispose() + return + } + showGeneration++ host = null card = null closing = false + columns.dispose() preDrawListener?.let { listener -> runCatching { preDrawContainer?.viewTreeObserver?.takeIf { it.isAlive }?.removeOnPreDrawListener(listener) @@ -202,166 +226,187 @@ object AppVolumePanel { anchor: View, attempt: Int = 0, ) { - hide() + // Replacement is synchronous: old animation callbacks cannot take ownership of a new panel. + host?.let { old -> + card?.animate()?.withEndAction(null)?.setListener(null)?.cancel() + cleanup(old, officialColumns) + } + val requestGeneration = ++showGeneration + officialColumns = newColumnResources() + val columns = officialColumns closing = false - val density = context.resources.displayMetrics.density - // 正在播放的应用:与系统官方(音质音效 `playervolume.f.a`)逐条对齐的判定(共享 UID 感知), - // 读后台探测快照;快照里没有应用时先请一次后台探测(主线程不等待音频 IPC, - // 通话等场景该 IPC 会阻塞数秒)。 - val apps = AppVolumePlayback.snapshot().targets - if (apps.isEmpty()) { - if (attempt == 0) { - AppVolumePlayback.refresh(context, force = true) { - if (!isShowing()) show(root, dialog, context, pluginClassLoader, anchor, attempt = 1) + try { + val density = context.resources.displayMetrics.density + // 正在播放的应用:与系统官方(音质音效 `playervolume.f.a`)逐条对齐的判定(共享 UID 感知), + // 读后台探测快照;快照里没有应用时先请一次后台探测(主线程不等待音频 IPC, + // 通话等场景该 IPC 会阻塞数秒)。 + val apps = AppVolumePlayback.snapshot().targets + if (apps.isEmpty()) { + if (attempt == 0) { + val rootRef = WeakReference(root) + val dialogViewRef = WeakReference(dialog) + val anchorRef = WeakReference(anchor) + AppVolumePlayback.refresh(context, force = true) { + val currentRoot = rootRef.get() ?: return@refresh + val currentDialog = dialogViewRef.get() ?: return@refresh + val currentAnchor = anchorRef.get() ?: return@refresh + if (requestGeneration == showGeneration && !isShowing() && + currentRoot.isAttachedToWindow && currentDialog.isShown + ) show(currentRoot, currentDialog, currentAnchor.context, pluginClassLoader, currentAnchor, attempt = 1) + } + } else { + HookHelper.log("$TAG: no active app, skip panel") } - } else { - HookHelper.log("$TAG: no active app, skip panel") + return + } + if (!OfficialVolumeColumnFactory.isAvailable(pluginClassLoader)) { + HookHelper.log("$TAG: official VolumeColumn unavailable") + return } - return - } - if (!OfficialVolumeColumnFactory.isAvailable(pluginClassLoader)) { - HookHelper.log("$TAG: official VolumeColumn unavailable") - return - } - - // 全窗口透明容器(不消费触摸),面板放在侧边音量条左侧,二者同时可见。 - val container = FrameLayout(context).apply { - layoutParams = ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT, - ) - clipChildren = false - clipToPadding = false - isClickable = false - } - val panel = FrameLayout(context).apply { - background = null - clipChildren = false - clipToPadding = false - isClickable = true - // 初始透明:避免在展开动画播放前先闪一帧最终形态。 - alpha = 0f - // 面板背景内边距:「多应用面板背景调节」开启后按上下 / 左右分别设置,否则维持现状 16dp。 - val bgCustom = HookPrefs.getBoolean(VolumeBarKeys.PANEL_BG, false) - val padV = (if (bgCustom) { - HookPrefs.getFloat(VolumeBarKeys.PANEL_PAD_VERTICAL, VolumeBarKeys.DEFAULT_PAD) - } else { - VolumeBarKeys.DEFAULT_PAD - }).coerceIn(0f, 120f) - val padH = (if (bgCustom) { - HookPrefs.getFloat(VolumeBarKeys.PANEL_PAD_HORIZONTAL, VolumeBarKeys.DEFAULT_PAD) - } else { - VolumeBarKeys.DEFAULT_PAD - }).coerceIn(0f, 120f) - setPadding( - (padH * density).toInt(), - (padV * density).toInt(), - (padH * density).toInt(), - (padV * density).toInt(), - ) - } - val maxWidth = (context.resources.displayMetrics.widthPixels * 0.92f).toInt() - fun panelLp() = FrameLayout.LayoutParams( - ViewGroup.LayoutParams.WRAP_CONTENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { - gravity = Gravity.END or Gravity.CENTER_VERTICAL - marginEnd = (12 * density).toInt() - topMargin = (MARGIN_DP * density).toInt() - bottomMargin = (MARGIN_DP * density).toInt() - } - // 面板背景单独成层,作为音量条面板的**兄弟视图**放在其后面(与官方二级面板一致), - // 使音量条玻璃取色时不会把面板背景纳入,从而与官方取色一致。 - val bg = FrameLayout(context).apply { - background = null - clipChildren = false - clipToPadding = false - isClickable = false - alpha = 0f - } - container.addView(bg, panelLp()) - panel.background = null - container.addView(panel, panelLp()) + // 全窗口透明容器(不消费触摸),面板放在侧边音量条左侧,二者同时可见。 + val container = FrameLayout(context).apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + clipChildren = false + clipToPadding = false + isClickable = false + } - panel.addView( - buildBars(context, density, apps, pluginClassLoader!!, maxWidth), - FrameLayout.LayoutParams( + val panel = FrameLayout(context).apply { + background = null + clipChildren = false + clipToPadding = false + isClickable = true + // 初始透明:避免在展开动画播放前先闪一帧最终形态。 + alpha = 0f + // 面板背景内边距:「多应用面板背景调节」开启后按上下 / 左右分别设置,否则维持现状 16dp。 + val bgCustom = HookPrefs.getBoolean(VolumeBarKeys.PANEL_BG, false) + val padV = (if (bgCustom) { + HookPrefs.getFloat(VolumeBarKeys.PANEL_PAD_VERTICAL, VolumeBarKeys.DEFAULT_PAD) + } else { + VolumeBarKeys.DEFAULT_PAD + }).coerceIn(0f, 120f) + val padH = (if (bgCustom) { + HookPrefs.getFloat(VolumeBarKeys.PANEL_PAD_HORIZONTAL, VolumeBarKeys.DEFAULT_PAD) + } else { + VolumeBarKeys.DEFAULT_PAD + }).coerceIn(0f, 120f) + setPadding( + (padH * density).toInt(), + (padV * density).toInt(), + (padH * density).toInt(), + (padV * density).toInt(), + ) + } + val maxWidth = (context.resources.displayMetrics.widthPixels * 0.92f).toInt() + fun panelLp() = FrameLayout.LayoutParams( ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT, - ), - ) - - // 竖直位置:「高度自动」时与侧边音量条竖直对齐;否则用「高度」百分比(0% 最低、100% 最高、50% 居中)。 - val heightAuto = HookPrefs.getBoolean(AppVolumeKeys.HEIGHT_AUTO, true) - val heightPercent = HookPrefs.getFloat(AppVolumeKeys.HEIGHT_PERCENT, 50f).coerceIn(0f, 100f) - bg.addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener { - override fun onViewAttachedToWindow(v: View) = applyPanelBackground(v, context, pluginClassLoader) - override fun onViewDetachedFromWindow(v: View) {} - }) - // 背景层尺寸跟随面板。 - panel.addOnLayoutChangeListener { v, _, _, _, _, _, _, _, _ -> - val lp = bg.layoutParams - if (lp != null && (lp.width != v.width || lp.height != v.height)) { - lp.width = v.width - lp.height = v.height - bg.layoutParams = lp + ).apply { + gravity = Gravity.END or Gravity.CENTER_VERTICAL + marginEnd = (12 * density).toInt() + topMargin = (MARGIN_DP * density).toInt() + bottomMargin = (MARGIN_DP * density).toInt() } - } - // 音量条窗口隐藏(侧边音量条收回 / 超时消失)时一并移除面板,避免下次打开残留在最右。 - container.addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener { - override fun onViewAttachedToWindow(v: View) {} - override fun onViewDetachedFromWindow(v: View) { - if (host === v) cleanup(v) + // 面板背景单独成层,作为音量条面板的**兄弟视图**放在其后面(与官方二级面板一致), + // 使音量条玻璃取色时不会把面板背景纳入,从而与官方取色一致。 + val bg = FrameLayout(context).apply { + background = null + clipChildren = false + clipToPadding = false + isClickable = false + alpha = 0f } - }) - - host = container - card = panel - dialogRef = dialog - root.addView(container) - - // 侧边音量条保持显示;仅接管窗口触摸区域,使位于其左侧的面板可正常拖动。 - pauseInsets(dialog) - clearWindowDim(dialog) - - // 每帧跟踪侧边音量条位置(避免收起后重开时位置跳变 / 遮挡主音量条); - // 首帧完成布局后再启动展开动画(面板初始 alpha=0,不会先闪最终形态)。 - var started = false - val listener = object : ViewTreeObserver.OnPreDrawListener { - override fun onPreDraw(): Boolean { - // 淡出期间暂停跟随(否则侧边条一变就瞬移),交给整体平移 + 淡出。 - if (!closing) { - if (heightAuto) { - alignToDialogBar(panel, dialog) - } else { - val available = container.height - panel.height - if (available > 0) panel.translationY = (0.5f - heightPercent / 100f) * available - } - positionLeftOfDialog(container, panel, dialog, density) + container.addView(bg, panelLp()) + panel.background = null + container.addView(panel, panelLp()) + + panel.addView( + buildBars(context, density, apps, pluginClassLoader!!, maxWidth), + FrameLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ), + ) + + // 竖直位置:「高度自动」时与侧边音量条竖直对齐;否则用「高度」百分比(0% 最低、100% 最高、50% 居中)。 + val heightAuto = HookPrefs.getBoolean(AppVolumeKeys.HEIGHT_AUTO, true) + val heightPercent = HookPrefs.getFloat(AppVolumeKeys.HEIGHT_PERCENT, 50f).coerceIn(0f, 100f) + bg.addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener { + override fun onViewAttachedToWindow(v: View) = applyPanelBackground(v, context, pluginClassLoader) + override fun onViewDetachedFromWindow(v: View) {} + }) + // 背景层尺寸跟随面板。 + panel.addOnLayoutChangeListener { v, _, _, _, _, _, _, _, _ -> + val lp = bg.layoutParams + if (lp != null && (lp.width != v.width || lp.height != v.height)) { + lp.width = v.width + lp.height = v.height + bg.layoutParams = lp } - // 背景层跟随面板的位置 / 透明度(兄弟层,不参与音量条取色)。 - bg.translationY = panel.translationY - bg.translationX = panel.translationX - val pLp = panel.layoutParams as? FrameLayout.LayoutParams - val bLp = bg.layoutParams as? FrameLayout.LayoutParams - if (pLp != null && bLp != null && bLp.marginEnd != pLp.marginEnd) { - bLp.marginEnd = pLp.marginEnd - bg.layoutParams = bLp + } + // 音量条窗口隐藏(侧边音量条收回 / 超时消失)时一并移除面板,避免下次打开残留在最右。 + container.addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener { + override fun onViewAttachedToWindow(v: View) {} + override fun onViewDetachedFromWindow(v: View) { + cleanup(v, columns) } - bg.alpha = panel.alpha - // 官方可能在展开 / 材质变化后重新应用窗口压暗,这里每帧兜底清除。 - clearWindowDim(dialog) - if (!started && panel.width > 0) { - started = true - startShowAnimation(panel) + }) + + host = container + card = panel + dialogRef = dialog + root.addView(container) + + // 侧边音量条保持显示;仅接管窗口触摸区域,使位于其左侧的面板可正常拖动。 + pauseInsets(dialog) + clearWindowDim(dialog) + + // 每帧跟踪侧边音量条位置(避免收起后重开时位置跳变 / 遮挡主音量条); + // 首帧完成布局后再启动展开动画(面板初始 alpha=0,不会先闪最终形态)。 + var started = false + val listener = object : ViewTreeObserver.OnPreDrawListener { + override fun onPreDraw(): Boolean { + // 淡出期间暂停跟随(否则侧边条一变就瞬移),交给整体平移 + 淡出。 + if (!closing) { + if (heightAuto) { + alignToDialogBar(panel, dialog) + } else { + val available = container.height - panel.height + if (available > 0) panel.translationY = (0.5f - heightPercent / 100f) * available + } + positionLeftOfDialog(container, panel, dialog, density) + } + // 背景层跟随面板的位置 / 透明度(兄弟层,不参与音量条取色)。 + bg.translationY = panel.translationY + bg.translationX = panel.translationX + val pLp = panel.layoutParams as? FrameLayout.LayoutParams + val bLp = bg.layoutParams as? FrameLayout.LayoutParams + if (pLp != null && bLp != null && bLp.marginEnd != pLp.marginEnd) { + bLp.marginEnd = pLp.marginEnd + bg.layoutParams = bLp + } + bg.alpha = panel.alpha + // 官方可能在展开 / 材质变化后重新应用窗口压暗,这里每帧兜底清除。 + clearWindowDim(dialog) + if (!started && panel.width > 0) { + started = true + startShowAnimation(panel) + } + return true } - return true } + container.viewTreeObserver.addOnPreDrawListener(listener) + preDrawContainer = container + preDrawListener = listener + } catch (failure: Throwable) { + val current = host + if (current != null && officialColumns === columns) cleanup(current, columns) else columns.dispose() + throw failure } - container.viewTreeObserver.addOnPreDrawListener(listener) - preDrawContainer = container - preDrawListener = listener } /** 把面板右边缘贴到侧边音量条左边缘(垂直居中)。 */ @@ -392,7 +437,7 @@ object AppVolumePanel { */ private fun alignToDialogBar(panel: View, dialog: View) { runCatching { - val panelSlider = officialColumns.firstOrNull()?.slider ?: return + val panelSlider = officialColumns.values.firstOrNull()?.slider ?: return val dialogSlider = findSideVolumeSlider(dialog) ?: return if (panelSlider.height <= 0 || dialogSlider.height <= 0) return val root = panel.rootView @@ -786,22 +831,23 @@ object AppVolumePanel { private fun forward(context: Context, app: AppVolumeTarget, volume: Float) { if (!volume.isFinite()) return val v = volume.coerceIn(0f, 1f) - runCatching { - context.sendBroadcast( - Intent(AppVolumeKeys.ACTION_SET_VOLUME) - .setPackage(AppVolumeKeys.TARGET_PACKAGE) - .putExtra(AppVolumeKeys.EXTRA_PACKAGE, app.packageName) - .putExtra(AppVolumeKeys.EXTRA_UID, app.uid) - .putExtra(AppVolumeKeys.EXTRA_VOLUME, v) - .addFlags(Intent.FLAG_RECEIVER_FOREGROUND), - ) - }.onFailure { HookHelper.log("$TAG: forward failed", it) } - - // 直接生效(`AudioManager.setPlayerVolume`)需要重新查找播放配置并跨进程调用音频服务, - // 通话等场景可能阻塞数秒,放到后台线程执行,避免卡住系统界面主线程。 val appContext = context.applicationContext ?: context - runCatching { applyExecutor.execute { AppVolumePlayback.applyVolume(appContext, app, v) } } - .onFailure { HookHelper.log("$TAG: forward enqueue failed", it) } + volumeUpdates.submit(app.uid) { + // MiSound mirrors a successful direct write. If it failed, retain the old receiver fallback. + AppVolumeDelivery.send(v, write = { AppVolumePlayback.applyVolume(appContext, app, it) }) { value, applied -> + runCatching { + appContext.sendBroadcast( + Intent(AppVolumeKeys.ACTION_SET_VOLUME) + .setPackage(AppVolumeKeys.TARGET_PACKAGE) + .putExtra(AppVolumeKeys.EXTRA_PACKAGE, app.packageName) + .putExtra(AppVolumeKeys.EXTRA_UID, app.uid) + .putExtra(AppVolumeKeys.EXTRA_VOLUME, value) + .putExtra(AppVolumeKeys.EXTRA_ALREADY_APPLIED, applied) + .addFlags(Intent.FLAG_RECEIVER_FOREGROUND), + ) + }.onFailure { HookHelper.log("$TAG: forward failed", it) } + } + } } private const val TOUCHABLE_INSETS_FRAME = 0 diff --git a/hook/src/main/java/cn/ianzb/hyperrefine/hook/systemui/OfficialVolumeColumn.kt b/hook/src/main/java/cn/ianzb/hyperrefine/hook/systemui/OfficialVolumeColumn.kt index 3b496eb..81b92d9 100644 --- a/hook/src/main/java/cn/ianzb/hyperrefine/hook/systemui/OfficialVolumeColumn.kt +++ b/hook/src/main/java/cn/ianzb/hyperrefine/hook/systemui/OfficialVolumeColumn.kt @@ -176,19 +176,30 @@ object OfficialVolumeColumnFactory { updateSliderRatio?.let { runCatching { it.invoke(instance) } } slider.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener { + private var lastCommitted = -1 + + private fun commit(progress: Int) { + val percent = (progress * 100f / SLIDER_MAX).toInt().coerceIn(0, 100) + if (percent == lastCommitted) return + lastCommitted = percent + onCommit(percent) + } + override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) { runCatching { toProgressWithAnim.invoke(progressView, fromUser, seekBar) } .onFailure { HookHelper.log("OfficialVolumeColumn: progress anim failed", it) } val percent = (progress * 100f / SLIDER_MAX).toInt().coerceIn(0, 100) onPercent(percent) - if (fromUser) onCommit(percent) + if (fromUser) commit(progress) } override fun onStartTrackingTouch(seekBar: SeekBar) { + lastCommitted = -1 setTracking?.let { runCatching { it.invoke(instance, true) } } } override fun onStopTrackingTouch(seekBar: SeekBar) { + commit(seekBar.progress) setTracking?.let { runCatching { it.invoke(instance, false) } } } }) diff --git a/hook/src/main/java/cn/ianzb/hyperrefine/hook/volume/AppVolumeDelivery.kt b/hook/src/main/java/cn/ianzb/hyperrefine/hook/volume/AppVolumeDelivery.kt new file mode 100644 index 0000000..43ea466 --- /dev/null +++ b/hook/src/main/java/cn/ianzb/hyperrefine/hook/volume/AppVolumeDelivery.kt @@ -0,0 +1,17 @@ +package cn.ianzb.hyperrefine.hook.volume + +/** Direct write is authoritative; failed writes and legacy broadcasts retain the receiver fallback. */ +internal object AppVolumeDelivery { + fun send(volume: Float, write: (Float) -> Int, forward: (Float, Boolean) -> Unit) { + if (!volume.isFinite()) return + val value = volume.coerceIn(0f, 1f) + val applied = runCatching { write(value) > 0 }.getOrDefault(false) + forward(value, applied) + } + + fun receive(volume: Float, alreadyApplied: Boolean, write: (Float) -> Int, mirror: (Float) -> Unit) { + if (!volume.isFinite()) return + val value = volume.coerceIn(0f, 1f) + if (alreadyApplied || write(value) > 0) mirror(value) + } +} diff --git a/hook/src/main/java/cn/ianzb/hyperrefine/hook/volume/LatestValueDispatcher.kt b/hook/src/main/java/cn/ianzb/hyperrefine/hook/volume/LatestValueDispatcher.kt new file mode 100644 index 0000000..0241f13 --- /dev/null +++ b/hook/src/main/java/cn/ianzb/hyperrefine/hook/volume/LatestValueDispatcher.kt @@ -0,0 +1,44 @@ +package cn.ianzb.hyperrefine.hook.volume + +import java.util.concurrent.Executor + +/** One worker, one pending value per playback identity; slow IPC cannot queue every drag event. */ +internal class LatestValueDispatcher( + private val executor: Executor, + private val consume: (V) -> Unit, + private val onFailure: (Throwable) -> Unit = {}, +) { + private val pending = LinkedHashMap() + private var running = false + + fun submit(key: K, value: V) { + synchronized(pending) { + pending[key] = value + if (running) return + running = true + } + try { + executor.execute(::drain) + } catch (failure: Throwable) { + synchronized(pending) { + pending.clear() + running = false + } + runCatching { onFailure(failure) } + } + } + + private fun drain() { + while (true) { + val next = synchronized(pending) { + val iterator = pending.entries.iterator() + if (!iterator.hasNext()) { + running = false + return + } + iterator.next().value.also { iterator.remove() } + } + runCatching { consume(next) }.onFailure { runCatching { onFailure(it) } } + } + } +} diff --git a/hook/src/main/java/cn/ianzb/hyperrefine/hook/volume/OwnedResources.kt b/hook/src/main/java/cn/ianzb/hyperrefine/hook/volume/OwnedResources.kt new file mode 100644 index 0000000..fe1b176 --- /dev/null +++ b/hook/src/main/java/cn/ianzb/hyperrefine/hook/volume/OwnedResources.kt @@ -0,0 +1,27 @@ +package cn.ianzb.hyperrefine.hook.volume + +/** Resources belong to one panel generation and can be disposed by end, cancel, or detach. */ +internal class OwnedResources( + private val release: (T) -> Unit, + private val onFailure: (Throwable) -> Unit = {}, +) { + private val resources = mutableListOf() + private var disposed = false + val values: List get() = resources + + fun add(resource: T) { + if (disposed) releaseSafely(resource) else resources.add(resource) + } + + fun dispose() { + if (disposed) return + disposed = true + val owned = resources.toList() + resources.clear() + owned.forEach(::releaseSafely) + } + + private fun releaseSafely(resource: T) { + runCatching { release(resource) }.onFailure { runCatching { onFailure(it) } } + } +} diff --git a/hook/src/test/java/cn/ianzb/hyperrefine/hook/volume/AppVolumeDeliveryTest.kt b/hook/src/test/java/cn/ianzb/hyperrefine/hook/volume/AppVolumeDeliveryTest.kt new file mode 100644 index 0000000..d9ab65f --- /dev/null +++ b/hook/src/test/java/cn/ianzb/hyperrefine/hook/volume/AppVolumeDeliveryTest.kt @@ -0,0 +1,60 @@ +package cn.ianzb.hyperrefine.hook.volume + +import org.junit.Assert.* +import org.junit.Test + +class AppVolumeDeliveryTest { + @Test fun successfulDirectWriteUpdatesEverySharedUidPlayerOnceAndStillMirrors() { + val players = listOf( + AppVolumePolicy.Player("track-1", 10_276, 1, 2, 1, null), + AppVolumePolicy.Player("track-2", 10_276, 1, 2, 1, null), + AppVolumePolicy.Player("paused", 10_276, 1, 3, 1, null), + AppVolumePolicy.Player("other", 10_301, 2, 2, 1, null), + ) + val writes = mutableListOf>() + val mirrors = mutableListOf() + val write = { v: Float -> AppVolumePolicy.applyVolume(players, 10_276, v, { id, value -> writes.add(id to value) }) } + AppVolumeDelivery.send(0.23f, write) { value, applied -> + assertTrue(applied) + AppVolumeDelivery.receive(value, applied, write, { mirrors.add(it) }) + } + assertEquals(listOf("track-1" to 0.23f, "track-2" to 0.23f), writes) + assertEquals(listOf(0.23f), mirrors) + } + + @Test fun failedDirectWriteUsesTheOriginalReceiverFallback() { + val values = mutableListOf() + AppVolumeDelivery.send(0.7f, { 0 }) { value, applied -> + assertFalse(applied) + AppVolumeDelivery.receive(value, applied, { values.add(it); 1 }, { values.add(it) }) + } + assertEquals(listOf(0.7f, 0.7f), values) + } + + @Test fun unavailableDirectApiStillForwardsAndLegacyBroadcastStillWrites() { + var writes = 0 + var mirrored = false + AppVolumeDelivery.send(0.6f, { error("API unavailable") }) { value, applied -> + assertFalse(applied) + AppVolumeDelivery.receive(value, false, { writes++; 2 }, { mirrored = true }) + } + assertEquals(1, writes) + assertTrue(mirrored) + } + + @Test fun unsuccessfulLegacyWriteDoesNotPublishAnUnappliedMirror() { + AppVolumeDelivery.receive(0.5f, false, { 0 }, { fail("nothing was written") }) + } + + @Test fun invalidValuesNeverWriteOrForwardAndValidValuesStayBounded() { + listOf(Float.NaN, Float.POSITIVE_INFINITY, Float.NEGATIVE_INFINITY).forEach { value -> + AppVolumeDelivery.send(value, { fail("invalid write"); 0 }, { _, _ -> fail("invalid forward") }) + AppVolumeDelivery.receive(value, true, { fail("invalid write"); 0 }, { fail("invalid mirror") }) + } + val forwarded = mutableListOf() + listOf(-1f, 0f, 1f, 2f).forEach { value -> + AppVolumeDelivery.send(value, { 1 }) { v, _ -> forwarded.add(v) } + } + assertEquals(listOf(0f, 0f, 1f, 1f), forwarded) + } +} diff --git a/hook/src/test/java/cn/ianzb/hyperrefine/hook/volume/LatestValueDispatcherTest.kt b/hook/src/test/java/cn/ianzb/hyperrefine/hook/volume/LatestValueDispatcherTest.kt new file mode 100644 index 0000000..e4bad69 --- /dev/null +++ b/hook/src/test/java/cn/ianzb/hyperrefine/hook/volume/LatestValueDispatcherTest.kt @@ -0,0 +1,89 @@ +package cn.ianzb.hyperrefine.hook.volume + +import org.junit.Assert.* +import org.junit.Test +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executor +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.TimeUnit + +class LatestValueDispatcherTest { + private class ControlledExecutor : Executor { + val tasks = LinkedBlockingQueue() + override fun execute(command: Runnable) { tasks.add(command) } + fun runNext() = checkNotNull(tasks.poll(2, TimeUnit.SECONDS)).run() + } + + @Test fun burstQueuesOneWorkerAndOnlyTheFinalValueOfEachUid() { + val executor = ControlledExecutor() + val applied = mutableListOf>() + val dispatcher = LatestValueDispatcher>(executor, { applied.add(it) }) + repeat(10_000) { dispatcher.submit(10_276, 10_276 to it) } + dispatcher.submit(99_910_276, 99_910_276 to 47) + assertEquals(1, executor.tasks.size) + executor.runNext() + assertEquals(listOf(10_276 to 9999, 99_910_276 to 47), applied) + assertTrue(executor.tasks.isEmpty()) + } + + @Test fun slowIpcKeepsFinalValueWithoutLosingAnotherAppOrSchedulingMoreWorkers() { + val executor = ControlledExecutor() + val entered = CountDownLatch(1) + val resume = CountDownLatch(1) + val done = CountDownLatch(1) + val applied = mutableListOf>() + val dispatcher = LatestValueDispatcher>(executor, { + if (it == 10_276 to 0) { + entered.countDown() + check(resume.await(2, TimeUnit.SECONDS)) + } + applied.add(it) + }) + dispatcher.submit(10_276, 10_276 to 0) + val worker = Thread { try { executor.runNext() } finally { done.countDown() } } + worker.start() + try { + assertTrue(entered.await(2, TimeUnit.SECONDS)) + repeat(10_000) { dispatcher.submit(10_276, 10_276 to it) } + dispatcher.submit(10_301, 10_301 to 63) + assertTrue(executor.tasks.isEmpty()) + } finally { resume.countDown() } + assertTrue(done.await(2, TimeUnit.SECONDS)) + assertEquals(listOf(10_276 to 0, 10_276 to 9999, 10_301 to 63), applied) + dispatcher.submit(10_276, 10_276 to 12) + executor.runNext() + assertEquals(10_276 to 12, applied.last()) + } + + @Test fun failedWriteAndFailedErrorCallbackDoNotStrandTheFinalValue() { + val executor = ControlledExecutor() + val applied = mutableListOf() + lateinit var dispatcher: LatestValueDispatcher + dispatcher = LatestValueDispatcher(executor, { + if (it == 1) { + dispatcher.submit(1, 2) + error("released player") + } + applied.add(it) + }, { error("logging failed") }) + dispatcher.submit(1, 1) + executor.runNext() + assertEquals(listOf(2), applied) + dispatcher.submit(1, 3) + executor.runNext() + assertEquals(listOf(2, 3), applied) + } + + @Test fun rejectedWorkerDoesNotPreventTheNextSubmission() { + var reject = true + val controlled = ControlledExecutor() + val applied = mutableListOf() + val executor = Executor { if (reject) error("rejected") else controlled.execute(it) } + val dispatcher = LatestValueDispatcher(executor, { applied.add(it) }) + dispatcher.submit(1, 5) + reject = false + dispatcher.submit(1, 8) + controlled.runNext() + assertEquals(listOf(8), applied) + } +} diff --git a/hook/src/test/java/cn/ianzb/hyperrefine/hook/volume/OwnedResourcesTest.kt b/hook/src/test/java/cn/ianzb/hyperrefine/hook/volume/OwnedResourcesTest.kt new file mode 100644 index 0000000..9c177f7 --- /dev/null +++ b/hook/src/test/java/cn/ianzb/hyperrefine/hook/volume/OwnedResourcesTest.kt @@ -0,0 +1,58 @@ +package cn.ianzb.hyperrefine.hook.volume + +import org.junit.Assert.* +import org.junit.Test + +class OwnedResourcesTest { + @Test fun detachThenAnimationCancelAndEndReleaseEachColumnOnlyOnce() { + val released = mutableListOf() + val panel = OwnedResources({ released.add(it) }) + panel.add("apple") + panel.add("bilibili") + panel.dispose() // detach + panel.dispose() // cancel + panel.dispose() // end + assertEquals(listOf("apple", "bilibili"), released) + assertTrue(panel.values.isEmpty()) + } + + @Test fun lateOldPanelCallbackDoesNotReleaseTheReplacementPanelsColumns() { + val released = mutableListOf() + val old = OwnedResources({ released.add(it) }) + old.add("old") + old.dispose() + val current = OwnedResources({ released.add(it) }) + current.add("current") + old.dispose() + assertEquals(listOf("old"), released) + assertEquals(listOf("current"), current.values) + current.dispose() + assertEquals(listOf("old", "current"), released) + } + + @Test fun reentrantDisposalAndAReleaseFailureStillReleaseTheOtherResources() { + val released = mutableListOf() + val failures = mutableListOf() + lateinit var panel: OwnedResources + panel = OwnedResources({ + assertTrue(panel.values.isEmpty()) + panel.dispose() + if (it == "broken") error("native release failed") + released.add(it) + }, { failures.add(it) }) + panel.add("broken") + panel.add("healthy") + panel.dispose() + assertEquals(listOf("healthy"), released) + assertEquals(1, failures.size) + } + + @Test fun partiallyConstructedPanelCannotRetainResourcesAddedAfterDisposal() { + val released = mutableListOf() + val panel = OwnedResources({ released.add(it) }) + panel.dispose() + panel.add("late-column") + assertEquals(listOf("late-column"), released) + assertTrue(panel.values.isEmpty()) + } +}