Lenovo TB371FC 手写笔 attach 抖动的软件修复实战

Lenovo TB371FC 手写笔 attach 抖动的软件修复实战

本文记录一次 Lenovo TB371FC / Android 14 平板的系统级排障过程:手写笔充满电后持续触发“拿起、放下”逻辑,导致原厂手写笔悬浮球反复闪动,锁屏后设备也会被不断唤醒。

最终修复不是关闭蓝牙规避,而是在 framework 层修改 services.jar 中的 Lenovo 手写笔策略类:

  • 对底层笔吸附状态加入 1500ms 防抖确认。
  • 过滤不稳定的 attach/detach 抖动。
  • 去掉笔 attach/detach 与锁屏唤醒的强绑定。
  • 保留原厂 com.lenovo.penservice、悬浮球、电量和笔服务能力。

最终产物:

1
2
3
4
5
远端工作目录:hualai@192.168.1.248:~/Share/stylus-debug
Magisk 模块:parsec_monitor_input v0.3
替换文件:/system/framework/services.jar
最终 services.jar sha256:
6af5896af2bcacfa7fc4de2f71f7abda64ec8ac029f99e9dd4565425851a3af4

1. 问题背景

设备:

1
2
3
4
型号:Lenovo TB371FC
系统:Android 14 / API 34
调试主机:hualai@192.168.1.248
ADB 设备:HA1YX1J7

故障现象:

  1. 手写笔充满电后,即使没有真实拿起/放下,也会不断触发笔吸附状态变化。
  2. 原厂悬浮球反复显示和隐藏,看起来像一直闪。
  3. 设备锁屏后会被反复唤醒,无法稳定保持息屏。
  4. 关闭蓝牙后现象消失,但这会牺牲蓝牙笔的电量、按键、远程控制等功能,也影响其他蓝牙设备。

一开始可以用关闭蓝牙作为临时止血:

1
2
adb shell su -c 'svc bluetooth disable'
adb shell input keyevent KEYCODE_SLEEP

但这个方案本质是绕开问题,不是修复问题。真正要解决的是:底层 attach/detach 抖动不应该直接广播给上层服务,更不应该直接唤醒屏幕。

2. 日志定位:问题不在触摸事件,而在笔吸附状态

先在远端主机确认设备连接:

1
2
ssh hualai@192.168.1.248
adb devices -l

确认设备是 TB371FC:

1
HA1YX1J7 device usb:... product:TB371FC_PRC model:TB371FC device:TB371FC

抓取相关日志:

1
2
adb logcat -c
adb logcat -v time | grep -Ei 'BluetoothManagerPolicy|PEN_ATTACH_CHANGED|LenovoIME|PenBattery|FloatingCollapseView|wakeUp'

关键日志模式如下:

1
2
3
4
5
6
7
8
9
10
D/BluetoothManagerPolicy: onUeventReceved: address 00:00:00:00:00:00,
battery level null, chargeState Unknown, isPenAttached 0, mCurrentPenAttached 1

I/PenBatteryControllerImpl: Receive Intent ACTION = lenovo.intent.action.PEN_ATTACH_CHANGED
I/LenovoIME: Take off pen, then show floatview

D/BluetoothManagerPolicy: onUeventReceved: address 00:00:00:00:00:00,
battery level null, chargeState Unknown, isPenAttached 1, mCurrentPenAttached 0

I/LenovoIME: attach the pen, then hide floatview

这说明问题链路是:

1
2
3
4
5
底层 sysfs / uevent 报告 attached 0/1 抖动
-> framework 中 Lenovo BluetoothManagerPolicy / BluetoothPenConnectPolicy 接收状态
-> 发送 lenovo.intent.action.PEN_ATTACH_CHANGED 广播
-> com.lenovo.penservice / LenovoIME 显示或隐藏 FloatingCollapseView
-> 锁屏状态下还会走 PowerManager.wakeUp

所以这不是普通 MotionEvent、触摸屏、手写输入坐标的问题,而是系统 framework 中的手写笔吸附状态策略问题。

3. 为什么选择改 services.jar

