前几天想解决一件小事:不想被微信电话打扰,但又不想点「拒接」——拒接之后对方看到的是「已拒绝」,
太生硬;我想要的是「忙线」,让对方以为我正占着线在打电话。

顺着这个需求逆向了一晚上,发现微信判断「本机是否在通话中」的方式,比想象中简单,也比想象中有趣。
中间还因为两个自己挖的坑,把微信搞崩过两次。

测试环境:Android 17 / 澎湃 OS 4.0,root + LSPosed,微信 8.0.76(versionCode 3140)。
本文只讨论本机客户端行为,不含任何微信代码,也不分发修改过的安装包。

先说结论

  1. 微信判断「本机在不在通话」,不查自己的状态机,而是问系统:
    AudioManager.getMode() == 2(MODE_IN_CALL)。没有电话状态权限、或 targetSdk ≤ 30 时,
    退化成 TelephonyManager.getCallState() 等于 2(OFFHOOK)或 1(RINGING)。
  2. 「忙线」和「拒接」是两条不同的信令:忙线走 /voipack 且状态 3,拒接走 /voipanswer 且 answer=2。
  3. 8.0.76 的实际来电链路跑在 native / 独立服务里,Java 层挂不到;但只要接管它对框架接口的询问,
    它自己就会走进忙线分支。

「忙线」不是「拒接」

最初的直觉是:找到微信拒绝来电的方法,把参数改掉就行。这个直觉只对了一半。

拒接走的是一条 CGI:

1
/cgi-bin/micromsg-bin/voipanswer   answer = 1 接听 / 2 拒接

而「电话占线」走的是另一条:

1
/cgi-bin/micromsg-bin/voipack      ackStatus = 3

也就是说,忙线是 ack 通道的 3,不是 answer 通道的 2。这也解释了我最开始一个想不通的现象:
把 answer 改成 3、4 试了一圈,对方看到的依然是「已拒绝」——改错了通道,怎么改都不对。

在 8.0.76 的来电处理方法 h2.h()(内部叫 doTaskCallin)里,这段逻辑是现成的:

1
2
3
4
5
if (phoneStatusWatcher.isCalling()) {
sendAck(roomId, roomKey, /* ackStatus = */ 3);
log("doTaskCallin invite ignor because telephone busy");
return; // 直接返回,不进入通话界面
}

微信自己就有「电话占线」这支分支,只等一个条件:它认为手机正在打电话。

顺便说一句混淆。微信用 R8 把外层类名全改成了 h2、c0、d3 这种,但内部类的名字会保留,
比如 NewVoipIncomingCallManager$voipListener$1,从它的 this$0 字段类型就能反推出被改名的真身。
这是读混淆代码时最好用的一个路标,比逐个方法猜快得多。

它到底怎么问系统

那个 isCalling() 是微信自带的 PhoneStatusWatcher。整段逻辑翻译过来是:

1
2
3
4
5
6
7
static boolean isCalling() {
if (!hasPermission(READ_PHONE_STATE) || targetSdkVersion <= 30) {
int s = telephonyManager.getCallState();
return s == OFFHOOK (2) || s == RINGING (1);
}
return audioManager.getMode() == 2; // 只认 MODE_IN_CALL
}

两个细节很关键:

  • 它只认 2。MODE_IN_COMMUNICATION(3)不算数。而第三方 App 最多只能把音频模式设成 3,
    所以「写个 App 伪装通话中」这条路,在不 root 的前提下根本走不通。
  • MODE_IN_CALL 只有电话应用/系统能设,TelephonyManager 的通话状态也只有电话栈能改——
    系统状态不是普通应用能伪造的。想伪造,必须拿到 root 级别的注入能力。

三次失败的尝试

既然 h2.h 里有现成的分支,直接在那里让它以为占线不就行了?实际测试非常打脸:

做法 结果
在 h2.h 里让 isCalling() 返回 true 来电时一行日志都没有,8.0.76 根本不走这条 Java 路径
追加 hook 来电管理器 c0、d3 的各个方法 点「拒绝」依然全静默
hook 新版 Flutter 通话插件 u13.t、v13.m 依然静默

转机来自一条通知的调用栈:

