Commit 93b22c420763bd4a8cffcc3eea8a61e746ee3e6e
1 parent
a4c2c52d
feat(wechathook): 微信内部 API 发送 — 文字✅ / 图片🟢突破 / 语音 hot-swap,附文档+skill+备份
逆向微信 8.0.74 内部发送 API(LSPosed,无 UI 模拟):
- 文字(中英文): w11.r0(type=1) Object-ctor + z2.b NetSceneQueue,真机验证送达。
中文走 text_b64 base64 通道避免 shell 损坏 UTF-8。避开 dk5.s5.Ni(实为发名片)。
- 图片: hook 真实发送抓 o70.h/o70.k/o70.i 原样重放,攻克消息构建——
能建 type=3 图片消息、缩略图渲染、进 pt0.e0 管线;全图 CDN 上传是最后一公里。
- 语音: jg0.x.w hot-swap(真机验证投递)。
- wxid 解析: EnMicroMsg.db SQLCipher 解密(key=MD5(imei+uin)[:7])查 rcontact。
- 坑: 真实代码在 Tinker 补丁 CL(hook 用 sTinkerCL);force-stop 不 flush 加密 WAL。
产物: WECHAT_SEND_API.md(完整逆向文档)+ skill_wechat_send/SKILL.md
+ backups/(WeChatHook.java 快照 + 构建/发送脚本)。
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Showing
7 changed files
with
5154 additions
and
118 deletions
xhs_lsposed_a16_fix/WECHAT_SEND_API.md
0 → 100644
| 1 | +# 微信 8.0.74 内部发送 API 逆向全景(文字 / 语音 / 图片) | ||
| 2 | + | ||
| 3 | +> 通过 LSPosed 模块 `wechathook` 调用微信自己的内部发送 API,**无 UI 模拟点击**。 | ||
| 4 | +> 逆向自微信 **8.0.74(versionCode 3120)** on Pixel(Android 16 / A16)。 | ||
| 5 | +> 源码 `wechathook_src/WeChatHook.java`,构建 `rebuild_wechathook.sh`,发送 CLI `wechat_send.sh`。 | ||
| 6 | + | ||
| 7 | +## TL;DR 能力矩阵 | ||
| 8 | + | ||
| 9 | +| 能力 | 状态 | 广播接口 | | ||
| 10 | +|---|---|---| | ||
| 11 | +| **文字(中英文)** | ✅ 完全可用,真机验证送达 | `SEND_MSG_INTERNAL --es to <wxid> --es text <文字>` 或 `--es text_b64 <base64>` | | ||
| 12 | +| **语音(TTS→SILK)** | ✅ 半自动 hot-swap(真机验证投递) | `ARM_VOICE_SWAP --es src_path <SILK> --ei duration <ms>` + 触发录音 | | ||
| 13 | +| **图片** | 🟢 消息建成+缩略图渲染+进管线,全图 CDN 上传是最后一公里 | `SEND_IMAGE_INTERNAL --es to <wxid> --es path <路径>` | | ||
| 14 | + | ||
| 15 | +--- | ||
| 16 | + | ||
| 17 | +## 0. 通用基础设施 | ||
| 18 | + | ||
| 19 | +### 0.1 wxid 解析(nickname → username) | ||
| 20 | +内部 API 认 **wxid(username)**,不是昵称。从解密的 `EnMicroMsg.db` 查: | ||
| 21 | + | ||
| 22 | +``` | ||
| 23 | +key = MD5("1234567890ABCDEF" + uin)[:7] # imei 用微信默认值 | ||
| 24 | +本机: uin=215004919 → key=e03ce81 | ||
| 25 | +SQLCipher: cipher_compatibility=1, cipher_page_size=1024 | ||
| 26 | +uin 来源: /data/data/com.tencent.mm/shared_prefs/*.xml 里 last_login_uin | ||
| 27 | +``` | ||
| 28 | + | ||
| 29 | +```bash | ||
| 30 | +# Mac: brew install sqlcipher | ||
| 31 | +sqlcipher EnMicroMsg.db | ||
| 32 | +> PRAGMA key='e03ce81'; PRAGMA cipher_compatibility=1; PRAGMA cipher_page_size=1024; | ||
| 33 | +> SELECT username,nickname FROM rcontact WHERE nickname LIKE '%火星人%'; | ||
| 34 | +# → yibooo | 火星人 | ||
| 35 | +``` | ||
| 36 | + | ||
| 37 | +解密逻辑完整实现见 `lifelog-recorder/.../vision/WeChatDb.kt`。 | ||
| 38 | + | ||
| 39 | +### 0.2 Tinker classloader 坑(关键) | ||
| 40 | +微信真正跑的代码在 **Tinker 补丁** 里(`/data/user/0/com.tencent.mm/tinker/patch-*/dex/tinker_classN.apk`,~174MB), | ||
| 41 | +**混淆字段名与 base.apk 不同**。反编译要拉 tinker dex: | ||
| 42 | + | ||
| 43 | +```bash | ||
| 44 | +adb shell su -c 'cp /data/user/0/com.tencent.mm/tinker/patch-*/dex/tinker_classN.apk /sdcard/tinkerN.apk' | ||
| 45 | +adb pull /sdcard/tinkerN.apk | ||
| 46 | +jadx --single-class <cls> --no-res -d out tinkerN.apk | ||
| 47 | +# 定位含某字符串的类:dexdump -d classesN.dex | 解析 Class descriptor + const-string | ||
| 48 | +``` | ||
| 49 | + | ||
| 50 | +**hook 必须在 `sTinkerCL` 上做**(真实发送跑在 tinker CL,base CL hook 不触发)。 | ||
| 51 | +`App.onCreate` 里对每个 hook 都要 `hookXxx(sTinkerCL, procName+"/tinker")`。 | ||
| 52 | + | ||
| 53 | +### 0.3 验证陷阱 | ||
| 54 | +- `am force-stop com.tencent.mm` 是 SIGKILL,**不 flush WCDB 加密 WAL**;sqlcipher 读不了加密 WAL | ||
| 55 | + → force-stop 后查主库永远停在旧 checkpoint。**验证送达靠实时 UI 截图 / 肉眼确认,别靠 force-stop 后查库**。 | ||
| 56 | +- LSPosed 日志文件会滚动(大 dump 触发),grep 前先 `ls -t` 找当前文件。 | ||
| 57 | + | ||
| 58 | +--- | ||
| 59 | + | ||
| 60 | +## 1. 文字发送 ✅ | ||
| 61 | + | ||
| 62 | +### 用法 | ||
| 63 | +```bash | ||
| 64 | +# 英文/ASCII | ||
| 65 | +adb shell su -c 'am broadcast -a com.hermes.wechat.SEND_MSG_INTERNAL --es to yibooo --es text "hello"' | ||
| 66 | +# 中文(base64 避免 shell 损坏 UTF-8) | ||
| 67 | +B64=$(python3 -c "import base64;print(base64.b64encode('你好火星人'.encode()).decode())") | ||
| 68 | +adb shell su -c "am broadcast -a com.hermes.wechat.SEND_MSG_INTERNAL --es to yibooo --es text_b64 '$B64'" | ||
| 69 | +``` | ||
| 70 | + | ||
| 71 | +### 内部实现(`sendViaHighLevel`) | ||
| 72 | +``` | ||
| 73 | +w11.r0(talker, content, type=1, flag=0, Object=null) # Object-构造器插入 f9 | ||
| 74 | + → com.tencent.mm.modelbase.z2.b(scene) # 入 NetSceneQueue(真实网络派发) | ||
| 75 | +``` | ||
| 76 | + | ||
| 77 | +**关键点 / 踩过的坑**: | ||
| 78 | +- 必须用 **Object-构造器**(第5参 Object);long-构造器和 builder 路径 log OK 但**不入库**。 | ||
| 79 | +- `dk5.s5.Ni` **是发名片的**(`db.b` = `com.tencent.mm.ui.contact.db` 联系人卡片生成器),不是发文字! | ||
| 80 | + 用它会得到 type=42 空名片失败消息。别用。 | ||
| 81 | +- `w11.s1.b(IMAGE/TEXT,...)` 4参形式建的 r1 不完整,`n1.a()` NPE。别用于文字。 | ||
| 82 | +- `z2.b(m1)` 是 `IGetNetSceneQueue`,内部 `.d(scene)` 入队,绕开硬编码混淆字段名。 | ||
| 83 | + | ||
| 84 | +--- | ||
| 85 | + | ||
| 86 | +## 2. 语音发送 ✅(半自动 hot-swap) | ||
| 87 | + | ||
| 88 | +纯合成(newsendmsg/r0/z2.b)永远卡「发送中」——音频上传锁死在 `jg0.x.w` Kotlin 协程里。 | ||
| 89 | +**绕过方案**:让微信真录一段,在 CDN 上传步偷换 SILK 字节。 | ||
| 90 | + | ||
| 91 | +``` | ||
| 92 | +偷换点 = jg0.x.w(self, qi3.d0, w21.w0, int type=4, int durMs, w21.t silk, Continuation) | ||
| 93 | + arg5 w21.t 只有一个 byte[] 字段 = 明文 SILK 音频 | ||
| 94 | +广播 ARM_VOICE_SWAP 装弹 → input swipe 触发录音键长按 → 偷换在 hookVoiceProbe 的 w 分支 | ||
| 95 | +``` | ||
| 96 | + | ||
| 97 | +完整细节见 memory `wechat-voice-send-internals.md` 和 `tts_voice_swap.sh`。 | ||
| 98 | + | ||
| 99 | +--- | ||
| 100 | + | ||
| 101 | +## 3. 图片发送 🟢(消息建成,CDN 上传是最后一公里) | ||
| 102 | + | ||
| 103 | +### 用法(已能发出图片消息+缩略图,全图上传待完成) | ||
| 104 | +```bash | ||
| 105 | +adb shell su -c 'am broadcast -a com.hermes.wechat.SEND_IMAGE_INTERNAL --es to filehelper --es path /sdcard/Pictures/x.png' | ||
| 106 | +``` | ||
| 107 | + | ||
| 108 | +### 完整发图链路(hook 真实发送三次抓取 + tinkerN.apk 反编译) | ||
| 109 | +``` | ||
| 110 | +真实发图 insert 栈: | ||
| 111 | + b80.u.oj(o70.k) ← 插库+发送 (返 ArrayList) | ||
| 112 | + ← b80.s.invokeSuspend ← = MicroMsg.ImgUpload.SendImgPreFeatureService (只做 prebuild) | ||
| 113 | + ← SendImgProxyUI / gallery | ||
| 114 | + | ||
| 115 | +图片插件 b80.u (implements o70.e, = i95.n0.c(o70.e.class)): | ||
| 116 | + pj(o70.h) prebuild: 压缩 + 缩略图, stage 进 r6 文件夹 | ||
| 117 | + Bi(o70.h, String) 文件存在检查 (返 boolean) | ||
| 118 | + oj(o70.k) 插库 + 发送 (返 ArrayList) | ||
| 119 | + nj(o70.h, int) 进度回调 | ||
| 120 | +``` | ||
| 121 | + | ||
| 122 | +### 关键数据结构(字段都是单字母,混淆) | ||
| 123 | +```java | ||
| 124 | +o70.k(r6 preBuildFolder) // 批量发送对象 | ||
| 125 | + a = r6 folder | ||
| 126 | + b = UUID clientId (auto) | ||
| 127 | + c = talker (toUserName) | ||
| 128 | + d = source (真实用 3) | ||
| 129 | + f = compressType | ||
| 130 | + g = List<o70.i> // 图片列表 | ||
| 131 | + i = HashMap extras | ||
| 132 | + | ||
| 133 | +o70.i(String imgPath, Uri, Long imgId) // 单张图片 | ||
| 134 | + a = 路径, b = Uri, c = imgId | ||
| 135 | + | ||
| 136 | +o70.h(r6) // PreBuildParams (pj 的输入) | ||
| 137 | + e = imgPath, g = talker, k = Uri, b(方法) = clientId | ||
| 138 | + c = source, f = imgId, h = compressType | ||
| 139 | + | ||
| 140 | +r6 folder = lp0.b.h0(gm0.j1.b().j() + "_MsgImgUploadFSC") // = g80.o.c7() | ||
| 141 | +``` | ||
| 142 | + | ||
| 143 | +### 已实现的重放(`sendImageViaAPI`) | ||
| 144 | +``` | ||
| 145 | +取 b80.u 插件 + r6 folder | ||
| 146 | +→ 建 o70.k{c=talker, d=3, g=[o70.i(path, fileUri, 0)]} | ||
| 147 | +→ 建 o70.h (同步字段: e=path, g=talker, k=fileUri, c=3, clientId) | ||
| 148 | +→ Bi(h,"thumb.jpg") 否则 pj(h) + nj(h,10000) # prebuild | ||
| 149 | +→ oj(k) # 插库+发送 | ||
| 150 | +真机: oj 返回 size=1, convertTo msgtype=3 触发, pt0.e0.h 触发, 缩略图渲染 ✅ | ||
| 151 | + 但全图 CDN 上传转圈不完成 ❌ | ||
| 152 | +``` | ||
| 153 | + | ||
| 154 | +### 剩余:图片 CDN 上传最后一公里 | ||
| 155 | +- `b80.s` 只是 prebuild service(`checkPreBuildImg`),**不含 oj**;图片字节 CDN 上传是独立异步服务(log tag `MicroMsg.ImgUpload`)。我们同步 pj+oj 没把它拉起来 / 没跑完。 | ||
| 156 | +- 试过 uri=null→fileUri、source 0→3,均未解决。imgId=0(真实是 MediaStore id)。 | ||
| 157 | +- **下一步**:① hook `MicroMsg.ImgUpload` 上传 NetScene(像语音摸 `w21.g1` 那样)看真实发图怎么触发上传;② 或让流程跑在 worker 线程/协程(真实 prebuild 在 worker,我们是同步 main);③ 或 imgId 用真实 MediaStore id(把文件插 MediaStore 拿 content://+id)。 | ||
| 158 | + | ||
| 159 | +--- | ||
| 160 | + | ||
| 161 | +## 构建 / 部署 | ||
| 162 | + | ||
| 163 | +```bash | ||
| 164 | +export ADB=/opt/homebrew/share/android-commandlinetools/platform-tools/adb | ||
| 165 | +export ANDROID_HOME=/opt/homebrew/share/android-commandlinetools | ||
| 166 | +export JAVA_HOME=/opt/homebrew/opt/openjdk@17 | ||
| 167 | +bash rebuild_wechathook.sh # 编译 + d8 + 签名 + pm install -r + 重启微信(无需 reboot) | ||
| 168 | +``` | ||
| 169 | + | ||
| 170 | +⚠️ 反复 `pm install -r` 模块可能搞断 LSPosed 注入 → 重启设备 + `pm install-existing com.tencent.mm` 修复(非破坏性,不掉登录)。 | ||
| 171 | + | ||
| 172 | +## 相关文件 | ||
| 173 | +- `wechathook_src/WeChatHook.java` — hook 源码(2981 行,文字/语音/图片全在里面) | ||
| 174 | +- `backups/WeChatHook_*_text+voice+image.java` — 本文档对应的代码快照 | ||
| 175 | +- memory `wechat-voice-send-internals.md` — 更细的逆向记录 + 踩坑史 | ||
| 176 | +- `tts_voice_swap.sh` — 语音 hot-swap 端到端脚本 |
xhs_lsposed_a16_fix/backups/WeChatHook_20260706_text+voice+image.java
0 → 100644
| 1 | +package com.example.wechathook; | ||
| 2 | + | ||
| 3 | +import android.app.Activity; | ||
| 4 | +import android.content.BroadcastReceiver; | ||
| 5 | +import android.content.Context; | ||
| 6 | +import android.content.Intent; | ||
| 7 | +import android.content.IntentFilter; | ||
| 8 | +import android.os.Bundle; | ||
| 9 | +import android.os.Handler; | ||
| 10 | +import android.os.Looper; | ||
| 11 | +import android.view.View; | ||
| 12 | +import java.lang.reflect.Proxy; | ||
| 13 | +import de.robv.android.xposed.IXposedHookLoadPackage; | ||
| 14 | +import de.robv.android.xposed.XC_MethodHook; | ||
| 15 | +import de.robv.android.xposed.XposedBridge; | ||
| 16 | +import de.robv.android.xposed.XposedHelpers; | ||
| 17 | +import de.robv.android.xposed.callbacks.XC_LoadPackage; | ||
| 18 | + | ||
| 19 | +public class WeChatHook implements IXposedHookLoadPackage { | ||
| 20 | + | ||
| 21 | + private static final String ACTION_SEND = "com.hermes.wechat.SEND_MSG_INTERNAL"; | ||
| 22 | + private static final String ACTION_SEND_IMAGE = "com.hermes.wechat.SEND_IMAGE_INTERNAL"; | ||
| 23 | + private static final String ACTION_SEND_VOICE = "com.hermes.wechat.SEND_VOICE_INTERNAL"; | ||
| 24 | + private static final String ACTION_ARM_SWAP = "com.hermes.wechat.ARM_VOICE_SWAP"; | ||
| 25 | + private static final String ACTION_DUMP_API = "com.hermes.wechat.DUMP_WECHAT_API"; | ||
| 26 | + | ||
| 27 | + // ── Voice hot-swap state (the route that actually delivers) ──────────────── | ||
| 28 | + // Fully-synthetic voice (SEND_VOICE_INTERNAL) can build the message shell but | ||
| 29 | + // can never upload the SILK to WeChat's CDN (upload is locked inside jg0.x's | ||
| 30 | + // Kotlin coroutine). So instead we let the user trigger a REAL recording (hold | ||
| 31 | + // "按住说话") and intercept jg0.x.w — the CDN uploadAttach step — replacing the | ||
| 32 | + // recorded SILK bytes (w21.t.a) with our TTS SILK. WeChat's own machinery then | ||
| 33 | + // uploads + delivers our audio. Armed by ARM_VOICE_SWAP; consumed once. | ||
| 34 | + private static volatile boolean sSwapArmed = false; | ||
| 35 | + private static volatile byte[] sSwapSilk = null; // TTS SILK bytes to inject | ||
| 36 | + private static volatile int sSwapDuration = 0; // ms, replaces the recorded duration | ||
| 37 | + // Safety: an arm auto-expires at this wall-clock time (set to arm + audio duration | ||
| 38 | + // + 10s slack). The swap fires only on release AFTER a full hold, so the window must | ||
| 39 | + // outlast the hold; past it, a stale arm silently dies instead of hijacking a later | ||
| 40 | + // unrelated recording. | ||
| 41 | + private static volatile long sSwapExpiresAt = 0; | ||
| 42 | + // Cached f9 setter map (column -> methodName), built once on first voice send | ||
| 43 | + // (sF9SetterMap is declared alongside probeAllF9Setters below) | ||
| 44 | + // Thread-locals for voice send: voiceId and SILK bytes to inject into f9 ctor | ||
| 45 | + private static final ThreadLocal<String> sPendingVoiceId = new ThreadLocal<>(); | ||
| 46 | + private static final ThreadLocal<byte[]> sPendingLvBuffer = new ThreadLocal<>(); | ||
| 47 | + // Captures the f9 that called convertTo() (= the one written to DB); used to populate r0.i. | ||
| 48 | + private static final ThreadLocal<Object> sDatabaseF9 = new ThreadLocal<>(); | ||
| 49 | + private static ClassLoader sWeChatCL; // pre-Tinker classloader (from handleLoadPackage) | ||
| 50 | + private static volatile ClassLoader sTinkerCL; // Tinker-patched classloader (from App.onCreate) | ||
| 51 | + private static volatile Context sAppContext; | ||
| 52 | + private static volatile boolean sFsGReady = false; | ||
| 53 | + // Set true while WE are calling newInstance so r0Hook can distinguish us from WeChat | ||
| 54 | + private static volatile boolean sCalledByUs = false; | ||
| 55 | + // Network dispatcher captured from a real WeChat r0 at H()-entry; needed by CDN upload | ||
| 56 | + private static volatile Object sCapturedDispatcher = null; | ||
| 57 | + private static volatile Object sPt0E0Instance = null; // pt0.e0 singleton, captured from r() hook | ||
| 58 | + private static volatile Object sImgPlugin = null; // b80.u instance, captured from pj/Bi hook | ||
| 59 | + private static volatile Object sRealImgParams = null; // last real o70.h, captured for replication | ||
| 60 | + private static volatile Object sRealImgK = null; // last real o70.k (oj insert object) | ||
| 61 | + private static final ThreadLocal<Long> sPendingMsgLocalId = new ThreadLocal<>(); // msgId from DB insert | ||
| 62 | + // Logged-in user's wxid, auto-discovered from first real voice message send | ||
| 63 | + private static volatile String sMyWxid = null; | ||
| 64 | + // Pending send: stored when broadcast arrives before gm0.j1 is initialized | ||
| 65 | + private static volatile String sPendingTalker = null; | ||
| 66 | + private static volatile String sPendingText = null; | ||
| 67 | + private static volatile String sPendingProc = null; | ||
| 68 | + | ||
| 69 | + @Override | ||
| 70 | + public void handleLoadPackage(XC_LoadPackage.LoadPackageParam lpparam) throws Throwable { | ||
| 71 | + if (!"com.tencent.mm".equals(lpparam.packageName)) return; | ||
| 72 | + // Use android.util.Log directly so it appears even before VectorLegacyBridge is ready | ||
| 73 | + android.util.Log.i("WeChatHookEarly", "INIT proc=" + lpparam.processName | ||
| 74 | + + " pid=" + android.os.Process.myPid()); | ||
| 75 | + XposedBridge.log("[WeChatHook] Injected into WeChat " + lpparam.processName | ||
| 76 | + + " pid=" + android.os.Process.myPid()); | ||
| 77 | + ClassLoader cl = lpparam.classLoader; | ||
| 78 | + sWeChatCL = cl; | ||
| 79 | + | ||
| 80 | + // Tablet login hooks — DISABLED. User switched to normal direct (primary) | ||
| 81 | + // login. The bk.A/B tablet-spoof would make WeChat think it's a tablet | ||
| 82 | + // mid-session and can re-trigger the beta "体验资格" gate on this test build, | ||
| 83 | + // kicking the login. Keep them off so restarts don't disturb direct login. | ||
| 84 | + // hookInTabletEnv(cl); | ||
| 85 | + // hookProbe(cl); | ||
| 86 | + // hookLoginHistoryUIInitView(cl); | ||
| 87 | + // hookLoginUIInitView(cl); | ||
| 88 | + // hookLoginSelectUICheck(cl); | ||
| 89 | + // hookLoginButtonClick(cl); | ||
| 90 | + | ||
| 91 | + // WCDB diagnostic: which process writes? + media ContentValues dump | ||
| 92 | + hookWCDB(cl, lpparam.processName); | ||
| 93 | + | ||
| 94 | + // Factory method discovery: dumps w11.s1 signatures + hooks all static methods | ||
| 95 | + // so that when WeChat UI sends image/voice we capture the exact API call. | ||
| 96 | + hookFactoryMethods(cl, lpparam.processName); | ||
| 97 | + | ||
| 98 | + // MediaPlayer hook: captures voice file paths when WeChat plays voice messages. | ||
| 99 | + // This reveals the voice2/{dir1}/{dir2}/msg_{id}.amr naming formula. | ||
| 100 | + hookMediaPlayer(lpparam.processName); | ||
| 101 | + | ||
| 102 | + // Hook gm0.j1.g() to detect when DI kernel is ready — runs in ALL processes. | ||
| 103 | + // This enables the pending-send mechanism: if broadcast arrives before DI init, | ||
| 104 | + // we store the args and retry once g() fires. | ||
| 105 | + hookDIInit(cl, lpparam.processName); | ||
| 106 | + | ||
| 107 | + // Send hook in ALL WeChat processes — we need to find which one can actually send. | ||
| 108 | + // The broadcast receiver registered in each process will all receive the intent; | ||
| 109 | + // whichever process has gm0.j1 initialized will succeed. | ||
| 110 | + hookSendMessage(cl, lpparam.processName); | ||
| 111 | + | ||
| 112 | + // Hook w11.r0.H(String) to trace CDN upload: log r0 String fields + file access. | ||
| 113 | + hookR0H(cl, lpparam.processName); | ||
| 114 | + | ||
| 115 | + // Capture the live network dispatcher com.tencent.mm.modelbase.m2 (MicroMsg.RDispatcher) | ||
| 116 | + // at construction. Synthetic voice sends need the REAL m2 to actually reach the | ||
| 117 | + // network; without it we'd fall back to a fake $Proxy that never sends. | ||
| 118 | + hookDispatcher(cl, lpparam.processName); | ||
| 119 | + | ||
| 120 | + // Instrument the voice-upload service w21.g1 (voiceinfo table): on a REAL voice | ||
| 121 | + // send, dump the exact w21.w0 upload-task fields so we can replicate them for | ||
| 122 | + // synthetic voice (the last missing step = CDN audio upload). | ||
| 123 | + hookVoiceUpload(cl, lpparam.processName); | ||
| 124 | + | ||
| 125 | + // Image send: hook the non-suspend plugin methods b80.u.pj/Bi (o70.h) — the | ||
| 126 | + // actual image-send entry the coroutine calls. Capture the b80.u instance + | ||
| 127 | + // dump the full o70.h a REAL send builds, so we can replicate it directly. | ||
| 128 | + hookImageSend(cl, lpparam.processName); | ||
| 129 | + // Capture the SendImgProxyUI launch Intent (real gallery send goes through it), | ||
| 130 | + // so we can replay the whole real send flow via `am start`. | ||
| 131 | + hookSendImgProxyUI(cl, lpparam.processName); | ||
| 132 | + | ||
| 133 | + // Hook pt0.e0 — the voice-send coroutine class found in the stack trace. | ||
| 134 | + // Real voice sends don't use r0.H(); we need to find where CDN upload actually fires. | ||
| 135 | + hookPt0E0(cl, lpparam.processName); | ||
| 136 | + | ||
| 137 | + // Surgical voice-send entry probe (jg0.x + qi3.f0/g0 only). On this fresh | ||
| 138 | + // reinstall the send runs in the base CL (no Tinker patch yet), so hook here. | ||
| 139 | + hookVoiceProbe(cl, lpparam.processName); | ||
| 140 | + | ||
| 141 | + // Voice hot-swap: intercept jg0.x.w (CDN uploadAttach) to replace the recorded | ||
| 142 | + // SILK with our TTS audio during a real (user- or A11y-triggered) recording. | ||
| 143 | + // This is the ONE route that actually delivers synthetic voice. | ||
| 144 | + hookVoiceSwap(cl, lpparam.processName); | ||
| 145 | + } | ||
| 146 | + | ||
| 147 | + // ── WCDB hook ────────────────────────────────────────────────────────────── | ||
| 148 | + // Intercepts every INSERT into the message table. | ||
| 149 | + // For msgtype 3 (image) and 34 (voice) logs the full ContentValues so we can | ||
| 150 | + // reverse-engineer the exact schema WeChat uses when building those messages. | ||
| 151 | + private void hookWCDB(ClassLoader cl, String procName) { | ||
| 152 | + try { | ||
| 153 | + XposedHelpers.findAndHookMethod( | ||
| 154 | + "com.tencent.wcdb.database.SQLiteDatabase", cl, | ||
| 155 | + "insertWithOnConflict", | ||
| 156 | + String.class, String.class, android.content.ContentValues.class, int.class, | ||
| 157 | + new XC_MethodHook() { | ||
| 158 | + private boolean textLogged = false; | ||
| 159 | + @Override | ||
| 160 | + protected void beforeHookedMethod(MethodHookParam param) throws Throwable { | ||
| 161 | + String table = (String) param.args[0]; | ||
| 162 | + if (table == null) return; | ||
| 163 | + android.content.ContentValues cv = | ||
| 164 | + (android.content.ContentValues) param.args[2]; | ||
| 165 | + | ||
| 166 | + // Determine msgtype from ContentValues (key may be "type" or "msgtype") | ||
| 167 | + Object rawType = null; | ||
| 168 | + if (cv != null) { | ||
| 169 | + rawType = cv.get("type"); | ||
| 170 | + if (rawType == null) rawType = cv.get("msgtype"); | ||
| 171 | + if (rawType == null) rawType = cv.get("msgType"); | ||
| 172 | + } | ||
| 173 | + int msgtype = -1; | ||
| 174 | + try { if (rawType != null) msgtype = Integer.parseInt(rawType.toString()); } | ||
| 175 | + catch (NumberFormatException ignored) {} | ||
| 176 | + | ||
| 177 | + if (msgtype == 3 || msgtype == 34) { | ||
| 178 | + // Image (3) or Voice (34): dump full ContentValues + stack | ||
| 179 | + String cvStr = cv != null ? cv.toString() : "null"; | ||
| 180 | + String stack = android.util.Log.getStackTraceString(new Throwable()); | ||
| 181 | + if (stack.length() > 4000) stack = stack.substring(0, 4000) + "…"; | ||
| 182 | + XposedBridge.log("[WeChatHook] WCDB MEDIA INSERT table=" + table | ||
| 183 | + + " msgtype=" + msgtype | ||
| 184 | + + " proc=" + procName | ||
| 185 | + + "\nContentValues=" + cvStr | ||
| 186 | + + "\nStack=" + stack); | ||
| 187 | + } else if (msgtype == 1) { | ||
| 188 | + // Text: only log once (to confirm which process writes) | ||
| 189 | + if (!textLogged) { | ||
| 190 | + textLogged = true; | ||
| 191 | + String stack = android.util.Log.getStackTraceString(new Throwable()); | ||
| 192 | + if (stack.length() > 3000) stack = stack.substring(0, 3000) + "…"; | ||
| 193 | + XposedBridge.log("[WeChatHook] WCDB TEXT INSERT table=" + table | ||
| 194 | + + " proc=" + procName + "\n" + stack); | ||
| 195 | + } else { | ||
| 196 | + XposedBridge.log("[WeChatHook] WCDB TEXT table=" + table | ||
| 197 | + + " proc=" + procName); | ||
| 198 | + } | ||
| 199 | + } | ||
| 200 | + } | ||
| 201 | + }); | ||
| 202 | + XposedBridge.log("[WeChatHook] WCDB hook OK in " + procName); | ||
| 203 | + } catch (Throwable t) { | ||
| 204 | + XposedBridge.log("[WeChatHook] WCDB hook failed in " + procName + ": " + t); | ||
| 205 | + } | ||
| 206 | + } | ||
| 207 | + | ||
| 208 | + // ── MediaPlayer hook: reveals voice file path format ───────────────────── | ||
| 209 | + // When WeChat plays a voice message it calls MediaPlayer.setDataSource(path). | ||
| 210 | + // We log any path under voice2/ to see the full path including subdirectory, | ||
| 211 | + // which reveals the formula WeChat uses to map voice file IDs to directories. | ||
| 212 | + private void hookMediaPlayer(String procName) { | ||
| 213 | + try { | ||
| 214 | + XposedHelpers.findAndHookMethod("android.media.MediaPlayer", null, | ||
| 215 | + "setDataSource", String.class, new XC_MethodHook() { | ||
| 216 | + @Override | ||
| 217 | + protected void beforeHookedMethod(MethodHookParam p) throws Throwable { | ||
| 218 | + String path = (String) p.args[0]; | ||
| 219 | + if (path != null && path.contains("voice2")) { | ||
| 220 | + XposedBridge.log("[WeChatHook] MediaPlayer.setDataSource VOICE=" | ||
| 221 | + + path + " proc=" + procName); | ||
| 222 | + } | ||
| 223 | + } | ||
| 224 | + }); | ||
| 225 | + XposedBridge.log("[WeChatHook] MediaPlayer hook OK in " + procName); | ||
| 226 | + } catch (Throwable t) { | ||
| 227 | + XposedBridge.log("[WeChatHook] MediaPlayer hook failed in " + procName + ": " + t); | ||
| 228 | + } | ||
| 229 | + } | ||
| 230 | + | ||
| 231 | + // ── f9.convertTo() hook: maps f9 fields to DB columns ───────────────────── | ||
| 232 | + // Captures ContentValues produced by convertTo() for image (3) and voice (34). | ||
| 233 | + // From these ContentValues we know which f9 setter corresponds to which DB column | ||
| 234 | + // (imgPath, content, talker, etc.) so we can construct f9 programmatically. | ||
| 235 | + private void hookF9ConvertTo(ClassLoader cl, String procName) { | ||
| 236 | + try { | ||
| 237 | + XposedHelpers.findAndHookMethod("com.tencent.mm.storage.f9", cl, | ||
| 238 | + "convertTo", new XC_MethodHook() { | ||
| 239 | + @Override | ||
| 240 | + protected void afterHookedMethod(MethodHookParam p) throws Throwable { | ||
| 241 | + if (p.hasThrowable()) return; | ||
| 242 | + android.content.ContentValues cv = | ||
| 243 | + (android.content.ContentValues) p.getResult(); | ||
| 244 | + if (cv == null) return; | ||
| 245 | + Object rawType = cv.get("type"); | ||
| 246 | + if (rawType == null) rawType = cv.get("msgtype"); | ||
| 247 | + if (rawType == null) return; | ||
| 248 | + int msgtype = -1; | ||
| 249 | + try { msgtype = Integer.parseInt(rawType.toString()); } catch (Throwable e) {} | ||
| 250 | + if (msgtype == 3 && !sCalledByUs) { | ||
| 251 | + // Dump full call stack once for a real IMAGE send so we can find | ||
| 252 | + // the top-level app entry point to replicate for API image send. | ||
| 253 | + XposedBridge.log("[WeChatHook] convertTo IMAGE STACK (real send):\n" | ||
| 254 | + + android.util.Log.getStackTraceString(new Exception())); | ||
| 255 | + } | ||
| 256 | + if (msgtype == 34) { | ||
| 257 | + // Dump call stack once for real sends to find the CDN upload caller. | ||
| 258 | + if (!sCalledByUs) { | ||
| 259 | + XposedBridge.log("[WeChatHook] convertTo STACK (real send):\n" | ||
| 260 | + + android.util.Log.getStackTraceString(new Exception())); | ||
| 261 | + } | ||
| 262 | + // Auto-discover logged-in user's wxid from real UI voice sends. | ||
| 263 | + // We look for isSend=1 messages (user's own sends) and extract | ||
| 264 | + // fromusername from the voicemsg XML, cache as sMyWxid. | ||
| 265 | + if (sMyWxid == null && !sCalledByUs) { | ||
| 266 | + Object isSend = cv.get("isSend"); | ||
| 267 | + Object contentVal = cv.get("content"); | ||
| 268 | + if (isSend instanceof Integer && ((Integer)isSend) == 1 | ||
| 269 | + && contentVal instanceof String) { | ||
| 270 | + String c = (String) contentVal; | ||
| 271 | + int fi = c.indexOf("fromusername=\""); | ||
| 272 | + if (fi >= 0) { | ||
| 273 | + int start = fi + "fromusername=\"".length(); | ||
| 274 | + int end = c.indexOf("\"", start); | ||
| 275 | + if (end > start) { | ||
| 276 | + String wxid = c.substring(start, end); | ||
| 277 | + if (!wxid.isEmpty()) { | ||
| 278 | + sMyWxid = wxid; | ||
| 279 | + XposedBridge.log("[WeChatHook] auto-discovered sMyWxid=" + wxid); | ||
| 280 | + } | ||
| 281 | + } | ||
| 282 | + } | ||
| 283 | + } | ||
| 284 | + } | ||
| 285 | + } | ||
| 286 | + if (msgtype == 3 || msgtype == 34) { | ||
| 287 | + // Inject lvbuffer (SILK bytes) if we're sending voice via hook. | ||
| 288 | + // sPendingLvBuffer is still set here — convertTo fires INSIDE | ||
| 289 | + // the r0 constructor which runs before our finally-block clears it. | ||
| 290 | + byte[] pendingLv = sPendingLvBuffer.get(); | ||
| 291 | + if (pendingLv != null && !(cv.get("lvbuffer") instanceof byte[])) { | ||
| 292 | + cv.put("lvbuffer", pendingLv); | ||
| 293 | + XposedBridge.log("[WeChatHook] convertTo: injected lvbuffer " + pendingLv.length + " bytes"); | ||
| 294 | + } | ||
| 295 | + // Capture the DB-bound f9 (the one convertTo() runs on). | ||
| 296 | + // This is the correct object for r0.i: it has content + msgId set. | ||
| 297 | + if (pendingLv != null) { | ||
| 298 | + sDatabaseF9.set(p.thisObject); | ||
| 299 | + // Capture msgLocalId for pt0.e0.s() CDN trigger | ||
| 300 | + try { | ||
| 301 | + Object msgIdObj = cv.get("msgId"); | ||
| 302 | + if (msgIdObj != null) { | ||
| 303 | + long mid = Long.parseLong(msgIdObj.toString()); | ||
| 304 | + sPendingMsgLocalId.set(mid); | ||
| 305 | + XposedBridge.log("[WeChatHook] convertTo: captured msgLocalId=" + mid); | ||
| 306 | + } | ||
| 307 | + } catch (Throwable ignored) {} | ||
| 308 | + // Do NOT force status=4 here. | ||
| 309 | + // Forcing status=4 before H() causes H() to skip CDN upload | ||
| 310 | + // (WeChat checks status and exits early if already "delivered"). | ||
| 311 | + // Let H() set status=4 naturally after successful CDN upload. | ||
| 312 | + // Ensure flag and msgSvrId are present (missing from our sends). | ||
| 313 | + if (cv.get("flag") == null) cv.put("flag", 0); | ||
| 314 | + if (cv.get("msgSvrId") == null) cv.put("msgSvrId", 0L); | ||
| 315 | + XposedBridge.log("[WeChatHook] convertTo: initial INSERT (status not forced, H() will set it)"); | ||
| 316 | + } | ||
| 317 | + // Log full ContentValues including lvbuffer details | ||
| 318 | + StringBuilder sb = new StringBuilder(); | ||
| 319 | + sb.append("[WeChatHook] f9.convertTo() msgtype=").append(msgtype) | ||
| 320 | + .append(" proc=").append(procName).append("\nContentValues=").append(cv); | ||
| 321 | + Object lvbuf = cv.get("lvbuffer"); | ||
| 322 | + if (lvbuf instanceof byte[]) { | ||
| 323 | + byte[] b = (byte[]) lvbuf; | ||
| 324 | + StringBuilder hex = new StringBuilder(); | ||
| 325 | + int show = Math.min(b.length, 32); | ||
| 326 | + for (int i = 0; i < show; i++) | ||
| 327 | + hex.append(String.format("%02x", b[i] & 0xff)); | ||
| 328 | + sb.append("\nlvbuffer len=").append(b.length).append(" hex=").append(hex); | ||
| 329 | + } else { | ||
| 330 | + sb.append("\nlvbuffer=null"); | ||
| 331 | + } | ||
| 332 | + XposedBridge.log(sb.toString()); | ||
| 333 | + } | ||
| 334 | + } | ||
| 335 | + }); | ||
| 336 | + XposedBridge.log("[WeChatHook] f9.convertTo hook OK in " + procName); | ||
| 337 | + } catch (Throwable t) { | ||
| 338 | + XposedBridge.log("[WeChatHook] f9.convertTo hook failed in " + procName + ": " + t); | ||
| 339 | + } | ||
| 340 | + } | ||
| 341 | + | ||
| 342 | + // ── f9 constructor imgPath injector ─────────────────────────────────────── | ||
| 343 | + // Hooks ALL f9 constructors. When sPendingVoiceId is set on the calling thread, | ||
| 344 | + // immediately calls the discovered imgPath setter on the newly created f9 object. | ||
| 345 | + // This fires INSIDE the r0 constructor (before its internal f9.convertTo() call), | ||
| 346 | + // so the DB insert from r0 constructor already has the correct voice file ID. | ||
| 347 | + private void hookF9ImgPathInjector(ClassLoader cl, String procName) { | ||
| 348 | + try { | ||
| 349 | + Class<?> f9Class = XposedHelpers.findClass("com.tencent.mm.storage.f9", cl); | ||
| 350 | + XC_MethodHook injHook = new XC_MethodHook() { | ||
| 351 | + @Override | ||
| 352 | + protected void afterHookedMethod(MethodHookParam param) throws Throwable { | ||
| 353 | + String pending = sPendingVoiceId.get(); | ||
| 354 | + if (pending == null) return; | ||
| 355 | + java.util.Map<String, String> sm = sF9SetterMap; | ||
| 356 | + if (sm == null) return; | ||
| 357 | + // Inject imgPath | ||
| 358 | + String setter = sm.get("imgPath"); | ||
| 359 | + if (setter != null) { | ||
| 360 | + XposedHelpers.callMethod(param.thisObject, setter, pending); | ||
| 361 | + XposedBridge.log("[WeChatHook] f9 ctor injected imgPath=" + pending + " via " + setter); | ||
| 362 | + } | ||
| 363 | + // Inject lvbuffer (SILK bytes) so WeChat treats the voice as locally available | ||
| 364 | + byte[] lvbuf = sPendingLvBuffer.get(); | ||
| 365 | + String lvSetter = sm.get("lvbuffer:bytes"); | ||
| 366 | + if (lvbuf != null && lvSetter != null) { | ||
| 367 | + XposedHelpers.callMethod(param.thisObject, lvSetter, (Object) lvbuf); | ||
| 368 | + XposedBridge.log("[WeChatHook] f9 ctor injected lvbuffer len=" + lvbuf.length + " via " + lvSetter); | ||
| 369 | + } else { | ||
| 370 | + XposedBridge.log("[WeChatHook] f9 ctor lvbuffer skip: lvbuf=" + (lvbuf==null?"null":"ok") + " setter=" + lvSetter); | ||
| 371 | + } | ||
| 372 | + // sDatabaseF9 is captured in convertTo() — that's the correct DB-bound f9. | ||
| 373 | + } | ||
| 374 | + }; | ||
| 375 | + int hooked = 0; | ||
| 376 | + for (java.lang.reflect.Constructor<?> ctor : f9Class.getDeclaredConstructors()) { | ||
| 377 | + XposedBridge.hookMethod(ctor, injHook); | ||
| 378 | + hooked++; | ||
| 379 | + } | ||
| 380 | + XposedBridge.log("[WeChatHook] f9 ctor injector OK in " + procName + " (" + hooked + " ctors)"); | ||
| 381 | + } catch (Throwable t) { | ||
| 382 | + XposedBridge.log("[WeChatHook] f9 ctor injector failed in " + procName + ": " + t); | ||
| 383 | + } | ||
| 384 | + } | ||
| 385 | + | ||
| 386 | + // ── Image send via internal API ──────────────────────────────────────────── | ||
| 387 | + // Calls the gallery's own static image-send helper: | ||
| 388 | + // j33.d0.k(long imgId, String path, String talker, boolean, int, boolean, | ||
| 389 | + // GalleryItem$MediaItem) | ||
| 390 | + // This is exactly what the album picker calls (traced via a real image send's | ||
| 391 | + // convertTo stack → b80.u.oj → b80.s coroutine → built from o70.h; d0.k is the | ||
| 392 | + // top-level builder). It builds o70.h (PreBuildParams) and launches the send | ||
| 393 | + // coroutine internally (thumbnail gen + CDN upload + DB insert), fire-and-forget. | ||
| 394 | + // The old w11.s1.b(IMAGE) path built an incomplete r1 → n1.a() NPE; abandoned. | ||
| 395 | + private static void sendImageViaAPI(String talker, String imagePath) throws Throwable { | ||
| 396 | + ClassLoader cl = sTinkerCL != null ? sTinkerCL : sWeChatCL; | ||
| 397 | + if (cl == null) throw new IllegalStateException("classloader null"); | ||
| 398 | + // Replicate the REAL image send captured by hookImageSend: build the exact | ||
| 399 | + // o70.h (PreBuildParams) that b80.u.pj() receives on a genuine send, then call | ||
| 400 | + // b80.u.pj(o70.h) directly (non-suspend), skipping the whole gallery/coroutine UI. | ||
| 401 | + // | ||
| 402 | + // Captured real o70.h (fields are single letters a–l): | ||
| 403 | + // a=r6 preBuildFolder, e=imgPath, g=toUserName(talker), c=source(0), d=0, | ||
| 404 | + // f=imgId(0 ok), h=compressType(0), k=Uri(null for pj), b=UUID(auto) | ||
| 405 | + // | ||
| 406 | + // r6 folder = lp0.b.h0(gm0.j1.b().j() + "_MsgImgUploadFSC") (= g80.o.c7()). | ||
| 407 | + // plugin b80.u = i95.n0.c(o70.e.class) (fallback to hook-captured instance). | ||
| 408 | + // b80.u image plugin = i95.n0.c(o70.e.class) (fallback to hook-captured instance). | ||
| 409 | + Object imgPlugin = sImgPlugin; | ||
| 410 | + if (imgPlugin == null) { | ||
| 411 | + Class<?> n0 = XposedHelpers.findClass("i95.n0", cl); | ||
| 412 | + Class<?> o70e = XposedHelpers.findClass("o70.e", cl); | ||
| 413 | + imgPlugin = XposedHelpers.callStaticMethod(n0, "c", o70e); | ||
| 414 | + } | ||
| 415 | + if (imgPlugin == null) throw new IllegalStateException("b80.u image plugin null"); | ||
| 416 | + | ||
| 417 | + // r6 pre-build folder = lp0.b.h0(gm0.j1.b().j() + "_MsgImgUploadFSC") (= g80.o.c7()). | ||
| 418 | + Object m = XposedHelpers.callStaticMethod(XposedHelpers.findClass("gm0.j1", cl), "b"); | ||
| 419 | + Object prefix = XposedHelpers.callMethod(m, "j"); | ||
| 420 | + Class<?> lp0b = XposedHelpers.findClass("lp0.b", cl); | ||
| 421 | + Object r6 = XposedHelpers.callStaticMethod(lp0b, "h0", prefix + "_MsgImgUploadFSC"); | ||
| 422 | + | ||
| 423 | + // Build o70.k (the batch send object) — fields captured from a real oj(o70.k): | ||
| 424 | + // a=r6, b=UUID(auto), c=talker, d=source, f=compressType, g=List<o70.i> | ||
| 425 | + Class<?> o70k = XposedHelpers.findClass("o70.k", cl); | ||
| 426 | + Object k = XposedHelpers.newInstance(o70k, r6); | ||
| 427 | + XposedHelpers.setObjectField(k, "c", talker); // toUserName | ||
| 428 | + // source (field d) — real send used 3; set via reflection (int field). | ||
| 429 | + try { java.lang.reflect.Field df = o70k.getDeclaredField("d"); df.setAccessible(true); df.setInt(k, 3); } catch (Throwable ignored) {} | ||
| 430 | + // o70.i(imgPath, Uri, Long imgId) — give it a file:// Uri so the CDN upload has | ||
| 431 | + // a readable source (real sends pass a MediaStore content:// Uri). | ||
| 432 | + Class<?> o70i = XposedHelpers.findClass("o70.i", cl); | ||
| 433 | + android.net.Uri fileUri = android.net.Uri.fromFile(new java.io.File(imagePath)); | ||
| 434 | + Object item = XposedHelpers.newInstance(o70i, | ||
| 435 | + new Class<?>[]{String.class, android.net.Uri.class, Long.class}, | ||
| 436 | + imagePath, fileUri, Long.valueOf(0L)); | ||
| 437 | + java.util.List<Object> list = new java.util.ArrayList<>(); | ||
| 438 | + list.add(item); | ||
| 439 | + XposedHelpers.setObjectField(k, "g", list); | ||
| 440 | + String clientId = (String) XposedHelpers.getObjectField(k, "b"); | ||
| 441 | + | ||
| 442 | + // Replicate b80.s coroutine's per-item prebuild loop: build o70.h from k+item, | ||
| 443 | + // then pj (stage compressed image + thumbnail into r6). | ||
| 444 | + Class<?> o70h = XposedHelpers.findClass("o70.h", cl); | ||
| 445 | + Object h = XposedHelpers.newInstance(o70h, r6); | ||
| 446 | + XposedHelpers.callMethod(h, "b", clientId); // clientId (field b) | ||
| 447 | + XposedHelpers.setObjectField(h, "e", imagePath); // imgPath | ||
| 448 | + XposedHelpers.setObjectField(h, "g", talker); // toUserName | ||
| 449 | + XposedHelpers.setObjectField(h, "k", fileUri); // Uri (source for staging/upload) | ||
| 450 | + try { java.lang.reflect.Field hc = o70h.getDeclaredField("c"); hc.setAccessible(true); hc.setInt(h, 3); } catch (Throwable ignored) {} // source | ||
| 451 | + // d(rotate)/h(compressType) default 0. f(imgId) 0. | ||
| 452 | + XposedBridge.log("[WeChatHook] sendImageViaAPI: built o70.k+o70.h e=" + imagePath + " g=" + talker | ||
| 453 | + + " plugin=" + imgPlugin.getClass().getName()); | ||
| 454 | + | ||
| 455 | + sCalledByUs = true; | ||
| 456 | + try { | ||
| 457 | + if (!(Boolean) XposedHelpers.callMethod(imgPlugin, "Bi", h, "thumb.jpg")) { | ||
| 458 | + XposedHelpers.callMethod(imgPlugin, "pj", h); // prebuild (stage) | ||
| 459 | + try { XposedHelpers.callMethod(imgPlugin, "nj", h, 10000); } catch (Throwable ignored) {} | ||
| 460 | + } | ||
| 461 | + java.util.ArrayList<?> res = (java.util.ArrayList<?>) | ||
| 462 | + XposedHelpers.callMethod(imgPlugin, "oj", k); // insert + send | ||
| 463 | + XposedBridge.log("[WeChatHook] sendImageViaAPI: oj() DONE to=" + talker | ||
| 464 | + + " res=" + (res == null ? "null" : "size=" + res.size())); | ||
| 465 | + } finally { sCalledByUs = false; } | ||
| 466 | + } | ||
| 467 | + | ||
| 468 | + // ── Probe all f9 setter→column mappings (String + int, one-shot) ────────── | ||
| 469 | + // Calls each String setter and each void(int) method with a unique marker, | ||
| 470 | + // then calls convertTo() to see which ContentValues key reflects the marker. | ||
| 471 | + // Returns a Map<columnName, setterMethodName>. | ||
| 472 | + private static volatile java.util.Map<String, String> sF9SetterMap = null; | ||
| 473 | + | ||
| 474 | + private static java.util.Map<String, String> probeAllF9Setters(Class<?> f9Class) { | ||
| 475 | + java.util.Map<String, String> result = new java.util.HashMap<>(); | ||
| 476 | + | ||
| 477 | + // String setters (known from class dump) | ||
| 478 | + for (String sn : new String[]{"Z0", "C1", "D1", "a1", "d1", "h1", "i1"}) { | ||
| 479 | + try { | ||
| 480 | + Object probe = XposedHelpers.newInstance(f9Class); | ||
| 481 | + XposedHelpers.callMethod(probe, sn, "PROBE_" + sn); | ||
| 482 | + android.content.ContentValues cv = (android.content.ContentValues) | ||
| 483 | + XposedHelpers.callMethod(probe, "convertTo"); | ||
| 484 | + for (String key : cv.keySet()) { | ||
| 485 | + Object val = cv.get(key); | ||
| 486 | + if (("PROBE_" + sn).equals(val != null ? val.toString() : "")) { | ||
| 487 | + result.put(key, sn); | ||
| 488 | + XposedBridge.log("[WeChatHook] f9 str setter " + sn + " -> " + key); | ||
| 489 | + } | ||
| 490 | + } | ||
| 491 | + } catch (Throwable t) { | ||
| 492 | + XposedBridge.log("[WeChatHook] f9 probe str " + sn + ": " + t); | ||
| 493 | + } | ||
| 494 | + } | ||
| 495 | + | ||
| 496 | + // Int setters: scan all void(int) methods | ||
| 497 | + try { | ||
| 498 | + for (java.lang.reflect.Method m : f9Class.getDeclaredMethods()) { | ||
| 499 | + if (m.getReturnType() != void.class) continue; | ||
| 500 | + java.lang.Class<?>[] pt = m.getParameterTypes(); | ||
| 501 | + if (pt.length != 1 || pt[0] != int.class) continue; | ||
| 502 | + m.setAccessible(true); | ||
| 503 | + try { | ||
| 504 | + Object probe = XposedHelpers.newInstance(f9Class); | ||
| 505 | + m.invoke(probe, 88888); | ||
| 506 | + android.content.ContentValues cv = (android.content.ContentValues) | ||
| 507 | + XposedHelpers.callMethod(probe, "convertTo"); | ||
| 508 | + for (String key : cv.keySet()) { | ||
| 509 | + Object val = cv.get(key); | ||
| 510 | + if (val instanceof Integer && (Integer) val == 88888) { | ||
| 511 | + result.put(key, m.getName()); | ||
| 512 | + XposedBridge.log("[WeChatHook] f9 int setter " + m.getName() + " -> " + key); | ||
| 513 | + } | ||
| 514 | + } | ||
| 515 | + } catch (Throwable ignored) {} | ||
| 516 | + } | ||
| 517 | + } catch (Throwable t) { | ||
| 518 | + XposedBridge.log("[WeChatHook] f9 int probe scan failed: " + t); | ||
| 519 | + } | ||
| 520 | + | ||
| 521 | + // byte[] setters: scan all void(byte[]) methods — discovers lvbuffer setter | ||
| 522 | + try { | ||
| 523 | + for (java.lang.reflect.Method m : f9Class.getDeclaredMethods()) { | ||
| 524 | + if (m.getReturnType() != void.class) continue; | ||
| 525 | + java.lang.Class<?>[] pt = m.getParameterTypes(); | ||
| 526 | + if (pt.length != 1 || pt[0] != byte[].class) continue; | ||
| 527 | + m.setAccessible(true); | ||
| 528 | + // Marker: a 4-byte sentinel we can recognize in cv | ||
| 529 | + byte[] marker = new byte[]{(byte)0xDE, (byte)0xAD, (byte)0xBE, (byte)0xEF}; | ||
| 530 | + try { | ||
| 531 | + Object probe = XposedHelpers.newInstance(f9Class); | ||
| 532 | + m.invoke(probe, (Object) marker); | ||
| 533 | + android.content.ContentValues cv = (android.content.ContentValues) | ||
| 534 | + XposedHelpers.callMethod(probe, "convertTo"); | ||
| 535 | + for (String key : cv.keySet()) { | ||
| 536 | + Object val = cv.get(key); | ||
| 537 | + if (val instanceof byte[]) { | ||
| 538 | + byte[] got = (byte[]) val; | ||
| 539 | + if (got.length == 4 && got[0] == (byte)0xDE && got[1] == (byte)0xAD) { | ||
| 540 | + result.put(key + ":bytes", m.getName()); | ||
| 541 | + XposedBridge.log("[WeChatHook] f9 bytes setter " + m.getName() + " -> " + key); | ||
| 542 | + } | ||
| 543 | + } | ||
| 544 | + } | ||
| 545 | + } catch (Throwable ignored) {} | ||
| 546 | + } | ||
| 547 | + } catch (Throwable t) { | ||
| 548 | + XposedBridge.log("[WeChatHook] f9 bytes probe scan failed: " + t); | ||
| 549 | + } | ||
| 550 | + | ||
| 551 | + XposedBridge.log("[WeChatHook] f9 setter map: " + result); | ||
| 552 | + return result; | ||
| 553 | + } | ||
| 554 | + | ||
| 555 | + // ── Hook w11.r0.H(String) — CDN upload entry-point trace ───────────────── | ||
| 556 | + // Logs every r0.H() call: dumps all String+File fields on r0 so we can identify | ||
| 557 | + // which field contains the audio file path that H() uploads to CDN. | ||
| 558 | + // Hook ALL methods on pt0.e0 (voice-send coroutine class found in stack trace). | ||
| 559 | + // Goal: find which method triggers CDN upload AFTER f9.convertTo() returns. | ||
| 560 | + // pt0.e0.k returns f9 objects and fires in multiple threads — CDN upload entry point. | ||
| 561 | + private void hookPt0E0(ClassLoader cl, String procName) { | ||
| 562 | + try { | ||
| 563 | + Class<?> cls = XposedHelpers.findClass("pt0.e0", cl); | ||
| 564 | + java.lang.reflect.Method[] methods = cls.getDeclaredMethods(); | ||
| 565 | + StringBuilder sig = new StringBuilder("[WeChatHook] pt0.e0 methods(" + methods.length + "):"); | ||
| 566 | + for (java.lang.reflect.Method m : methods) sig.append(" ").append(m.getName()).append("(").append(m.getParameterTypes().length).append(")"); | ||
| 567 | + XposedBridge.log(sig.toString()); | ||
| 568 | + for (java.lang.reflect.Method m : methods) { | ||
| 569 | + final String mName = m.getName(); | ||
| 570 | + m.setAccessible(true); | ||
| 571 | + XposedBridge.hookMethod(m, new XC_MethodHook() { | ||
| 572 | + @Override protected void beforeHookedMethod(MethodHookParam p) { | ||
| 573 | + // Capture singleton from ANY method (not just r()), so it's | ||
| 574 | + // available even when no real voice send has happened this session. | ||
| 575 | + if (sPt0E0Instance == null && p.thisObject != null) { | ||
| 576 | + sPt0E0Instance = p.thisObject; | ||
| 577 | + XposedBridge.log("[WeChatHook] pt0.e0 singleton captured via " + mName + ": " + p.thisObject); | ||
| 578 | + } | ||
| 579 | + if ("k".equals(mName)) { | ||
| 580 | + // k(2): confirmed signature k(String talker, Long msgLocalId) | ||
| 581 | + // CDN worker calls this to dequeue next upload task. | ||
| 582 | + // Only log first call per send to avoid spam. | ||
| 583 | + Object a0 = p.args != null && p.args.length > 0 ? p.args[0] : null; | ||
| 584 | + Object a1 = p.args != null && p.args.length > 1 ? p.args[1] : null; | ||
| 585 | + XposedBridge.log("[WeChatHook] pt0.e0.k ENTER talker=" + a0 | ||
| 586 | + + " msgLocalId=" + (a1 instanceof Long ? ((Long)a1).longValue() : a1)); | ||
| 587 | + } else if ("r".equals(mName)) { | ||
| 588 | + // Save pt0.e0 singleton for later use in sendVoiceViaAPI. | ||
| 589 | + if (sPt0E0Instance == null && p.thisObject != null) { | ||
| 590 | + sPt0E0Instance = p.thisObject; | ||
| 591 | + XposedBridge.log("[WeChatHook] pt0.e0 singleton captured: " + p.thisObject); | ||
| 592 | + } | ||
| 593 | + XposedBridge.log("[WeChatHook] pt0.e0.r ENTER, dumping thisObject fields:"); | ||
| 594 | + if (p.thisObject != null) { | ||
| 595 | + Class<?> c = p.thisObject.getClass(); | ||
| 596 | + while (c != null && !c.equals(Object.class)) { | ||
| 597 | + for (java.lang.reflect.Field f : c.getDeclaredFields()) { | ||
| 598 | + f.setAccessible(true); | ||
| 599 | + try { | ||
| 600 | + Object v = f.get(p.thisObject); | ||
| 601 | + XposedBridge.log("[WeChatHook] " + c.getSimpleName() | ||
| 602 | + + "." + f.getName() + "=" + (v == null ? "null" | ||
| 603 | + : (v instanceof String || v instanceof Number || v instanceof Boolean) | ||
| 604 | + ? v.toString() : v.getClass().getName() + "@" + System.identityHashCode(v))); | ||
| 605 | + } catch (Throwable ignored) {} | ||
| 606 | + } | ||
| 607 | + c = c.getSuperclass(); | ||
| 608 | + } | ||
| 609 | + } | ||
| 610 | + } else if ("s".equals(mName)) { | ||
| 611 | + // s(3): CDN upload trigger. Log args + full f9 hierarchy for diff analysis. | ||
| 612 | + StringBuilder sb = new StringBuilder("[WeChatHook] pt0.e0.s ENTER args:"); | ||
| 613 | + if (p.args != null) for (int i = 0; i < p.args.length; i++) { | ||
| 614 | + Object a = p.args[i]; | ||
| 615 | + if (a == null) { sb.append(" [").append(i).append("]=null"); } | ||
| 616 | + else if (a instanceof String) { sb.append(" [").append(i).append("]=String(\"").append(a).append("\")"); } | ||
| 617 | + else if (a instanceof Number) { sb.append(" [").append(i).append("]=").append(a); } | ||
| 618 | + else { sb.append(" [").append(i).append("]=").append(a.getClass().getName()).append("@").append(System.identityHashCode(a)); } | ||
| 619 | + } | ||
| 620 | + XposedBridge.log(sb.toString()); | ||
| 621 | + // Dump ALL fields of f9 (args[2]) across full parent hierarchy | ||
| 622 | + if (p.args != null && p.args.length > 2 && p.args[2] != null) { | ||
| 623 | + Object f9 = p.args[2]; | ||
| 624 | + StringBuilder fb = new StringBuilder("[WeChatHook] pt0.e0.s f9 DUMP:"); | ||
| 625 | + Class<?> c = f9.getClass(); | ||
| 626 | + while (c != null && !c.equals(Object.class)) { | ||
| 627 | + for (java.lang.reflect.Field f : c.getDeclaredFields()) { | ||
| 628 | + f.setAccessible(true); | ||
| 629 | + try { | ||
| 630 | + Object v = f.get(f9); | ||
| 631 | + String vs; | ||
| 632 | + if (v == null) vs = "null"; | ||
| 633 | + else if (v instanceof byte[]) vs = "byte[" + ((byte[])v).length + "]"; | ||
| 634 | + else if (v instanceof String || v instanceof Number || v instanceof Boolean) vs = v.toString(); | ||
| 635 | + else vs = v.getClass().getSimpleName() + "@" + System.identityHashCode(v); | ||
| 636 | + fb.append("\n ").append(c.getSimpleName()).append(".").append(f.getName()).append("=").append(vs); | ||
| 637 | + } catch (Throwable ignored) {} | ||
| 638 | + } | ||
| 639 | + c = c.getSuperclass(); | ||
| 640 | + } | ||
| 641 | + XposedBridge.log(fb.toString()); | ||
| 642 | + } | ||
| 643 | + } else if (p.args != null && p.args.length == 2 | ||
| 644 | + && ("a".equals(mName) || "b".equals(mName) || "l".equals(mName) | ||
| 645 | + || "n".equals(mName) || "q".equals(mName))) { | ||
| 646 | + // Log 2-arg entry methods — one of these is likely the | ||
| 647 | + // "enqueue voice for send" entry point called by WeChat UI | ||
| 648 | + StringBuilder sb2 = new StringBuilder( | ||
| 649 | + "[WeChatHook] pt0.e0." + mName + "(2) ENTER"); | ||
| 650 | + if (p.args != null) for (int i = 0; i < p.args.length; i++) { | ||
| 651 | + Object a = p.args[i]; | ||
| 652 | + if (a == null) sb2.append(" arg").append(i).append("=null"); | ||
| 653 | + else if (a instanceof String) sb2.append(" arg").append(i).append("=\"").append(a).append("\""); | ||
| 654 | + else if (a instanceof Number) sb2.append(" arg").append(i).append("=").append(a); | ||
| 655 | + else sb2.append(" arg").append(i).append("=").append(a.getClass().getName()).append("@").append(System.identityHashCode(a)); | ||
| 656 | + } | ||
| 657 | + XposedBridge.log(sb2.toString()); | ||
| 658 | + } else { | ||
| 659 | + XposedBridge.log("[WeChatHook] pt0.e0." + mName + " ENTER"); | ||
| 660 | + } | ||
| 661 | + } | ||
| 662 | + @Override protected void afterHookedMethod(MethodHookParam p) { | ||
| 663 | + if (p.hasThrowable()) { | ||
| 664 | + XposedBridge.log("[WeChatHook] pt0.e0." + mName + " THREW " + p.getThrowable()); | ||
| 665 | + } else if ("k".equals(mName)) { | ||
| 666 | + Object ret = p.getResult(); | ||
| 667 | + if (ret != null) { | ||
| 668 | + // Dump ALL declared fields on f9 (first call only, to learn field names) | ||
| 669 | + StringBuilder fb = new StringBuilder("[WeChatHook] pt0.e0.k OK f9=").append(ret.getClass().getSimpleName()); | ||
| 670 | + java.lang.reflect.Field[] fields = ret.getClass().getDeclaredFields(); | ||
| 671 | + for (java.lang.reflect.Field f : fields) { | ||
| 672 | + f.setAccessible(true); | ||
| 673 | + try { | ||
| 674 | + Object v = f.get(ret); | ||
| 675 | + fb.append(" ").append(f.getName()).append("=").append( | ||
| 676 | + v == null ? "null" : (v instanceof String || v instanceof Number || v instanceof Boolean) | ||
| 677 | + ? v.toString() : v.getClass().getSimpleName() + "@" + System.identityHashCode(v)); | ||
| 678 | + } catch (Throwable ignored) {} | ||
| 679 | + } | ||
| 680 | + XposedBridge.log(fb.toString()); | ||
| 681 | + } else { | ||
| 682 | + XposedBridge.log("[WeChatHook] pt0.e0.k OK ret=null"); | ||
| 683 | + } | ||
| 684 | + } else { | ||
| 685 | + XposedBridge.log("[WeChatHook] pt0.e0." + mName + " OK ret=" + p.getResult()); | ||
| 686 | + } | ||
| 687 | + } | ||
| 688 | + }); | ||
| 689 | + } | ||
| 690 | + XposedBridge.log("[WeChatHook] hookPt0E0: hooked " + methods.length + " methods in " + procName); | ||
| 691 | + } catch (Throwable t) { | ||
| 692 | + XposedBridge.log("[WeChatHook] hookPt0E0 failed in " + procName + ": " + t); | ||
| 693 | + } | ||
| 694 | + } | ||
| 695 | + | ||
| 696 | + // ── Surgical voice-send entry probe ────────────────────────────────────── | ||
| 697 | + // SAFE version: hooks ONLY the voice-send-specific classes (jg0.x coordinator | ||
| 698 | + // + qi3.f0/g0 voice-message objects), NOT generic coroutine classes, so it | ||
| 699 | + // won't add anti-tamper risk to the live Pad session. Goal: find the entry | ||
| 700 | + // method that receives (talker, silk file/bytes, durationMs) in plain form, | ||
| 701 | + // which we can then call synthetically to get a real, delivered voice send. | ||
| 702 | + private static String fmtArg(Object a) { | ||
| 703 | + if (a == null) return "null"; | ||
| 704 | + if (a instanceof byte[]) return "byte[" + ((byte[]) a).length + "]"; | ||
| 705 | + if (a instanceof String) return "\"" + a + "\""; | ||
| 706 | + if (a instanceof Number || a instanceof Boolean) return a.toString(); | ||
| 707 | + return a.getClass().getName() + "@" + System.identityHashCode(a); | ||
| 708 | + } | ||
| 709 | + | ||
| 710 | + // Recursively dump an object's non-static fields, bounded by depth, to find | ||
| 711 | + // where scalar values (talker string / silk path / duration) are captured. | ||
| 712 | + private static void dumpDeep(Object o, String prefix, int depth, StringBuilder sb) { | ||
| 713 | + if (o == null || depth < 0) return; | ||
| 714 | + for (Class<?> c = o.getClass(); c != null && !c.equals(Object.class); c = c.getSuperclass()) { | ||
| 715 | + for (java.lang.reflect.Field f : c.getDeclaredFields()) { | ||
| 716 | + if (java.lang.reflect.Modifier.isStatic(f.getModifiers())) continue; | ||
| 717 | + f.setAccessible(true); | ||
| 718 | + Object v; | ||
| 719 | + try { v = f.get(o); } catch (Throwable t) { continue; } | ||
| 720 | + sb.append("\n").append(prefix).append(f.getName()).append("=").append(fmtArg(v)); | ||
| 721 | + // Recurse into non-scalar, non-array WeChat/kotlin objects. | ||
| 722 | + if (v != null && depth > 0 | ||
| 723 | + && !(v instanceof String) && !(v instanceof Number) | ||
| 724 | + && !(v instanceof Boolean) && !(v instanceof byte[]) | ||
| 725 | + && !v.getClass().isArray() | ||
| 726 | + && !v.getClass().getName().startsWith("java.") | ||
| 727 | + && !v.getClass().getName().startsWith("android.")) { | ||
| 728 | + dumpDeep(v, prefix + " ", depth - 1, sb); | ||
| 729 | + } | ||
| 730 | + } | ||
| 731 | + } | ||
| 732 | + } | ||
| 733 | + | ||
| 734 | + private void hookVoiceProbe(ClassLoader cl, String procName) { | ||
| 735 | + // jg0.x — voice send coordinator; hook ALL methods to find the entry. | ||
| 736 | + try { | ||
| 737 | + Class<?> xClass = XposedHelpers.findClass("jg0.x", cl); | ||
| 738 | + int n = 0; | ||
| 739 | + for (java.lang.reflect.Method m : xClass.getDeclaredMethods()) { | ||
| 740 | + final String mn = m.getName(); | ||
| 741 | + m.setAccessible(true); | ||
| 742 | + XposedBridge.hookMethod(m, new XC_MethodHook() { | ||
| 743 | + @Override protected void beforeHookedMethod(MethodHookParam p) { | ||
| 744 | + // ── Voice hot-swap (the route that actually delivers) ────────── | ||
| 745 | + // jg0.x.w(jg0.x, qi3.d0, w21.w0, int type, int durMs, w21.t silk, Cont) | ||
| 746 | + // is the CDN uploadAttach step. On the REAL (non-resume) call, arg5 | ||
| 747 | + // (w21.t) holds the recorded SILK in its single byte[] field. When | ||
| 748 | + // armed, replace it with our TTS SILK so WeChat uploads our audio. | ||
| 749 | + // (This lives in the probe hook because this fork won't stack a 2nd | ||
| 750 | + // hook on an already-hooked method — see hookVoiceSwap note.) | ||
| 751 | + if ("w".equals(mn) && sSwapArmed && sSwapSilk != null | ||
| 752 | + && System.currentTimeMillis() < sSwapExpiresAt | ||
| 753 | + && p.args != null && p.args.length == 7 && p.args[5] != null) { | ||
| 754 | + try { | ||
| 755 | + Object tHolder = p.args[5]; | ||
| 756 | + java.lang.reflect.Field byteF = null; | ||
| 757 | + for (java.lang.reflect.Field f : tHolder.getClass().getDeclaredFields()) | ||
| 758 | + if (f.getType() == byte[].class) { byteF = f; break; } | ||
| 759 | + if (byteF != null) { | ||
| 760 | + byteF.setAccessible(true); | ||
| 761 | + byte[] orig = (byte[]) byteF.get(tHolder); | ||
| 762 | + byteF.set(tHolder, sSwapSilk); | ||
| 763 | + if (orig != null) for (java.lang.reflect.Field f : tHolder.getClass().getDeclaredFields()) { | ||
| 764 | + if (f.getType() != int.class) continue; | ||
| 765 | + f.setAccessible(true); | ||
| 766 | + if (f.getInt(tHolder) == orig.length) f.setInt(tHolder, sSwapSilk.length); | ||
| 767 | + } | ||
| 768 | + if (sSwapDuration > 0) p.args[4] = sSwapDuration; | ||
| 769 | + XposedBridge.log("[WeChatHook] VOICE SWAP DONE: " | ||
| 770 | + + (orig == null ? "?" : orig.length) + "B -> " + sSwapSilk.length | ||
| 771 | + + "B dur -> " + p.args[4]); | ||
| 772 | + sSwapArmed = false; sSwapSilk = null; // one-shot | ||
| 773 | + } | ||
| 774 | + } catch (Throwable t) { | ||
| 775 | + XposedBridge.log("[WeChatHook] VOICE SWAP failed: " + t); | ||
| 776 | + } | ||
| 777 | + } | ||
| 778 | + StringBuilder sb = new StringBuilder("[WeChatHook] jg0.x." + mn + "("); | ||
| 779 | + if (p.args != null) for (int i = 0; i < p.args.length; i++) { | ||
| 780 | + if (i > 0) sb.append(", "); | ||
| 781 | + sb.append(fmtArg(p.args[i])); | ||
| 782 | + } | ||
| 783 | + sb.append(")"); | ||
| 784 | + // Deep-dump the send-carrying methods: D's lambda (arg0) and | ||
| 785 | + // w's descriptor args — to locate talker/silk/duration fields. | ||
| 786 | + if ("D".equals(mn) && p.args != null && p.args.length > 0) { | ||
| 787 | + sb.append("\n [D arg0 pu.m0 deep]:"); | ||
| 788 | + dumpDeep(p.args[0], " ", 3, sb); | ||
| 789 | + } else if ("w".equals(mn) && p.args != null) { | ||
| 790 | + for (int i = 0; i < p.args.length; i++) { | ||
| 791 | + if (p.args[i] != null && !(p.args[i] instanceof Number)) { | ||
| 792 | + sb.append("\n [w arg").append(i).append(" deep]:"); | ||
| 793 | + dumpDeep(p.args[i], " ", 2, sb); | ||
| 794 | + } | ||
| 795 | + } | ||
| 796 | + } | ||
| 797 | + XposedBridge.log(sb.toString()); | ||
| 798 | + } | ||
| 799 | + }); | ||
| 800 | + n++; | ||
| 801 | + } | ||
| 802 | + XposedBridge.log("[WeChatHook] hookVoiceProbe: jg0.x " + n + " methods in " + procName); | ||
| 803 | + } catch (Throwable t) { | ||
| 804 | + XposedBridge.log("[WeChatHook] hookVoiceProbe jg0.x failed: " + t); | ||
| 805 | + } | ||
| 806 | + // qi3.f0 / qi3.g0 — voice-message domain objects; dump ctor args + fields. | ||
| 807 | + for (String cn : new String[]{"qi3.f0", "qi3.g0"}) { | ||
| 808 | + try { | ||
| 809 | + Class<?> c = XposedHelpers.findClass(cn, cl); | ||
| 810 | + for (java.lang.reflect.Constructor<?> ctor : c.getDeclaredConstructors()) { | ||
| 811 | + ctor.setAccessible(true); | ||
| 812 | + final String fcn = cn; | ||
| 813 | + XposedBridge.hookMethod(ctor, new XC_MethodHook() { | ||
| 814 | + @Override protected void afterHookedMethod(MethodHookParam p) { | ||
| 815 | + StringBuilder sb = new StringBuilder("[WeChatHook] " + fcn + ".<init>("); | ||
| 816 | + if (p.args != null) for (int i = 0; i < p.args.length; i++) { | ||
| 817 | + if (i > 0) sb.append(", "); | ||
| 818 | + sb.append(fmtArg(p.args[i])); | ||
| 819 | + } | ||
| 820 | + sb.append(") fields:"); | ||
| 821 | + for (Class<?> cc = p.thisObject.getClass(); | ||
| 822 | + cc != null && !cc.equals(Object.class); cc = cc.getSuperclass()) { | ||
| 823 | + for (java.lang.reflect.Field f : cc.getDeclaredFields()) { | ||
| 824 | + if (java.lang.reflect.Modifier.isStatic(f.getModifiers())) continue; | ||
| 825 | + f.setAccessible(true); | ||
| 826 | + try { | ||
| 827 | + sb.append("\n ").append(f.getName()).append("=").append(fmtArg(f.get(p.thisObject))); | ||
| 828 | + } catch (Throwable ignored) {} | ||
| 829 | + } | ||
| 830 | + } | ||
| 831 | + XposedBridge.log(sb.toString()); | ||
| 832 | + } | ||
| 833 | + }); | ||
| 834 | + } | ||
| 835 | + XposedBridge.log("[WeChatHook] hookVoiceProbe: " + cn + " ctor hooked in " + procName); | ||
| 836 | + } catch (Throwable t) { | ||
| 837 | + XposedBridge.log("[WeChatHook] hookVoiceProbe " + cn + " failed: " + t); | ||
| 838 | + } | ||
| 839 | + } | ||
| 840 | + } | ||
| 841 | + | ||
| 842 | + // ── Find the voice-send entry point ────────────────────────────────────── | ||
| 843 | + // pu.o0 is the outer coroutine, launched by the real entry point (async, so | ||
| 844 | + // it doesn't appear in the jg0.x.D stack). Hooking pu.o0's CONSTRUCTOR gives | ||
| 845 | + // a synchronous stack trace revealing the method that starts a voice send. | ||
| 846 | + // Also dumps qi3.f0/g0 constructors (the voice-message domain objects that | ||
| 847 | + // carry talker/silk/duration). | ||
| 848 | + private void hookVoiceEntry(ClassLoader cl, String procName) { | ||
| 849 | + // pu.o0 constructor — stack trace = the real send entry point. | ||
| 850 | + for (String cn : new String[]{"pu.o0", "pu.m0"}) { | ||
| 851 | + try { | ||
| 852 | + Class<?> c = XposedHelpers.findClass(cn, cl); | ||
| 853 | + for (java.lang.reflect.Constructor<?> ctor : c.getDeclaredConstructors()) { | ||
| 854 | + ctor.setAccessible(true); | ||
| 855 | + final String fcn = cn; | ||
| 856 | + final int np = ctor.getParameterTypes().length; | ||
| 857 | + XposedBridge.hookMethod(ctor, new XC_MethodHook() { | ||
| 858 | + private volatile boolean logged = false; | ||
| 859 | + @Override protected void afterHookedMethod(MethodHookParam p) { | ||
| 860 | + if (logged) return; | ||
| 861 | + logged = true; | ||
| 862 | + StringBuilder sb = new StringBuilder( | ||
| 863 | + "[WeChatHook] " + fcn + ".<init>(" + np + ") args:"); | ||
| 864 | + if (p.args != null) for (int i = 0; i < p.args.length; i++) { | ||
| 865 | + Object a = p.args[i]; | ||
| 866 | + sb.append("\n arg[").append(i).append("]=") | ||
| 867 | + .append(a == null ? "null" : a.getClass().getName()); | ||
| 868 | + } | ||
| 869 | + sb.append("\n").append(android.util.Log.getStackTraceString(new Exception())); | ||
| 870 | + XposedBridge.log(sb.toString()); | ||
| 871 | + } | ||
| 872 | + }); | ||
| 873 | + } | ||
| 874 | + XposedBridge.log("[WeChatHook] hookVoiceEntry: " + cn + " ctor hooked in " + procName); | ||
| 875 | + } catch (Throwable t) { | ||
| 876 | + XposedBridge.log("[WeChatHook] hookVoiceEntry " + cn + " failed: " + t); | ||
| 877 | + } | ||
| 878 | + } | ||
| 879 | + // qi3.f0 / qi3.g0 constructors — dump their args (talker/silk/duration). | ||
| 880 | + for (String cn : new String[]{"qi3.f0", "qi3.g0"}) { | ||
| 881 | + try { | ||
| 882 | + Class<?> c = XposedHelpers.findClass(cn, cl); | ||
| 883 | + StringBuilder info = new StringBuilder("[WeChatHook] " + cn | ||
| 884 | + + " super=" + c.getSuperclass().getName() + " fields:"); | ||
| 885 | + for (java.lang.reflect.Field f : c.getDeclaredFields()) | ||
| 886 | + info.append(" ").append(f.getName()).append("(").append(f.getType().getSimpleName()).append(")"); | ||
| 887 | + XposedBridge.log(info.toString()); | ||
| 888 | + for (java.lang.reflect.Constructor<?> ctor : c.getDeclaredConstructors()) { | ||
| 889 | + ctor.setAccessible(true); | ||
| 890 | + final String fcn = cn; | ||
| 891 | + XposedBridge.hookMethod(ctor, new XC_MethodHook() { | ||
| 892 | + private volatile boolean logged = false; | ||
| 893 | + @Override protected void afterHookedMethod(MethodHookParam p) { | ||
| 894 | + if (logged) return; | ||
| 895 | + logged = true; | ||
| 896 | + StringBuilder sb = new StringBuilder( | ||
| 897 | + "[WeChatHook] " + fcn + ".<init> args:"); | ||
| 898 | + if (p.args != null) for (int i = 0; i < p.args.length; i++) { | ||
| 899 | + Object a = p.args[i]; | ||
| 900 | + String vs; | ||
| 901 | + if (a == null) vs = "null"; | ||
| 902 | + else if (a instanceof byte[]) vs = "byte[" + ((byte[])a).length + "]"; | ||
| 903 | + else if (a instanceof String || a instanceof Number || a instanceof Boolean) | ||
| 904 | + vs = a.getClass().getSimpleName() + "(" + a + ")"; | ||
| 905 | + else vs = a.getClass().getName(); | ||
| 906 | + sb.append("\n arg[").append(i).append("]=").append(vs); | ||
| 907 | + } | ||
| 908 | + // Dump the constructed object's fields too | ||
| 909 | + for (Class<?> cc = p.thisObject.getClass(); | ||
| 910 | + cc != null && !cc.equals(Object.class); cc = cc.getSuperclass()) { | ||
| 911 | + for (java.lang.reflect.Field f : cc.getDeclaredFields()) { | ||
| 912 | + if (java.lang.reflect.Modifier.isStatic(f.getModifiers())) continue; | ||
| 913 | + f.setAccessible(true); | ||
| 914 | + try { | ||
| 915 | + Object v = f.get(p.thisObject); | ||
| 916 | + String vs; | ||
| 917 | + if (v == null) vs = "null"; | ||
| 918 | + else if (v instanceof byte[]) vs = "byte[" + ((byte[])v).length + "]"; | ||
| 919 | + else if (v instanceof String || v instanceof Number || v instanceof Boolean) vs = v.toString(); | ||
| 920 | + else vs = v.getClass().getSimpleName(); | ||
| 921 | + sb.append("\n this.").append(f.getName()).append("=").append(vs); | ||
| 922 | + } catch (Throwable ignored) {} | ||
| 923 | + } | ||
| 924 | + } | ||
| 925 | + XposedBridge.log(sb.toString()); | ||
| 926 | + } | ||
| 927 | + }); | ||
| 928 | + } | ||
| 929 | + XposedBridge.log("[WeChatHook] hookVoiceEntry: " + cn + " ctor hooked in " + procName); | ||
| 930 | + } catch (Throwable t) { | ||
| 931 | + XposedBridge.log("[WeChatHook] hookVoiceEntry " + cn + " failed: " + t); | ||
| 932 | + } | ||
| 933 | + } | ||
| 934 | + } | ||
| 935 | + | ||
| 936 | + // ── Hook jg0.j / jg0.x / g9 ───────────────────────────────────────────── | ||
| 937 | + // Reveals the internal structure needed to call pt0.e0.r() synthetically. | ||
| 938 | + // jg0.j captures the g9 (voice message / f9 subclass) as a field. | ||
| 939 | + // jg0.x.D sets up jg0.j and starts the coroutine. | ||
| 940 | + // g9 extends f9 (or is f9) with M9/N9 DB-insert methods. | ||
| 941 | + private void hookJg0(ClassLoader cl, String procName) { | ||
| 942 | + // ── jg0.j (the inner coroutine whose invokeSuspend calls pt0.e0.r) ──── | ||
| 943 | + try { | ||
| 944 | + Class<?> jClass = XposedHelpers.findClass("jg0.j", cl); | ||
| 945 | + StringBuilder jInfo = new StringBuilder("[WeChatHook] jg0.j class=" + jClass.getName() | ||
| 946 | + + " super=" + jClass.getSuperclass().getName() + " ownFields:"); | ||
| 947 | + for (java.lang.reflect.Field f : jClass.getDeclaredFields()) | ||
| 948 | + jInfo.append(" ").append(f.getName()).append("(").append(f.getType().getSimpleName()).append(")"); | ||
| 949 | + jInfo.append(" | superFields:"); | ||
| 950 | + for (java.lang.reflect.Field f : jClass.getSuperclass().getDeclaredFields()) | ||
| 951 | + jInfo.append(" ").append(f.getName()).append("(").append(f.getType().getSimpleName()).append(")"); | ||
| 952 | + XposedBridge.log(jInfo.toString()); | ||
| 953 | + | ||
| 954 | + // Hook invokeSuspend — fires every time a jg0.j coroutine step runs. | ||
| 955 | + // We only care about the FIRST call (state=0, label=0) to see captured fields. | ||
| 956 | + try { | ||
| 957 | + XposedHelpers.findAndHookMethod(jClass, "invokeSuspend", Object.class, | ||
| 958 | + new XC_MethodHook() { | ||
| 959 | + private volatile boolean sLogged = false; | ||
| 960 | + @Override protected void beforeHookedMethod(MethodHookParam p) { | ||
| 961 | + if (sLogged) return; | ||
| 962 | + sLogged = true; | ||
| 963 | + StringBuilder sb = new StringBuilder( | ||
| 964 | + "[WeChatHook] jg0.j.invokeSuspend FIRST fields:"); | ||
| 965 | + for (Class<?> c = p.thisObject.getClass(); | ||
| 966 | + c != null && !c.equals(Object.class); c = c.getSuperclass()) { | ||
| 967 | + for (java.lang.reflect.Field f : c.getDeclaredFields()) { | ||
| 968 | + if (java.lang.reflect.Modifier.isStatic(f.getModifiers())) continue; | ||
| 969 | + f.setAccessible(true); | ||
| 970 | + try { | ||
| 971 | + Object v = f.get(p.thisObject); | ||
| 972 | + sb.append("\n ").append(c.getSimpleName()) | ||
| 973 | + .append(".").append(f.getName()).append("=") | ||
| 974 | + .append(v == null ? "null" | ||
| 975 | + : v.getClass().getName() + "@" + System.identityHashCode(v)); | ||
| 976 | + } catch (Throwable ignored) {} | ||
| 977 | + } | ||
| 978 | + } | ||
| 979 | + XposedBridge.log(sb.toString()); | ||
| 980 | + } | ||
| 981 | + }); | ||
| 982 | + } catch (Throwable t) { | ||
| 983 | + XposedBridge.log("[WeChatHook] hookJg0 jg0.j.invokeSuspend hook failed: " + t); | ||
| 984 | + } | ||
| 985 | + XposedBridge.log("[WeChatHook] hookJg0: jg0.j hooked in " + procName); | ||
| 986 | + } catch (Throwable t) { | ||
| 987 | + XposedBridge.log("[WeChatHook] hookJg0 jg0.j failed: " + t); | ||
| 988 | + } | ||
| 989 | + | ||
| 990 | + // ── jg0.x (coordinator that creates jg0.j and calls D to enqueue send) ── | ||
| 991 | + try { | ||
| 992 | + Class<?> xClass = XposedHelpers.findClass("jg0.x", cl); | ||
| 993 | + StringBuilder xInfo = new StringBuilder("[WeChatHook] jg0.x methods:"); | ||
| 994 | + for (java.lang.reflect.Method m : xClass.getDeclaredMethods()) | ||
| 995 | + xInfo.append(" ").append(m.getName()).append("(").append(m.getParameterTypes().length).append(")"); | ||
| 996 | + xInfo.append(" | ownFields:"); | ||
| 997 | + for (java.lang.reflect.Field f : xClass.getDeclaredFields()) | ||
| 998 | + xInfo.append(" ").append(f.getName()).append("(").append(f.getType().getSimpleName()).append(")"); | ||
| 999 | + XposedBridge.log(xInfo.toString()); | ||
| 1000 | + | ||
| 1001 | + // Hook D() — the method called from pu.o0.invokeSuspend(84) that enqueues the send | ||
| 1002 | + for (java.lang.reflect.Method m : xClass.getDeclaredMethods()) { | ||
| 1003 | + if (!"D".equals(m.getName())) continue; | ||
| 1004 | + m.setAccessible(true); | ||
| 1005 | + final String sig = m.toGenericString(); | ||
| 1006 | + XposedBridge.hookMethod(m, new XC_MethodHook() { | ||
| 1007 | + @Override protected void beforeHookedMethod(MethodHookParam p) { | ||
| 1008 | + StringBuilder sb = new StringBuilder( | ||
| 1009 | + "[WeChatHook] jg0.x.D ENTER sig=" + sig); | ||
| 1010 | + // args | ||
| 1011 | + if (p.args != null) for (int i = 0; i < p.args.length; i++) { | ||
| 1012 | + Object a = p.args[i]; | ||
| 1013 | + sb.append("\n arg[").append(i).append("]=") | ||
| 1014 | + .append(a == null ? "null" : a.getClass().getName() | ||
| 1015 | + + "@" + System.identityHashCode(a)); | ||
| 1016 | + // If it looks like a voice-msg / f9 subclass, dump its fields | ||
| 1017 | + if (a != null) { | ||
| 1018 | + for (Class<?> c2 = a.getClass(); | ||
| 1019 | + c2 != null && !c2.equals(Object.class); c2 = c2.getSuperclass()) { | ||
| 1020 | + for (java.lang.reflect.Field f2 : c2.getDeclaredFields()) { | ||
| 1021 | + if (java.lang.reflect.Modifier.isStatic(f2.getModifiers())) continue; | ||
| 1022 | + f2.setAccessible(true); | ||
| 1023 | + try { | ||
| 1024 | + Object v2 = f2.get(a); | ||
| 1025 | + String vs; | ||
| 1026 | + if (v2 == null) vs = "null"; | ||
| 1027 | + else if (v2 instanceof byte[]) vs = "byte[" + ((byte[])v2).length + "]"; | ||
| 1028 | + else if (v2 instanceof String || v2 instanceof Number | ||
| 1029 | + || v2 instanceof Boolean) vs = v2.toString(); | ||
| 1030 | + else vs = v2.getClass().getSimpleName(); | ||
| 1031 | + sb.append("\n ").append(c2.getSimpleName()) | ||
| 1032 | + .append(".").append(f2.getName()).append("=").append(vs); | ||
| 1033 | + } catch (Throwable ignored2) {} | ||
| 1034 | + } | ||
| 1035 | + } | ||
| 1036 | + } | ||
| 1037 | + } | ||
| 1038 | + // thisObject fields | ||
| 1039 | + for (Class<?> c = p.thisObject.getClass(); | ||
| 1040 | + c != null && !c.equals(Object.class); c = c.getSuperclass()) { | ||
| 1041 | + for (java.lang.reflect.Field f : c.getDeclaredFields()) { | ||
| 1042 | + if (java.lang.reflect.Modifier.isStatic(f.getModifiers())) continue; | ||
| 1043 | + f.setAccessible(true); | ||
| 1044 | + try { | ||
| 1045 | + Object v = f.get(p.thisObject); | ||
| 1046 | + sb.append("\n this.").append(c.getSimpleName()) | ||
| 1047 | + .append(".").append(f.getName()).append("=") | ||
| 1048 | + .append(v == null ? "null" : v.getClass().getName()); | ||
| 1049 | + } catch (Throwable ignored) {} | ||
| 1050 | + } | ||
| 1051 | + } | ||
| 1052 | + XposedBridge.log(sb.toString()); | ||
| 1053 | + } | ||
| 1054 | + }); | ||
| 1055 | + } | ||
| 1056 | + XposedBridge.log("[WeChatHook] hookJg0: jg0.x hooked in " + procName); | ||
| 1057 | + } catch (Throwable t) { | ||
| 1058 | + XposedBridge.log("[WeChatHook] hookJg0 jg0.x failed: " + t); | ||
| 1059 | + } | ||
| 1060 | + | ||
| 1061 | + // ── g9 (likely a subclass of f9; M9/N9 are the DB-insert methods) ───── | ||
| 1062 | + try { | ||
| 1063 | + Class<?> g9Class = XposedHelpers.findClass("com.tencent.mm.storage.g9", cl); | ||
| 1064 | + StringBuilder g9Info = new StringBuilder("[WeChatHook] g9 super=" | ||
| 1065 | + + g9Class.getSuperclass().getName() + " methods:"); | ||
| 1066 | + for (java.lang.reflect.Method m : g9Class.getDeclaredMethods()) | ||
| 1067 | + g9Info.append(" ").append(m.getName()).append("(").append(m.getParameterTypes().length).append(")"); | ||
| 1068 | + g9Info.append(" | ownFields:"); | ||
| 1069 | + for (java.lang.reflect.Field f : g9Class.getDeclaredFields()) | ||
| 1070 | + g9Info.append(" ").append(f.getName()).append("(").append(f.getType().getSimpleName()).append(")"); | ||
| 1071 | + XposedBridge.log(g9Info.toString()); | ||
| 1072 | + | ||
| 1073 | + for (java.lang.reflect.Method m : g9Class.getDeclaredMethods()) { | ||
| 1074 | + if (!"M9".equals(m.getName()) && !"N9".equals(m.getName())) continue; | ||
| 1075 | + m.setAccessible(true); | ||
| 1076 | + final String mName2 = m.getName(); | ||
| 1077 | + final String mSig2 = m.toGenericString(); | ||
| 1078 | + XposedBridge.hookMethod(m, new XC_MethodHook() { | ||
| 1079 | + @Override protected void beforeHookedMethod(MethodHookParam p) { | ||
| 1080 | + StringBuilder sb = new StringBuilder( | ||
| 1081 | + "[WeChatHook] g9." + mName2 + " ENTER sig=" + mSig2 | ||
| 1082 | + + " thisClass=" + p.thisObject.getClass().getName()); | ||
| 1083 | + if (p.args != null) for (int i = 0; i < p.args.length; i++) | ||
| 1084 | + sb.append(" arg[").append(i).append("]=") | ||
| 1085 | + .append(p.args[i] == null ? "null" : p.args[i].getClass().getName()); | ||
| 1086 | + XposedBridge.log(sb.toString()); | ||
| 1087 | + } | ||
| 1088 | + @Override protected void afterHookedMethod(MethodHookParam p) { | ||
| 1089 | + if (p.hasThrowable()) | ||
| 1090 | + XposedBridge.log("[WeChatHook] g9." + mName2 + " THREW: " + p.getThrowable()); | ||
| 1091 | + else | ||
| 1092 | + XposedBridge.log("[WeChatHook] g9." + mName2 + " OK ret=" + p.getResult()); | ||
| 1093 | + } | ||
| 1094 | + }); | ||
| 1095 | + } | ||
| 1096 | + XposedBridge.log("[WeChatHook] hookJg0: g9 hooked in " + procName); | ||
| 1097 | + } catch (Throwable t) { | ||
| 1098 | + XposedBridge.log("[WeChatHook] hookJg0 g9 failed: " + t); | ||
| 1099 | + } | ||
| 1100 | + | ||
| 1101 | + // ── pu.o0 (outer coroutine that calls jg0.x.D) ─────────────────────── | ||
| 1102 | + // Hook invokeSuspend to see what it captures (especially the g9/voice data). | ||
| 1103 | + try { | ||
| 1104 | + Class<?> puClass = XposedHelpers.findClass("pu.o0", cl); | ||
| 1105 | + StringBuilder puInfo = new StringBuilder("[WeChatHook] pu.o0 super=" | ||
| 1106 | + + puClass.getSuperclass().getName() + " | ownFields:"); | ||
| 1107 | + for (java.lang.reflect.Field f : puClass.getDeclaredFields()) | ||
| 1108 | + puInfo.append(" ").append(f.getName()).append("(").append(f.getType().getSimpleName()).append(")"); | ||
| 1109 | + XposedBridge.log(puInfo.toString()); | ||
| 1110 | + | ||
| 1111 | + XposedHelpers.findAndHookMethod(puClass, "invokeSuspend", Object.class, | ||
| 1112 | + new XC_MethodHook() { | ||
| 1113 | + private volatile boolean sLogged2 = false; | ||
| 1114 | + @Override protected void beforeHookedMethod(MethodHookParam p) { | ||
| 1115 | + if (sLogged2) return; | ||
| 1116 | + sLogged2 = true; | ||
| 1117 | + StringBuilder sb = new StringBuilder( | ||
| 1118 | + "[WeChatHook] pu.o0.invokeSuspend FIRST fields:"); | ||
| 1119 | + for (Class<?> c = p.thisObject.getClass(); | ||
| 1120 | + c != null && !c.equals(Object.class); c = c.getSuperclass()) { | ||
| 1121 | + for (java.lang.reflect.Field f : c.getDeclaredFields()) { | ||
| 1122 | + if (java.lang.reflect.Modifier.isStatic(f.getModifiers())) continue; | ||
| 1123 | + f.setAccessible(true); | ||
| 1124 | + try { | ||
| 1125 | + Object v = f.get(p.thisObject); | ||
| 1126 | + sb.append("\n ").append(c.getSimpleName()) | ||
| 1127 | + .append(".").append(f.getName()).append("=") | ||
| 1128 | + .append(v == null ? "null" | ||
| 1129 | + : v.getClass().getName() + "@" + System.identityHashCode(v)); | ||
| 1130 | + } catch (Throwable ignored) {} | ||
| 1131 | + } | ||
| 1132 | + } | ||
| 1133 | + XposedBridge.log(sb.toString()); | ||
| 1134 | + } | ||
| 1135 | + }); | ||
| 1136 | + XposedBridge.log("[WeChatHook] hookJg0: pu.o0 hooked in " + procName); | ||
| 1137 | + } catch (Throwable t) { | ||
| 1138 | + XposedBridge.log("[WeChatHook] hookJg0 pu.o0 failed: " + t); | ||
| 1139 | + } | ||
| 1140 | + } | ||
| 1141 | + | ||
| 1142 | + private void hookR0H(ClassLoader cl, String procName) { | ||
| 1143 | + try { | ||
| 1144 | + Class<?> r0Class = XposedHelpers.findClass("w11.r0", cl); | ||
| 1145 | + int hooked = 0; | ||
| 1146 | + // Hook ALL methods named H on r0 — real voice sends may use H() or H(int), not just H(String). | ||
| 1147 | + for (java.lang.reflect.Method m : r0Class.getDeclaredMethods()) { | ||
| 1148 | + if (!m.getName().equals("H")) continue; | ||
| 1149 | + final String sig = m.toGenericString(); | ||
| 1150 | + final boolean isHString = m.getParameterTypes().length == 1 | ||
| 1151 | + && m.getParameterTypes()[0] == String.class; | ||
| 1152 | + m.setAccessible(true); | ||
| 1153 | + XposedBridge.hookMethod(m, new XC_MethodHook() { | ||
| 1154 | + @Override | ||
| 1155 | + protected void beforeHookedMethod(MethodHookParam p) throws Throwable { | ||
| 1156 | + String by = sCalledByUs ? "US" : "WeChat"; | ||
| 1157 | + XposedBridge.log("[WeChatHook] r0." + sig + " ENTER by=" + by + " proc=" + procName); | ||
| 1158 | + // Capture real dispatcher from any real WeChat r0 H() call. | ||
| 1159 | + if (!sCalledByUs) { | ||
| 1160 | + try { | ||
| 1161 | + Object disp = XposedHelpers.getObjectField(p.thisObject, "dispatcher"); | ||
| 1162 | + if (disp != null && !(disp instanceof java.lang.reflect.Proxy)) { | ||
| 1163 | + sCapturedDispatcher = disp; | ||
| 1164 | + XposedBridge.log("[WeChatHook] hookR0H: REAL dispatcher=" + disp.getClass().getName() + " from " + sig); | ||
| 1165 | + } | ||
| 1166 | + } catch (Throwable ignored) {} | ||
| 1167 | + // Also dump r0 fields on first real send to capture full state. | ||
| 1168 | + dumpR0Fields(p.thisObject, "r0.H-WeChat by=" + by + " proc=" + procName); | ||
| 1169 | + } else if (isHString) { | ||
| 1170 | + dumpR0Fields(p.thisObject, "r0.H-ENTER by=US proc=" + procName); | ||
| 1171 | + } | ||
| 1172 | + } | ||
| 1173 | + @Override | ||
| 1174 | + protected void afterHookedMethod(MethodHookParam p) throws Throwable { | ||
| 1175 | + String by = sCalledByUs ? "US" : "WeChat"; | ||
| 1176 | + XposedBridge.log("[WeChatHook] r0.H() DONE by=" + by + " proc=" + procName | ||
| 1177 | + + (p.hasThrowable() ? " THREW=" + p.getThrowable() : " OK")); | ||
| 1178 | + } | ||
| 1179 | + }); | ||
| 1180 | + hooked++; | ||
| 1181 | + } | ||
| 1182 | + XposedBridge.log("[WeChatHook] hookR0H: hooked " + hooked + " H() overloads in " + procName); | ||
| 1183 | + } catch (Throwable t) { | ||
| 1184 | + XposedBridge.log("[WeChatHook] hookR0H failed in " + procName + ": " + t); | ||
| 1185 | + } | ||
| 1186 | + } | ||
| 1187 | + | ||
| 1188 | + // ── Capture live network dispatcher (com.tencent.mm.modelbase.m2) ──────────── | ||
| 1189 | + // m2 = MicroMsg.RDispatcher, the com.tencent.mm.network.s the send scene needs. | ||
| 1190 | + // We grab the instance at construction (network init, before any synthetic send) | ||
| 1191 | + // into sCapturedDispatcher, so sendVoiceViaAPI can hand r0 the REAL dispatcher. | ||
| 1192 | + private void hookDispatcher(ClassLoader cl, String procName) { | ||
| 1193 | + try { | ||
| 1194 | + Class<?> m2 = XposedHelpers.findClass("com.tencent.mm.modelbase.m2", cl); | ||
| 1195 | + XC_MethodHook grab = new XC_MethodHook() { | ||
| 1196 | + @Override protected void beforeHookedMethod(MethodHookParam p) { | ||
| 1197 | + if (sCapturedDispatcher == null && p.thisObject != null | ||
| 1198 | + && !(p.thisObject instanceof java.lang.reflect.Proxy)) { | ||
| 1199 | + sCapturedDispatcher = p.thisObject; | ||
| 1200 | + XposedBridge.log("[WeChatHook] hookDispatcher: captured REAL m2=" | ||
| 1201 | + + p.thisObject + " in " + procName); | ||
| 1202 | + } | ||
| 1203 | + } | ||
| 1204 | + }; | ||
| 1205 | + int n = 0; | ||
| 1206 | + // Ctor: catches m2 constructed AFTER we install (network re-init). | ||
| 1207 | + for (java.lang.reflect.Constructor<?> c : m2.getDeclaredConstructors()) { | ||
| 1208 | + c.setAccessible(true); | ||
| 1209 | + XposedBridge.hookMethod(c, new XC_MethodHook() { | ||
| 1210 | + @Override protected void afterHookedMethod(MethodHookParam p) { | ||
| 1211 | + sCapturedDispatcher = p.thisObject; | ||
| 1212 | + XposedBridge.log("[WeChatHook] hookDispatcher: captured REAL m2 (ctor)=" | ||
| 1213 | + + p.thisObject + " in " + procName); | ||
| 1214 | + } | ||
| 1215 | + }); | ||
| 1216 | + n++; | ||
| 1217 | + } | ||
| 1218 | + // Instance methods: catches an ALREADY-existing m2 (constructed before our | ||
| 1219 | + // hook / persistent connection) on its next network call. After capture the | ||
| 1220 | + // callback is a single null check — negligible overhead. | ||
| 1221 | + int mh = 0; | ||
| 1222 | + for (java.lang.reflect.Method m : m2.getDeclaredMethods()) { | ||
| 1223 | + if (java.lang.reflect.Modifier.isStatic(m.getModifiers())) continue; | ||
| 1224 | + if (java.lang.reflect.Modifier.isAbstract(m.getModifiers())) continue; | ||
| 1225 | + try { m.setAccessible(true); XposedBridge.hookMethod(m, grab); mh++; } | ||
| 1226 | + catch (Throwable ignored) {} | ||
| 1227 | + } | ||
| 1228 | + XposedBridge.log("[WeChatHook] hookDispatcher: hooked " + n + " ctor(s) + " | ||
| 1229 | + + mh + " method(s) on m2 in " + procName); | ||
| 1230 | + } catch (Throwable t) { | ||
| 1231 | + XposedBridge.log("[WeChatHook] hookDispatcher failed in " + procName + ": " + t); | ||
| 1232 | + } | ||
| 1233 | + } | ||
| 1234 | + | ||
| 1235 | + // ── Instrument voice-upload service w21.g1 (voiceinfo table) ───────────────── | ||
| 1236 | + // On a REAL voice send, WeChat builds a w21.w0 upload-task and calls g1.L0(w0) | ||
| 1237 | + // (insert) / g1.P0(file, w0) (upload). Dump every w0 field on those calls so we | ||
| 1238 | + // learn the exact template (FileName/ClientId/MsgLocalId/VoiceLength/MsgTalker/ | ||
| 1239 | + // Status correlation) needed to synthesize the upload for hook-sent voice. | ||
| 1240 | + // ── Voice hot-swap: replace recorded SILK with our TTS at the CDN-upload step ─ | ||
| 1241 | + // jg0.x.w(jg0.x self, qi3.d0, w21.w0, int type, int durationMs, w21.t silk, Cont) | ||
| 1242 | + // is WeChat's uploadAttach step (confirmed by real-recording capture + tinker | ||
| 1243 | + // decompile). w21.t has exactly one byte[] field (f443506a) = the SILK audio | ||
| 1244 | + // about to be sent to CDN. When armed, we overwrite that byte[] with our TTS | ||
| 1245 | + // SILK and the duration int (arg4). WeChat then uploads + delivers OUR audio, | ||
| 1246 | + // reusing the entire real send pipeline we cannot rebuild. | ||
| 1247 | + // Hook runs in the process where recording happens (main). Verified swap target, | ||
| 1248 | + // but does its own field dump on every fire so a first real test confirms layout. | ||
| 1249 | + private void hookVoiceSwap(ClassLoader cl, String procName) { | ||
| 1250 | + try { | ||
| 1251 | + Class<?> xClass = XposedHelpers.findClass("jg0.x", cl); | ||
| 1252 | + int hooked = 0; | ||
| 1253 | + for (java.lang.reflect.Method m : xClass.getDeclaredMethods()) { | ||
| 1254 | + if (!"w".equals(m.getName())) continue; // hook EVERY 'w' overload | ||
| 1255 | + m.setAccessible(true); | ||
| 1256 | + XposedBridge.hookMethod(m, new XC_MethodHook() { | ||
| 1257 | + @Override protected void beforeHookedMethod(MethodHookParam p) { | ||
| 1258 | + // Absolute-first, arg-safe entry log so we KNOW the hook runs. | ||
| 1259 | + int n = (p.args == null) ? 0 : p.args.length; | ||
| 1260 | + XposedBridge.log("[WeChatHook] SWAPHOOK ENTER w nargs=" + n + " armed=" + sSwapArmed); | ||
| 1261 | + try { | ||
| 1262 | + // Real uploadAttach call: (jg0.x, qi3.d0, w21.w0, int, int, w21.t, Cont). | ||
| 1263 | + // Coroutine resumptions re-call w with nulls — skip those. | ||
| 1264 | + if (n != 7 || p.args[5] == null) return; | ||
| 1265 | + Object tHolder = p.args[5]; // w21.t (SILK holder) | ||
| 1266 | + java.lang.reflect.Field byteF = null; | ||
| 1267 | + for (java.lang.reflect.Field f : tHolder.getClass().getDeclaredFields()) { | ||
| 1268 | + if (f.getType() == byte[].class) { byteF = f; break; } | ||
| 1269 | + } | ||
| 1270 | + if (byteF == null) return; | ||
| 1271 | + byteF.setAccessible(true); | ||
| 1272 | + byte[] orig = (byte[]) byteF.get(tHolder); | ||
| 1273 | + XposedBridge.log("[WeChatHook] SWAPHOOK real w: silk=" | ||
| 1274 | + + (orig == null ? "null" : orig.length + "B") + " durArg=" + p.args[4] | ||
| 1275 | + + " holder=" + tHolder.getClass().getName()); | ||
| 1276 | + if (!sSwapArmed || sSwapSilk == null) return; | ||
| 1277 | + | ||
| 1278 | + byteF.set(tHolder, sSwapSilk); | ||
| 1279 | + if (orig != null) { | ||
| 1280 | + for (java.lang.reflect.Field f : tHolder.getClass().getDeclaredFields()) { | ||
| 1281 | + if (f.getType() != int.class) continue; | ||
| 1282 | + f.setAccessible(true); | ||
| 1283 | + if (f.getInt(tHolder) == orig.length) f.setInt(tHolder, sSwapSilk.length); | ||
| 1284 | + } | ||
| 1285 | + } | ||
| 1286 | + if (sSwapDuration > 0) p.args[4] = sSwapDuration; | ||
| 1287 | + XposedBridge.log("[WeChatHook] VOICE SWAP DONE: " | ||
| 1288 | + + (orig == null ? "?" : orig.length) + "B -> " + sSwapSilk.length | ||
| 1289 | + + "B, dur -> " + p.args[4]); | ||
| 1290 | + sSwapArmed = false; // one-shot | ||
| 1291 | + sSwapSilk = null; | ||
| 1292 | + } catch (Throwable t) { | ||
| 1293 | + XposedBridge.log("[WeChatHook] VOICE SWAP failed: " + t | ||
| 1294 | + + "\n" + android.util.Log.getStackTraceString(t)); | ||
| 1295 | + } | ||
| 1296 | + } | ||
| 1297 | + }); | ||
| 1298 | + hooked++; | ||
| 1299 | + } | ||
| 1300 | + XposedBridge.log("[WeChatHook] hookVoiceSwap: hooked jg0.x.w x" + hooked + " in " + procName); | ||
| 1301 | + } catch (Throwable t) { | ||
| 1302 | + XposedBridge.log("[WeChatHook] hookVoiceSwap failed in " + procName + ": " + t); | ||
| 1303 | + } | ||
| 1304 | + } | ||
| 1305 | + | ||
| 1306 | + private void hookVoiceUpload(ClassLoader cl, String procName) { | ||
| 1307 | + try { | ||
| 1308 | + Class<?> g1 = XposedHelpers.findClass("w21.g1", cl); | ||
| 1309 | + int mh = 0; | ||
| 1310 | + for (java.lang.reflect.Method m : g1.getDeclaredMethods()) { | ||
| 1311 | + final String mName = m.getName(); | ||
| 1312 | + // L0(w0)=insert, P0(file,w0)=upload, D0/J0/y0/z0=lookups. Dump w0 on all. | ||
| 1313 | + if (!("L0".equals(mName) || "P0".equals(mName))) continue; | ||
| 1314 | + m.setAccessible(true); | ||
| 1315 | + XposedBridge.hookMethod(m, new XC_MethodHook() { | ||
| 1316 | + @Override protected void beforeHookedMethod(MethodHookParam p) { | ||
| 1317 | + try { | ||
| 1318 | + // find the w0 arg (w21.w0) among args | ||
| 1319 | + Object w0 = null; | ||
| 1320 | + if (p.args != null) for (Object a : p.args) | ||
| 1321 | + if (a != null && a.getClass().getName().equals("w21.w0")) { w0 = a; break; } | ||
| 1322 | + StringBuilder sb = new StringBuilder("[WeChatHook] g1." + mName | ||
| 1323 | + + " by=" + (sCalledByUs ? "US" : "REAL") + " args=["); | ||
| 1324 | + if (p.args != null) for (int i = 0; i < p.args.length; i++) { | ||
| 1325 | + Object a = p.args[i]; | ||
| 1326 | + sb.append(i > 0 ? ", " : "").append(a == null ? "null" | ||
| 1327 | + : (a instanceof String ? "\"" + a + "\"" : a.getClass().getSimpleName())); | ||
| 1328 | + } | ||
| 1329 | + sb.append("]"); | ||
| 1330 | + if (w0 != null) { | ||
| 1331 | + sb.append("\n w21.w0 DUMP:"); | ||
| 1332 | + for (java.lang.reflect.Field f : w0.getClass().getDeclaredFields()) { | ||
| 1333 | + if (java.lang.reflect.Modifier.isStatic(f.getModifiers())) continue; | ||
| 1334 | + f.setAccessible(true); | ||
| 1335 | + Object v = f.get(w0); | ||
| 1336 | + String vs = v == null ? "null" | ||
| 1337 | + : (v instanceof byte[] ? "byte[" + ((byte[]) v).length + "]" | ||
| 1338 | + : (v instanceof String || v instanceof Number || v instanceof Boolean ? v.toString() | ||
| 1339 | + : v.getClass().getSimpleName())); | ||
| 1340 | + sb.append("\n ").append(f.getName()).append("=").append(vs); | ||
| 1341 | + } | ||
| 1342 | + } | ||
| 1343 | + XposedBridge.log(sb.toString()); | ||
| 1344 | + } catch (Throwable ignored) {} | ||
| 1345 | + } | ||
| 1346 | + }); | ||
| 1347 | + mh++; | ||
| 1348 | + } | ||
| 1349 | + XposedBridge.log("[WeChatHook] hookVoiceUpload: hooked " + mh + " g1 method(s) in " + procName); | ||
| 1350 | + } catch (Throwable t) { | ||
| 1351 | + XposedBridge.log("[WeChatHook] hookVoiceUpload failed in " + procName + ": " + t); | ||
| 1352 | + } | ||
| 1353 | + } | ||
| 1354 | + | ||
| 1355 | + // ── Image send: hook b80.u.pj/Bi (the real non-suspend send entry) ─────────── | ||
| 1356 | + // b80.u (implements o70.e, = n0.c(o70.e.class)) is the image plugin. The send | ||
| 1357 | + // coroutine b80.s calls b80.u.Bi(o70.h,String) then b80.u.pj(o70.h). We hook both | ||
| 1358 | + // to (1) capture the live b80.u instance and (2) on a REAL image send, dump the | ||
| 1359 | + // full o70.h (PreBuildParams) field-by-field — that's the exact recipe to replicate. | ||
| 1360 | + private static void dumpO70h(Object h, String tag) { | ||
| 1361 | + try { | ||
| 1362 | + StringBuilder sb = new StringBuilder("[WeChatHook] " + tag + " o70.h DUMP:"); | ||
| 1363 | + for (java.lang.reflect.Field f : h.getClass().getDeclaredFields()) { | ||
| 1364 | + if (java.lang.reflect.Modifier.isStatic(f.getModifiers())) continue; | ||
| 1365 | + f.setAccessible(true); | ||
| 1366 | + Object v = f.get(h); | ||
| 1367 | + String vs = v == null ? "null" | ||
| 1368 | + : (v instanceof byte[] ? "byte[" + ((byte[]) v).length + "]" | ||
| 1369 | + : (v instanceof String || v instanceof Number || v instanceof Boolean || v instanceof android.net.Uri | ||
| 1370 | + ? v.toString() : v.getClass().getName() + "@" + System.identityHashCode(v))); | ||
| 1371 | + sb.append("\n ").append(f.getName()).append(" (").append(f.getType().getSimpleName()) | ||
| 1372 | + .append(") = ").append(vs); | ||
| 1373 | + } | ||
| 1374 | + XposedBridge.log(sb.toString()); | ||
| 1375 | + } catch (Throwable t) { XposedBridge.log("[WeChatHook] dumpO70h err: " + t); } | ||
| 1376 | + } | ||
| 1377 | + | ||
| 1378 | + // Hook SendImgProxyUI.onCreate → dump the launch Intent extras. Real gallery image | ||
| 1379 | + // sends run through this proxy Activity ($$a.run → b80.s coroutine → send). Capturing | ||
| 1380 | + // its Intent lets us replay the WHOLE real flow via `am start` with the same extras. | ||
| 1381 | + private void hookSendImgProxyUI(ClassLoader cl, String procName) { | ||
| 1382 | + try { | ||
| 1383 | + XposedHelpers.findAndHookMethod( | ||
| 1384 | + "com.tencent.mm.ui.chatting.SendImgProxyUI", cl, "onCreate", | ||
| 1385 | + android.os.Bundle.class, new XC_MethodHook() { | ||
| 1386 | + @Override protected void afterHookedMethod(MethodHookParam p) { | ||
| 1387 | + try { | ||
| 1388 | + android.content.Intent it = (android.content.Intent) | ||
| 1389 | + XposedHelpers.callMethod(p.thisObject, "getIntent"); | ||
| 1390 | + if (it == null) { XposedBridge.log("[WeChatHook] SendImgProxyUI: intent null"); return; } | ||
| 1391 | + android.os.Bundle ex = it.getExtras(); | ||
| 1392 | + StringBuilder sb = new StringBuilder("[WeChatHook] SendImgProxyUI INTENT:"); | ||
| 1393 | + sb.append("\n action=").append(it.getAction()); | ||
| 1394 | + sb.append("\n data=").append(it.getDataString()); | ||
| 1395 | + if (ex != null) for (String key : ex.keySet()) { | ||
| 1396 | + Object v = ex.get(key); | ||
| 1397 | + String vs = v == null ? "null" | ||
| 1398 | + : (v instanceof java.util.ArrayList ? "ArrayList" + v | ||
| 1399 | + : (v.getClass().isArray() ? v.getClass().getSimpleName() + java.util.Arrays.deepToString(new Object[]{v}) | ||
| 1400 | + : v.toString())); | ||
| 1401 | + if (vs.length() > 300) vs = vs.substring(0, 300) + "…"; | ||
| 1402 | + sb.append("\n [").append(key).append("] (") | ||
| 1403 | + .append(v == null ? "?" : v.getClass().getName()).append(") = ").append(vs); | ||
| 1404 | + } | ||
| 1405 | + XposedBridge.log(sb.toString()); | ||
| 1406 | + } catch (Throwable t) { XposedBridge.log("[WeChatHook] SendImgProxyUI dump err: " + t); } | ||
| 1407 | + } | ||
| 1408 | + }); | ||
| 1409 | + XposedBridge.log("[WeChatHook] hookSendImgProxyUI OK in " + procName); | ||
| 1410 | + } catch (Throwable t) { | ||
| 1411 | + XposedBridge.log("[WeChatHook] hookSendImgProxyUI failed in " + procName + ": " + t); | ||
| 1412 | + } | ||
| 1413 | + } | ||
| 1414 | + | ||
| 1415 | + private void hookImageSend(ClassLoader cl, String procName) { | ||
| 1416 | + try { | ||
| 1417 | + Class<?> u = XposedHelpers.findClass("b80.u", cl); | ||
| 1418 | + int n = 0; | ||
| 1419 | + for (java.lang.reflect.Method m : u.getDeclaredMethods()) { | ||
| 1420 | + final String mn = m.getName(); | ||
| 1421 | + if (!("pj".equals(mn) || "Bi".equals(mn) || "oj".equals(mn))) continue; | ||
| 1422 | + Class<?>[] pts = m.getParameterTypes(); | ||
| 1423 | + if (pts.length < 1) continue; | ||
| 1424 | + final boolean isOj = "oj".equals(mn); // oj(o70.k) = the insert/send step | ||
| 1425 | + if (!isOj && !pts[0].getName().equals("o70.h")) continue; | ||
| 1426 | + m.setAccessible(true); | ||
| 1427 | + XposedBridge.hookMethod(m, new XC_MethodHook() { | ||
| 1428 | + @Override protected void beforeHookedMethod(MethodHookParam p) { | ||
| 1429 | + if (sImgPlugin == null && p.thisObject != null) { | ||
| 1430 | + sImgPlugin = p.thisObject; | ||
| 1431 | + XposedBridge.log("[WeChatHook] hookImageSend: captured b80.u=" + p.thisObject); | ||
| 1432 | + } | ||
| 1433 | + if (!sCalledByUs && p.args != null && p.args.length > 0 && p.args[0] != null) { | ||
| 1434 | + if (isOj) { | ||
| 1435 | + // o70.k — the insert object. Dump its full field tree. | ||
| 1436 | + Object k = p.args[0]; | ||
| 1437 | + sRealImgK = k; | ||
| 1438 | + XposedBridge.log("[WeChatHook] b80.u.oj REAL o70.k class=" + k.getClass().getName()); | ||
| 1439 | + dumpO70h(k, "b80.u.oj REAL o70.k"); | ||
| 1440 | + } else { | ||
| 1441 | + sRealImgParams = p.args[0]; | ||
| 1442 | + dumpO70h(p.args[0], "b80.u." + mn + " REAL"); | ||
| 1443 | + } | ||
| 1444 | + } | ||
| 1445 | + } | ||
| 1446 | + }); | ||
| 1447 | + n++; | ||
| 1448 | + } | ||
| 1449 | + XposedBridge.log("[WeChatHook] hookImageSend: hooked " + n + " b80.u method(s) in " + procName); | ||
| 1450 | + } catch (Throwable t) { | ||
| 1451 | + XposedBridge.log("[WeChatHook] hookImageSend failed in " + procName + ": " + t); | ||
| 1452 | + } | ||
| 1453 | + } | ||
| 1454 | + | ||
| 1455 | + // ── Voice send via internal API ──────────────────────────────────────────── | ||
| 1456 | + // Protocol (confirmed by hooking f9.convertTo() on real user send): | ||
| 1457 | + // • content XML: <msg><voicemsg voicelength="{ms}" fromusername="{wxid}" isPlayed="false"></voicemsg></msg> | ||
| 1458 | + // • imgPath : 26-char hex voice file ID (f9 field, determines file location) | ||
| 1459 | + // • voice2 dir : voice2/md5(voiceId)[0:2]/md5(voiceId)[2:4]/msg_{voiceId}.amr (confirmed formula) | ||
| 1460 | + // | ||
| 1461 | + // Two modes (mutually exclusive extras): | ||
| 1462 | + // (a) voice_id — caller pre-placed the SILK file in voice2 already (no file I/O here) | ||
| 1463 | + // (b) src_path — WeChat-readable path (e.g. /data/data/com.tencent.mm/cache/…); | ||
| 1464 | + // hook generates voice_id, copies src_path → voice2/{dir}/msg_{id}.amr internally | ||
| 1465 | + // (WeChat process has full access to its own data dir, unlike root which is SELinux-blocked) | ||
| 1466 | + // | ||
| 1467 | + // Typical flow for TTS voice: | ||
| 1468 | + // root: cp tts.amr /data/data/com.tencent.mm/cache/hook_voice_src.amr && chown u0_a330 ... | ||
| 1469 | + // broadcast: --es to filehelper --es src_path /data/data/com.tencent.mm/cache/hook_voice_src.amr --ei duration 3000 | ||
| 1470 | + private static void sendVoiceViaAPI(String talker, String voiceId, String srcPath, | ||
| 1471 | + int durationMs, String fromWxId) throws Throwable { | ||
| 1472 | + ClassLoader cl = sTinkerCL != null ? sTinkerCL : sWeChatCL; | ||
| 1473 | + if (cl == null) throw new IllegalStateException("classloader null"); | ||
| 1474 | + | ||
| 1475 | + // ── File placement ─────────────────────────────────────────────────── | ||
| 1476 | + if (voiceId == null || voiceId.isEmpty()) { | ||
| 1477 | + // Mode (b): generate voice_id and copy SILK from srcPath | ||
| 1478 | + if (srcPath == null || srcPath.isEmpty()) | ||
| 1479 | + throw new IllegalArgumentException("need either voice_id or src_path"); | ||
| 1480 | + byte[] idBytes = new byte[13]; | ||
| 1481 | + new java.util.Random().nextBytes(idBytes); | ||
| 1482 | + StringBuilder sb = new StringBuilder(26); | ||
| 1483 | + for (byte b : idBytes) sb.append(String.format("%02x", b & 0xff)); | ||
| 1484 | + voiceId = sb.toString(); | ||
| 1485 | + | ||
| 1486 | + java.security.MessageDigest md5 = java.security.MessageDigest.getInstance("MD5"); | ||
| 1487 | + byte[] mdBytes = md5.digest(voiceId.getBytes("UTF-8")); | ||
| 1488 | + String dir1 = String.format("%02x", mdBytes[0] & 0xff); | ||
| 1489 | + String dir2 = String.format("%02x", mdBytes[1] & 0xff); | ||
| 1490 | + | ||
| 1491 | + String mmDir = "/data/data/com.tencent.mm/MicroMsg/653cdc2320abb089e149a3f7a7683fcf"; | ||
| 1492 | + String destDir = mmDir + "/voice2/" + dir1 + "/" + dir2; | ||
| 1493 | + String destPath = destDir + "/msg_" + voiceId + ".amr"; | ||
| 1494 | + new java.io.File(destDir).mkdirs(); | ||
| 1495 | + try (java.io.FileInputStream in = new java.io.FileInputStream(srcPath); | ||
| 1496 | + java.io.FileOutputStream out = new java.io.FileOutputStream(destPath)) { | ||
| 1497 | + byte[] buf = new byte[8192]; int n; | ||
| 1498 | + while ((n = in.read(buf)) != -1) out.write(buf, 0, n); | ||
| 1499 | + } | ||
| 1500 | + new java.io.File(destPath).setReadable(true, false); | ||
| 1501 | + XposedBridge.log("[WeChatHook] sendVoiceViaAPI: " + srcPath + " -> " + destPath); | ||
| 1502 | + } else if (voiceId.length() != 26) { | ||
| 1503 | + throw new IllegalArgumentException("voice_id must be 26 hex chars, got: " + voiceId); | ||
| 1504 | + } | ||
| 1505 | + | ||
| 1506 | + // ── Build voice XML ────────────────────────────────────────────────── | ||
| 1507 | + // fromusername is the sender's wxid. sMyWxid is auto-discovered from the first | ||
| 1508 | + // real UI voice send; fall back to the known wxid for this device. | ||
| 1509 | + String safeFrom = (fromWxId != null && !fromWxId.isEmpty()) | ||
| 1510 | + ? fromWxId | ||
| 1511 | + : (sMyWxid != null ? sMyWxid : "wxid_51ap3m6p9xs722"); | ||
| 1512 | + String voiceXml = "<msg><voicemsg voicelength=\"" + durationMs | ||
| 1513 | + + "\" fromusername=\"" + safeFrom + "\" isPlayed=\"false\"></voicemsg></msg>"; | ||
| 1514 | + | ||
| 1515 | + // ── Probe f9 setter map BEFORE setting sPendingVoiceId ─────────────── | ||
| 1516 | + Class<?> f9Class = XposedHelpers.findClass("com.tencent.mm.storage.f9", cl); | ||
| 1517 | + if (sF9SetterMap == null) sF9SetterMap = probeAllF9Setters(f9Class); | ||
| 1518 | + if (!sF9SetterMap.containsKey("imgPath")) | ||
| 1519 | + throw new IllegalStateException("imgPath setter not found in map: " + sF9SetterMap); | ||
| 1520 | + | ||
| 1521 | + // ── Synthetic lvbuffer (waveform visualization, NOT the SILK audio) ──── | ||
| 1522 | + // Real WeChat always stores exactly 74 bytes regardless of duration: | ||
| 1523 | + // 4-byte header (0x7b 0x00 0x00 0x00) + 70 zero bytes. The size is fixed. | ||
| 1524 | + byte[] syntheticLv = new byte[74]; | ||
| 1525 | + syntheticLv[0] = 0x7b; | ||
| 1526 | + // bytes 1..73 default to 0x00 | ||
| 1527 | + XposedBridge.log("[WeChatHook] sendVoiceViaAPI: synthetic lvbuffer 74 bytes for " + durationMs + "ms"); | ||
| 1528 | + | ||
| 1529 | + // ── Create r0: try Object-param ctor first (real WeChat voice path) ───── | ||
| 1530 | + // WeChat has two 5-param r0 ctors: | ||
| 1531 | + // (String, String, int, int, long) — timestamp variant | ||
| 1532 | + // (String, String, int, int, Object) — audio-path variant (used by real voice sends) | ||
| 1533 | + // The Object ctor stores the audio file reference that H() reads for CDN upload. | ||
| 1534 | + // If it works, r0 sets imgPath internally and H() can find the SILK for upload. | ||
| 1535 | + // Fall back to long ctor if the Object ctor throws. | ||
| 1536 | + Class<?> r0Class = XposedHelpers.findClass("w11.r0", cl); | ||
| 1537 | + sPendingVoiceId.set(voiceId); | ||
| 1538 | + sPendingLvBuffer.set(syntheticLv); | ||
| 1539 | + sCalledByUs = true; | ||
| 1540 | + Object r0; | ||
| 1541 | + try { | ||
| 1542 | + try { | ||
| 1543 | + // Pass the voice2 path (where we placed the SILK) as the Object arg. | ||
| 1544 | + // Real WeChat passes the temp recording file; we pass our already-placed file. | ||
| 1545 | + String voiceFilePath = srcPath != null ? srcPath | ||
| 1546 | + : "/data/data/com.tencent.mm/MicroMsg/653cdc2320abb089e149a3f7a7683fcf/voice2/" | ||
| 1547 | + + String.format("%02x", java.security.MessageDigest.getInstance("MD5") | ||
| 1548 | + .digest(voiceId.getBytes("UTF-8"))[0] & 0xff) | ||
| 1549 | + + "/" + String.format("%02x", java.security.MessageDigest.getInstance("MD5") | ||
| 1550 | + .digest(voiceId.getBytes("UTF-8"))[1] & 0xff) | ||
| 1551 | + + "/msg_" + voiceId + ".amr"; | ||
| 1552 | + r0 = XposedHelpers.newInstance(r0Class, | ||
| 1553 | + new Class<?>[]{String.class, String.class, int.class, int.class, Object.class}, | ||
| 1554 | + talker, voiceXml, 34, 0, voiceFilePath); | ||
| 1555 | + XposedBridge.log("[WeChatHook] sendVoiceViaAPI: used Object-ctor with path=" + voiceFilePath); | ||
| 1556 | + } catch (Throwable e1) { | ||
| 1557 | + XposedBridge.log("[WeChatHook] sendVoiceViaAPI: Object-ctor failed (" + e1 + "), falling back to long-ctor"); | ||
| 1558 | + long ts = System.currentTimeMillis(); | ||
| 1559 | + r0 = XposedHelpers.newInstance(r0Class, | ||
| 1560 | + new Class<?>[]{String.class, String.class, int.class, int.class, long.class}, | ||
| 1561 | + talker, voiceXml, 34, 0, ts); | ||
| 1562 | + } | ||
| 1563 | + } finally { | ||
| 1564 | + sCalledByUs = false; | ||
| 1565 | + sPendingVoiceId.remove(); | ||
| 1566 | + sPendingLvBuffer.remove(); | ||
| 1567 | + } | ||
| 1568 | + // Dump r0 fields so we can see which field holds the audio path. | ||
| 1569 | + dumpR0Fields(r0, "r0-after-ctor by=US"); | ||
| 1570 | + | ||
| 1571 | + // ── Leave r0.p == null: take the AUTHENTIC new-send path ────────────────── | ||
| 1572 | + // The r0 Object-ctor already inserted a proper f9 (content=voiceXml, imgPath | ||
| 1573 | + // injected by hookF9ImgPathInjector, status=1) via cj().N9(). With p==null, | ||
| 1574 | + // NetSceneSendMsg.doScene() reloads that f9 fresh from the DB (cj().c6()) — | ||
| 1575 | + // content/msgsource intact — and assembles + dispatches it, exactly like a | ||
| 1576 | + // real new send. | ||
| 1577 | + // | ||
| 1578 | + // Previously we forced r0.p = sDatabaseF9. That sends doScene down the *resend* | ||
| 1579 | + // branch (which expects status=5 and logs "should not be resend"), handing the | ||
| 1580 | + // MsgSource assembler a stale in-memory f9 whose content reads null → | ||
| 1581 | + // e14.k.fromXml(null) NPE. That was the root-cause blocker; fixed by NOT setting p. | ||
| 1582 | + sDatabaseF9.remove(); | ||
| 1583 | + | ||
| 1584 | + // ── Set dispatcher on r0 (required for CDN upload via H()) ───────────── | ||
| 1585 | + // r0.dispatcher (type com.tencent.mm.network.s) is null in our synthetic r0. | ||
| 1586 | + // H() NPEs on `dispatcher.J(z0, o0, int)` if null. | ||
| 1587 | + // We capture dispatcher from the first real WeChat r0 that enters H() (hookR0H). | ||
| 1588 | + if (sCapturedDispatcher != null && !(sCapturedDispatcher instanceof java.lang.reflect.Proxy)) { | ||
| 1589 | + // Real dispatcher captured from a genuine WeChat send — use it directly. | ||
| 1590 | + try { | ||
| 1591 | + XposedHelpers.setObjectField(r0, "dispatcher", sCapturedDispatcher); | ||
| 1592 | + XposedBridge.log("[WeChatHook] sendVoiceViaAPI: set r0.dispatcher REAL class=" | ||
| 1593 | + + sCapturedDispatcher.getClass().getName()); | ||
| 1594 | + } catch (Throwable ed) { | ||
| 1595 | + XposedBridge.log("[WeChatHook] sendVoiceViaAPI: r0.dispatcher set failed: " + ed); | ||
| 1596 | + } | ||
| 1597 | + } else { | ||
| 1598 | + // No real dispatcher yet — fall through to proxy creation below. | ||
| 1599 | + // (Cached proxy is reused if already created; fresh one made otherwise.) | ||
| 1600 | + // Dispatcher not yet captured — search WeChat singletons for it. | ||
| 1601 | + // gm0.j1.i() returns the message manager which likely holds a dispatcher reference. | ||
| 1602 | + XposedBridge.log("[WeChatHook] sendVoiceViaAPI: dispatcher null, searching singletons..."); | ||
| 1603 | + outer: | ||
| 1604 | + for (String[] candidate : new String[][]{ | ||
| 1605 | + {"gm0.j1", "i"}, // message manager singleton | ||
| 1606 | + {"tg3.t1", "a"}, // service locator | ||
| 1607 | + }) { | ||
| 1608 | + try { | ||
| 1609 | + Class<?> mgr = XposedHelpers.findClass(candidate[0], cl); | ||
| 1610 | + Object inst = XposedHelpers.callStaticMethod(mgr, candidate[1]); | ||
| 1611 | + if (inst == null) continue; | ||
| 1612 | + // Walk inst's fields recursively (depth 2) looking for dispatcher | ||
| 1613 | + for (java.lang.reflect.Field f1 : inst.getClass().getDeclaredFields()) { | ||
| 1614 | + if (java.lang.reflect.Modifier.isStatic(f1.getModifiers())) continue; | ||
| 1615 | + f1.setAccessible(true); | ||
| 1616 | + Object v1 = f1.get(inst); | ||
| 1617 | + if (v1 == null) continue; | ||
| 1618 | + // Level 1: try r0.dispatcher field name on v1 directly | ||
| 1619 | + try { | ||
| 1620 | + Object d = XposedHelpers.getObjectField(v1, "dispatcher"); | ||
| 1621 | + if (d != null) { | ||
| 1622 | + sCapturedDispatcher = d; | ||
| 1623 | + XposedBridge.log("[WeChatHook] dispatcher found: " | ||
| 1624 | + + candidate[0] + "." + candidate[1] + "()." + f1.getName() | ||
| 1625 | + + ".dispatcher=" + d.getClass().getName()); | ||
| 1626 | + XposedHelpers.setObjectField(r0, "dispatcher", d); | ||
| 1627 | + break outer; | ||
| 1628 | + } | ||
| 1629 | + } catch (Throwable ignored1) {} | ||
| 1630 | + // Level 2: walk v1's fields | ||
| 1631 | + for (java.lang.reflect.Field f2 : v1.getClass().getDeclaredFields()) { | ||
| 1632 | + if (java.lang.reflect.Modifier.isStatic(f2.getModifiers())) continue; | ||
| 1633 | + f2.setAccessible(true); | ||
| 1634 | + Object v2 = f2.get(v1); | ||
| 1635 | + if (v2 == null) continue; | ||
| 1636 | + try { | ||
| 1637 | + Object d = XposedHelpers.getObjectField(v2, "dispatcher"); | ||
| 1638 | + if (d != null) { | ||
| 1639 | + sCapturedDispatcher = d; | ||
| 1640 | + XposedBridge.log("[WeChatHook] dispatcher found (L2): " | ||
| 1641 | + + candidate[0] + "." + candidate[1] + "()." | ||
| 1642 | + + f1.getName() + "." + f2.getName() | ||
| 1643 | + + ".dispatcher=" + d.getClass().getName()); | ||
| 1644 | + XposedHelpers.setObjectField(r0, "dispatcher", d); | ||
| 1645 | + break outer; | ||
| 1646 | + } | ||
| 1647 | + } catch (Throwable ignored2) {} | ||
| 1648 | + } | ||
| 1649 | + } | ||
| 1650 | + } catch (Throwable t2) { | ||
| 1651 | + XposedBridge.log("[WeChatHook] dispatcher search " + candidate[0] + ": " + t2); | ||
| 1652 | + } | ||
| 1653 | + } | ||
| 1654 | + if (sCapturedDispatcher instanceof java.lang.reflect.Proxy) { | ||
| 1655 | + // Already have a proxy from a previous voice send — reuse it on this r0. | ||
| 1656 | + try { | ||
| 1657 | + XposedHelpers.setObjectField(r0, "dispatcher", sCapturedDispatcher); | ||
| 1658 | + XposedBridge.log("[WeChatHook] sendVoiceViaAPI: reusing proxy dispatcher on r0"); | ||
| 1659 | + } catch (Throwable ed2) { | ||
| 1660 | + XposedBridge.log("[WeChatHook] sendVoiceViaAPI: proxy reuse failed: " + ed2); | ||
| 1661 | + } | ||
| 1662 | + } else if (sCapturedDispatcher == null) { | ||
| 1663 | + // Still not found — create a minimal proxy for s that logs calls. | ||
| 1664 | + // This lets H() proceed past the NPE; CDN upload may work via callback. | ||
| 1665 | + try { | ||
| 1666 | + Class<?> sInterface = XposedHelpers.findClass("com.tencent.mm.network.s", cl); | ||
| 1667 | + java.lang.reflect.Method[] sMethods = sInterface.getDeclaredMethods(); | ||
| 1668 | + StringBuilder sb = new StringBuilder("[WeChatHook] s interface methods:\n"); | ||
| 1669 | + for (java.lang.reflect.Method m : sMethods) sb.append(" ").append(m.toGenericString()).append("\n"); | ||
| 1670 | + XposedBridge.log(sb.toString()); | ||
| 1671 | + Object dispProxy = java.lang.reflect.Proxy.newProxyInstance(cl, | ||
| 1672 | + new Class<?>[]{sInterface}, | ||
| 1673 | + (proxy, method, args) -> { | ||
| 1674 | + XposedBridge.log("[WeChatHook] dispatcher.proxy: " + method.getName() | ||
| 1675 | + + " ret=" + method.getReturnType().getSimpleName()); | ||
| 1676 | + if ("J".equals(method.getName()) && args != null && args.length >= 2) { | ||
| 1677 | + // ── Attempt 1: forward J() via l0 method on m05.j ────────────── | ||
| 1678 | + // method.declaringClass = s (s has own J, NOT inherited from l0). | ||
| 1679 | + // m05.j implements l0 (not s). Solution: get J() from l0 interface | ||
| 1680 | + // and invoke THAT on m05.j — valid since m05.j implements l0. | ||
| 1681 | + try { | ||
| 1682 | + Object q2arg = args[1]; | ||
| 1683 | + Object mj = XposedHelpers.getObjectField(q2arg, "e"); | ||
| 1684 | + if (mj != null) { | ||
| 1685 | + Class<?> l0Iface = XposedHelpers.findClass("com.tencent.mm.network.l0", cl); | ||
| 1686 | + // Dump l0 methods for diagnosis | ||
| 1687 | + StringBuilder l0log = new StringBuilder("[WeChatHook] l0 methods:"); | ||
| 1688 | + java.lang.reflect.Method foundL0J = null; | ||
| 1689 | + for (java.lang.reflect.Method l0m : l0Iface.getDeclaredMethods()) { | ||
| 1690 | + l0log.append(" ").append(l0m.getName()).append("(").append(l0m.getParameterTypes().length).append(")"); | ||
| 1691 | + if (l0m.getName().equals(method.getName()) | ||
| 1692 | + && l0m.getParameterTypes().length == method.getParameterTypes().length) { | ||
| 1693 | + foundL0J = l0m; | ||
| 1694 | + } | ||
| 1695 | + } | ||
| 1696 | + XposedBridge.log(l0log.toString()); | ||
| 1697 | + if (foundL0J != null) { | ||
| 1698 | + foundL0J.setAccessible(true); | ||
| 1699 | + XposedBridge.log("[WeChatHook] J→m05.j via l0.J()"); | ||
| 1700 | + try { | ||
| 1701 | + Object result = foundL0J.invoke(mj, args); | ||
| 1702 | + XposedBridge.log("[WeChatHook] J→m05.j l0 OK result=" + result); | ||
| 1703 | + return result; | ||
| 1704 | + } catch (java.lang.reflect.InvocationTargetException ite) { | ||
| 1705 | + XposedBridge.log("[WeChatHook] J→m05.j l0 threw: " + ite.getCause()); | ||
| 1706 | + } | ||
| 1707 | + } else { | ||
| 1708 | + XposedBridge.log("[WeChatHook] J not found in l0 by name+paramcount"); | ||
| 1709 | + } | ||
| 1710 | + } | ||
| 1711 | + } catch (Throwable jt0) { | ||
| 1712 | + XposedBridge.log("[WeChatHook] J→m05.j err: " + jt0); | ||
| 1713 | + } | ||
| 1714 | + // ── Attempt 2: find s implementor in J() args (skip l0-only classes) ── | ||
| 1715 | + try { | ||
| 1716 | + Class<?> sIface = XposedHelpers.findClass("com.tencent.mm.network.s", cl); | ||
| 1717 | + for (Object searchRoot : new Object[]{args[0], args[1]}) { | ||
| 1718 | + if (searchRoot == null) continue; | ||
| 1719 | + for (Class<?> cls2 = searchRoot.getClass(); cls2 != null && !cls2.equals(Object.class); cls2 = cls2.getSuperclass()) { | ||
| 1720 | + for (java.lang.reflect.Field sf1 : cls2.getDeclaredFields()) { | ||
| 1721 | + if (java.lang.reflect.Modifier.isStatic(sf1.getModifiers())) continue; | ||
| 1722 | + sf1.setAccessible(true); | ||
| 1723 | + Object sv1 = sf1.get(searchRoot); | ||
| 1724 | + if (sv1 == null || sv1 == proxy) continue; | ||
| 1725 | + if (sIface.isInstance(sv1)) { | ||
| 1726 | + sCapturedDispatcher = sv1; | ||
| 1727 | + XposedBridge.log("[WeChatHook] J: REAL s L1 " + sv1.getClass().getName()); | ||
| 1728 | + try { return method.invoke(sv1, args); } catch (java.lang.reflect.InvocationTargetException ite) { throw ite.getCause(); } | ||
| 1729 | + } | ||
| 1730 | + // L2 sub-fields | ||
| 1731 | + try { | ||
| 1732 | + for (Class<?> cls3 = sv1.getClass(); cls3 != null && !cls3.equals(Object.class); cls3 = cls3.getSuperclass()) { | ||
| 1733 | + for (java.lang.reflect.Field sf2 : cls3.getDeclaredFields()) { | ||
| 1734 | + if (java.lang.reflect.Modifier.isStatic(sf2.getModifiers())) continue; | ||
| 1735 | + sf2.setAccessible(true); | ||
| 1736 | + Object sv2 = sf2.get(sv1); | ||
| 1737 | + if (sv2 == null || sv2 == proxy) continue; | ||
| 1738 | + if (sIface.isInstance(sv2)) { | ||
| 1739 | + sCapturedDispatcher = sv2; | ||
| 1740 | + XposedBridge.log("[WeChatHook] J: REAL s L2 " + sf1.getName() + "." + sf2.getName() + " " + sv2.getClass().getName()); | ||
| 1741 | + try { return method.invoke(sv2, args); } catch (java.lang.reflect.InvocationTargetException ite) { throw ite.getCause(); } | ||
| 1742 | + } | ||
| 1743 | + } | ||
| 1744 | + } | ||
| 1745 | + } catch (Throwable ignored4) {} | ||
| 1746 | + } | ||
| 1747 | + } | ||
| 1748 | + } | ||
| 1749 | + XposedBridge.log("[WeChatHook] J: no s implementor in args"); | ||
| 1750 | + } catch (Throwable jse) { | ||
| 1751 | + XposedBridge.log("[WeChatHook] J: search err: " + jse); | ||
| 1752 | + } | ||
| 1753 | + // ── Deep-dump v2 (CDN request) sub-fields ─────────────────── | ||
| 1754 | + try { | ||
| 1755 | + Object v2arg = args[0]; | ||
| 1756 | + StringBuilder cdnDump = new StringBuilder("[WeChatHook] J v2-deep:\n"); | ||
| 1757 | + // Walk class hierarchy (getDeclaredFields only finds own fields) | ||
| 1758 | + for (Class<?> cls2 = v2arg.getClass(); cls2 != null && !cls2.equals(Object.class); cls2 = cls2.getSuperclass()) { | ||
| 1759 | + for (java.lang.reflect.Field sf1 : cls2.getDeclaredFields()) { | ||
| 1760 | + if (java.lang.reflect.Modifier.isStatic(sf1.getModifiers())) continue; | ||
| 1761 | + sf1.setAccessible(true); | ||
| 1762 | + Object sv1 = sf1.get(v2arg); | ||
| 1763 | + cdnDump.append(" ").append(sf1.getName()).append("=") | ||
| 1764 | + .append(sv1 == null ? "null" : sv1.getClass().getSimpleName()).append("\n"); | ||
| 1765 | + if (sv1 == null) continue; | ||
| 1766 | + for (java.lang.reflect.Field sf2 : sv1.getClass().getDeclaredFields()) { | ||
| 1767 | + if (java.lang.reflect.Modifier.isStatic(sf2.getModifiers())) continue; | ||
| 1768 | + sf2.setAccessible(true); | ||
| 1769 | + Object sv2 = sf2.get(sv1); | ||
| 1770 | + String sv2s; | ||
| 1771 | + if (sv2 == null) { sv2s = "null"; } | ||
| 1772 | + else if (sv2 instanceof byte[]) { | ||
| 1773 | + byte[] ba = (byte[]) sv2; | ||
| 1774 | + StringBuilder hex = new StringBuilder(); | ||
| 1775 | + for (int xi = 0; xi < Math.min(ba.length, 20); xi++) hex.append(String.format("%02x", ba[xi] & 0xff)); | ||
| 1776 | + sv2s = "byte[" + ba.length + "]=" + hex; | ||
| 1777 | + } else { | ||
| 1778 | + sv2s = sv2.toString(); | ||
| 1779 | + if (sv2s.length() > 100) sv2s = sv2s.substring(0, 100); | ||
| 1780 | + } | ||
| 1781 | + cdnDump.append(" ").append(sf1.getName()).append(".").append(sf2.getName()).append("=").append(sv2s).append("\n"); | ||
| 1782 | + } | ||
| 1783 | + } | ||
| 1784 | + } | ||
| 1785 | + XposedBridge.log(cdnDump.toString()); | ||
| 1786 | + } catch (Throwable jde) { | ||
| 1787 | + XposedBridge.log("[WeChatHook] J v2-dump err: " + jde); | ||
| 1788 | + } | ||
| 1789 | + } | ||
| 1790 | + if (method.getReturnType() == int.class || method.getReturnType() == Integer.class) | ||
| 1791 | + return 0; | ||
| 1792 | + if (method.getReturnType() == boolean.class || method.getReturnType() == Boolean.class) | ||
| 1793 | + return false; | ||
| 1794 | + return null; | ||
| 1795 | + }); | ||
| 1796 | + sCapturedDispatcher = dispProxy; | ||
| 1797 | + XposedHelpers.setObjectField(r0, "dispatcher", dispProxy); | ||
| 1798 | + XposedBridge.log("[WeChatHook] sendVoiceViaAPI: dispatcher proxy set on r0"); | ||
| 1799 | + } catch (Throwable ep) { | ||
| 1800 | + XposedBridge.log("[WeChatHook] sendVoiceViaAPI: dispatcher proxy failed: " + ep); | ||
| 1801 | + } | ||
| 1802 | + } | ||
| 1803 | + } | ||
| 1804 | + | ||
| 1805 | + // ── u0 callback stub (same pattern as sendViaR0) ───────────────────── | ||
| 1806 | + // r0.d (type com.tencent.mm.modelbase.u0) is null unless we set it; | ||
| 1807 | + // H() calls u0.onSceneEnd which NPEs otherwise. | ||
| 1808 | + // hStarted gates the u0 stub: ignore premature onSceneEnd (fires synchronously | ||
| 1809 | + // when we set r0.d, BEFORE H() is called) and only act on H()'s async CDN callback. | ||
| 1810 | + // Root cause: setting r0.d triggers an immediate "local save done" callback. | ||
| 1811 | + // If we call K(0) there, WeChat marks the message as "completed" → H() skips CDN. | ||
| 1812 | + final java.util.concurrent.atomic.AtomicBoolean hStarted = | ||
| 1813 | + new java.util.concurrent.atomic.AtomicBoolean(false); | ||
| 1814 | + try { | ||
| 1815 | + Class<?> u0 = XposedHelpers.findClass("com.tencent.mm.modelbase.u0", cl); | ||
| 1816 | + Object stub = Proxy.newProxyInstance(cl, new Class<?>[]{u0}, | ||
| 1817 | + (proxy, method, args) -> { | ||
| 1818 | + StringBuilder params = new StringBuilder(); | ||
| 1819 | + if (args != null) { | ||
| 1820 | + for (int i = 0; i < args.length; i++) { | ||
| 1821 | + if (i > 0) params.append(", "); | ||
| 1822 | + params.append(args[i]); | ||
| 1823 | + } | ||
| 1824 | + } | ||
| 1825 | + XposedBridge.log("[WeChatHook] voice-cb: " + method.getName() | ||
| 1826 | + + "(" + params + ") hStarted=" + hStarted.get()); | ||
| 1827 | + // onSceneEnd(int code, int errCode, String msg, m1 scene) | ||
| 1828 | + // Real m2 dispatch: r0.onGYNetEnd handles the network result itself | ||
| 1829 | + // (status/msgSvrId update, SendMsgSuccessEvent, K/I). This u0 only needs | ||
| 1830 | + // to be non-null so that internal onSceneEnd call doesn't NPE — do NOT | ||
| 1831 | + // call K/I here or success/fail events double-fire. | ||
| 1832 | + if ("onSceneEnd".equals(method.getName()) && args != null && args.length >= 2) { | ||
| 1833 | + XposedBridge.log("[WeChatHook] voice onSceneEnd code=" + args[0] + " err=" + args[1]); | ||
| 1834 | + } | ||
| 1835 | + return null; | ||
| 1836 | + }); | ||
| 1837 | + XposedHelpers.setObjectField(r0, "d", stub); | ||
| 1838 | + // Premature onSceneEnd fires synchronously during setObjectField above and is suppressed. | ||
| 1839 | + } catch (Throwable t) { | ||
| 1840 | + XposedBridge.log("[WeChatHook] u0 stub failed (non-fatal): " + t); | ||
| 1841 | + } | ||
| 1842 | + | ||
| 1843 | + // ── Dispatch via WeChat's REAL NetSceneQueue ───────────────────────────── | ||
| 1844 | + // Exactly the path the (now-working) internal text send uses. n1.a() does: | ||
| 1845 | + // ((gm0.n) com.tencent.mm.modelbase.z2.f88087b).f290500a.f290528b.h(scene, 0) | ||
| 1846 | + // The queue supplies the real network dispatcher and runs doScene properly: | ||
| 1847 | + // p==null → it reloads our just-inserted voice f9 from the DB, assembles the | ||
| 1848 | + // MsgSource, uploads the SILK to CDN, and transports to the server. | ||
| 1849 | + // | ||
| 1850 | + // This replaces the old manual r0.H()+fake-$Proxy dispatcher, which never | ||
| 1851 | + // reached the network — real transport is the queue's job (routed via IPC to | ||
| 1852 | + // the :push connection), NOT a hand-set main-process m2 (which is never even | ||
| 1853 | + // exercised, so it can't be captured). | ||
| 1854 | + hStarted.set(true); | ||
| 1855 | + sCalledByUs = true; | ||
| 1856 | + try { | ||
| 1857 | + // z2.b(m1) = WeChat's own "run cgi" helper (IGetNetSceneQueue): enqueues the | ||
| 1858 | + // scene into the real NetSceneQueue, which supplies the live dispatcher and | ||
| 1859 | + // runs doScene. Using the static helper avoids hardcoding the queue's field | ||
| 1860 | + // chain, whose obfuscated names differ between base.apk and the Tinker patch | ||
| 1861 | + // (the running code). r0 extends m1, so this resolves b(m1) by assignability. | ||
| 1862 | + Class<?> z2c = XposedHelpers.findClass("com.tencent.mm.modelbase.z2", cl); | ||
| 1863 | + XposedHelpers.callStaticMethod(z2c, "b", r0); | ||
| 1864 | + XposedBridge.log("[WeChatHook] sendVoiceViaAPI: enqueued via z2.b(NetSceneQueue) to=" + talker | ||
| 1865 | + + " voiceId=" + voiceId + " durationMs=" + durationMs); | ||
| 1866 | + } catch (Throwable t) { | ||
| 1867 | + XposedBridge.log("[WeChatHook] sendVoiceViaAPI: enqueue threw: " + t | ||
| 1868 | + + "\n" + android.util.Log.getStackTraceString(t)); | ||
| 1869 | + } finally { | ||
| 1870 | + sCalledByUs = false; | ||
| 1871 | + } | ||
| 1872 | + | ||
| 1873 | + // ── Trigger REAL delivery via ResendVoiceMsgEvent{f9} ────────────────── | ||
| 1874 | + // Decompiling WeChat 8.0.74 revealed the real voice send path: | ||
| 1875 | + // cg0.d(talker,file) → jg0.x(cg0.d) → sendCgi(m)+uploadAttach(w). | ||
| 1876 | + // ResendVoiceMsgEvent (com.tencent.mm.autogen.events) carries a single f9 | ||
| 1877 | + // (field g = am.wr, wr.a = f9). Publishing it makes WeChat's voice-resend | ||
| 1878 | + // handler run the FULL sendCgi + CDN upload for our already-inserted f9 — | ||
| 1879 | + // i.e. the same machinery a real send uses. This replaces the dead | ||
| 1880 | + // r0.H()/msgSvrId-poll path (delivery never happened → msgSvrId stayed 0). | ||
| 1881 | + final Long msgLocalId = sPendingMsgLocalId.get(); | ||
| 1882 | + sPendingMsgLocalId.remove(); | ||
| 1883 | + sDatabaseF9.remove(); | ||
| 1884 | + final Object fPt0e0 = sPt0E0Instance; | ||
| 1885 | + final String fTalker = talker; | ||
| 1886 | + final ClassLoader fCl = cl; | ||
| 1887 | + // ResendVoiceMsgEvent path CONFIRMED DEAD (2026-07-06): even after marking the | ||
| 1888 | + // properly-inserted voice msg failed (status=5) and firing the event, WeChat's | ||
| 1889 | + // resend handler does NOT respond — the message just shows red ! (failed), no | ||
| 1890 | + // CDN upload. So this force-fails sends for nothing. Disabled. The remaining | ||
| 1891 | + // real path is to build a voiceinfo/w0 upload task and drive w21.g1.P0() (or | ||
| 1892 | + // reconstruct jg0.x). Kept for reference. | ||
| 1893 | + if (false && msgLocalId != null) { | ||
| 1894 | + new Thread(() -> { | ||
| 1895 | + try { | ||
| 1896 | + Thread.sleep(1500); // let the z2.b send settle first | ||
| 1897 | + // Re-fetch the DB-bound f9 via pt0.e0.k(talker, msgLocalId). | ||
| 1898 | + Object f9 = null; | ||
| 1899 | + if (fPt0e0 != null) { | ||
| 1900 | + try { | ||
| 1901 | + java.lang.reflect.Method kM = null; | ||
| 1902 | + for (java.lang.reflect.Method m : fPt0e0.getClass().getDeclaredMethods()) | ||
| 1903 | + if ("k".equals(m.getName()) && m.getParameterTypes().length == 2) { kM = m; break; } | ||
| 1904 | + if (kM != null) { kM.setAccessible(true); f9 = kM.invoke(fPt0e0, fTalker, msgLocalId); } | ||
| 1905 | + } catch (Throwable t) { | ||
| 1906 | + XposedBridge.log("[WeChatHook] resend: k() failed: " + t); | ||
| 1907 | + } | ||
| 1908 | + } | ||
| 1909 | + if (f9 == null) { | ||
| 1910 | + XposedBridge.log("[WeChatHook] resend: f9 null for msgLocalId=" + msgLocalId + ", abort"); | ||
| 1911 | + return; | ||
| 1912 | + } | ||
| 1913 | + // Mark the message failed (status=5) + persist, so the resend handler | ||
| 1914 | + // treats it as a resendable failed voice. f9.r1(int)=status setter; | ||
| 1915 | + // pt0.e0.s(talker,msgId,f9) persists via cj().lb(). | ||
| 1916 | + try { | ||
| 1917 | + XposedHelpers.callMethod(f9, "r1", 5); | ||
| 1918 | + long mid = ((Number) XposedHelpers.callMethod(f9, "getMsgId")).longValue(); | ||
| 1919 | + if (fPt0e0 != null) { | ||
| 1920 | + java.lang.reflect.Method sM = null; | ||
| 1921 | + for (java.lang.reflect.Method m : fPt0e0.getClass().getDeclaredMethods()) | ||
| 1922 | + if ("s".equals(m.getName()) && m.getParameterTypes().length == 3) { sM = m; break; } | ||
| 1923 | + if (sM != null) { sM.setAccessible(true); sM.invoke(fPt0e0, fTalker, mid, f9); } | ||
| 1924 | + } | ||
| 1925 | + XposedBridge.log("[WeChatHook] resend: marked f9 status=5 + persisted msgId=" + mid); | ||
| 1926 | + } catch (Throwable t) { | ||
| 1927 | + XposedBridge.log("[WeChatHook] resend: status=5 failed: " + t); | ||
| 1928 | + } | ||
| 1929 | + // Build ResendVoiceMsgEvent, set its f9 (field g -> wr.a), publish. | ||
| 1930 | + Class<?> evCls = XposedHelpers.findClass( | ||
| 1931 | + "com.tencent.mm.autogen.events.ResendVoiceMsgEvent", fCl); | ||
| 1932 | + Object ev = evCls.newInstance(); | ||
| 1933 | + Object wr = XposedHelpers.getObjectField(ev, "g"); // am.wr | ||
| 1934 | + XposedHelpers.setObjectField(wr, "a", f9); | ||
| 1935 | + XposedHelpers.callMethod(ev, "b", Looper.getMainLooper()); | ||
| 1936 | + XposedBridge.log("[WeChatHook] resend: ResendVoiceMsgEvent published msgLocalId=" | ||
| 1937 | + + msgLocalId + " talker=" + fTalker + " f9=" + f9.getClass().getName()); | ||
| 1938 | + } catch (Throwable t) { | ||
| 1939 | + XposedBridge.log("[WeChatHook] resend: failed: " + t); | ||
| 1940 | + } | ||
| 1941 | + }, "WCHook-resend-" + msgLocalId).start(); | ||
| 1942 | + } else { | ||
| 1943 | + XposedBridge.log("[WeChatHook] resend: msgLocalId null, skip"); | ||
| 1944 | + } | ||
| 1945 | + } | ||
| 1946 | + | ||
| 1947 | + // ── Factory method discovery ─────────────────────────────────────────────── | ||
| 1948 | + // Hooks ALL static methods on w11.s1 and logs their signatures + call args. | ||
| 1949 | + // When WeChat UI sends image/voice, we'll see which s1 method is called and | ||
| 1950 | + // what parameters it receives, revealing the builder API for each msgtype. | ||
| 1951 | + private void hookFactoryMethods(ClassLoader cl, String procName) { | ||
| 1952 | + try { | ||
| 1953 | + Class<?> s1 = XposedHelpers.findClass("w11.s1", cl); | ||
| 1954 | + java.lang.reflect.Method[] methods = s1.getDeclaredMethods(); | ||
| 1955 | + | ||
| 1956 | + // Dump all method signatures immediately | ||
| 1957 | + StringBuilder sb = new StringBuilder(); | ||
| 1958 | + sb.append("[WeChatHook] w11.s1 ALL METHODS (").append(methods.length).append("):\n"); | ||
| 1959 | + for (java.lang.reflect.Method m : methods) { | ||
| 1960 | + sb.append(" ").append(m.toGenericString()).append("\n"); | ||
| 1961 | + } | ||
| 1962 | + XposedBridge.log(sb.toString()); | ||
| 1963 | + | ||
| 1964 | + // Hook each static method to intercept calls from WeChat UI | ||
| 1965 | + for (java.lang.reflect.Method m : methods) { | ||
| 1966 | + if (!java.lang.reflect.Modifier.isStatic(m.getModifiers())) continue; | ||
| 1967 | + final String mName = m.getName(); | ||
| 1968 | + final String mSig = m.toGenericString(); | ||
| 1969 | + m.setAccessible(true); | ||
| 1970 | + XposedBridge.hookMethod(m, new XC_MethodHook() { | ||
| 1971 | + @Override | ||
| 1972 | + protected void afterHookedMethod(MethodHookParam param) throws Throwable { | ||
| 1973 | + StringBuilder log = new StringBuilder(); | ||
| 1974 | + log.append("[WeChatHook] s1.").append(mName).append("("); | ||
| 1975 | + if (param.args != null) { | ||
| 1976 | + for (int i = 0; i < param.args.length; i++) { | ||
| 1977 | + if (i > 0) log.append(", "); | ||
| 1978 | + Object a = param.args[i]; | ||
| 1979 | + log.append(a == null ? "null" : a.getClass().getSimpleName() + "=" + a); | ||
| 1980 | + } | ||
| 1981 | + } | ||
| 1982 | + log.append(") -> "); | ||
| 1983 | + Object ret = param.getResult(); | ||
| 1984 | + if (ret == null) { | ||
| 1985 | + log.append("null"); | ||
| 1986 | + } else { | ||
| 1987 | + log.append(ret.getClass().getName()); | ||
| 1988 | + dumpBuilderMethods(ret, "s1." + mName); | ||
| 1989 | + // Capture dispatcher from the r0 inside r1 (set by factory, null in direct ctor) | ||
| 1990 | + try { | ||
| 1991 | + StringBuilder r1Fields = new StringBuilder("[WeChatHook] r1 fields:\n"); | ||
| 1992 | + for (Class<?> rc = ret.getClass(); rc != null && !rc.equals(Object.class); rc = rc.getSuperclass()) { | ||
| 1993 | + for (java.lang.reflect.Field rf : rc.getDeclaredFields()) { | ||
| 1994 | + if (java.lang.reflect.Modifier.isStatic(rf.getModifiers())) continue; | ||
| 1995 | + rf.setAccessible(true); | ||
| 1996 | + Object rv = rf.get(ret); | ||
| 1997 | + String typeName = rv != null ? rv.getClass().getName() : rf.getType().getName(); | ||
| 1998 | + r1Fields.append(" ").append(rf.getName()).append(" (").append(typeName).append(")"); | ||
| 1999 | + if (sCapturedDispatcher == null && rv != null) { | ||
| 2000 | + // Try to get dispatcher from any field, whether it's r0 or a container | ||
| 2001 | + try { | ||
| 2002 | + Object disp = XposedHelpers.getObjectField(rv, "dispatcher"); | ||
| 2003 | + if (disp != null) { | ||
| 2004 | + sCapturedDispatcher = disp; | ||
| 2005 | + r1Fields.append(" [dispatcher captured!]"); | ||
| 2006 | + } | ||
| 2007 | + } catch (Throwable ignored2) {} | ||
| 2008 | + } | ||
| 2009 | + r1Fields.append("\n"); | ||
| 2010 | + } | ||
| 2011 | + } | ||
| 2012 | + XposedBridge.log(r1Fields.toString()); | ||
| 2013 | + } catch (Throwable r1e) { | ||
| 2014 | + XposedBridge.log("[WeChatHook] r1 field dump error: " + r1e); | ||
| 2015 | + } | ||
| 2016 | + } | ||
| 2017 | + if (param.hasThrowable()) | ||
| 2018 | + log.append(" THREW: ").append(param.getThrowable()); | ||
| 2019 | + XposedBridge.log(log.toString()); | ||
| 2020 | + } | ||
| 2021 | + }); | ||
| 2022 | + } | ||
| 2023 | + XposedBridge.log("[WeChatHook] hookFactoryMethods: hooked " + methods.length | ||
| 2024 | + + " methods on w11.s1 in " + procName); | ||
| 2025 | + } catch (Throwable t) { | ||
| 2026 | + XposedBridge.log("[WeChatHook] hookFactoryMethods failed in " + procName + ": " + t); | ||
| 2027 | + } | ||
| 2028 | + } | ||
| 2029 | + | ||
| 2030 | + // Dump ALL instance methods on a builder object returned by the factory. | ||
| 2031 | + // Each unique builder class is only dumped once to avoid logcat spam. | ||
| 2032 | + private static final java.util.Set<String> sDumpedClasses = | ||
| 2033 | + java.util.Collections.synchronizedSet(new java.util.HashSet<>()); | ||
| 2034 | + | ||
| 2035 | + private static void dumpBuilderMethods(Object builder, String fromFactory) { | ||
| 2036 | + if (builder == null) return; | ||
| 2037 | + String clsName = builder.getClass().getName(); | ||
| 2038 | + if (!sDumpedClasses.add(clsName)) return; // already dumped | ||
| 2039 | + try { | ||
| 2040 | + java.lang.reflect.Method[] ms = builder.getClass().getDeclaredMethods(); | ||
| 2041 | + StringBuilder sb = new StringBuilder(); | ||
| 2042 | + sb.append("[WeChatHook] BUILDER from ").append(fromFactory) | ||
| 2043 | + .append(" class=").append(clsName) | ||
| 2044 | + .append(" methods(").append(ms.length).append("):\n"); | ||
| 2045 | + for (java.lang.reflect.Method m : ms) { | ||
| 2046 | + sb.append(" ").append(m.toGenericString()).append("\n"); | ||
| 2047 | + } | ||
| 2048 | + XposedBridge.log(sb.toString()); | ||
| 2049 | + } catch (Throwable t) { | ||
| 2050 | + XposedBridge.log("[WeChatHook] dumpBuilderMethods " + clsName + ": " + t); | ||
| 2051 | + } | ||
| 2052 | + } | ||
| 2053 | + | ||
| 2054 | + // Dump all instance fields of w11.r0 to logcat. | ||
| 2055 | + // Used to discover which field holds the SILK audio data for CDN upload. | ||
| 2056 | + // Logs each field name, type, and a brief value summary. | ||
| 2057 | + private static void dumpR0Fields(Object r0, String tag) { | ||
| 2058 | + if (r0 == null) return; | ||
| 2059 | + try { | ||
| 2060 | + Class<?> cls = r0.getClass(); | ||
| 2061 | + StringBuilder sb = new StringBuilder(); | ||
| 2062 | + sb.append("[WeChatHook] ").append(tag).append(" class=").append(cls.getName()).append("\n"); | ||
| 2063 | + // Walk the class hierarchy to capture inherited fields | ||
| 2064 | + for (Class<?> c = cls; c != null && !c.equals(Object.class); c = c.getSuperclass()) { | ||
| 2065 | + for (java.lang.reflect.Field f : c.getDeclaredFields()) { | ||
| 2066 | + if (java.lang.reflect.Modifier.isStatic(f.getModifiers())) continue; | ||
| 2067 | + f.setAccessible(true); | ||
| 2068 | + try { | ||
| 2069 | + Object val = f.get(r0); | ||
| 2070 | + if (val == null) { | ||
| 2071 | + sb.append(" ").append(f.getName()).append(" (").append(f.getType().getSimpleName()).append(") = null\n"); | ||
| 2072 | + } else if (val instanceof byte[]) { | ||
| 2073 | + byte[] ba = (byte[]) val; | ||
| 2074 | + StringBuilder hex = new StringBuilder(); | ||
| 2075 | + int show = Math.min(ba.length, 16); | ||
| 2076 | + for (int i = 0; i < show; i++) hex.append(String.format("%02x", ba[i] & 0xff)); | ||
| 2077 | + sb.append(" ").append(f.getName()).append(" (byte[]) len=").append(ba.length).append(" hex=").append(hex).append("\n"); | ||
| 2078 | + } else if (val instanceof String) { | ||
| 2079 | + String s = (String) val; | ||
| 2080 | + sb.append(" ").append(f.getName()).append(" (String) = \"").append(s.length() > 80 ? s.substring(0, 80) + "…" : s).append("\"\n"); | ||
| 2081 | + } else { | ||
| 2082 | + String vs = val.toString(); | ||
| 2083 | + sb.append(" ").append(f.getName()).append(" (").append(val.getClass().getSimpleName()).append(") = ").append(vs.length() > 80 ? vs.substring(0, 80) + "…" : vs).append("\n"); | ||
| 2084 | + } | ||
| 2085 | + } catch (Throwable e2) { | ||
| 2086 | + sb.append(" ").append(f.getName()).append(" ERROR: ").append(e2).append("\n"); | ||
| 2087 | + } | ||
| 2088 | + } | ||
| 2089 | + } | ||
| 2090 | + XposedBridge.log(sb.toString()); | ||
| 2091 | + } catch (Throwable t) { | ||
| 2092 | + XposedBridge.log("[WeChatHook] dumpR0Fields failed: " + t); | ||
| 2093 | + } | ||
| 2094 | + } | ||
| 2095 | + | ||
| 2096 | + // ── On-demand API dump broadcast ────────────────────────────────────────── | ||
| 2097 | + // am broadcast -a com.hermes.wechat.DUMP_WECHAT_API | ||
| 2098 | + // Optional extra: --es class "w11.s1" (defaults to w11.s1 if omitted) | ||
| 2099 | + private static void handleDumpApi(Intent intent) { | ||
| 2100 | + ClassLoader cl = sTinkerCL != null ? sTinkerCL : sWeChatCL; | ||
| 2101 | + if (cl == null) { XposedBridge.log("[WeChatHook] DUMP_API: classloader null"); return; } | ||
| 2102 | + | ||
| 2103 | + String className = intent.getStringExtra("class"); | ||
| 2104 | + if (className == null || className.isEmpty()) className = "w11.s1"; | ||
| 2105 | + | ||
| 2106 | + try { | ||
| 2107 | + Class<?> cls = XposedHelpers.findClass(className, cl); | ||
| 2108 | + java.lang.reflect.Method[] ms = cls.getDeclaredMethods(); | ||
| 2109 | + java.lang.reflect.Constructor[] cs = cls.getDeclaredConstructors(); | ||
| 2110 | + java.lang.reflect.Field[] fs = cls.getDeclaredFields(); | ||
| 2111 | + | ||
| 2112 | + StringBuilder sb = new StringBuilder(); | ||
| 2113 | + sb.append("[WeChatHook] DUMP_API class=").append(cls.getName()).append("\n"); | ||
| 2114 | + | ||
| 2115 | + // For enum classes, print each constant with its ordinal and any int fields | ||
| 2116 | + if (cls.isEnum()) { | ||
| 2117 | + sb.append(" ENUM CONSTANTS:\n"); | ||
| 2118 | + try { | ||
| 2119 | + Object[] constants = (Object[]) cls.getMethod("values").invoke(null); | ||
| 2120 | + java.lang.reflect.Method nameM = Enum.class.getMethod("name"); | ||
| 2121 | + java.lang.reflect.Method ordinalM = Enum.class.getMethod("ordinal"); | ||
| 2122 | + for (Object c : constants) { | ||
| 2123 | + String cName = (String) nameM.invoke(c); | ||
| 2124 | + int cOrd = (int) ordinalM.invoke(c); | ||
| 2125 | + sb.append(" ").append(cName).append(" ordinal=").append(cOrd); | ||
| 2126 | + // Also print any int/String fields on the enum instance | ||
| 2127 | + for (java.lang.reflect.Field f2 : fs) { | ||
| 2128 | + if (java.lang.reflect.Modifier.isStatic(f2.getModifiers())) continue; | ||
| 2129 | + f2.setAccessible(true); | ||
| 2130 | + try { | ||
| 2131 | + Object fv = f2.get(c); | ||
| 2132 | + sb.append(" ").append(f2.getName()).append("=").append(fv); | ||
| 2133 | + } catch (Throwable ignored) {} | ||
| 2134 | + } | ||
| 2135 | + sb.append("\n"); | ||
| 2136 | + } | ||
| 2137 | + } catch (Throwable e2) { | ||
| 2138 | + sb.append(" (enum values() failed: ").append(e2).append(")\n"); | ||
| 2139 | + } | ||
| 2140 | + } | ||
| 2141 | + | ||
| 2142 | + sb.append(" FIELDS (").append(fs.length).append("):\n"); | ||
| 2143 | + for (java.lang.reflect.Field f : fs) { | ||
| 2144 | + sb.append(" ").append(f.toGenericString()); | ||
| 2145 | + // Read static field values (helpful for non-enum static constants) | ||
| 2146 | + if (java.lang.reflect.Modifier.isStatic(f.getModifiers()) && !cls.isEnum()) { | ||
| 2147 | + f.setAccessible(true); | ||
| 2148 | + try { sb.append(" = ").append(f.get(null)); } catch (Throwable ignored) {} | ||
| 2149 | + } | ||
| 2150 | + sb.append("\n"); | ||
| 2151 | + } | ||
| 2152 | + sb.append(" CONSTRUCTORS (").append(cs.length).append("):\n"); | ||
| 2153 | + for (java.lang.reflect.Constructor<?> c : cs) sb.append(" ").append(c.toGenericString()).append("\n"); | ||
| 2154 | + sb.append(" METHODS (").append(ms.length).append("):\n"); | ||
| 2155 | + for (java.lang.reflect.Method m : ms) sb.append(" ").append(m.toGenericString()).append("\n"); | ||
| 2156 | + XposedBridge.log(sb.toString()); | ||
| 2157 | + } catch (Throwable t) { | ||
| 2158 | + XposedBridge.log("[WeChatHook] DUMP_API failed for " + className + ": " + t); | ||
| 2159 | + } | ||
| 2160 | + } | ||
| 2161 | + | ||
| 2162 | + // PRIMARY FIX: WeChat's own methods (LoginPasswordUI.onCreate etc.) are AOT-compiled | ||
| 2163 | + // in a way that bypasses ArtMethod entry point replacement. But framework-dispatched | ||
| 2164 | + // base class methods like Activity.onResume() always go through the entry point. | ||
| 2165 | + // We hook onResume, filter for the login screens, and force the tablet button visible. | ||
| 2166 | + // By onResume time, all views are initialized (onCreate + initView have run). | ||
| 2167 | + private void hookProbe(ClassLoader cl) { | ||
| 2168 | + try { | ||
| 2169 | + XposedHelpers.findAndHookMethod( | ||
| 2170 | + "android.app.Activity", null, | ||
| 2171 | + "onResume", new XC_MethodHook() { | ||
| 2172 | + @Override | ||
| 2173 | + protected void afterHookedMethod(MethodHookParam param) throws Throwable { | ||
| 2174 | + String cls = param.thisObject.getClass().getName(); | ||
| 2175 | + if (!cls.equals("com.tencent.mm.plugin.account.ui.LoginPasswordUI") | ||
| 2176 | + && !cls.equals("com.tencent.mm.plugin.account.ui.LoginHistoryUI")) { | ||
| 2177 | + return; | ||
| 2178 | + } | ||
| 2179 | + Object obj = param.thisObject; | ||
| 2180 | + View tabletBtn = null; | ||
| 2181 | + try { | ||
| 2182 | + tabletBtn = (View) XposedHelpers.getObjectField(obj, "N"); | ||
| 2183 | + } catch (Throwable e) { | ||
| 2184 | + XposedBridge.log("[WeChatHook] onResume getField N failed: " + e); | ||
| 2185 | + } | ||
| 2186 | + if (tabletBtn == null) { | ||
| 2187 | + XposedBridge.log("[WeChatHook] onResume: N is null for " + cls); | ||
| 2188 | + return; | ||
| 2189 | + } | ||
| 2190 | + tabletBtn.setVisibility(View.VISIBLE); | ||
| 2191 | + // CRITICAL: resolve m4 from the Activity's OWN classloader. The login | ||
| 2192 | + // Activities run under the Tinker CL, but hookProbe was installed with | ||
| 2193 | + // the pre-Tinker CL. m4's ctor param LoginHistoryUI and the running | ||
| 2194 | + // Activity are the SAME FQN but from DIFFERENT classloaders, so a | ||
| 2195 | + // captured-CL m4 fails with "type LoginHistoryUI, got LoginHistoryUI". | ||
| 2196 | + // Looking up m4 via obj's CL makes the types line up. | ||
| 2197 | + boolean attached = false; | ||
| 2198 | + try { | ||
| 2199 | + ClassLoader runtimeCl = obj.getClass().getClassLoader(); | ||
| 2200 | + Class<?> m4Class = XposedHelpers.findClass( | ||
| 2201 | + "com.tencent.mm.plugin.account.ui.m4", runtimeCl); | ||
| 2202 | + java.lang.reflect.Constructor<?> chosen = null; | ||
| 2203 | + for (java.lang.reflect.Constructor<?> c : m4Class.getDeclaredConstructors()) { | ||
| 2204 | + Class<?>[] pt = c.getParameterTypes(); | ||
| 2205 | + if (pt.length == 1 && pt[0].isAssignableFrom(obj.getClass())) { | ||
| 2206 | + chosen = c; break; | ||
| 2207 | + } | ||
| 2208 | + } | ||
| 2209 | + if (chosen != null && View.OnClickListener.class.isAssignableFrom(m4Class)) { | ||
| 2210 | + chosen.setAccessible(true); | ||
| 2211 | + tabletBtn.setOnClickListener( | ||
| 2212 | + (View.OnClickListener) chosen.newInstance(obj)); | ||
| 2213 | + attached = true; | ||
| 2214 | + XposedBridge.log("[WeChatHook] onResume: m4 listener attached via " | ||
| 2215 | + + chosen.getParameterTypes()[0].getName() + " for " + cls); | ||
| 2216 | + } else { | ||
| 2217 | + XposedBridge.log("[WeChatHook] onResume: no assignable m4 ctor for " | ||
| 2218 | + + cls + " (obj CL=" + runtimeCl + ")"); | ||
| 2219 | + } | ||
| 2220 | + } catch (Throwable e) { | ||
| 2221 | + XposedBridge.log("[WeChatHook] onResume setListener failed: " + e); | ||
| 2222 | + } | ||
| 2223 | + if (!attached) { | ||
| 2224 | + XposedBridge.log("[WeChatHook] onResume: N VISIBLE but NO listener for " + cls); | ||
| 2225 | + } | ||
| 2226 | + } | ||
| 2227 | + }); | ||
| 2228 | + XposedBridge.log("[WeChatHook] Hooked Activity.onResume for tablet button"); | ||
| 2229 | + } catch (Throwable t) { | ||
| 2230 | + XposedBridge.log("[WeChatHook] hookProbe/onResume failed: " + t); | ||
| 2231 | + } | ||
| 2232 | + } | ||
| 2233 | + | ||
| 2234 | + private void hookInTabletEnv(ClassLoader cl) { | ||
| 2235 | + try { | ||
| 2236 | + Class<?> bkClass = XposedHelpers.findClass("com.tencent.mm.ui.bk", cl); | ||
| 2237 | + Class<?> x0Class = XposedHelpers.findClass("bm5.x0", cl); | ||
| 2238 | + XposedHelpers.findAndHookMethod(bkClass, "B", x0Class, new XC_MethodHook() { | ||
| 2239 | + @Override | ||
| 2240 | + protected void beforeHookedMethod(MethodHookParam param) throws Throwable { | ||
| 2241 | + param.setResult(Boolean.TRUE); | ||
| 2242 | + } | ||
| 2243 | + }); | ||
| 2244 | + XposedHelpers.findAndHookMethod(bkClass, "A", new XC_MethodHook() { | ||
| 2245 | + @Override | ||
| 2246 | + protected void beforeHookedMethod(MethodHookParam param) throws Throwable { | ||
| 2247 | + XposedBridge.log("[WeChatHook] bk.A() called -> TRUE"); | ||
| 2248 | + param.setResult(Boolean.TRUE); | ||
| 2249 | + } | ||
| 2250 | + }); | ||
| 2251 | + XposedBridge.log("[WeChatHook] Hooked bk.B(x0) + bk.A()"); | ||
| 2252 | + } catch (Throwable t) { | ||
| 2253 | + XposedBridge.log("[WeChatHook] hookInTabletEnv failed: " + t); | ||
| 2254 | + } | ||
| 2255 | + } | ||
| 2256 | + | ||
| 2257 | + // PRIMARY FIX: Hook LoginPasswordUI.onCreate(Bundle) afterHookedMethod. | ||
| 2258 | + // | ||
| 2259 | + // Previous attempts to hook initView() and LoginHistoryUI.initView() both failed | ||
| 2260 | + // because ART AOT devirtualizes the call to this.initView() inside | ||
| 2261 | + // LoginHistoryUI.onCreate() — the compiled native code jumps directly past the | ||
| 2262 | + // Xposed entry point replacement. | ||
| 2263 | + // | ||
| 2264 | + // onCreate() itself is called by the Android framework via the ArtMethod pointer | ||
| 2265 | + // directly (no vtable devirtualization possible), so this hook fires reliably. | ||
| 2266 | + // By the time afterHookedMethod runs, super.onCreate() has completed which means | ||
| 2267 | + // LoginHistoryUI.initView() has already run and this.N is assigned. | ||
| 2268 | + private void hookLoginHistoryUIInitView(ClassLoader cl) { | ||
| 2269 | + final Class<?> m4Class; | ||
| 2270 | + try { | ||
| 2271 | + m4Class = XposedHelpers.findClass("com.tencent.mm.plugin.account.ui.m4", cl); | ||
| 2272 | + } catch (Throwable t) { | ||
| 2273 | + XposedBridge.log("[WeChatHook] m4 class not found: " + t); | ||
| 2274 | + return; | ||
| 2275 | + } | ||
| 2276 | + | ||
| 2277 | + try { | ||
| 2278 | + XposedHelpers.findAndHookMethod( | ||
| 2279 | + "com.tencent.mm.plugin.account.ui.LoginPasswordUI", cl, | ||
| 2280 | + "onCreate", Bundle.class, new XC_MethodHook() { | ||
| 2281 | + @Override | ||
| 2282 | + protected void afterHookedMethod(MethodHookParam param) throws Throwable { | ||
| 2283 | + Object obj = param.thisObject; | ||
| 2284 | + View tabletBtn = (View) XposedHelpers.getObjectField(obj, "N"); | ||
| 2285 | + if (tabletBtn == null) { | ||
| 2286 | + XposedBridge.log("[WeChatHook] LoginPasswordUI.onCreate: N is null"); | ||
| 2287 | + return; | ||
| 2288 | + } | ||
| 2289 | + tabletBtn.setVisibility(View.VISIBLE); | ||
| 2290 | + tabletBtn.setOnClickListener( | ||
| 2291 | + (View.OnClickListener) XposedHelpers.newInstance(m4Class, obj)); | ||
| 2292 | + XposedBridge.log("[WeChatHook] LoginPasswordUI.onCreate() -> N VISIBLE + m4 attached"); | ||
| 2293 | + } | ||
| 2294 | + }); | ||
| 2295 | + XposedBridge.log("[WeChatHook] Hooked LoginPasswordUI.onCreate()"); | ||
| 2296 | + } catch (Throwable t) { | ||
| 2297 | + XposedBridge.log("[WeChatHook] hookLoginPasswordUI.onCreate failed: " + t); | ||
| 2298 | + } | ||
| 2299 | + | ||
| 2300 | + // Belt-and-suspenders: also hook LoginHistoryUI.onCreate() for any subclass | ||
| 2301 | + // that doesn't override onCreate but does have the N button. | ||
| 2302 | + try { | ||
| 2303 | + XposedHelpers.findAndHookMethod( | ||
| 2304 | + "com.tencent.mm.plugin.account.ui.LoginHistoryUI", cl, | ||
| 2305 | + "onCreate", Bundle.class, new XC_MethodHook() { | ||
| 2306 | + @Override | ||
| 2307 | + protected void afterHookedMethod(MethodHookParam param) throws Throwable { | ||
| 2308 | + Object obj = param.thisObject; | ||
| 2309 | + if (obj.getClass().getName().equals( | ||
| 2310 | + "com.tencent.mm.plugin.account.ui.LoginPasswordUI")) return; | ||
| 2311 | + View tabletBtn = (View) XposedHelpers.getObjectField(obj, "N"); | ||
| 2312 | + if (tabletBtn == null) { | ||
| 2313 | + XposedBridge.log("[WeChatHook] LoginHistoryUI.onCreate: N is null"); | ||
| 2314 | + return; | ||
| 2315 | + } | ||
| 2316 | + tabletBtn.setVisibility(View.VISIBLE); | ||
| 2317 | + tabletBtn.setOnClickListener( | ||
| 2318 | + (View.OnClickListener) XposedHelpers.newInstance(m4Class, obj)); | ||
| 2319 | + XposedBridge.log("[WeChatHook] LoginHistoryUI.onCreate() -> N VISIBLE + m4 attached"); | ||
| 2320 | + } | ||
| 2321 | + }); | ||
| 2322 | + XposedBridge.log("[WeChatHook] Hooked LoginHistoryUI.onCreate()"); | ||
| 2323 | + } catch (Throwable t) { | ||
| 2324 | + XposedBridge.log("[WeChatHook] hookLoginHistoryUI.onCreate failed: " + t); | ||
| 2325 | + } | ||
| 2326 | + } | ||
| 2327 | + | ||
| 2328 | + private void hookLoginUIInitView(ClassLoader cl) { | ||
| 2329 | + try { | ||
| 2330 | + final Class<?> wbClass = XposedHelpers.findClass( | ||
| 2331 | + "com.tencent.mm.plugin.account.ui.wb", cl); | ||
| 2332 | + XposedHelpers.findAndHookMethod( | ||
| 2333 | + "com.tencent.mm.plugin.account.ui.MobileInputUI", cl, | ||
| 2334 | + "initView", new XC_MethodHook() { | ||
| 2335 | + @Override | ||
| 2336 | + protected void afterHookedMethod(MethodHookParam param) throws Throwable { | ||
| 2337 | + Object mobileInputUI = param.thisObject; | ||
| 2338 | + View tabletBtn = (View) XposedHelpers.getObjectField(mobileInputUI, "w"); | ||
| 2339 | + if (tabletBtn == null) { | ||
| 2340 | + XposedBridge.log("[WeChatHook] MobileInputUI.w is null"); | ||
| 2341 | + return; | ||
| 2342 | + } | ||
| 2343 | + tabletBtn.setVisibility(View.VISIBLE); | ||
| 2344 | + tabletBtn.setOnClickListener( | ||
| 2345 | + (View.OnClickListener) XposedHelpers.newInstance(wbClass, mobileInputUI)); | ||
| 2346 | + XposedBridge.log("[WeChatHook] MobileInputUI.initView() -> w VISIBLE + wb attached"); | ||
| 2347 | + } | ||
| 2348 | + }); | ||
| 2349 | + XposedBridge.log("[WeChatHook] Hooked MobileInputUI.initView()"); | ||
| 2350 | + } catch (Throwable t) { | ||
| 2351 | + XposedBridge.log("[WeChatHook] hookLoginUIInitView(MobileInputUI) failed: " + t); | ||
| 2352 | + } | ||
| 2353 | + try { | ||
| 2354 | + final Class<?> g7Class = XposedHelpers.findClass( | ||
| 2355 | + "com.tencent.mm.plugin.account.ui.g7", cl); | ||
| 2356 | + XposedHelpers.findAndHookMethod( | ||
| 2357 | + "com.tencent.mm.plugin.account.ui.LoginUI", cl, | ||
| 2358 | + "initView", new XC_MethodHook() { | ||
| 2359 | + @Override | ||
| 2360 | + protected void afterHookedMethod(MethodHookParam param) throws Throwable { | ||
| 2361 | + Object loginUI = param.thisObject; | ||
| 2362 | + View tabletBtn = (View) XposedHelpers.getObjectField(loginUI, "f992u"); | ||
| 2363 | + if (tabletBtn == null) return; | ||
| 2364 | + tabletBtn.setVisibility(View.VISIBLE); | ||
| 2365 | + tabletBtn.setOnClickListener( | ||
| 2366 | + (View.OnClickListener) XposedHelpers.newInstance(g7Class, loginUI)); | ||
| 2367 | + XposedBridge.log("[WeChatHook] LoginUI.initView() -> f992u VISIBLE + g7 attached"); | ||
| 2368 | + } | ||
| 2369 | + }); | ||
| 2370 | + XposedBridge.log("[WeChatHook] Hooked LoginUI.initView()"); | ||
| 2371 | + } catch (Throwable t) { | ||
| 2372 | + XposedBridge.log("[WeChatHook] hookLoginUIInitView(LoginUI) failed: " + t); | ||
| 2373 | + } | ||
| 2374 | + } | ||
| 2375 | + | ||
| 2376 | + private void hookLoginSelectUICheck(ClassLoader cl) { | ||
| 2377 | + try { | ||
| 2378 | + Class<?> callbackClass = XposedHelpers.findClass( | ||
| 2379 | + "com.tencent.mm.plugin.account.ui.LoginSelectUI$a", cl); | ||
| 2380 | + XposedHelpers.findAndHookMethod(callbackClass, "a", boolean.class, new XC_MethodHook() { | ||
| 2381 | + @Override | ||
| 2382 | + protected void beforeHookedMethod(MethodHookParam param) throws Throwable { | ||
| 2383 | + XposedBridge.log("[WeChatHook] LoginSelectUI$a.a(" + param.args[0] + ") -> false"); | ||
| 2384 | + param.args[0] = false; | ||
| 2385 | + } | ||
| 2386 | + }); | ||
| 2387 | + XposedBridge.log("[WeChatHook] Hooked LoginSelectUI$a.a()"); | ||
| 2388 | + } catch (Throwable t) { | ||
| 2389 | + XposedBridge.log("[WeChatHook] hookLoginSelectUICheck failed: " + t); | ||
| 2390 | + } | ||
| 2391 | + } | ||
| 2392 | + | ||
| 2393 | + private void hookLoginButtonClick(ClassLoader cl) { | ||
| 2394 | + try { | ||
| 2395 | + Class<?> ljClass = XposedHelpers.findClass( | ||
| 2396 | + "com.tencent.mm.plugin.account.ui.lj", cl); | ||
| 2397 | + final Class<?> loginSelectUIClass = XposedHelpers.findClass( | ||
| 2398 | + "com.tencent.mm.plugin.account.ui.LoginSelectUI", cl); | ||
| 2399 | + XposedHelpers.findAndHookMethod(ljClass, "onClick", View.class, new XC_MethodHook() { | ||
| 2400 | + @Override | ||
| 2401 | + protected void beforeHookedMethod(MethodHookParam param) throws Throwable { | ||
| 2402 | + XposedBridge.log("[WeChatHook] lj.onClick() -> forcing LoginSelectUI"); | ||
| 2403 | + Activity welcomeActivity = (Activity) XposedHelpers.getObjectField( | ||
| 2404 | + param.thisObject, "d"); | ||
| 2405 | + Intent intent = new Intent(welcomeActivity, loginSelectUIClass); | ||
| 2406 | + intent.putExtra("mobile_input_purpose", 1); | ||
| 2407 | + welcomeActivity.startActivity(intent); | ||
| 2408 | + param.setResult(null); | ||
| 2409 | + } | ||
| 2410 | + }); | ||
| 2411 | + XposedBridge.log("[WeChatHook] Hooked lj.onClick() -> LoginSelectUI"); | ||
| 2412 | + } catch (Throwable t) { | ||
| 2413 | + XposedBridge.log("[WeChatHook] hookLoginButtonClick failed: " + t); | ||
| 2414 | + } | ||
| 2415 | + } | ||
| 2416 | + | ||
| 2417 | + // ── Send message via internal API ──────────────────────────────────────── | ||
| 2418 | + // | ||
| 2419 | + // Registered in ALL WeChat processes. We don't yet know which process has gm0.j1 | ||
| 2420 | + // initialized — the first process where the receiver fires and succeeds wins. | ||
| 2421 | + // | ||
| 2422 | + // Trigger from ADB (must run as root — RECEIVER_NOT_EXPORTED): | ||
| 2423 | + // adb shell su -c 'am broadcast -a com.hermes.wechat.SEND_MSG_INTERNAL \ | ||
| 2424 | + // --es to "filehelper" --es text "hello"' | ||
| 2425 | + private void hookSendMessage(ClassLoader cl, String procName) { | ||
| 2426 | + try { | ||
| 2427 | + XposedHelpers.findAndHookMethod( | ||
| 2428 | + "android.app.Application", null, "onCreate", | ||
| 2429 | + new XC_MethodHook() { | ||
| 2430 | + @Override | ||
| 2431 | + protected void afterHookedMethod(MethodHookParam param) throws Throwable { | ||
| 2432 | + Context ctx = (Context) param.thisObject; | ||
| 2433 | + if (!"com.tencent.mm".equals(ctx.getPackageName())) return; | ||
| 2434 | + sAppContext = ctx; | ||
| 2435 | + // Capture Tinker-patched classloader (Tinker installs in attachBaseContext, | ||
| 2436 | + // so by App.onCreate this loader has the patch DEX classes). | ||
| 2437 | + sTinkerCL = ctx.getClassLoader(); | ||
| 2438 | + XposedBridge.log("[WeChatHook] App.onCreate proc=" + procName | ||
| 2439 | + + " pid=" + android.os.Process.myPid() | ||
| 2440 | + + " tinkerCL=" + (sTinkerCL != null ? sTinkerCL.getClass().getSimpleName() : "null")); | ||
| 2441 | + // Re-hook with Tinker CL — w11.s1 and f9 may live in a Tinker DEX. | ||
| 2442 | + // This ensures our factory and convertTo hooks fire on actual WeChat UI sends. | ||
| 2443 | + if (sTinkerCL != sWeChatCL) { | ||
| 2444 | + hookFactoryMethods(sTinkerCL, procName + "/tinker"); | ||
| 2445 | + hookF9ConvertTo(sTinkerCL, procName + "/tinker"); | ||
| 2446 | + hookF9ImgPathInjector(sTinkerCL, procName + "/tinker"); | ||
| 2447 | + hookR0H(sTinkerCL, procName + "/tinker"); | ||
| 2448 | + // Hook pt0.e0 in Tinker CL — real voice sends run in Tinker CL. | ||
| 2449 | + hookPt0E0(sTinkerCL, procName + "/tinker"); | ||
| 2450 | + // NOTE: hookJg0 / hookVoiceEntry disabled — they hook WeChat's | ||
| 2451 | + // generic coroutine classes (pu.o0/pu.m0) and every message | ||
| 2452 | + // DB insert (g9.M9/N9), which adds anti-tamper risk and | ||
| 2453 | + // destabilizes the fragile Pad login. Re-enable only for a | ||
| 2454 | + // targeted investigation session, never during normal use. | ||
| 2455 | + // hookJg0(sTinkerCL, procName + "/tinker"); | ||
| 2456 | + // hookVoiceEntry(sTinkerCL, procName + "/tinker"); | ||
| 2457 | + // Surgical probe: jg0.x + qi3.f0/g0 only (safe). | ||
| 2458 | + hookVoiceProbe(sTinkerCL, procName + "/tinker"); | ||
| 2459 | + // Image send runs in Tinker CL — hook b80.u.pj/Bi there. | ||
| 2460 | + hookImageSend(sTinkerCL, procName + "/tinker"); | ||
| 2461 | + } | ||
| 2462 | + hookF9ConvertTo(sWeChatCL, procName); | ||
| 2463 | + hookF9ImgPathInjector(sWeChatCL, procName); | ||
| 2464 | + registerSendReceiver(ctx, procName); | ||
| 2465 | + } | ||
| 2466 | + }); | ||
| 2467 | + XposedBridge.log("[WeChatHook] Send hook registered in " + procName); | ||
| 2468 | + } catch (Throwable t) { | ||
| 2469 | + XposedBridge.log("[WeChatHook] hookSendMessage failed in " + procName + ": " + t); | ||
| 2470 | + } | ||
| 2471 | + | ||
| 2472 | + // ── w11.r0 constructor hooks ─────────────────────────────────────────── | ||
| 2473 | + // Log "by=US" vs "by=WeChat" to tell if WeChat's own UI send uses this ctor. | ||
| 2474 | + XC_MethodHook r0Hook = new XC_MethodHook() { | ||
| 2475 | + @Override | ||
| 2476 | + protected void beforeHookedMethod(MethodHookParam param) throws Throwable { | ||
| 2477 | + String by = sCalledByUs ? "US" : "WeChat"; | ||
| 2478 | + XposedBridge.log("[WeChatHook] w11.r0 ctor by=" + by | ||
| 2479 | + + " args=" + param.args.length + " proc=" + procName | ||
| 2480 | + + " pid=" + android.os.Process.myPid()); | ||
| 2481 | + if (sCalledByUs) { | ||
| 2482 | + forceInitDI(); | ||
| 2483 | + diagMCoreStorage("before-r0-ctor"); | ||
| 2484 | + } | ||
| 2485 | + } | ||
| 2486 | + @Override | ||
| 2487 | + protected void afterHookedMethod(MethodHookParam param) throws Throwable { | ||
| 2488 | + String by = sCalledByUs ? "US" : "WeChat"; | ||
| 2489 | + if (param.hasThrowable()) { | ||
| 2490 | + Throwable t = param.getThrowable(); | ||
| 2491 | + XposedBridge.log("[WeChatHook] w11.r0 ctor THREW by=" + by | ||
| 2492 | + + " proc=" + procName + ": " + t); | ||
| 2493 | + if (sCalledByUs) | ||
| 2494 | + XposedBridge.log("[WeChatHook] r0-ctor-stack: " | ||
| 2495 | + + android.util.Log.getStackTraceString(t)); | ||
| 2496 | + return; | ||
| 2497 | + } | ||
| 2498 | + sFsGReady = true; | ||
| 2499 | + XposedBridge.log("[WeChatHook] w11.r0 ctor OK by=" + by + " proc=" + procName); | ||
| 2500 | + } | ||
| 2501 | + }; | ||
| 2502 | + for (Class<?>[] sig : new Class<?>[][]{ | ||
| 2503 | + {String.class, String.class, int.class, int.class, long.class}, | ||
| 2504 | + {String.class, String.class, int.class, int.class, Object.class}, | ||
| 2505 | + {long.class, int.class, String.class}, | ||
| 2506 | + {}}) { | ||
| 2507 | + try { | ||
| 2508 | + XposedHelpers.findAndHookConstructor("w11.r0", cl, | ||
| 2509 | + appendHook(sig, r0Hook)); | ||
| 2510 | + XposedBridge.log("[WeChatHook] Hooked w11.r0(" + sig.length + " params)"); | ||
| 2511 | + } catch (Throwable t) { | ||
| 2512 | + XposedBridge.log("[WeChatHook] w11.r0(" + sig.length + "p) hook failed: " + t); | ||
| 2513 | + } | ||
| 2514 | + } | ||
| 2515 | + | ||
| 2516 | + // ── w11.r0.H(String) — the actual dispatch method ───────────────────── | ||
| 2517 | + // Fires for EVERY send (ours AND WeChat UI sends). Shows if WeChat uses r0. | ||
| 2518 | + // For real WeChat voice sends (type=34, by=WeChat), dumps ALL r0 fields so | ||
| 2519 | + // we can find which field holds the SILK audio data (root cause of spinner). | ||
| 2520 | + try { | ||
| 2521 | + XposedHelpers.findAndHookMethod("w11.r0", cl, "H", String.class, | ||
| 2522 | + new XC_MethodHook() { | ||
| 2523 | + @Override | ||
| 2524 | + protected void beforeHookedMethod(MethodHookParam param) throws Throwable { | ||
| 2525 | + String by = sCalledByUs ? "US" : "WeChat"; | ||
| 2526 | + XposedBridge.log("[WeChatHook] w11.r0.H() called by=" + by | ||
| 2527 | + + " proc=" + procName + " pid=" + android.os.Process.myPid()); | ||
| 2528 | + // Dump all r0 fields for real WeChat voice sends only. | ||
| 2529 | + // This reveals which field holds the SILK audio data that H() uses for CDN upload. | ||
| 2530 | + if (!sCalledByUs) { | ||
| 2531 | + dumpR0Fields(param.thisObject, "r0.H-before"); | ||
| 2532 | + } | ||
| 2533 | + } | ||
| 2534 | + @Override | ||
| 2535 | + protected void afterHookedMethod(MethodHookParam param) throws Throwable { | ||
| 2536 | + if (param.hasThrowable()) | ||
| 2537 | + XposedBridge.log("[WeChatHook] w11.r0.H() threw: " + param.getThrowable()); | ||
| 2538 | + else | ||
| 2539 | + XposedBridge.log("[WeChatHook] w11.r0.H() OK proc=" + procName); | ||
| 2540 | + } | ||
| 2541 | + }); | ||
| 2542 | + XposedBridge.log("[WeChatHook] Hooked w11.r0.H(String) in " + procName); | ||
| 2543 | + } catch (Throwable t) { | ||
| 2544 | + XposedBridge.log("[WeChatHook] w11.r0.H hook failed in " + procName + ": " + t); | ||
| 2545 | + } | ||
| 2546 | + | ||
| 2547 | + // ── r0.I(int) and r0.K(int) — delivery completion hooks ────────────────── | ||
| 2548 | + // Log when WeChat calls these so we understand their role in the send flow. | ||
| 2549 | + // Our u0 stub now calls r0.I(0) after onSceneEnd — this confirms whether I() | ||
| 2550 | + // is the right trigger for delivery completion and spinner removal. | ||
| 2551 | + try { | ||
| 2552 | + XposedHelpers.findAndHookMethod("w11.r0", cl, "I", int.class, | ||
| 2553 | + new XC_MethodHook() { | ||
| 2554 | + @Override protected void beforeHookedMethod(MethodHookParam p) throws Throwable { | ||
| 2555 | + String by = sCalledByUs ? "US" : "WeChat"; | ||
| 2556 | + XposedBridge.log("[WeChatHook] r0.I(" + p.args[0] + ") by=" + by + " proc=" + procName); | ||
| 2557 | + } | ||
| 2558 | + }); | ||
| 2559 | + XposedBridge.log("[WeChatHook] Hooked w11.r0.I(int) in " + procName); | ||
| 2560 | + } catch (Throwable t) { | ||
| 2561 | + XposedBridge.log("[WeChatHook] r0.I hook failed in " + procName + ": " + t); | ||
| 2562 | + } | ||
| 2563 | + try { | ||
| 2564 | + XposedHelpers.findAndHookMethod("w11.r0", cl, "K", int.class, | ||
| 2565 | + new XC_MethodHook() { | ||
| 2566 | + @Override protected void beforeHookedMethod(MethodHookParam p) throws Throwable { | ||
| 2567 | + String by = sCalledByUs ? "US" : "WeChat"; | ||
| 2568 | + XposedBridge.log("[WeChatHook] r0.K(" + p.args[0] + ") by=" + by + " proc=" + procName); | ||
| 2569 | + } | ||
| 2570 | + }); | ||
| 2571 | + XposedBridge.log("[WeChatHook] Hooked w11.r0.K(int) in " + procName); | ||
| 2572 | + } catch (Throwable t) { | ||
| 2573 | + XposedBridge.log("[WeChatHook] r0.K hook failed in " + procName + ": " + t); | ||
| 2574 | + } | ||
| 2575 | + | ||
| 2576 | + // ── Diagnostic probes on the constructor call chain ─────────────────── | ||
| 2577 | + // Each probe fires when the method is called from inside the r0 constructor | ||
| 2578 | + // (by WeChat OR by us). They trace which methods succeed before the NPE. | ||
| 2579 | + hookProbeMethod(cl, "gm0.j1", "u", "gm0.j1.u"); | ||
| 2580 | + hookProbeMethod(cl, "c01.z1", "r", "c01.z1.r"); | ||
| 2581 | + // gm0.b0.c() is a virtual method — probe it too | ||
| 2582 | + try { | ||
| 2583 | + XposedHelpers.findAndHookMethod("gm0.b0", cl, "c", | ||
| 2584 | + new XC_MethodHook() { | ||
| 2585 | + @Override protected void beforeHookedMethod(MethodHookParam p) { | ||
| 2586 | + XposedBridge.log("[WeChatHook] PROBE gm0.b0.c() called"); | ||
| 2587 | + } | ||
| 2588 | + @Override protected void afterHookedMethod(MethodHookParam p) { | ||
| 2589 | + if (p.hasThrowable()) XposedBridge.log("[WeChatHook] PROBE gm0.b0.c() THREW: " + p.getThrowable()); | ||
| 2590 | + else XposedBridge.log("[WeChatHook] PROBE gm0.b0.c() → " + p.getResult()); | ||
| 2591 | + } | ||
| 2592 | + }); | ||
| 2593 | + } catch (Throwable t) { | ||
| 2594 | + XposedBridge.log("[WeChatHook] gm0.b0.c probe failed: " + t); | ||
| 2595 | + } | ||
| 2596 | + } | ||
| 2597 | + | ||
| 2598 | + private static Object[] appendHook(Class<?>[] params, XC_MethodHook hook) { | ||
| 2599 | + Object[] result = new Object[params.length + 1]; | ||
| 2600 | + System.arraycopy(params, 0, result, 0, params.length); | ||
| 2601 | + result[params.length] = hook; | ||
| 2602 | + return result; | ||
| 2603 | + } | ||
| 2604 | + | ||
| 2605 | + private void hookProbeMethod(ClassLoader cl, String cls, String method, String tag) { | ||
| 2606 | + try { | ||
| 2607 | + XposedHelpers.findAndHookMethod(cls, cl, method, | ||
| 2608 | + new XC_MethodHook() { | ||
| 2609 | + @Override protected void beforeHookedMethod(MethodHookParam p) { | ||
| 2610 | + XposedBridge.log("[WeChatHook] PROBE " + tag + "() called"); | ||
| 2611 | + } | ||
| 2612 | + @Override protected void afterHookedMethod(MethodHookParam p) { | ||
| 2613 | + if (p.hasThrowable()) XposedBridge.log("[WeChatHook] PROBE " + tag + "() THREW: " + p.getThrowable()); | ||
| 2614 | + else XposedBridge.log("[WeChatHook] PROBE " + tag + "() → " + p.getResult()); | ||
| 2615 | + } | ||
| 2616 | + }); | ||
| 2617 | + XposedBridge.log("[WeChatHook] Probe hooked: " + tag); | ||
| 2618 | + } catch (Throwable t) { | ||
| 2619 | + XposedBridge.log("[WeChatHook] Probe " + tag + " failed: " + t); | ||
| 2620 | + } | ||
| 2621 | + } | ||
| 2622 | + | ||
| 2623 | + private static boolean isDIReady() { | ||
| 2624 | + if (sWeChatCL == null) return false; | ||
| 2625 | + try { | ||
| 2626 | + XposedHelpers.callStaticMethod(XposedHelpers.findClass("gm0.j1", sWeChatCL), "i"); | ||
| 2627 | + return true; | ||
| 2628 | + } catch (Throwable e) { return false; } | ||
| 2629 | + } | ||
| 2630 | + | ||
| 2631 | + // Hook gm0.j1.g(km0.c) to detect when WeChat DI kernel is initialized. | ||
| 2632 | + // gm0.j1.g() sets gm0.j1.m (the singleton) and is called lazily — not during | ||
| 2633 | + // Application.onCreate, so our broadcast often arrives first. After g() fires, | ||
| 2634 | + // gm0.j1.i() will succeed and w11.r0 can be constructed. | ||
| 2635 | + private static void hookDIInit(ClassLoader cl, String procName) { | ||
| 2636 | + try { | ||
| 2637 | + Class<?> km0cCls = XposedHelpers.findClass("km0.c", cl); | ||
| 2638 | + XposedHelpers.findAndHookMethod("gm0.j1", cl, "g", km0cCls, | ||
| 2639 | + new XC_MethodHook() { | ||
| 2640 | + @Override | ||
| 2641 | + protected void afterHookedMethod(MethodHookParam p) throws Throwable { | ||
| 2642 | + XposedBridge.log("[WeChatHook] gm0.j1.g() fired — DI ready in " + procName); | ||
| 2643 | + String t = sPendingTalker, x = sPendingText, pr = sPendingProc; | ||
| 2644 | + if (t != null && x != null && procName.equals(pr)) { | ||
| 2645 | + sPendingTalker = null; sPendingText = null; sPendingProc = null; | ||
| 2646 | + XposedBridge.log("[WeChatHook] resuming pending send to=" + t + " proc=" + procName); | ||
| 2647 | + new Handler(Looper.getMainLooper()).postDelayed( | ||
| 2648 | + () -> trySendWithRetry(t, x, 0, procName), 50); | ||
| 2649 | + } | ||
| 2650 | + } | ||
| 2651 | + }); | ||
| 2652 | + XposedBridge.log("[WeChatHook] gm0.j1.g hook OK in " + procName); | ||
| 2653 | + } catch (Throwable t) { | ||
| 2654 | + XposedBridge.log("[WeChatHook] gm0.j1.g hook failed in " + procName + ": " + t); | ||
| 2655 | + } | ||
| 2656 | + } | ||
| 2657 | + | ||
| 2658 | + private static void diagMCoreStorage(String where) { | ||
| 2659 | + if (sWeChatCL == null) return; | ||
| 2660 | + try { | ||
| 2661 | + Class<?> j1Cls = XposedHelpers.findClass("gm0.j1", sWeChatCL); | ||
| 2662 | + Object j1 = XposedHelpers.callStaticMethod(j1Cls, "i"); | ||
| 2663 | + Object mCS = (j1 != null) ? XposedHelpers.getObjectField(j1, "d") : null; | ||
| 2664 | + XposedBridge.log("[WeChatHook] " + where + " mCoreStorage=" + mCS); | ||
| 2665 | + } catch (Throwable e) { | ||
| 2666 | + XposedBridge.log("[WeChatHook] " + where + " mCoreStorage-check: " + e); | ||
| 2667 | + } | ||
| 2668 | + } | ||
| 2669 | + | ||
| 2670 | + private static void forceInitDI() { | ||
| 2671 | + if (sWeChatCL == null) return; | ||
| 2672 | + try { | ||
| 2673 | + Class<?> fsgCls = XposedHelpers.findClass("fs.g", sWeChatCL); | ||
| 2674 | + java.lang.reflect.Field faFld = fsgCls.getDeclaredField("a"); | ||
| 2675 | + faFld.setAccessible(true); | ||
| 2676 | + Object[] arr = (Object[]) faFld.get(null); | ||
| 2677 | + | ||
| 2678 | + if (arr == null || arr.length == 0 || arr[0] == null) { | ||
| 2679 | + Class<?> k2Cls = XposedHelpers.findClass("fs.k2", sWeChatCL); | ||
| 2680 | + java.lang.reflect.Field k2eFld = k2Cls.getDeclaredField("e"); | ||
| 2681 | + k2eFld.setAccessible(true); | ||
| 2682 | + Object k2e = k2eFld.get(null); | ||
| 2683 | + if (k2e == null) { | ||
| 2684 | + XposedBridge.log("[WeChatHook] forceInitDI: fs.k2.e null"); | ||
| 2685 | + return; | ||
| 2686 | + } | ||
| 2687 | + if (arr == null || arr.length == 0) { | ||
| 2688 | + arr = new Object[1]; | ||
| 2689 | + faFld.set(null, arr); | ||
| 2690 | + } | ||
| 2691 | + arr[0] = k2e; | ||
| 2692 | + XposedBridge.log("[WeChatHook] forceInitDI: fs.g.a[0]=" + k2e.getClass().getName()); | ||
| 2693 | + } | ||
| 2694 | + } catch (Throwable e) { | ||
| 2695 | + XposedBridge.log("[WeChatHook] forceInitDI: " + e); | ||
| 2696 | + } | ||
| 2697 | + } | ||
| 2698 | + | ||
| 2699 | + private static void trySendWithRetry(String talker, String text, int attempt, String procName) { | ||
| 2700 | + // Non-main WeChat processes (:push, :tools, etc.) lack storage init — fail immediately. | ||
| 2701 | + if (procName != null && procName.contains(":")) { | ||
| 2702 | + if (attempt == 0) XposedBridge.log("[WeChatHook] Skip send in non-main proc=" + procName); | ||
| 2703 | + return; | ||
| 2704 | + } | ||
| 2705 | + // Route A2: tg3.t1 high-level service locator | ||
| 2706 | + try { | ||
| 2707 | + sendViaHighLevel(talker, text); | ||
| 2708 | + XposedBridge.log("[WeChatHook] HighLevel send OK to=" + talker + " proc=" + procName); | ||
| 2709 | + return; | ||
| 2710 | + } catch (Throwable t) { | ||
| 2711 | + if (attempt == 0) | ||
| 2712 | + XposedBridge.log("[WeChatHook] HighLevel FAILED proc=" + procName + ": " + t); | ||
| 2713 | + } | ||
| 2714 | + | ||
| 2715 | + // Route A1b: w11.s1 factory (mid-level) | ||
| 2716 | + try { | ||
| 2717 | + sendViaFactory(talker, text); | ||
| 2718 | + XposedBridge.log("[WeChatHook] Factory send OK to=" + talker + " proc=" + procName); | ||
| 2719 | + return; | ||
| 2720 | + } catch (Throwable t) { | ||
| 2721 | + // Log every 5 attempts to track what's changing | ||
| 2722 | + if (attempt % 5 == 0) | ||
| 2723 | + XposedBridge.log("[WeChatHook] Factory FAILED attempt=" + attempt | ||
| 2724 | + + " proc=" + procName + ": " + t); | ||
| 2725 | + } | ||
| 2726 | + | ||
| 2727 | + // Route A1: w11.r0 direct (requires gm0.j1 DI ready) | ||
| 2728 | + if (isDIReady()) { | ||
| 2729 | + try { | ||
| 2730 | + sendViaR0(talker, text); | ||
| 2731 | + XposedBridge.log("[WeChatHook] R0 send OK to=" + talker + " proc=" + procName); | ||
| 2732 | + return; | ||
| 2733 | + } catch (Throwable t) { | ||
| 2734 | + if (attempt == 0) | ||
| 2735 | + XposedBridge.log("[WeChatHook] R0 FAILED proc=" + procName + ": " + t); | ||
| 2736 | + } | ||
| 2737 | + } | ||
| 2738 | + | ||
| 2739 | + // All paths failed — poll every 1s up to 60 attempts waiting for WeChat DI init | ||
| 2740 | + if (attempt < 60) { | ||
| 2741 | + if (attempt == 0) | ||
| 2742 | + XposedBridge.log("[WeChatHook] All paths not ready, polling proc=" + procName | ||
| 2743 | + + " to=" + talker); | ||
| 2744 | + new Handler(Looper.getMainLooper()).postDelayed( | ||
| 2745 | + () -> trySendWithRetry(talker, text, attempt + 1, procName), 1000); | ||
| 2746 | + } else { | ||
| 2747 | + XposedBridge.log("[WeChatHook] GIVE UP after 60s proc=" + procName + " to=" + talker); | ||
| 2748 | + } | ||
| 2749 | + } | ||
| 2750 | + | ||
| 2751 | + // Route A1b: w11.s1 factory — SendMsgCgiFactory.a(talker) → w11.r1.e(content).b() | ||
| 2752 | + private static void sendViaFactory(String talker, String text) throws Throwable { | ||
| 2753 | + ClassLoader cl = sTinkerCL != null ? sTinkerCL : sWeChatCL; | ||
| 2754 | + if (cl == null) throw new IllegalStateException("classloader null"); | ||
| 2755 | + Class<?> s1 = XposedHelpers.findClass("w11.s1", cl); | ||
| 2756 | + Object r1 = XposedHelpers.callStaticMethod(s1, "a", talker); | ||
| 2757 | + if (r1 == null) throw new IllegalStateException("w11.s1.a() returned null"); | ||
| 2758 | + try { | ||
| 2759 | + XposedHelpers.callMethod(r1, "e", text); | ||
| 2760 | + } catch (Throwable t) { | ||
| 2761 | + throw new RuntimeException("r1.e() failed: " + t, t); | ||
| 2762 | + } | ||
| 2763 | + try { | ||
| 2764 | + XposedHelpers.callMethod(r1, "b"); | ||
| 2765 | + } catch (Throwable t) { | ||
| 2766 | + throw new RuntimeException("r1.b() failed: " + t, t); | ||
| 2767 | + } | ||
| 2768 | + } | ||
| 2769 | + | ||
| 2770 | + // Route A2: high-level API via tg3.t1 service locator → dk5.s5.Ni(). | ||
| 2771 | + // This path is what WeChat UI uses — internally handles DI, account context, etc. | ||
| 2772 | + private static void sendViaHighLevel(String talker, String text) throws Throwable { | ||
| 2773 | + ClassLoader cl = sTinkerCL != null ? sTinkerCL : sWeChatCL; | ||
| 2774 | + if (cl == null) throw new IllegalStateException("classloader null"); | ||
| 2775 | + // Text = same PROVEN path as voice sendVoiceViaAPI: w11.r0 ctor (type=1) inserts | ||
| 2776 | + // the f9 into the DB, then z2.b(NetSceneQueue) dispatches over the real network. | ||
| 2777 | + // Text carries no CDN attachment, so unlike voice it completes fully. (The s1.a / | ||
| 2778 | + // s1.b / dk5.s5.Ni builder paths either produced name-cards or silently failed to | ||
| 2779 | + // persist — they need hidden r1 fields + MsgRetransmitUI.P6 that we can't set robustly.) | ||
| 2780 | + Class<?> r0Class = XposedHelpers.findClass("w11.r0", cl); | ||
| 2781 | + Object r0; | ||
| 2782 | + sCalledByUs = true; | ||
| 2783 | + try { | ||
| 2784 | + // Object-ctor is the one that actually inserts the f9 (confirmed by | ||
| 2785 | + // sendVoiceViaAPI). obj=null for text (no audio/msgsource extras). | ||
| 2786 | + r0 = XposedHelpers.newInstance(r0Class, | ||
| 2787 | + new Class<?>[]{String.class, String.class, int.class, int.class, Object.class}, | ||
| 2788 | + talker, text, 1, 0, null); // (talker, content, type=1 TEXT, flag=0, null) | ||
| 2789 | + } finally { sCalledByUs = false; } | ||
| 2790 | + Class<?> z2c = XposedHelpers.findClass("com.tencent.mm.modelbase.z2", cl); | ||
| 2791 | + XposedHelpers.callStaticMethod(z2c, "b", r0); // enqueue → doScene → newsendmsg | ||
| 2792 | + XposedBridge.log("[WeChatHook] sendViaHighLevel(TEXT via r0-obj+z2.b): OK to=" + talker); | ||
| 2793 | + } | ||
| 2794 | + | ||
| 2795 | + private static void registerSendReceiver(Context ctx, String procName) { | ||
| 2796 | + // SEND_MSG_INTERNAL: send a text message | ||
| 2797 | + BroadcastReceiver sendRecv = new BroadcastReceiver() { | ||
| 2798 | + @Override | ||
| 2799 | + public void onReceive(Context context, Intent intent) { | ||
| 2800 | + String talker = intent.getStringExtra("to"); | ||
| 2801 | + String rawText = intent.getStringExtra("text"); | ||
| 2802 | + // Non-ASCII (Chinese) via `am broadcast --es text` gets mangled by the | ||
| 2803 | + // shell/am → server rejects → stuck "sending". Prefer base64: send with | ||
| 2804 | + // --es text_b64 <base64(utf-8)> and we decode it here intact. | ||
| 2805 | + String textB64 = intent.getStringExtra("text_b64"); | ||
| 2806 | + if (textB64 != null && !textB64.isEmpty()) { | ||
| 2807 | + try { | ||
| 2808 | + rawText = new String(android.util.Base64.decode(textB64, android.util.Base64.DEFAULT), "UTF-8"); | ||
| 2809 | + } catch (Throwable t) { | ||
| 2810 | + XposedBridge.log("[WeChatHook] SEND_MSG_INTERNAL: bad text_b64: " + t); | ||
| 2811 | + } | ||
| 2812 | + } | ||
| 2813 | + final String text = rawText; | ||
| 2814 | + if (talker == null || talker.isEmpty() || text == null || text.isEmpty()) { | ||
| 2815 | + XposedBridge.log("[WeChatHook] SEND_MSG_INTERNAL: missing 'to' or 'text'/'text_b64'"); | ||
| 2816 | + return; | ||
| 2817 | + } | ||
| 2818 | + XposedBridge.log("[WeChatHook] SEND_MSG_INTERNAL proc=" + procName | ||
| 2819 | + + " pid=" + android.os.Process.myPid() | ||
| 2820 | + + " to=" + talker + " len=" + text.length()); | ||
| 2821 | + new Handler(Looper.getMainLooper()).post(() -> trySendWithRetry(talker, text, 0, procName)); | ||
| 2822 | + } | ||
| 2823 | + }; | ||
| 2824 | + ctx.registerReceiver(sendRecv, new IntentFilter(ACTION_SEND), Context.RECEIVER_NOT_EXPORTED); | ||
| 2825 | + | ||
| 2826 | + // SEND_IMAGE_INTERNAL: send an image via w11.s1.b internal API | ||
| 2827 | + // Usage: adb shell su -c 'am broadcast -a com.hermes.wechat.SEND_IMAGE_INTERNAL \ | ||
| 2828 | + // --es to "filehelper" --es path "/sdcard/test.jpg"' | ||
| 2829 | + BroadcastReceiver imgRecv = new BroadcastReceiver() { | ||
| 2830 | + @Override | ||
| 2831 | + public void onReceive(Context context, Intent intent) { | ||
| 2832 | + // Only handle in main WeChat process (sub-processes lack storage init) | ||
| 2833 | + if (procName.contains(":")) return; | ||
| 2834 | + String talker = intent.getStringExtra("to"); | ||
| 2835 | + String path = intent.getStringExtra("path"); | ||
| 2836 | + if (talker == null || talker.isEmpty() || path == null || path.isEmpty()) { | ||
| 2837 | + XposedBridge.log("[WeChatHook] SEND_IMAGE_INTERNAL: missing 'to' or 'path'"); | ||
| 2838 | + return; | ||
| 2839 | + } | ||
| 2840 | + XposedBridge.log("[WeChatHook] SEND_IMAGE_INTERNAL proc=" + procName | ||
| 2841 | + + " to=" + talker + " path=" + path); | ||
| 2842 | + new Handler(Looper.getMainLooper()).post(() -> { | ||
| 2843 | + try { | ||
| 2844 | + sendImageViaAPI(talker, path); | ||
| 2845 | + } catch (Throwable t) { | ||
| 2846 | + XposedBridge.log("[WeChatHook] SEND_IMAGE_INTERNAL failed: " + t | ||
| 2847 | + + "\n" + android.util.Log.getStackTraceString(t)); | ||
| 2848 | + } | ||
| 2849 | + }); | ||
| 2850 | + } | ||
| 2851 | + }; | ||
| 2852 | + ctx.registerReceiver(imgRecv, new IntentFilter(ACTION_SEND_IMAGE), Context.RECEIVER_NOT_EXPORTED); | ||
| 2853 | + | ||
| 2854 | + // SEND_VOICE_INTERNAL: send a voice message via w11.r0 + f9 internal API. | ||
| 2855 | + // The caller (root, Python helper) must pre-place the SILK file at: | ||
| 2856 | + // /data/data/com.tencent.mm/MicroMsg/{hash}/voice2/{md5(id)[0:2]}/{md5(id)[2:4]}/msg_{id}.amr | ||
| 2857 | + // Usage: adb shell su -c 'am broadcast -a com.hermes.wechat.SEND_VOICE_INTERNAL \ | ||
| 2858 | + // --es to "filehelper" --es voice_id "26hexchars" --ei duration 3000 [--es from_wxid "wxid_..."]' | ||
| 2859 | + BroadcastReceiver voiceRecv = new BroadcastReceiver() { | ||
| 2860 | + @Override | ||
| 2861 | + public void onReceive(Context context, Intent intent) { | ||
| 2862 | + if (procName.contains(":")) return; | ||
| 2863 | + String talker = intent.getStringExtra("to"); | ||
| 2864 | + String voiceId = intent.getStringExtra("voice_id"); // pre-placed file | ||
| 2865 | + String srcPath = intent.getStringExtra("src_path"); // hook copies from here | ||
| 2866 | + int duration = intent.getIntExtra("duration", 0); | ||
| 2867 | + String fromWxId = intent.getStringExtra("from_wxid"); | ||
| 2868 | + if (talker == null || talker.isEmpty() | ||
| 2869 | + || (voiceId == null && srcPath == null)) { | ||
| 2870 | + XposedBridge.log("[WeChatHook] SEND_VOICE_INTERNAL: need 'to' + 'voice_id' or 'src_path'"); | ||
| 2871 | + return; | ||
| 2872 | + } | ||
| 2873 | + if (duration <= 0) { | ||
| 2874 | + XposedBridge.log("[WeChatHook] SEND_VOICE_INTERNAL: missing 'duration'"); | ||
| 2875 | + return; | ||
| 2876 | + } | ||
| 2877 | + XposedBridge.log("[WeChatHook] SEND_VOICE_INTERNAL proc=" + procName | ||
| 2878 | + + " to=" + talker + " voice_id=" + voiceId + " src=" + srcPath + " dur=" + duration); | ||
| 2879 | + final String fVoiceId = voiceId, fSrcPath = srcPath; | ||
| 2880 | + new Handler(Looper.getMainLooper()).post(() -> { | ||
| 2881 | + try { | ||
| 2882 | + sendVoiceViaAPI(talker, fVoiceId, fSrcPath, duration, fromWxId); | ||
| 2883 | + } catch (Throwable t) { | ||
| 2884 | + XposedBridge.log("[WeChatHook] SEND_VOICE_INTERNAL failed: " + t | ||
| 2885 | + + "\n" + android.util.Log.getStackTraceString(t)); | ||
| 2886 | + } | ||
| 2887 | + }); | ||
| 2888 | + } | ||
| 2889 | + }; | ||
| 2890 | + ctx.registerReceiver(voiceRecv, new IntentFilter(ACTION_SEND_VOICE), Context.RECEIVER_NOT_EXPORTED); | ||
| 2891 | + | ||
| 2892 | + // ARM_VOICE_SWAP: load our TTS SILK into memory and arm the jg0.x.w swap. | ||
| 2893 | + // After arming, trigger a REAL recording (hold "按住说话") to the target chat — | ||
| 2894 | + // WeChat records, but jg0.x.w uploads our TTS bytes instead → delivered. | ||
| 2895 | + // Usage: am broadcast -a com.hermes.wechat.ARM_VOICE_SWAP \ | ||
| 2896 | + // --es src_path /data/data/com.tencent.mm/cache/hook_voice_src.amr --ei duration 3200 | ||
| 2897 | + BroadcastReceiver armRecv = new BroadcastReceiver() { | ||
| 2898 | + @Override | ||
| 2899 | + public void onReceive(Context context, Intent intent) { | ||
| 2900 | + if (procName.contains(":")) return; // main process only (where recording runs) | ||
| 2901 | + String srcPath = intent.getStringExtra("src_path"); | ||
| 2902 | + int duration = intent.getIntExtra("duration", 0); | ||
| 2903 | + if (srcPath == null || srcPath.isEmpty()) { | ||
| 2904 | + XposedBridge.log("[WeChatHook] ARM_VOICE_SWAP: missing 'src_path'"); | ||
| 2905 | + return; | ||
| 2906 | + } | ||
| 2907 | + try { | ||
| 2908 | + java.io.File f = new java.io.File(srcPath); | ||
| 2909 | + byte[] bytes = new byte[(int) f.length()]; | ||
| 2910 | + try (java.io.FileInputStream in = new java.io.FileInputStream(f)) { | ||
| 2911 | + int off = 0, n; | ||
| 2912 | + while (off < bytes.length && (n = in.read(bytes, off, bytes.length - off)) > 0) off += n; | ||
| 2913 | + } | ||
| 2914 | + sSwapSilk = bytes; | ||
| 2915 | + sSwapDuration = duration; | ||
| 2916 | + sSwapExpiresAt = System.currentTimeMillis() + Math.max(duration, 0) + 10000; | ||
| 2917 | + sSwapArmed = true; | ||
| 2918 | + XposedBridge.log("[WeChatHook] ARM_VOICE_SWAP: armed " + bytes.length | ||
| 2919 | + + "B dur=" + duration + "ms from " + srcPath | ||
| 2920 | + + " (expires in " + ((sSwapExpiresAt - System.currentTimeMillis()) / 1000) | ||
| 2921 | + + "s — trigger a real recording now)"); | ||
| 2922 | + } catch (Throwable t) { | ||
| 2923 | + XposedBridge.log("[WeChatHook] ARM_VOICE_SWAP failed: " + t); | ||
| 2924 | + } | ||
| 2925 | + } | ||
| 2926 | + }; | ||
| 2927 | + ctx.registerReceiver(armRecv, new IntentFilter(ACTION_ARM_SWAP), Context.RECEIVER_NOT_EXPORTED); | ||
| 2928 | + | ||
| 2929 | + // DUMP_WECHAT_API: on-demand class introspection (main process only) | ||
| 2930 | + // Usage: adb shell su -c 'am broadcast -a com.hermes.wechat.DUMP_WECHAT_API [--es class "w11.s1"]' | ||
| 2931 | + BroadcastReceiver dumpRecv = new BroadcastReceiver() { | ||
| 2932 | + @Override | ||
| 2933 | + public void onReceive(Context context, Intent intent) { | ||
| 2934 | + XposedBridge.log("[WeChatHook] DUMP_WECHAT_API received in " + procName); | ||
| 2935 | + new Handler(Looper.getMainLooper()).post(() -> handleDumpApi(intent)); | ||
| 2936 | + } | ||
| 2937 | + }; | ||
| 2938 | + ctx.registerReceiver(dumpRecv, new IntentFilter(ACTION_DUMP_API), Context.RECEIVER_NOT_EXPORTED); | ||
| 2939 | + | ||
| 2940 | + XposedBridge.log("[WeChatHook] BroadcastReceivers registered in " + procName); | ||
| 2941 | + } | ||
| 2942 | + | ||
| 2943 | + private static void sendViaR0(String talker, String text) throws Throwable { | ||
| 2944 | + ClassLoader cl = sTinkerCL != null ? sTinkerCL : sWeChatCL; | ||
| 2945 | + if (cl == null) throw new IllegalStateException("classloader null"); | ||
| 2946 | + Class<?> r0Class = XposedHelpers.findClass("w11.r0", cl); | ||
| 2947 | + long ts = System.currentTimeMillis(); | ||
| 2948 | + Object msg; | ||
| 2949 | + sCalledByUs = true; | ||
| 2950 | + try { | ||
| 2951 | + try { | ||
| 2952 | + msg = XposedHelpers.newInstance(r0Class, | ||
| 2953 | + new Class<?>[]{String.class, String.class, int.class, int.class, long.class}, | ||
| 2954 | + talker, text, 1, 0, ts); | ||
| 2955 | + } catch (Throwable t) { | ||
| 2956 | + msg = XposedHelpers.newInstance(r0Class, | ||
| 2957 | + new Class<?>[]{String.class, String.class, int.class, int.class, Object.class}, | ||
| 2958 | + talker, text, 1, 0, (Object) ts); | ||
| 2959 | + } | ||
| 2960 | + } finally { | ||
| 2961 | + sCalledByUs = false; | ||
| 2962 | + } | ||
| 2963 | + | ||
| 2964 | + try { | ||
| 2965 | + Class<?> u0 = XposedHelpers.findClass("com.tencent.mm.modelbase.u0", cl); | ||
| 2966 | + Object stub = Proxy.newProxyInstance(cl, new Class<?>[]{u0}, | ||
| 2967 | + (proxy, method, args) -> { | ||
| 2968 | + XposedBridge.log("[WeChatHook] send-cb: " + method.getName()); | ||
| 2969 | + return null; | ||
| 2970 | + }); | ||
| 2971 | + XposedHelpers.setObjectField(msg, "d", stub); | ||
| 2972 | + } catch (Throwable ignored) {} | ||
| 2973 | + | ||
| 2974 | + sCalledByUs = true; | ||
| 2975 | + try { | ||
| 2976 | + XposedHelpers.callMethod(msg, "H", ""); | ||
| 2977 | + } finally { | ||
| 2978 | + sCalledByUs = false; | ||
| 2979 | + } | ||
| 2980 | + } | ||
| 2981 | +} |
xhs_lsposed_a16_fix/backups/rebuild_wechathook_20260706.sh
0 → 100755
| 1 | +#!/bin/bash | ||
| 2 | +# rebuild_wechathook.sh — compile WeChatHook, repackage, redeploy to device | ||
| 3 | +# Does not need the LSPosed source tree — uses bundled XposedBridge stubs. | ||
| 4 | +set -e | ||
| 5 | + | ||
| 6 | +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" | ||
| 7 | +ADB="${ADB:-$SCRIPT_DIR/../platform-tools/adb}" | ||
| 8 | +[ ! -f "$ADB" ] && ADB=adb | ||
| 9 | + | ||
| 10 | +ANDROID_HOME="${ANDROID_HOME:-/opt/homebrew/share/android-commandlinetools}" | ||
| 11 | +ANDROID_JAR="$ANDROID_HOME/platforms/android-36/android.jar" | ||
| 12 | +D8="$ANDROID_HOME/build-tools/36.0.0/d8" | ||
| 13 | +APKSIGNER="$ANDROID_HOME/build-tools/36.0.0/apksigner" | ||
| 14 | +ZIPALIGN="$ANDROID_HOME/build-tools/36.0.0/zipalign" | ||
| 15 | +JAVA_HOME="${JAVA_HOME:-/opt/homebrew/opt/openjdk@17}" | ||
| 16 | +JAVAC="$JAVA_HOME/bin/javac" | ||
| 17 | +KEYTOOL="$JAVA_HOME/bin/keytool" | ||
| 18 | + | ||
| 19 | +GOLDEN_APK="$SCRIPT_DIR/wechathook_v2_signed.apk" | ||
| 20 | +DEBUG_KS="$HOME/.android/debug.keystore" | ||
| 21 | +STUBS_DIR="/tmp/xposed_stubs" | ||
| 22 | +BUILD_DIR="/tmp/wechathook_rebuild" | ||
| 23 | + | ||
| 24 | +# ── Write XposedBridge stubs (minimal, for compilation only) ────────────────── | ||
| 25 | +echo "== Writing XposedBridge stubs ==" | ||
| 26 | +mkdir -p "$STUBS_DIR/de/robv/android/xposed/callbacks" | ||
| 27 | + | ||
| 28 | +cat > "$STUBS_DIR/de/robv/android/xposed/XposedBridge.java" << 'JAVA' | ||
| 29 | +package de.robv.android.xposed; | ||
| 30 | +public class XposedBridge { | ||
| 31 | + public static void log(String text) {} | ||
| 32 | + public static void log(Throwable t) {} | ||
| 33 | + public static XC_MethodHook.Unhook hookMethod( | ||
| 34 | + java.lang.reflect.Member hookMethod, XC_MethodHook callback) { return null; } | ||
| 35 | +} | ||
| 36 | +JAVA | ||
| 37 | + | ||
| 38 | +cat > "$STUBS_DIR/de/robv/android/xposed/XC_MethodHook.java" << 'JAVA' | ||
| 39 | +package de.robv.android.xposed; | ||
| 40 | +public abstract class XC_MethodHook { | ||
| 41 | + public class Unhook { public void unhook() {} } | ||
| 42 | + public static class MethodHookParam { | ||
| 43 | + public Object thisObject; | ||
| 44 | + public Object[] args; | ||
| 45 | + public Object getResult() { return null; } | ||
| 46 | + public void setResult(Object result) {} | ||
| 47 | + public Throwable getThrowable() { return null; } | ||
| 48 | + public boolean hasThrowable() { return false; } | ||
| 49 | + } | ||
| 50 | + protected void beforeHookedMethod(MethodHookParam param) throws Throwable {} | ||
| 51 | + protected void afterHookedMethod(MethodHookParam param) throws Throwable {} | ||
| 52 | +} | ||
| 53 | +JAVA | ||
| 54 | + | ||
| 55 | +cat > "$STUBS_DIR/de/robv/android/xposed/XposedHelpers.java" << 'JAVA' | ||
| 56 | +package de.robv.android.xposed; | ||
| 57 | +public class XposedHelpers { | ||
| 58 | + public static Class<?> findClass(String className, ClassLoader classLoader) { return null; } | ||
| 59 | + public static Object callStaticMethod(Class<?> clazz, String methodName, Object... args) { return null; } | ||
| 60 | + public static Object callMethod(Object obj, String methodName, Object... args) { return null; } | ||
| 61 | + public static Object callMethod(Object obj, String methodName, Class<?>[] paramTypes, Object... args) { return null; } | ||
| 62 | + public static Object newInstance(Class<?> clazz, Object... args) { return null; } | ||
| 63 | + public static Object newInstance(Class<?> clazz, Class<?>[] paramTypes, Object... args) { return null; } | ||
| 64 | + public static Object getObjectField(Object obj, String fieldName) { return null; } | ||
| 65 | + public static void setObjectField(Object obj, String fieldName, Object value) {} | ||
| 66 | + public static XC_MethodHook.Unhook findAndHookMethod( | ||
| 67 | + String className, ClassLoader cl, String methodName, Object... paramTypesAndCallback) { return null; } | ||
| 68 | + public static XC_MethodHook.Unhook findAndHookMethod( | ||
| 69 | + Class<?> clazz, String methodName, Object... paramTypesAndCallback) { return null; } | ||
| 70 | + public static XC_MethodHook.Unhook findAndHookConstructor( | ||
| 71 | + String className, ClassLoader cl, Object... paramTypesAndCallback) { return null; } | ||
| 72 | + public static class InvocationTargetError extends Error { | ||
| 73 | + public InvocationTargetError(String msg, Throwable cause) { super(msg, cause); } | ||
| 74 | + } | ||
| 75 | +} | ||
| 76 | +JAVA | ||
| 77 | + | ||
| 78 | +cat > "$STUBS_DIR/de/robv/android/xposed/IXposedHookLoadPackage.java" << 'JAVA' | ||
| 79 | +package de.robv.android.xposed; | ||
| 80 | +public interface IXposedHookLoadPackage { | ||
| 81 | + void handleLoadPackage(de.robv.android.xposed.callbacks.XC_LoadPackage.LoadPackageParam lpparam) throws Throwable; | ||
| 82 | +} | ||
| 83 | +JAVA | ||
| 84 | + | ||
| 85 | +cat > "$STUBS_DIR/de/robv/android/xposed/callbacks/XC_LoadPackage.java" << 'JAVA' | ||
| 86 | +package de.robv.android.xposed.callbacks; | ||
| 87 | +public class XC_LoadPackage { | ||
| 88 | + public static class LoadPackageParam { | ||
| 89 | + public String packageName; | ||
| 90 | + public String processName; | ||
| 91 | + public ClassLoader classLoader; | ||
| 92 | + public android.content.pm.ApplicationInfo appInfo; | ||
| 93 | + public boolean isFirstApplication; | ||
| 94 | + } | ||
| 95 | +} | ||
| 96 | +JAVA | ||
| 97 | + | ||
| 98 | +# ── Compile ──────────────────────────────────────────────────────────────────── | ||
| 99 | +rm -rf "$BUILD_DIR" | ||
| 100 | +mkdir -p "$BUILD_DIR/compiled" "$BUILD_DIR/dex" "$BUILD_DIR/pack" | ||
| 101 | + | ||
| 102 | +echo "== Compiling XposedBridge stubs ==" | ||
| 103 | +"$JAVAC" -source 11 -target 11 -cp "$ANDROID_JAR" \ | ||
| 104 | + -d "$BUILD_DIR/compiled" \ | ||
| 105 | + $(find "$STUBS_DIR" -name "*.java") 2>/dev/null | ||
| 106 | + | ||
| 107 | +echo "== Compiling WeChatHook.java ==" | ||
| 108 | +"$JAVAC" -source 11 -target 11 \ | ||
| 109 | + -cp "$ANDROID_JAR:$BUILD_DIR/compiled" \ | ||
| 110 | + -d "$BUILD_DIR/compiled" \ | ||
| 111 | + "$SCRIPT_DIR/wechathook_src/WeChatHook.java" | ||
| 112 | + | ||
| 113 | +echo "== Converting to DEX ==" | ||
| 114 | +"$D8" --min-api 30 --output "$BUILD_DIR/dex/" \ | ||
| 115 | + $(find "$BUILD_DIR/compiled/com/example/wechathook" -name "*.class") | ||
| 116 | + | ||
| 117 | +# ── Package ─────────────────────────────────────────────────────────────────── | ||
| 118 | +echo "== Packing APK ==" | ||
| 119 | +cp "$GOLDEN_APK" "$BUILD_DIR/pack/wechathook.apk" | ||
| 120 | +# Replace DEX in place (the golden APK already has correct manifest + xposed_init) | ||
| 121 | +cd "$BUILD_DIR/pack" | ||
| 122 | +zip -u wechathook.apk -j "$BUILD_DIR/dex/classes.dex" > /dev/null | ||
| 123 | + | ||
| 124 | +"$ZIPALIGN" -v -f 4 wechathook.apk wechathook_aligned.apk > /dev/null | ||
| 125 | + | ||
| 126 | +# ── Sign with Android debug keystore (same as golden APK) ──────────────────── | ||
| 127 | +echo "== Signing ==" | ||
| 128 | +OUT="$BUILD_DIR/wechathook_signed.apk" | ||
| 129 | +"$APKSIGNER" sign \ | ||
| 130 | + --ks "$DEBUG_KS" \ | ||
| 131 | + --ks-key-alias androiddebugkey \ | ||
| 132 | + --ks-pass pass:android \ | ||
| 133 | + --key-pass pass:android \ | ||
| 134 | + --out "$OUT" \ | ||
| 135 | + wechathook_aligned.apk 2>/dev/null | ||
| 136 | + | ||
| 137 | +echo "APK size: $(wc -c < "$OUT") bytes" | ||
| 138 | + | ||
| 139 | +# ── Deploy to device ────────────────────────────────────────────────────────── | ||
| 140 | +echo "== Deploying to device ==" | ||
| 141 | + | ||
| 142 | +# Push to stable path | ||
| 143 | +$ADB push "$OUT" /data/local/tmp/wechathook.apk | ||
| 144 | + | ||
| 145 | +# Install via PKMS (use -r to update in-place if same sig; if first time, just install) | ||
| 146 | +INSTALL_OUT=$($ADB shell "su 0 sh -c 'pm install -r /data/local/tmp/wechathook.apk 2>&1'" | tr -d '\r') | ||
| 147 | +echo "pm install: $INSTALL_OUT" | ||
| 148 | +if echo "$INSTALL_OUT" | grep -q "FAILED"; then | ||
| 149 | + echo "Retrying without -r (signature mismatch or first install)..." | ||
| 150 | + $ADB shell "su 0 sh -c 'pm uninstall com.example.wechathook 2>&1 || true'" | ||
| 151 | + $ADB shell "su 0 sh -c 'pm install /data/local/tmp/wechathook.apk 2>&1'" | ||
| 152 | +fi | ||
| 153 | + | ||
| 154 | +# Get new PKMS path | ||
| 155 | +NEW_PATH=$($ADB shell "pm path com.example.wechathook 2>/dev/null | tr -d '\r' | sed 's/package://'" 2>/dev/null) | ||
| 156 | +echo "New PKMS path: $NEW_PATH" | ||
| 157 | + | ||
| 158 | +# Update lspd DB (device must be unlocked — Termux CE storage must be accessible) | ||
| 159 | +SQLITE3="LD_LIBRARY_PATH=/data/data/com.termux/files/usr/lib /data/data/com.termux/files/usr/bin/sqlite3" | ||
| 160 | +DB="/data/adb/lspd/config/modules_config.db" | ||
| 161 | + | ||
| 162 | +$ADB shell "su -c \"printf \\\"INSERT OR REPLACE INTO modules(module_pkg_name,apk_path,enabled) VALUES('com.example.wechathook','$NEW_PATH',1);\\n\ | ||
| 163 | +INSERT OR IGNORE INTO scope(mid,app_pkg_name,user_id) SELECT mid,'com.tencent.mm',0 FROM modules WHERE module_pkg_name='com.example.wechathook';\\n\ | ||
| 164 | +SELECT mid,module_pkg_name,apk_path FROM modules;\\n\\\" > /data/local/tmp/update.sql\"" | ||
| 165 | +$ADB shell "su -c '$SQLITE3 $DB < /data/local/tmp/update.sql 2>&1'" | ||
| 166 | + | ||
| 167 | +# Ensure scope file exists | ||
| 168 | +$ADB shell "su -c 'mkdir -p /data/adb/lspd/config/modules/com.example.wechathook && echo com.tencent.mm > /data/adb/lspd/config/modules/com.example.wechathook/scope && chmod 644 /data/adb/lspd/config/modules/com.example.wechathook/scope'" | ||
| 169 | + | ||
| 170 | +# WAL checkpoint + sync to all .bak files | ||
| 171 | +$ADB shell "su -c '$SQLITE3 $DB \"PRAGMA wal_checkpoint(TRUNCATE);\" && \ | ||
| 172 | + cp $DB ${DB}.bak && cp $DB ${DB}.bak2 && cp $DB ${DB}.bak3 && cp $DB ${DB}.bak4 && echo baks_done'" | ||
| 173 | + | ||
| 174 | +# Restart WeChat to pick up new module (no reboot needed) | ||
| 175 | +$ADB shell "su 0 sh -c 'am force-stop com.tencent.mm'" | ||
| 176 | +sleep 2 | ||
| 177 | +$ADB shell "am start -n com.tencent.mm/.ui.LauncherUI 2>/dev/null || true" | ||
| 178 | + | ||
| 179 | +echo "" | ||
| 180 | +echo "== Done. Test with: ==" | ||
| 181 | +echo " $ADB shell \"su 0 sh -c 'am broadcast -a com.hermes.wechat.SEND_MSG_INTERNAL --es to filehelper --es text test'\"" |
xhs_lsposed_a16_fix/backups/wechat_send_20260706.sh
0 → 100755
| 1 | +#!/bin/bash | ||
| 2 | +# wechat_send.sh — unified WeChat send CLI via the internal hook | ||
| 3 | +# | ||
| 4 | +# Usage: | ||
| 5 | +# ./wechat_send.sh text <wxid> <message> | ||
| 6 | +# ./wechat_send.sh image <wxid> <device_image_path> | ||
| 7 | +# ./wechat_send.sh voice <wxid> <local_silk_path> [duration_ms] | ||
| 8 | +# | ||
| 9 | +# All send operations go through the wechathook LSPosed module's internal API, | ||
| 10 | +# which calls WeChat's own w11.r0/w11.s1 factory — no UI simulation. | ||
| 11 | +# <wxid>: talker id as stored in WeChat DB (e.g. 'filehelper', 'wxid_xxx'). | ||
| 12 | +# IMPORTANT: test-only with 'filehelper' to avoid account risk. | ||
| 13 | +# | ||
| 14 | +# Voice notes: | ||
| 15 | +# <local_silk_path> — local SILK V3 file (Tencent prefix \x02#!SILK_V3). | ||
| 16 | +# [duration_ms] — playback duration in ms (default: 3000). | ||
| 17 | +# The script pushes the SILK to WeChat's cache dir and fires the broadcast. | ||
| 18 | +# | ||
| 19 | +# Examples: | ||
| 20 | +# ./wechat_send.sh text filehelper "hello world" | ||
| 21 | +# ./wechat_send.sh image filehelper /tmp/screenshot.png | ||
| 22 | +# ./wechat_send.sh voice filehelper /tmp/tts_out.silk 4617 | ||
| 23 | + | ||
| 24 | +set -e | ||
| 25 | + | ||
| 26 | +ADB="${ADB:-adb}" | ||
| 27 | +WECHAT_CACHE="/data/data/com.tencent.mm/cache/hook_voice_src.amr" | ||
| 28 | + | ||
| 29 | +usage() { | ||
| 30 | + echo "Usage: $0 text <wxid> <message>" | ||
| 31 | + echo " $0 image <wxid> <device_path>" | ||
| 32 | + echo " $0 voice <wxid> <local_silk_path> [duration_ms]" | ||
| 33 | + exit 1 | ||
| 34 | +} | ||
| 35 | + | ||
| 36 | +[ $# -lt 3 ] && usage | ||
| 37 | + | ||
| 38 | +CMD="$1"; WXID="$2"; shift 2 | ||
| 39 | + | ||
| 40 | +case "$CMD" in | ||
| 41 | +text) | ||
| 42 | + PAYLOAD="$*" | ||
| 43 | + B64_WXID=$(printf '%s' "$WXID" | base64 | tr -d '\n') | ||
| 44 | + B64_TEXT=$(printf '%s' "$PAYLOAD" | base64 | tr -d '\n') | ||
| 45 | + INNER=$(printf 'TO=$(printf "%%s" "%s" | base64 -d); TEXT=$(printf "%%s" "%s" | base64 -d); am broadcast -a com.hermes.wechat.SEND_MSG_INTERNAL --es to "$TO" --es text "$TEXT"' \ | ||
| 46 | + "$B64_WXID" "$B64_TEXT") | ||
| 47 | + B64_INNER=$(printf '%s' "$INNER" | base64 | tr -d '\n') | ||
| 48 | + echo "Sending text to $WXID..." | ||
| 49 | + $ADB shell "su -c 'echo $B64_INNER | base64 -d | sh'" | ||
| 50 | + ;; | ||
| 51 | + | ||
| 52 | +image) | ||
| 53 | + IMAGE_PATH="$1" | ||
| 54 | + EXT="${IMAGE_PATH##*.}" | ||
| 55 | + SHARE="/sdcard/wechat_share_tmp.$EXT" | ||
| 56 | + MIME="image/jpeg" | ||
| 57 | + [ "$EXT" = "png" ] && MIME="image/png" | ||
| 58 | + echo "Copying image to $SHARE..." | ||
| 59 | + $ADB shell "su -c 'cp \"$IMAGE_PATH\" \"$SHARE\" && chmod 644 \"$SHARE\"'" | ||
| 60 | + echo "Sending image to $WXID via share intent..." | ||
| 61 | + $ADB shell "am start -a android.intent.action.SEND -t $MIME \ | ||
| 62 | + --eu android.intent.extra.STREAM 'file://$SHARE' \ | ||
| 63 | + -p com.tencent.mm" | ||
| 64 | + echo "WeChat share intent fired. Select '$WXID' in WeChat's contact picker and tap 发送." | ||
| 65 | + ;; | ||
| 66 | + | ||
| 67 | +voice) | ||
| 68 | + SILK_PATH="$1" | ||
| 69 | + DURATION="${2:-3000}" | ||
| 70 | + [ -z "$SILK_PATH" ] && usage | ||
| 71 | + [ ! -f "$SILK_PATH" ] && { echo "File not found: $SILK_PATH"; exit 1; } | ||
| 72 | + | ||
| 73 | + echo "Pushing SILK to WeChat cache..." | ||
| 74 | + $ADB push "$SILK_PATH" /sdcard/hook_voice_tmp.amr | ||
| 75 | + $ADB shell "su -c 'cp /sdcard/hook_voice_tmp.amr $WECHAT_CACHE && chmod 644 $WECHAT_CACHE && chown u0_a330:u0_a330 $WECHAT_CACHE && rm /sdcard/hook_voice_tmp.amr'" | ||
| 76 | + | ||
| 77 | + echo "Sending voice to $WXID (duration=${DURATION}ms)..." | ||
| 78 | + $ADB shell "su -c 'am broadcast -a com.hermes.wechat.SEND_VOICE_INTERNAL --es to $WXID --es src_path $WECHAT_CACHE --ei duration $DURATION'" | ||
| 79 | + ;; | ||
| 80 | + | ||
| 81 | +*) | ||
| 82 | + echo "Unknown command: $CMD" | ||
| 83 | + usage | ||
| 84 | + ;; | ||
| 85 | +esac |
xhs_lsposed_a16_fix/skill_wechat_send/SKILL.md
0 → 100644
| 1 | +--- | ||
| 2 | +name: wechat-internal-send | ||
| 3 | +description: 通过 LSPosed 内部 API 发送微信文字/图片/语音(调用微信自己的发送 API,无 UI 模拟点击) | ||
| 4 | +version: 1.0 | ||
| 5 | +category: communication | ||
| 6 | +--- | ||
| 7 | + | ||
| 8 | +# WeChat 内部 API 发送(文字 / 图片 / 语音) | ||
| 9 | + | ||
| 10 | +通过 LSPosed 模块 `wechathook` 直接调用微信自己的内部发送 API,**不模拟点击、不走无障碍**。 | ||
| 11 | +逆向自微信 8.0.74。完整技术文档见 `../WECHAT_SEND_API.md`。 | ||
| 12 | + | ||
| 13 | +## When to Use | ||
| 14 | +- 用户要求「发微信」「发条消息/图片/语音给某人」 | ||
| 15 | +- 需要程序化、无 UI 干扰地发微信(agent 自动发通知、日报、提醒) | ||
| 16 | + | ||
| 17 | +## 前置:拿到 wxid | ||
| 18 | +内部 API 认 **wxid(username)不认昵称**。从解密的 `EnMicroMsg.db` 查 `rcontact`: | ||
| 19 | +```bash | ||
| 20 | +# key = MD5("1234567890ABCDEF"+uin)[:7];本机 uin=215004919 → key=e03ce81 | ||
| 21 | +sqlcipher EnMicroMsg.db <<'EOF' | ||
| 22 | +PRAGMA key='e03ce81'; PRAGMA cipher_compatibility=1; PRAGMA cipher_page_size=1024; | ||
| 23 | +SELECT username,nickname FROM rcontact WHERE nickname LIKE '%火星人%'; | ||
| 24 | +EOF | ||
| 25 | +# → yibooo(火星人的 wxid) | ||
| 26 | +``` | ||
| 27 | +> ⚠️ 先只对 `filehelper`(文件传输助手,自己)测试,确认无误再发真人,避免账号风险。 | ||
| 28 | + | ||
| 29 | +## 能力与用法 | ||
| 30 | + | ||
| 31 | +### ✅ 文字(中英文,已验证送达) | ||
| 32 | +```bash | ||
| 33 | +# ASCII | ||
| 34 | +adb shell su -c 'am broadcast -a com.hermes.wechat.SEND_MSG_INTERNAL --es to yibooo --es text "hello"' | ||
| 35 | +# 中文(base64 避免 shell 损坏 UTF-8) | ||
| 36 | +B64=$(python3 -c "import base64;print(base64.b64encode('你好'.encode()).decode())") | ||
| 37 | +adb shell su -c "am broadcast -a com.hermes.wechat.SEND_MSG_INTERNAL --es to yibooo --es text_b64 '$B64'" | ||
| 38 | +``` | ||
| 39 | +或用封装脚本:`./wechat_send.sh text yibooo "你好火星人"` | ||
| 40 | + | ||
| 41 | +### 🟢 图片(消息+缩略图已可发,全图 CDN 上传是最后一公里) | ||
| 42 | +```bash | ||
| 43 | +adb shell su -c 'am broadcast -a com.hermes.wechat.SEND_IMAGE_INTERNAL --es to filehelper --es path /sdcard/Pictures/x.png' | ||
| 44 | +``` | ||
| 45 | +现状:图片消息建成、缩略图渲染、进真实发送管线;全图 CDN 上传仍在完善(详见技术文档 §3)。 | ||
| 46 | + | ||
| 47 | +### ✅ 语音(TTS→SILK,半自动 hot-swap) | ||
| 48 | +纯合成卡「发送中」,用 hot-swap:让微信真录一段 + 偷换 SILK 字节。 | ||
| 49 | +端到端脚本 `../tts_voice_swap.sh`(需先打开目标聊天并处语音模式)。详见技术文档 §2。 | ||
| 50 | + | ||
| 51 | +## 不要做 | ||
| 52 | +- **不要用 `dk5.s5.Ni` 发文字**——那是发名片的(会得到失败的空名片)。 | ||
| 53 | +- **不要自己实现编解码 / 缩略图 / CDN 上传**——都是 hook 内部的事。 | ||
| 54 | +- **不要假设 base.apk = 正在跑的码**——真实代码在 Tinker 补丁里,hook 要用 `sTinkerCL`。 | ||
| 55 | +- **不要用 force-stop 后查库验证送达**——SIGKILL 不 flush 加密 WAL,查不到最新。用实时截图/肉眼确认。 | ||
| 56 | + | ||
| 57 | +## 部署 / 重建 hook | ||
| 58 | +```bash | ||
| 59 | +export ADB=/opt/homebrew/share/android-commandlinetools/platform-tools/adb | ||
| 60 | +export ANDROID_HOME=/opt/homebrew/share/android-commandlinetools | ||
| 61 | +export JAVA_HOME=/opt/homebrew/opt/openjdk@17 | ||
| 62 | +bash ../rebuild_wechathook.sh # 编译+签名+安装+重启微信 | ||
| 63 | +``` | ||
| 64 | + | ||
| 65 | +## 相关 | ||
| 66 | +- `../WECHAT_SEND_API.md` — 完整逆向技术文档(内部 API 结构、踩坑、图片链路) | ||
| 67 | +- `../wechathook_src/WeChatHook.java` — hook 源码 | ||
| 68 | +- memory `wechat-voice-send-internals.md` — 逆向记录 + 踩坑史 |
xhs_lsposed_a16_fix/tts_voice_swap.sh
0 → 100755
| 1 | +#!/bin/bash | ||
| 2 | +# tts_voice_swap.sh — text → TTS → REAL WeChat voice message (delivered) | ||
| 3 | +# | ||
| 4 | +# This is the ONLY route that actually delivers synthetic voice. Fully-synthetic | ||
| 5 | +# sends (SEND_VOICE_INTERNAL) build the message shell but can never upload the SILK | ||
| 6 | +# to WeChat's CDN (upload is locked inside jg0.x's Kotlin coroutine). So instead we: | ||
| 7 | +# | ||
| 8 | +# 1. TTS the text → SILK V3 (same format WeChat records). | ||
| 9 | +# 2. Arm the wechathook swap (ARM_VOICE_SWAP) with our SILK. | ||
| 10 | +# 3. Trigger a REAL recording by holding "按住 说话" (input swipe, same point). | ||
| 11 | +# WeChat records ambient audio, but at the CDN-upload step (jg0.x.w) the hook | ||
| 12 | +# replaces the recorded SILK bytes (w21.t.a) with our TTS → WeChat uploads and | ||
| 13 | +# delivers OUR audio, reusing its entire real send pipeline. | ||
| 14 | +# | ||
| 15 | +# PRECONDITION: WeChat must already be open on the TARGET chat, in voice-input mode | ||
| 16 | +# (the bottom bar shows "按住 说话"). This script does NOT navigate there — pair it | ||
| 17 | +# with an A11y opener for full automation. Test-only with 文件传输助手 (filehelper). | ||
| 18 | +# | ||
| 19 | +# Usage: | ||
| 20 | +# ./tts_voice_swap.sh "<text>" [voice] [record_x] [record_y] | ||
| 21 | +# | ||
| 22 | +# Examples: | ||
| 23 | +# ./tts_voice_swap.sh "你好,我现在没办法说话,这是我用文字转成的语音。" | ||
| 24 | +# ./tts_voice_swap.sh "下午三点开会" zh-CN-YunxiNeural | ||
| 25 | + | ||
| 26 | +set -e | ||
| 27 | + | ||
| 28 | +TEXT="${1:?Usage: $0 \"<text>\" [voice] [record_x] [record_y]}" | ||
| 29 | +VOICE="${2:-zh-CN-YunxiNeural}" | ||
| 30 | +# Record-button center (device-specific). Defaults are for Pixel 10 Pro Fold cover | ||
| 31 | +# screen 1080x2364 with WeChat's "按住 说话" bar. Override via args if your layout differs. | ||
| 32 | +REC_X="${3:-466}" | ||
| 33 | +REC_Y="${4:-2247}" | ||
| 34 | + | ||
| 35 | +ADB="${ADB:-/opt/homebrew/share/android-commandlinetools/platform-tools/adb}" | ||
| 36 | +SILK_WASM="${SILK_WASM:-/opt/homebrew/lib/node_modules/openclaw/node_modules/silk-wasm}" | ||
| 37 | +WXUID="${WXUID:-u0_a336}" | ||
| 38 | +MM_CACHE="/data/data/com.tencent.mm/cache/hook_voice_src.amr" | ||
| 39 | +TMPDIR="$(mktemp -d /tmp/ttsswap_XXXXXX)" | ||
| 40 | +trap "rm -rf $TMPDIR" EXIT | ||
| 41 | + | ||
| 42 | +echo "[1/5] TTS: \"$TEXT\" (voice=$VOICE)" | ||
| 43 | +python3 -c " | ||
| 44 | +import asyncio, edge_tts | ||
| 45 | +asyncio.run(edge_tts.Communicate('''$TEXT''', '$VOICE').save('$TMPDIR/out.mp3')) | ||
| 46 | +" | ||
| 47 | + | ||
| 48 | +echo "[2/5] MP3 → PCM 16k mono → SILK V3" | ||
| 49 | +ffmpeg -y -i "$TMPDIR/out.mp3" -ar 16000 -ac 1 -f s16le "$TMPDIR/out.pcm" -loglevel error | ||
| 50 | +DUR=$(node -e " | ||
| 51 | +const silk=require('$SILK_WASM'), fs=require('fs'); | ||
| 52 | +silk.encode(fs.readFileSync('$TMPDIR/out.pcm'),16000).then(r=>{fs.writeFileSync('$TMPDIR/out.silk',r.data);process.stdout.write(String(r.duration));}); | ||
| 53 | +") | ||
| 54 | +echo " SILK $(wc -c < $TMPDIR/out.silk | tr -d ' ') bytes, duration=${DUR}ms" | ||
| 55 | + | ||
| 56 | +echo "[3/5] Push SILK to WeChat cache" | ||
| 57 | +$ADB push "$TMPDIR/out.silk" /sdcard/hook_voice_tmp.silk -q | ||
| 58 | +$ADB shell "su -c 'cp /sdcard/hook_voice_tmp.silk $MM_CACHE && chmod 644 $MM_CACHE && chown $WXUID:$WXUID $MM_CACHE && rm /sdcard/hook_voice_tmp.silk'" | ||
| 59 | + | ||
| 60 | +echo "[4/5] Arm swap (duration=$DUR)" | ||
| 61 | +$ADB shell "su -c 'am broadcast -a com.hermes.wechat.ARM_VOICE_SWAP --es src_path $MM_CACHE --ei duration $DUR'" >/dev/null | ||
| 62 | + | ||
| 63 | +# Hold the record button for TTS duration + 700ms so WeChat records long enough. | ||
| 64 | +HOLD=$(( DUR + 700 )) | ||
| 65 | +echo "[5/5] Hold '按住 说话' at ($REC_X,$REC_Y) for ${HOLD}ms → real recording → swap fires" | ||
| 66 | +$ADB shell input swipe "$REC_X" "$REC_Y" "$REC_X" "$REC_Y" "$HOLD" | ||
| 67 | + | ||
| 68 | +sleep 2 | ||
| 69 | +LOG=$($ADB shell "su -c 'ls -t /data/adb/lspd/log/*.log | head -1'" | tr -d '\r') | ||
| 70 | +if $ADB shell "su -c 'grep -a \"VOICE SWAP DONE\" \"$LOG\"'" | tr -d '\r' | tail -1 | grep -q "VOICE SWAP DONE"; then | ||
| 71 | + echo "✓ Swap fired: $($ADB shell "su -c 'grep -a \"VOICE SWAP DONE\" \"$LOG\"'" | tr -d '\r' | tail -1 | sed 's/.*VOICE SWAP DONE/VOICE SWAP DONE/')" | ||
| 72 | + echo "✓ Voice sent. Recipient receives your TTS audio." | ||
| 73 | +else | ||
| 74 | + echo "✗ Swap did not fire — check: chat open? voice mode? record coords ($REC_X,$REC_Y) correct?" | ||
| 75 | + echo " (a real recording must reach jg0.x.w; verify the hold actually recorded a voice bubble)" | ||
| 76 | +fi |
xhs_lsposed_a16_fix/wechathook_src/WeChatHook.java
| @@ -21,7 +21,24 @@ public class WeChatHook implements IXposedHookLoadPackage { | @@ -21,7 +21,24 @@ public class WeChatHook implements IXposedHookLoadPackage { | ||
| 21 | private static final String ACTION_SEND = "com.hermes.wechat.SEND_MSG_INTERNAL"; | 21 | private static final String ACTION_SEND = "com.hermes.wechat.SEND_MSG_INTERNAL"; |
| 22 | private static final String ACTION_SEND_IMAGE = "com.hermes.wechat.SEND_IMAGE_INTERNAL"; | 22 | private static final String ACTION_SEND_IMAGE = "com.hermes.wechat.SEND_IMAGE_INTERNAL"; |
| 23 | private static final String ACTION_SEND_VOICE = "com.hermes.wechat.SEND_VOICE_INTERNAL"; | 23 | private static final String ACTION_SEND_VOICE = "com.hermes.wechat.SEND_VOICE_INTERNAL"; |
| 24 | + private static final String ACTION_ARM_SWAP = "com.hermes.wechat.ARM_VOICE_SWAP"; | ||
| 24 | private static final String ACTION_DUMP_API = "com.hermes.wechat.DUMP_WECHAT_API"; | 25 | private static final String ACTION_DUMP_API = "com.hermes.wechat.DUMP_WECHAT_API"; |
| 26 | + | ||
| 27 | + // ── Voice hot-swap state (the route that actually delivers) ──────────────── | ||
| 28 | + // Fully-synthetic voice (SEND_VOICE_INTERNAL) can build the message shell but | ||
| 29 | + // can never upload the SILK to WeChat's CDN (upload is locked inside jg0.x's | ||
| 30 | + // Kotlin coroutine). So instead we let the user trigger a REAL recording (hold | ||
| 31 | + // "按住说话") and intercept jg0.x.w — the CDN uploadAttach step — replacing the | ||
| 32 | + // recorded SILK bytes (w21.t.a) with our TTS SILK. WeChat's own machinery then | ||
| 33 | + // uploads + delivers our audio. Armed by ARM_VOICE_SWAP; consumed once. | ||
| 34 | + private static volatile boolean sSwapArmed = false; | ||
| 35 | + private static volatile byte[] sSwapSilk = null; // TTS SILK bytes to inject | ||
| 36 | + private static volatile int sSwapDuration = 0; // ms, replaces the recorded duration | ||
| 37 | + // Safety: an arm auto-expires at this wall-clock time (set to arm + audio duration | ||
| 38 | + // + 10s slack). The swap fires only on release AFTER a full hold, so the window must | ||
| 39 | + // outlast the hold; past it, a stale arm silently dies instead of hijacking a later | ||
| 40 | + // unrelated recording. | ||
| 41 | + private static volatile long sSwapExpiresAt = 0; | ||
| 25 | // Cached f9 setter map (column -> methodName), built once on first voice send | 42 | // Cached f9 setter map (column -> methodName), built once on first voice send |
| 26 | // (sF9SetterMap is declared alongside probeAllF9Setters below) | 43 | // (sF9SetterMap is declared alongside probeAllF9Setters below) |
| 27 | // Thread-locals for voice send: voiceId and SILK bytes to inject into f9 ctor | 44 | // Thread-locals for voice send: voiceId and SILK bytes to inject into f9 ctor |
| @@ -35,6 +52,13 @@ public class WeChatHook implements IXposedHookLoadPackage { | @@ -35,6 +52,13 @@ public class WeChatHook implements IXposedHookLoadPackage { | ||
| 35 | private static volatile boolean sFsGReady = false; | 52 | private static volatile boolean sFsGReady = false; |
| 36 | // Set true while WE are calling newInstance so r0Hook can distinguish us from WeChat | 53 | // Set true while WE are calling newInstance so r0Hook can distinguish us from WeChat |
| 37 | private static volatile boolean sCalledByUs = false; | 54 | private static volatile boolean sCalledByUs = false; |
| 55 | + // Network dispatcher captured from a real WeChat r0 at H()-entry; needed by CDN upload | ||
| 56 | + private static volatile Object sCapturedDispatcher = null; | ||
| 57 | + private static volatile Object sPt0E0Instance = null; // pt0.e0 singleton, captured from r() hook | ||
| 58 | + private static volatile Object sImgPlugin = null; // b80.u instance, captured from pj/Bi hook | ||
| 59 | + private static volatile Object sRealImgParams = null; // last real o70.h, captured for replication | ||
| 60 | + private static volatile Object sRealImgK = null; // last real o70.k (oj insert object) | ||
| 61 | + private static final ThreadLocal<Long> sPendingMsgLocalId = new ThreadLocal<>(); // msgId from DB insert | ||
| 38 | // Logged-in user's wxid, auto-discovered from first real voice message send | 62 | // Logged-in user's wxid, auto-discovered from first real voice message send |
| 39 | private static volatile String sMyWxid = null; | 63 | private static volatile String sMyWxid = null; |
| 40 | // Pending send: stored when broadcast arrives before gm0.j1 is initialized | 64 | // Pending send: stored when broadcast arrives before gm0.j1 is initialized |
| @@ -53,13 +77,16 @@ public class WeChatHook implements IXposedHookLoadPackage { | @@ -53,13 +77,16 @@ public class WeChatHook implements IXposedHookLoadPackage { | ||
| 53 | ClassLoader cl = lpparam.classLoader; | 77 | ClassLoader cl = lpparam.classLoader; |
| 54 | sWeChatCL = cl; | 78 | sWeChatCL = cl; |
| 55 | 79 | ||
| 56 | - // Tablet login hooks (main process + any process) | ||
| 57 | - hookInTabletEnv(cl); | ||
| 58 | - hookProbe(cl); | ||
| 59 | - hookLoginHistoryUIInitView(cl); | ||
| 60 | - hookLoginUIInitView(cl); | ||
| 61 | - hookLoginSelectUICheck(cl); | ||
| 62 | - hookLoginButtonClick(cl); | 80 | + // Tablet login hooks — DISABLED. User switched to normal direct (primary) |
| 81 | + // login. The bk.A/B tablet-spoof would make WeChat think it's a tablet | ||
| 82 | + // mid-session and can re-trigger the beta "体验资格" gate on this test build, | ||
| 83 | + // kicking the login. Keep them off so restarts don't disturb direct login. | ||
| 84 | + // hookInTabletEnv(cl); | ||
| 85 | + // hookProbe(cl); | ||
| 86 | + // hookLoginHistoryUIInitView(cl); | ||
| 87 | + // hookLoginUIInitView(cl); | ||
| 88 | + // hookLoginSelectUICheck(cl); | ||
| 89 | + // hookLoginButtonClick(cl); | ||
| 63 | 90 | ||
| 64 | // WCDB diagnostic: which process writes? + media ContentValues dump | 91 | // WCDB diagnostic: which process writes? + media ContentValues dump |
| 65 | hookWCDB(cl, lpparam.processName); | 92 | hookWCDB(cl, lpparam.processName); |
| @@ -81,6 +108,40 @@ public class WeChatHook implements IXposedHookLoadPackage { | @@ -81,6 +108,40 @@ public class WeChatHook implements IXposedHookLoadPackage { | ||
| 81 | // The broadcast receiver registered in each process will all receive the intent; | 108 | // The broadcast receiver registered in each process will all receive the intent; |
| 82 | // whichever process has gm0.j1 initialized will succeed. | 109 | // whichever process has gm0.j1 initialized will succeed. |
| 83 | hookSendMessage(cl, lpparam.processName); | 110 | hookSendMessage(cl, lpparam.processName); |
| 111 | + | ||
| 112 | + // Hook w11.r0.H(String) to trace CDN upload: log r0 String fields + file access. | ||
| 113 | + hookR0H(cl, lpparam.processName); | ||
| 114 | + | ||
| 115 | + // Capture the live network dispatcher com.tencent.mm.modelbase.m2 (MicroMsg.RDispatcher) | ||
| 116 | + // at construction. Synthetic voice sends need the REAL m2 to actually reach the | ||
| 117 | + // network; without it we'd fall back to a fake $Proxy that never sends. | ||
| 118 | + hookDispatcher(cl, lpparam.processName); | ||
| 119 | + | ||
| 120 | + // Instrument the voice-upload service w21.g1 (voiceinfo table): on a REAL voice | ||
| 121 | + // send, dump the exact w21.w0 upload-task fields so we can replicate them for | ||
| 122 | + // synthetic voice (the last missing step = CDN audio upload). | ||
| 123 | + hookVoiceUpload(cl, lpparam.processName); | ||
| 124 | + | ||
| 125 | + // Image send: hook the non-suspend plugin methods b80.u.pj/Bi (o70.h) — the | ||
| 126 | + // actual image-send entry the coroutine calls. Capture the b80.u instance + | ||
| 127 | + // dump the full o70.h a REAL send builds, so we can replicate it directly. | ||
| 128 | + hookImageSend(cl, lpparam.processName); | ||
| 129 | + // Capture the SendImgProxyUI launch Intent (real gallery send goes through it), | ||
| 130 | + // so we can replay the whole real send flow via `am start`. | ||
| 131 | + hookSendImgProxyUI(cl, lpparam.processName); | ||
| 132 | + | ||
| 133 | + // Hook pt0.e0 — the voice-send coroutine class found in the stack trace. | ||
| 134 | + // Real voice sends don't use r0.H(); we need to find where CDN upload actually fires. | ||
| 135 | + hookPt0E0(cl, lpparam.processName); | ||
| 136 | + | ||
| 137 | + // Surgical voice-send entry probe (jg0.x + qi3.f0/g0 only). On this fresh | ||
| 138 | + // reinstall the send runs in the base CL (no Tinker patch yet), so hook here. | ||
| 139 | + hookVoiceProbe(cl, lpparam.processName); | ||
| 140 | + | ||
| 141 | + // Voice hot-swap: intercept jg0.x.w (CDN uploadAttach) to replace the recorded | ||
| 142 | + // SILK with our TTS audio during a real (user- or A11y-triggered) recording. | ||
| 143 | + // This is the ONE route that actually delivers synthetic voice. | ||
| 144 | + hookVoiceSwap(cl, lpparam.processName); | ||
| 84 | } | 145 | } |
| 85 | 146 | ||
| 86 | // ── WCDB hook ────────────────────────────────────────────────────────────── | 147 | // ── WCDB hook ────────────────────────────────────────────────────────────── |
| @@ -186,7 +247,18 @@ public class WeChatHook implements IXposedHookLoadPackage { | @@ -186,7 +247,18 @@ public class WeChatHook implements IXposedHookLoadPackage { | ||
| 186 | if (rawType == null) return; | 247 | if (rawType == null) return; |
| 187 | int msgtype = -1; | 248 | int msgtype = -1; |
| 188 | try { msgtype = Integer.parseInt(rawType.toString()); } catch (Throwable e) {} | 249 | try { msgtype = Integer.parseInt(rawType.toString()); } catch (Throwable e) {} |
| 250 | + if (msgtype == 3 && !sCalledByUs) { | ||
| 251 | + // Dump full call stack once for a real IMAGE send so we can find | ||
| 252 | + // the top-level app entry point to replicate for API image send. | ||
| 253 | + XposedBridge.log("[WeChatHook] convertTo IMAGE STACK (real send):\n" | ||
| 254 | + + android.util.Log.getStackTraceString(new Exception())); | ||
| 255 | + } | ||
| 189 | if (msgtype == 34) { | 256 | if (msgtype == 34) { |
| 257 | + // Dump call stack once for real sends to find the CDN upload caller. | ||
| 258 | + if (!sCalledByUs) { | ||
| 259 | + XposedBridge.log("[WeChatHook] convertTo STACK (real send):\n" | ||
| 260 | + + android.util.Log.getStackTraceString(new Exception())); | ||
| 261 | + } | ||
| 190 | // Auto-discover logged-in user's wxid from real UI voice sends. | 262 | // Auto-discover logged-in user's wxid from real UI voice sends. |
| 191 | // We look for isSend=1 messages (user's own sends) and extract | 263 | // We look for isSend=1 messages (user's own sends) and extract |
| 192 | // fromusername from the voicemsg XML, cache as sMyWxid. | 264 | // fromusername from the voicemsg XML, cache as sMyWxid. |
| @@ -224,22 +296,23 @@ public class WeChatHook implements IXposedHookLoadPackage { | @@ -224,22 +296,23 @@ public class WeChatHook implements IXposedHookLoadPackage { | ||
| 224 | // This is the correct object for r0.i: it has content + msgId set. | 296 | // This is the correct object for r0.i: it has content + msgId set. |
| 225 | if (pendingLv != null) { | 297 | if (pendingLv != null) { |
| 226 | sDatabaseF9.set(p.thisObject); | 298 | sDatabaseF9.set(p.thisObject); |
| 227 | - // Force status=4 (delivered) so no spinner appears. | ||
| 228 | - // Real WeChat gets status=4 via CDN upload→server ACK push, which | ||
| 229 | - // we skip. Directly setting it here is safe for filehelper sends. | ||
| 230 | - cv.put("status", 4); | 299 | + // Capture msgLocalId for pt0.e0.s() CDN trigger |
| 300 | + try { | ||
| 301 | + Object msgIdObj = cv.get("msgId"); | ||
| 302 | + if (msgIdObj != null) { | ||
| 303 | + long mid = Long.parseLong(msgIdObj.toString()); | ||
| 304 | + sPendingMsgLocalId.set(mid); | ||
| 305 | + XposedBridge.log("[WeChatHook] convertTo: captured msgLocalId=" + mid); | ||
| 306 | + } | ||
| 307 | + } catch (Throwable ignored) {} | ||
| 308 | + // Do NOT force status=4 here. | ||
| 309 | + // Forcing status=4 before H() causes H() to skip CDN upload | ||
| 310 | + // (WeChat checks status and exits early if already "delivered"). | ||
| 311 | + // Let H() set status=4 naturally after successful CDN upload. | ||
| 231 | // Ensure flag and msgSvrId are present (missing from our sends). | 312 | // Ensure flag and msgSvrId are present (missing from our sends). |
| 232 | if (cv.get("flag") == null) cv.put("flag", 0); | 313 | if (cv.get("flag") == null) cv.put("flag", 0); |
| 233 | if (cv.get("msgSvrId") == null) cv.put("msgSvrId", 0L); | 314 | if (cv.get("msgSvrId") == null) cv.put("msgSvrId", 0L); |
| 234 | - // Also set the in-memory f9 status field via setter (r1). | ||
| 235 | - // Without this, K(0)'s subsequent convertTo() reads the stale | ||
| 236 | - // in-memory status=1 and overwrites our status=4 back to 1. | ||
| 237 | - if (sF9SetterMap != null && sF9SetterMap.containsKey("status")) { | ||
| 238 | - try { | ||
| 239 | - XposedHelpers.callMethod(p.thisObject, sF9SetterMap.get("status"), 4); | ||
| 240 | - } catch (Throwable ignored) {} | ||
| 241 | - } | ||
| 242 | - XposedBridge.log("[WeChatHook] convertTo: forced status=4 (CV+field) for voice hook send"); | 315 | + XposedBridge.log("[WeChatHook] convertTo: initial INSERT (status not forced, H() will set it)"); |
| 243 | } | 316 | } |
| 244 | // Log full ContentValues including lvbuffer details | 317 | // Log full ContentValues including lvbuffer details |
| 245 | StringBuilder sb = new StringBuilder(); | 318 | StringBuilder sb = new StringBuilder(); |
| @@ -311,34 +384,85 @@ public class WeChatHook implements IXposedHookLoadPackage { | @@ -311,34 +384,85 @@ public class WeChatHook implements IXposedHookLoadPackage { | ||
| 311 | } | 384 | } |
| 312 | 385 | ||
| 313 | // ── Image send via internal API ──────────────────────────────────────────── | 386 | // ── Image send via internal API ──────────────────────────────────────────── |
| 314 | - // Calls w11.s1.b(talker, p1.IMAGE, 0, localImagePath) → w11.r1 → r1.b(). | ||
| 315 | - // WeChat handles thumbnail generation and CDN upload internally. | 387 | + // Calls the gallery's own static image-send helper: |
| 388 | + // j33.d0.k(long imgId, String path, String talker, boolean, int, boolean, | ||
| 389 | + // GalleryItem$MediaItem) | ||
| 390 | + // This is exactly what the album picker calls (traced via a real image send's | ||
| 391 | + // convertTo stack → b80.u.oj → b80.s coroutine → built from o70.h; d0.k is the | ||
| 392 | + // top-level builder). It builds o70.h (PreBuildParams) and launches the send | ||
| 393 | + // coroutine internally (thumbnail gen + CDN upload + DB insert), fire-and-forget. | ||
| 394 | + // The old w11.s1.b(IMAGE) path built an incomplete r1 → n1.a() NPE; abandoned. | ||
| 316 | private static void sendImageViaAPI(String talker, String imagePath) throws Throwable { | 395 | private static void sendImageViaAPI(String talker, String imagePath) throws Throwable { |
| 317 | ClassLoader cl = sTinkerCL != null ? sTinkerCL : sWeChatCL; | 396 | ClassLoader cl = sTinkerCL != null ? sTinkerCL : sWeChatCL; |
| 318 | if (cl == null) throw new IllegalStateException("classloader null"); | 397 | if (cl == null) throw new IllegalStateException("classloader null"); |
| 398 | + // Replicate the REAL image send captured by hookImageSend: build the exact | ||
| 399 | + // o70.h (PreBuildParams) that b80.u.pj() receives on a genuine send, then call | ||
| 400 | + // b80.u.pj(o70.h) directly (non-suspend), skipping the whole gallery/coroutine UI. | ||
| 401 | + // | ||
| 402 | + // Captured real o70.h (fields are single letters a–l): | ||
| 403 | + // a=r6 preBuildFolder, e=imgPath, g=toUserName(talker), c=source(0), d=0, | ||
| 404 | + // f=imgId(0 ok), h=compressType(0), k=Uri(null for pj), b=UUID(auto) | ||
| 405 | + // | ||
| 406 | + // r6 folder = lp0.b.h0(gm0.j1.b().j() + "_MsgImgUploadFSC") (= g80.o.c7()). | ||
| 407 | + // plugin b80.u = i95.n0.c(o70.e.class) (fallback to hook-captured instance). | ||
| 408 | + // b80.u image plugin = i95.n0.c(o70.e.class) (fallback to hook-captured instance). | ||
| 409 | + Object imgPlugin = sImgPlugin; | ||
| 410 | + if (imgPlugin == null) { | ||
| 411 | + Class<?> n0 = XposedHelpers.findClass("i95.n0", cl); | ||
| 412 | + Class<?> o70e = XposedHelpers.findClass("o70.e", cl); | ||
| 413 | + imgPlugin = XposedHelpers.callStaticMethod(n0, "c", o70e); | ||
| 414 | + } | ||
| 415 | + if (imgPlugin == null) throw new IllegalStateException("b80.u image plugin null"); | ||
| 416 | + | ||
| 417 | + // r6 pre-build folder = lp0.b.h0(gm0.j1.b().j() + "_MsgImgUploadFSC") (= g80.o.c7()). | ||
| 418 | + Object m = XposedHelpers.callStaticMethod(XposedHelpers.findClass("gm0.j1", cl), "b"); | ||
| 419 | + Object prefix = XposedHelpers.callMethod(m, "j"); | ||
| 420 | + Class<?> lp0b = XposedHelpers.findClass("lp0.b", cl); | ||
| 421 | + Object r6 = XposedHelpers.callStaticMethod(lp0b, "h0", prefix + "_MsgImgUploadFSC"); | ||
| 422 | + | ||
| 423 | + // Build o70.k (the batch send object) — fields captured from a real oj(o70.k): | ||
| 424 | + // a=r6, b=UUID(auto), c=talker, d=source, f=compressType, g=List<o70.i> | ||
| 425 | + Class<?> o70k = XposedHelpers.findClass("o70.k", cl); | ||
| 426 | + Object k = XposedHelpers.newInstance(o70k, r6); | ||
| 427 | + XposedHelpers.setObjectField(k, "c", talker); // toUserName | ||
| 428 | + // source (field d) — real send used 3; set via reflection (int field). | ||
| 429 | + try { java.lang.reflect.Field df = o70k.getDeclaredField("d"); df.setAccessible(true); df.setInt(k, 3); } catch (Throwable ignored) {} | ||
| 430 | + // o70.i(imgPath, Uri, Long imgId) — give it a file:// Uri so the CDN upload has | ||
| 431 | + // a readable source (real sends pass a MediaStore content:// Uri). | ||
| 432 | + Class<?> o70i = XposedHelpers.findClass("o70.i", cl); | ||
| 433 | + android.net.Uri fileUri = android.net.Uri.fromFile(new java.io.File(imagePath)); | ||
| 434 | + Object item = XposedHelpers.newInstance(o70i, | ||
| 435 | + new Class<?>[]{String.class, android.net.Uri.class, Long.class}, | ||
| 436 | + imagePath, fileUri, Long.valueOf(0L)); | ||
| 437 | + java.util.List<Object> list = new java.util.ArrayList<>(); | ||
| 438 | + list.add(item); | ||
| 439 | + XposedHelpers.setObjectField(k, "g", list); | ||
| 440 | + String clientId = (String) XposedHelpers.getObjectField(k, "b"); | ||
| 441 | + | ||
| 442 | + // Replicate b80.s coroutine's per-item prebuild loop: build o70.h from k+item, | ||
| 443 | + // then pj (stage compressed image + thumbnail into r6). | ||
| 444 | + Class<?> o70h = XposedHelpers.findClass("o70.h", cl); | ||
| 445 | + Object h = XposedHelpers.newInstance(o70h, r6); | ||
| 446 | + XposedHelpers.callMethod(h, "b", clientId); // clientId (field b) | ||
| 447 | + XposedHelpers.setObjectField(h, "e", imagePath); // imgPath | ||
| 448 | + XposedHelpers.setObjectField(h, "g", talker); // toUserName | ||
| 449 | + XposedHelpers.setObjectField(h, "k", fileUri); // Uri (source for staging/upload) | ||
| 450 | + try { java.lang.reflect.Field hc = o70h.getDeclaredField("c"); hc.setAccessible(true); hc.setInt(h, 3); } catch (Throwable ignored) {} // source | ||
| 451 | + // d(rotate)/h(compressType) default 0. f(imgId) 0. | ||
| 452 | + XposedBridge.log("[WeChatHook] sendImageViaAPI: built o70.k+o70.h e=" + imagePath + " g=" + talker | ||
| 453 | + + " plugin=" + imgPlugin.getClass().getName()); | ||
| 319 | 454 | ||
| 320 | - Class<?> s1Cls = XposedHelpers.findClass("w11.s1", cl); | ||
| 321 | - Class<?> p1Cls = XposedHelpers.findClass("w11.p1", cl); | ||
| 322 | - | ||
| 323 | - // p1.IMAGE is ordinal 1 (confirmed from enum dump: TEXT=0, IMAGE=1) | ||
| 324 | - Object[] p1Values = (Object[]) p1Cls.getMethod("values").invoke(null); | ||
| 325 | - if (p1Values.length < 2) throw new IllegalStateException("p1 enum too short: " + p1Values.length); | ||
| 326 | - Object imageEnum = p1Values[1]; | ||
| 327 | - | ||
| 328 | - XposedBridge.log("[WeChatHook] sendImageViaAPI: s1.b talker=" + talker | ||
| 329 | - + " p1=" + imageEnum + "(" + imageEnum.getClass().getSimpleName() + ")" | ||
| 330 | - + " path=" + imagePath); | ||
| 331 | - | ||
| 332 | - // XposedHelpers.callStaticMethod has no explicit-paramTypes overload; just pass args | ||
| 333 | - // and let findMethodBestMatch resolve b(String, w11.p1, int, Object). | ||
| 334 | - Object r1 = XposedHelpers.callStaticMethod(s1Cls, "b", talker, imageEnum, 0, imagePath); | ||
| 335 | - | ||
| 336 | - if (r1 == null) throw new IllegalStateException("w11.s1.b() returned null"); | ||
| 337 | - | ||
| 338 | - dumpBuilderMethods(r1, "s1.b[image]"); | ||
| 339 | - XposedBridge.log("[WeChatHook] sendImageViaAPI: calling r1.b() on " + r1.getClass().getName()); | ||
| 340 | - XposedHelpers.callMethod(r1, "b"); | ||
| 341 | - XposedBridge.log("[WeChatHook] sendImageViaAPI: OK to=" + talker); | 455 | + sCalledByUs = true; |
| 456 | + try { | ||
| 457 | + if (!(Boolean) XposedHelpers.callMethod(imgPlugin, "Bi", h, "thumb.jpg")) { | ||
| 458 | + XposedHelpers.callMethod(imgPlugin, "pj", h); // prebuild (stage) | ||
| 459 | + try { XposedHelpers.callMethod(imgPlugin, "nj", h, 10000); } catch (Throwable ignored) {} | ||
| 460 | + } | ||
| 461 | + java.util.ArrayList<?> res = (java.util.ArrayList<?>) | ||
| 462 | + XposedHelpers.callMethod(imgPlugin, "oj", k); // insert + send | ||
| 463 | + XposedBridge.log("[WeChatHook] sendImageViaAPI: oj() DONE to=" + talker | ||
| 464 | + + " res=" + (res == null ? "null" : "size=" + res.size())); | ||
| 465 | + } finally { sCalledByUs = false; } | ||
| 342 | } | 466 | } |
| 343 | 467 | ||
| 344 | // ── Probe all f9 setter→column mappings (String + int, one-shot) ────────── | 468 | // ── Probe all f9 setter→column mappings (String + int, one-shot) ────────── |
| @@ -428,6 +552,906 @@ public class WeChatHook implements IXposedHookLoadPackage { | @@ -428,6 +552,906 @@ public class WeChatHook implements IXposedHookLoadPackage { | ||
| 428 | return result; | 552 | return result; |
| 429 | } | 553 | } |
| 430 | 554 | ||
| 555 | + // ── Hook w11.r0.H(String) — CDN upload entry-point trace ───────────────── | ||
| 556 | + // Logs every r0.H() call: dumps all String+File fields on r0 so we can identify | ||
| 557 | + // which field contains the audio file path that H() uploads to CDN. | ||
| 558 | + // Hook ALL methods on pt0.e0 (voice-send coroutine class found in stack trace). | ||
| 559 | + // Goal: find which method triggers CDN upload AFTER f9.convertTo() returns. | ||
| 560 | + // pt0.e0.k returns f9 objects and fires in multiple threads — CDN upload entry point. | ||
| 561 | + private void hookPt0E0(ClassLoader cl, String procName) { | ||
| 562 | + try { | ||
| 563 | + Class<?> cls = XposedHelpers.findClass("pt0.e0", cl); | ||
| 564 | + java.lang.reflect.Method[] methods = cls.getDeclaredMethods(); | ||
| 565 | + StringBuilder sig = new StringBuilder("[WeChatHook] pt0.e0 methods(" + methods.length + "):"); | ||
| 566 | + for (java.lang.reflect.Method m : methods) sig.append(" ").append(m.getName()).append("(").append(m.getParameterTypes().length).append(")"); | ||
| 567 | + XposedBridge.log(sig.toString()); | ||
| 568 | + for (java.lang.reflect.Method m : methods) { | ||
| 569 | + final String mName = m.getName(); | ||
| 570 | + m.setAccessible(true); | ||
| 571 | + XposedBridge.hookMethod(m, new XC_MethodHook() { | ||
| 572 | + @Override protected void beforeHookedMethod(MethodHookParam p) { | ||
| 573 | + // Capture singleton from ANY method (not just r()), so it's | ||
| 574 | + // available even when no real voice send has happened this session. | ||
| 575 | + if (sPt0E0Instance == null && p.thisObject != null) { | ||
| 576 | + sPt0E0Instance = p.thisObject; | ||
| 577 | + XposedBridge.log("[WeChatHook] pt0.e0 singleton captured via " + mName + ": " + p.thisObject); | ||
| 578 | + } | ||
| 579 | + if ("k".equals(mName)) { | ||
| 580 | + // k(2): confirmed signature k(String talker, Long msgLocalId) | ||
| 581 | + // CDN worker calls this to dequeue next upload task. | ||
| 582 | + // Only log first call per send to avoid spam. | ||
| 583 | + Object a0 = p.args != null && p.args.length > 0 ? p.args[0] : null; | ||
| 584 | + Object a1 = p.args != null && p.args.length > 1 ? p.args[1] : null; | ||
| 585 | + XposedBridge.log("[WeChatHook] pt0.e0.k ENTER talker=" + a0 | ||
| 586 | + + " msgLocalId=" + (a1 instanceof Long ? ((Long)a1).longValue() : a1)); | ||
| 587 | + } else if ("r".equals(mName)) { | ||
| 588 | + // Save pt0.e0 singleton for later use in sendVoiceViaAPI. | ||
| 589 | + if (sPt0E0Instance == null && p.thisObject != null) { | ||
| 590 | + sPt0E0Instance = p.thisObject; | ||
| 591 | + XposedBridge.log("[WeChatHook] pt0.e0 singleton captured: " + p.thisObject); | ||
| 592 | + } | ||
| 593 | + XposedBridge.log("[WeChatHook] pt0.e0.r ENTER, dumping thisObject fields:"); | ||
| 594 | + if (p.thisObject != null) { | ||
| 595 | + Class<?> c = p.thisObject.getClass(); | ||
| 596 | + while (c != null && !c.equals(Object.class)) { | ||
| 597 | + for (java.lang.reflect.Field f : c.getDeclaredFields()) { | ||
| 598 | + f.setAccessible(true); | ||
| 599 | + try { | ||
| 600 | + Object v = f.get(p.thisObject); | ||
| 601 | + XposedBridge.log("[WeChatHook] " + c.getSimpleName() | ||
| 602 | + + "." + f.getName() + "=" + (v == null ? "null" | ||
| 603 | + : (v instanceof String || v instanceof Number || v instanceof Boolean) | ||
| 604 | + ? v.toString() : v.getClass().getName() + "@" + System.identityHashCode(v))); | ||
| 605 | + } catch (Throwable ignored) {} | ||
| 606 | + } | ||
| 607 | + c = c.getSuperclass(); | ||
| 608 | + } | ||
| 609 | + } | ||
| 610 | + } else if ("s".equals(mName)) { | ||
| 611 | + // s(3): CDN upload trigger. Log args + full f9 hierarchy for diff analysis. | ||
| 612 | + StringBuilder sb = new StringBuilder("[WeChatHook] pt0.e0.s ENTER args:"); | ||
| 613 | + if (p.args != null) for (int i = 0; i < p.args.length; i++) { | ||
| 614 | + Object a = p.args[i]; | ||
| 615 | + if (a == null) { sb.append(" [").append(i).append("]=null"); } | ||
| 616 | + else if (a instanceof String) { sb.append(" [").append(i).append("]=String(\"").append(a).append("\")"); } | ||
| 617 | + else if (a instanceof Number) { sb.append(" [").append(i).append("]=").append(a); } | ||
| 618 | + else { sb.append(" [").append(i).append("]=").append(a.getClass().getName()).append("@").append(System.identityHashCode(a)); } | ||
| 619 | + } | ||
| 620 | + XposedBridge.log(sb.toString()); | ||
| 621 | + // Dump ALL fields of f9 (args[2]) across full parent hierarchy | ||
| 622 | + if (p.args != null && p.args.length > 2 && p.args[2] != null) { | ||
| 623 | + Object f9 = p.args[2]; | ||
| 624 | + StringBuilder fb = new StringBuilder("[WeChatHook] pt0.e0.s f9 DUMP:"); | ||
| 625 | + Class<?> c = f9.getClass(); | ||
| 626 | + while (c != null && !c.equals(Object.class)) { | ||
| 627 | + for (java.lang.reflect.Field f : c.getDeclaredFields()) { | ||
| 628 | + f.setAccessible(true); | ||
| 629 | + try { | ||
| 630 | + Object v = f.get(f9); | ||
| 631 | + String vs; | ||
| 632 | + if (v == null) vs = "null"; | ||
| 633 | + else if (v instanceof byte[]) vs = "byte[" + ((byte[])v).length + "]"; | ||
| 634 | + else if (v instanceof String || v instanceof Number || v instanceof Boolean) vs = v.toString(); | ||
| 635 | + else vs = v.getClass().getSimpleName() + "@" + System.identityHashCode(v); | ||
| 636 | + fb.append("\n ").append(c.getSimpleName()).append(".").append(f.getName()).append("=").append(vs); | ||
| 637 | + } catch (Throwable ignored) {} | ||
| 638 | + } | ||
| 639 | + c = c.getSuperclass(); | ||
| 640 | + } | ||
| 641 | + XposedBridge.log(fb.toString()); | ||
| 642 | + } | ||
| 643 | + } else if (p.args != null && p.args.length == 2 | ||
| 644 | + && ("a".equals(mName) || "b".equals(mName) || "l".equals(mName) | ||
| 645 | + || "n".equals(mName) || "q".equals(mName))) { | ||
| 646 | + // Log 2-arg entry methods — one of these is likely the | ||
| 647 | + // "enqueue voice for send" entry point called by WeChat UI | ||
| 648 | + StringBuilder sb2 = new StringBuilder( | ||
| 649 | + "[WeChatHook] pt0.e0." + mName + "(2) ENTER"); | ||
| 650 | + if (p.args != null) for (int i = 0; i < p.args.length; i++) { | ||
| 651 | + Object a = p.args[i]; | ||
| 652 | + if (a == null) sb2.append(" arg").append(i).append("=null"); | ||
| 653 | + else if (a instanceof String) sb2.append(" arg").append(i).append("=\"").append(a).append("\""); | ||
| 654 | + else if (a instanceof Number) sb2.append(" arg").append(i).append("=").append(a); | ||
| 655 | + else sb2.append(" arg").append(i).append("=").append(a.getClass().getName()).append("@").append(System.identityHashCode(a)); | ||
| 656 | + } | ||
| 657 | + XposedBridge.log(sb2.toString()); | ||
| 658 | + } else { | ||
| 659 | + XposedBridge.log("[WeChatHook] pt0.e0." + mName + " ENTER"); | ||
| 660 | + } | ||
| 661 | + } | ||
| 662 | + @Override protected void afterHookedMethod(MethodHookParam p) { | ||
| 663 | + if (p.hasThrowable()) { | ||
| 664 | + XposedBridge.log("[WeChatHook] pt0.e0." + mName + " THREW " + p.getThrowable()); | ||
| 665 | + } else if ("k".equals(mName)) { | ||
| 666 | + Object ret = p.getResult(); | ||
| 667 | + if (ret != null) { | ||
| 668 | + // Dump ALL declared fields on f9 (first call only, to learn field names) | ||
| 669 | + StringBuilder fb = new StringBuilder("[WeChatHook] pt0.e0.k OK f9=").append(ret.getClass().getSimpleName()); | ||
| 670 | + java.lang.reflect.Field[] fields = ret.getClass().getDeclaredFields(); | ||
| 671 | + for (java.lang.reflect.Field f : fields) { | ||
| 672 | + f.setAccessible(true); | ||
| 673 | + try { | ||
| 674 | + Object v = f.get(ret); | ||
| 675 | + fb.append(" ").append(f.getName()).append("=").append( | ||
| 676 | + v == null ? "null" : (v instanceof String || v instanceof Number || v instanceof Boolean) | ||
| 677 | + ? v.toString() : v.getClass().getSimpleName() + "@" + System.identityHashCode(v)); | ||
| 678 | + } catch (Throwable ignored) {} | ||
| 679 | + } | ||
| 680 | + XposedBridge.log(fb.toString()); | ||
| 681 | + } else { | ||
| 682 | + XposedBridge.log("[WeChatHook] pt0.e0.k OK ret=null"); | ||
| 683 | + } | ||
| 684 | + } else { | ||
| 685 | + XposedBridge.log("[WeChatHook] pt0.e0." + mName + " OK ret=" + p.getResult()); | ||
| 686 | + } | ||
| 687 | + } | ||
| 688 | + }); | ||
| 689 | + } | ||
| 690 | + XposedBridge.log("[WeChatHook] hookPt0E0: hooked " + methods.length + " methods in " + procName); | ||
| 691 | + } catch (Throwable t) { | ||
| 692 | + XposedBridge.log("[WeChatHook] hookPt0E0 failed in " + procName + ": " + t); | ||
| 693 | + } | ||
| 694 | + } | ||
| 695 | + | ||
| 696 | + // ── Surgical voice-send entry probe ────────────────────────────────────── | ||
| 697 | + // SAFE version: hooks ONLY the voice-send-specific classes (jg0.x coordinator | ||
| 698 | + // + qi3.f0/g0 voice-message objects), NOT generic coroutine classes, so it | ||
| 699 | + // won't add anti-tamper risk to the live Pad session. Goal: find the entry | ||
| 700 | + // method that receives (talker, silk file/bytes, durationMs) in plain form, | ||
| 701 | + // which we can then call synthetically to get a real, delivered voice send. | ||
| 702 | + private static String fmtArg(Object a) { | ||
| 703 | + if (a == null) return "null"; | ||
| 704 | + if (a instanceof byte[]) return "byte[" + ((byte[]) a).length + "]"; | ||
| 705 | + if (a instanceof String) return "\"" + a + "\""; | ||
| 706 | + if (a instanceof Number || a instanceof Boolean) return a.toString(); | ||
| 707 | + return a.getClass().getName() + "@" + System.identityHashCode(a); | ||
| 708 | + } | ||
| 709 | + | ||
| 710 | + // Recursively dump an object's non-static fields, bounded by depth, to find | ||
| 711 | + // where scalar values (talker string / silk path / duration) are captured. | ||
| 712 | + private static void dumpDeep(Object o, String prefix, int depth, StringBuilder sb) { | ||
| 713 | + if (o == null || depth < 0) return; | ||
| 714 | + for (Class<?> c = o.getClass(); c != null && !c.equals(Object.class); c = c.getSuperclass()) { | ||
| 715 | + for (java.lang.reflect.Field f : c.getDeclaredFields()) { | ||
| 716 | + if (java.lang.reflect.Modifier.isStatic(f.getModifiers())) continue; | ||
| 717 | + f.setAccessible(true); | ||
| 718 | + Object v; | ||
| 719 | + try { v = f.get(o); } catch (Throwable t) { continue; } | ||
| 720 | + sb.append("\n").append(prefix).append(f.getName()).append("=").append(fmtArg(v)); | ||
| 721 | + // Recurse into non-scalar, non-array WeChat/kotlin objects. | ||
| 722 | + if (v != null && depth > 0 | ||
| 723 | + && !(v instanceof String) && !(v instanceof Number) | ||
| 724 | + && !(v instanceof Boolean) && !(v instanceof byte[]) | ||
| 725 | + && !v.getClass().isArray() | ||
| 726 | + && !v.getClass().getName().startsWith("java.") | ||
| 727 | + && !v.getClass().getName().startsWith("android.")) { | ||
| 728 | + dumpDeep(v, prefix + " ", depth - 1, sb); | ||
| 729 | + } | ||
| 730 | + } | ||
| 731 | + } | ||
| 732 | + } | ||
| 733 | + | ||
| 734 | + private void hookVoiceProbe(ClassLoader cl, String procName) { | ||
| 735 | + // jg0.x — voice send coordinator; hook ALL methods to find the entry. | ||
| 736 | + try { | ||
| 737 | + Class<?> xClass = XposedHelpers.findClass("jg0.x", cl); | ||
| 738 | + int n = 0; | ||
| 739 | + for (java.lang.reflect.Method m : xClass.getDeclaredMethods()) { | ||
| 740 | + final String mn = m.getName(); | ||
| 741 | + m.setAccessible(true); | ||
| 742 | + XposedBridge.hookMethod(m, new XC_MethodHook() { | ||
| 743 | + @Override protected void beforeHookedMethod(MethodHookParam p) { | ||
| 744 | + // ── Voice hot-swap (the route that actually delivers) ────────── | ||
| 745 | + // jg0.x.w(jg0.x, qi3.d0, w21.w0, int type, int durMs, w21.t silk, Cont) | ||
| 746 | + // is the CDN uploadAttach step. On the REAL (non-resume) call, arg5 | ||
| 747 | + // (w21.t) holds the recorded SILK in its single byte[] field. When | ||
| 748 | + // armed, replace it with our TTS SILK so WeChat uploads our audio. | ||
| 749 | + // (This lives in the probe hook because this fork won't stack a 2nd | ||
| 750 | + // hook on an already-hooked method — see hookVoiceSwap note.) | ||
| 751 | + if ("w".equals(mn) && sSwapArmed && sSwapSilk != null | ||
| 752 | + && System.currentTimeMillis() < sSwapExpiresAt | ||
| 753 | + && p.args != null && p.args.length == 7 && p.args[5] != null) { | ||
| 754 | + try { | ||
| 755 | + Object tHolder = p.args[5]; | ||
| 756 | + java.lang.reflect.Field byteF = null; | ||
| 757 | + for (java.lang.reflect.Field f : tHolder.getClass().getDeclaredFields()) | ||
| 758 | + if (f.getType() == byte[].class) { byteF = f; break; } | ||
| 759 | + if (byteF != null) { | ||
| 760 | + byteF.setAccessible(true); | ||
| 761 | + byte[] orig = (byte[]) byteF.get(tHolder); | ||
| 762 | + byteF.set(tHolder, sSwapSilk); | ||
| 763 | + if (orig != null) for (java.lang.reflect.Field f : tHolder.getClass().getDeclaredFields()) { | ||
| 764 | + if (f.getType() != int.class) continue; | ||
| 765 | + f.setAccessible(true); | ||
| 766 | + if (f.getInt(tHolder) == orig.length) f.setInt(tHolder, sSwapSilk.length); | ||
| 767 | + } | ||
| 768 | + if (sSwapDuration > 0) p.args[4] = sSwapDuration; | ||
| 769 | + XposedBridge.log("[WeChatHook] VOICE SWAP DONE: " | ||
| 770 | + + (orig == null ? "?" : orig.length) + "B -> " + sSwapSilk.length | ||
| 771 | + + "B dur -> " + p.args[4]); | ||
| 772 | + sSwapArmed = false; sSwapSilk = null; // one-shot | ||
| 773 | + } | ||
| 774 | + } catch (Throwable t) { | ||
| 775 | + XposedBridge.log("[WeChatHook] VOICE SWAP failed: " + t); | ||
| 776 | + } | ||
| 777 | + } | ||
| 778 | + StringBuilder sb = new StringBuilder("[WeChatHook] jg0.x." + mn + "("); | ||
| 779 | + if (p.args != null) for (int i = 0; i < p.args.length; i++) { | ||
| 780 | + if (i > 0) sb.append(", "); | ||
| 781 | + sb.append(fmtArg(p.args[i])); | ||
| 782 | + } | ||
| 783 | + sb.append(")"); | ||
| 784 | + // Deep-dump the send-carrying methods: D's lambda (arg0) and | ||
| 785 | + // w's descriptor args — to locate talker/silk/duration fields. | ||
| 786 | + if ("D".equals(mn) && p.args != null && p.args.length > 0) { | ||
| 787 | + sb.append("\n [D arg0 pu.m0 deep]:"); | ||
| 788 | + dumpDeep(p.args[0], " ", 3, sb); | ||
| 789 | + } else if ("w".equals(mn) && p.args != null) { | ||
| 790 | + for (int i = 0; i < p.args.length; i++) { | ||
| 791 | + if (p.args[i] != null && !(p.args[i] instanceof Number)) { | ||
| 792 | + sb.append("\n [w arg").append(i).append(" deep]:"); | ||
| 793 | + dumpDeep(p.args[i], " ", 2, sb); | ||
| 794 | + } | ||
| 795 | + } | ||
| 796 | + } | ||
| 797 | + XposedBridge.log(sb.toString()); | ||
| 798 | + } | ||
| 799 | + }); | ||
| 800 | + n++; | ||
| 801 | + } | ||
| 802 | + XposedBridge.log("[WeChatHook] hookVoiceProbe: jg0.x " + n + " methods in " + procName); | ||
| 803 | + } catch (Throwable t) { | ||
| 804 | + XposedBridge.log("[WeChatHook] hookVoiceProbe jg0.x failed: " + t); | ||
| 805 | + } | ||
| 806 | + // qi3.f0 / qi3.g0 — voice-message domain objects; dump ctor args + fields. | ||
| 807 | + for (String cn : new String[]{"qi3.f0", "qi3.g0"}) { | ||
| 808 | + try { | ||
| 809 | + Class<?> c = XposedHelpers.findClass(cn, cl); | ||
| 810 | + for (java.lang.reflect.Constructor<?> ctor : c.getDeclaredConstructors()) { | ||
| 811 | + ctor.setAccessible(true); | ||
| 812 | + final String fcn = cn; | ||
| 813 | + XposedBridge.hookMethod(ctor, new XC_MethodHook() { | ||
| 814 | + @Override protected void afterHookedMethod(MethodHookParam p) { | ||
| 815 | + StringBuilder sb = new StringBuilder("[WeChatHook] " + fcn + ".<init>("); | ||
| 816 | + if (p.args != null) for (int i = 0; i < p.args.length; i++) { | ||
| 817 | + if (i > 0) sb.append(", "); | ||
| 818 | + sb.append(fmtArg(p.args[i])); | ||
| 819 | + } | ||
| 820 | + sb.append(") fields:"); | ||
| 821 | + for (Class<?> cc = p.thisObject.getClass(); | ||
| 822 | + cc != null && !cc.equals(Object.class); cc = cc.getSuperclass()) { | ||
| 823 | + for (java.lang.reflect.Field f : cc.getDeclaredFields()) { | ||
| 824 | + if (java.lang.reflect.Modifier.isStatic(f.getModifiers())) continue; | ||
| 825 | + f.setAccessible(true); | ||
| 826 | + try { | ||
| 827 | + sb.append("\n ").append(f.getName()).append("=").append(fmtArg(f.get(p.thisObject))); | ||
| 828 | + } catch (Throwable ignored) {} | ||
| 829 | + } | ||
| 830 | + } | ||
| 831 | + XposedBridge.log(sb.toString()); | ||
| 832 | + } | ||
| 833 | + }); | ||
| 834 | + } | ||
| 835 | + XposedBridge.log("[WeChatHook] hookVoiceProbe: " + cn + " ctor hooked in " + procName); | ||
| 836 | + } catch (Throwable t) { | ||
| 837 | + XposedBridge.log("[WeChatHook] hookVoiceProbe " + cn + " failed: " + t); | ||
| 838 | + } | ||
| 839 | + } | ||
| 840 | + } | ||
| 841 | + | ||
| 842 | + // ── Find the voice-send entry point ────────────────────────────────────── | ||
| 843 | + // pu.o0 is the outer coroutine, launched by the real entry point (async, so | ||
| 844 | + // it doesn't appear in the jg0.x.D stack). Hooking pu.o0's CONSTRUCTOR gives | ||
| 845 | + // a synchronous stack trace revealing the method that starts a voice send. | ||
| 846 | + // Also dumps qi3.f0/g0 constructors (the voice-message domain objects that | ||
| 847 | + // carry talker/silk/duration). | ||
| 848 | + private void hookVoiceEntry(ClassLoader cl, String procName) { | ||
| 849 | + // pu.o0 constructor — stack trace = the real send entry point. | ||
| 850 | + for (String cn : new String[]{"pu.o0", "pu.m0"}) { | ||
| 851 | + try { | ||
| 852 | + Class<?> c = XposedHelpers.findClass(cn, cl); | ||
| 853 | + for (java.lang.reflect.Constructor<?> ctor : c.getDeclaredConstructors()) { | ||
| 854 | + ctor.setAccessible(true); | ||
| 855 | + final String fcn = cn; | ||
| 856 | + final int np = ctor.getParameterTypes().length; | ||
| 857 | + XposedBridge.hookMethod(ctor, new XC_MethodHook() { | ||
| 858 | + private volatile boolean logged = false; | ||
| 859 | + @Override protected void afterHookedMethod(MethodHookParam p) { | ||
| 860 | + if (logged) return; | ||
| 861 | + logged = true; | ||
| 862 | + StringBuilder sb = new StringBuilder( | ||
| 863 | + "[WeChatHook] " + fcn + ".<init>(" + np + ") args:"); | ||
| 864 | + if (p.args != null) for (int i = 0; i < p.args.length; i++) { | ||
| 865 | + Object a = p.args[i]; | ||
| 866 | + sb.append("\n arg[").append(i).append("]=") | ||
| 867 | + .append(a == null ? "null" : a.getClass().getName()); | ||
| 868 | + } | ||
| 869 | + sb.append("\n").append(android.util.Log.getStackTraceString(new Exception())); | ||
| 870 | + XposedBridge.log(sb.toString()); | ||
| 871 | + } | ||
| 872 | + }); | ||
| 873 | + } | ||
| 874 | + XposedBridge.log("[WeChatHook] hookVoiceEntry: " + cn + " ctor hooked in " + procName); | ||
| 875 | + } catch (Throwable t) { | ||
| 876 | + XposedBridge.log("[WeChatHook] hookVoiceEntry " + cn + " failed: " + t); | ||
| 877 | + } | ||
| 878 | + } | ||
| 879 | + // qi3.f0 / qi3.g0 constructors — dump their args (talker/silk/duration). | ||
| 880 | + for (String cn : new String[]{"qi3.f0", "qi3.g0"}) { | ||
| 881 | + try { | ||
| 882 | + Class<?> c = XposedHelpers.findClass(cn, cl); | ||
| 883 | + StringBuilder info = new StringBuilder("[WeChatHook] " + cn | ||
| 884 | + + " super=" + c.getSuperclass().getName() + " fields:"); | ||
| 885 | + for (java.lang.reflect.Field f : c.getDeclaredFields()) | ||
| 886 | + info.append(" ").append(f.getName()).append("(").append(f.getType().getSimpleName()).append(")"); | ||
| 887 | + XposedBridge.log(info.toString()); | ||
| 888 | + for (java.lang.reflect.Constructor<?> ctor : c.getDeclaredConstructors()) { | ||
| 889 | + ctor.setAccessible(true); | ||
| 890 | + final String fcn = cn; | ||
| 891 | + XposedBridge.hookMethod(ctor, new XC_MethodHook() { | ||
| 892 | + private volatile boolean logged = false; | ||
| 893 | + @Override protected void afterHookedMethod(MethodHookParam p) { | ||
| 894 | + if (logged) return; | ||
| 895 | + logged = true; | ||
| 896 | + StringBuilder sb = new StringBuilder( | ||
| 897 | + "[WeChatHook] " + fcn + ".<init> args:"); | ||
| 898 | + if (p.args != null) for (int i = 0; i < p.args.length; i++) { | ||
| 899 | + Object a = p.args[i]; | ||
| 900 | + String vs; | ||
| 901 | + if (a == null) vs = "null"; | ||
| 902 | + else if (a instanceof byte[]) vs = "byte[" + ((byte[])a).length + "]"; | ||
| 903 | + else if (a instanceof String || a instanceof Number || a instanceof Boolean) | ||
| 904 | + vs = a.getClass().getSimpleName() + "(" + a + ")"; | ||
| 905 | + else vs = a.getClass().getName(); | ||
| 906 | + sb.append("\n arg[").append(i).append("]=").append(vs); | ||
| 907 | + } | ||
| 908 | + // Dump the constructed object's fields too | ||
| 909 | + for (Class<?> cc = p.thisObject.getClass(); | ||
| 910 | + cc != null && !cc.equals(Object.class); cc = cc.getSuperclass()) { | ||
| 911 | + for (java.lang.reflect.Field f : cc.getDeclaredFields()) { | ||
| 912 | + if (java.lang.reflect.Modifier.isStatic(f.getModifiers())) continue; | ||
| 913 | + f.setAccessible(true); | ||
| 914 | + try { | ||
| 915 | + Object v = f.get(p.thisObject); | ||
| 916 | + String vs; | ||
| 917 | + if (v == null) vs = "null"; | ||
| 918 | + else if (v instanceof byte[]) vs = "byte[" + ((byte[])v).length + "]"; | ||
| 919 | + else if (v instanceof String || v instanceof Number || v instanceof Boolean) vs = v.toString(); | ||
| 920 | + else vs = v.getClass().getSimpleName(); | ||
| 921 | + sb.append("\n this.").append(f.getName()).append("=").append(vs); | ||
| 922 | + } catch (Throwable ignored) {} | ||
| 923 | + } | ||
| 924 | + } | ||
| 925 | + XposedBridge.log(sb.toString()); | ||
| 926 | + } | ||
| 927 | + }); | ||
| 928 | + } | ||
| 929 | + XposedBridge.log("[WeChatHook] hookVoiceEntry: " + cn + " ctor hooked in " + procName); | ||
| 930 | + } catch (Throwable t) { | ||
| 931 | + XposedBridge.log("[WeChatHook] hookVoiceEntry " + cn + " failed: " + t); | ||
| 932 | + } | ||
| 933 | + } | ||
| 934 | + } | ||
| 935 | + | ||
| 936 | + // ── Hook jg0.j / jg0.x / g9 ───────────────────────────────────────────── | ||
| 937 | + // Reveals the internal structure needed to call pt0.e0.r() synthetically. | ||
| 938 | + // jg0.j captures the g9 (voice message / f9 subclass) as a field. | ||
| 939 | + // jg0.x.D sets up jg0.j and starts the coroutine. | ||
| 940 | + // g9 extends f9 (or is f9) with M9/N9 DB-insert methods. | ||
| 941 | + private void hookJg0(ClassLoader cl, String procName) { | ||
| 942 | + // ── jg0.j (the inner coroutine whose invokeSuspend calls pt0.e0.r) ──── | ||
| 943 | + try { | ||
| 944 | + Class<?> jClass = XposedHelpers.findClass("jg0.j", cl); | ||
| 945 | + StringBuilder jInfo = new StringBuilder("[WeChatHook] jg0.j class=" + jClass.getName() | ||
| 946 | + + " super=" + jClass.getSuperclass().getName() + " ownFields:"); | ||
| 947 | + for (java.lang.reflect.Field f : jClass.getDeclaredFields()) | ||
| 948 | + jInfo.append(" ").append(f.getName()).append("(").append(f.getType().getSimpleName()).append(")"); | ||
| 949 | + jInfo.append(" | superFields:"); | ||
| 950 | + for (java.lang.reflect.Field f : jClass.getSuperclass().getDeclaredFields()) | ||
| 951 | + jInfo.append(" ").append(f.getName()).append("(").append(f.getType().getSimpleName()).append(")"); | ||
| 952 | + XposedBridge.log(jInfo.toString()); | ||
| 953 | + | ||
| 954 | + // Hook invokeSuspend — fires every time a jg0.j coroutine step runs. | ||
| 955 | + // We only care about the FIRST call (state=0, label=0) to see captured fields. | ||
| 956 | + try { | ||
| 957 | + XposedHelpers.findAndHookMethod(jClass, "invokeSuspend", Object.class, | ||
| 958 | + new XC_MethodHook() { | ||
| 959 | + private volatile boolean sLogged = false; | ||
| 960 | + @Override protected void beforeHookedMethod(MethodHookParam p) { | ||
| 961 | + if (sLogged) return; | ||
| 962 | + sLogged = true; | ||
| 963 | + StringBuilder sb = new StringBuilder( | ||
| 964 | + "[WeChatHook] jg0.j.invokeSuspend FIRST fields:"); | ||
| 965 | + for (Class<?> c = p.thisObject.getClass(); | ||
| 966 | + c != null && !c.equals(Object.class); c = c.getSuperclass()) { | ||
| 967 | + for (java.lang.reflect.Field f : c.getDeclaredFields()) { | ||
| 968 | + if (java.lang.reflect.Modifier.isStatic(f.getModifiers())) continue; | ||
| 969 | + f.setAccessible(true); | ||
| 970 | + try { | ||
| 971 | + Object v = f.get(p.thisObject); | ||
| 972 | + sb.append("\n ").append(c.getSimpleName()) | ||
| 973 | + .append(".").append(f.getName()).append("=") | ||
| 974 | + .append(v == null ? "null" | ||
| 975 | + : v.getClass().getName() + "@" + System.identityHashCode(v)); | ||
| 976 | + } catch (Throwable ignored) {} | ||
| 977 | + } | ||
| 978 | + } | ||
| 979 | + XposedBridge.log(sb.toString()); | ||
| 980 | + } | ||
| 981 | + }); | ||
| 982 | + } catch (Throwable t) { | ||
| 983 | + XposedBridge.log("[WeChatHook] hookJg0 jg0.j.invokeSuspend hook failed: " + t); | ||
| 984 | + } | ||
| 985 | + XposedBridge.log("[WeChatHook] hookJg0: jg0.j hooked in " + procName); | ||
| 986 | + } catch (Throwable t) { | ||
| 987 | + XposedBridge.log("[WeChatHook] hookJg0 jg0.j failed: " + t); | ||
| 988 | + } | ||
| 989 | + | ||
| 990 | + // ── jg0.x (coordinator that creates jg0.j and calls D to enqueue send) ── | ||
| 991 | + try { | ||
| 992 | + Class<?> xClass = XposedHelpers.findClass("jg0.x", cl); | ||
| 993 | + StringBuilder xInfo = new StringBuilder("[WeChatHook] jg0.x methods:"); | ||
| 994 | + for (java.lang.reflect.Method m : xClass.getDeclaredMethods()) | ||
| 995 | + xInfo.append(" ").append(m.getName()).append("(").append(m.getParameterTypes().length).append(")"); | ||
| 996 | + xInfo.append(" | ownFields:"); | ||
| 997 | + for (java.lang.reflect.Field f : xClass.getDeclaredFields()) | ||
| 998 | + xInfo.append(" ").append(f.getName()).append("(").append(f.getType().getSimpleName()).append(")"); | ||
| 999 | + XposedBridge.log(xInfo.toString()); | ||
| 1000 | + | ||
| 1001 | + // Hook D() — the method called from pu.o0.invokeSuspend(84) that enqueues the send | ||
| 1002 | + for (java.lang.reflect.Method m : xClass.getDeclaredMethods()) { | ||
| 1003 | + if (!"D".equals(m.getName())) continue; | ||
| 1004 | + m.setAccessible(true); | ||
| 1005 | + final String sig = m.toGenericString(); | ||
| 1006 | + XposedBridge.hookMethod(m, new XC_MethodHook() { | ||
| 1007 | + @Override protected void beforeHookedMethod(MethodHookParam p) { | ||
| 1008 | + StringBuilder sb = new StringBuilder( | ||
| 1009 | + "[WeChatHook] jg0.x.D ENTER sig=" + sig); | ||
| 1010 | + // args | ||
| 1011 | + if (p.args != null) for (int i = 0; i < p.args.length; i++) { | ||
| 1012 | + Object a = p.args[i]; | ||
| 1013 | + sb.append("\n arg[").append(i).append("]=") | ||
| 1014 | + .append(a == null ? "null" : a.getClass().getName() | ||
| 1015 | + + "@" + System.identityHashCode(a)); | ||
| 1016 | + // If it looks like a voice-msg / f9 subclass, dump its fields | ||
| 1017 | + if (a != null) { | ||
| 1018 | + for (Class<?> c2 = a.getClass(); | ||
| 1019 | + c2 != null && !c2.equals(Object.class); c2 = c2.getSuperclass()) { | ||
| 1020 | + for (java.lang.reflect.Field f2 : c2.getDeclaredFields()) { | ||
| 1021 | + if (java.lang.reflect.Modifier.isStatic(f2.getModifiers())) continue; | ||
| 1022 | + f2.setAccessible(true); | ||
| 1023 | + try { | ||
| 1024 | + Object v2 = f2.get(a); | ||
| 1025 | + String vs; | ||
| 1026 | + if (v2 == null) vs = "null"; | ||
| 1027 | + else if (v2 instanceof byte[]) vs = "byte[" + ((byte[])v2).length + "]"; | ||
| 1028 | + else if (v2 instanceof String || v2 instanceof Number | ||
| 1029 | + || v2 instanceof Boolean) vs = v2.toString(); | ||
| 1030 | + else vs = v2.getClass().getSimpleName(); | ||
| 1031 | + sb.append("\n ").append(c2.getSimpleName()) | ||
| 1032 | + .append(".").append(f2.getName()).append("=").append(vs); | ||
| 1033 | + } catch (Throwable ignored2) {} | ||
| 1034 | + } | ||
| 1035 | + } | ||
| 1036 | + } | ||
| 1037 | + } | ||
| 1038 | + // thisObject fields | ||
| 1039 | + for (Class<?> c = p.thisObject.getClass(); | ||
| 1040 | + c != null && !c.equals(Object.class); c = c.getSuperclass()) { | ||
| 1041 | + for (java.lang.reflect.Field f : c.getDeclaredFields()) { | ||
| 1042 | + if (java.lang.reflect.Modifier.isStatic(f.getModifiers())) continue; | ||
| 1043 | + f.setAccessible(true); | ||
| 1044 | + try { | ||
| 1045 | + Object v = f.get(p.thisObject); | ||
| 1046 | + sb.append("\n this.").append(c.getSimpleName()) | ||
| 1047 | + .append(".").append(f.getName()).append("=") | ||
| 1048 | + .append(v == null ? "null" : v.getClass().getName()); | ||
| 1049 | + } catch (Throwable ignored) {} | ||
| 1050 | + } | ||
| 1051 | + } | ||
| 1052 | + XposedBridge.log(sb.toString()); | ||
| 1053 | + } | ||
| 1054 | + }); | ||
| 1055 | + } | ||
| 1056 | + XposedBridge.log("[WeChatHook] hookJg0: jg0.x hooked in " + procName); | ||
| 1057 | + } catch (Throwable t) { | ||
| 1058 | + XposedBridge.log("[WeChatHook] hookJg0 jg0.x failed: " + t); | ||
| 1059 | + } | ||
| 1060 | + | ||
| 1061 | + // ── g9 (likely a subclass of f9; M9/N9 are the DB-insert methods) ───── | ||
| 1062 | + try { | ||
| 1063 | + Class<?> g9Class = XposedHelpers.findClass("com.tencent.mm.storage.g9", cl); | ||
| 1064 | + StringBuilder g9Info = new StringBuilder("[WeChatHook] g9 super=" | ||
| 1065 | + + g9Class.getSuperclass().getName() + " methods:"); | ||
| 1066 | + for (java.lang.reflect.Method m : g9Class.getDeclaredMethods()) | ||
| 1067 | + g9Info.append(" ").append(m.getName()).append("(").append(m.getParameterTypes().length).append(")"); | ||
| 1068 | + g9Info.append(" | ownFields:"); | ||
| 1069 | + for (java.lang.reflect.Field f : g9Class.getDeclaredFields()) | ||
| 1070 | + g9Info.append(" ").append(f.getName()).append("(").append(f.getType().getSimpleName()).append(")"); | ||
| 1071 | + XposedBridge.log(g9Info.toString()); | ||
| 1072 | + | ||
| 1073 | + for (java.lang.reflect.Method m : g9Class.getDeclaredMethods()) { | ||
| 1074 | + if (!"M9".equals(m.getName()) && !"N9".equals(m.getName())) continue; | ||
| 1075 | + m.setAccessible(true); | ||
| 1076 | + final String mName2 = m.getName(); | ||
| 1077 | + final String mSig2 = m.toGenericString(); | ||
| 1078 | + XposedBridge.hookMethod(m, new XC_MethodHook() { | ||
| 1079 | + @Override protected void beforeHookedMethod(MethodHookParam p) { | ||
| 1080 | + StringBuilder sb = new StringBuilder( | ||
| 1081 | + "[WeChatHook] g9." + mName2 + " ENTER sig=" + mSig2 | ||
| 1082 | + + " thisClass=" + p.thisObject.getClass().getName()); | ||
| 1083 | + if (p.args != null) for (int i = 0; i < p.args.length; i++) | ||
| 1084 | + sb.append(" arg[").append(i).append("]=") | ||
| 1085 | + .append(p.args[i] == null ? "null" : p.args[i].getClass().getName()); | ||
| 1086 | + XposedBridge.log(sb.toString()); | ||
| 1087 | + } | ||
| 1088 | + @Override protected void afterHookedMethod(MethodHookParam p) { | ||
| 1089 | + if (p.hasThrowable()) | ||
| 1090 | + XposedBridge.log("[WeChatHook] g9." + mName2 + " THREW: " + p.getThrowable()); | ||
| 1091 | + else | ||
| 1092 | + XposedBridge.log("[WeChatHook] g9." + mName2 + " OK ret=" + p.getResult()); | ||
| 1093 | + } | ||
| 1094 | + }); | ||
| 1095 | + } | ||
| 1096 | + XposedBridge.log("[WeChatHook] hookJg0: g9 hooked in " + procName); | ||
| 1097 | + } catch (Throwable t) { | ||
| 1098 | + XposedBridge.log("[WeChatHook] hookJg0 g9 failed: " + t); | ||
| 1099 | + } | ||
| 1100 | + | ||
| 1101 | + // ── pu.o0 (outer coroutine that calls jg0.x.D) ─────────────────────── | ||
| 1102 | + // Hook invokeSuspend to see what it captures (especially the g9/voice data). | ||
| 1103 | + try { | ||
| 1104 | + Class<?> puClass = XposedHelpers.findClass("pu.o0", cl); | ||
| 1105 | + StringBuilder puInfo = new StringBuilder("[WeChatHook] pu.o0 super=" | ||
| 1106 | + + puClass.getSuperclass().getName() + " | ownFields:"); | ||
| 1107 | + for (java.lang.reflect.Field f : puClass.getDeclaredFields()) | ||
| 1108 | + puInfo.append(" ").append(f.getName()).append("(").append(f.getType().getSimpleName()).append(")"); | ||
| 1109 | + XposedBridge.log(puInfo.toString()); | ||
| 1110 | + | ||
| 1111 | + XposedHelpers.findAndHookMethod(puClass, "invokeSuspend", Object.class, | ||
| 1112 | + new XC_MethodHook() { | ||
| 1113 | + private volatile boolean sLogged2 = false; | ||
| 1114 | + @Override protected void beforeHookedMethod(MethodHookParam p) { | ||
| 1115 | + if (sLogged2) return; | ||
| 1116 | + sLogged2 = true; | ||
| 1117 | + StringBuilder sb = new StringBuilder( | ||
| 1118 | + "[WeChatHook] pu.o0.invokeSuspend FIRST fields:"); | ||
| 1119 | + for (Class<?> c = p.thisObject.getClass(); | ||
| 1120 | + c != null && !c.equals(Object.class); c = c.getSuperclass()) { | ||
| 1121 | + for (java.lang.reflect.Field f : c.getDeclaredFields()) { | ||
| 1122 | + if (java.lang.reflect.Modifier.isStatic(f.getModifiers())) continue; | ||
| 1123 | + f.setAccessible(true); | ||
| 1124 | + try { | ||
| 1125 | + Object v = f.get(p.thisObject); | ||
| 1126 | + sb.append("\n ").append(c.getSimpleName()) | ||
| 1127 | + .append(".").append(f.getName()).append("=") | ||
| 1128 | + .append(v == null ? "null" | ||
| 1129 | + : v.getClass().getName() + "@" + System.identityHashCode(v)); | ||
| 1130 | + } catch (Throwable ignored) {} | ||
| 1131 | + } | ||
| 1132 | + } | ||
| 1133 | + XposedBridge.log(sb.toString()); | ||
| 1134 | + } | ||
| 1135 | + }); | ||
| 1136 | + XposedBridge.log("[WeChatHook] hookJg0: pu.o0 hooked in " + procName); | ||
| 1137 | + } catch (Throwable t) { | ||
| 1138 | + XposedBridge.log("[WeChatHook] hookJg0 pu.o0 failed: " + t); | ||
| 1139 | + } | ||
| 1140 | + } | ||
| 1141 | + | ||
| 1142 | + private void hookR0H(ClassLoader cl, String procName) { | ||
| 1143 | + try { | ||
| 1144 | + Class<?> r0Class = XposedHelpers.findClass("w11.r0", cl); | ||
| 1145 | + int hooked = 0; | ||
| 1146 | + // Hook ALL methods named H on r0 — real voice sends may use H() or H(int), not just H(String). | ||
| 1147 | + for (java.lang.reflect.Method m : r0Class.getDeclaredMethods()) { | ||
| 1148 | + if (!m.getName().equals("H")) continue; | ||
| 1149 | + final String sig = m.toGenericString(); | ||
| 1150 | + final boolean isHString = m.getParameterTypes().length == 1 | ||
| 1151 | + && m.getParameterTypes()[0] == String.class; | ||
| 1152 | + m.setAccessible(true); | ||
| 1153 | + XposedBridge.hookMethod(m, new XC_MethodHook() { | ||
| 1154 | + @Override | ||
| 1155 | + protected void beforeHookedMethod(MethodHookParam p) throws Throwable { | ||
| 1156 | + String by = sCalledByUs ? "US" : "WeChat"; | ||
| 1157 | + XposedBridge.log("[WeChatHook] r0." + sig + " ENTER by=" + by + " proc=" + procName); | ||
| 1158 | + // Capture real dispatcher from any real WeChat r0 H() call. | ||
| 1159 | + if (!sCalledByUs) { | ||
| 1160 | + try { | ||
| 1161 | + Object disp = XposedHelpers.getObjectField(p.thisObject, "dispatcher"); | ||
| 1162 | + if (disp != null && !(disp instanceof java.lang.reflect.Proxy)) { | ||
| 1163 | + sCapturedDispatcher = disp; | ||
| 1164 | + XposedBridge.log("[WeChatHook] hookR0H: REAL dispatcher=" + disp.getClass().getName() + " from " + sig); | ||
| 1165 | + } | ||
| 1166 | + } catch (Throwable ignored) {} | ||
| 1167 | + // Also dump r0 fields on first real send to capture full state. | ||
| 1168 | + dumpR0Fields(p.thisObject, "r0.H-WeChat by=" + by + " proc=" + procName); | ||
| 1169 | + } else if (isHString) { | ||
| 1170 | + dumpR0Fields(p.thisObject, "r0.H-ENTER by=US proc=" + procName); | ||
| 1171 | + } | ||
| 1172 | + } | ||
| 1173 | + @Override | ||
| 1174 | + protected void afterHookedMethod(MethodHookParam p) throws Throwable { | ||
| 1175 | + String by = sCalledByUs ? "US" : "WeChat"; | ||
| 1176 | + XposedBridge.log("[WeChatHook] r0.H() DONE by=" + by + " proc=" + procName | ||
| 1177 | + + (p.hasThrowable() ? " THREW=" + p.getThrowable() : " OK")); | ||
| 1178 | + } | ||
| 1179 | + }); | ||
| 1180 | + hooked++; | ||
| 1181 | + } | ||
| 1182 | + XposedBridge.log("[WeChatHook] hookR0H: hooked " + hooked + " H() overloads in " + procName); | ||
| 1183 | + } catch (Throwable t) { | ||
| 1184 | + XposedBridge.log("[WeChatHook] hookR0H failed in " + procName + ": " + t); | ||
| 1185 | + } | ||
| 1186 | + } | ||
| 1187 | + | ||
| 1188 | + // ── Capture live network dispatcher (com.tencent.mm.modelbase.m2) ──────────── | ||
| 1189 | + // m2 = MicroMsg.RDispatcher, the com.tencent.mm.network.s the send scene needs. | ||
| 1190 | + // We grab the instance at construction (network init, before any synthetic send) | ||
| 1191 | + // into sCapturedDispatcher, so sendVoiceViaAPI can hand r0 the REAL dispatcher. | ||
| 1192 | + private void hookDispatcher(ClassLoader cl, String procName) { | ||
| 1193 | + try { | ||
| 1194 | + Class<?> m2 = XposedHelpers.findClass("com.tencent.mm.modelbase.m2", cl); | ||
| 1195 | + XC_MethodHook grab = new XC_MethodHook() { | ||
| 1196 | + @Override protected void beforeHookedMethod(MethodHookParam p) { | ||
| 1197 | + if (sCapturedDispatcher == null && p.thisObject != null | ||
| 1198 | + && !(p.thisObject instanceof java.lang.reflect.Proxy)) { | ||
| 1199 | + sCapturedDispatcher = p.thisObject; | ||
| 1200 | + XposedBridge.log("[WeChatHook] hookDispatcher: captured REAL m2=" | ||
| 1201 | + + p.thisObject + " in " + procName); | ||
| 1202 | + } | ||
| 1203 | + } | ||
| 1204 | + }; | ||
| 1205 | + int n = 0; | ||
| 1206 | + // Ctor: catches m2 constructed AFTER we install (network re-init). | ||
| 1207 | + for (java.lang.reflect.Constructor<?> c : m2.getDeclaredConstructors()) { | ||
| 1208 | + c.setAccessible(true); | ||
| 1209 | + XposedBridge.hookMethod(c, new XC_MethodHook() { | ||
| 1210 | + @Override protected void afterHookedMethod(MethodHookParam p) { | ||
| 1211 | + sCapturedDispatcher = p.thisObject; | ||
| 1212 | + XposedBridge.log("[WeChatHook] hookDispatcher: captured REAL m2 (ctor)=" | ||
| 1213 | + + p.thisObject + " in " + procName); | ||
| 1214 | + } | ||
| 1215 | + }); | ||
| 1216 | + n++; | ||
| 1217 | + } | ||
| 1218 | + // Instance methods: catches an ALREADY-existing m2 (constructed before our | ||
| 1219 | + // hook / persistent connection) on its next network call. After capture the | ||
| 1220 | + // callback is a single null check — negligible overhead. | ||
| 1221 | + int mh = 0; | ||
| 1222 | + for (java.lang.reflect.Method m : m2.getDeclaredMethods()) { | ||
| 1223 | + if (java.lang.reflect.Modifier.isStatic(m.getModifiers())) continue; | ||
| 1224 | + if (java.lang.reflect.Modifier.isAbstract(m.getModifiers())) continue; | ||
| 1225 | + try { m.setAccessible(true); XposedBridge.hookMethod(m, grab); mh++; } | ||
| 1226 | + catch (Throwable ignored) {} | ||
| 1227 | + } | ||
| 1228 | + XposedBridge.log("[WeChatHook] hookDispatcher: hooked " + n + " ctor(s) + " | ||
| 1229 | + + mh + " method(s) on m2 in " + procName); | ||
| 1230 | + } catch (Throwable t) { | ||
| 1231 | + XposedBridge.log("[WeChatHook] hookDispatcher failed in " + procName + ": " + t); | ||
| 1232 | + } | ||
| 1233 | + } | ||
| 1234 | + | ||
| 1235 | + // ── Instrument voice-upload service w21.g1 (voiceinfo table) ───────────────── | ||
| 1236 | + // On a REAL voice send, WeChat builds a w21.w0 upload-task and calls g1.L0(w0) | ||
| 1237 | + // (insert) / g1.P0(file, w0) (upload). Dump every w0 field on those calls so we | ||
| 1238 | + // learn the exact template (FileName/ClientId/MsgLocalId/VoiceLength/MsgTalker/ | ||
| 1239 | + // Status correlation) needed to synthesize the upload for hook-sent voice. | ||
| 1240 | + // ── Voice hot-swap: replace recorded SILK with our TTS at the CDN-upload step ─ | ||
| 1241 | + // jg0.x.w(jg0.x self, qi3.d0, w21.w0, int type, int durationMs, w21.t silk, Cont) | ||
| 1242 | + // is WeChat's uploadAttach step (confirmed by real-recording capture + tinker | ||
| 1243 | + // decompile). w21.t has exactly one byte[] field (f443506a) = the SILK audio | ||
| 1244 | + // about to be sent to CDN. When armed, we overwrite that byte[] with our TTS | ||
| 1245 | + // SILK and the duration int (arg4). WeChat then uploads + delivers OUR audio, | ||
| 1246 | + // reusing the entire real send pipeline we cannot rebuild. | ||
| 1247 | + // Hook runs in the process where recording happens (main). Verified swap target, | ||
| 1248 | + // but does its own field dump on every fire so a first real test confirms layout. | ||
| 1249 | + private void hookVoiceSwap(ClassLoader cl, String procName) { | ||
| 1250 | + try { | ||
| 1251 | + Class<?> xClass = XposedHelpers.findClass("jg0.x", cl); | ||
| 1252 | + int hooked = 0; | ||
| 1253 | + for (java.lang.reflect.Method m : xClass.getDeclaredMethods()) { | ||
| 1254 | + if (!"w".equals(m.getName())) continue; // hook EVERY 'w' overload | ||
| 1255 | + m.setAccessible(true); | ||
| 1256 | + XposedBridge.hookMethod(m, new XC_MethodHook() { | ||
| 1257 | + @Override protected void beforeHookedMethod(MethodHookParam p) { | ||
| 1258 | + // Absolute-first, arg-safe entry log so we KNOW the hook runs. | ||
| 1259 | + int n = (p.args == null) ? 0 : p.args.length; | ||
| 1260 | + XposedBridge.log("[WeChatHook] SWAPHOOK ENTER w nargs=" + n + " armed=" + sSwapArmed); | ||
| 1261 | + try { | ||
| 1262 | + // Real uploadAttach call: (jg0.x, qi3.d0, w21.w0, int, int, w21.t, Cont). | ||
| 1263 | + // Coroutine resumptions re-call w with nulls — skip those. | ||
| 1264 | + if (n != 7 || p.args[5] == null) return; | ||
| 1265 | + Object tHolder = p.args[5]; // w21.t (SILK holder) | ||
| 1266 | + java.lang.reflect.Field byteF = null; | ||
| 1267 | + for (java.lang.reflect.Field f : tHolder.getClass().getDeclaredFields()) { | ||
| 1268 | + if (f.getType() == byte[].class) { byteF = f; break; } | ||
| 1269 | + } | ||
| 1270 | + if (byteF == null) return; | ||
| 1271 | + byteF.setAccessible(true); | ||
| 1272 | + byte[] orig = (byte[]) byteF.get(tHolder); | ||
| 1273 | + XposedBridge.log("[WeChatHook] SWAPHOOK real w: silk=" | ||
| 1274 | + + (orig == null ? "null" : orig.length + "B") + " durArg=" + p.args[4] | ||
| 1275 | + + " holder=" + tHolder.getClass().getName()); | ||
| 1276 | + if (!sSwapArmed || sSwapSilk == null) return; | ||
| 1277 | + | ||
| 1278 | + byteF.set(tHolder, sSwapSilk); | ||
| 1279 | + if (orig != null) { | ||
| 1280 | + for (java.lang.reflect.Field f : tHolder.getClass().getDeclaredFields()) { | ||
| 1281 | + if (f.getType() != int.class) continue; | ||
| 1282 | + f.setAccessible(true); | ||
| 1283 | + if (f.getInt(tHolder) == orig.length) f.setInt(tHolder, sSwapSilk.length); | ||
| 1284 | + } | ||
| 1285 | + } | ||
| 1286 | + if (sSwapDuration > 0) p.args[4] = sSwapDuration; | ||
| 1287 | + XposedBridge.log("[WeChatHook] VOICE SWAP DONE: " | ||
| 1288 | + + (orig == null ? "?" : orig.length) + "B -> " + sSwapSilk.length | ||
| 1289 | + + "B, dur -> " + p.args[4]); | ||
| 1290 | + sSwapArmed = false; // one-shot | ||
| 1291 | + sSwapSilk = null; | ||
| 1292 | + } catch (Throwable t) { | ||
| 1293 | + XposedBridge.log("[WeChatHook] VOICE SWAP failed: " + t | ||
| 1294 | + + "\n" + android.util.Log.getStackTraceString(t)); | ||
| 1295 | + } | ||
| 1296 | + } | ||
| 1297 | + }); | ||
| 1298 | + hooked++; | ||
| 1299 | + } | ||
| 1300 | + XposedBridge.log("[WeChatHook] hookVoiceSwap: hooked jg0.x.w x" + hooked + " in " + procName); | ||
| 1301 | + } catch (Throwable t) { | ||
| 1302 | + XposedBridge.log("[WeChatHook] hookVoiceSwap failed in " + procName + ": " + t); | ||
| 1303 | + } | ||
| 1304 | + } | ||
| 1305 | + | ||
| 1306 | + private void hookVoiceUpload(ClassLoader cl, String procName) { | ||
| 1307 | + try { | ||
| 1308 | + Class<?> g1 = XposedHelpers.findClass("w21.g1", cl); | ||
| 1309 | + int mh = 0; | ||
| 1310 | + for (java.lang.reflect.Method m : g1.getDeclaredMethods()) { | ||
| 1311 | + final String mName = m.getName(); | ||
| 1312 | + // L0(w0)=insert, P0(file,w0)=upload, D0/J0/y0/z0=lookups. Dump w0 on all. | ||
| 1313 | + if (!("L0".equals(mName) || "P0".equals(mName))) continue; | ||
| 1314 | + m.setAccessible(true); | ||
| 1315 | + XposedBridge.hookMethod(m, new XC_MethodHook() { | ||
| 1316 | + @Override protected void beforeHookedMethod(MethodHookParam p) { | ||
| 1317 | + try { | ||
| 1318 | + // find the w0 arg (w21.w0) among args | ||
| 1319 | + Object w0 = null; | ||
| 1320 | + if (p.args != null) for (Object a : p.args) | ||
| 1321 | + if (a != null && a.getClass().getName().equals("w21.w0")) { w0 = a; break; } | ||
| 1322 | + StringBuilder sb = new StringBuilder("[WeChatHook] g1." + mName | ||
| 1323 | + + " by=" + (sCalledByUs ? "US" : "REAL") + " args=["); | ||
| 1324 | + if (p.args != null) for (int i = 0; i < p.args.length; i++) { | ||
| 1325 | + Object a = p.args[i]; | ||
| 1326 | + sb.append(i > 0 ? ", " : "").append(a == null ? "null" | ||
| 1327 | + : (a instanceof String ? "\"" + a + "\"" : a.getClass().getSimpleName())); | ||
| 1328 | + } | ||
| 1329 | + sb.append("]"); | ||
| 1330 | + if (w0 != null) { | ||
| 1331 | + sb.append("\n w21.w0 DUMP:"); | ||
| 1332 | + for (java.lang.reflect.Field f : w0.getClass().getDeclaredFields()) { | ||
| 1333 | + if (java.lang.reflect.Modifier.isStatic(f.getModifiers())) continue; | ||
| 1334 | + f.setAccessible(true); | ||
| 1335 | + Object v = f.get(w0); | ||
| 1336 | + String vs = v == null ? "null" | ||
| 1337 | + : (v instanceof byte[] ? "byte[" + ((byte[]) v).length + "]" | ||
| 1338 | + : (v instanceof String || v instanceof Number || v instanceof Boolean ? v.toString() | ||
| 1339 | + : v.getClass().getSimpleName())); | ||
| 1340 | + sb.append("\n ").append(f.getName()).append("=").append(vs); | ||
| 1341 | + } | ||
| 1342 | + } | ||
| 1343 | + XposedBridge.log(sb.toString()); | ||
| 1344 | + } catch (Throwable ignored) {} | ||
| 1345 | + } | ||
| 1346 | + }); | ||
| 1347 | + mh++; | ||
| 1348 | + } | ||
| 1349 | + XposedBridge.log("[WeChatHook] hookVoiceUpload: hooked " + mh + " g1 method(s) in " + procName); | ||
| 1350 | + } catch (Throwable t) { | ||
| 1351 | + XposedBridge.log("[WeChatHook] hookVoiceUpload failed in " + procName + ": " + t); | ||
| 1352 | + } | ||
| 1353 | + } | ||
| 1354 | + | ||
| 1355 | + // ── Image send: hook b80.u.pj/Bi (the real non-suspend send entry) ─────────── | ||
| 1356 | + // b80.u (implements o70.e, = n0.c(o70.e.class)) is the image plugin. The send | ||
| 1357 | + // coroutine b80.s calls b80.u.Bi(o70.h,String) then b80.u.pj(o70.h). We hook both | ||
| 1358 | + // to (1) capture the live b80.u instance and (2) on a REAL image send, dump the | ||
| 1359 | + // full o70.h (PreBuildParams) field-by-field — that's the exact recipe to replicate. | ||
| 1360 | + private static void dumpO70h(Object h, String tag) { | ||
| 1361 | + try { | ||
| 1362 | + StringBuilder sb = new StringBuilder("[WeChatHook] " + tag + " o70.h DUMP:"); | ||
| 1363 | + for (java.lang.reflect.Field f : h.getClass().getDeclaredFields()) { | ||
| 1364 | + if (java.lang.reflect.Modifier.isStatic(f.getModifiers())) continue; | ||
| 1365 | + f.setAccessible(true); | ||
| 1366 | + Object v = f.get(h); | ||
| 1367 | + String vs = v == null ? "null" | ||
| 1368 | + : (v instanceof byte[] ? "byte[" + ((byte[]) v).length + "]" | ||
| 1369 | + : (v instanceof String || v instanceof Number || v instanceof Boolean || v instanceof android.net.Uri | ||
| 1370 | + ? v.toString() : v.getClass().getName() + "@" + System.identityHashCode(v))); | ||
| 1371 | + sb.append("\n ").append(f.getName()).append(" (").append(f.getType().getSimpleName()) | ||
| 1372 | + .append(") = ").append(vs); | ||
| 1373 | + } | ||
| 1374 | + XposedBridge.log(sb.toString()); | ||
| 1375 | + } catch (Throwable t) { XposedBridge.log("[WeChatHook] dumpO70h err: " + t); } | ||
| 1376 | + } | ||
| 1377 | + | ||
| 1378 | + // Hook SendImgProxyUI.onCreate → dump the launch Intent extras. Real gallery image | ||
| 1379 | + // sends run through this proxy Activity ($$a.run → b80.s coroutine → send). Capturing | ||
| 1380 | + // its Intent lets us replay the WHOLE real flow via `am start` with the same extras. | ||
| 1381 | + private void hookSendImgProxyUI(ClassLoader cl, String procName) { | ||
| 1382 | + try { | ||
| 1383 | + XposedHelpers.findAndHookMethod( | ||
| 1384 | + "com.tencent.mm.ui.chatting.SendImgProxyUI", cl, "onCreate", | ||
| 1385 | + android.os.Bundle.class, new XC_MethodHook() { | ||
| 1386 | + @Override protected void afterHookedMethod(MethodHookParam p) { | ||
| 1387 | + try { | ||
| 1388 | + android.content.Intent it = (android.content.Intent) | ||
| 1389 | + XposedHelpers.callMethod(p.thisObject, "getIntent"); | ||
| 1390 | + if (it == null) { XposedBridge.log("[WeChatHook] SendImgProxyUI: intent null"); return; } | ||
| 1391 | + android.os.Bundle ex = it.getExtras(); | ||
| 1392 | + StringBuilder sb = new StringBuilder("[WeChatHook] SendImgProxyUI INTENT:"); | ||
| 1393 | + sb.append("\n action=").append(it.getAction()); | ||
| 1394 | + sb.append("\n data=").append(it.getDataString()); | ||
| 1395 | + if (ex != null) for (String key : ex.keySet()) { | ||
| 1396 | + Object v = ex.get(key); | ||
| 1397 | + String vs = v == null ? "null" | ||
| 1398 | + : (v instanceof java.util.ArrayList ? "ArrayList" + v | ||
| 1399 | + : (v.getClass().isArray() ? v.getClass().getSimpleName() + java.util.Arrays.deepToString(new Object[]{v}) | ||
| 1400 | + : v.toString())); | ||
| 1401 | + if (vs.length() > 300) vs = vs.substring(0, 300) + "…"; | ||
| 1402 | + sb.append("\n [").append(key).append("] (") | ||
| 1403 | + .append(v == null ? "?" : v.getClass().getName()).append(") = ").append(vs); | ||
| 1404 | + } | ||
| 1405 | + XposedBridge.log(sb.toString()); | ||
| 1406 | + } catch (Throwable t) { XposedBridge.log("[WeChatHook] SendImgProxyUI dump err: " + t); } | ||
| 1407 | + } | ||
| 1408 | + }); | ||
| 1409 | + XposedBridge.log("[WeChatHook] hookSendImgProxyUI OK in " + procName); | ||
| 1410 | + } catch (Throwable t) { | ||
| 1411 | + XposedBridge.log("[WeChatHook] hookSendImgProxyUI failed in " + procName + ": " + t); | ||
| 1412 | + } | ||
| 1413 | + } | ||
| 1414 | + | ||
| 1415 | + private void hookImageSend(ClassLoader cl, String procName) { | ||
| 1416 | + try { | ||
| 1417 | + Class<?> u = XposedHelpers.findClass("b80.u", cl); | ||
| 1418 | + int n = 0; | ||
| 1419 | + for (java.lang.reflect.Method m : u.getDeclaredMethods()) { | ||
| 1420 | + final String mn = m.getName(); | ||
| 1421 | + if (!("pj".equals(mn) || "Bi".equals(mn) || "oj".equals(mn))) continue; | ||
| 1422 | + Class<?>[] pts = m.getParameterTypes(); | ||
| 1423 | + if (pts.length < 1) continue; | ||
| 1424 | + final boolean isOj = "oj".equals(mn); // oj(o70.k) = the insert/send step | ||
| 1425 | + if (!isOj && !pts[0].getName().equals("o70.h")) continue; | ||
| 1426 | + m.setAccessible(true); | ||
| 1427 | + XposedBridge.hookMethod(m, new XC_MethodHook() { | ||
| 1428 | + @Override protected void beforeHookedMethod(MethodHookParam p) { | ||
| 1429 | + if (sImgPlugin == null && p.thisObject != null) { | ||
| 1430 | + sImgPlugin = p.thisObject; | ||
| 1431 | + XposedBridge.log("[WeChatHook] hookImageSend: captured b80.u=" + p.thisObject); | ||
| 1432 | + } | ||
| 1433 | + if (!sCalledByUs && p.args != null && p.args.length > 0 && p.args[0] != null) { | ||
| 1434 | + if (isOj) { | ||
| 1435 | + // o70.k — the insert object. Dump its full field tree. | ||
| 1436 | + Object k = p.args[0]; | ||
| 1437 | + sRealImgK = k; | ||
| 1438 | + XposedBridge.log("[WeChatHook] b80.u.oj REAL o70.k class=" + k.getClass().getName()); | ||
| 1439 | + dumpO70h(k, "b80.u.oj REAL o70.k"); | ||
| 1440 | + } else { | ||
| 1441 | + sRealImgParams = p.args[0]; | ||
| 1442 | + dumpO70h(p.args[0], "b80.u." + mn + " REAL"); | ||
| 1443 | + } | ||
| 1444 | + } | ||
| 1445 | + } | ||
| 1446 | + }); | ||
| 1447 | + n++; | ||
| 1448 | + } | ||
| 1449 | + XposedBridge.log("[WeChatHook] hookImageSend: hooked " + n + " b80.u method(s) in " + procName); | ||
| 1450 | + } catch (Throwable t) { | ||
| 1451 | + XposedBridge.log("[WeChatHook] hookImageSend failed in " + procName + ": " + t); | ||
| 1452 | + } | ||
| 1453 | + } | ||
| 1454 | + | ||
| 431 | // ── Voice send via internal API ──────────────────────────────────────────── | 1455 | // ── Voice send via internal API ──────────────────────────────────────────── |
| 432 | // Protocol (confirmed by hooking f9.convertTo() on real user send): | 1456 | // Protocol (confirmed by hooking f9.convertTo() on real user send): |
| 433 | // • content XML: <msg><voicemsg voicelength="{ms}" fromusername="{wxid}" isPlayed="false"></voicemsg></msg> | 1457 | // • content XML: <msg><voicemsg voicelength="{ms}" fromusername="{wxid}" isPlayed="false"></voicemsg></msg> |
| @@ -502,52 +1526,291 @@ public class WeChatHook implements IXposedHookLoadPackage { | @@ -502,52 +1526,291 @@ public class WeChatHook implements IXposedHookLoadPackage { | ||
| 502 | // bytes 1..73 default to 0x00 | 1526 | // bytes 1..73 default to 0x00 |
| 503 | XposedBridge.log("[WeChatHook] sendVoiceViaAPI: synthetic lvbuffer 74 bytes for " + durationMs + "ms"); | 1527 | XposedBridge.log("[WeChatHook] sendVoiceViaAPI: synthetic lvbuffer 74 bytes for " + durationMs + "ms"); |
| 504 | 1528 | ||
| 505 | - // ── Create r0 with injector armed (imgPath + lvbuffer) ─────────────── | ||
| 506 | - // hookF9ImgPathInjector fires on every f9 ctor called inside r0 constructor, | ||
| 507 | - // injecting imgPath (=voiceId) and lvbuffer (=SILK bytes) before the DB insert. | ||
| 508 | - long ts = System.currentTimeMillis(); | 1529 | + // ── Create r0: try Object-param ctor first (real WeChat voice path) ───── |
| 1530 | + // WeChat has two 5-param r0 ctors: | ||
| 1531 | + // (String, String, int, int, long) — timestamp variant | ||
| 1532 | + // (String, String, int, int, Object) — audio-path variant (used by real voice sends) | ||
| 1533 | + // The Object ctor stores the audio file reference that H() reads for CDN upload. | ||
| 1534 | + // If it works, r0 sets imgPath internally and H() can find the SILK for upload. | ||
| 1535 | + // Fall back to long ctor if the Object ctor throws. | ||
| 509 | Class<?> r0Class = XposedHelpers.findClass("w11.r0", cl); | 1536 | Class<?> r0Class = XposedHelpers.findClass("w11.r0", cl); |
| 510 | sPendingVoiceId.set(voiceId); | 1537 | sPendingVoiceId.set(voiceId); |
| 511 | sPendingLvBuffer.set(syntheticLv); | 1538 | sPendingLvBuffer.set(syntheticLv); |
| 512 | sCalledByUs = true; | 1539 | sCalledByUs = true; |
| 513 | Object r0; | 1540 | Object r0; |
| 514 | try { | 1541 | try { |
| 515 | - r0 = XposedHelpers.newInstance(r0Class, | ||
| 516 | - new Class<?>[]{String.class, String.class, int.class, int.class, long.class}, | ||
| 517 | - talker, voiceXml, 34, 0, ts); | 1542 | + try { |
| 1543 | + // Pass the voice2 path (where we placed the SILK) as the Object arg. | ||
| 1544 | + // Real WeChat passes the temp recording file; we pass our already-placed file. | ||
| 1545 | + String voiceFilePath = srcPath != null ? srcPath | ||
| 1546 | + : "/data/data/com.tencent.mm/MicroMsg/653cdc2320abb089e149a3f7a7683fcf/voice2/" | ||
| 1547 | + + String.format("%02x", java.security.MessageDigest.getInstance("MD5") | ||
| 1548 | + .digest(voiceId.getBytes("UTF-8"))[0] & 0xff) | ||
| 1549 | + + "/" + String.format("%02x", java.security.MessageDigest.getInstance("MD5") | ||
| 1550 | + .digest(voiceId.getBytes("UTF-8"))[1] & 0xff) | ||
| 1551 | + + "/msg_" + voiceId + ".amr"; | ||
| 1552 | + r0 = XposedHelpers.newInstance(r0Class, | ||
| 1553 | + new Class<?>[]{String.class, String.class, int.class, int.class, Object.class}, | ||
| 1554 | + talker, voiceXml, 34, 0, voiceFilePath); | ||
| 1555 | + XposedBridge.log("[WeChatHook] sendVoiceViaAPI: used Object-ctor with path=" + voiceFilePath); | ||
| 1556 | + } catch (Throwable e1) { | ||
| 1557 | + XposedBridge.log("[WeChatHook] sendVoiceViaAPI: Object-ctor failed (" + e1 + "), falling back to long-ctor"); | ||
| 1558 | + long ts = System.currentTimeMillis(); | ||
| 1559 | + r0 = XposedHelpers.newInstance(r0Class, | ||
| 1560 | + new Class<?>[]{String.class, String.class, int.class, int.class, long.class}, | ||
| 1561 | + talker, voiceXml, 34, 0, ts); | ||
| 1562 | + } | ||
| 518 | } finally { | 1563 | } finally { |
| 519 | sCalledByUs = false; | 1564 | sCalledByUs = false; |
| 520 | sPendingVoiceId.remove(); | 1565 | sPendingVoiceId.remove(); |
| 521 | sPendingLvBuffer.remove(); | 1566 | sPendingLvBuffer.remove(); |
| 522 | } | 1567 | } |
| 523 | - | ||
| 524 | - // ── Populate r0.i with the DB-bound f9 so r0.K(0) can access r0.i.get(0) ── | ||
| 525 | - // r0.K(int) is the delivery-success handler; r0.I(int) is the failure handler. | ||
| 526 | - // Both internally do r0.i.get(0) to locate the message in memory. | ||
| 527 | - // sDatabaseF9 is the f9 that called convertTo() → the one actually in the DB. | ||
| 528 | - try { | ||
| 529 | - Object dbF9 = sDatabaseF9.get(); | ||
| 530 | - if (dbF9 != null) { | ||
| 531 | - @SuppressWarnings("unchecked") | ||
| 532 | - java.util.List<Object> rOi = (java.util.List<Object>) XposedHelpers.getObjectField(r0, "i"); | ||
| 533 | - if (rOi != null && rOi.isEmpty()) { | ||
| 534 | - rOi.add(dbF9); | ||
| 535 | - XposedBridge.log("[WeChatHook] sendVoiceViaAPI: added dbF9 to r0.i size=" + rOi.size()); | ||
| 536 | - } else { | ||
| 537 | - XposedBridge.log("[WeChatHook] sendVoiceViaAPI: r0.i already " + (rOi == null ? "null" : "size=" + rOi.size())); | 1568 | + // Dump r0 fields so we can see which field holds the audio path. |
| 1569 | + dumpR0Fields(r0, "r0-after-ctor by=US"); | ||
| 1570 | + | ||
| 1571 | + // ── Leave r0.p == null: take the AUTHENTIC new-send path ────────────────── | ||
| 1572 | + // The r0 Object-ctor already inserted a proper f9 (content=voiceXml, imgPath | ||
| 1573 | + // injected by hookF9ImgPathInjector, status=1) via cj().N9(). With p==null, | ||
| 1574 | + // NetSceneSendMsg.doScene() reloads that f9 fresh from the DB (cj().c6()) — | ||
| 1575 | + // content/msgsource intact — and assembles + dispatches it, exactly like a | ||
| 1576 | + // real new send. | ||
| 1577 | + // | ||
| 1578 | + // Previously we forced r0.p = sDatabaseF9. That sends doScene down the *resend* | ||
| 1579 | + // branch (which expects status=5 and logs "should not be resend"), handing the | ||
| 1580 | + // MsgSource assembler a stale in-memory f9 whose content reads null → | ||
| 1581 | + // e14.k.fromXml(null) NPE. That was the root-cause blocker; fixed by NOT setting p. | ||
| 1582 | + sDatabaseF9.remove(); | ||
| 1583 | + | ||
| 1584 | + // ── Set dispatcher on r0 (required for CDN upload via H()) ───────────── | ||
| 1585 | + // r0.dispatcher (type com.tencent.mm.network.s) is null in our synthetic r0. | ||
| 1586 | + // H() NPEs on `dispatcher.J(z0, o0, int)` if null. | ||
| 1587 | + // We capture dispatcher from the first real WeChat r0 that enters H() (hookR0H). | ||
| 1588 | + if (sCapturedDispatcher != null && !(sCapturedDispatcher instanceof java.lang.reflect.Proxy)) { | ||
| 1589 | + // Real dispatcher captured from a genuine WeChat send — use it directly. | ||
| 1590 | + try { | ||
| 1591 | + XposedHelpers.setObjectField(r0, "dispatcher", sCapturedDispatcher); | ||
| 1592 | + XposedBridge.log("[WeChatHook] sendVoiceViaAPI: set r0.dispatcher REAL class=" | ||
| 1593 | + + sCapturedDispatcher.getClass().getName()); | ||
| 1594 | + } catch (Throwable ed) { | ||
| 1595 | + XposedBridge.log("[WeChatHook] sendVoiceViaAPI: r0.dispatcher set failed: " + ed); | ||
| 1596 | + } | ||
| 1597 | + } else { | ||
| 1598 | + // No real dispatcher yet — fall through to proxy creation below. | ||
| 1599 | + // (Cached proxy is reused if already created; fresh one made otherwise.) | ||
| 1600 | + // Dispatcher not yet captured — search WeChat singletons for it. | ||
| 1601 | + // gm0.j1.i() returns the message manager which likely holds a dispatcher reference. | ||
| 1602 | + XposedBridge.log("[WeChatHook] sendVoiceViaAPI: dispatcher null, searching singletons..."); | ||
| 1603 | + outer: | ||
| 1604 | + for (String[] candidate : new String[][]{ | ||
| 1605 | + {"gm0.j1", "i"}, // message manager singleton | ||
| 1606 | + {"tg3.t1", "a"}, // service locator | ||
| 1607 | + }) { | ||
| 1608 | + try { | ||
| 1609 | + Class<?> mgr = XposedHelpers.findClass(candidate[0], cl); | ||
| 1610 | + Object inst = XposedHelpers.callStaticMethod(mgr, candidate[1]); | ||
| 1611 | + if (inst == null) continue; | ||
| 1612 | + // Walk inst's fields recursively (depth 2) looking for dispatcher | ||
| 1613 | + for (java.lang.reflect.Field f1 : inst.getClass().getDeclaredFields()) { | ||
| 1614 | + if (java.lang.reflect.Modifier.isStatic(f1.getModifiers())) continue; | ||
| 1615 | + f1.setAccessible(true); | ||
| 1616 | + Object v1 = f1.get(inst); | ||
| 1617 | + if (v1 == null) continue; | ||
| 1618 | + // Level 1: try r0.dispatcher field name on v1 directly | ||
| 1619 | + try { | ||
| 1620 | + Object d = XposedHelpers.getObjectField(v1, "dispatcher"); | ||
| 1621 | + if (d != null) { | ||
| 1622 | + sCapturedDispatcher = d; | ||
| 1623 | + XposedBridge.log("[WeChatHook] dispatcher found: " | ||
| 1624 | + + candidate[0] + "." + candidate[1] + "()." + f1.getName() | ||
| 1625 | + + ".dispatcher=" + d.getClass().getName()); | ||
| 1626 | + XposedHelpers.setObjectField(r0, "dispatcher", d); | ||
| 1627 | + break outer; | ||
| 1628 | + } | ||
| 1629 | + } catch (Throwable ignored1) {} | ||
| 1630 | + // Level 2: walk v1's fields | ||
| 1631 | + for (java.lang.reflect.Field f2 : v1.getClass().getDeclaredFields()) { | ||
| 1632 | + if (java.lang.reflect.Modifier.isStatic(f2.getModifiers())) continue; | ||
| 1633 | + f2.setAccessible(true); | ||
| 1634 | + Object v2 = f2.get(v1); | ||
| 1635 | + if (v2 == null) continue; | ||
| 1636 | + try { | ||
| 1637 | + Object d = XposedHelpers.getObjectField(v2, "dispatcher"); | ||
| 1638 | + if (d != null) { | ||
| 1639 | + sCapturedDispatcher = d; | ||
| 1640 | + XposedBridge.log("[WeChatHook] dispatcher found (L2): " | ||
| 1641 | + + candidate[0] + "." + candidate[1] + "()." | ||
| 1642 | + + f1.getName() + "." + f2.getName() | ||
| 1643 | + + ".dispatcher=" + d.getClass().getName()); | ||
| 1644 | + XposedHelpers.setObjectField(r0, "dispatcher", d); | ||
| 1645 | + break outer; | ||
| 1646 | + } | ||
| 1647 | + } catch (Throwable ignored2) {} | ||
| 1648 | + } | ||
| 1649 | + } | ||
| 1650 | + } catch (Throwable t2) { | ||
| 1651 | + XposedBridge.log("[WeChatHook] dispatcher search " + candidate[0] + ": " + t2); | ||
| 1652 | + } | ||
| 1653 | + } | ||
| 1654 | + if (sCapturedDispatcher instanceof java.lang.reflect.Proxy) { | ||
| 1655 | + // Already have a proxy from a previous voice send — reuse it on this r0. | ||
| 1656 | + try { | ||
| 1657 | + XposedHelpers.setObjectField(r0, "dispatcher", sCapturedDispatcher); | ||
| 1658 | + XposedBridge.log("[WeChatHook] sendVoiceViaAPI: reusing proxy dispatcher on r0"); | ||
| 1659 | + } catch (Throwable ed2) { | ||
| 1660 | + XposedBridge.log("[WeChatHook] sendVoiceViaAPI: proxy reuse failed: " + ed2); | ||
| 1661 | + } | ||
| 1662 | + } else if (sCapturedDispatcher == null) { | ||
| 1663 | + // Still not found — create a minimal proxy for s that logs calls. | ||
| 1664 | + // This lets H() proceed past the NPE; CDN upload may work via callback. | ||
| 1665 | + try { | ||
| 1666 | + Class<?> sInterface = XposedHelpers.findClass("com.tencent.mm.network.s", cl); | ||
| 1667 | + java.lang.reflect.Method[] sMethods = sInterface.getDeclaredMethods(); | ||
| 1668 | + StringBuilder sb = new StringBuilder("[WeChatHook] s interface methods:\n"); | ||
| 1669 | + for (java.lang.reflect.Method m : sMethods) sb.append(" ").append(m.toGenericString()).append("\n"); | ||
| 1670 | + XposedBridge.log(sb.toString()); | ||
| 1671 | + Object dispProxy = java.lang.reflect.Proxy.newProxyInstance(cl, | ||
| 1672 | + new Class<?>[]{sInterface}, | ||
| 1673 | + (proxy, method, args) -> { | ||
| 1674 | + XposedBridge.log("[WeChatHook] dispatcher.proxy: " + method.getName() | ||
| 1675 | + + " ret=" + method.getReturnType().getSimpleName()); | ||
| 1676 | + if ("J".equals(method.getName()) && args != null && args.length >= 2) { | ||
| 1677 | + // ── Attempt 1: forward J() via l0 method on m05.j ────────────── | ||
| 1678 | + // method.declaringClass = s (s has own J, NOT inherited from l0). | ||
| 1679 | + // m05.j implements l0 (not s). Solution: get J() from l0 interface | ||
| 1680 | + // and invoke THAT on m05.j — valid since m05.j implements l0. | ||
| 1681 | + try { | ||
| 1682 | + Object q2arg = args[1]; | ||
| 1683 | + Object mj = XposedHelpers.getObjectField(q2arg, "e"); | ||
| 1684 | + if (mj != null) { | ||
| 1685 | + Class<?> l0Iface = XposedHelpers.findClass("com.tencent.mm.network.l0", cl); | ||
| 1686 | + // Dump l0 methods for diagnosis | ||
| 1687 | + StringBuilder l0log = new StringBuilder("[WeChatHook] l0 methods:"); | ||
| 1688 | + java.lang.reflect.Method foundL0J = null; | ||
| 1689 | + for (java.lang.reflect.Method l0m : l0Iface.getDeclaredMethods()) { | ||
| 1690 | + l0log.append(" ").append(l0m.getName()).append("(").append(l0m.getParameterTypes().length).append(")"); | ||
| 1691 | + if (l0m.getName().equals(method.getName()) | ||
| 1692 | + && l0m.getParameterTypes().length == method.getParameterTypes().length) { | ||
| 1693 | + foundL0J = l0m; | ||
| 1694 | + } | ||
| 1695 | + } | ||
| 1696 | + XposedBridge.log(l0log.toString()); | ||
| 1697 | + if (foundL0J != null) { | ||
| 1698 | + foundL0J.setAccessible(true); | ||
| 1699 | + XposedBridge.log("[WeChatHook] J→m05.j via l0.J()"); | ||
| 1700 | + try { | ||
| 1701 | + Object result = foundL0J.invoke(mj, args); | ||
| 1702 | + XposedBridge.log("[WeChatHook] J→m05.j l0 OK result=" + result); | ||
| 1703 | + return result; | ||
| 1704 | + } catch (java.lang.reflect.InvocationTargetException ite) { | ||
| 1705 | + XposedBridge.log("[WeChatHook] J→m05.j l0 threw: " + ite.getCause()); | ||
| 1706 | + } | ||
| 1707 | + } else { | ||
| 1708 | + XposedBridge.log("[WeChatHook] J not found in l0 by name+paramcount"); | ||
| 1709 | + } | ||
| 1710 | + } | ||
| 1711 | + } catch (Throwable jt0) { | ||
| 1712 | + XposedBridge.log("[WeChatHook] J→m05.j err: " + jt0); | ||
| 1713 | + } | ||
| 1714 | + // ── Attempt 2: find s implementor in J() args (skip l0-only classes) ── | ||
| 1715 | + try { | ||
| 1716 | + Class<?> sIface = XposedHelpers.findClass("com.tencent.mm.network.s", cl); | ||
| 1717 | + for (Object searchRoot : new Object[]{args[0], args[1]}) { | ||
| 1718 | + if (searchRoot == null) continue; | ||
| 1719 | + for (Class<?> cls2 = searchRoot.getClass(); cls2 != null && !cls2.equals(Object.class); cls2 = cls2.getSuperclass()) { | ||
| 1720 | + for (java.lang.reflect.Field sf1 : cls2.getDeclaredFields()) { | ||
| 1721 | + if (java.lang.reflect.Modifier.isStatic(sf1.getModifiers())) continue; | ||
| 1722 | + sf1.setAccessible(true); | ||
| 1723 | + Object sv1 = sf1.get(searchRoot); | ||
| 1724 | + if (sv1 == null || sv1 == proxy) continue; | ||
| 1725 | + if (sIface.isInstance(sv1)) { | ||
| 1726 | + sCapturedDispatcher = sv1; | ||
| 1727 | + XposedBridge.log("[WeChatHook] J: REAL s L1 " + sv1.getClass().getName()); | ||
| 1728 | + try { return method.invoke(sv1, args); } catch (java.lang.reflect.InvocationTargetException ite) { throw ite.getCause(); } | ||
| 1729 | + } | ||
| 1730 | + // L2 sub-fields | ||
| 1731 | + try { | ||
| 1732 | + for (Class<?> cls3 = sv1.getClass(); cls3 != null && !cls3.equals(Object.class); cls3 = cls3.getSuperclass()) { | ||
| 1733 | + for (java.lang.reflect.Field sf2 : cls3.getDeclaredFields()) { | ||
| 1734 | + if (java.lang.reflect.Modifier.isStatic(sf2.getModifiers())) continue; | ||
| 1735 | + sf2.setAccessible(true); | ||
| 1736 | + Object sv2 = sf2.get(sv1); | ||
| 1737 | + if (sv2 == null || sv2 == proxy) continue; | ||
| 1738 | + if (sIface.isInstance(sv2)) { | ||
| 1739 | + sCapturedDispatcher = sv2; | ||
| 1740 | + XposedBridge.log("[WeChatHook] J: REAL s L2 " + sf1.getName() + "." + sf2.getName() + " " + sv2.getClass().getName()); | ||
| 1741 | + try { return method.invoke(sv2, args); } catch (java.lang.reflect.InvocationTargetException ite) { throw ite.getCause(); } | ||
| 1742 | + } | ||
| 1743 | + } | ||
| 1744 | + } | ||
| 1745 | + } catch (Throwable ignored4) {} | ||
| 1746 | + } | ||
| 1747 | + } | ||
| 1748 | + } | ||
| 1749 | + XposedBridge.log("[WeChatHook] J: no s implementor in args"); | ||
| 1750 | + } catch (Throwable jse) { | ||
| 1751 | + XposedBridge.log("[WeChatHook] J: search err: " + jse); | ||
| 1752 | + } | ||
| 1753 | + // ── Deep-dump v2 (CDN request) sub-fields ─────────────────── | ||
| 1754 | + try { | ||
| 1755 | + Object v2arg = args[0]; | ||
| 1756 | + StringBuilder cdnDump = new StringBuilder("[WeChatHook] J v2-deep:\n"); | ||
| 1757 | + // Walk class hierarchy (getDeclaredFields only finds own fields) | ||
| 1758 | + for (Class<?> cls2 = v2arg.getClass(); cls2 != null && !cls2.equals(Object.class); cls2 = cls2.getSuperclass()) { | ||
| 1759 | + for (java.lang.reflect.Field sf1 : cls2.getDeclaredFields()) { | ||
| 1760 | + if (java.lang.reflect.Modifier.isStatic(sf1.getModifiers())) continue; | ||
| 1761 | + sf1.setAccessible(true); | ||
| 1762 | + Object sv1 = sf1.get(v2arg); | ||
| 1763 | + cdnDump.append(" ").append(sf1.getName()).append("=") | ||
| 1764 | + .append(sv1 == null ? "null" : sv1.getClass().getSimpleName()).append("\n"); | ||
| 1765 | + if (sv1 == null) continue; | ||
| 1766 | + for (java.lang.reflect.Field sf2 : sv1.getClass().getDeclaredFields()) { | ||
| 1767 | + if (java.lang.reflect.Modifier.isStatic(sf2.getModifiers())) continue; | ||
| 1768 | + sf2.setAccessible(true); | ||
| 1769 | + Object sv2 = sf2.get(sv1); | ||
| 1770 | + String sv2s; | ||
| 1771 | + if (sv2 == null) { sv2s = "null"; } | ||
| 1772 | + else if (sv2 instanceof byte[]) { | ||
| 1773 | + byte[] ba = (byte[]) sv2; | ||
| 1774 | + StringBuilder hex = new StringBuilder(); | ||
| 1775 | + for (int xi = 0; xi < Math.min(ba.length, 20); xi++) hex.append(String.format("%02x", ba[xi] & 0xff)); | ||
| 1776 | + sv2s = "byte[" + ba.length + "]=" + hex; | ||
| 1777 | + } else { | ||
| 1778 | + sv2s = sv2.toString(); | ||
| 1779 | + if (sv2s.length() > 100) sv2s = sv2s.substring(0, 100); | ||
| 1780 | + } | ||
| 1781 | + cdnDump.append(" ").append(sf1.getName()).append(".").append(sf2.getName()).append("=").append(sv2s).append("\n"); | ||
| 1782 | + } | ||
| 1783 | + } | ||
| 1784 | + } | ||
| 1785 | + XposedBridge.log(cdnDump.toString()); | ||
| 1786 | + } catch (Throwable jde) { | ||
| 1787 | + XposedBridge.log("[WeChatHook] J v2-dump err: " + jde); | ||
| 1788 | + } | ||
| 1789 | + } | ||
| 1790 | + if (method.getReturnType() == int.class || method.getReturnType() == Integer.class) | ||
| 1791 | + return 0; | ||
| 1792 | + if (method.getReturnType() == boolean.class || method.getReturnType() == Boolean.class) | ||
| 1793 | + return false; | ||
| 1794 | + return null; | ||
| 1795 | + }); | ||
| 1796 | + sCapturedDispatcher = dispProxy; | ||
| 1797 | + XposedHelpers.setObjectField(r0, "dispatcher", dispProxy); | ||
| 1798 | + XposedBridge.log("[WeChatHook] sendVoiceViaAPI: dispatcher proxy set on r0"); | ||
| 1799 | + } catch (Throwable ep) { | ||
| 1800 | + XposedBridge.log("[WeChatHook] sendVoiceViaAPI: dispatcher proxy failed: " + ep); | ||
| 538 | } | 1801 | } |
| 539 | - } else { | ||
| 540 | - XposedBridge.log("[WeChatHook] sendVoiceViaAPI: sDatabaseF9 is null — r0.i not populated"); | ||
| 541 | } | 1802 | } |
| 542 | - } catch (Throwable t) { | ||
| 543 | - XposedBridge.log("[WeChatHook] sendVoiceViaAPI: r0.i populate failed: " + t); | ||
| 544 | - } finally { | ||
| 545 | - sDatabaseF9.remove(); | ||
| 546 | } | 1803 | } |
| 547 | 1804 | ||
| 548 | // ── u0 callback stub (same pattern as sendViaR0) ───────────────────── | 1805 | // ── u0 callback stub (same pattern as sendViaR0) ───────────────────── |
| 549 | // r0.d (type com.tencent.mm.modelbase.u0) is null unless we set it; | 1806 | // r0.d (type com.tencent.mm.modelbase.u0) is null unless we set it; |
| 550 | // H() calls u0.onSceneEnd which NPEs otherwise. | 1807 | // H() calls u0.onSceneEnd which NPEs otherwise. |
| 1808 | + // hStarted gates the u0 stub: ignore premature onSceneEnd (fires synchronously | ||
| 1809 | + // when we set r0.d, BEFORE H() is called) and only act on H()'s async CDN callback. | ||
| 1810 | + // Root cause: setting r0.d triggers an immediate "local save done" callback. | ||
| 1811 | + // If we call K(0) there, WeChat marks the message as "completed" → H() skips CDN. | ||
| 1812 | + final java.util.concurrent.atomic.AtomicBoolean hStarted = | ||
| 1813 | + new java.util.concurrent.atomic.AtomicBoolean(false); | ||
| 551 | try { | 1814 | try { |
| 552 | Class<?> u0 = XposedHelpers.findClass("com.tencent.mm.modelbase.u0", cl); | 1815 | Class<?> u0 = XposedHelpers.findClass("com.tencent.mm.modelbase.u0", cl); |
| 553 | Object stub = Proxy.newProxyInstance(cl, new Class<?>[]{u0}, | 1816 | Object stub = Proxy.newProxyInstance(cl, new Class<?>[]{u0}, |
| @@ -559,36 +1822,126 @@ public class WeChatHook implements IXposedHookLoadPackage { | @@ -559,36 +1822,126 @@ public class WeChatHook implements IXposedHookLoadPackage { | ||
| 559 | params.append(args[i]); | 1822 | params.append(args[i]); |
| 560 | } | 1823 | } |
| 561 | } | 1824 | } |
| 562 | - XposedBridge.log("[WeChatHook] voice-cb: " + method.getName() + "(" + params + ")"); | 1825 | + XposedBridge.log("[WeChatHook] voice-cb: " + method.getName() |
| 1826 | + + "(" + params + ") hStarted=" + hStarted.get()); | ||
| 563 | // onSceneEnd(int code, int errCode, String msg, m1 scene) | 1827 | // onSceneEnd(int code, int errCode, String msg, m1 scene) |
| 564 | - // Real u0 calls r0.K(code) on success, r0.I(code) on failure. | ||
| 565 | - // code=0 from WeChat server means success. | ||
| 566 | - if ("onSceneEnd".equals(method.getName()) && args != null && args.length >= 4) { | ||
| 567 | - Object scene = args[3]; // w11.r0 implementing m1 | ||
| 568 | - if (scene != null) { | ||
| 569 | - try { | ||
| 570 | - int code = args[0] instanceof Integer ? (Integer) args[0] : 0; | ||
| 571 | - String handler = (code == 0) ? "K" : "I"; | ||
| 572 | - XposedHelpers.callMethod(scene, handler, code); | ||
| 573 | - XposedBridge.log("[WeChatHook] voice-cb: r0." + handler + "(" + code + ") called OK"); | ||
| 574 | - } catch (Throwable e2) { | ||
| 575 | - XposedBridge.log("[WeChatHook] voice-cb: r0.K/I() failed: " + e2); | ||
| 576 | - } | ||
| 577 | - } | 1828 | + // Real m2 dispatch: r0.onGYNetEnd handles the network result itself |
| 1829 | + // (status/msgSvrId update, SendMsgSuccessEvent, K/I). This u0 only needs | ||
| 1830 | + // to be non-null so that internal onSceneEnd call doesn't NPE — do NOT | ||
| 1831 | + // call K/I here or success/fail events double-fire. | ||
| 1832 | + if ("onSceneEnd".equals(method.getName()) && args != null && args.length >= 2) { | ||
| 1833 | + XposedBridge.log("[WeChatHook] voice onSceneEnd code=" + args[0] + " err=" + args[1]); | ||
| 578 | } | 1834 | } |
| 579 | return null; | 1835 | return null; |
| 580 | }); | 1836 | }); |
| 581 | XposedHelpers.setObjectField(r0, "d", stub); | 1837 | XposedHelpers.setObjectField(r0, "d", stub); |
| 1838 | + // Premature onSceneEnd fires synchronously during setObjectField above and is suppressed. | ||
| 582 | } catch (Throwable t) { | 1839 | } catch (Throwable t) { |
| 583 | XposedBridge.log("[WeChatHook] u0 stub failed (non-fatal): " + t); | 1840 | XposedBridge.log("[WeChatHook] u0 stub failed (non-fatal): " + t); |
| 584 | } | 1841 | } |
| 585 | 1842 | ||
| 586 | - // ── Skip H() — message was already INSERTed into DB inside the r0 constructor | ||
| 587 | - // (convertTo fires before H). H() only triggers CDN upload + server delivery, | ||
| 588 | - // which fails for us (no CDN upload path), causing WeChat to delete the message | ||
| 589 | - // on server error. With status=4 already set, the message shows as delivered. | ||
| 590 | - XposedBridge.log("[WeChatHook] sendVoiceViaAPI: OK (no-H) to=" + talker | ||
| 591 | - + " voiceId=" + voiceId + " durationMs=" + durationMs); | 1843 | + // ── Dispatch via WeChat's REAL NetSceneQueue ───────────────────────────── |
| 1844 | + // Exactly the path the (now-working) internal text send uses. n1.a() does: | ||
| 1845 | + // ((gm0.n) com.tencent.mm.modelbase.z2.f88087b).f290500a.f290528b.h(scene, 0) | ||
| 1846 | + // The queue supplies the real network dispatcher and runs doScene properly: | ||
| 1847 | + // p==null → it reloads our just-inserted voice f9 from the DB, assembles the | ||
| 1848 | + // MsgSource, uploads the SILK to CDN, and transports to the server. | ||
| 1849 | + // | ||
| 1850 | + // This replaces the old manual r0.H()+fake-$Proxy dispatcher, which never | ||
| 1851 | + // reached the network — real transport is the queue's job (routed via IPC to | ||
| 1852 | + // the :push connection), NOT a hand-set main-process m2 (which is never even | ||
| 1853 | + // exercised, so it can't be captured). | ||
| 1854 | + hStarted.set(true); | ||
| 1855 | + sCalledByUs = true; | ||
| 1856 | + try { | ||
| 1857 | + // z2.b(m1) = WeChat's own "run cgi" helper (IGetNetSceneQueue): enqueues the | ||
| 1858 | + // scene into the real NetSceneQueue, which supplies the live dispatcher and | ||
| 1859 | + // runs doScene. Using the static helper avoids hardcoding the queue's field | ||
| 1860 | + // chain, whose obfuscated names differ between base.apk and the Tinker patch | ||
| 1861 | + // (the running code). r0 extends m1, so this resolves b(m1) by assignability. | ||
| 1862 | + Class<?> z2c = XposedHelpers.findClass("com.tencent.mm.modelbase.z2", cl); | ||
| 1863 | + XposedHelpers.callStaticMethod(z2c, "b", r0); | ||
| 1864 | + XposedBridge.log("[WeChatHook] sendVoiceViaAPI: enqueued via z2.b(NetSceneQueue) to=" + talker | ||
| 1865 | + + " voiceId=" + voiceId + " durationMs=" + durationMs); | ||
| 1866 | + } catch (Throwable t) { | ||
| 1867 | + XposedBridge.log("[WeChatHook] sendVoiceViaAPI: enqueue threw: " + t | ||
| 1868 | + + "\n" + android.util.Log.getStackTraceString(t)); | ||
| 1869 | + } finally { | ||
| 1870 | + sCalledByUs = false; | ||
| 1871 | + } | ||
| 1872 | + | ||
| 1873 | + // ── Trigger REAL delivery via ResendVoiceMsgEvent{f9} ────────────────── | ||
| 1874 | + // Decompiling WeChat 8.0.74 revealed the real voice send path: | ||
| 1875 | + // cg0.d(talker,file) → jg0.x(cg0.d) → sendCgi(m)+uploadAttach(w). | ||
| 1876 | + // ResendVoiceMsgEvent (com.tencent.mm.autogen.events) carries a single f9 | ||
| 1877 | + // (field g = am.wr, wr.a = f9). Publishing it makes WeChat's voice-resend | ||
| 1878 | + // handler run the FULL sendCgi + CDN upload for our already-inserted f9 — | ||
| 1879 | + // i.e. the same machinery a real send uses. This replaces the dead | ||
| 1880 | + // r0.H()/msgSvrId-poll path (delivery never happened → msgSvrId stayed 0). | ||
| 1881 | + final Long msgLocalId = sPendingMsgLocalId.get(); | ||
| 1882 | + sPendingMsgLocalId.remove(); | ||
| 1883 | + sDatabaseF9.remove(); | ||
| 1884 | + final Object fPt0e0 = sPt0E0Instance; | ||
| 1885 | + final String fTalker = talker; | ||
| 1886 | + final ClassLoader fCl = cl; | ||
| 1887 | + // ResendVoiceMsgEvent path CONFIRMED DEAD (2026-07-06): even after marking the | ||
| 1888 | + // properly-inserted voice msg failed (status=5) and firing the event, WeChat's | ||
| 1889 | + // resend handler does NOT respond — the message just shows red ! (failed), no | ||
| 1890 | + // CDN upload. So this force-fails sends for nothing. Disabled. The remaining | ||
| 1891 | + // real path is to build a voiceinfo/w0 upload task and drive w21.g1.P0() (or | ||
| 1892 | + // reconstruct jg0.x). Kept for reference. | ||
| 1893 | + if (false && msgLocalId != null) { | ||
| 1894 | + new Thread(() -> { | ||
| 1895 | + try { | ||
| 1896 | + Thread.sleep(1500); // let the z2.b send settle first | ||
| 1897 | + // Re-fetch the DB-bound f9 via pt0.e0.k(talker, msgLocalId). | ||
| 1898 | + Object f9 = null; | ||
| 1899 | + if (fPt0e0 != null) { | ||
| 1900 | + try { | ||
| 1901 | + java.lang.reflect.Method kM = null; | ||
| 1902 | + for (java.lang.reflect.Method m : fPt0e0.getClass().getDeclaredMethods()) | ||
| 1903 | + if ("k".equals(m.getName()) && m.getParameterTypes().length == 2) { kM = m; break; } | ||
| 1904 | + if (kM != null) { kM.setAccessible(true); f9 = kM.invoke(fPt0e0, fTalker, msgLocalId); } | ||
| 1905 | + } catch (Throwable t) { | ||
| 1906 | + XposedBridge.log("[WeChatHook] resend: k() failed: " + t); | ||
| 1907 | + } | ||
| 1908 | + } | ||
| 1909 | + if (f9 == null) { | ||
| 1910 | + XposedBridge.log("[WeChatHook] resend: f9 null for msgLocalId=" + msgLocalId + ", abort"); | ||
| 1911 | + return; | ||
| 1912 | + } | ||
| 1913 | + // Mark the message failed (status=5) + persist, so the resend handler | ||
| 1914 | + // treats it as a resendable failed voice. f9.r1(int)=status setter; | ||
| 1915 | + // pt0.e0.s(talker,msgId,f9) persists via cj().lb(). | ||
| 1916 | + try { | ||
| 1917 | + XposedHelpers.callMethod(f9, "r1", 5); | ||
| 1918 | + long mid = ((Number) XposedHelpers.callMethod(f9, "getMsgId")).longValue(); | ||
| 1919 | + if (fPt0e0 != null) { | ||
| 1920 | + java.lang.reflect.Method sM = null; | ||
| 1921 | + for (java.lang.reflect.Method m : fPt0e0.getClass().getDeclaredMethods()) | ||
| 1922 | + if ("s".equals(m.getName()) && m.getParameterTypes().length == 3) { sM = m; break; } | ||
| 1923 | + if (sM != null) { sM.setAccessible(true); sM.invoke(fPt0e0, fTalker, mid, f9); } | ||
| 1924 | + } | ||
| 1925 | + XposedBridge.log("[WeChatHook] resend: marked f9 status=5 + persisted msgId=" + mid); | ||
| 1926 | + } catch (Throwable t) { | ||
| 1927 | + XposedBridge.log("[WeChatHook] resend: status=5 failed: " + t); | ||
| 1928 | + } | ||
| 1929 | + // Build ResendVoiceMsgEvent, set its f9 (field g -> wr.a), publish. | ||
| 1930 | + Class<?> evCls = XposedHelpers.findClass( | ||
| 1931 | + "com.tencent.mm.autogen.events.ResendVoiceMsgEvent", fCl); | ||
| 1932 | + Object ev = evCls.newInstance(); | ||
| 1933 | + Object wr = XposedHelpers.getObjectField(ev, "g"); // am.wr | ||
| 1934 | + XposedHelpers.setObjectField(wr, "a", f9); | ||
| 1935 | + XposedHelpers.callMethod(ev, "b", Looper.getMainLooper()); | ||
| 1936 | + XposedBridge.log("[WeChatHook] resend: ResendVoiceMsgEvent published msgLocalId=" | ||
| 1937 | + + msgLocalId + " talker=" + fTalker + " f9=" + f9.getClass().getName()); | ||
| 1938 | + } catch (Throwable t) { | ||
| 1939 | + XposedBridge.log("[WeChatHook] resend: failed: " + t); | ||
| 1940 | + } | ||
| 1941 | + }, "WCHook-resend-" + msgLocalId).start(); | ||
| 1942 | + } else { | ||
| 1943 | + XposedBridge.log("[WeChatHook] resend: msgLocalId null, skip"); | ||
| 1944 | + } | ||
| 592 | } | 1945 | } |
| 593 | 1946 | ||
| 594 | // ── Factory method discovery ─────────────────────────────────────────────── | 1947 | // ── Factory method discovery ─────────────────────────────────────────────── |
| @@ -632,8 +1985,34 @@ public class WeChatHook implements IXposedHookLoadPackage { | @@ -632,8 +1985,34 @@ public class WeChatHook implements IXposedHookLoadPackage { | ||
| 632 | log.append("null"); | 1985 | log.append("null"); |
| 633 | } else { | 1986 | } else { |
| 634 | log.append(ret.getClass().getName()); | 1987 | log.append(ret.getClass().getName()); |
| 635 | - // Dump the builder's methods so we know how to set image/audio | ||
| 636 | dumpBuilderMethods(ret, "s1." + mName); | 1988 | dumpBuilderMethods(ret, "s1." + mName); |
| 1989 | + // Capture dispatcher from the r0 inside r1 (set by factory, null in direct ctor) | ||
| 1990 | + try { | ||
| 1991 | + StringBuilder r1Fields = new StringBuilder("[WeChatHook] r1 fields:\n"); | ||
| 1992 | + for (Class<?> rc = ret.getClass(); rc != null && !rc.equals(Object.class); rc = rc.getSuperclass()) { | ||
| 1993 | + for (java.lang.reflect.Field rf : rc.getDeclaredFields()) { | ||
| 1994 | + if (java.lang.reflect.Modifier.isStatic(rf.getModifiers())) continue; | ||
| 1995 | + rf.setAccessible(true); | ||
| 1996 | + Object rv = rf.get(ret); | ||
| 1997 | + String typeName = rv != null ? rv.getClass().getName() : rf.getType().getName(); | ||
| 1998 | + r1Fields.append(" ").append(rf.getName()).append(" (").append(typeName).append(")"); | ||
| 1999 | + if (sCapturedDispatcher == null && rv != null) { | ||
| 2000 | + // Try to get dispatcher from any field, whether it's r0 or a container | ||
| 2001 | + try { | ||
| 2002 | + Object disp = XposedHelpers.getObjectField(rv, "dispatcher"); | ||
| 2003 | + if (disp != null) { | ||
| 2004 | + sCapturedDispatcher = disp; | ||
| 2005 | + r1Fields.append(" [dispatcher captured!]"); | ||
| 2006 | + } | ||
| 2007 | + } catch (Throwable ignored2) {} | ||
| 2008 | + } | ||
| 2009 | + r1Fields.append("\n"); | ||
| 2010 | + } | ||
| 2011 | + } | ||
| 2012 | + XposedBridge.log(r1Fields.toString()); | ||
| 2013 | + } catch (Throwable r1e) { | ||
| 2014 | + XposedBridge.log("[WeChatHook] r1 field dump error: " + r1e); | ||
| 2015 | + } | ||
| 637 | } | 2016 | } |
| 638 | if (param.hasThrowable()) | 2017 | if (param.hasThrowable()) |
| 639 | log.append(" THREW: ").append(param.getThrowable()); | 2018 | log.append(" THREW: ").append(param.getThrowable()); |
| @@ -787,9 +2166,6 @@ public class WeChatHook implements IXposedHookLoadPackage { | @@ -787,9 +2166,6 @@ public class WeChatHook implements IXposedHookLoadPackage { | ||
| 787 | // By onResume time, all views are initialized (onCreate + initView have run). | 2166 | // By onResume time, all views are initialized (onCreate + initView have run). |
| 788 | private void hookProbe(ClassLoader cl) { | 2167 | private void hookProbe(ClassLoader cl) { |
| 789 | try { | 2168 | try { |
| 790 | - final Class<?> m4Class = XposedHelpers.findClass( | ||
| 791 | - "com.tencent.mm.plugin.account.ui.m4", cl); | ||
| 792 | - | ||
| 793 | XposedHelpers.findAndHookMethod( | 2169 | XposedHelpers.findAndHookMethod( |
| 794 | "android.app.Activity", null, | 2170 | "android.app.Activity", null, |
| 795 | "onResume", new XC_MethodHook() { | 2171 | "onResume", new XC_MethodHook() { |
| @@ -812,21 +2188,41 @@ public class WeChatHook implements IXposedHookLoadPackage { | @@ -812,21 +2188,41 @@ public class WeChatHook implements IXposedHookLoadPackage { | ||
| 812 | return; | 2188 | return; |
| 813 | } | 2189 | } |
| 814 | tabletBtn.setVisibility(View.VISIBLE); | 2190 | tabletBtn.setVisibility(View.VISIBLE); |
| 2191 | + // CRITICAL: resolve m4 from the Activity's OWN classloader. The login | ||
| 2192 | + // Activities run under the Tinker CL, but hookProbe was installed with | ||
| 2193 | + // the pre-Tinker CL. m4's ctor param LoginHistoryUI and the running | ||
| 2194 | + // Activity are the SAME FQN but from DIFFERENT classloaders, so a | ||
| 2195 | + // captured-CL m4 fails with "type LoginHistoryUI, got LoginHistoryUI". | ||
| 2196 | + // Looking up m4 via obj's CL makes the types line up. | ||
| 2197 | + boolean attached = false; | ||
| 815 | try { | 2198 | try { |
| 816 | - // m4 constructor is m4(LoginHistoryUI), but obj is LoginPasswordUI | ||
| 817 | - // (a subclass). XposedHelpers.newInstance infers types from runtime | ||
| 818 | - // class which is LoginPasswordUI, not LoginHistoryUI — no match. | ||
| 819 | - // Use explicit param type to force the right constructor lookup. | ||
| 820 | - Class<?> loginHistoryUIClass = XposedHelpers.findClass( | ||
| 821 | - "com.tencent.mm.plugin.account.ui.LoginHistoryUI", | ||
| 822 | - obj.getClass().getClassLoader()); | ||
| 823 | - tabletBtn.setOnClickListener( | ||
| 824 | - (View.OnClickListener) XposedHelpers.newInstance( | ||
| 825 | - m4Class, new Class<?>[]{loginHistoryUIClass}, obj)); | 2199 | + ClassLoader runtimeCl = obj.getClass().getClassLoader(); |
| 2200 | + Class<?> m4Class = XposedHelpers.findClass( | ||
| 2201 | + "com.tencent.mm.plugin.account.ui.m4", runtimeCl); | ||
| 2202 | + java.lang.reflect.Constructor<?> chosen = null; | ||
| 2203 | + for (java.lang.reflect.Constructor<?> c : m4Class.getDeclaredConstructors()) { | ||
| 2204 | + Class<?>[] pt = c.getParameterTypes(); | ||
| 2205 | + if (pt.length == 1 && pt[0].isAssignableFrom(obj.getClass())) { | ||
| 2206 | + chosen = c; break; | ||
| 2207 | + } | ||
| 2208 | + } | ||
| 2209 | + if (chosen != null && View.OnClickListener.class.isAssignableFrom(m4Class)) { | ||
| 2210 | + chosen.setAccessible(true); | ||
| 2211 | + tabletBtn.setOnClickListener( | ||
| 2212 | + (View.OnClickListener) chosen.newInstance(obj)); | ||
| 2213 | + attached = true; | ||
| 2214 | + XposedBridge.log("[WeChatHook] onResume: m4 listener attached via " | ||
| 2215 | + + chosen.getParameterTypes()[0].getName() + " for " + cls); | ||
| 2216 | + } else { | ||
| 2217 | + XposedBridge.log("[WeChatHook] onResume: no assignable m4 ctor for " | ||
| 2218 | + + cls + " (obj CL=" + runtimeCl + ")"); | ||
| 2219 | + } | ||
| 826 | } catch (Throwable e) { | 2220 | } catch (Throwable e) { |
| 827 | XposedBridge.log("[WeChatHook] onResume setListener failed: " + e); | 2221 | XposedBridge.log("[WeChatHook] onResume setListener failed: " + e); |
| 828 | } | 2222 | } |
| 829 | - XposedBridge.log("[WeChatHook] onResume -> N VISIBLE + m4 attached for " + cls); | 2223 | + if (!attached) { |
| 2224 | + XposedBridge.log("[WeChatHook] onResume: N VISIBLE but NO listener for " + cls); | ||
| 2225 | + } | ||
| 830 | } | 2226 | } |
| 831 | }); | 2227 | }); |
| 832 | XposedBridge.log("[WeChatHook] Hooked Activity.onResume for tablet button"); | 2228 | XposedBridge.log("[WeChatHook] Hooked Activity.onResume for tablet button"); |
| @@ -1048,6 +2444,20 @@ public class WeChatHook implements IXposedHookLoadPackage { | @@ -1048,6 +2444,20 @@ public class WeChatHook implements IXposedHookLoadPackage { | ||
| 1048 | hookFactoryMethods(sTinkerCL, procName + "/tinker"); | 2444 | hookFactoryMethods(sTinkerCL, procName + "/tinker"); |
| 1049 | hookF9ConvertTo(sTinkerCL, procName + "/tinker"); | 2445 | hookF9ConvertTo(sTinkerCL, procName + "/tinker"); |
| 1050 | hookF9ImgPathInjector(sTinkerCL, procName + "/tinker"); | 2446 | hookF9ImgPathInjector(sTinkerCL, procName + "/tinker"); |
| 2447 | + hookR0H(sTinkerCL, procName + "/tinker"); | ||
| 2448 | + // Hook pt0.e0 in Tinker CL — real voice sends run in Tinker CL. | ||
| 2449 | + hookPt0E0(sTinkerCL, procName + "/tinker"); | ||
| 2450 | + // NOTE: hookJg0 / hookVoiceEntry disabled — they hook WeChat's | ||
| 2451 | + // generic coroutine classes (pu.o0/pu.m0) and every message | ||
| 2452 | + // DB insert (g9.M9/N9), which adds anti-tamper risk and | ||
| 2453 | + // destabilizes the fragile Pad login. Re-enable only for a | ||
| 2454 | + // targeted investigation session, never during normal use. | ||
| 2455 | + // hookJg0(sTinkerCL, procName + "/tinker"); | ||
| 2456 | + // hookVoiceEntry(sTinkerCL, procName + "/tinker"); | ||
| 2457 | + // Surgical probe: jg0.x + qi3.f0/g0 only (safe). | ||
| 2458 | + hookVoiceProbe(sTinkerCL, procName + "/tinker"); | ||
| 2459 | + // Image send runs in Tinker CL — hook b80.u.pj/Bi there. | ||
| 2460 | + hookImageSend(sTinkerCL, procName + "/tinker"); | ||
| 1051 | } | 2461 | } |
| 1052 | hookF9ConvertTo(sWeChatCL, procName); | 2462 | hookF9ConvertTo(sWeChatCL, procName); |
| 1053 | hookF9ImgPathInjector(sWeChatCL, procName); | 2463 | hookF9ImgPathInjector(sWeChatCL, procName); |
| @@ -1362,14 +2772,24 @@ public class WeChatHook implements IXposedHookLoadPackage { | @@ -1362,14 +2772,24 @@ public class WeChatHook implements IXposedHookLoadPackage { | ||
| 1362 | private static void sendViaHighLevel(String talker, String text) throws Throwable { | 2772 | private static void sendViaHighLevel(String talker, String text) throws Throwable { |
| 1363 | ClassLoader cl = sTinkerCL != null ? sTinkerCL : sWeChatCL; | 2773 | ClassLoader cl = sTinkerCL != null ? sTinkerCL : sWeChatCL; |
| 1364 | if (cl == null) throw new IllegalStateException("classloader null"); | 2774 | if (cl == null) throw new IllegalStateException("classloader null"); |
| 1365 | - Class<?> tg3t1 = XposedHelpers.findClass("tg3.t1", cl); | ||
| 1366 | - Object sendMgr = XposedHelpers.callStaticMethod(tg3t1, "a"); | ||
| 1367 | - if (sendMgr == null) throw new IllegalStateException("tg3.t1.a() returned null"); | ||
| 1368 | - XposedBridge.log("[WeChatHook] sendMgr=" + sendMgr.getClass().getName()); | ||
| 1369 | - // Ni(content, talker, isGroup, extraKl) — 4-param overload for text | ||
| 1370 | - XposedHelpers.callMethod(sendMgr, "Ni", | ||
| 1371 | - new Class<?>[]{String.class, String.class, boolean.class, Object.class}, | ||
| 1372 | - text, talker, false, null); | 2775 | + // Text = same PROVEN path as voice sendVoiceViaAPI: w11.r0 ctor (type=1) inserts |
| 2776 | + // the f9 into the DB, then z2.b(NetSceneQueue) dispatches over the real network. | ||
| 2777 | + // Text carries no CDN attachment, so unlike voice it completes fully. (The s1.a / | ||
| 2778 | + // s1.b / dk5.s5.Ni builder paths either produced name-cards or silently failed to | ||
| 2779 | + // persist — they need hidden r1 fields + MsgRetransmitUI.P6 that we can't set robustly.) | ||
| 2780 | + Class<?> r0Class = XposedHelpers.findClass("w11.r0", cl); | ||
| 2781 | + Object r0; | ||
| 2782 | + sCalledByUs = true; | ||
| 2783 | + try { | ||
| 2784 | + // Object-ctor is the one that actually inserts the f9 (confirmed by | ||
| 2785 | + // sendVoiceViaAPI). obj=null for text (no audio/msgsource extras). | ||
| 2786 | + r0 = XposedHelpers.newInstance(r0Class, | ||
| 2787 | + new Class<?>[]{String.class, String.class, int.class, int.class, Object.class}, | ||
| 2788 | + talker, text, 1, 0, null); // (talker, content, type=1 TEXT, flag=0, null) | ||
| 2789 | + } finally { sCalledByUs = false; } | ||
| 2790 | + Class<?> z2c = XposedHelpers.findClass("com.tencent.mm.modelbase.z2", cl); | ||
| 2791 | + XposedHelpers.callStaticMethod(z2c, "b", r0); // enqueue → doScene → newsendmsg | ||
| 2792 | + XposedBridge.log("[WeChatHook] sendViaHighLevel(TEXT via r0-obj+z2.b): OK to=" + talker); | ||
| 1373 | } | 2793 | } |
| 1374 | 2794 | ||
| 1375 | private static void registerSendReceiver(Context ctx, String procName) { | 2795 | private static void registerSendReceiver(Context ctx, String procName) { |
| @@ -1378,9 +2798,21 @@ public class WeChatHook implements IXposedHookLoadPackage { | @@ -1378,9 +2798,21 @@ public class WeChatHook implements IXposedHookLoadPackage { | ||
| 1378 | @Override | 2798 | @Override |
| 1379 | public void onReceive(Context context, Intent intent) { | 2799 | public void onReceive(Context context, Intent intent) { |
| 1380 | String talker = intent.getStringExtra("to"); | 2800 | String talker = intent.getStringExtra("to"); |
| 1381 | - String text = intent.getStringExtra("text"); | 2801 | + String rawText = intent.getStringExtra("text"); |
| 2802 | + // Non-ASCII (Chinese) via `am broadcast --es text` gets mangled by the | ||
| 2803 | + // shell/am → server rejects → stuck "sending". Prefer base64: send with | ||
| 2804 | + // --es text_b64 <base64(utf-8)> and we decode it here intact. | ||
| 2805 | + String textB64 = intent.getStringExtra("text_b64"); | ||
| 2806 | + if (textB64 != null && !textB64.isEmpty()) { | ||
| 2807 | + try { | ||
| 2808 | + rawText = new String(android.util.Base64.decode(textB64, android.util.Base64.DEFAULT), "UTF-8"); | ||
| 2809 | + } catch (Throwable t) { | ||
| 2810 | + XposedBridge.log("[WeChatHook] SEND_MSG_INTERNAL: bad text_b64: " + t); | ||
| 2811 | + } | ||
| 2812 | + } | ||
| 2813 | + final String text = rawText; | ||
| 1382 | if (talker == null || talker.isEmpty() || text == null || text.isEmpty()) { | 2814 | if (talker == null || talker.isEmpty() || text == null || text.isEmpty()) { |
| 1383 | - XposedBridge.log("[WeChatHook] SEND_MSG_INTERNAL: missing 'to' or 'text'"); | 2815 | + XposedBridge.log("[WeChatHook] SEND_MSG_INTERNAL: missing 'to' or 'text'/'text_b64'"); |
| 1384 | return; | 2816 | return; |
| 1385 | } | 2817 | } |
| 1386 | XposedBridge.log("[WeChatHook] SEND_MSG_INTERNAL proc=" + procName | 2818 | XposedBridge.log("[WeChatHook] SEND_MSG_INTERNAL proc=" + procName |
| @@ -1457,6 +2889,43 @@ public class WeChatHook implements IXposedHookLoadPackage { | @@ -1457,6 +2889,43 @@ public class WeChatHook implements IXposedHookLoadPackage { | ||
| 1457 | }; | 2889 | }; |
| 1458 | ctx.registerReceiver(voiceRecv, new IntentFilter(ACTION_SEND_VOICE), Context.RECEIVER_NOT_EXPORTED); | 2890 | ctx.registerReceiver(voiceRecv, new IntentFilter(ACTION_SEND_VOICE), Context.RECEIVER_NOT_EXPORTED); |
| 1459 | 2891 | ||
| 2892 | + // ARM_VOICE_SWAP: load our TTS SILK into memory and arm the jg0.x.w swap. | ||
| 2893 | + // After arming, trigger a REAL recording (hold "按住说话") to the target chat — | ||
| 2894 | + // WeChat records, but jg0.x.w uploads our TTS bytes instead → delivered. | ||
| 2895 | + // Usage: am broadcast -a com.hermes.wechat.ARM_VOICE_SWAP \ | ||
| 2896 | + // --es src_path /data/data/com.tencent.mm/cache/hook_voice_src.amr --ei duration 3200 | ||
| 2897 | + BroadcastReceiver armRecv = new BroadcastReceiver() { | ||
| 2898 | + @Override | ||
| 2899 | + public void onReceive(Context context, Intent intent) { | ||
| 2900 | + if (procName.contains(":")) return; // main process only (where recording runs) | ||
| 2901 | + String srcPath = intent.getStringExtra("src_path"); | ||
| 2902 | + int duration = intent.getIntExtra("duration", 0); | ||
| 2903 | + if (srcPath == null || srcPath.isEmpty()) { | ||
| 2904 | + XposedBridge.log("[WeChatHook] ARM_VOICE_SWAP: missing 'src_path'"); | ||
| 2905 | + return; | ||
| 2906 | + } | ||
| 2907 | + try { | ||
| 2908 | + java.io.File f = new java.io.File(srcPath); | ||
| 2909 | + byte[] bytes = new byte[(int) f.length()]; | ||
| 2910 | + try (java.io.FileInputStream in = new java.io.FileInputStream(f)) { | ||
| 2911 | + int off = 0, n; | ||
| 2912 | + while (off < bytes.length && (n = in.read(bytes, off, bytes.length - off)) > 0) off += n; | ||
| 2913 | + } | ||
| 2914 | + sSwapSilk = bytes; | ||
| 2915 | + sSwapDuration = duration; | ||
| 2916 | + sSwapExpiresAt = System.currentTimeMillis() + Math.max(duration, 0) + 10000; | ||
| 2917 | + sSwapArmed = true; | ||
| 2918 | + XposedBridge.log("[WeChatHook] ARM_VOICE_SWAP: armed " + bytes.length | ||
| 2919 | + + "B dur=" + duration + "ms from " + srcPath | ||
| 2920 | + + " (expires in " + ((sSwapExpiresAt - System.currentTimeMillis()) / 1000) | ||
| 2921 | + + "s — trigger a real recording now)"); | ||
| 2922 | + } catch (Throwable t) { | ||
| 2923 | + XposedBridge.log("[WeChatHook] ARM_VOICE_SWAP failed: " + t); | ||
| 2924 | + } | ||
| 2925 | + } | ||
| 2926 | + }; | ||
| 2927 | + ctx.registerReceiver(armRecv, new IntentFilter(ACTION_ARM_SWAP), Context.RECEIVER_NOT_EXPORTED); | ||
| 2928 | + | ||
| 1460 | // DUMP_WECHAT_API: on-demand class introspection (main process only) | 2929 | // DUMP_WECHAT_API: on-demand class introspection (main process only) |
| 1461 | // Usage: adb shell su -c 'am broadcast -a com.hermes.wechat.DUMP_WECHAT_API [--es class "w11.s1"]' | 2930 | // Usage: adb shell su -c 'am broadcast -a com.hermes.wechat.DUMP_WECHAT_API [--es class "w11.s1"]' |
| 1462 | BroadcastReceiver dumpRecv = new BroadcastReceiver() { | 2931 | BroadcastReceiver dumpRecv = new BroadcastReceiver() { |