Commit 93b22c420763bd4a8cffcc3eea8a61e746ee3e6e

Authored by yibo
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>
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() {