1
2
3
4
NotificationItem.a
└─ notification.a.a
└─ notification.x.m
└─ VoipForegroundService$$c.invoke

原来来电界面根本不是 Activity,而是一条带「接听/拒绝」按钮的通知横幅,发通知的是 voip 的前台服务。
响铃、拒绝这些动作跑在 native / 服务层,Java 侧没有可下手的判断点。

这也顺便解释了两个我一直觉得奇怪的现象:为什么 Activity.onCreate 里从来没见过来电界面,
为什么音频模式从头到尾都是 0——因为整个通话控制压根不在我们以为的那一层。

换个方向:不去找入口,接管「被问」

既然业务逻辑在 native 里,那就别再追它了。它总要通过 Java 框架知道自己「在通话中」——
与其干预它怎么处理来电,不如直接接管它对环境的查询。

在微信进程内挂三个钩子,回答「在通话中」:

1
2
3
hook(AudioManager.class, "getMode",          () -> 2);   // MODE_IN_CALL
hook(TelephonyManager.class, "getCallState", () -> 2); // OFFHOOK
hook(PhoneStatusWatcher.class, "kj", () -> true);

结果是决定性的:朋友再打进来,他手机上直接显示 「忙线未接听」,本机安静,不响铃不弹横幅。

而模块的计数器显示了一个有意思的事实:AudioManager.getMode 和 TelephonyManager.getCallState
被询问了多次,而微信自己的 PhoneStatusWatcher 命中 0 次。

也就是说,微信压根不经过自己的工具类,native 层直接问框架。
前面费劲找到的那个 l5.kj(),在这个版本里只是一段没人调用的遗留代码——
如果我一直盯着「微信自己的工具类」,可能到现在还在改那个永远不执行的判断。

两个把自己坑进去的细节

这一夜最大的收获其实不是上面那个结论,而是两次把微信搞闪退。

第一次,我把调试日志直接打在类加载器的回调里:

1
2
// 看起来无害,实际上是自杀
ClassLoader.loadClass → 记录日志 → 日志组件要加载类 → 再次触发 loadClass → …

结果是 StackOverflowError,微信启动即崩。

第二次,学乖了一点,但还是在钩子里读了配置:

1
2
3
4
5
6
7
XposedBridge.hookAllMethods(AudioManager.class, "getMode", new XC_MethodHook() {
protected void beforeHookedMethod(MethodHookParam param) {
if (readConfigFromProvider().enabled) { // 这里会走 binder、会加载类
param.setResult(2);
}
}
});

getMode() 在音频初始化时被高频调用,而读配置本身又要加载类、走 ContentProvider,
于是再次递归,微信又崩了一次。

正确的写法只有一句话:钩子体里只做一件事。

1
2
3
4
5
6
7
8
private static volatile boolean ENABLED;          // 后台线程刷新,钩子只读
private static final AtomicInteger HITS = new AtomicInteger();

@Override protected void beforeHookedMethod(MethodHookParam param) {
if (!ENABLED) return; // 不读配置、不打日志、不加载类
HITS.incrementAndGet();
param.setResult(2);
}

日志、配置、计数汇总全部交给一个后台线程,每几秒刷一次。钩子越薄,越不容易把宿主搞死。

这条路的边界

能做到:

  • 所有微信来电按「电话占线」回绝,对方看到忙线;
  • 系统电话、短信、其它 App 完全不受影响——钩子只在微信进程内生效;
  • 开关热切换,不用重启微信。

做不到:

  • 伪造一个真实的通话会话。服务端看到的仍然是「没有会话」,所以这是「让微信按占线处理」,
    而不是「让对方真的以为你在通话」。
  • 只对某一个联系人生效。判断发生在会话建立之前,那里没有联系人信息。
  • 零副作用。微信会认为自己确实在通话,期间你主动外呼可能被它自己拦下,语音消息播放也可能被暂停。

一点合规提醒

这套东西只改本机客户端行为:不伪造身份、不篡改数据包、不碰服务端。风险主要不在技术层面,而在:

  • 违反微信《软件许可及服务协议》(明确禁止外挂 / 第三方工具干扰功能)→ 封号;
  • 商业化、批量操控才会显著抬高民事乃至刑事风险。

仅记录本机客户端的行为分析与实验验证,请勿用于违反服务条款的用途。