尝试过几种方向:

  1. 关闭 com.lenovo.penservice:可以阻止悬浮球闪,但原厂笔服务也没了。
  2. 关闭蓝牙:可以阻止 attach 抖动进入蓝牙笔逻辑,但功能损失太大。
  3. 只改设置项:无法阻断系统层 PEN_ATTACH_CHANGED 和唤醒路径。
  4. 改 SystemUI:不是主因,风险更大。

日志里真正的源头类名是:

1
com.zui.server.input.styluspen.pen.bluetooth.policy.BluetoothPenConnectPolicy

这个类位于 /system/framework/services.jar 的 dex 中。它负责处理笔 attach/detach uevent,并继续广播给上层组件。所以最小有效修改点就是 services.jar

4. 提取 services.jar

在远端主机建立工作目录:

1
2
3
ssh hualai@192.168.1.248
mkdir -p ~/Share/stylus-debug
cd ~/Share/stylus-debug

从设备拉取 framework jar:

1
2
3
adb root 2>/dev/null || true
adb pull /system/framework/services.jar ./services.jar
sha256sum services.jar

原始 services.jar 记录:

1
f121281a575d9479ef6810ff909d0d30f57dcb419b77302229fac9487099a6c7  services.jar

由于 services.jar 本质是一个 zip/jar 容器,里面有多个 dex:

1
2
3
4
mkdir -p dex
cd dex
jar xf ../services.jar
ls -lh classes*.dex

本次目标类在 classes3.dex 中。

5. 准备 smali / baksmali 工具

工作目录中使用的是 smali 3.0.9 相关 jar:

1
2
3
4
smali-tools/smali-3.0.9.jar
smali-tools/smali-baksmali-3.0.9.jar
smali-tools/smali-dexlib2-3.0.9.jar
...

反编译目标 dex:

1
2
3
4
5
cd ~/Share/stylus-debug
mkdir -p smali-classes3
java -jar smali-tools/smali-baksmali-3.0.9.jar disassemble \
dex/classes3.dex \
-o smali-classes3

定位目标类:

1
find smali-classes3 -path '*BluetoothPenConnectPolicy.smali' -print

目标文件:

1
smali-classes3/com/zui/server/input/styluspen/pen/bluetooth/policy/BluetoothPenConnectPolicy.smali

备份原始 smali:

1
2
cp smali-classes3/com/zui/server/input/styluspen/pen/bluetooth/policy/BluetoothPenConnectPolicy.smali \
BluetoothPenConnectPolicy.smali.before_nowake

6. 找到需要修改的方法

用日志关键字和方法名定位:

1
2
3
4
5
grep -n 'onUeventReceved' \
smali-classes3/com/zui/server/input/styluspen/pen/bluetooth/policy/BluetoothPenConnectPolicy.smali

grep -n 'PENATTACHED\|PENNOTATTACHED\|PEN_ATTACH_CHANGED\|wakeUp' \
smali-classes3/com/zui/server/input/styluspen/pen/bluetooth/policy/BluetoothPenConnectPolicy.smali

核心方法是:

1
onUeventReceved(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V

它会:

  1. 读取 uevent 上报的笔状态。
  2. 判断 isPenAttachedmCurrentPenAttached 是否不同。
  3. 更新当前状态。
  4. 在 attach/detach 时调用 PowerManager.wakeUp(...)
  5. 构建并发送 PEN_ATTACH_CHANGED 广播。

这也是为什么日志里一旦底层 attached 在 0/1 之间抖动,上层就会一直收到“拿起/放下”。

7. 第一个补丁:去掉 attach/detach 唤醒

第一版修复先处理锁屏唤醒。原始逻辑中 attach 和 detach 分支会调用:

1
2
PowerManager.wakeUp(..., "PENATTACHED")
PowerManager.wakeUp(..., "PENNOTATTACHED")

在 smali 里把这两个 wakeUp 调用替换为跳转,让流程不再实际唤醒屏幕:

1
2
- invoke-virtual {v3, v4, v5, v2, v6}, Landroid/os/PowerManager;->wakeUp(JILjava/lang/String;)V
+ goto/16 :goto_b7

这个补丁可以解决“锁屏后不断亮屏”的核心问题,但它还没有解决亮屏状态下悬浮球闪动。因为只要 attach/detach 广播仍然发出去,com.lenovo.penservice 就会继续显示/隐藏悬浮球。

8. 第二个补丁:在 onUeventReceved 中加入 1500ms 防抖

最终方案是在 onUeventReceved(...) 的 attach 状态处理前加入防抖。

逻辑是:

  1. 如果新上报的状态与 mCurrentPenAttached 一致,说明没有真实变化,继续原流程。
  2. 如果新状态与当前状态不一致,先等待 1500ms。
  3. 等待后重新读取真实状态文件:
1
/sys/devices/virtual/misc/ctn730/attached
  1. 如果 1500ms 后状态已经变回去了,认为这次是抖动,直接丢弃。
  2. 如果状态稳定,才继续更新 mCurrentPenAttached 并发送广播。

对应插入的 smali 关键逻辑如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
iget v3, p0, Lcom/zui/server/input/styluspen/pen/bluetooth/policy/BluetoothPenConnectPolicy;->mCurrentPenAttached:I

if-eqz v0, :debounce_desired_detached

if-ne v3, p3, :debounce_changed
goto :debounce_done

:debounce_desired_detached
if-nez v3, :debounce_changed
goto :debounce_done

:debounce_changed
const-wide/16 v3, 0x5dc

:try_start_debounce
invoke-static {v3, v4}, Ljava/lang/Thread;->sleep(J)V
:try_end_debounce
.catch Ljava/lang/InterruptedException; {:try_start_debounce .. :try_end_debounce} :catch_debounce

goto :debounce_after_sleep

:catch_debounce
invoke-static {}, Ljava/lang/Thread;->currentThread()Ljava/lang/Thread;
move-result-object v3
invoke-virtual {v3}, Ljava/lang/Thread;->interrupt()V

:debounce_after_sleep
const-string v3, "/sys/devices/virtual/misc/ctn730/attached"
invoke-virtual {p0, v3}, Lcom/zui/server/input/styluspen/pen/bluetooth/policy/BluetoothPenConnectPolicy;->getFileValue(Ljava/lang/String;)Ljava/lang/String;
move-result-object v3

const-string v4, "1"
invoke-virtual {v4, v3}, Ljava/lang/String;->equals(Ljava/lang/Object;)Z
move-result v4

if-eq v4, v0, :debounce_done

; 状态不稳定,记录日志并跳过 attach 变化处理
goto/16 :cond_e0

:debounce_done

这里的 0x5dc 是十六进制的 1500,也就是 1500ms。

最终 diff 的关键部分如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@@ -3889,6 +3889,76 @@
const/4 v2, 0x0

+ iget v3, p0, Lcom/zui/server/input/styluspen/pen/bluetooth/policy/BluetoothPenConnectPolicy;->mCurrentPenAttached:I
+ ...
+ const-wide/16 v3, 0x5dc
+ invoke-static {v3, v4}, Ljava/lang/Thread;->sleep(J)V
+ ...
+ const-string v3, "/sys/devices/virtual/misc/ctn730/attached"
+ invoke-virtual {p0, v3}, ...->getFileValue(Ljava/lang/String;)Ljava/lang/String;
+ ...
+ if-eq v4, v0, :debounce_done
+ ...
+ goto/16 :cond_e0
+
+ :debounce_done
if-eqz v0, :cond_71

为什么选择 1500ms:

  • 太短,比如 100-300ms,可能挡不住充满电后的机械/驱动抖动。
  • 太长,比如 3-5s,会让真实拿起/放下反馈明显变慢。
  • 1500ms 是一个偏保守但仍可接受的折中点。如果后续仍偶发闪动,可以提升到 2000-3000ms。

9. 重新编译 dex

修改 smali 后重新编译 classes3.dex

1
2
3
4
5
6
cd ~/Share/stylus-debug
java -jar smali-tools/smali-3.0.9.jar assemble \
smali-classes3 \
-o classes3.debounce.dex

sha256sum classes3.dex classes3.debounce.dex

本次记录:

1
2
bb202b2143cc7b1728f72d703ba0e1ad013b8efcf9a11d060b5cf8a10b6edc24  classes3.dex
33c8d163c6d0c4adb147eb203de4dabdd2d38f2f87ee18ac196ced5effb8475b classes3.debounce.dex

10. 重新打包 services.jar

不要只把一个 dex 单独推到设备。services.jar 需要保持 jar 容器结构,把原 jar 的其他 dex 和 META-INF 一起保留,只替换 classes3.dex

准备重打包目录:

1
2
3
4
5
6
7
cd ~/Share/stylus-debug
rm -rf jar-debounce
mkdir -p jar-debounce
cd jar-debounce
jar xf ../services.jar
cp ../classes3.debounce.dex classes3.dex
jar cf ../services.debounce.jar .

校验:

1
2
3
cd ~/Share/stylus-debug
jar tf services.debounce.jar | grep '^classes'
sha256sum services.debounce.jar

最终 services.debounce.jar

1
6af5896af2bcacfa7fc4de2f71f7abda64ec8ac029f99e9dd4565425851a3af4  services.debounce.jar

11. 为什么用 Magisk 模块替换

直接 remount /system 修改 framework 风险高,而且 Android 14 的动态分区、只读分区和 AVB 校验会让直接替换更麻烦。

设备已有 root / Magisk 环境,所以更稳妥的方式是做一个 Magisk 模块,用 systemless overlay 替换:

1
/system/framework/services.jar

模块目录:

1
2
3
4
5
6
magisk-module-debounce/
module.prop
customize.sh
system/
framework/
services.jar

module.prop 内容:

1
2
3
4
5
6
id=parsec_monitor_input
name=Parsec MONITOR_INPUT + stylus debounce framework patch
version=0.3
versionCode=3
author=OpenClaw
summary=Framework patch for Parsec immersive input plus Lenovo stylus attach debounce/wake suppression

customize.sh 内容:

1
2
3
4
SKIPMOUNT=false
PROPFILE=false
POSTFSDATA=false
LATESTARTSERVICE=false

复制 jar:

1
2
3
cd ~/Share/stylus-debug
mkdir -p magisk-module-debounce/system/framework
cp services.debounce.jar magisk-module-debounce/system/framework/services.jar

打包模块:

1
2
cd ~/Share/stylus-debug/magisk-module-debounce
zip -r ../parsec_monitor_input_v0.3_stylus_debounce.zip .

记录 zip:

1
78c057a2a9821030171cbfe6a9f1bd16f2396cfe44b0fa8d9479b4279c93dc26  parsec_monitor_input_v0.3_stylus_debounce.zip

12. 安装 Magisk 模块

推送模块到设备:

1
2
cd ~/Share/stylus-debug
adb push parsec_monitor_input_v0.3_stylus_debounce.zip /data/local/tmp/

通过 Magisk 安装:

1
2
adb shell su -c 'magisk --install-module /data/local/tmp/parsec_monitor_input_v0.3_stylus_debounce.zip'
adb reboot

重启后检查实际生效文件:

1
adb shell su -c 'sha256sum /system/framework/services.jar'

应当看到:

1
6af5896af2bcacfa7fc4de2f71f7abda64ec8ac029f99e9dd4565425851a3af4  /system/framework/services.jar

13. 恢复原厂笔服务

早期为了止血,曾经临时禁用过 com.lenovo.penservice 和关键组件。framework 层修复完成后,需要恢复它们,否则悬浮球、电量和笔服务不会完整工作。

启用包和组件:

1
2
3
4
5
6
adb shell su -c 'pm enable com.lenovo.penservice'
adb shell su -c 'pm enable com.lenovo.penservice/com.lenovo.pen.bt.service.FloatService'
adb shell su -c 'pm enable com.lenovo.penservice/com.lenovo.penservice.service.ViewEventService'
adb shell su -c 'pm enable com.lenovo.penservice/com.lenovo.penservice.ime.LenovoIME'
adb shell su -c 'pm enable com.lenovo.penservice/com.lenovo.penservice.receiver.BootBroadcastReceiver'
adb shell su -c 'pm enable com.lenovo.penservice/com.lenovo.penservice.receiver.EasyJotReceiver'

恢复笔相关设置,具体 key 会因系统版本不同而不同,本次做法是把之前关闭的笔设置恢复为 1:

1
2
3
4
adb shell settings put system stylus_floating_ball 1
adb shell settings put system stylus_screen_off_note 1
adb shell settings put system stylus_button_note 1
adb shell settings put system stylus_global_handwriting 1

给悬浮窗权限:

1
adb shell su -c 'appops set com.lenovo.penservice SYSTEM_ALERT_WINDOW allow'

启动悬浮球服务:

1
adb shell su -c 'am startservice -n com.lenovo.penservice/com.lenovo.pen.bt.service.FloatService'

注意不要用:

1
adb shell su -c 'am start-foreground-service -n com.lenovo.penservice/com.lenovo.pen.bt.service.FloatService'

原因是这个服务没有及时调用 startForeground(),Android 会抛出 ForegroundServiceDidNotStartInTimeException,导致悬浮球服务被系统杀掉并重建。

14. 验证修复

14.1 检查服务和窗口

1
2
adb shell dumpsys activity services com.lenovo.penservice | grep -E 'FloatService|ViewEventService'
adb shell dumpsys window | grep -E 'FloatingCollapseView|com.lenovo.penservice'

修复后可以看到:

1
2
FloatService 正在运行
WindowManager 中存在 FloatingCollapseView

14.2 检查没有广播刷屏

1
2
3
adb logcat -c
sleep 15
adb logcat -d | grep -Ei 'PEN_ATTACH_CHANGED|Take off pen|attach the pen|debounce drop|PENATTACHED|PENNOTATTACHED|wakeUp'

预期:

  • 不再出现 PEN_ATTACH_CHANGED 高频刷屏。
  • 不再出现 Take off pen, then show floatview / attach the pen, then hide floatview 循环。
  • 不再出现 PENATTACHED / PENNOTATTACHED 唤醒路径。
  • 如果底层仍抖动,可能看到 debounce drop unstable attach,这说明 framework 防抖正在丢弃不稳定状态。

14.3 锁屏稳定性验证

1
2
3
adb shell input keyevent KEYCODE_SLEEP
sleep 30
adb shell dumpsys power | grep -E 'mWakefulness|Display Power'

修复后的验证结果:

1
mWakefulness=Dozing

设备可以保持息屏,不再被笔 attach/detach 抖动反复唤醒。

15. 回滚方法

如果 framework 补丁导致系统异常,优先从 Magisk 恢复。

方法一:Magisk 管理器禁用模块后重启。

方法二:ADB root 下删除模块目录。模块 id 是 parsec_monitor_input

1
2
adb shell su -c 'rm -rf /data/adb/modules/parsec_monitor_input'
adb reboot

如果设备无法正常进系统,需要进入 Magisk 安全模式或 recovery 环境删除同一目录。

16. 本次排障的关键判断

这次问题能修对,关键是没有停留在“悬浮球闪”这个表象,而是顺着日志往下追:

1
2
3
4
5
FloatingCollapseView 闪动
-> LenovoIME show/hide
-> PEN_ATTACH_CHANGED 广播
-> BluetoothPenConnectPolicy.onUeventReceved
-> /sys/devices/virtual/misc/ctn730/attached 抖动

如果只处理上层 UI,例如禁用悬浮球、杀掉笔服务,确实能让现象消失,但手写笔功能也会被破坏。

如果只关闭蓝牙,也能让现象消失,但本质是阻断了整个蓝牙笔通道。

更合理的修复点是在 framework 收到 attach 状态后做稳定性确认:真实拿起/放下可以继续广播,短时间抖动则丢弃。这样既保留原厂笔服务,又避免底层噪声直接驱动 UI 和电源管理。

17. 最终状态

最终设备状态:

1
2
3
4
5
6
7
8
蓝牙:开启
com.lenovo.penservice:启用
FloatService:运行
ViewEventService:启用
LenovoIME:启用
SYSTEM_ALERT_WINDOW:allow
Magisk 模块:parsec_monitor_input v0.3
framework services.jar:已替换为 debounce 版本

最终效果:

  • 原厂手写笔服务保留。
  • 悬浮球可以显示,并且不再反复闪动。
  • 锁屏后设备可以稳定保持 Dozing。
  • 底层 attach/detach 抖动仍可能存在,但被 framework 层过滤,不再向上层广播成连续“拿起/放下”。

这类问题的通用经验是:遇到厂商定制硬件状态抖动时,先用日志定位广播源头和 framework 类,再判断是否适合做 debounce。不要急着改 UI,也不要把关闭硬件通道当成最终修复。