环境:OnePlus 15 (codename corvette) / ColorOS 16 / Android 16 / Magisk root / Debian chroot 目标:左侧实体键短按 = 循环切换输入法;长按 = 保留系统原生功能(相机/免打扰等) 状态:已上线运行,随 chroot 服务开机自启
| 手势 | 行为 |
|---|---|
| 短按 | 系统弹一个 2 秒的状态胶囊(无法去除,见坑 1)+ 循环切换默认输入法 |
| 长按 | 执行系统设置里配置的原生功能(我配的"打开相机"),不影响输入法 |
输入法循环顺序 = enabled_input_methods 列表顺序,自动跳过选不上的幽灵条目(见坑 6)。
OnePlus 15 取消了经典三段式滑块,换成可按压的"智慧键"。逆向结论(设备树 + 内核 + logcat 实证):
- 设备树节点:
/sys/firmware/devicetree/base/soc/gpio-hall-sensor/oplus-magic-buttom,GPIO 48-51,DT 里 keycode 735 - 不产生任何 Linux input 事件(
/dev/input/event*十几个设备全监听,按压零事件;音量下键是独立的 gpio_keys,别认错) - 不产生任何 uevent(netlink KOBJECT_UEVENT 监听为空)
- GPIO 电平也不直接反映按压(debugfs gpio 只见 48-51 四脚 func0/func1 整组切换,是厂商驱动的重配置动作)
- 按键由 OPLUS 私有栈处理:
com.oplus.gesture(ActionButtonSettingsActivity / ActionKeyStartApp) - 手势语义:短按只弹状态胶囊(Fluid Cloud),长按才执行功能——早期测试全部失败就是因为一直短按
- 功能档位配置:
settings get/put system oplus_action_button_switch_state,取值如ring_mode/no_disturb/camera - 手势分发时会打日志:logcat tag
ActionKeyStartApp,singleTap/longPressStartApp明确区分手势,且与档位无关——这就是最终方案的信叽数据源
核心思路:旁路监听 OPLUS gesture app 的日志,短按(singleTap)时通过 ime set 循环切换输入法。不 hook、不修改系统,纯观察者。
物理短按
└─ com.oplus.gesture 私有处理(无 input 事件)
├─ 原生动作: 状态胶囊
└─ logcat: "ActionKeyStartApp: singleTap, ..."
▲ adb shell 流式读取
chroot 守护进程 (python3, 开机自启)
├─ 正则匹配 singleTap → 1.2s 去抖
└─ cycle_ime():
settings get secure enabled_input_methods (剥离 ";subtype" 后缀)
settings get secure default_input_method (当前)
从下一个开始逐个 ime set X:
"Unknown input method" → 幽灵条目,跳过
成功 → 结束
容错: adb 流断开 → 5s 重连; logcat -T 1 从最新行开始防止重放旧日志
ADB = ["/usr/bin/adb", "-s", "127.0.0.1:<port>"]
LOGCAT_CMD = "logcat -v time -T 1 -s ActionKeyStartApp:D"
TAP_RE = re.compile(r"ActionKeyStartApp\(.*\):\s+singleTap")
DEBOUNCE_S = 1.2
def cycle_ime() -> None:
raw = shell("settings get secure enabled_input_methods")
enabled = [e.split(";")[0] for e in raw.split(":") if e.split(";")[0]]
current = shell("settings get secure default_input_method")
if current not in enabled:
return
start = enabled.index(current)
for offset in range(1, len(enabled)):
candidate = enabled[(start + offset) % len(enabled)]
result = shell(f"ime set {candidate} 2>&1") # 关键: 2>&1
if result and "Unknown input method" in result:
continue # 幽灵条目跳过
log.info("IME %s -> %s", current, candidate)
return
# 主循环: Popen(ADB + ["shell", LOGCAT_CMD]) 逐行读, 命中 TAP_RE 且过 debounce 即 cycle_ime()
# 流断开(proc.stdout EOF) → wait + sleep 5 → adb connect → 重开流#!/bin/bash
# Service: ime-trigger
# Description: watch action button singleTap via adb logcat; cycle default IME
start() {
# 以普通用户跑, 保证 adb key 是已授权的那份
nohup su - <user> -c 'exec /usr/bin/python3 /usr/local/bin/ime-trigger.py' >> /var/log/ime-trigger.log 2>&1 &
echo $! > "$PID_FILE"
}
stop() {
if [ -f "$PID_FILE" ]; then
kill "$(cat "$PID_FILE")" 2>/dev/null
rm -f "$PID_FILE"
fi
pkill -f 'python3 /usr/local/bin/ime-trigger.py' 2>/dev/null
}依赖:本机 adbd 监听 TCP(adb connect 127.0.0.1:<port>),chroot 与 Android 同内核,/dev/input、settings 命令均可用。
输入法切换 app 里加了个 manifest 注册的 RINGER_MODE_CHANGED receiver(响铃模式变化 → 直接 Settings.Secure.putString(DEFAULT_INPUT_METHOD),需要 WRITE_SECURE_SETTINGS)。默认关闭,原因见坑 2 和坑 4;留给未来 ROM 行为变化时手动启用。
- 没有 input 事件可用。event0-11 全监听 + uevent netlink + debugfs GPIO 轮询全空。结论:这个键在 Linux 侧不可见,一切 key remap 类工具(Key Mapper / Button Mapper)原理性失效。唯一出口是"状态变化的侧面观察"或厂商日志。
RINGER_MODE_CHANGED不投递给 manifest receiver(ColorOS 16 / targetSdk 35 的隐式广播限制)。app 侧常驻方案需要前台服务/无障碍等重权限,放弃。- 响铃模式轮询方案的两个信号坑:a) 档位为
no_disturb时只有zen_mode动、mode_ringer不动;b) 短按根本不改任何状态——轮询天然只能抓到长按副作用。 pm grant权限坑:adb shell 无GRANT_RUNTIME_PERMISSIONS(ColorOS 收紧);su -c pm grant返回 0 但权限表不落盘。WRITE_SECURE_SETTINGS走不通,app 直接切换不可行,改用 shell 侧ime set。- 焦点在按键自己的设置页时,按压事件被设置页消费,只做预览,不执行功能,也不写日志。测试时务必退到桌面。
- 幽灵输入法条目:
enabled_input_methods里残留已卸载/改名的开发中输入法(如 fcitx5 debug build),ime set对它们报 "Unknown input method"——错误在 stderr,只看 stdout 会误判成功(我就这样翻过车:日志全是"(ok)",用户端键盘纹丝不动)。修复:2>&1合并 + 失败自动试下一个 + 一定让用户肉体验收。 - 自测技巧:shell 可以伪造日志注入事件,不用每次都按实体键:
adb shell 'log -t ActionKeyStartApp -p d "singleTap, fluidTxet=selftest"'(注意-p d指定级别,-d是无效参数。) - 附赠:本地 arm64 构建 APK 的坑(chroot on aarch64):AGP 从 Maven 拉的 aapt2 是 x86_64,经 binfmt/box64 运行时 argv 错位(子命令变成程序路径),daemon 模式必挂。解法:
gradle.properties里android.aapt2FromMavenOverride指向一个自编译的 aarch64 ELF 启动器(exec box64.real + 真 aapt2),文件名必须叫aapt2(AGP 会校验文件名和 ELF magic,shell 脚本不行)。
sudo chroot-init status ime-trigger # 服务状态
tail -f /var/log/ime-trigger.log # 触发日志(含切换与跳过记录)
adb shell 'log -t ActionKeyStartApp -p d "singleTap"' # 无线自测- 换循环列表:无需改代码,enabled 列表变了自动跟随;卸载残留会自动跳过
- 换触发手势:把
TAP_RE改成匹配longPressStartApp即可 - 档位建议:配"长按=相机、短按=仅提示"类档位,短按原生副作用最小