Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/src/main/java/cn/ianzb/hyperrefine/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ class MainActivity : ComponentActivity() {

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
XposedServiceManager.checkRootOnUiLaunch()
enableEdgeToEdge()
window.isNavigationBarContrastEnforced = false

Expand Down
11 changes: 3 additions & 8 deletions app/src/main/java/cn/ianzb/hyperrefine/xposed/RootHelper.kt
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
30 changes: 30 additions & 0 deletions app/src/main/java/cn/ianzb/hyperrefine/xposed/RootProbe.kt
Original file line number Diff line number Diff line change
@@ -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() }
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -51,15 +51,19 @@ 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()
}

/**
* 异步检测 Root 权限,避免阻塞主线程;进行中的检测会被合并。
*
* 检测会真正拉起一次 `su`,在部分 Root 管理器上会弹出授权 / 使用提示,
* 因此只在两个时机调用:
* 1. 应用启动时([init]);
* 1. 首次进入应用界面([checkRootOnUiLaunch]);
* 2. 用户在主页手动点按状态卡片。
* 其余场景(回到前台、定时轮询等)一律复用上一次结果。
*/
Expand Down
66 changes: 66 additions & 0 deletions app/src/test/java/cn/ianzb/hyperrefine/xposed/RootProbeTest.kt
Original file line number Diff line number Diff line change
@@ -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") }))
}
}
4 changes: 4 additions & 0 deletions changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@

### 修复

- **多应用音量 · 视图生命周期**:入口状态由视图自身持有,百分比视图缓存和后台回调使用弱引用;面板正常关闭、动画取消、窗口脱离和替换时统一释放官方音量列,避免保留旧窗口;关闭后过期的播放探测不会重新弹出面板。
- **多应用音量 · 高频写入**:每个完整 UID 合并为最新待处理音量,滑动同一百分比不重复提交。系统界面直接写入成功后音质音效只同步原生镜像,失败时保留转发写入兜底,继续兼容旧广播、共享 UID 和同 UID 多播放器。
- **Root 检测 · 后台状态上报**:状态广播冷启动不再附带 Root 检测;首次打开界面与手动点按仍可检测,权限等待超时后可重试,授权结果只保留在本进程。

- **壁纸 · 模糊背景未跟随深色压暗**(目标 `com.android.systemui` / `com.miui.personalassistant`):控制中心、负一屏取壁纸做模糊的背景此前停在压暗前的亮度(压暗改的是壁纸图层的 alpha,模糊读的是壁纸纹理);现在给这类模糊容器补上同浓度的压暗层,背后是应用而不是壁纸时自动不加。重启系统界面生效。
- **安全模式 · 快速重启被误判为崩溃循环**:系统界面等进程短时间内被重启两次就会被计为崩溃循环、自动跳过全部 Hook(表现为多应用音量入口消失、系统界面侧功能全部无效,真机日志已确认该链路)。现在阈值提高到连续 3 次,应用内主动发起的重启后 30 秒内的启动与子进程上报均不计入;误触发后可在「设置 → 安全模式」一键恢复。

Expand Down
9 changes: 6 additions & 3 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -554,7 +554,8 @@ object XposedServiceManager {
var rootChecked: Boolean // 是否已完成 Root 检测
var scope: List<String> // Compose 可观察

fun init() // 在 Application.onCreate 调用(含 Root 检测)
fun init() // 在 Application.onCreate 调用,仅绑定服务
fun checkRootOnUiLaunch() // 首次进入 MainActivity 时检测,重建页面不重复检测
fun checkRoot() // 异步检测 Root 权限(会真正拉起一次 su)
fun getService(): XposedService?
fun refreshScope()
Expand All @@ -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`

Expand Down
33 changes: 33 additions & 0 deletions docs/APP_VOLUME_LIFECYCLE.md
Original file line number Diff line number Diff line change
@@ -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 下不默认整机重启。
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
Loading
Loading