把 ColorOS / Oplus(一加、OPPO、realme)的电源菜单恢复成原生 Android(AOSP)样式的 LSPosed 模块, 并在原生菜单上补上「引导模式 / 恢复模式」两个扩展项。
长按电源键
└── system_server: PhoneWindowManager
└── IStatusBarService.showGlobalActions()
└── CommandQueue → GlobalActionsComponent.handleShowGlobalActionsMenu()
└── GlobalActions 插件 (GlobalActionsImpl)
├── ColorOS: OplusGlobalActionsDialog ← 自绘的 ShutdownView
└── 本模块 : 直接 inflate AOSP 原版的 global_actions_grid_lite
不是自绘的近似,而是 AOSP 原版布局。 这一点是逆向时发现的:
设备自己的 SystemUI.apk 里仍然原封不动地躺着 AOSP 的
res/layout/global_actions_grid_lite.xml、global_actions_grid_item_lite.xml、
res/drawable/global_actions_lite_background.xml 等资源,只是 ColorOS 把整条调用链换成了
OplusShutdownView,这些资源再也没人引用。模块现在直接 inflate 它们,并像
GlobalActionsLayoutLite.onUpdateList() 那样,把每个 GlobalActionsItem 交给
ConstraintLayout 的 Flow 排布。
因此布局、GlobalActionsItem 的按压反馈、dimen、颜色、主题全部来自设备本身:
- 面板背景
global_actions_lite_background+ 圆角global_actions_corner_radius - 圆形按钮
global_actions_lite_button/global_actions_button_size/_button_padding - 文字 14sp +
global_actions_lite_text,列数取power_menu_lite_max_columns - 图标与文案取自
framework-res(ic_lock_power_off、ic_restart、ic_screenshot、ic_lock_lockdown、emergency_icon以及对应的global_action_*字符串),自动跟随系统语言
顺带对比过 HyperOS 提取出来的
系统界面_16.03.251211.r.apk:它那份global_actions_grid_lite.xml与 ColorOS 自带的逐字节相同(只差一个android:clipChildren),所以用设备自己那份最准。
菜单项顺序沿用 AOSP 的 config_globalActionsList,功能与 AOSP 完全一致:
| 菜单项 | 行为 | 长按 |
|---|---|---|
| 关机 | GlobalActionsManager.shutdown() |
重启到安全模式(reboot(true)) |
| 重启 | GlobalActionsManager.reboot(false) |
– |
| 截屏 | ScreenshotHelper.takeScreenshot(GLOBAL_ACTIONS, …) |
– |
| 紧急 | android.intent.action.EMERGENCY_ASSISTANCE |
– |
| 锁定 | LockPatternUtils.requireStrongAuth() + IWindowManager.lockNow() |
– |
| 菜单项 | 行为 |
|---|---|
| 引导模式 | 点击直接 PowerManager.reboot("bootloader") |
| 恢复模式 | 点击直接 PowerManager.reboot("recovery") |
这两项不是 AOSP 自带的,但形态与其它项完全一致:点一下就重启,没有二次确认框 (进 fastboot 后是音量键选择、长按电源键退出)。
难点在于权限:SystemUI 没有 android.permission.REBOOT(dumpsys package com.android.systemui
里连申请都没有),而 PowerManager.reboot(String) 是在 system_server 里做权限检查的。
所以模块同时注入 system 进程,并借用一个只读的 binder 方法
IStatusBarService.getDisableFlags(IBinder, int) 当私有通道:SystemUI 用 null token + magic 的
userId 调它,system_server 侧的钩子识别 magic、吞掉这次调用(真实实现按 token 查不到记录,
本来也只返回 {0, 0},不会有副作用),再以系统进程身份调用 IPowerManager.reboot ——
这条路正是 ColorOS 自己的 StatusBarManagerService.reboot(boolean) 走的路,权限天然满足。
挑这个方法的另一个原因是它有返回值:system_server 能把 ACK / NAK 回给 SystemUI,
于是「对面根本没加载」和「重启本身失败」能被区分开,而不是静默失败。
同一个通道也用来下发「长按电源键延迟」:magic userId 的高 16 位区分请求类型,低 16 位带上
毫秒值(0 = 用系统默认),所以不需要第二条 binder 方法。
嵌套的同进程 binder 调用会继承调用方的 uid,所以 system_server 侧在
IPowerManager.reboot前 必须Binder.clearCallingIdentity(),否则PowerManagerService看到的是 SystemUI 的 uid 10237, 直接抛SecurityException: ... does not have android.permission.REBOOT。
系统里「按住电源键多久弹出电源菜单」由 PhoneWindowManager 的一条按键规则决定:
SingleKeyGestureDetector 用它给出的超时来判定长按,超时后调用 powerLongPress() →
showGlobalActions()(也就是本模块替换掉的那个入口)。ColorOS 这一层也确认过:
powerLongPress() 会先问 Oplus 的 oplusInterceptLongPowerPress(),它只在屏幕已熄灭时截胡
(返回 true),亮屏时只做一次振动就放行 —— 所以亮屏下的菜单确实走 AOSP 那条路。
关键结论:这台设备上的电源菜单不是「长按」弹出来的,而是「超长按」弹出来的,而且那个超时被 ColorOS 写死在自己的扩展里:
// SingleKeyGestureDetectorExtImpl(oplus-services.jar)
public long modifyPressTimeout(int pressType, long veryLongPressTimeout, KeyEvent event) {
if (pressType == 1 && event.getKeyCode() == 26) { // 26 = 电源键
return 2500L; // ← 就是这 2.5 秒
}
return veryLongPressTimeout;
}SingleKeyGestureDetector.interceptKeyDown() 给「超长按」排消息时,延时要过这一层
(modifyPressTimeout(1, rule.getVeryLongPressTimeoutMs(), event)),于是不管
config_veryLongPressTimeout 是多少,电源键永远是 2500 ms;到点后
PowerKeyRule.onVeryLongPress() → powerVeryLongPress() → showGlobalActions() 弹菜单。
而 PowerKeyRule#getLongPressTimeoutMs()(普通长按)在这台机器上根本不会被投递 ——
日志实测:按键到 showGlobalActions() 相隔 2.513 s,onLongPress/powerLongPress 一次都没出现过。
所以模块(2.1.4 起)挂两个点,任一路径都认这个设置:
| 挂点 | 作用 |
|---|---|
SingleKeyGestureDetectorExtImpl#modifyPressTimeout |
这台设备真正生效的那个:把写死的 2500 ms 换成设置值;同时把 2500 记下来当作「设备默认值」 |
PhoneWindowManager$PowerKeyRule#getLongPressTimeoutMs |
普通长按路径(原生 AOSP 上是这条),一并替换 |
两者都只认「pressType = 超长按 且 keyCode = 电源键」,不影响其它按键。
默认值不能只看
config_longPressOnPowerDurationMs。 framework-res 里写的是 500 ms,但实测 按住电源键要两秒多菜单才出来,说明真正生效的值另有来源(Settings.Global的power_button_long_press/power_button_long_press_duration_ms是一层,ColorOS 自己覆盖的 资源又是一层)。所以模块不去猜:system_server 侧记下框架每次真正返回的值,通过有序广播的 结果回传给设置页,卡片上显示的默认值就是设备实测值(本机 2500 ms)。 这里刻意不记录普通长按规则报告的 500 ms —— 那个值在 ColorOS 上根本不参与弹菜单。该值要按过一次电源键之后才有 (只有 system_server 观察得到),在那之前只显示「默认」。
设置里改完点「保存并应用」会立刻下发(不用重启 system_server)。取值范围 500 – 5000 ms,
拖动步进 100 ms;右侧的数值本身就是一个输入框,可以直接敲精确到毫秒的数字(离开输入框或按
回车才提交),标题右侧的 ⟳ 图标按钮恢复设备默认值(存 0 = 不干预,此时按钮变暗表示当前就是默认)。
上限 5000 ms 是安全的:ColorOS 在按下 3 秒后开始的只是关机状态记录(HwShutdownRecord 写标志位、
刷数据库),真正强制断电由硬件在更久之后处理。
「默认」这个数字取自 system_server 实际观察到的那一个 —— 也就是
modifyPressTimeout原本会返回的 2500,不是普通长按规则报告的 500 ms。 这一条要装完新版本重启手机后才生效:设置页显示的默认值是 system_server 回报的, 而 system_server 里的模块代码只在开机时加载。
改到 system_server 的代码必须重启手机才生效。 模块注入 system_server 是在它启动时完成的, 装完新 APK 只重启「系统界面」不会重新加载系统进程里那份 dex(LSPosed 会为 SystemUI 重新注入, 但不会重启动系统进程)。判据:LSPosed 日志里搜
(system)[com.nativepowermenu,看不到system_server: hooked ...那一行就是系统进程还在跑旧代码。
模块自带一个启动器入口(SettingsActivity),用 Theme.DeviceDefault.DayNight + 框架 Material 控件
搭出 AOSP 设置风格的界面,不引入 Material Components 依赖:
┌──────────────────────────────┐
│ 启用模块 [ ●——] │ 总开关,关掉立刻回到 ColorOS 默认电源菜单
│ 关闭后恢复 ColorOS 默认电源菜单 │
└──────────────────────────────┘
┌──────────────────────────────┐
│ 电源菜单长按时间 ⟳ 2500 ms │ ⟳ = 恢复默认(数值等于默认值时也变暗);数值可直接编辑
│ ●────────────────────────── │ 500 – 5000 ms,步进 100 ms;滑杆两端各内缩 8dp
└──────────────────────────────┘
电源菜单项 ⟳ ⟳ = 恢复默认排序与开关(引导/恢复 关,其余开);
已经是默认状态时变暗不可点
┌──────────────────────────────┐
│ ⠿ ⏻ 关机 [——●] │ 关机 / 重启 是必选项,开关置灰常开
│ ⠿ ↻ 重启 [——●] │
│ ⠿ ⛨ 锁定 [●——] │
│ ⠿ ⏏ 截屏 [●——] │
│ ⠿ ⬓ 引导模式 [●——] │
│ ⠿ ⬓ 恢复模式 [●——] │
└──────────────────────────────┘
[ 保存并应用 ]
- 拖动排序:按住手柄后只有被抓住的那一行跟手 ——
translationY每帧按手指原始坐标重算 (上下限夹在列表首尾槽位之间),所以一次手势能连续拖到列表内任意位置,也不会跑出列表。 其它行会实时让位:落点一变,它们就用 140ms 滑到相邻槽位,把那个位置空出来。让位只改绘制偏移 (translationY),不改顺序也不改被拖行的下标,所以整套坐标计算不会因为重排而失效; 松手时被拖行用 160ms 滑进空位,顺序才真正提交 —— 此时其它行已经在正确位置上,不需要再动。 移动的始终是槽位里的那一行,槽位本身不动 —— 行在槽位内上/下滑动(槽位关掉clipChildren才不会被裁),因此在它上面、以及被让开的那些槽位上的分割线全程静止, 既不会被手指拖走也不会跟着滑动。抬起来的行靠setElevation浮在列表之上(槽位自己也 抬一个 elevation 只是为了改变绘制顺序,它没有背景所以不会画阴影),圆角用同一个CARD_RADIUS_DP,和它下面的列表框一致 (ViewGroup会按 Z 重排绘制顺序)。 - 列表重置:「电源菜单项」右边的 ⟳ 把顺序恢复成设备
config_globalActionsList的默认 顺序(扩展项紧跟在「重启」之后),开关恢复成「引导模式 / 恢复模式 关,其余开」—— 和模块首次运行的默认值一致;顺序与开关都已是默认值时,按钮变暗且不可点(拖动排序、 拨动开关会立刻让它重新可用)。和别的改动一样,按下「保存并应用」之前不会写入。 - 长按卡片的顶部/底部留白要和上面那张纯文字卡片"看起来一样":卡片内容内边距统一是
CARD_PADDING_DP = 14,但标题行(36dp 里居中一行 22dp 的文字)和滑杆(40dp 里居中一个 缩略图)各自带着约 7dp / 10dp 的空隙,所以这张卡片写的是14 - 7和14 - 10, 让文字与滑杆的可见边缘落在和其它卡片相同的位置上(这两个常量就是拿去微调的)。 - 长按卡片的高度是写死的(标题行 36dp / 输入框 36dp / 滑杆 40dp):这台机器的主题下
wrap_content会把这张卡片撑到接近一整屏(标题行被垂直居中、滑杆被挤出可视区), 而且 EditText 的宽度改成随内容收缩,下划线才会跟着数字长度走。 - 避让系统栏:模块
targetSdk 36,Android 强制 edge-to-edge,设置页把系统栏 + 挖孔 inset 加进内容内边距;电源菜单那个窗口是全屏、且层级在状态栏之上(TYPE_STATUS_BAR_SUB_PANEL), 系统不会替它避让,同样按 inset 加内边距,保证菜单顶部元素不会跑进状态栏。 - 保存并应用:写入设置 → 有序广播推给 SystemUI → SystemUI 存盘并重启自己(它是 persistent 应用, 被系统立刻拉回)。广播的结果码用来判断是否真的送达,没送到会提示「未生效:请确认模块已启用」。
- 打开设置页时会静默重推一次已保存的值(不重启),因此「先配置、后启用模块」也不会丢配置。
- 长按延迟立即生效:它由 system_server 里的钩子执行,广播一到就先下发过去,不必等 SystemUI 重启完(SystemUI 每次启动也会重新下发一次,因为 system_server 比它活得久)。
- 广播由模块自己声明的 signature 权限保护,SystemUI 注册时要求发送方持有该权限, 别的应用无法通过这个通道重启 SystemUI。
ModuleLog走反射访问 Xposed API,并且探测不到就直接只写 logcat:设置应用进程里没有 Xposed API,直接引用它会以NoClassDefFoundError闪退(2.2.2 的诊断日志就这么崩过一次)。
- Android 12+(
minSdk 31),已在 OnePlus PJZ110 / ColorOS 16.0.5.703 (Android 16, SDK 36) 上验证。 - LSPosed(含 Zygisk / 内置版本均可)已激活。
- 作用域:系统界面(com.android.systemui) + 系统(LSPosed 里 system_server 对应的包名是
system,模块的推荐作用域把android和system都列上了,勾中任意一个能命中系统进程的即可)。
系统进程这个作用域是必须的,不是可选项。
ColorOS 的
PowerManagerService.reboot()会enforceCallingOrSelfPermission:public void reboot(boolean confirm, String reason, boolean wait) { mContext.enforceCallingOrSelfPermission("android.permission.REBOOT", null); if ("recovery".equals(reason) || "recovery-update".equals(reason)) { mContext.enforceCallingOrSelfPermission("android.permission.RECOVERY", null); } ...而 SystemUI 两个权限都没有申请(
dumpsys package com.android.systemui | grep REBOOT是空的), 所以带 reason 的重启只能由系统进程发起 —— 这也正是 ColorOS 自己StatusBarManagerService.reboot(boolean)的做法。模块因此需要在 system_server 里也跑一份。注意:升级安装 APK 不会重置 LSPosed 的作用域,装了新版本之后要自己回 「模块 → 原生电源菜单 → 作用域」确认系统进程那一项被勾上,再重启手机。 漏了这一步的表现是:设置项与图标都正常,但点「引导模式 / 恢复模式」确定之后毫无反应 (新版会弹提示告诉你去哪里开)。
- 安装
build/NativePowerMenu.apk。 - 打开 LSPosed 管理器 → 模块 → 启用「原生电源菜单」。
- 作用域同时勾选「系统界面」和系统进程那一项。
- 重启手机(系统进程侧的作用域只对重启后新起的 system_server 生效)。
- 之后直接打开「原生电源菜单」应用调整开关与顺序,点「保存并应用」即可。
日志里搜 NativePowerMenu(LSPosed 日志或 logcat)就能看出链路走到哪一步:
| 日志 | 含义 |
|---|---|
installed 1 hook(s) on ...GlobalActionsImpl#showGlobalActions |
SystemUI 侧挂钩成功 |
apply receiver installed |
「保存并应用」的通道就绪 |
system_server: hooked 1 getDisableFlags method(s) |
系统侧挂钩成功,缺这行就是作用域没勾「系统框架」 |
system_server: hooked 1 getLongPressTimeoutMs method(s) on ...PowerKeyRule |
长按延迟的钩子就位(2.1.0 起) |
system_server: hooked 1 modifyPressTimeout method(s) on ...SingleKeyGestureDetectorExtImpl |
ColorOS 那个写死 2500 ms 的钩子就位(2.1.4 起,这台设备靠它生效) |
power key long press: device=2500 ms, applied=300 ms |
按电源键时钩子被调用:设备自己会用多久、模块实际返回多久。每进程前几次 + 值变化时记录 |
PowerKeyRule.onVeryLongPress / powerVeryLongPress() / showGlobalActions() |
长按链路的时间线,用来确认菜单是哪条路径弹出来的 |
system_server accepted the long-press timeout: N ms (framework's own: M ms) |
设置页下发成功;M 就是设置页显示的「默认」 |
system_server: rebooting to recovery |
系统进程真的开始重启了 |
no answer from system_server |
系统侧没响应:作用域没勾「系统框架」,或者装了新版本之后没重启手机(系统进程里的 dex 还是旧的) |
排查顺序:先看有没有 (system)[com.nativepowermenu 开头的行 → 没有就是系统进程没注入(作用域 / 没重启);
有的话看 hooked ... 那几行有没有出现,缺哪条就是对应版本的代码没加载。
项目不依赖 Gradle / AGP:只有 aapt2 → javac → d8 → zipalign → apksigner 五步。
./tools/fetch-deps.sh # 下载 Xposed API 82 编译桩(不打包进 APK)
./build.sh
# 产物:build/NativePowerMenu.apk需要:JDK 11+、Android SDK 的 build-tools(aapt2/d8/zipalign/apksigner)以及
platform 32 / platform 36 的 android.jar。路径都可以用环境变量覆盖:
| 变量 | 说明 |
|---|---|
ANDROID_HOME |
有值时会从中取 platforms/android-36 与 platforms/android-32 |
ANDROID_JAR_COMPILE |
编译 Java 用的 android.jar(默认 platform 36) |
ANDROID_JAR_LINK |
aapt2 链接资源用的 android.jar(默认 platform 32,见下) |
AAPT2 / D8 / ZIPALIGN / APKSIGNER / KEYTOOL |
各工具路径 |
XPOSED_API_JAR |
Xposed API 桩的位置 |
SDK_FALLBACK_DIR |
找不到工具时的兜底目录 |
为什么链接用 platform 32 的 android.jar? 链接时 aapt2 需要解析 framework 的
resources.arsc。Android 15/16 的资源表格式比老版本 aapt2 能解析的要新,用 36 的 jar 会报RES_TABLE_TYPE_TYPE entry offsets overlap actual entry data。清单里只用到长期存在的属性,用 32 的 framework 表链接完全等价;Java 侧仍然 用 36 的 jar 编译。
app/AndroidManifest.xml 模块清单(xposedmodule / xposedscope)
app/assets/xposed_init 入口类
app/assets/xposed_scope 默认作用域:com.android.systemui + android
app/res/drawable/ 扩展项的两个矢量图标(其余资源都用设备自带的)
app/src/.../ 模块源码
build.sh 无 Gradle 构建脚本
tools/fetch-deps.sh 拉取编译期依赖
docs/reverse-engineering.md ColorOS 电源菜单逆向记录
在 LSPosed 里停用模块并重启 SystemUI 即可;ColorOS 的关机界面从未被改动,只是不再被调用。 system_server 侧只挂了一个死方法的钩子,停用后不会留下任何东西。
仅用于个人设备上的界面定制。