Commit efb2542b24df8556486d564801d6656873f4d46c

Authored by yibo
1 parent d9fcf453

feat(wechathook): 设备伪装改运行时开关(默认关) + 安全切换脚本(不自动重启)

风控踢下线+实名验证的真正根因 = 登录状态下改设备指纹(不是平板登录本身)。实证:
火星人在线时,测开关 33 秒内 Pixel→SM-X700→Pixel 翻转两次+重启两次 → 指纹横跳被踢。
修正之前「平板登录固有高风险别做」的过重判断:登录后设备身份保持稳定即可持续。

- DEVICE_SPOOF 从编译期常量改为运行时 flag 文件 /data/data/com.tencent.mm/files/.wcspoof
  (默认不存在=OFF=干净 Pixel,最低风控风险)。4 个 pad 登录 hook 全由该 flag 门控。
- wechat_spoof_toggle.sh: 一键翻转 flag,【绝不自动重启微信】(早期自动 force-stop
  重启=登录态下身份突变=踢,已删)。改后由用户在没账号在线时手动重启微信生效,脚本大字警告。
- 部署到设备 ~/ + ~/.shortcuts/(Termux:Widget 桌面按钮)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
wechat_pad_login/wechat_spoof_toggle.sh 0 → 100644
  1 +#!/data/data/com.termux/files/usr/bin/bash
  2 +# wechat_spoof_toggle.sh — 切换微信「三星平板伪装」标志(平板登录用)。
  3 +# ⚠️ 安全版:只翻转标志,【不自动重启微信】。因为「登录状态下改设备身份」会被
  4 +# 微信风控踢下线+实名验证(吃过亏)。标志改完,由你在【安全时机】手动重启微信生效:
  5 +# - 想开启伪装去平板登录:先确保没账号在线(在登录页),再重启微信。
  6 +# - 想关闭伪装:先把平板那个副设备登出,再重启微信。
  7 +# 默认关闭最安全:日常微信是干净 Pixel,避开设备指纹矛盾触发的风控。
  8 +FLAG=/data/data/com.tencent.mm/files/.wcspoof
  9 +WX_UID=$(su -c "stat -c %U /data/user/0/com.tencent.mm" 2>/dev/null | tr -d '\r')
  10 +[ -z "$WX_UID" ] && { echo "❌ 取微信 uid 失败(微信没装/没权限)"; exit 1; }
  11 +
  12 +if su -c "[ -f $FLAG ]"; then
  13 + su -c "rm -f $FLAG"
  14 + echo "设备伪装标志 → 关闭 ✅(下次微信启动=干净 Pixel)"
  15 +else
  16 + su -c "touch $FLAG && chown $WX_UID:$WX_UID $FLAG && chmod 600 $FLAG; restorecon $FLAG 2>/dev/null"
  17 + echo "设备伪装标志 → 开启 📱(下次微信启动=三星 Galaxy Tab,可平板登录)"
  18 +fi
  19 +
  20 +echo ""
  21 +echo "⚠️ 标志已改,但【尚未生效】——微信没有重启。"
  22 +echo " 请在【没有账号正在登录 / 已把要切换的账号登出】时,手动关掉微信再打开。"
  23 +echo " ❌ 千万不要在账号登录状态下重启切换——设备身份突变会被风控踢下线+实名验证。"
  24 +if su -c "pidof com.tencent.mm" >/dev/null 2>&1; then
  25 + echo " (检测到微信正在运行;改动会在你下次手动重启微信后生效。)"
  26 +fi
... ...
xhs_lsposed_a16_fix/backups/WeChatHook_20260708_spoof-runtime-toggle.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_SEND_VOICE_CGI = "com.hermes.wechat.SEND_VOICE_CGI";
  25 + private static final String ACTION_ARM_SWAP = "com.hermes.wechat.ARM_VOICE_SWAP";
  26 + private static final String ACTION_DUMP_API = "com.hermes.wechat.DUMP_WECHAT_API";
  27 +
  28 + // ── Voice hot-swap state (the route that actually delivers) ────────────────
  29 + // Fully-synthetic voice (SEND_VOICE_INTERNAL) can build the message shell but
  30 + // can never upload the SILK to WeChat's CDN (upload is locked inside jg0.x's
  31 + // Kotlin coroutine). So instead we let the user trigger a REAL recording (hold
  32 + // "按住说话") and intercept jg0.x.w — the CDN uploadAttach step — replacing the
  33 + // recorded SILK bytes (w21.t.a) with our TTS SILK. WeChat's own machinery then
  34 + // uploads + delivers our audio. Armed by ARM_VOICE_SWAP; consumed once.
  35 + private static volatile boolean sSwapArmed = false;
  36 + private static volatile byte[] sSwapSilk = null; // TTS SILK bytes to inject
  37 + private static volatile int sSwapDuration = 0; // ms, replaces the recorded duration
  38 + // Safety: an arm auto-expires at this wall-clock time (set to arm + audio duration
  39 + // + 10s slack). The swap fires only on release AFTER a full hold, so the window must
  40 + // outlast the hold; past it, a stale arm silently dies instead of hijacking a later
  41 + // unrelated recording.
  42 + private static volatile long sSwapExpiresAt = 0;
  43 + // Cached f9 setter map (column -> methodName), built once on first voice send
  44 + // (sF9SetterMap is declared alongside probeAllF9Setters below)
  45 + // Thread-locals for voice send: voiceId and SILK bytes to inject into f9 ctor
  46 + private static final ThreadLocal<String> sPendingVoiceId = new ThreadLocal<>();
  47 + // Set only while sendVoiceViaCgi inserts its local f9 bubble: the cgi already
  48 + // uploaded+delivered, so force status=4 (sent) — unlike the hot-swap path which
  49 + // needs H() to set status after CDN upload.
  50 + private static final ThreadLocal<Boolean> sVoiceCgiInsert = new ThreadLocal<>();
  51 + private static final ThreadLocal<byte[]> sPendingLvBuffer = new ThreadLocal<>();
  52 + // Captures the f9 that called convertTo() (= the one written to DB); used to populate r0.i.
  53 + private static final ThreadLocal<Object> sDatabaseF9 = new ThreadLocal<>();
  54 + private static ClassLoader sWeChatCL; // pre-Tinker classloader (from handleLoadPackage)
  55 + private static volatile ClassLoader sTinkerCL; // Tinker-patched classloader (from App.onCreate)
  56 + private static volatile Context sAppContext;
  57 + private static volatile boolean sFsGReady = false;
  58 + // Set true while WE are calling newInstance so r0Hook can distinguish us from WeChat
  59 + private static volatile boolean sCalledByUs = false;
  60 + // Network dispatcher captured from a real WeChat r0 at H()-entry; needed by CDN upload
  61 + private static volatile Object sCapturedDispatcher = null;
  62 + private static volatile Object sPt0E0Instance = null; // pt0.e0 singleton, captured from r() hook
  63 + // Keeps synthetic-voice-cgi i/deferred objects strongly referenced (the deferred
  64 + // holds the CgiLifecycleObserver weakly, so without this GC kills the in-flight send).
  65 + private static final java.util.List<Object> sVoiceCgiKeepAlive = new java.util.concurrent.CopyOnWriteArrayList<>();
  66 + private static volatile Object sImgPlugin = null; // b80.u instance, captured from pj/Bi hook
  67 + private static volatile Object sRealImgParams = null; // last real o70.h, captured for replication
  68 + private static volatile Object sRealImgK = null; // last real o70.k (oj insert object)
  69 + private static final ThreadLocal<Long> sPendingMsgLocalId = new ThreadLocal<>(); // msgId from DB insert
  70 + // Logged-in user's wxid, auto-discovered from first real voice message send
  71 + private static volatile String sMyWxid = null;
  72 + // Pending send: stored when broadcast arrives before gm0.j1 is initialized
  73 + private static volatile String sPendingTalker = null;
  74 + private static volatile String sPendingText = null;
  75 + private static volatile String sPendingProc = null;
  76 +
  77 + @Override
  78 + public void handleLoadPackage(XC_LoadPackage.LoadPackageParam lpparam) throws Throwable {
  79 + if (!"com.tencent.mm".equals(lpparam.packageName)) return;
  80 + // Use android.util.Log directly so it appears even before VectorLegacyBridge is ready
  81 + android.util.Log.i("WeChatHookEarly", "INIT proc=" + lpparam.processName
  82 + + " pid=" + android.os.Process.myPid());
  83 + XposedBridge.log("[WeChatHook] Injected into WeChat " + lpparam.processName
  84 + + " pid=" + android.os.Process.myPid());
  85 + ClassLoader cl = lpparam.classLoader;
  86 + sWeChatCL = cl;
  87 +
  88 + // ── Pad login (以平板登录) — WORKING via device-model spoof (2026-07-07) ────
  89 + // CONFIRMED ROOT CAUSE: WeChat's tablet gate is a device-MODEL whitelist, NOT an
  90 + // account "体验资格" (beta trial) check. Spoofing Build to a Samsung Galaxy Tab
  91 + // (SM-X700) let the SAME old 小火子 account — the one we'd blamed for 体验资格
  92 + // expiry — log in as a secondary/pad device. So the earlier "体验资格 gate"
  93 + // conclusion was WRONG; the blocker was always the Pixel model failing bk.B/Q.
  94 + // 1) hookDeviceSpoof → Build + ro.product.* = Samsung tablet (passes bk.B/Q +
  95 + // the server secondary-device handshake).
  96 + // 2) hookInTabletEnv → bk.A/B → true (inTabletEnv), belt-and-suspenders.
  97 + // 3) hookLoginHistoryUIInitView/hookLoginUIInitView → force the "以平板登录"
  98 + // button (field N) VISIBLE on LoginPasswordUI/LoginHistoryUI.onCreate + m4 click.
  99 + // RUNTIME TOGGLE (default OFF = clean un-spoofed Pixel, lowest 风控/ban risk):
  100 + // all 4 pad-login hooks install ONLY when the flag file exists. Flip it with the
  101 + // spoof_toggle shortcut (touch/rm the flag + restart WeChat). Keeping spoof off
  102 + // normally avoids the device-fingerprint mismatch that trips WeChat risk control
  103 + // (forced logout + 实名验证). Turn on only when you actually need pad login.
  104 + // When pad login breaks later, Hermes first-responds via the on-device skill
  105 + // wechat-pad-login-repair (diagnose logs/screen → no-rebuild fixes → escalate
  106 + // Case B). Recipe: wechat_pad_login/LESSONS.md + HERMES_REPAIR_SKILL.md.
  107 + if (isDeviceSpoofEnabled()) {
  108 + XposedBridge.log("[WeChatHook] pad-spoof flag ON — installing device spoof + tablet-login hooks");
  109 + hookDeviceSpoof(cl, lpparam.processName);
  110 + hookInTabletEnv(cl);
  111 + hookLoginHistoryUIInitView(cl);
  112 + hookLoginUIInitView(cl);
  113 + } else {
  114 + XposedBridge.log("[WeChatHook] pad-spoof flag OFF — clean Pixel, no spoof/tablet hooks");
  115 + }
  116 + // hookProbe(cl);
  117 + // hookLoginSelectUICheck(cl);
  118 + // hookLoginButtonClick(cl);
  119 +
  120 + // WCDB diagnostic: which process writes? + media ContentValues dump
  121 + hookWCDB(cl, lpparam.processName);
  122 +
  123 + // Factory method discovery: dumps w11.s1 signatures + hooks all static methods
  124 + // so that when WeChat UI sends image/voice we capture the exact API call.
  125 + hookFactoryMethods(cl, lpparam.processName);
  126 +
  127 + // MediaPlayer hook: captures voice file paths when WeChat plays voice messages.
  128 + // This reveals the voice2/{dir1}/{dir2}/msg_{id}.amr naming formula.
  129 + hookMediaPlayer(lpparam.processName);
  130 +
  131 + // Hook gm0.j1.g() to detect when DI kernel is ready — runs in ALL processes.
  132 + // This enables the pending-send mechanism: if broadcast arrives before DI init,
  133 + // we store the args and retry once g() fires.
  134 + hookDIInit(cl, lpparam.processName);
  135 +
  136 + // Send hook in ALL WeChat processes — we need to find which one can actually send.
  137 + // The broadcast receiver registered in each process will all receive the intent;
  138 + // whichever process has gm0.j1 initialized will succeed.
  139 + hookSendMessage(cl, lpparam.processName);
  140 +
  141 + // Hook w11.r0.H(String) to trace CDN upload: log r0 String fields + file access.
  142 + hookR0H(cl, lpparam.processName);
  143 +
  144 + // Capture the live network dispatcher com.tencent.mm.modelbase.m2 (MicroMsg.RDispatcher)
  145 + // at construction. Synthetic voice sends need the REAL m2 to actually reach the
  146 + // network; without it we'd fall back to a fake $Proxy that never sends.
  147 + hookDispatcher(cl, lpparam.processName);
  148 +
  149 + // Instrument the voice-upload service w21.g1 (voiceinfo table): on a REAL voice
  150 + // send, dump the exact w21.w0 upload-task fields so we can replicate them for
  151 + // synthetic voice (the last missing step = CDN audio upload).
  152 + hookVoiceUpload(cl, lpparam.processName);
  153 +
  154 + // Image send: hook the non-suspend plugin methods b80.u.pj/Bi (o70.h) — the
  155 + // actual image-send entry the coroutine calls. Capture the b80.u instance +
  156 + // dump the full o70.h a REAL send builds, so we can replicate it directly.
  157 + hookImageSend(cl, lpparam.processName);
  158 + // Capture the SendImgProxyUI launch Intent (real gallery send goes through it),
  159 + // so we can replay the whole real send flow via `am start`.
  160 + hookSendImgProxyUI(cl, lpparam.processName);
  161 +
  162 + // Hook pt0.e0 — the voice-send coroutine class found in the stack trace.
  163 + // Real voice sends don't use r0.H(); we need to find where CDN upload actually fires.
  164 + hookPt0E0(cl, lpparam.processName);
  165 +
  166 + // Surgical voice-send entry probe (jg0.x + qi3.f0/g0 only). On this fresh
  167 + // reinstall the send runs in the base CL (no Tinker patch yet), so hook here.
  168 + hookVoiceProbe(cl, lpparam.processName);
  169 +
  170 + // Voice hot-swap: intercept jg0.x.w (CDN uploadAttach) to replace the recorded
  171 + // SILK with our TTS audio during a real (user- or A11y-triggered) recording.
  172 + // This is the ONE route that actually delivers synthetic voice.
  173 + hookVoiceSwap(cl, lpparam.processName);
  174 +
  175 + // Trace the CDN upload submission (dn.m task + assigned aeskey/fileId) on a real
  176 + // voice send — the layer full-synthesis was missing (addSendTask / CdnManager).
  177 + hookCdnTrace(cl, lpparam.processName);
  178 + // Capture the real uploadvoice cgi request (this device uses inline-cgi, not CDN).
  179 + hookVoiceCgiCapture(cl, lpparam.processName);
  180 + hookVoiceCgiResult(cl, lpparam.processName);
  181 + }
  182 +
  183 + // ── WCDB hook ──────────────────────────────────────────────────────────────
  184 + // Intercepts every INSERT into the message table.
  185 + // For msgtype 3 (image) and 34 (voice) logs the full ContentValues so we can
  186 + // reverse-engineer the exact schema WeChat uses when building those messages.
  187 + private void hookWCDB(ClassLoader cl, String procName) {
  188 + try {
  189 + XposedHelpers.findAndHookMethod(
  190 + "com.tencent.wcdb.database.SQLiteDatabase", cl,
  191 + "insertWithOnConflict",
  192 + String.class, String.class, android.content.ContentValues.class, int.class,
  193 + new XC_MethodHook() {
  194 + private boolean textLogged = false;
  195 + @Override
  196 + protected void beforeHookedMethod(MethodHookParam param) throws Throwable {
  197 + String table = (String) param.args[0];
  198 + if (table == null) return;
  199 + android.content.ContentValues cv =
  200 + (android.content.ContentValues) param.args[2];
  201 +
  202 + // Determine msgtype from ContentValues (key may be "type" or "msgtype")
  203 + Object rawType = null;
  204 + if (cv != null) {
  205 + rawType = cv.get("type");
  206 + if (rawType == null) rawType = cv.get("msgtype");
  207 + if (rawType == null) rawType = cv.get("msgType");
  208 + }
  209 + int msgtype = -1;
  210 + try { if (rawType != null) msgtype = Integer.parseInt(rawType.toString()); }
  211 + catch (NumberFormatException ignored) {}
  212 +
  213 + if (msgtype == 3 || msgtype == 34) {
  214 + // Image (3) or Voice (34): dump full ContentValues + stack
  215 + String cvStr = cv != null ? cv.toString() : "null";
  216 + String stack = android.util.Log.getStackTraceString(new Throwable());
  217 + if (stack.length() > 4000) stack = stack.substring(0, 4000) + "…";
  218 + XposedBridge.log("[WeChatHook] WCDB MEDIA INSERT table=" + table
  219 + + " msgtype=" + msgtype
  220 + + " proc=" + procName
  221 + + "\nContentValues=" + cvStr
  222 + + "\nStack=" + stack);
  223 + } else if (msgtype == 1) {
  224 + // Text: only log once (to confirm which process writes)
  225 + if (!textLogged) {
  226 + textLogged = true;
  227 + String stack = android.util.Log.getStackTraceString(new Throwable());
  228 + if (stack.length() > 3000) stack = stack.substring(0, 3000) + "…";
  229 + XposedBridge.log("[WeChatHook] WCDB TEXT INSERT table=" + table
  230 + + " proc=" + procName + "\n" + stack);
  231 + } else {
  232 + XposedBridge.log("[WeChatHook] WCDB TEXT table=" + table
  233 + + " proc=" + procName);
  234 + }
  235 + }
  236 + }
  237 + });
  238 + XposedBridge.log("[WeChatHook] WCDB hook OK in " + procName);
  239 + } catch (Throwable t) {
  240 + XposedBridge.log("[WeChatHook] WCDB hook failed in " + procName + ": " + t);
  241 + }
  242 + }
  243 +
  244 + // ── MediaPlayer hook: reveals voice file path format ─────────────────────
  245 + // When WeChat plays a voice message it calls MediaPlayer.setDataSource(path).
  246 + // We log any path under voice2/ to see the full path including subdirectory,
  247 + // which reveals the formula WeChat uses to map voice file IDs to directories.
  248 + private void hookMediaPlayer(String procName) {
  249 + try {
  250 + XposedHelpers.findAndHookMethod("android.media.MediaPlayer", null,
  251 + "setDataSource", String.class, new XC_MethodHook() {
  252 + @Override
  253 + protected void beforeHookedMethod(MethodHookParam p) throws Throwable {
  254 + String path = (String) p.args[0];
  255 + if (path != null && path.contains("voice2")) {
  256 + XposedBridge.log("[WeChatHook] MediaPlayer.setDataSource VOICE="
  257 + + path + " proc=" + procName);
  258 + }
  259 + }
  260 + });
  261 + XposedBridge.log("[WeChatHook] MediaPlayer hook OK in " + procName);
  262 + } catch (Throwable t) {
  263 + XposedBridge.log("[WeChatHook] MediaPlayer hook failed in " + procName + ": " + t);
  264 + }
  265 + }
  266 +
  267 + // ── f9.convertTo() hook: maps f9 fields to DB columns ─────────────────────
  268 + // Captures ContentValues produced by convertTo() for image (3) and voice (34).
  269 + // From these ContentValues we know which f9 setter corresponds to which DB column
  270 + // (imgPath, content, talker, etc.) so we can construct f9 programmatically.
  271 + private void hookF9ConvertTo(ClassLoader cl, String procName) {
  272 + try {
  273 + XposedHelpers.findAndHookMethod("com.tencent.mm.storage.f9", cl,
  274 + "convertTo", new XC_MethodHook() {
  275 + @Override
  276 + protected void afterHookedMethod(MethodHookParam p) throws Throwable {
  277 + if (p.hasThrowable()) return;
  278 + android.content.ContentValues cv =
  279 + (android.content.ContentValues) p.getResult();
  280 + if (cv == null) return;
  281 + Object rawType = cv.get("type");
  282 + if (rawType == null) rawType = cv.get("msgtype");
  283 + if (rawType == null) return;
  284 + int msgtype = -1;
  285 + try { msgtype = Integer.parseInt(rawType.toString()); } catch (Throwable e) {}
  286 + if (msgtype == 3 && !sCalledByUs) {
  287 + // Dump full call stack once for a real IMAGE send so we can find
  288 + // the top-level app entry point to replicate for API image send.
  289 + XposedBridge.log("[WeChatHook] convertTo IMAGE STACK (real send):\n"
  290 + + android.util.Log.getStackTraceString(new Exception()));
  291 + }
  292 + if (msgtype == 34) {
  293 + // Dump call stack once for real sends to find the CDN upload caller.
  294 + if (!sCalledByUs) {
  295 + XposedBridge.log("[WeChatHook] convertTo STACK (real send):\n"
  296 + + android.util.Log.getStackTraceString(new Exception()));
  297 + }
  298 + // Auto-discover logged-in user's wxid from real UI voice sends.
  299 + // We look for isSend=1 messages (user's own sends) and extract
  300 + // fromusername from the voicemsg XML, cache as sMyWxid.
  301 + if (sMyWxid == null && !sCalledByUs) {
  302 + Object isSend = cv.get("isSend");
  303 + Object contentVal = cv.get("content");
  304 + if (isSend instanceof Integer && ((Integer)isSend) == 1
  305 + && contentVal instanceof String) {
  306 + String c = (String) contentVal;
  307 + int fi = c.indexOf("fromusername=\"");
  308 + if (fi >= 0) {
  309 + int start = fi + "fromusername=\"".length();
  310 + int end = c.indexOf("\"", start);
  311 + if (end > start) {
  312 + String wxid = c.substring(start, end);
  313 + if (!wxid.isEmpty()) {
  314 + sMyWxid = wxid;
  315 + XposedBridge.log("[WeChatHook] auto-discovered sMyWxid=" + wxid);
  316 + }
  317 + }
  318 + }
  319 + }
  320 + }
  321 + }
  322 + if (msgtype == 3 || msgtype == 34) {
  323 + // Inject lvbuffer (SILK bytes) if we're sending voice via hook.
  324 + // sPendingLvBuffer is still set here — convertTo fires INSIDE
  325 + // the r0 constructor which runs before our finally-block clears it.
  326 + byte[] pendingLv = sPendingLvBuffer.get();
  327 + if (pendingLv != null && !(cv.get("lvbuffer") instanceof byte[])) {
  328 + cv.put("lvbuffer", pendingLv);
  329 + XposedBridge.log("[WeChatHook] convertTo: injected lvbuffer " + pendingLv.length + " bytes");
  330 + }
  331 + // Capture the DB-bound f9 (the one convertTo() runs on).
  332 + // This is the correct object for r0.i: it has content + msgId set.
  333 + if (pendingLv != null) {
  334 + sDatabaseF9.set(p.thisObject);
  335 + // Capture msgLocalId for pt0.e0.s() CDN trigger
  336 + try {
  337 + Object msgIdObj = cv.get("msgId");
  338 + if (msgIdObj != null) {
  339 + long mid = Long.parseLong(msgIdObj.toString());
  340 + sPendingMsgLocalId.set(mid);
  341 + XposedBridge.log("[WeChatHook] convertTo: captured msgLocalId=" + mid);
  342 + }
  343 + } catch (Throwable ignored) {}
  344 + // Hot-swap path: do NOT force status=4 (H() would skip CDN
  345 + // upload if the msg already looks delivered). But the cgi
  346 + // path (sendVoiceViaCgi) already uploaded+delivered via the
  347 + // uploadvoice cgi, so force status=4 (sent) — else the sender
  348 + // bubble spins "正在发送中" forever.
  349 + if (Boolean.TRUE.equals(sVoiceCgiInsert.get())) {
  350 + cv.put("status", 4);
  351 + XposedBridge.log("[WeChatHook] convertTo: cgi insert — forced status=4 (sent)");
  352 + }
  353 + // Ensure flag and msgSvrId are present (missing from our sends).
  354 + if (cv.get("flag") == null) cv.put("flag", 0);
  355 + if (cv.get("msgSvrId") == null) cv.put("msgSvrId", 0L);
  356 + XposedBridge.log("[WeChatHook] convertTo: initial INSERT");
  357 + }
  358 + // Log full ContentValues including lvbuffer details
  359 + StringBuilder sb = new StringBuilder();
  360 + sb.append("[WeChatHook] f9.convertTo() msgtype=").append(msgtype)
  361 + .append(" proc=").append(procName).append("\nContentValues=").append(cv);
  362 + Object lvbuf = cv.get("lvbuffer");
  363 + if (lvbuf instanceof byte[]) {
  364 + byte[] b = (byte[]) lvbuf;
  365 + StringBuilder hex = new StringBuilder();
  366 + int show = Math.min(b.length, 32);
  367 + for (int i = 0; i < show; i++)
  368 + hex.append(String.format("%02x", b[i] & 0xff));
  369 + sb.append("\nlvbuffer len=").append(b.length).append(" hex=").append(hex);
  370 + } else {
  371 + sb.append("\nlvbuffer=null");
  372 + }
  373 + XposedBridge.log(sb.toString());
  374 + }
  375 + }
  376 + });
  377 + XposedBridge.log("[WeChatHook] f9.convertTo hook OK in " + procName);
  378 + } catch (Throwable t) {
  379 + XposedBridge.log("[WeChatHook] f9.convertTo hook failed in " + procName + ": " + t);
  380 + }
  381 + }
  382 +
  383 + // ── f9 constructor imgPath injector ───────────────────────────────────────
  384 + // Hooks ALL f9 constructors. When sPendingVoiceId is set on the calling thread,
  385 + // immediately calls the discovered imgPath setter on the newly created f9 object.
  386 + // This fires INSIDE the r0 constructor (before its internal f9.convertTo() call),
  387 + // so the DB insert from r0 constructor already has the correct voice file ID.
  388 + private void hookF9ImgPathInjector(ClassLoader cl, String procName) {
  389 + try {
  390 + Class<?> f9Class = XposedHelpers.findClass("com.tencent.mm.storage.f9", cl);
  391 + XC_MethodHook injHook = new XC_MethodHook() {
  392 + @Override
  393 + protected void afterHookedMethod(MethodHookParam param) throws Throwable {
  394 + String pending = sPendingVoiceId.get();
  395 + if (pending == null) return;
  396 + java.util.Map<String, String> sm = sF9SetterMap;
  397 + if (sm == null) return;
  398 + // Inject imgPath
  399 + String setter = sm.get("imgPath");
  400 + if (setter != null) {
  401 + XposedHelpers.callMethod(param.thisObject, setter, pending);
  402 + XposedBridge.log("[WeChatHook] f9 ctor injected imgPath=" + pending + " via " + setter);
  403 + }
  404 + // Inject lvbuffer (SILK bytes) so WeChat treats the voice as locally available
  405 + byte[] lvbuf = sPendingLvBuffer.get();
  406 + String lvSetter = sm.get("lvbuffer:bytes");
  407 + if (lvbuf != null && lvSetter != null) {
  408 + XposedHelpers.callMethod(param.thisObject, lvSetter, (Object) lvbuf);
  409 + XposedBridge.log("[WeChatHook] f9 ctor injected lvbuffer len=" + lvbuf.length + " via " + lvSetter);
  410 + } else {
  411 + XposedBridge.log("[WeChatHook] f9 ctor lvbuffer skip: lvbuf=" + (lvbuf==null?"null":"ok") + " setter=" + lvSetter);
  412 + }
  413 + // sDatabaseF9 is captured in convertTo() — that's the correct DB-bound f9.
  414 + }
  415 + };
  416 + int hooked = 0;
  417 + for (java.lang.reflect.Constructor<?> ctor : f9Class.getDeclaredConstructors()) {
  418 + XposedBridge.hookMethod(ctor, injHook);
  419 + hooked++;
  420 + }
  421 + XposedBridge.log("[WeChatHook] f9 ctor injector OK in " + procName + " (" + hooked + " ctors)");
  422 + } catch (Throwable t) {
  423 + XposedBridge.log("[WeChatHook] f9 ctor injector failed in " + procName + ": " + t);
  424 + }
  425 + }
  426 +
  427 + // ── Image send via internal API ────────────────────────────────────────────
  428 + // Calls the gallery's own static image-send helper:
  429 + // j33.d0.k(long imgId, String path, String talker, boolean, int, boolean,
  430 + // GalleryItem$MediaItem)
  431 + // This is exactly what the album picker calls (traced via a real image send's
  432 + // convertTo stack → b80.u.oj → b80.s coroutine → built from o70.h; d0.k is the
  433 + // top-level builder). It builds o70.h (PreBuildParams) and launches the send
  434 + // coroutine internally (thumbnail gen + CDN upload + DB insert), fire-and-forget.
  435 + // The old w11.s1.b(IMAGE) path built an incomplete r1 → n1.a() NPE; abandoned.
  436 + private static void sendImageViaAPI(String talker, String imagePath) throws Throwable {
  437 + ClassLoader cl = sTinkerCL != null ? sTinkerCL : sWeChatCL;
  438 + if (cl == null) throw new IllegalStateException("classloader null");
  439 + // Replicate the REAL image send captured by hookImageSend: build the exact
  440 + // o70.h (PreBuildParams) that b80.u.pj() receives on a genuine send, then call
  441 + // b80.u.pj(o70.h) directly (non-suspend), skipping the whole gallery/coroutine UI.
  442 + //
  443 + // Captured real o70.h (fields are single letters a–l):
  444 + // a=r6 preBuildFolder, e=imgPath, g=toUserName(talker), c=source(0), d=0,
  445 + // f=imgId(0 ok), h=compressType(0), k=Uri(null for pj), b=UUID(auto)
  446 + //
  447 + // r6 folder = lp0.b.h0(gm0.j1.b().j() + "_MsgImgUploadFSC") (= g80.o.c7()).
  448 + // plugin b80.u = i95.n0.c(o70.e.class) (fallback to hook-captured instance).
  449 + // b80.u image plugin = i95.n0.c(o70.e.class) (fallback to hook-captured instance).
  450 + Object imgPlugin = sImgPlugin;
  451 + if (imgPlugin == null) {
  452 + Class<?> n0 = XposedHelpers.findClass("i95.n0", cl);
  453 + Class<?> o70e = XposedHelpers.findClass("o70.e", cl);
  454 + imgPlugin = XposedHelpers.callStaticMethod(n0, "c", o70e);
  455 + }
  456 + if (imgPlugin == null) throw new IllegalStateException("b80.u image plugin null");
  457 +
  458 + // r6 pre-build folder = lp0.b.h0(gm0.j1.b().j() + "_MsgImgUploadFSC") (= g80.o.c7()).
  459 + Object m = XposedHelpers.callStaticMethod(XposedHelpers.findClass("gm0.j1", cl), "b");
  460 + Object prefix = XposedHelpers.callMethod(m, "j");
  461 + Class<?> lp0b = XposedHelpers.findClass("lp0.b", cl);
  462 + Object r6 = XposedHelpers.callStaticMethod(lp0b, "h0", prefix + "_MsgImgUploadFSC");
  463 +
  464 + // Build o70.k (the batch send object) — fields captured from a real oj(o70.k):
  465 + // a=r6, b=UUID(auto), c=talker, d=source, f=compressType, g=List<o70.i>
  466 + Class<?> o70k = XposedHelpers.findClass("o70.k", cl);
  467 + Object k = XposedHelpers.newInstance(o70k, r6);
  468 + XposedHelpers.setObjectField(k, "c", talker); // toUserName
  469 + // source (field d) — real send used 3; set via reflection (int field).
  470 + try { java.lang.reflect.Field df = o70k.getDeclaredField("d"); df.setAccessible(true); df.setInt(k, 3); } catch (Throwable ignored) {}
  471 + // o70.i(imgPath, Uri, Long imgId) — give it a file:// Uri so the CDN upload has
  472 + // a readable source (real sends pass a MediaStore content:// Uri).
  473 + Class<?> o70i = XposedHelpers.findClass("o70.i", cl);
  474 + android.net.Uri fileUri = android.net.Uri.fromFile(new java.io.File(imagePath));
  475 + Object item = XposedHelpers.newInstance(o70i,
  476 + new Class<?>[]{String.class, android.net.Uri.class, Long.class},
  477 + imagePath, fileUri, Long.valueOf(0L));
  478 + java.util.List<Object> list = new java.util.ArrayList<>();
  479 + list.add(item);
  480 + XposedHelpers.setObjectField(k, "g", list);
  481 + String clientId = (String) XposedHelpers.getObjectField(k, "b");
  482 +
  483 + // Replicate b80.s coroutine's per-item prebuild loop: build o70.h from k+item,
  484 + // then pj (stage compressed image + thumbnail into r6).
  485 + Class<?> o70h = XposedHelpers.findClass("o70.h", cl);
  486 + Object h = XposedHelpers.newInstance(o70h, r6);
  487 + XposedHelpers.callMethod(h, "b", clientId); // clientId (field b)
  488 + XposedHelpers.setObjectField(h, "e", imagePath); // imgPath
  489 + XposedHelpers.setObjectField(h, "g", talker); // toUserName
  490 + XposedHelpers.setObjectField(h, "k", fileUri); // Uri (source for staging/upload)
  491 + try { java.lang.reflect.Field hc = o70h.getDeclaredField("c"); hc.setAccessible(true); hc.setInt(h, 3); } catch (Throwable ignored) {} // source
  492 + // d(rotate)/h(compressType) default 0. f(imgId) 0.
  493 + XposedBridge.log("[WeChatHook] sendImageViaAPI: built o70.k+o70.h e=" + imagePath + " g=" + talker
  494 + + " plugin=" + imgPlugin.getClass().getName());
  495 +
  496 + sCalledByUs = true;
  497 + try {
  498 + if (!(Boolean) XposedHelpers.callMethod(imgPlugin, "Bi", h, "thumb.jpg")) {
  499 + XposedHelpers.callMethod(imgPlugin, "pj", h); // prebuild (stage)
  500 + try { XposedHelpers.callMethod(imgPlugin, "nj", h, 10000); } catch (Throwable ignored) {}
  501 + }
  502 + java.util.ArrayList<?> res = (java.util.ArrayList<?>)
  503 + XposedHelpers.callMethod(imgPlugin, "oj", k); // insert + build descriptors
  504 + XposedBridge.log("[WeChatHook] sendImageViaAPI: oj() DONE to=" + talker
  505 + + " res=" + (res == null ? "null" : "size=" + res.size()));
  506 +
  507 + // ── Last mile: submit each o70.l descriptor to the CDN upload service ──
  508 + // oj() only inserts the msg (f9) + ImgInfo (b0) and RETURNS List<o70.l>
  509 + // upload descriptors — it does NOT start the CDN upload. The real flow
  510 + // (pe5.f.a) loops the list and, per item, builds r70.g + k70.i0 crossParams
  511 + // and calls the d1 img-send service's oj(r70.g, o70.l), which launches the
  512 + // CDN upload coroutine (e80.c). Without this, the full image never uploads
  513 + // (thumbnail renders but the send spins forever). jadx field names f30xxxN
  514 + // map to the real single-letter dex field N (verified: o70.k "d"=source).
  515 + // v70.w.oj builds a ViewModel via ViewModelProvider.get → must run on the
  516 + // main thread; the broadcast receiver already dispatches us there.
  517 + if (res != null && !res.isEmpty()) {
  518 + Class<?> n0 = XposedHelpers.findClass("i95.n0", cl);
  519 + Object cdnSvc = XposedHelpers.callStaticMethod(
  520 + n0, "c", XposedHelpers.findClass("xs.d1", cl)); // → v70.w (MsgImgFeatureService)
  521 + String selfWxid = null;
  522 + try { selfWxid = (String) XposedHelpers.callStaticMethod(
  523 + XposedHelpers.findClass("c01.z1", cl), "r"); } catch (Throwable ignored) {}
  524 + if (selfWxid == null) selfWxid = ""; // API accepts "" (pe5.f.a does the same)
  525 + Class<?> i0cls = XposedHelpers.findClass("k70.i0", cl);
  526 + Class<?> r70g = XposedHelpers.findClass("r70.g", cl);
  527 + int compressType = 0;
  528 + try { java.lang.reflect.Field cf = o70k.getDeclaredField("f"); cf.setAccessible(true); compressType = cf.getInt(k); } catch (Throwable ignored) {}
  529 + int submitted = 0;
  530 + for (Object l : res) {
  531 + // crossParams: only source (field a) matters for a plain photo;
  532 + // mirror quote(t)/long(u) from the descriptor (usually null/0).
  533 + Object crossParams = XposedHelpers.newInstance(i0cls);
  534 + try { java.lang.reflect.Field af = i0cls.getDeclaredField("a"); af.setAccessible(true); af.setInt(crossParams, 3); } catch (Throwable ignored) {}
  535 + try { XposedHelpers.setObjectField(crossParams, "t", XposedHelpers.getObjectField(l, "s")); } catch (Throwable ignored) {}
  536 + try {
  537 + java.lang.reflect.Field lr = l.getClass().getDeclaredField("r"); lr.setAccessible(true);
  538 + java.lang.reflect.Field iu = i0cls.getDeclaredField("u"); iu.setAccessible(true);
  539 + iu.setLong(crossParams, lr.getLong(l));
  540 + } catch (Throwable ignored) {}
  541 + String builtPath = (String) XposedHelpers.getObjectField(l, "e"); // pre-built compressed path
  542 + Object gVar = XposedHelpers.newInstance(r70g,
  543 + new Class<?>[]{String.class, int.class, String.class, String.class, i0cls},
  544 + builtPath, compressType, selfWxid, talker, crossParams);
  545 + XposedHelpers.callMethod(cdnSvc, "oj", gVar, l); // → e80.c CDN upload coroutine
  546 + submitted++;
  547 + }
  548 + XposedBridge.log("[WeChatHook] sendImageViaAPI: CDN submit DONE items=" + submitted
  549 + + " self=" + selfWxid + " compress=" + compressType);
  550 + }
  551 + } finally { sCalledByUs = false; }
  552 + }
  553 +
  554 + // ── Probe all f9 setter→column mappings (String + int, one-shot) ──────────
  555 + // Calls each String setter and each void(int) method with a unique marker,
  556 + // then calls convertTo() to see which ContentValues key reflects the marker.
  557 + // Returns a Map<columnName, setterMethodName>.
  558 + private static volatile java.util.Map<String, String> sF9SetterMap = null;
  559 +
  560 + private static java.util.Map<String, String> probeAllF9Setters(Class<?> f9Class) {
  561 + java.util.Map<String, String> result = new java.util.HashMap<>();
  562 +
  563 + // String setters (known from class dump)
  564 + for (String sn : new String[]{"Z0", "C1", "D1", "a1", "d1", "h1", "i1"}) {
  565 + try {
  566 + Object probe = XposedHelpers.newInstance(f9Class);
  567 + XposedHelpers.callMethod(probe, sn, "PROBE_" + sn);
  568 + android.content.ContentValues cv = (android.content.ContentValues)
  569 + XposedHelpers.callMethod(probe, "convertTo");
  570 + for (String key : cv.keySet()) {
  571 + Object val = cv.get(key);
  572 + if (("PROBE_" + sn).equals(val != null ? val.toString() : "")) {
  573 + result.put(key, sn);
  574 + XposedBridge.log("[WeChatHook] f9 str setter " + sn + " -> " + key);
  575 + }
  576 + }
  577 + } catch (Throwable t) {
  578 + XposedBridge.log("[WeChatHook] f9 probe str " + sn + ": " + t);
  579 + }
  580 + }
  581 +
  582 + // Int setters: scan all void(int) methods
  583 + try {
  584 + for (java.lang.reflect.Method m : f9Class.getDeclaredMethods()) {
  585 + if (m.getReturnType() != void.class) continue;
  586 + java.lang.Class<?>[] pt = m.getParameterTypes();
  587 + if (pt.length != 1 || pt[0] != int.class) continue;
  588 + m.setAccessible(true);
  589 + try {
  590 + Object probe = XposedHelpers.newInstance(f9Class);
  591 + m.invoke(probe, 88888);
  592 + android.content.ContentValues cv = (android.content.ContentValues)
  593 + XposedHelpers.callMethod(probe, "convertTo");
  594 + for (String key : cv.keySet()) {
  595 + Object val = cv.get(key);
  596 + if (val instanceof Integer && (Integer) val == 88888) {
  597 + result.put(key, m.getName());
  598 + XposedBridge.log("[WeChatHook] f9 int setter " + m.getName() + " -> " + key);
  599 + }
  600 + }
  601 + } catch (Throwable ignored) {}
  602 + }
  603 + } catch (Throwable t) {
  604 + XposedBridge.log("[WeChatHook] f9 int probe scan failed: " + t);
  605 + }
  606 +
  607 + // byte[] setters: scan all void(byte[]) methods — discovers lvbuffer setter
  608 + try {
  609 + for (java.lang.reflect.Method m : f9Class.getDeclaredMethods()) {
  610 + if (m.getReturnType() != void.class) continue;
  611 + java.lang.Class<?>[] pt = m.getParameterTypes();
  612 + if (pt.length != 1 || pt[0] != byte[].class) continue;
  613 + m.setAccessible(true);
  614 + // Marker: a 4-byte sentinel we can recognize in cv
  615 + byte[] marker = new byte[]{(byte)0xDE, (byte)0xAD, (byte)0xBE, (byte)0xEF};
  616 + try {
  617 + Object probe = XposedHelpers.newInstance(f9Class);
  618 + m.invoke(probe, (Object) marker);
  619 + android.content.ContentValues cv = (android.content.ContentValues)
  620 + XposedHelpers.callMethod(probe, "convertTo");
  621 + for (String key : cv.keySet()) {
  622 + Object val = cv.get(key);
  623 + if (val instanceof byte[]) {
  624 + byte[] got = (byte[]) val;
  625 + if (got.length == 4 && got[0] == (byte)0xDE && got[1] == (byte)0xAD) {
  626 + result.put(key + ":bytes", m.getName());
  627 + XposedBridge.log("[WeChatHook] f9 bytes setter " + m.getName() + " -> " + key);
  628 + }
  629 + }
  630 + }
  631 + } catch (Throwable ignored) {}
  632 + }
  633 + } catch (Throwable t) {
  634 + XposedBridge.log("[WeChatHook] f9 bytes probe scan failed: " + t);
  635 + }
  636 +
  637 + XposedBridge.log("[WeChatHook] f9 setter map: " + result);
  638 + return result;
  639 + }
  640 +
  641 + // ── Hook w11.r0.H(String) — CDN upload entry-point trace ─────────────────
  642 + // Logs every r0.H() call: dumps all String+File fields on r0 so we can identify
  643 + // which field contains the audio file path that H() uploads to CDN.
  644 + // Hook ALL methods on pt0.e0 (voice-send coroutine class found in stack trace).
  645 + // Goal: find which method triggers CDN upload AFTER f9.convertTo() returns.
  646 + // pt0.e0.k returns f9 objects and fires in multiple threads — CDN upload entry point.
  647 + private void hookPt0E0(ClassLoader cl, String procName) {
  648 + try {
  649 + Class<?> cls = XposedHelpers.findClass("pt0.e0", cl);
  650 + java.lang.reflect.Method[] methods = cls.getDeclaredMethods();
  651 + StringBuilder sig = new StringBuilder("[WeChatHook] pt0.e0 methods(" + methods.length + "):");
  652 + for (java.lang.reflect.Method m : methods) sig.append(" ").append(m.getName()).append("(").append(m.getParameterTypes().length).append(")");
  653 + XposedBridge.log(sig.toString());
  654 + for (java.lang.reflect.Method m : methods) {
  655 + final String mName = m.getName();
  656 + m.setAccessible(true);
  657 + XposedBridge.hookMethod(m, new XC_MethodHook() {
  658 + @Override protected void beforeHookedMethod(MethodHookParam p) {
  659 + // Capture singleton from ANY method (not just r()), so it's
  660 + // available even when no real voice send has happened this session.
  661 + if (sPt0E0Instance == null && p.thisObject != null) {
  662 + sPt0E0Instance = p.thisObject;
  663 + XposedBridge.log("[WeChatHook] pt0.e0 singleton captured via " + mName + ": " + p.thisObject);
  664 + }
  665 + if ("k".equals(mName)) {
  666 + // k(2): confirmed signature k(String talker, Long msgLocalId)
  667 + // CDN worker calls this to dequeue next upload task.
  668 + // Only log first call per send to avoid spam.
  669 + Object a0 = p.args != null && p.args.length > 0 ? p.args[0] : null;
  670 + Object a1 = p.args != null && p.args.length > 1 ? p.args[1] : null;
  671 + XposedBridge.log("[WeChatHook] pt0.e0.k ENTER talker=" + a0
  672 + + " msgLocalId=" + (a1 instanceof Long ? ((Long)a1).longValue() : a1));
  673 + } else if ("r".equals(mName)) {
  674 + // Save pt0.e0 singleton for later use in sendVoiceViaAPI.
  675 + if (sPt0E0Instance == null && p.thisObject != null) {
  676 + sPt0E0Instance = p.thisObject;
  677 + XposedBridge.log("[WeChatHook] pt0.e0 singleton captured: " + p.thisObject);
  678 + }
  679 + XposedBridge.log("[WeChatHook] pt0.e0.r ENTER, dumping thisObject fields:");
  680 + if (p.thisObject != null) {
  681 + Class<?> c = p.thisObject.getClass();
  682 + while (c != null && !c.equals(Object.class)) {
  683 + for (java.lang.reflect.Field f : c.getDeclaredFields()) {
  684 + f.setAccessible(true);
  685 + try {
  686 + Object v = f.get(p.thisObject);
  687 + XposedBridge.log("[WeChatHook] " + c.getSimpleName()
  688 + + "." + f.getName() + "=" + (v == null ? "null"
  689 + : (v instanceof String || v instanceof Number || v instanceof Boolean)
  690 + ? v.toString() : v.getClass().getName() + "@" + System.identityHashCode(v)));
  691 + } catch (Throwable ignored) {}
  692 + }
  693 + c = c.getSuperclass();
  694 + }
  695 + }
  696 + } else if ("s".equals(mName)) {
  697 + // s(3): CDN upload trigger. Log args + full f9 hierarchy for diff analysis.
  698 + StringBuilder sb = new StringBuilder("[WeChatHook] pt0.e0.s ENTER args:");
  699 + if (p.args != null) for (int i = 0; i < p.args.length; i++) {
  700 + Object a = p.args[i];
  701 + if (a == null) { sb.append(" [").append(i).append("]=null"); }
  702 + else if (a instanceof String) { sb.append(" [").append(i).append("]=String(\"").append(a).append("\")"); }
  703 + else if (a instanceof Number) { sb.append(" [").append(i).append("]=").append(a); }
  704 + else { sb.append(" [").append(i).append("]=").append(a.getClass().getName()).append("@").append(System.identityHashCode(a)); }
  705 + }
  706 + XposedBridge.log(sb.toString());
  707 + // Dump ALL fields of f9 (args[2]) across full parent hierarchy
  708 + if (p.args != null && p.args.length > 2 && p.args[2] != null) {
  709 + Object f9 = p.args[2];
  710 + StringBuilder fb = new StringBuilder("[WeChatHook] pt0.e0.s f9 DUMP:");
  711 + Class<?> c = f9.getClass();
  712 + while (c != null && !c.equals(Object.class)) {
  713 + for (java.lang.reflect.Field f : c.getDeclaredFields()) {
  714 + f.setAccessible(true);
  715 + try {
  716 + Object v = f.get(f9);
  717 + String vs;
  718 + if (v == null) vs = "null";
  719 + else if (v instanceof byte[]) vs = "byte[" + ((byte[])v).length + "]";
  720 + else if (v instanceof String || v instanceof Number || v instanceof Boolean) vs = v.toString();
  721 + else vs = v.getClass().getSimpleName() + "@" + System.identityHashCode(v);
  722 + fb.append("\n ").append(c.getSimpleName()).append(".").append(f.getName()).append("=").append(vs);
  723 + } catch (Throwable ignored) {}
  724 + }
  725 + c = c.getSuperclass();
  726 + }
  727 + XposedBridge.log(fb.toString());
  728 + }
  729 + } else if (p.args != null && p.args.length == 2
  730 + && ("a".equals(mName) || "b".equals(mName) || "l".equals(mName)
  731 + || "n".equals(mName) || "q".equals(mName))) {
  732 + // Log 2-arg entry methods — one of these is likely the
  733 + // "enqueue voice for send" entry point called by WeChat UI
  734 + StringBuilder sb2 = new StringBuilder(
  735 + "[WeChatHook] pt0.e0." + mName + "(2) ENTER");
  736 + if (p.args != null) for (int i = 0; i < p.args.length; i++) {
  737 + Object a = p.args[i];
  738 + if (a == null) sb2.append(" arg").append(i).append("=null");
  739 + else if (a instanceof String) sb2.append(" arg").append(i).append("=\"").append(a).append("\"");
  740 + else if (a instanceof Number) sb2.append(" arg").append(i).append("=").append(a);
  741 + else sb2.append(" arg").append(i).append("=").append(a.getClass().getName()).append("@").append(System.identityHashCode(a));
  742 + }
  743 + XposedBridge.log(sb2.toString());
  744 + } else {
  745 + XposedBridge.log("[WeChatHook] pt0.e0." + mName + " ENTER");
  746 + }
  747 + }
  748 + @Override protected void afterHookedMethod(MethodHookParam p) {
  749 + if (p.hasThrowable()) {
  750 + XposedBridge.log("[WeChatHook] pt0.e0." + mName + " THREW " + p.getThrowable());
  751 + } else if ("k".equals(mName)) {
  752 + Object ret = p.getResult();
  753 + if (ret != null) {
  754 + // Dump ALL declared fields on f9 (first call only, to learn field names)
  755 + StringBuilder fb = new StringBuilder("[WeChatHook] pt0.e0.k OK f9=").append(ret.getClass().getSimpleName());
  756 + java.lang.reflect.Field[] fields = ret.getClass().getDeclaredFields();
  757 + for (java.lang.reflect.Field f : fields) {
  758 + f.setAccessible(true);
  759 + try {
  760 + Object v = f.get(ret);
  761 + fb.append(" ").append(f.getName()).append("=").append(
  762 + v == null ? "null" : (v instanceof String || v instanceof Number || v instanceof Boolean)
  763 + ? v.toString() : v.getClass().getSimpleName() + "@" + System.identityHashCode(v));
  764 + } catch (Throwable ignored) {}
  765 + }
  766 + XposedBridge.log(fb.toString());
  767 + } else {
  768 + XposedBridge.log("[WeChatHook] pt0.e0.k OK ret=null");
  769 + }
  770 + } else {
  771 + XposedBridge.log("[WeChatHook] pt0.e0." + mName + " OK ret=" + p.getResult());
  772 + }
  773 + }
  774 + });
  775 + }
  776 + XposedBridge.log("[WeChatHook] hookPt0E0: hooked " + methods.length + " methods in " + procName);
  777 + } catch (Throwable t) {
  778 + XposedBridge.log("[WeChatHook] hookPt0E0 failed in " + procName + ": " + t);
  779 + }
  780 + }
  781 +
  782 + // ── Surgical voice-send entry probe ──────────────────────────────────────
  783 + // SAFE version: hooks ONLY the voice-send-specific classes (jg0.x coordinator
  784 + // + qi3.f0/g0 voice-message objects), NOT generic coroutine classes, so it
  785 + // won't add anti-tamper risk to the live Pad session. Goal: find the entry
  786 + // method that receives (talker, silk file/bytes, durationMs) in plain form,
  787 + // which we can then call synthetically to get a real, delivered voice send.
  788 + private static String fmtArg(Object a) {
  789 + if (a == null) return "null";
  790 + if (a instanceof byte[]) return "byte[" + ((byte[]) a).length + "]";
  791 + if (a instanceof String) return "\"" + a + "\"";
  792 + if (a instanceof Number || a instanceof Boolean) return a.toString();
  793 + return a.getClass().getName() + "@" + System.identityHashCode(a);
  794 + }
  795 +
  796 + // Recursively dump an object's non-static fields, bounded by depth, to find
  797 + // where scalar values (talker string / silk path / duration) are captured.
  798 + private static void dumpDeep(Object o, String prefix, int depth, StringBuilder sb) {
  799 + if (o == null || depth < 0) return;
  800 + for (Class<?> c = o.getClass(); c != null && !c.equals(Object.class); c = c.getSuperclass()) {
  801 + for (java.lang.reflect.Field f : c.getDeclaredFields()) {
  802 + if (java.lang.reflect.Modifier.isStatic(f.getModifiers())) continue;
  803 + f.setAccessible(true);
  804 + Object v;
  805 + try { v = f.get(o); } catch (Throwable t) { continue; }
  806 + sb.append("\n").append(prefix).append(f.getName()).append("=").append(fmtArg(v));
  807 + // Recurse into non-scalar, non-array WeChat/kotlin objects.
  808 + if (v != null && depth > 0
  809 + && !(v instanceof String) && !(v instanceof Number)
  810 + && !(v instanceof Boolean) && !(v instanceof byte[])
  811 + && !v.getClass().isArray()
  812 + && !v.getClass().getName().startsWith("java.")
  813 + && !v.getClass().getName().startsWith("android.")) {
  814 + dumpDeep(v, prefix + " ", depth - 1, sb);
  815 + }
  816 + }
  817 + }
  818 + }
  819 +
  820 + private void hookVoiceProbe(ClassLoader cl, String procName) {
  821 + // jg0.x — voice send coordinator; hook ALL methods to find the entry.
  822 + try {
  823 + Class<?> xClass = XposedHelpers.findClass("jg0.x", cl);
  824 + int n = 0;
  825 + for (java.lang.reflect.Method m : xClass.getDeclaredMethods()) {
  826 + final String mn = m.getName();
  827 + m.setAccessible(true);
  828 + XposedBridge.hookMethod(m, new XC_MethodHook() {
  829 + @Override protected void beforeHookedMethod(MethodHookParam p) {
  830 + // ── Voice hot-swap (the route that actually delivers) ──────────
  831 + // jg0.x.w(jg0.x, qi3.d0, w21.w0, int type, int durMs, w21.t silk, Cont)
  832 + // is the CDN uploadAttach step. On the REAL (non-resume) call, arg5
  833 + // (w21.t) holds the recorded SILK in its single byte[] field. When
  834 + // armed, replace it with our TTS SILK so WeChat uploads our audio.
  835 + // (This lives in the probe hook because this fork won't stack a 2nd
  836 + // hook on an already-hooked method — see hookVoiceSwap note.)
  837 + if ("w".equals(mn) && sSwapArmed && sSwapSilk != null
  838 + && System.currentTimeMillis() < sSwapExpiresAt
  839 + && p.args != null && p.args.length == 7 && p.args[5] != null) {
  840 + try {
  841 + Object tHolder = p.args[5];
  842 + java.lang.reflect.Field byteF = null;
  843 + for (java.lang.reflect.Field f : tHolder.getClass().getDeclaredFields())
  844 + if (f.getType() == byte[].class) { byteF = f; break; }
  845 + if (byteF != null) {
  846 + byteF.setAccessible(true);
  847 + byte[] orig = (byte[]) byteF.get(tHolder);
  848 + byteF.set(tHolder, sSwapSilk);
  849 + if (orig != null) for (java.lang.reflect.Field f : tHolder.getClass().getDeclaredFields()) {
  850 + if (f.getType() != int.class) continue;
  851 + f.setAccessible(true);
  852 + if (f.getInt(tHolder) == orig.length) f.setInt(tHolder, sSwapSilk.length);
  853 + }
  854 + if (sSwapDuration > 0) p.args[4] = sSwapDuration;
  855 + XposedBridge.log("[WeChatHook] VOICE SWAP DONE: "
  856 + + (orig == null ? "?" : orig.length) + "B -> " + sSwapSilk.length
  857 + + "B dur -> " + p.args[4]);
  858 + sSwapArmed = false; sSwapSilk = null; // one-shot
  859 + }
  860 + } catch (Throwable t) {
  861 + XposedBridge.log("[WeChatHook] VOICE SWAP failed: " + t);
  862 + }
  863 + }
  864 + StringBuilder sb = new StringBuilder("[WeChatHook] jg0.x." + mn + "(");
  865 + if (p.args != null) for (int i = 0; i < p.args.length; i++) {
  866 + if (i > 0) sb.append(", ");
  867 + sb.append(fmtArg(p.args[i]));
  868 + }
  869 + sb.append(")");
  870 + // Deep-dump the send-carrying methods: D's lambda (arg0) and
  871 + // w's descriptor args — to locate talker/silk/duration fields.
  872 + if ("D".equals(mn) && p.args != null && p.args.length > 0) {
  873 + sb.append("\n [D arg0 pu.m0 deep]:");
  874 + dumpDeep(p.args[0], " ", 3, sb);
  875 + } else if ("w".equals(mn) && p.args != null) {
  876 + for (int i = 0; i < p.args.length; i++) {
  877 + if (p.args[i] != null && !(p.args[i] instanceof Number)) {
  878 + sb.append("\n [w arg").append(i).append(" deep]:");
  879 + dumpDeep(p.args[i], " ", 2, sb);
  880 + }
  881 + }
  882 + }
  883 + XposedBridge.log(sb.toString());
  884 + }
  885 + });
  886 + n++;
  887 + }
  888 + XposedBridge.log("[WeChatHook] hookVoiceProbe: jg0.x " + n + " methods in " + procName);
  889 + } catch (Throwable t) {
  890 + XposedBridge.log("[WeChatHook] hookVoiceProbe jg0.x failed: " + t);
  891 + }
  892 + // qi3.f0 / qi3.g0 — voice-message domain objects; dump ctor args + fields.
  893 + for (String cn : new String[]{"qi3.f0", "qi3.g0"}) {
  894 + try {
  895 + Class<?> c = XposedHelpers.findClass(cn, cl);
  896 + for (java.lang.reflect.Constructor<?> ctor : c.getDeclaredConstructors()) {
  897 + ctor.setAccessible(true);
  898 + final String fcn = cn;
  899 + XposedBridge.hookMethod(ctor, new XC_MethodHook() {
  900 + @Override protected void afterHookedMethod(MethodHookParam p) {
  901 + StringBuilder sb = new StringBuilder("[WeChatHook] " + fcn + ".<init>(");
  902 + if (p.args != null) for (int i = 0; i < p.args.length; i++) {
  903 + if (i > 0) sb.append(", ");
  904 + sb.append(fmtArg(p.args[i]));
  905 + }
  906 + sb.append(") fields:");
  907 + for (Class<?> cc = p.thisObject.getClass();
  908 + cc != null && !cc.equals(Object.class); cc = cc.getSuperclass()) {
  909 + for (java.lang.reflect.Field f : cc.getDeclaredFields()) {
  910 + if (java.lang.reflect.Modifier.isStatic(f.getModifiers())) continue;
  911 + f.setAccessible(true);
  912 + try {
  913 + sb.append("\n ").append(f.getName()).append("=").append(fmtArg(f.get(p.thisObject)));
  914 + } catch (Throwable ignored) {}
  915 + }
  916 + }
  917 + XposedBridge.log(sb.toString());
  918 + }
  919 + });
  920 + }
  921 + XposedBridge.log("[WeChatHook] hookVoiceProbe: " + cn + " ctor hooked in " + procName);
  922 + } catch (Throwable t) {
  923 + XposedBridge.log("[WeChatHook] hookVoiceProbe " + cn + " failed: " + t);
  924 + }
  925 + }
  926 + }
  927 +
  928 + // ── Find the voice-send entry point ──────────────────────────────────────
  929 + // pu.o0 is the outer coroutine, launched by the real entry point (async, so
  930 + // it doesn't appear in the jg0.x.D stack). Hooking pu.o0's CONSTRUCTOR gives
  931 + // a synchronous stack trace revealing the method that starts a voice send.
  932 + // Also dumps qi3.f0/g0 constructors (the voice-message domain objects that
  933 + // carry talker/silk/duration).
  934 + private void hookVoiceEntry(ClassLoader cl, String procName) {
  935 + // pu.o0 constructor — stack trace = the real send entry point.
  936 + for (String cn : new String[]{"pu.o0", "pu.m0"}) {
  937 + try {
  938 + Class<?> c = XposedHelpers.findClass(cn, cl);
  939 + for (java.lang.reflect.Constructor<?> ctor : c.getDeclaredConstructors()) {
  940 + ctor.setAccessible(true);
  941 + final String fcn = cn;
  942 + final int np = ctor.getParameterTypes().length;
  943 + XposedBridge.hookMethod(ctor, new XC_MethodHook() {
  944 + private volatile boolean logged = false;
  945 + @Override protected void afterHookedMethod(MethodHookParam p) {
  946 + if (logged) return;
  947 + logged = true;
  948 + StringBuilder sb = new StringBuilder(
  949 + "[WeChatHook] " + fcn + ".<init>(" + np + ") args:");
  950 + if (p.args != null) for (int i = 0; i < p.args.length; i++) {
  951 + Object a = p.args[i];
  952 + sb.append("\n arg[").append(i).append("]=")
  953 + .append(a == null ? "null" : a.getClass().getName());
  954 + }
  955 + sb.append("\n").append(android.util.Log.getStackTraceString(new Exception()));
  956 + XposedBridge.log(sb.toString());
  957 + }
  958 + });
  959 + }
  960 + XposedBridge.log("[WeChatHook] hookVoiceEntry: " + cn + " ctor hooked in " + procName);
  961 + } catch (Throwable t) {
  962 + XposedBridge.log("[WeChatHook] hookVoiceEntry " + cn + " failed: " + t);
  963 + }
  964 + }
  965 + // qi3.f0 / qi3.g0 constructors — dump their args (talker/silk/duration).
  966 + for (String cn : new String[]{"qi3.f0", "qi3.g0"}) {
  967 + try {
  968 + Class<?> c = XposedHelpers.findClass(cn, cl);
  969 + StringBuilder info = new StringBuilder("[WeChatHook] " + cn
  970 + + " super=" + c.getSuperclass().getName() + " fields:");
  971 + for (java.lang.reflect.Field f : c.getDeclaredFields())
  972 + info.append(" ").append(f.getName()).append("(").append(f.getType().getSimpleName()).append(")");
  973 + XposedBridge.log(info.toString());
  974 + for (java.lang.reflect.Constructor<?> ctor : c.getDeclaredConstructors()) {
  975 + ctor.setAccessible(true);
  976 + final String fcn = cn;
  977 + XposedBridge.hookMethod(ctor, new XC_MethodHook() {
  978 + private volatile boolean logged = false;
  979 + @Override protected void afterHookedMethod(MethodHookParam p) {
  980 + if (logged) return;
  981 + logged = true;
  982 + StringBuilder sb = new StringBuilder(
  983 + "[WeChatHook] " + fcn + ".<init> args:");
  984 + if (p.args != null) for (int i = 0; i < p.args.length; i++) {
  985 + Object a = p.args[i];
  986 + String vs;
  987 + if (a == null) vs = "null";
  988 + else if (a instanceof byte[]) vs = "byte[" + ((byte[])a).length + "]";
  989 + else if (a instanceof String || a instanceof Number || a instanceof Boolean)
  990 + vs = a.getClass().getSimpleName() + "(" + a + ")";
  991 + else vs = a.getClass().getName();
  992 + sb.append("\n arg[").append(i).append("]=").append(vs);
  993 + }
  994 + // Dump the constructed object's fields too
  995 + for (Class<?> cc = p.thisObject.getClass();
  996 + cc != null && !cc.equals(Object.class); cc = cc.getSuperclass()) {
  997 + for (java.lang.reflect.Field f : cc.getDeclaredFields()) {
  998 + if (java.lang.reflect.Modifier.isStatic(f.getModifiers())) continue;
  999 + f.setAccessible(true);
  1000 + try {
  1001 + Object v = f.get(p.thisObject);
  1002 + String vs;
  1003 + if (v == null) vs = "null";
  1004 + else if (v instanceof byte[]) vs = "byte[" + ((byte[])v).length + "]";
  1005 + else if (v instanceof String || v instanceof Number || v instanceof Boolean) vs = v.toString();
  1006 + else vs = v.getClass().getSimpleName();
  1007 + sb.append("\n this.").append(f.getName()).append("=").append(vs);
  1008 + } catch (Throwable ignored) {}
  1009 + }
  1010 + }
  1011 + XposedBridge.log(sb.toString());
  1012 + }
  1013 + });
  1014 + }
  1015 + XposedBridge.log("[WeChatHook] hookVoiceEntry: " + cn + " ctor hooked in " + procName);
  1016 + } catch (Throwable t) {
  1017 + XposedBridge.log("[WeChatHook] hookVoiceEntry " + cn + " failed: " + t);
  1018 + }
  1019 + }
  1020 + }
  1021 +
  1022 + // ── Hook jg0.j / jg0.x / g9 ─────────────────────────────────────────────
  1023 + // Reveals the internal structure needed to call pt0.e0.r() synthetically.
  1024 + // jg0.j captures the g9 (voice message / f9 subclass) as a field.
  1025 + // jg0.x.D sets up jg0.j and starts the coroutine.
  1026 + // g9 extends f9 (or is f9) with M9/N9 DB-insert methods.
  1027 + private void hookJg0(ClassLoader cl, String procName) {
  1028 + // ── jg0.j (the inner coroutine whose invokeSuspend calls pt0.e0.r) ────
  1029 + try {
  1030 + Class<?> jClass = XposedHelpers.findClass("jg0.j", cl);
  1031 + StringBuilder jInfo = new StringBuilder("[WeChatHook] jg0.j class=" + jClass.getName()
  1032 + + " super=" + jClass.getSuperclass().getName() + " ownFields:");
  1033 + for (java.lang.reflect.Field f : jClass.getDeclaredFields())
  1034 + jInfo.append(" ").append(f.getName()).append("(").append(f.getType().getSimpleName()).append(")");
  1035 + jInfo.append(" | superFields:");
  1036 + for (java.lang.reflect.Field f : jClass.getSuperclass().getDeclaredFields())
  1037 + jInfo.append(" ").append(f.getName()).append("(").append(f.getType().getSimpleName()).append(")");
  1038 + XposedBridge.log(jInfo.toString());
  1039 +
  1040 + // Hook invokeSuspend — fires every time a jg0.j coroutine step runs.
  1041 + // We only care about the FIRST call (state=0, label=0) to see captured fields.
  1042 + try {
  1043 + XposedHelpers.findAndHookMethod(jClass, "invokeSuspend", Object.class,
  1044 + new XC_MethodHook() {
  1045 + private volatile boolean sLogged = false;
  1046 + @Override protected void beforeHookedMethod(MethodHookParam p) {
  1047 + if (sLogged) return;
  1048 + sLogged = true;
  1049 + StringBuilder sb = new StringBuilder(
  1050 + "[WeChatHook] jg0.j.invokeSuspend FIRST fields:");
  1051 + for (Class<?> c = p.thisObject.getClass();
  1052 + c != null && !c.equals(Object.class); c = c.getSuperclass()) {
  1053 + for (java.lang.reflect.Field f : c.getDeclaredFields()) {
  1054 + if (java.lang.reflect.Modifier.isStatic(f.getModifiers())) continue;
  1055 + f.setAccessible(true);
  1056 + try {
  1057 + Object v = f.get(p.thisObject);
  1058 + sb.append("\n ").append(c.getSimpleName())
  1059 + .append(".").append(f.getName()).append("=")
  1060 + .append(v == null ? "null"
  1061 + : v.getClass().getName() + "@" + System.identityHashCode(v));
  1062 + } catch (Throwable ignored) {}
  1063 + }
  1064 + }
  1065 + XposedBridge.log(sb.toString());
  1066 + }
  1067 + });
  1068 + } catch (Throwable t) {
  1069 + XposedBridge.log("[WeChatHook] hookJg0 jg0.j.invokeSuspend hook failed: " + t);
  1070 + }
  1071 + XposedBridge.log("[WeChatHook] hookJg0: jg0.j hooked in " + procName);
  1072 + } catch (Throwable t) {
  1073 + XposedBridge.log("[WeChatHook] hookJg0 jg0.j failed: " + t);
  1074 + }
  1075 +
  1076 + // ── jg0.x (coordinator that creates jg0.j and calls D to enqueue send) ──
  1077 + try {
  1078 + Class<?> xClass = XposedHelpers.findClass("jg0.x", cl);
  1079 + StringBuilder xInfo = new StringBuilder("[WeChatHook] jg0.x methods:");
  1080 + for (java.lang.reflect.Method m : xClass.getDeclaredMethods())
  1081 + xInfo.append(" ").append(m.getName()).append("(").append(m.getParameterTypes().length).append(")");
  1082 + xInfo.append(" | ownFields:");
  1083 + for (java.lang.reflect.Field f : xClass.getDeclaredFields())
  1084 + xInfo.append(" ").append(f.getName()).append("(").append(f.getType().getSimpleName()).append(")");
  1085 + XposedBridge.log(xInfo.toString());
  1086 +
  1087 + // Hook D() — the method called from pu.o0.invokeSuspend(84) that enqueues the send
  1088 + for (java.lang.reflect.Method m : xClass.getDeclaredMethods()) {
  1089 + if (!"D".equals(m.getName())) continue;
  1090 + m.setAccessible(true);
  1091 + final String sig = m.toGenericString();
  1092 + XposedBridge.hookMethod(m, new XC_MethodHook() {
  1093 + @Override protected void beforeHookedMethod(MethodHookParam p) {
  1094 + StringBuilder sb = new StringBuilder(
  1095 + "[WeChatHook] jg0.x.D ENTER sig=" + sig);
  1096 + // args
  1097 + if (p.args != null) for (int i = 0; i < p.args.length; i++) {
  1098 + Object a = p.args[i];
  1099 + sb.append("\n arg[").append(i).append("]=")
  1100 + .append(a == null ? "null" : a.getClass().getName()
  1101 + + "@" + System.identityHashCode(a));
  1102 + // If it looks like a voice-msg / f9 subclass, dump its fields
  1103 + if (a != null) {
  1104 + for (Class<?> c2 = a.getClass();
  1105 + c2 != null && !c2.equals(Object.class); c2 = c2.getSuperclass()) {
  1106 + for (java.lang.reflect.Field f2 : c2.getDeclaredFields()) {
  1107 + if (java.lang.reflect.Modifier.isStatic(f2.getModifiers())) continue;
  1108 + f2.setAccessible(true);
  1109 + try {
  1110 + Object v2 = f2.get(a);
  1111 + String vs;
  1112 + if (v2 == null) vs = "null";
  1113 + else if (v2 instanceof byte[]) vs = "byte[" + ((byte[])v2).length + "]";
  1114 + else if (v2 instanceof String || v2 instanceof Number
  1115 + || v2 instanceof Boolean) vs = v2.toString();
  1116 + else vs = v2.getClass().getSimpleName();
  1117 + sb.append("\n ").append(c2.getSimpleName())
  1118 + .append(".").append(f2.getName()).append("=").append(vs);
  1119 + } catch (Throwable ignored2) {}
  1120 + }
  1121 + }
  1122 + }
  1123 + }
  1124 + // thisObject fields
  1125 + for (Class<?> c = p.thisObject.getClass();
  1126 + c != null && !c.equals(Object.class); c = c.getSuperclass()) {
  1127 + for (java.lang.reflect.Field f : c.getDeclaredFields()) {
  1128 + if (java.lang.reflect.Modifier.isStatic(f.getModifiers())) continue;
  1129 + f.setAccessible(true);
  1130 + try {
  1131 + Object v = f.get(p.thisObject);
  1132 + sb.append("\n this.").append(c.getSimpleName())
  1133 + .append(".").append(f.getName()).append("=")
  1134 + .append(v == null ? "null" : v.getClass().getName());
  1135 + } catch (Throwable ignored) {}
  1136 + }
  1137 + }
  1138 + XposedBridge.log(sb.toString());
  1139 + }
  1140 + });
  1141 + }
  1142 + XposedBridge.log("[WeChatHook] hookJg0: jg0.x hooked in " + procName);
  1143 + } catch (Throwable t) {
  1144 + XposedBridge.log("[WeChatHook] hookJg0 jg0.x failed: " + t);
  1145 + }
  1146 +
  1147 + // ── g9 (likely a subclass of f9; M9/N9 are the DB-insert methods) ─────
  1148 + try {
  1149 + Class<?> g9Class = XposedHelpers.findClass("com.tencent.mm.storage.g9", cl);
  1150 + StringBuilder g9Info = new StringBuilder("[WeChatHook] g9 super="
  1151 + + g9Class.getSuperclass().getName() + " methods:");
  1152 + for (java.lang.reflect.Method m : g9Class.getDeclaredMethods())
  1153 + g9Info.append(" ").append(m.getName()).append("(").append(m.getParameterTypes().length).append(")");
  1154 + g9Info.append(" | ownFields:");
  1155 + for (java.lang.reflect.Field f : g9Class.getDeclaredFields())
  1156 + g9Info.append(" ").append(f.getName()).append("(").append(f.getType().getSimpleName()).append(")");
  1157 + XposedBridge.log(g9Info.toString());
  1158 +
  1159 + for (java.lang.reflect.Method m : g9Class.getDeclaredMethods()) {
  1160 + if (!"M9".equals(m.getName()) && !"N9".equals(m.getName())) continue;
  1161 + m.setAccessible(true);
  1162 + final String mName2 = m.getName();
  1163 + final String mSig2 = m.toGenericString();
  1164 + XposedBridge.hookMethod(m, new XC_MethodHook() {
  1165 + @Override protected void beforeHookedMethod(MethodHookParam p) {
  1166 + StringBuilder sb = new StringBuilder(
  1167 + "[WeChatHook] g9." + mName2 + " ENTER sig=" + mSig2
  1168 + + " thisClass=" + p.thisObject.getClass().getName());
  1169 + if (p.args != null) for (int i = 0; i < p.args.length; i++)
  1170 + sb.append(" arg[").append(i).append("]=")
  1171 + .append(p.args[i] == null ? "null" : p.args[i].getClass().getName());
  1172 + XposedBridge.log(sb.toString());
  1173 + }
  1174 + @Override protected void afterHookedMethod(MethodHookParam p) {
  1175 + if (p.hasThrowable())
  1176 + XposedBridge.log("[WeChatHook] g9." + mName2 + " THREW: " + p.getThrowable());
  1177 + else
  1178 + XposedBridge.log("[WeChatHook] g9." + mName2 + " OK ret=" + p.getResult());
  1179 + }
  1180 + });
  1181 + }
  1182 + XposedBridge.log("[WeChatHook] hookJg0: g9 hooked in " + procName);
  1183 + } catch (Throwable t) {
  1184 + XposedBridge.log("[WeChatHook] hookJg0 g9 failed: " + t);
  1185 + }
  1186 +
  1187 + // ── pu.o0 (outer coroutine that calls jg0.x.D) ───────────────────────
  1188 + // Hook invokeSuspend to see what it captures (especially the g9/voice data).
  1189 + try {
  1190 + Class<?> puClass = XposedHelpers.findClass("pu.o0", cl);
  1191 + StringBuilder puInfo = new StringBuilder("[WeChatHook] pu.o0 super="
  1192 + + puClass.getSuperclass().getName() + " | ownFields:");
  1193 + for (java.lang.reflect.Field f : puClass.getDeclaredFields())
  1194 + puInfo.append(" ").append(f.getName()).append("(").append(f.getType().getSimpleName()).append(")");
  1195 + XposedBridge.log(puInfo.toString());
  1196 +
  1197 + XposedHelpers.findAndHookMethod(puClass, "invokeSuspend", Object.class,
  1198 + new XC_MethodHook() {
  1199 + private volatile boolean sLogged2 = false;
  1200 + @Override protected void beforeHookedMethod(MethodHookParam p) {
  1201 + if (sLogged2) return;
  1202 + sLogged2 = true;
  1203 + StringBuilder sb = new StringBuilder(
  1204 + "[WeChatHook] pu.o0.invokeSuspend FIRST fields:");
  1205 + for (Class<?> c = p.thisObject.getClass();
  1206 + c != null && !c.equals(Object.class); c = c.getSuperclass()) {
  1207 + for (java.lang.reflect.Field f : c.getDeclaredFields()) {
  1208 + if (java.lang.reflect.Modifier.isStatic(f.getModifiers())) continue;
  1209 + f.setAccessible(true);
  1210 + try {
  1211 + Object v = f.get(p.thisObject);
  1212 + sb.append("\n ").append(c.getSimpleName())
  1213 + .append(".").append(f.getName()).append("=")
  1214 + .append(v == null ? "null"
  1215 + : v.getClass().getName() + "@" + System.identityHashCode(v));
  1216 + } catch (Throwable ignored) {}
  1217 + }
  1218 + }
  1219 + XposedBridge.log(sb.toString());
  1220 + }
  1221 + });
  1222 + XposedBridge.log("[WeChatHook] hookJg0: pu.o0 hooked in " + procName);
  1223 + } catch (Throwable t) {
  1224 + XposedBridge.log("[WeChatHook] hookJg0 pu.o0 failed: " + t);
  1225 + }
  1226 + }
  1227 +
  1228 + private void hookR0H(ClassLoader cl, String procName) {
  1229 + try {
  1230 + Class<?> r0Class = XposedHelpers.findClass("w11.r0", cl);
  1231 + int hooked = 0;
  1232 + // Hook ALL methods named H on r0 — real voice sends may use H() or H(int), not just H(String).
  1233 + for (java.lang.reflect.Method m : r0Class.getDeclaredMethods()) {
  1234 + if (!m.getName().equals("H")) continue;
  1235 + final String sig = m.toGenericString();
  1236 + final boolean isHString = m.getParameterTypes().length == 1
  1237 + && m.getParameterTypes()[0] == String.class;
  1238 + m.setAccessible(true);
  1239 + XposedBridge.hookMethod(m, new XC_MethodHook() {
  1240 + @Override
  1241 + protected void beforeHookedMethod(MethodHookParam p) throws Throwable {
  1242 + String by = sCalledByUs ? "US" : "WeChat";
  1243 + XposedBridge.log("[WeChatHook] r0." + sig + " ENTER by=" + by + " proc=" + procName);
  1244 + // Capture real dispatcher from any real WeChat r0 H() call.
  1245 + if (!sCalledByUs) {
  1246 + try {
  1247 + Object disp = XposedHelpers.getObjectField(p.thisObject, "dispatcher");
  1248 + if (disp != null && !(disp instanceof java.lang.reflect.Proxy)) {
  1249 + sCapturedDispatcher = disp;
  1250 + XposedBridge.log("[WeChatHook] hookR0H: REAL dispatcher=" + disp.getClass().getName() + " from " + sig);
  1251 + }
  1252 + } catch (Throwable ignored) {}
  1253 + // Also dump r0 fields on first real send to capture full state.
  1254 + dumpR0Fields(p.thisObject, "r0.H-WeChat by=" + by + " proc=" + procName);
  1255 + } else if (isHString) {
  1256 + dumpR0Fields(p.thisObject, "r0.H-ENTER by=US proc=" + procName);
  1257 + }
  1258 + }
  1259 + @Override
  1260 + protected void afterHookedMethod(MethodHookParam p) throws Throwable {
  1261 + String by = sCalledByUs ? "US" : "WeChat";
  1262 + XposedBridge.log("[WeChatHook] r0.H() DONE by=" + by + " proc=" + procName
  1263 + + (p.hasThrowable() ? " THREW=" + p.getThrowable() : " OK"));
  1264 + }
  1265 + });
  1266 + hooked++;
  1267 + }
  1268 + XposedBridge.log("[WeChatHook] hookR0H: hooked " + hooked + " H() overloads in " + procName);
  1269 + } catch (Throwable t) {
  1270 + XposedBridge.log("[WeChatHook] hookR0H failed in " + procName + ": " + t);
  1271 + }
  1272 + }
  1273 +
  1274 + // ── Capture live network dispatcher (com.tencent.mm.modelbase.m2) ────────────
  1275 + // m2 = MicroMsg.RDispatcher, the com.tencent.mm.network.s the send scene needs.
  1276 + // We grab the instance at construction (network init, before any synthetic send)
  1277 + // into sCapturedDispatcher, so sendVoiceViaAPI can hand r0 the REAL dispatcher.
  1278 + private void hookDispatcher(ClassLoader cl, String procName) {
  1279 + try {
  1280 + Class<?> m2 = XposedHelpers.findClass("com.tencent.mm.modelbase.m2", cl);
  1281 + XC_MethodHook grab = new XC_MethodHook() {
  1282 + @Override protected void beforeHookedMethod(MethodHookParam p) {
  1283 + if (sCapturedDispatcher == null && p.thisObject != null
  1284 + && !(p.thisObject instanceof java.lang.reflect.Proxy)) {
  1285 + sCapturedDispatcher = p.thisObject;
  1286 + XposedBridge.log("[WeChatHook] hookDispatcher: captured REAL m2="
  1287 + + p.thisObject + " in " + procName);
  1288 + }
  1289 + }
  1290 + };
  1291 + int n = 0;
  1292 + // Ctor: catches m2 constructed AFTER we install (network re-init).
  1293 + for (java.lang.reflect.Constructor<?> c : m2.getDeclaredConstructors()) {
  1294 + c.setAccessible(true);
  1295 + XposedBridge.hookMethod(c, new XC_MethodHook() {
  1296 + @Override protected void afterHookedMethod(MethodHookParam p) {
  1297 + sCapturedDispatcher = p.thisObject;
  1298 + XposedBridge.log("[WeChatHook] hookDispatcher: captured REAL m2 (ctor)="
  1299 + + p.thisObject + " in " + procName);
  1300 + }
  1301 + });
  1302 + n++;
  1303 + }
  1304 + // Instance methods: catches an ALREADY-existing m2 (constructed before our
  1305 + // hook / persistent connection) on its next network call. After capture the
  1306 + // callback is a single null check — negligible overhead.
  1307 + int mh = 0;
  1308 + for (java.lang.reflect.Method m : m2.getDeclaredMethods()) {
  1309 + if (java.lang.reflect.Modifier.isStatic(m.getModifiers())) continue;
  1310 + if (java.lang.reflect.Modifier.isAbstract(m.getModifiers())) continue;
  1311 + try { m.setAccessible(true); XposedBridge.hookMethod(m, grab); mh++; }
  1312 + catch (Throwable ignored) {}
  1313 + }
  1314 + XposedBridge.log("[WeChatHook] hookDispatcher: hooked " + n + " ctor(s) + "
  1315 + + mh + " method(s) on m2 in " + procName);
  1316 + } catch (Throwable t) {
  1317 + XposedBridge.log("[WeChatHook] hookDispatcher failed in " + procName + ": " + t);
  1318 + }
  1319 + }
  1320 +
  1321 + // ── Instrument voice-upload service w21.g1 (voiceinfo table) ─────────────────
  1322 + // On a REAL voice send, WeChat builds a w21.w0 upload-task and calls g1.L0(w0)
  1323 + // (insert) / g1.P0(file, w0) (upload). Dump every w0 field on those calls so we
  1324 + // learn the exact template (FileName/ClientId/MsgLocalId/VoiceLength/MsgTalker/
  1325 + // Status correlation) needed to synthesize the upload for hook-sent voice.
  1326 + // ── Voice hot-swap: replace recorded SILK with our TTS at the CDN-upload step ─
  1327 + // jg0.x.w(jg0.x self, qi3.d0, w21.w0, int type, int durationMs, w21.t silk, Cont)
  1328 + // is WeChat's uploadAttach step (confirmed by real-recording capture + tinker
  1329 + // decompile). w21.t has exactly one byte[] field (f443506a) = the SILK audio
  1330 + // about to be sent to CDN. When armed, we overwrite that byte[] with our TTS
  1331 + // SILK and the duration int (arg4). WeChat then uploads + delivers OUR audio,
  1332 + // reusing the entire real send pipeline we cannot rebuild.
  1333 + // Hook runs in the process where recording happens (main). Verified swap target,
  1334 + // but does its own field dump on every fire so a first real test confirms layout.
  1335 + private void hookVoiceSwap(ClassLoader cl, String procName) {
  1336 + try {
  1337 + Class<?> xClass = XposedHelpers.findClass("jg0.x", cl);
  1338 + int hooked = 0;
  1339 + for (java.lang.reflect.Method m : xClass.getDeclaredMethods()) {
  1340 + if (!"w".equals(m.getName())) continue; // hook EVERY 'w' overload
  1341 + m.setAccessible(true);
  1342 + XposedBridge.hookMethod(m, new XC_MethodHook() {
  1343 + @Override protected void beforeHookedMethod(MethodHookParam p) {
  1344 + // Absolute-first, arg-safe entry log so we KNOW the hook runs.
  1345 + int n = (p.args == null) ? 0 : p.args.length;
  1346 + XposedBridge.log("[WeChatHook] SWAPHOOK ENTER w nargs=" + n + " armed=" + sSwapArmed);
  1347 + try {
  1348 + // Real uploadAttach call: (jg0.x, qi3.d0, w21.w0, int, int, w21.t, Cont).
  1349 + // Coroutine resumptions re-call w with nulls — skip those.
  1350 + if (n != 7 || p.args[5] == null) return;
  1351 + Object tHolder = p.args[5]; // w21.t (SILK holder)
  1352 + java.lang.reflect.Field byteF = null;
  1353 + for (java.lang.reflect.Field f : tHolder.getClass().getDeclaredFields()) {
  1354 + if (f.getType() == byte[].class) { byteF = f; break; }
  1355 + }
  1356 + if (byteF == null) return;
  1357 + byteF.setAccessible(true);
  1358 + byte[] orig = (byte[]) byteF.get(tHolder);
  1359 + XposedBridge.log("[WeChatHook] SWAPHOOK real w: silk="
  1360 + + (orig == null ? "null" : orig.length + "B") + " durArg=" + p.args[4]
  1361 + + " holder=" + tHolder.getClass().getName());
  1362 + if (!sSwapArmed || sSwapSilk == null) return;
  1363 +
  1364 + byteF.set(tHolder, sSwapSilk);
  1365 + if (orig != null) {
  1366 + for (java.lang.reflect.Field f : tHolder.getClass().getDeclaredFields()) {
  1367 + if (f.getType() != int.class) continue;
  1368 + f.setAccessible(true);
  1369 + if (f.getInt(tHolder) == orig.length) f.setInt(tHolder, sSwapSilk.length);
  1370 + }
  1371 + }
  1372 + if (sSwapDuration > 0) p.args[4] = sSwapDuration;
  1373 + XposedBridge.log("[WeChatHook] VOICE SWAP DONE: "
  1374 + + (orig == null ? "?" : orig.length) + "B -> " + sSwapSilk.length
  1375 + + "B, dur -> " + p.args[4]);
  1376 + sSwapArmed = false; // one-shot
  1377 + sSwapSilk = null;
  1378 + } catch (Throwable t) {
  1379 + XposedBridge.log("[WeChatHook] VOICE SWAP failed: " + t
  1380 + + "\n" + android.util.Log.getStackTraceString(t));
  1381 + }
  1382 + }
  1383 + });
  1384 + hooked++;
  1385 + }
  1386 + XposedBridge.log("[WeChatHook] hookVoiceSwap: hooked jg0.x.w x" + hooked + " in " + procName);
  1387 + } catch (Throwable t) {
  1388 + XposedBridge.log("[WeChatHook] hookVoiceSwap failed in " + procName + ": " + t);
  1389 + }
  1390 + }
  1391 +
  1392 + // ── Full field dump across the whole class hierarchy (for RE capture) ────────
  1393 + private static void dumpFieldsDeep(Object o, String tag) {
  1394 + if (o == null) { XposedBridge.log("[WeChatHook] " + tag + " = null"); return; }
  1395 + try {
  1396 + StringBuilder sb = new StringBuilder("[WeChatHook] " + tag + " (" + o.getClass().getName() + "):");
  1397 + for (Class<?> c = o.getClass(); c != null && !c.equals(Object.class); c = c.getSuperclass()) {
  1398 + for (java.lang.reflect.Field f : c.getDeclaredFields()) {
  1399 + if (java.lang.reflect.Modifier.isStatic(f.getModifiers())) continue;
  1400 + f.setAccessible(true);
  1401 + Object v; try { v = f.get(o); } catch (Throwable t) { continue; }
  1402 + String vs = v == null ? "null"
  1403 + : (v instanceof byte[] ? "byte[" + ((byte[]) v).length + "]"
  1404 + : (v instanceof String || v instanceof Number || v instanceof Boolean ? v.toString()
  1405 + : v.getClass().getName() + "@" + System.identityHashCode(v)));
  1406 + if (vs.length() > 200) vs = vs.substring(0, 200) + "…";
  1407 + sb.append("\n ").append(f.getName()).append(" (").append(f.getType().getSimpleName()).append(") = ").append(vs);
  1408 + }
  1409 + }
  1410 + XposedBridge.log(sb.toString());
  1411 + } catch (Throwable t) { XposedBridge.log("[WeChatHook] dumpFieldsDeep err: " + t); }
  1412 + }
  1413 +
  1414 + // ── Trace the CDN upload submission for voice (the missing full-synthesis layer) ─
  1415 + // Real voice send: jg0.x.u builds a dn.m task (field_fileType=15, field_fullpath=
  1416 + // voice2 amr, field_mediaId="VideoData"+id, aesKey/fileId empty) and drives the CDN
  1417 + // engine (h2.d=addSendTask → l1.u → native CdnManager.startC2CUpload). The native
  1418 + // side ASSIGNS aeskey+fileId, returned in dn.h to l1.keep_onUploadSuccessed, then
  1419 + // jg0.x.u writes them back to the voiceinfo protobuf + newsendmsg. We OBSERVE only
  1420 + // (no modification) so a real recording still delivers, and we capture the exact
  1421 + // dn.m template + assigned aeskey/fileId to replicate CDN upload for synthetic voice.
  1422 + private void hookCdnTrace(ClassLoader cl, String procName) {
  1423 + // 1) h2.d(dn.m) = addSendTask — dump the full task struct on submit.
  1424 + try {
  1425 + Class<?> h2 = XposedHelpers.findClass("com.tencent.mm.modelcdntran.h2", cl);
  1426 + Class<?> dnm = XposedHelpers.findClass("dn.m", cl);
  1427 + XposedBridge.hookMethod(h2.getDeclaredMethod("d", dnm), new XC_MethodHook() {
  1428 + @Override protected void beforeHookedMethod(MethodHookParam p) {
  1429 + XposedBridge.log("[WeChatHook] CDNTRACE h2.d(addSendTask) ENTER");
  1430 + if (p.args != null && p.args.length > 0) dumpFieldsDeep(p.args[0], "CDNTRACE addSendTask dn.m");
  1431 + }
  1432 + @Override protected void afterHookedMethod(MethodHookParam p) {
  1433 + XposedBridge.log("[WeChatHook] CDNTRACE h2.d ret=" + p.getResult());
  1434 + }
  1435 + });
  1436 + XposedBridge.log("[WeChatHook] hookCdnTrace: hooked h2.d(addSendTask) in " + procName);
  1437 + } catch (Throwable t) { XposedBridge.log("[WeChatHook] hookCdnTrace h2.d failed in " + procName + ": " + t); }
  1438 +
  1439 + // 2) l1.keep_onUpload* — the native CDN callback carrying assigned aeskey/fileId (dn.h).
  1440 + try {
  1441 + Class<?> l1 = XposedHelpers.findClass("com.tencent.mm.modelcdntran.l1", cl);
  1442 + int n = 0;
  1443 + for (java.lang.reflect.Method m : l1.getDeclaredMethods()) {
  1444 + if (!m.getName().startsWith("keep_onUpload")) continue;
  1445 + m.setAccessible(true);
  1446 + final String mn = m.getName();
  1447 + XposedBridge.hookMethod(m, new XC_MethodHook() {
  1448 + @Override protected void afterHookedMethod(MethodHookParam p) {
  1449 + XposedBridge.log("[WeChatHook] CDNTRACE l1." + mn + " nargs=" + (p.args == null ? 0 : p.args.length));
  1450 + if (p.args != null) for (Object a : p.args) {
  1451 + if (a != null && a.getClass().getName().equals("dn.h")) dumpFieldsDeep(a, "CDNTRACE " + mn + " dn.h");
  1452 + }
  1453 + }
  1454 + });
  1455 + n++;
  1456 + }
  1457 + XposedBridge.log("[WeChatHook] hookCdnTrace: hooked l1.keep_onUpload* x" + n + " in " + procName);
  1458 + } catch (Throwable t) { XposedBridge.log("[WeChatHook] hookCdnTrace l1 failed in " + procName + ": " + t); }
  1459 +
  1460 + // 3) jg0.x.u — the orchestrator (builds dn.m, writes aeskey back). Log entry to
  1461 + // confirm it fires + timing vs the CDN calls.
  1462 + try {
  1463 + Class<?> x = XposedHelpers.findClass("jg0.x", cl);
  1464 + int n = 0;
  1465 + for (java.lang.reflect.Method m : x.getDeclaredMethods()) {
  1466 + if (!"u".equals(m.getName())) continue;
  1467 + m.setAccessible(true);
  1468 + XposedBridge.hookMethod(m, new XC_MethodHook() {
  1469 + @Override protected void beforeHookedMethod(MethodHookParam p) {
  1470 + XposedBridge.log("[WeChatHook] CDNTRACE jg0.x.u ENTER nargs=" + (p.args == null ? 0 : p.args.length));
  1471 + if (p.args != null && p.args.length > 1 && p.args[1] != null)
  1472 + dumpFieldsDeep(p.args[1], "CDNTRACE jg0.x.u w21.w0");
  1473 + }
  1474 + });
  1475 + n++;
  1476 + }
  1477 + XposedBridge.log("[WeChatHook] hookCdnTrace: hooked jg0.x.u x" + n + " in " + procName);
  1478 + } catch (Throwable t) { XposedBridge.log("[WeChatHook] hookCdnTrace jg0.x.u failed in " + procName + ": " + t); }
  1479 + }
  1480 +
  1481 + // ── Capture the real uploadvoice cgi request (this device's actual voice path) ──
  1482 + // This device uses the INLINE-cgi branch of jg0.x.w (not CDN): it builds a
  1483 + // com.tencent.mm.modelbase.l reqresp with url /cgi-bin/micromsg-bin/uploadvoice,
  1484 + // request pb r45.cv6 carrying the SILK inline in field 7. The scene
  1485 + // (modelbase.i.f70699d, a NetScene = m1 subclass) can be submitted via z2.b — the
  1486 + // SAME queue our text send uses. Hook modelbase.l.a() (the reqresp builder, fires
  1487 + // mid-jg0.x.w) filtered to the uploadvoice url, and dump every cv6 field index so
  1488 + // we can replicate the exact wire request for fully-synthetic voice.
  1489 + // Diagnostic: hook modelbase.i.k(errType,errCode,errMsg,respPb,scene) = scene-end
  1490 + // callback. Logs the result of any uploadvoice send (resp pb = r45.dv6), so we can
  1491 + // tell if our synthetic z2.b send was dispatched + accepted (errCode 0) or rejected.
  1492 + private void hookVoiceCgiResult(ClassLoader cl, String procName) {
  1493 + try {
  1494 + Class<?> iCls = XposedHelpers.findClass("com.tencent.mm.modelbase.i", cl);
  1495 + Class<?> pbCls = XposedHelpers.findClass("com.tencent.mm.protobuf.f", cl);
  1496 + Class<?> m1Cls = XposedHelpers.findClass("com.tencent.mm.modelbase.m1", cl);
  1497 + int n = 0;
  1498 + for (java.lang.reflect.Method m : iCls.getDeclaredMethods()) {
  1499 + Class<?>[] pt = m.getParameterTypes();
  1500 + // onSceneEnd: (int, int, String, protobuf.f, m1)
  1501 + if (pt.length == 5 && pt[0] == int.class && pt[1] == int.class && pt[2] == String.class
  1502 + && pbCls.isAssignableFrom(pt[3]) && m1Cls.isAssignableFrom(pt[4])) {
  1503 + m.setAccessible(true);
  1504 + XposedBridge.hookMethod(m, new XC_MethodHook() {
  1505 + @Override protected void beforeHookedMethod(MethodHookParam p) {
  1506 + try {
  1507 + Object resp = p.args[3];
  1508 + if (resp == null || !resp.getClass().getName().equals("r45.dv6")) return;
  1509 + long svr = 0; try { Object v = XposedHelpers.callMethod(resp, "getLong", 11); if (v != null) svr = (Long) v; } catch (Throwable t) {}
  1510 + XposedBridge.log("[WeChatHook] VOICECGI-RESULT errType=" + p.args[0]
  1511 + + " errCode=" + p.args[1] + " errMsg=" + p.args[2]
  1512 + + " respClass=" + resp.getClass().getName() + " svrMsgId(11)=" + svr);
  1513 + } catch (Throwable t) { XposedBridge.log("[WeChatHook] VOICECGI-RESULT err: " + t); }
  1514 + }
  1515 + });
  1516 + n++;
  1517 + }
  1518 + }
  1519 + XposedBridge.log("[WeChatHook] hookVoiceCgiResult: hooked i.onSceneEnd x" + n + " in " + procName);
  1520 + } catch (Throwable t) { XposedBridge.log("[WeChatHook] hookVoiceCgiResult failed in " + procName + ": " + t); }
  1521 + }
  1522 +
  1523 + private void hookVoiceCgiCapture(ClassLoader cl, String procName) {
  1524 + try {
  1525 + Class<?> lCls = XposedHelpers.findClass("com.tencent.mm.modelbase.l", cl);
  1526 + final Class<?> pbCls = XposedHelpers.findClass("com.tencent.mm.protobuf.f", cl);
  1527 + XposedBridge.hookMethod(lCls.getDeclaredMethod("a"), new XC_MethodHook() {
  1528 + @Override protected void beforeHookedMethod(MethodHookParam p) {
  1529 + try {
  1530 + // Field names differ base-vs-tinker, so locate by TYPE: the url String
  1531 + // (contains cgi-bin) + the request/response protobufs (protobuf.f).
  1532 + Object self = p.thisObject;
  1533 + String url = null;
  1534 + java.util.List<Object> pbs = new java.util.ArrayList<>();
  1535 + for (Class<?> c = self.getClass(); c != null && !c.equals(Object.class); c = c.getSuperclass()) {
  1536 + for (java.lang.reflect.Field f : c.getDeclaredFields()) {
  1537 + if (java.lang.reflect.Modifier.isStatic(f.getModifiers())) continue;
  1538 + f.setAccessible(true);
  1539 + Object v; try { v = f.get(self); } catch (Throwable t) { continue; }
  1540 + if (v instanceof String && ((String) v).contains("cgi-bin")) url = (String) v;
  1541 + else if (v != null && pbCls.isInstance(v)) pbs.add(v);
  1542 + }
  1543 + }
  1544 + if (url == null || !url.contains("uploadvoice")) return; // gate: voice cgi only
  1545 + StringBuilder sb = new StringBuilder("[WeChatHook] VOICECGI url=" + url + " pbCount=" + pbs.size());
  1546 + int idx = 0;
  1547 + for (Object req : pbs) {
  1548 + sb.append("\n -- pb#").append(idx++).append(" ").append(req.getClass().getName()).append(" --");
  1549 + for (int i = 0; i <= 25; i++) {
  1550 + Object val = null; String kind = "";
  1551 + try { Object c = XposedHelpers.callMethod(req, "getCustom", i);
  1552 + if (c != null) { val = "custom:" + c.getClass().getName(); kind = "C"; } } catch (Throwable t) {}
  1553 + if (val == null) { try { Object s = XposedHelpers.callMethod(req, "getString", i); if (s != null) { val = s; kind = "S"; } } catch (Throwable t) {} }
  1554 + if (val == null) { try { Object iv = XposedHelpers.callMethod(req, "getInteger", i); if (iv != null && ((Integer) iv) != 0) { val = iv; kind = "I"; } } catch (Throwable t) {} }
  1555 + if (val == null) { try { Object lv = XposedHelpers.callMethod(req, "getLong", i); if (lv != null && ((Long) lv) != 0L) { val = lv; kind = "L"; } } catch (Throwable t) {} }
  1556 + if (val != null) {
  1557 + String vs = String.valueOf(val);
  1558 + if (vs.length() > 140) vs = vs.substring(0, 140) + "…";
  1559 + sb.append("\n [").append(i).append("](").append(kind).append(")=").append(vs);
  1560 + }
  1561 + }
  1562 + }
  1563 + XposedBridge.log(sb.toString());
  1564 + } catch (Throwable t) { XposedBridge.log("[WeChatHook] VOICECGI err: " + t); }
  1565 + }
  1566 + });
  1567 + XposedBridge.log("[WeChatHook] hookVoiceCgiCapture: hooked modelbase.l.a() in " + procName);
  1568 + } catch (Throwable t) { XposedBridge.log("[WeChatHook] hookVoiceCgiCapture failed in " + procName + ": " + t); }
  1569 + }
  1570 +
  1571 + private void hookVoiceUpload(ClassLoader cl, String procName) {
  1572 + try {
  1573 + Class<?> g1 = XposedHelpers.findClass("w21.g1", cl);
  1574 + int mh = 0;
  1575 + for (java.lang.reflect.Method m : g1.getDeclaredMethods()) {
  1576 + final String mName = m.getName();
  1577 + // L0(w0)=insert, P0(file,w0)=upload, D0/J0/y0/z0=lookups. Dump w0 on all.
  1578 + if (!("L0".equals(mName) || "P0".equals(mName))) continue;
  1579 + m.setAccessible(true);
  1580 + XposedBridge.hookMethod(m, new XC_MethodHook() {
  1581 + @Override protected void beforeHookedMethod(MethodHookParam p) {
  1582 + try {
  1583 + // find the w0 arg (w21.w0) among args
  1584 + Object w0 = null;
  1585 + if (p.args != null) for (Object a : p.args)
  1586 + if (a != null && a.getClass().getName().equals("w21.w0")) { w0 = a; break; }
  1587 + StringBuilder sb = new StringBuilder("[WeChatHook] g1." + mName
  1588 + + " by=" + (sCalledByUs ? "US" : "REAL") + " args=[");
  1589 + if (p.args != null) for (int i = 0; i < p.args.length; i++) {
  1590 + Object a = p.args[i];
  1591 + sb.append(i > 0 ? ", " : "").append(a == null ? "null"
  1592 + : (a instanceof String ? "\"" + a + "\"" : a.getClass().getSimpleName()));
  1593 + }
  1594 + sb.append("]");
  1595 + if (w0 != null) {
  1596 + sb.append("\n w21.w0 DUMP:");
  1597 + for (java.lang.reflect.Field f : w0.getClass().getDeclaredFields()) {
  1598 + if (java.lang.reflect.Modifier.isStatic(f.getModifiers())) continue;
  1599 + f.setAccessible(true);
  1600 + Object v = f.get(w0);
  1601 + String vs = v == null ? "null"
  1602 + : (v instanceof byte[] ? "byte[" + ((byte[]) v).length + "]"
  1603 + : (v instanceof String || v instanceof Number || v instanceof Boolean ? v.toString()
  1604 + : v.getClass().getSimpleName()));
  1605 + sb.append("\n ").append(f.getName()).append("=").append(vs);
  1606 + }
  1607 + }
  1608 + XposedBridge.log(sb.toString());
  1609 + } catch (Throwable ignored) {}
  1610 + }
  1611 + });
  1612 + mh++;
  1613 + }
  1614 + XposedBridge.log("[WeChatHook] hookVoiceUpload: hooked " + mh + " g1 method(s) in " + procName);
  1615 + } catch (Throwable t) {
  1616 + XposedBridge.log("[WeChatHook] hookVoiceUpload failed in " + procName + ": " + t);
  1617 + }
  1618 + }
  1619 +
  1620 + // ── Image send: hook b80.u.pj/Bi (the real non-suspend send entry) ───────────
  1621 + // b80.u (implements o70.e, = n0.c(o70.e.class)) is the image plugin. The send
  1622 + // coroutine b80.s calls b80.u.Bi(o70.h,String) then b80.u.pj(o70.h). We hook both
  1623 + // to (1) capture the live b80.u instance and (2) on a REAL image send, dump the
  1624 + // full o70.h (PreBuildParams) field-by-field — that's the exact recipe to replicate.
  1625 + private static void dumpO70h(Object h, String tag) {
  1626 + try {
  1627 + StringBuilder sb = new StringBuilder("[WeChatHook] " + tag + " o70.h DUMP:");
  1628 + for (java.lang.reflect.Field f : h.getClass().getDeclaredFields()) {
  1629 + if (java.lang.reflect.Modifier.isStatic(f.getModifiers())) continue;
  1630 + f.setAccessible(true);
  1631 + Object v = f.get(h);
  1632 + String vs = v == null ? "null"
  1633 + : (v instanceof byte[] ? "byte[" + ((byte[]) v).length + "]"
  1634 + : (v instanceof String || v instanceof Number || v instanceof Boolean || v instanceof android.net.Uri
  1635 + ? v.toString() : v.getClass().getName() + "@" + System.identityHashCode(v)));
  1636 + sb.append("\n ").append(f.getName()).append(" (").append(f.getType().getSimpleName())
  1637 + .append(") = ").append(vs);
  1638 + }
  1639 + XposedBridge.log(sb.toString());
  1640 + } catch (Throwable t) { XposedBridge.log("[WeChatHook] dumpO70h err: " + t); }
  1641 + }
  1642 +
  1643 + // Hook SendImgProxyUI.onCreate → dump the launch Intent extras. Real gallery image
  1644 + // sends run through this proxy Activity ($$a.run → b80.s coroutine → send). Capturing
  1645 + // its Intent lets us replay the WHOLE real flow via `am start` with the same extras.
  1646 + private void hookSendImgProxyUI(ClassLoader cl, String procName) {
  1647 + try {
  1648 + XposedHelpers.findAndHookMethod(
  1649 + "com.tencent.mm.ui.chatting.SendImgProxyUI", cl, "onCreate",
  1650 + android.os.Bundle.class, new XC_MethodHook() {
  1651 + @Override protected void afterHookedMethod(MethodHookParam p) {
  1652 + try {
  1653 + android.content.Intent it = (android.content.Intent)
  1654 + XposedHelpers.callMethod(p.thisObject, "getIntent");
  1655 + if (it == null) { XposedBridge.log("[WeChatHook] SendImgProxyUI: intent null"); return; }
  1656 + android.os.Bundle ex = it.getExtras();
  1657 + StringBuilder sb = new StringBuilder("[WeChatHook] SendImgProxyUI INTENT:");
  1658 + sb.append("\n action=").append(it.getAction());
  1659 + sb.append("\n data=").append(it.getDataString());
  1660 + if (ex != null) for (String key : ex.keySet()) {
  1661 + Object v = ex.get(key);
  1662 + String vs = v == null ? "null"
  1663 + : (v instanceof java.util.ArrayList ? "ArrayList" + v
  1664 + : (v.getClass().isArray() ? v.getClass().getSimpleName() + java.util.Arrays.deepToString(new Object[]{v})
  1665 + : v.toString()));
  1666 + if (vs.length() > 300) vs = vs.substring(0, 300) + "…";
  1667 + sb.append("\n [").append(key).append("] (")
  1668 + .append(v == null ? "?" : v.getClass().getName()).append(") = ").append(vs);
  1669 + }
  1670 + XposedBridge.log(sb.toString());
  1671 + } catch (Throwable t) { XposedBridge.log("[WeChatHook] SendImgProxyUI dump err: " + t); }
  1672 + }
  1673 + });
  1674 + XposedBridge.log("[WeChatHook] hookSendImgProxyUI OK in " + procName);
  1675 + } catch (Throwable t) {
  1676 + XposedBridge.log("[WeChatHook] hookSendImgProxyUI failed in " + procName + ": " + t);
  1677 + }
  1678 + }
  1679 +
  1680 + private void hookImageSend(ClassLoader cl, String procName) {
  1681 + try {
  1682 + Class<?> u = XposedHelpers.findClass("b80.u", cl);
  1683 + int n = 0;
  1684 + for (java.lang.reflect.Method m : u.getDeclaredMethods()) {
  1685 + final String mn = m.getName();
  1686 + if (!("pj".equals(mn) || "Bi".equals(mn) || "oj".equals(mn))) continue;
  1687 + Class<?>[] pts = m.getParameterTypes();
  1688 + if (pts.length < 1) continue;
  1689 + final boolean isOj = "oj".equals(mn); // oj(o70.k) = the insert/send step
  1690 + if (!isOj && !pts[0].getName().equals("o70.h")) continue;
  1691 + m.setAccessible(true);
  1692 + XposedBridge.hookMethod(m, new XC_MethodHook() {
  1693 + @Override protected void beforeHookedMethod(MethodHookParam p) {
  1694 + if (sImgPlugin == null && p.thisObject != null) {
  1695 + sImgPlugin = p.thisObject;
  1696 + XposedBridge.log("[WeChatHook] hookImageSend: captured b80.u=" + p.thisObject);
  1697 + }
  1698 + if (!sCalledByUs && p.args != null && p.args.length > 0 && p.args[0] != null) {
  1699 + if (isOj) {
  1700 + // o70.k — the insert object. Dump its full field tree.
  1701 + Object k = p.args[0];
  1702 + sRealImgK = k;
  1703 + XposedBridge.log("[WeChatHook] b80.u.oj REAL o70.k class=" + k.getClass().getName());
  1704 + dumpO70h(k, "b80.u.oj REAL o70.k");
  1705 + } else {
  1706 + sRealImgParams = p.args[0];
  1707 + dumpO70h(p.args[0], "b80.u." + mn + " REAL");
  1708 + }
  1709 + }
  1710 + }
  1711 + });
  1712 + n++;
  1713 + }
  1714 + XposedBridge.log("[WeChatHook] hookImageSend: hooked " + n + " b80.u method(s) in " + procName);
  1715 + } catch (Throwable t) {
  1716 + XposedBridge.log("[WeChatHook] hookImageSend failed in " + procName + ": " + t);
  1717 + }
  1718 + }
  1719 +
  1720 + // ── Voice send via internal API ────────────────────────────────────────────
  1721 + // Protocol (confirmed by hooking f9.convertTo() on real user send):
  1722 + // • content XML: <msg><voicemsg voicelength="{ms}" fromusername="{wxid}" isPlayed="false"></voicemsg></msg>
  1723 + // • imgPath : 26-char hex voice file ID (f9 field, determines file location)
  1724 + // • voice2 dir : voice2/md5(voiceId)[0:2]/md5(voiceId)[2:4]/msg_{voiceId}.amr (confirmed formula)
  1725 + //
  1726 + // Two modes (mutually exclusive extras):
  1727 + // (a) voice_id — caller pre-placed the SILK file in voice2 already (no file I/O here)
  1728 + // (b) src_path — WeChat-readable path (e.g. /data/data/com.tencent.mm/cache/…);
  1729 + // hook generates voice_id, copies src_path → voice2/{dir}/msg_{id}.amr internally
  1730 + // (WeChat process has full access to its own data dir, unlike root which is SELinux-blocked)
  1731 + //
  1732 + // Typical flow for TTS voice:
  1733 + // root: cp tts.amr /data/data/com.tencent.mm/cache/hook_voice_src.amr && chown u0_a330 ...
  1734 + // broadcast: --es to filehelper --es src_path /data/data/com.tencent.mm/cache/hook_voice_src.amr --ei duration 3000
  1735 + // ── Fully-synthetic voice via the uploadvoice cgi (this device's real path) ──
  1736 + // Captured from a real send (VOICECGI): voice audio uploads INLINE via
  1737 + // /cgi-bin/micromsg-bin/uploadvoice (cmdid 127, req pb r45.cv6), NOT the CDN engine.
  1738 + // The scene (modelbase.g extends m1) submits via z2.b — same queue as our text send.
  1739 + // SILK is chunked at 6000 bytes; each chunk is one cgi with the slice in field 7.
  1740 + // cv6 fields (from capture): 0=self 1=talker 2=netOffset 3=chunkLen
  1741 + // 4="newSendVoice_"+voiceId 6=durMs 7=cu5(silk slice) 8=endFlag 11=msgsource
  1742 + // 12=4 15=long(createId). Last chunk (8=1) response dv6.getLong(11)>0 = delivered.
  1743 + // Builds modelbase.o via its 13-arg constructor (signature-stable) to sidestep the
  1744 + // base-vs-tinker field-name mismatch on modelbase.l.
  1745 + private static void sendVoiceViaCgi(String talker, String srcPath, int durationMs, String fromWxId) throws Throwable {
  1746 + ClassLoader cl = sTinkerCL != null ? sTinkerCL : sWeChatCL;
  1747 + if (cl == null) throw new IllegalStateException("classloader null");
  1748 + if (srcPath == null || srcPath.isEmpty()) throw new IllegalArgumentException("need src_path (SILK)");
  1749 +
  1750 + byte[] silk;
  1751 + try (java.io.FileInputStream in = new java.io.FileInputStream(srcPath)) {
  1752 + java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream();
  1753 + byte[] buf = new byte[8192]; int n;
  1754 + while ((n = in.read(buf)) != -1) bos.write(buf, 0, n);
  1755 + silk = bos.toByteArray();
  1756 + }
  1757 + if (silk.length == 0) throw new IllegalStateException("empty SILK file: " + srcPath);
  1758 +
  1759 + // voiceId (26 hex) + clientmsgid link the chunks to one message.
  1760 + byte[] idBytes = new byte[13];
  1761 + new java.util.Random().nextBytes(idBytes);
  1762 + StringBuilder vsb = new StringBuilder(26);
  1763 + for (byte b : idBytes) vsb.append(String.format("%02x", b & 0xff));
  1764 + String voiceId = vsb.toString();
  1765 + String clientMsgId = "newSendVoice_" + voiceId;
  1766 + String self = (fromWxId != null && !fromWxId.isEmpty()) ? fromWxId
  1767 + : (sMyWxid != null ? sMyWxid : "wxid_51ap3m6p9xs722");
  1768 +
  1769 + // ── Insert a local f9 voice bubble so the SENDER also sees the message ──────
  1770 + // The uploadvoice cgi uploads+delivers to the recipient but does NOT create the
  1771 + // sender's local row. Place the SILK at voice2/msg_<voiceId>.amr (in-process
  1772 + // write works as the WeChat uid, unlike an external root cp) so local playback
  1773 + // resolves, then construct r0 (Object-ctor) which inserts the f9 with the
  1774 + // voicemsg XML + voiceId injected by hookF9ImgPathInjector. Best-effort: a
  1775 + // failure here only costs the sender-side bubble, the cgi send still runs.
  1776 + String voice2Path = null;
  1777 + try {
  1778 + java.security.MessageDigest md5 = java.security.MessageDigest.getInstance("MD5");
  1779 + byte[] md = md5.digest(voiceId.getBytes("UTF-8"));
  1780 + String d1 = String.format("%02x", md[0] & 0xff), d2 = String.format("%02x", md[1] & 0xff);
  1781 + String mmDir = "/data/data/com.tencent.mm/MicroMsg/653cdc2320abb089e149a3f7a7683fcf";
  1782 + String destDir = mmDir + "/voice2/" + d1 + "/" + d2;
  1783 + voice2Path = destDir + "/msg_" + voiceId + ".amr";
  1784 + new java.io.File(destDir).mkdirs();
  1785 + try (java.io.FileOutputStream out = new java.io.FileOutputStream(voice2Path)) { out.write(silk); }
  1786 + new java.io.File(voice2Path).setReadable(true, false);
  1787 +
  1788 + String voiceXml = "<msg><voicemsg voicelength=\"" + durationMs
  1789 + + "\" fromusername=\"" + self + "\" isPlayed=\"false\"></voicemsg></msg>";
  1790 + Class<?> f9Class = XposedHelpers.findClass("com.tencent.mm.storage.f9", cl);
  1791 + if (sF9SetterMap == null) sF9SetterMap = probeAllF9Setters(f9Class);
  1792 + byte[] syntheticLv = new byte[74]; syntheticLv[0] = 0x7b;
  1793 + Class<?> r0Class = XposedHelpers.findClass("w11.r0", cl);
  1794 + sPendingVoiceId.set(voiceId); sPendingLvBuffer.set(syntheticLv);
  1795 + sVoiceCgiInsert.set(Boolean.TRUE); sCalledByUs = true;
  1796 + Object r0;
  1797 + try {
  1798 + r0 = XposedHelpers.newInstance(r0Class,
  1799 + new Class<?>[]{String.class, String.class, int.class, int.class, Object.class},
  1800 + talker, voiceXml, 34, 0, voice2Path); // ctor inserts the f9 (status forced to 4=sent)
  1801 + } finally { sCalledByUs = false; sPendingVoiceId.remove(); sPendingLvBuffer.remove(); sVoiceCgiInsert.remove(); }
  1802 + XposedBridge.log("[WeChatHook] sendVoiceViaCgi: local f9 bubble inserted voiceId=" + voiceId
  1803 + + " r0=" + (r0 == null ? "null" : r0.getClass().getName()));
  1804 + } catch (Throwable t) {
  1805 + XposedBridge.log("[WeChatHook] sendVoiceViaCgi: local f9 insert failed (bubble only, send continues): " + t);
  1806 + }
  1807 +
  1808 + Class<?> cv6C = XposedHelpers.findClass("r45.cv6", cl);
  1809 + Class<?> dv6C = XposedHelpers.findClass("r45.dv6", cl);
  1810 + Class<?> cu5C = XposedHelpers.findClass("r45.cu5", cl);
  1811 + Class<?> oCls = XposedHelpers.findClass("com.tencent.mm.modelbase.o", cl);
  1812 + Class<?> iCls = XposedHelpers.findClass("com.tencent.mm.modelbase.i", cl);
  1813 + Class<?> m1Cls = XposedHelpers.findClass("com.tencent.mm.modelbase.m1", cl);
  1814 + Class<?> pq5g = XposedHelpers.findClass("pq5.g", cl);
  1815 + Class<?> km5d = XposedHelpers.findClass("km5.d", cl);
  1816 + // i.l() (no-arg → pq5.g) registers the response consumer + returns the deferred;
  1817 + // pq5.g.l(long) (long → km5.d) is the terminal subscribe that fires the cgi.
  1818 + java.lang.reflect.Method runM = null;
  1819 + for (java.lang.reflect.Method m : iCls.getDeclaredMethods())
  1820 + if (m.getParameterCount() == 0 && m.getReturnType() == pq5g) { runM = m; runM.setAccessible(true); break; }
  1821 + java.lang.reflect.Method subM = null;
  1822 + for (java.lang.reflect.Method m : pq5g.getDeclaredMethods()) {
  1823 + Class<?>[] pt = m.getParameterTypes();
  1824 + if (pt.length == 1 && pt[0] == long.class && m.getReturnType() == km5d) { subM = m; subM.setAccessible(true); break; }
  1825 + }
  1826 + if (runM == null || subM == null) throw new IllegalStateException("i.l()/pq5.g.l(long) not found: run=" + runM + " sub=" + subM);
  1827 +
  1828 + // modelbase.o(req, resp, url, cmdid, 0, 0, true, 0, 0, false, 0, false, null) — find by arg count.
  1829 + java.lang.reflect.Constructor<?> oCtor = null;
  1830 + for (java.lang.reflect.Constructor<?> c : oCls.getDeclaredConstructors())
  1831 + if (c.getParameterTypes().length == 13) { oCtor = c; break; }
  1832 + if (oCtor == null) throw new IllegalStateException("modelbase.o 13-arg ctor not found");
  1833 + oCtor.setAccessible(true);
  1834 + // modelbase.i.p(o) — locate by signature (one param assignable from modelbase.o).
  1835 + java.lang.reflect.Method pM = null;
  1836 + for (java.lang.reflect.Method m : iCls.getDeclaredMethods()) {
  1837 + Class<?>[] pt = m.getParameterTypes();
  1838 + if (m.getReturnType() == void.class && pt.length == 1 && pt[0].isAssignableFrom(oCls)) { pM = m; break; }
  1839 + }
  1840 + if (pM == null) throw new IllegalStateException("modelbase.i.p(o) not found");
  1841 + pM.setAccessible(true);
  1842 + // i's scene field (type modelbase.g / m1 subclass).
  1843 + java.lang.reflect.Field sceneF = null;
  1844 + for (java.lang.reflect.Field f : iCls.getDeclaredFields()) {
  1845 + if (java.lang.reflect.Modifier.isStatic(f.getModifiers())) continue;
  1846 + if (m1Cls.isAssignableFrom(f.getType())) { sceneF = f; sceneF.setAccessible(true); break; }
  1847 + }
  1848 + if (sceneF == null) throw new IllegalStateException("modelbase.i scene field not found");
  1849 +
  1850 + final String URL = "/cgi-bin/micromsg-bin/uploadvoice";
  1851 + final int CHUNK = 6000;
  1852 + int total = silk.length, chunks = 0;
  1853 + Object lastDv6 = null;
  1854 + sCalledByUs = true;
  1855 + try {
  1856 + for (int off = 0; off < total; off += CHUNK) {
  1857 + int len = Math.min(CHUNK, total - off);
  1858 + boolean last = (off + len >= total);
  1859 + byte[] slice = java.util.Arrays.copyOfRange(silk, off, off + len);
  1860 +
  1861 + Object cv6 = XposedHelpers.newInstance(cv6C);
  1862 + XposedHelpers.callMethod(cv6, "set", 0, self);
  1863 + XposedHelpers.callMethod(cv6, "set", 1, talker);
  1864 + XposedHelpers.callMethod(cv6, "set", 2, Integer.valueOf(off));
  1865 + XposedHelpers.callMethod(cv6, "set", 3, Integer.valueOf(len));
  1866 + XposedHelpers.callMethod(cv6, "set", 4, clientMsgId);
  1867 + XposedHelpers.callMethod(cv6, "set", 6, Integer.valueOf(durationMs));
  1868 + Object cu5 = XposedHelpers.newInstance(cu5C);
  1869 + XposedHelpers.callMethod(cu5, "e", slice, 0, len); // cu5.e(byte[],off,len)
  1870 + XposedHelpers.callMethod(cv6, "set", 7, cu5);
  1871 + XposedHelpers.callMethod(cv6, "set", 8, Integer.valueOf(last ? 1 : 0));
  1872 + XposedHelpers.callMethod(cv6, "set", 11, ""); // msgsource (empty for filehelper)
  1873 + XposedHelpers.callMethod(cv6, "set", 12, Integer.valueOf(4));
  1874 + // [15] = per-message create id (long). Real sends carry it; derive a
  1875 + // large positive nonce from the voiceId so the server has a stable value.
  1876 + long createId = (System.currentTimeMillis() << 20) ^ (voiceId.hashCode() & 0xffffffffL);
  1877 + if (createId < 0) createId = -createId;
  1878 + XposedHelpers.callMethod(cv6, "set", 15, Long.valueOf(createId));
  1879 +
  1880 + Object dv6 = XposedHelpers.newInstance(dv6C);
  1881 + lastDv6 = dv6;
  1882 + Object o = oCtor.newInstance(cv6, dv6, URL, 127, 0, 0, Boolean.TRUE, 0, 0, Boolean.FALSE, 0, Boolean.FALSE, null);
  1883 + Object iInst = XposedHelpers.newInstance(iCls);
  1884 + pM.invoke(iInst, o);
  1885 + Object scene = sceneF.get(iInst);
  1886 + // Dispatch: i.l() registers the response consumer + returns the deferred,
  1887 + // then pq5.g.l(timeout) subscribes → the cgi actually runs. Bare z2.b(g)
  1888 + // doesn't wire the callback, so the scene never completes.
  1889 + Object deferred = runM.invoke(iInst);
  1890 + sVoiceCgiKeepAlive.add(iInst); // deferred holds i weakly
  1891 + if (deferred != null) { sVoiceCgiKeepAlive.add(deferred); subM.invoke(deferred, 30000L); }
  1892 + chunks++;
  1893 + XposedBridge.log("[WeChatHook] sendVoiceViaCgi: chunk off=" + off + " len=" + len
  1894 + + " last=" + last + " scene=" + (scene == null ? "null" : scene.getClass().getName())
  1895 + + " deferred=" + (deferred == null ? "null" : deferred.getClass().getName()));
  1896 + }
  1897 + } finally { sCalledByUs = false; }
  1898 + XposedBridge.log("[WeChatHook] sendVoiceViaCgi: submitted " + chunks + " chunk(s) to=" + talker
  1899 + + " voiceId=" + voiceId + " silk=" + total + "B dur=" + durationMs + " self=" + self);
  1900 +
  1901 + // Read the server response off the SAME dv6 we passed (WeChat parses the cgi
  1902 + // reply into it). getLong(11) = assigned server msgId; >0 means the server
  1903 + // accepted the voice = delivered. Poll a few times since z2.b is async.
  1904 + final Object fDv6 = lastDv6;
  1905 + final String fVoice = voiceId;
  1906 + if (fDv6 != null) {
  1907 + final Handler h = new Handler(Looper.getMainLooper());
  1908 + final int[] tries = {0};
  1909 + Runnable poll = new Runnable() {
  1910 + @Override public void run() {
  1911 + tries[0]++;
  1912 + try {
  1913 + long svrMsgId = 0; try { Object v = XposedHelpers.callMethod(fDv6, "getLong", 11); if (v != null) svrMsgId = (Long) v; } catch (Throwable t) {}
  1914 + int netOff = 0; try { Object v = XposedHelpers.callMethod(fDv6, "getInteger", 2); if (v != null) netOff = (Integer) v; } catch (Throwable t) {}
  1915 + if (svrMsgId != 0 || netOff != 0 || tries[0] >= 6) {
  1916 + StringBuilder sb = new StringBuilder("[WeChatHook] sendVoiceViaCgi RESP try=" + tries[0]
  1917 + + " voiceId=" + fVoice + " svrMsgId(11)=" + svrMsgId + " netOffset(2)=" + netOff + " dv6:");
  1918 + for (int i = 0; i <= 15; i++) {
  1919 + Object val = null;
  1920 + try { Object s = XposedHelpers.callMethod(fDv6, "getString", i); if (s != null && !"".equals(s)) val = "S:" + s; } catch (Throwable t) {}
  1921 + if (val == null) { try { Object iv = XposedHelpers.callMethod(fDv6, "getInteger", i); if (iv != null && ((Integer) iv) != 0) val = "I:" + iv; } catch (Throwable t) {} }
  1922 + if (val == null) { try { Object lv = XposedHelpers.callMethod(fDv6, "getLong", i); if (lv != null && ((Long) lv) != 0L) val = "L:" + lv; } catch (Throwable t) {} }
  1923 + if (val != null) sb.append("\n [").append(i).append("]=").append(val);
  1924 + }
  1925 + XposedBridge.log(sb.toString());
  1926 + } else {
  1927 + h.postDelayed(this, 1500);
  1928 + }
  1929 + } catch (Throwable t) { XposedBridge.log("[WeChatHook] sendVoiceViaCgi RESP err: " + t); }
  1930 + }
  1931 + };
  1932 + h.postDelayed(poll, 1500);
  1933 + }
  1934 + }
  1935 +
  1936 + private static void sendVoiceViaAPI(String talker, String voiceId, String srcPath,
  1937 + int durationMs, String fromWxId) throws Throwable {
  1938 + ClassLoader cl = sTinkerCL != null ? sTinkerCL : sWeChatCL;
  1939 + if (cl == null) throw new IllegalStateException("classloader null");
  1940 +
  1941 + // ── File placement ───────────────────────────────────────────────────
  1942 + if (voiceId == null || voiceId.isEmpty()) {
  1943 + // Mode (b): generate voice_id and copy SILK from srcPath
  1944 + if (srcPath == null || srcPath.isEmpty())
  1945 + throw new IllegalArgumentException("need either voice_id or src_path");
  1946 + byte[] idBytes = new byte[13];
  1947 + new java.util.Random().nextBytes(idBytes);
  1948 + StringBuilder sb = new StringBuilder(26);
  1949 + for (byte b : idBytes) sb.append(String.format("%02x", b & 0xff));
  1950 + voiceId = sb.toString();
  1951 +
  1952 + java.security.MessageDigest md5 = java.security.MessageDigest.getInstance("MD5");
  1953 + byte[] mdBytes = md5.digest(voiceId.getBytes("UTF-8"));
  1954 + String dir1 = String.format("%02x", mdBytes[0] & 0xff);
  1955 + String dir2 = String.format("%02x", mdBytes[1] & 0xff);
  1956 +
  1957 + String mmDir = "/data/data/com.tencent.mm/MicroMsg/653cdc2320abb089e149a3f7a7683fcf";
  1958 + String destDir = mmDir + "/voice2/" + dir1 + "/" + dir2;
  1959 + String destPath = destDir + "/msg_" + voiceId + ".amr";
  1960 + new java.io.File(destDir).mkdirs();
  1961 + try (java.io.FileInputStream in = new java.io.FileInputStream(srcPath);
  1962 + java.io.FileOutputStream out = new java.io.FileOutputStream(destPath)) {
  1963 + byte[] buf = new byte[8192]; int n;
  1964 + while ((n = in.read(buf)) != -1) out.write(buf, 0, n);
  1965 + }
  1966 + new java.io.File(destPath).setReadable(true, false);
  1967 + XposedBridge.log("[WeChatHook] sendVoiceViaAPI: " + srcPath + " -> " + destPath);
  1968 + } else if (voiceId.length() != 26) {
  1969 + throw new IllegalArgumentException("voice_id must be 26 hex chars, got: " + voiceId);
  1970 + }
  1971 +
  1972 + // ── Build voice XML ──────────────────────────────────────────────────
  1973 + // fromusername is the sender's wxid. sMyWxid is auto-discovered from the first
  1974 + // real UI voice send; fall back to the known wxid for this device.
  1975 + String safeFrom = (fromWxId != null && !fromWxId.isEmpty())
  1976 + ? fromWxId
  1977 + : (sMyWxid != null ? sMyWxid : "wxid_51ap3m6p9xs722");
  1978 + String voiceXml = "<msg><voicemsg voicelength=\"" + durationMs
  1979 + + "\" fromusername=\"" + safeFrom + "\" isPlayed=\"false\"></voicemsg></msg>";
  1980 +
  1981 + // ── Probe f9 setter map BEFORE setting sPendingVoiceId ───────────────
  1982 + Class<?> f9Class = XposedHelpers.findClass("com.tencent.mm.storage.f9", cl);
  1983 + if (sF9SetterMap == null) sF9SetterMap = probeAllF9Setters(f9Class);
  1984 + if (!sF9SetterMap.containsKey("imgPath"))
  1985 + throw new IllegalStateException("imgPath setter not found in map: " + sF9SetterMap);
  1986 +
  1987 + // ── Synthetic lvbuffer (waveform visualization, NOT the SILK audio) ────
  1988 + // Real WeChat always stores exactly 74 bytes regardless of duration:
  1989 + // 4-byte header (0x7b 0x00 0x00 0x00) + 70 zero bytes. The size is fixed.
  1990 + byte[] syntheticLv = new byte[74];
  1991 + syntheticLv[0] = 0x7b;
  1992 + // bytes 1..73 default to 0x00
  1993 + XposedBridge.log("[WeChatHook] sendVoiceViaAPI: synthetic lvbuffer 74 bytes for " + durationMs + "ms");
  1994 +
  1995 + // ── Create r0: try Object-param ctor first (real WeChat voice path) ─────
  1996 + // WeChat has two 5-param r0 ctors:
  1997 + // (String, String, int, int, long) — timestamp variant
  1998 + // (String, String, int, int, Object) — audio-path variant (used by real voice sends)
  1999 + // The Object ctor stores the audio file reference that H() reads for CDN upload.
  2000 + // If it works, r0 sets imgPath internally and H() can find the SILK for upload.
  2001 + // Fall back to long ctor if the Object ctor throws.
  2002 + Class<?> r0Class = XposedHelpers.findClass("w11.r0", cl);
  2003 + sPendingVoiceId.set(voiceId);
  2004 + sPendingLvBuffer.set(syntheticLv);
  2005 + sCalledByUs = true;
  2006 + Object r0;
  2007 + try {
  2008 + try {
  2009 + // Pass the voice2 path (where we placed the SILK) as the Object arg.
  2010 + // Real WeChat passes the temp recording file; we pass our already-placed file.
  2011 + String voiceFilePath = srcPath != null ? srcPath
  2012 + : "/data/data/com.tencent.mm/MicroMsg/653cdc2320abb089e149a3f7a7683fcf/voice2/"
  2013 + + String.format("%02x", java.security.MessageDigest.getInstance("MD5")
  2014 + .digest(voiceId.getBytes("UTF-8"))[0] & 0xff)
  2015 + + "/" + String.format("%02x", java.security.MessageDigest.getInstance("MD5")
  2016 + .digest(voiceId.getBytes("UTF-8"))[1] & 0xff)
  2017 + + "/msg_" + voiceId + ".amr";
  2018 + r0 = XposedHelpers.newInstance(r0Class,
  2019 + new Class<?>[]{String.class, String.class, int.class, int.class, Object.class},
  2020 + talker, voiceXml, 34, 0, voiceFilePath);
  2021 + XposedBridge.log("[WeChatHook] sendVoiceViaAPI: used Object-ctor with path=" + voiceFilePath);
  2022 + } catch (Throwable e1) {
  2023 + XposedBridge.log("[WeChatHook] sendVoiceViaAPI: Object-ctor failed (" + e1 + "), falling back to long-ctor");
  2024 + long ts = System.currentTimeMillis();
  2025 + r0 = XposedHelpers.newInstance(r0Class,
  2026 + new Class<?>[]{String.class, String.class, int.class, int.class, long.class},
  2027 + talker, voiceXml, 34, 0, ts);
  2028 + }
  2029 + } finally {
  2030 + sCalledByUs = false;
  2031 + sPendingVoiceId.remove();
  2032 + sPendingLvBuffer.remove();
  2033 + }
  2034 + // Dump r0 fields so we can see which field holds the audio path.
  2035 + dumpR0Fields(r0, "r0-after-ctor by=US");
  2036 +
  2037 + // ── Leave r0.p == null: take the AUTHENTIC new-send path ──────────────────
  2038 + // The r0 Object-ctor already inserted a proper f9 (content=voiceXml, imgPath
  2039 + // injected by hookF9ImgPathInjector, status=1) via cj().N9(). With p==null,
  2040 + // NetSceneSendMsg.doScene() reloads that f9 fresh from the DB (cj().c6()) —
  2041 + // content/msgsource intact — and assembles + dispatches it, exactly like a
  2042 + // real new send.
  2043 + //
  2044 + // Previously we forced r0.p = sDatabaseF9. That sends doScene down the *resend*
  2045 + // branch (which expects status=5 and logs "should not be resend"), handing the
  2046 + // MsgSource assembler a stale in-memory f9 whose content reads null →
  2047 + // e14.k.fromXml(null) NPE. That was the root-cause blocker; fixed by NOT setting p.
  2048 + sDatabaseF9.remove();
  2049 +
  2050 + // ── Set dispatcher on r0 (required for CDN upload via H()) ─────────────
  2051 + // r0.dispatcher (type com.tencent.mm.network.s) is null in our synthetic r0.
  2052 + // H() NPEs on `dispatcher.J(z0, o0, int)` if null.
  2053 + // We capture dispatcher from the first real WeChat r0 that enters H() (hookR0H).
  2054 + if (sCapturedDispatcher != null && !(sCapturedDispatcher instanceof java.lang.reflect.Proxy)) {
  2055 + // Real dispatcher captured from a genuine WeChat send — use it directly.
  2056 + try {
  2057 + XposedHelpers.setObjectField(r0, "dispatcher", sCapturedDispatcher);
  2058 + XposedBridge.log("[WeChatHook] sendVoiceViaAPI: set r0.dispatcher REAL class="
  2059 + + sCapturedDispatcher.getClass().getName());
  2060 + } catch (Throwable ed) {
  2061 + XposedBridge.log("[WeChatHook] sendVoiceViaAPI: r0.dispatcher set failed: " + ed);
  2062 + }
  2063 + } else {
  2064 + // No real dispatcher yet — fall through to proxy creation below.
  2065 + // (Cached proxy is reused if already created; fresh one made otherwise.)
  2066 + // Dispatcher not yet captured — search WeChat singletons for it.
  2067 + // gm0.j1.i() returns the message manager which likely holds a dispatcher reference.
  2068 + XposedBridge.log("[WeChatHook] sendVoiceViaAPI: dispatcher null, searching singletons...");
  2069 + outer:
  2070 + for (String[] candidate : new String[][]{
  2071 + {"gm0.j1", "i"}, // message manager singleton
  2072 + {"tg3.t1", "a"}, // service locator
  2073 + }) {
  2074 + try {
  2075 + Class<?> mgr = XposedHelpers.findClass(candidate[0], cl);
  2076 + Object inst = XposedHelpers.callStaticMethod(mgr, candidate[1]);
  2077 + if (inst == null) continue;
  2078 + // Walk inst's fields recursively (depth 2) looking for dispatcher
  2079 + for (java.lang.reflect.Field f1 : inst.getClass().getDeclaredFields()) {
  2080 + if (java.lang.reflect.Modifier.isStatic(f1.getModifiers())) continue;
  2081 + f1.setAccessible(true);
  2082 + Object v1 = f1.get(inst);
  2083 + if (v1 == null) continue;
  2084 + // Level 1: try r0.dispatcher field name on v1 directly
  2085 + try {
  2086 + Object d = XposedHelpers.getObjectField(v1, "dispatcher");
  2087 + if (d != null) {
  2088 + sCapturedDispatcher = d;
  2089 + XposedBridge.log("[WeChatHook] dispatcher found: "
  2090 + + candidate[0] + "." + candidate[1] + "()." + f1.getName()
  2091 + + ".dispatcher=" + d.getClass().getName());
  2092 + XposedHelpers.setObjectField(r0, "dispatcher", d);
  2093 + break outer;
  2094 + }
  2095 + } catch (Throwable ignored1) {}
  2096 + // Level 2: walk v1's fields
  2097 + for (java.lang.reflect.Field f2 : v1.getClass().getDeclaredFields()) {
  2098 + if (java.lang.reflect.Modifier.isStatic(f2.getModifiers())) continue;
  2099 + f2.setAccessible(true);
  2100 + Object v2 = f2.get(v1);
  2101 + if (v2 == null) continue;
  2102 + try {
  2103 + Object d = XposedHelpers.getObjectField(v2, "dispatcher");
  2104 + if (d != null) {
  2105 + sCapturedDispatcher = d;
  2106 + XposedBridge.log("[WeChatHook] dispatcher found (L2): "
  2107 + + candidate[0] + "." + candidate[1] + "()."
  2108 + + f1.getName() + "." + f2.getName()
  2109 + + ".dispatcher=" + d.getClass().getName());
  2110 + XposedHelpers.setObjectField(r0, "dispatcher", d);
  2111 + break outer;
  2112 + }
  2113 + } catch (Throwable ignored2) {}
  2114 + }
  2115 + }
  2116 + } catch (Throwable t2) {
  2117 + XposedBridge.log("[WeChatHook] dispatcher search " + candidate[0] + ": " + t2);
  2118 + }
  2119 + }
  2120 + if (sCapturedDispatcher instanceof java.lang.reflect.Proxy) {
  2121 + // Already have a proxy from a previous voice send — reuse it on this r0.
  2122 + try {
  2123 + XposedHelpers.setObjectField(r0, "dispatcher", sCapturedDispatcher);
  2124 + XposedBridge.log("[WeChatHook] sendVoiceViaAPI: reusing proxy dispatcher on r0");
  2125 + } catch (Throwable ed2) {
  2126 + XposedBridge.log("[WeChatHook] sendVoiceViaAPI: proxy reuse failed: " + ed2);
  2127 + }
  2128 + } else if (sCapturedDispatcher == null) {
  2129 + // Still not found — create a minimal proxy for s that logs calls.
  2130 + // This lets H() proceed past the NPE; CDN upload may work via callback.
  2131 + try {
  2132 + Class<?> sInterface = XposedHelpers.findClass("com.tencent.mm.network.s", cl);
  2133 + java.lang.reflect.Method[] sMethods = sInterface.getDeclaredMethods();
  2134 + StringBuilder sb = new StringBuilder("[WeChatHook] s interface methods:\n");
  2135 + for (java.lang.reflect.Method m : sMethods) sb.append(" ").append(m.toGenericString()).append("\n");
  2136 + XposedBridge.log(sb.toString());
  2137 + Object dispProxy = java.lang.reflect.Proxy.newProxyInstance(cl,
  2138 + new Class<?>[]{sInterface},
  2139 + (proxy, method, args) -> {
  2140 + XposedBridge.log("[WeChatHook] dispatcher.proxy: " + method.getName()
  2141 + + " ret=" + method.getReturnType().getSimpleName());
  2142 + if ("J".equals(method.getName()) && args != null && args.length >= 2) {
  2143 + // ── Attempt 1: forward J() via l0 method on m05.j ──────────────
  2144 + // method.declaringClass = s (s has own J, NOT inherited from l0).
  2145 + // m05.j implements l0 (not s). Solution: get J() from l0 interface
  2146 + // and invoke THAT on m05.j — valid since m05.j implements l0.
  2147 + try {
  2148 + Object q2arg = args[1];
  2149 + Object mj = XposedHelpers.getObjectField(q2arg, "e");
  2150 + if (mj != null) {
  2151 + Class<?> l0Iface = XposedHelpers.findClass("com.tencent.mm.network.l0", cl);
  2152 + // Dump l0 methods for diagnosis
  2153 + StringBuilder l0log = new StringBuilder("[WeChatHook] l0 methods:");
  2154 + java.lang.reflect.Method foundL0J = null;
  2155 + for (java.lang.reflect.Method l0m : l0Iface.getDeclaredMethods()) {
  2156 + l0log.append(" ").append(l0m.getName()).append("(").append(l0m.getParameterTypes().length).append(")");
  2157 + if (l0m.getName().equals(method.getName())
  2158 + && l0m.getParameterTypes().length == method.getParameterTypes().length) {
  2159 + foundL0J = l0m;
  2160 + }
  2161 + }
  2162 + XposedBridge.log(l0log.toString());
  2163 + if (foundL0J != null) {
  2164 + foundL0J.setAccessible(true);
  2165 + XposedBridge.log("[WeChatHook] J→m05.j via l0.J()");
  2166 + try {
  2167 + Object result = foundL0J.invoke(mj, args);
  2168 + XposedBridge.log("[WeChatHook] J→m05.j l0 OK result=" + result);
  2169 + return result;
  2170 + } catch (java.lang.reflect.InvocationTargetException ite) {
  2171 + XposedBridge.log("[WeChatHook] J→m05.j l0 threw: " + ite.getCause());
  2172 + }
  2173 + } else {
  2174 + XposedBridge.log("[WeChatHook] J not found in l0 by name+paramcount");
  2175 + }
  2176 + }
  2177 + } catch (Throwable jt0) {
  2178 + XposedBridge.log("[WeChatHook] J→m05.j err: " + jt0);
  2179 + }
  2180 + // ── Attempt 2: find s implementor in J() args (skip l0-only classes) ──
  2181 + try {
  2182 + Class<?> sIface = XposedHelpers.findClass("com.tencent.mm.network.s", cl);
  2183 + for (Object searchRoot : new Object[]{args[0], args[1]}) {
  2184 + if (searchRoot == null) continue;
  2185 + for (Class<?> cls2 = searchRoot.getClass(); cls2 != null && !cls2.equals(Object.class); cls2 = cls2.getSuperclass()) {
  2186 + for (java.lang.reflect.Field sf1 : cls2.getDeclaredFields()) {
  2187 + if (java.lang.reflect.Modifier.isStatic(sf1.getModifiers())) continue;
  2188 + sf1.setAccessible(true);
  2189 + Object sv1 = sf1.get(searchRoot);
  2190 + if (sv1 == null || sv1 == proxy) continue;
  2191 + if (sIface.isInstance(sv1)) {
  2192 + sCapturedDispatcher = sv1;
  2193 + XposedBridge.log("[WeChatHook] J: REAL s L1 " + sv1.getClass().getName());
  2194 + try { return method.invoke(sv1, args); } catch (java.lang.reflect.InvocationTargetException ite) { throw ite.getCause(); }
  2195 + }
  2196 + // L2 sub-fields
  2197 + try {
  2198 + for (Class<?> cls3 = sv1.getClass(); cls3 != null && !cls3.equals(Object.class); cls3 = cls3.getSuperclass()) {
  2199 + for (java.lang.reflect.Field sf2 : cls3.getDeclaredFields()) {
  2200 + if (java.lang.reflect.Modifier.isStatic(sf2.getModifiers())) continue;
  2201 + sf2.setAccessible(true);
  2202 + Object sv2 = sf2.get(sv1);
  2203 + if (sv2 == null || sv2 == proxy) continue;
  2204 + if (sIface.isInstance(sv2)) {
  2205 + sCapturedDispatcher = sv2;
  2206 + XposedBridge.log("[WeChatHook] J: REAL s L2 " + sf1.getName() + "." + sf2.getName() + " " + sv2.getClass().getName());
  2207 + try { return method.invoke(sv2, args); } catch (java.lang.reflect.InvocationTargetException ite) { throw ite.getCause(); }
  2208 + }
  2209 + }
  2210 + }
  2211 + } catch (Throwable ignored4) {}
  2212 + }
  2213 + }
  2214 + }
  2215 + XposedBridge.log("[WeChatHook] J: no s implementor in args");
  2216 + } catch (Throwable jse) {
  2217 + XposedBridge.log("[WeChatHook] J: search err: " + jse);
  2218 + }
  2219 + // ── Deep-dump v2 (CDN request) sub-fields ───────────────────
  2220 + try {
  2221 + Object v2arg = args[0];
  2222 + StringBuilder cdnDump = new StringBuilder("[WeChatHook] J v2-deep:\n");
  2223 + // Walk class hierarchy (getDeclaredFields only finds own fields)
  2224 + for (Class<?> cls2 = v2arg.getClass(); cls2 != null && !cls2.equals(Object.class); cls2 = cls2.getSuperclass()) {
  2225 + for (java.lang.reflect.Field sf1 : cls2.getDeclaredFields()) {
  2226 + if (java.lang.reflect.Modifier.isStatic(sf1.getModifiers())) continue;
  2227 + sf1.setAccessible(true);
  2228 + Object sv1 = sf1.get(v2arg);
  2229 + cdnDump.append(" ").append(sf1.getName()).append("=")
  2230 + .append(sv1 == null ? "null" : sv1.getClass().getSimpleName()).append("\n");
  2231 + if (sv1 == null) continue;
  2232 + for (java.lang.reflect.Field sf2 : sv1.getClass().getDeclaredFields()) {
  2233 + if (java.lang.reflect.Modifier.isStatic(sf2.getModifiers())) continue;
  2234 + sf2.setAccessible(true);
  2235 + Object sv2 = sf2.get(sv1);
  2236 + String sv2s;
  2237 + if (sv2 == null) { sv2s = "null"; }
  2238 + else if (sv2 instanceof byte[]) {
  2239 + byte[] ba = (byte[]) sv2;
  2240 + StringBuilder hex = new StringBuilder();
  2241 + for (int xi = 0; xi < Math.min(ba.length, 20); xi++) hex.append(String.format("%02x", ba[xi] & 0xff));
  2242 + sv2s = "byte[" + ba.length + "]=" + hex;
  2243 + } else {
  2244 + sv2s = sv2.toString();
  2245 + if (sv2s.length() > 100) sv2s = sv2s.substring(0, 100);
  2246 + }
  2247 + cdnDump.append(" ").append(sf1.getName()).append(".").append(sf2.getName()).append("=").append(sv2s).append("\n");
  2248 + }
  2249 + }
  2250 + }
  2251 + XposedBridge.log(cdnDump.toString());
  2252 + } catch (Throwable jde) {
  2253 + XposedBridge.log("[WeChatHook] J v2-dump err: " + jde);
  2254 + }
  2255 + }
  2256 + if (method.getReturnType() == int.class || method.getReturnType() == Integer.class)
  2257 + return 0;
  2258 + if (method.getReturnType() == boolean.class || method.getReturnType() == Boolean.class)
  2259 + return false;
  2260 + return null;
  2261 + });
  2262 + sCapturedDispatcher = dispProxy;
  2263 + XposedHelpers.setObjectField(r0, "dispatcher", dispProxy);
  2264 + XposedBridge.log("[WeChatHook] sendVoiceViaAPI: dispatcher proxy set on r0");
  2265 + } catch (Throwable ep) {
  2266 + XposedBridge.log("[WeChatHook] sendVoiceViaAPI: dispatcher proxy failed: " + ep);
  2267 + }
  2268 + }
  2269 + }
  2270 +
  2271 + // ── u0 callback stub (same pattern as sendViaR0) ─────────────────────
  2272 + // r0.d (type com.tencent.mm.modelbase.u0) is null unless we set it;
  2273 + // H() calls u0.onSceneEnd which NPEs otherwise.
  2274 + // hStarted gates the u0 stub: ignore premature onSceneEnd (fires synchronously
  2275 + // when we set r0.d, BEFORE H() is called) and only act on H()'s async CDN callback.
  2276 + // Root cause: setting r0.d triggers an immediate "local save done" callback.
  2277 + // If we call K(0) there, WeChat marks the message as "completed" → H() skips CDN.
  2278 + final java.util.concurrent.atomic.AtomicBoolean hStarted =
  2279 + new java.util.concurrent.atomic.AtomicBoolean(false);
  2280 + try {
  2281 + Class<?> u0 = XposedHelpers.findClass("com.tencent.mm.modelbase.u0", cl);
  2282 + Object stub = Proxy.newProxyInstance(cl, new Class<?>[]{u0},
  2283 + (proxy, method, args) -> {
  2284 + StringBuilder params = new StringBuilder();
  2285 + if (args != null) {
  2286 + for (int i = 0; i < args.length; i++) {
  2287 + if (i > 0) params.append(", ");
  2288 + params.append(args[i]);
  2289 + }
  2290 + }
  2291 + XposedBridge.log("[WeChatHook] voice-cb: " + method.getName()
  2292 + + "(" + params + ") hStarted=" + hStarted.get());
  2293 + // onSceneEnd(int code, int errCode, String msg, m1 scene)
  2294 + // Real m2 dispatch: r0.onGYNetEnd handles the network result itself
  2295 + // (status/msgSvrId update, SendMsgSuccessEvent, K/I). This u0 only needs
  2296 + // to be non-null so that internal onSceneEnd call doesn't NPE — do NOT
  2297 + // call K/I here or success/fail events double-fire.
  2298 + if ("onSceneEnd".equals(method.getName()) && args != null && args.length >= 2) {
  2299 + XposedBridge.log("[WeChatHook] voice onSceneEnd code=" + args[0] + " err=" + args[1]);
  2300 + }
  2301 + return null;
  2302 + });
  2303 + XposedHelpers.setObjectField(r0, "d", stub);
  2304 + // Premature onSceneEnd fires synchronously during setObjectField above and is suppressed.
  2305 + } catch (Throwable t) {
  2306 + XposedBridge.log("[WeChatHook] u0 stub failed (non-fatal): " + t);
  2307 + }
  2308 +
  2309 + // ── Dispatch via WeChat's REAL NetSceneQueue ─────────────────────────────
  2310 + // Exactly the path the (now-working) internal text send uses. n1.a() does:
  2311 + // ((gm0.n) com.tencent.mm.modelbase.z2.f88087b).f290500a.f290528b.h(scene, 0)
  2312 + // The queue supplies the real network dispatcher and runs doScene properly:
  2313 + // p==null → it reloads our just-inserted voice f9 from the DB, assembles the
  2314 + // MsgSource, uploads the SILK to CDN, and transports to the server.
  2315 + //
  2316 + // This replaces the old manual r0.H()+fake-$Proxy dispatcher, which never
  2317 + // reached the network — real transport is the queue's job (routed via IPC to
  2318 + // the :push connection), NOT a hand-set main-process m2 (which is never even
  2319 + // exercised, so it can't be captured).
  2320 + hStarted.set(true);
  2321 + sCalledByUs = true;
  2322 + try {
  2323 + // z2.b(m1) = WeChat's own "run cgi" helper (IGetNetSceneQueue): enqueues the
  2324 + // scene into the real NetSceneQueue, which supplies the live dispatcher and
  2325 + // runs doScene. Using the static helper avoids hardcoding the queue's field
  2326 + // chain, whose obfuscated names differ between base.apk and the Tinker patch
  2327 + // (the running code). r0 extends m1, so this resolves b(m1) by assignability.
  2328 + Class<?> z2c = XposedHelpers.findClass("com.tencent.mm.modelbase.z2", cl);
  2329 + XposedHelpers.callStaticMethod(z2c, "b", r0);
  2330 + XposedBridge.log("[WeChatHook] sendVoiceViaAPI: enqueued via z2.b(NetSceneQueue) to=" + talker
  2331 + + " voiceId=" + voiceId + " durationMs=" + durationMs);
  2332 + } catch (Throwable t) {
  2333 + XposedBridge.log("[WeChatHook] sendVoiceViaAPI: enqueue threw: " + t
  2334 + + "\n" + android.util.Log.getStackTraceString(t));
  2335 + } finally {
  2336 + sCalledByUs = false;
  2337 + }
  2338 +
  2339 + // ── Trigger REAL delivery via ResendVoiceMsgEvent{f9} ──────────────────
  2340 + // Decompiling WeChat 8.0.74 revealed the real voice send path:
  2341 + // cg0.d(talker,file) → jg0.x(cg0.d) → sendCgi(m)+uploadAttach(w).
  2342 + // ResendVoiceMsgEvent (com.tencent.mm.autogen.events) carries a single f9
  2343 + // (field g = am.wr, wr.a = f9). Publishing it makes WeChat's voice-resend
  2344 + // handler run the FULL sendCgi + CDN upload for our already-inserted f9 —
  2345 + // i.e. the same machinery a real send uses. This replaces the dead
  2346 + // r0.H()/msgSvrId-poll path (delivery never happened → msgSvrId stayed 0).
  2347 + final Long msgLocalId = sPendingMsgLocalId.get();
  2348 + sPendingMsgLocalId.remove();
  2349 + sDatabaseF9.remove();
  2350 + final Object fPt0e0 = sPt0E0Instance;
  2351 + final String fTalker = talker;
  2352 + final ClassLoader fCl = cl;
  2353 + // ResendVoiceMsgEvent path CONFIRMED DEAD (2026-07-06): even after marking the
  2354 + // properly-inserted voice msg failed (status=5) and firing the event, WeChat's
  2355 + // resend handler does NOT respond — the message just shows red ! (failed), no
  2356 + // CDN upload. So this force-fails sends for nothing. Disabled. The remaining
  2357 + // real path is to build a voiceinfo/w0 upload task and drive w21.g1.P0() (or
  2358 + // reconstruct jg0.x). Kept for reference.
  2359 + if (false && msgLocalId != null) {
  2360 + new Thread(() -> {
  2361 + try {
  2362 + Thread.sleep(1500); // let the z2.b send settle first
  2363 + // Re-fetch the DB-bound f9 via pt0.e0.k(talker, msgLocalId).
  2364 + Object f9 = null;
  2365 + if (fPt0e0 != null) {
  2366 + try {
  2367 + java.lang.reflect.Method kM = null;
  2368 + for (java.lang.reflect.Method m : fPt0e0.getClass().getDeclaredMethods())
  2369 + if ("k".equals(m.getName()) && m.getParameterTypes().length == 2) { kM = m; break; }
  2370 + if (kM != null) { kM.setAccessible(true); f9 = kM.invoke(fPt0e0, fTalker, msgLocalId); }
  2371 + } catch (Throwable t) {
  2372 + XposedBridge.log("[WeChatHook] resend: k() failed: " + t);
  2373 + }
  2374 + }
  2375 + if (f9 == null) {
  2376 + XposedBridge.log("[WeChatHook] resend: f9 null for msgLocalId=" + msgLocalId + ", abort");
  2377 + return;
  2378 + }
  2379 + // Mark the message failed (status=5) + persist, so the resend handler
  2380 + // treats it as a resendable failed voice. f9.r1(int)=status setter;
  2381 + // pt0.e0.s(talker,msgId,f9) persists via cj().lb().
  2382 + try {
  2383 + XposedHelpers.callMethod(f9, "r1", 5);
  2384 + long mid = ((Number) XposedHelpers.callMethod(f9, "getMsgId")).longValue();
  2385 + if (fPt0e0 != null) {
  2386 + java.lang.reflect.Method sM = null;
  2387 + for (java.lang.reflect.Method m : fPt0e0.getClass().getDeclaredMethods())
  2388 + if ("s".equals(m.getName()) && m.getParameterTypes().length == 3) { sM = m; break; }
  2389 + if (sM != null) { sM.setAccessible(true); sM.invoke(fPt0e0, fTalker, mid, f9); }
  2390 + }
  2391 + XposedBridge.log("[WeChatHook] resend: marked f9 status=5 + persisted msgId=" + mid);
  2392 + } catch (Throwable t) {
  2393 + XposedBridge.log("[WeChatHook] resend: status=5 failed: " + t);
  2394 + }
  2395 + // Build ResendVoiceMsgEvent, set its f9 (field g -> wr.a), publish.
  2396 + Class<?> evCls = XposedHelpers.findClass(
  2397 + "com.tencent.mm.autogen.events.ResendVoiceMsgEvent", fCl);
  2398 + Object ev = evCls.newInstance();
  2399 + Object wr = XposedHelpers.getObjectField(ev, "g"); // am.wr
  2400 + XposedHelpers.setObjectField(wr, "a", f9);
  2401 + XposedHelpers.callMethod(ev, "b", Looper.getMainLooper());
  2402 + XposedBridge.log("[WeChatHook] resend: ResendVoiceMsgEvent published msgLocalId="
  2403 + + msgLocalId + " talker=" + fTalker + " f9=" + f9.getClass().getName());
  2404 + } catch (Throwable t) {
  2405 + XposedBridge.log("[WeChatHook] resend: failed: " + t);
  2406 + }
  2407 + }, "WCHook-resend-" + msgLocalId).start();
  2408 + } else {
  2409 + XposedBridge.log("[WeChatHook] resend: msgLocalId null, skip");
  2410 + }
  2411 + }
  2412 +
  2413 + // ── Factory method discovery ───────────────────────────────────────────────
  2414 + // Hooks ALL static methods on w11.s1 and logs their signatures + call args.
  2415 + // When WeChat UI sends image/voice, we'll see which s1 method is called and
  2416 + // what parameters it receives, revealing the builder API for each msgtype.
  2417 + private void hookFactoryMethods(ClassLoader cl, String procName) {
  2418 + try {
  2419 + Class<?> s1 = XposedHelpers.findClass("w11.s1", cl);
  2420 + java.lang.reflect.Method[] methods = s1.getDeclaredMethods();
  2421 +
  2422 + // Dump all method signatures immediately
  2423 + StringBuilder sb = new StringBuilder();
  2424 + sb.append("[WeChatHook] w11.s1 ALL METHODS (").append(methods.length).append("):\n");
  2425 + for (java.lang.reflect.Method m : methods) {
  2426 + sb.append(" ").append(m.toGenericString()).append("\n");
  2427 + }
  2428 + XposedBridge.log(sb.toString());
  2429 +
  2430 + // Hook each static method to intercept calls from WeChat UI
  2431 + for (java.lang.reflect.Method m : methods) {
  2432 + if (!java.lang.reflect.Modifier.isStatic(m.getModifiers())) continue;
  2433 + final String mName = m.getName();
  2434 + final String mSig = m.toGenericString();
  2435 + m.setAccessible(true);
  2436 + XposedBridge.hookMethod(m, new XC_MethodHook() {
  2437 + @Override
  2438 + protected void afterHookedMethod(MethodHookParam param) throws Throwable {
  2439 + StringBuilder log = new StringBuilder();
  2440 + log.append("[WeChatHook] s1.").append(mName).append("(");
  2441 + if (param.args != null) {
  2442 + for (int i = 0; i < param.args.length; i++) {
  2443 + if (i > 0) log.append(", ");
  2444 + Object a = param.args[i];
  2445 + log.append(a == null ? "null" : a.getClass().getSimpleName() + "=" + a);
  2446 + }
  2447 + }
  2448 + log.append(") -> ");
  2449 + Object ret = param.getResult();
  2450 + if (ret == null) {
  2451 + log.append("null");
  2452 + } else {
  2453 + log.append(ret.getClass().getName());
  2454 + dumpBuilderMethods(ret, "s1." + mName);
  2455 + // Capture dispatcher from the r0 inside r1 (set by factory, null in direct ctor)
  2456 + try {
  2457 + StringBuilder r1Fields = new StringBuilder("[WeChatHook] r1 fields:\n");
  2458 + for (Class<?> rc = ret.getClass(); rc != null && !rc.equals(Object.class); rc = rc.getSuperclass()) {
  2459 + for (java.lang.reflect.Field rf : rc.getDeclaredFields()) {
  2460 + if (java.lang.reflect.Modifier.isStatic(rf.getModifiers())) continue;
  2461 + rf.setAccessible(true);
  2462 + Object rv = rf.get(ret);
  2463 + String typeName = rv != null ? rv.getClass().getName() : rf.getType().getName();
  2464 + r1Fields.append(" ").append(rf.getName()).append(" (").append(typeName).append(")");
  2465 + if (sCapturedDispatcher == null && rv != null) {
  2466 + // Try to get dispatcher from any field, whether it's r0 or a container
  2467 + try {
  2468 + Object disp = XposedHelpers.getObjectField(rv, "dispatcher");
  2469 + if (disp != null) {
  2470 + sCapturedDispatcher = disp;
  2471 + r1Fields.append(" [dispatcher captured!]");
  2472 + }
  2473 + } catch (Throwable ignored2) {}
  2474 + }
  2475 + r1Fields.append("\n");
  2476 + }
  2477 + }
  2478 + XposedBridge.log(r1Fields.toString());
  2479 + } catch (Throwable r1e) {
  2480 + XposedBridge.log("[WeChatHook] r1 field dump error: " + r1e);
  2481 + }
  2482 + }
  2483 + if (param.hasThrowable())
  2484 + log.append(" THREW: ").append(param.getThrowable());
  2485 + XposedBridge.log(log.toString());
  2486 + }
  2487 + });
  2488 + }
  2489 + XposedBridge.log("[WeChatHook] hookFactoryMethods: hooked " + methods.length
  2490 + + " methods on w11.s1 in " + procName);
  2491 + } catch (Throwable t) {
  2492 + XposedBridge.log("[WeChatHook] hookFactoryMethods failed in " + procName + ": " + t);
  2493 + }
  2494 + }
  2495 +
  2496 + // Dump ALL instance methods on a builder object returned by the factory.
  2497 + // Each unique builder class is only dumped once to avoid logcat spam.
  2498 + private static final java.util.Set<String> sDumpedClasses =
  2499 + java.util.Collections.synchronizedSet(new java.util.HashSet<>());
  2500 +
  2501 + private static void dumpBuilderMethods(Object builder, String fromFactory) {
  2502 + if (builder == null) return;
  2503 + String clsName = builder.getClass().getName();
  2504 + if (!sDumpedClasses.add(clsName)) return; // already dumped
  2505 + try {
  2506 + java.lang.reflect.Method[] ms = builder.getClass().getDeclaredMethods();
  2507 + StringBuilder sb = new StringBuilder();
  2508 + sb.append("[WeChatHook] BUILDER from ").append(fromFactory)
  2509 + .append(" class=").append(clsName)
  2510 + .append(" methods(").append(ms.length).append("):\n");
  2511 + for (java.lang.reflect.Method m : ms) {
  2512 + sb.append(" ").append(m.toGenericString()).append("\n");
  2513 + }
  2514 + XposedBridge.log(sb.toString());
  2515 + } catch (Throwable t) {
  2516 + XposedBridge.log("[WeChatHook] dumpBuilderMethods " + clsName + ": " + t);
  2517 + }
  2518 + }
  2519 +
  2520 + // Dump all instance fields of w11.r0 to logcat.
  2521 + // Used to discover which field holds the SILK audio data for CDN upload.
  2522 + // Logs each field name, type, and a brief value summary.
  2523 + private static void dumpR0Fields(Object r0, String tag) {
  2524 + if (r0 == null) return;
  2525 + try {
  2526 + Class<?> cls = r0.getClass();
  2527 + StringBuilder sb = new StringBuilder();
  2528 + sb.append("[WeChatHook] ").append(tag).append(" class=").append(cls.getName()).append("\n");
  2529 + // Walk the class hierarchy to capture inherited fields
  2530 + for (Class<?> c = cls; c != null && !c.equals(Object.class); c = c.getSuperclass()) {
  2531 + for (java.lang.reflect.Field f : c.getDeclaredFields()) {
  2532 + if (java.lang.reflect.Modifier.isStatic(f.getModifiers())) continue;
  2533 + f.setAccessible(true);
  2534 + try {
  2535 + Object val = f.get(r0);
  2536 + if (val == null) {
  2537 + sb.append(" ").append(f.getName()).append(" (").append(f.getType().getSimpleName()).append(") = null\n");
  2538 + } else if (val instanceof byte[]) {
  2539 + byte[] ba = (byte[]) val;
  2540 + StringBuilder hex = new StringBuilder();
  2541 + int show = Math.min(ba.length, 16);
  2542 + for (int i = 0; i < show; i++) hex.append(String.format("%02x", ba[i] & 0xff));
  2543 + sb.append(" ").append(f.getName()).append(" (byte[]) len=").append(ba.length).append(" hex=").append(hex).append("\n");
  2544 + } else if (val instanceof String) {
  2545 + String s = (String) val;
  2546 + sb.append(" ").append(f.getName()).append(" (String) = \"").append(s.length() > 80 ? s.substring(0, 80) + "…" : s).append("\"\n");
  2547 + } else {
  2548 + String vs = val.toString();
  2549 + sb.append(" ").append(f.getName()).append(" (").append(val.getClass().getSimpleName()).append(") = ").append(vs.length() > 80 ? vs.substring(0, 80) + "…" : vs).append("\n");
  2550 + }
  2551 + } catch (Throwable e2) {
  2552 + sb.append(" ").append(f.getName()).append(" ERROR: ").append(e2).append("\n");
  2553 + }
  2554 + }
  2555 + }
  2556 + XposedBridge.log(sb.toString());
  2557 + } catch (Throwable t) {
  2558 + XposedBridge.log("[WeChatHook] dumpR0Fields failed: " + t);
  2559 + }
  2560 + }
  2561 +
  2562 + // ── On-demand API dump broadcast ──────────────────────────────────────────
  2563 + // am broadcast -a com.hermes.wechat.DUMP_WECHAT_API
  2564 + // Optional extra: --es class "w11.s1" (defaults to w11.s1 if omitted)
  2565 + private static void handleDumpApi(Intent intent) {
  2566 + ClassLoader cl = sTinkerCL != null ? sTinkerCL : sWeChatCL;
  2567 + if (cl == null) { XposedBridge.log("[WeChatHook] DUMP_API: classloader null"); return; }
  2568 +
  2569 + String className = intent.getStringExtra("class");
  2570 + if (className == null || className.isEmpty()) className = "w11.s1";
  2571 +
  2572 + try {
  2573 + Class<?> cls = XposedHelpers.findClass(className, cl);
  2574 + java.lang.reflect.Method[] ms = cls.getDeclaredMethods();
  2575 + java.lang.reflect.Constructor[] cs = cls.getDeclaredConstructors();
  2576 + java.lang.reflect.Field[] fs = cls.getDeclaredFields();
  2577 +
  2578 + StringBuilder sb = new StringBuilder();
  2579 + sb.append("[WeChatHook] DUMP_API class=").append(cls.getName()).append("\n");
  2580 +
  2581 + // For enum classes, print each constant with its ordinal and any int fields
  2582 + if (cls.isEnum()) {
  2583 + sb.append(" ENUM CONSTANTS:\n");
  2584 + try {
  2585 + Object[] constants = (Object[]) cls.getMethod("values").invoke(null);
  2586 + java.lang.reflect.Method nameM = Enum.class.getMethod("name");
  2587 + java.lang.reflect.Method ordinalM = Enum.class.getMethod("ordinal");
  2588 + for (Object c : constants) {
  2589 + String cName = (String) nameM.invoke(c);
  2590 + int cOrd = (int) ordinalM.invoke(c);
  2591 + sb.append(" ").append(cName).append(" ordinal=").append(cOrd);
  2592 + // Also print any int/String fields on the enum instance
  2593 + for (java.lang.reflect.Field f2 : fs) {
  2594 + if (java.lang.reflect.Modifier.isStatic(f2.getModifiers())) continue;
  2595 + f2.setAccessible(true);
  2596 + try {
  2597 + Object fv = f2.get(c);
  2598 + sb.append(" ").append(f2.getName()).append("=").append(fv);
  2599 + } catch (Throwable ignored) {}
  2600 + }
  2601 + sb.append("\n");
  2602 + }
  2603 + } catch (Throwable e2) {
  2604 + sb.append(" (enum values() failed: ").append(e2).append(")\n");
  2605 + }
  2606 + }
  2607 +
  2608 + sb.append(" FIELDS (").append(fs.length).append("):\n");
  2609 + for (java.lang.reflect.Field f : fs) {
  2610 + sb.append(" ").append(f.toGenericString());
  2611 + // Read static field values (helpful for non-enum static constants)
  2612 + if (java.lang.reflect.Modifier.isStatic(f.getModifiers()) && !cls.isEnum()) {
  2613 + f.setAccessible(true);
  2614 + try { sb.append(" = ").append(f.get(null)); } catch (Throwable ignored) {}
  2615 + }
  2616 + sb.append("\n");
  2617 + }
  2618 + sb.append(" CONSTRUCTORS (").append(cs.length).append("):\n");
  2619 + for (java.lang.reflect.Constructor<?> c : cs) sb.append(" ").append(c.toGenericString()).append("\n");
  2620 + sb.append(" METHODS (").append(ms.length).append("):\n");
  2621 + for (java.lang.reflect.Method m : ms) sb.append(" ").append(m.toGenericString()).append("\n");
  2622 + XposedBridge.log(sb.toString());
  2623 + } catch (Throwable t) {
  2624 + XposedBridge.log("[WeChatHook] DUMP_API failed for " + className + ": " + t);
  2625 + }
  2626 + }
  2627 +
  2628 + // PRIMARY FIX: WeChat's own methods (LoginPasswordUI.onCreate etc.) are AOT-compiled
  2629 + // in a way that bypasses ArtMethod entry point replacement. But framework-dispatched
  2630 + // base class methods like Activity.onResume() always go through the entry point.
  2631 + // We hook onResume, filter for the login screens, and force the tablet button visible.
  2632 + // By onResume time, all views are initialized (onCreate + initView have run).
  2633 + private void hookProbe(ClassLoader cl) {
  2634 + try {
  2635 + XposedHelpers.findAndHookMethod(
  2636 + "android.app.Activity", null,
  2637 + "onResume", new XC_MethodHook() {
  2638 + @Override
  2639 + protected void afterHookedMethod(MethodHookParam param) throws Throwable {
  2640 + String cls = param.thisObject.getClass().getName();
  2641 + if (!cls.equals("com.tencent.mm.plugin.account.ui.LoginPasswordUI")
  2642 + && !cls.equals("com.tencent.mm.plugin.account.ui.LoginHistoryUI")) {
  2643 + return;
  2644 + }
  2645 + Object obj = param.thisObject;
  2646 + View tabletBtn = null;
  2647 + try {
  2648 + tabletBtn = (View) XposedHelpers.getObjectField(obj, "N");
  2649 + } catch (Throwable e) {
  2650 + XposedBridge.log("[WeChatHook] onResume getField N failed: " + e);
  2651 + }
  2652 + if (tabletBtn == null) {
  2653 + XposedBridge.log("[WeChatHook] onResume: N is null for " + cls);
  2654 + return;
  2655 + }
  2656 + tabletBtn.setVisibility(View.VISIBLE);
  2657 + // CRITICAL: resolve m4 from the Activity's OWN classloader. The login
  2658 + // Activities run under the Tinker CL, but hookProbe was installed with
  2659 + // the pre-Tinker CL. m4's ctor param LoginHistoryUI and the running
  2660 + // Activity are the SAME FQN but from DIFFERENT classloaders, so a
  2661 + // captured-CL m4 fails with "type LoginHistoryUI, got LoginHistoryUI".
  2662 + // Looking up m4 via obj's CL makes the types line up.
  2663 + boolean attached = false;
  2664 + try {
  2665 + ClassLoader runtimeCl = obj.getClass().getClassLoader();
  2666 + Class<?> m4Class = XposedHelpers.findClass(
  2667 + "com.tencent.mm.plugin.account.ui.m4", runtimeCl);
  2668 + java.lang.reflect.Constructor<?> chosen = null;
  2669 + for (java.lang.reflect.Constructor<?> c : m4Class.getDeclaredConstructors()) {
  2670 + Class<?>[] pt = c.getParameterTypes();
  2671 + if (pt.length == 1 && pt[0].isAssignableFrom(obj.getClass())) {
  2672 + chosen = c; break;
  2673 + }
  2674 + }
  2675 + if (chosen != null && View.OnClickListener.class.isAssignableFrom(m4Class)) {
  2676 + chosen.setAccessible(true);
  2677 + tabletBtn.setOnClickListener(
  2678 + (View.OnClickListener) chosen.newInstance(obj));
  2679 + attached = true;
  2680 + XposedBridge.log("[WeChatHook] onResume: m4 listener attached via "
  2681 + + chosen.getParameterTypes()[0].getName() + " for " + cls);
  2682 + } else {
  2683 + XposedBridge.log("[WeChatHook] onResume: no assignable m4 ctor for "
  2684 + + cls + " (obj CL=" + runtimeCl + ")");
  2685 + }
  2686 + } catch (Throwable e) {
  2687 + XposedBridge.log("[WeChatHook] onResume setListener failed: " + e);
  2688 + }
  2689 + if (!attached) {
  2690 + XposedBridge.log("[WeChatHook] onResume: N VISIBLE but NO listener for " + cls);
  2691 + }
  2692 + }
  2693 + });
  2694 + XposedBridge.log("[WeChatHook] Hooked Activity.onResume for tablet button");
  2695 + } catch (Throwable t) {
  2696 + XposedBridge.log("[WeChatHook] hookProbe/onResume failed: " + t);
  2697 + }
  2698 + }
  2699 +
  2700 + // ── Device spoof: make WeChat see a Samsung Galaxy Tab S8 (SM-X700) ──────────
  2701 + // For the pad-login experiment: WeChat's tablet detection (bk.B/Q) + the server's
  2702 + // secondary-device validation read android.os.Build fields + ro.product.* props.
  2703 + // Pixel is never a tablet; spoof Build + SystemProperties to a real Samsung tablet
  2704 + // so both the client gate and the server handshake see a legitimate pad model.
  2705 + // Runs in the main WeChat process only. Enabled at RUNTIME by the flag file below
  2706 + // (default OFF). Flip with the spoof_toggle shortcut, then restart WeChat.
  2707 + private static final String SPOOF_FLAG = "/data/data/com.tencent.mm/files/.wcspoof";
  2708 + static boolean isDeviceSpoofEnabled() {
  2709 + try { return new java.io.File(SPOOF_FLAG).exists(); } catch (Throwable t) { return false; }
  2710 + }
  2711 + private void hookDeviceSpoof(ClassLoader cl, String procName) {
  2712 + if (!isDeviceSpoofEnabled()) return;
  2713 + if (procName.contains(":")) return; // main process only
  2714 + try {
  2715 + // Samsung Galaxy Tab S8 (Wi-Fi) — a well-known Android tablet.
  2716 + final java.util.Map<String, String> B = new java.util.HashMap<>();
  2717 + B.put("BRAND", "samsung");
  2718 + B.put("MANUFACTURER", "samsung");
  2719 + B.put("MODEL", "SM-X700");
  2720 + B.put("DEVICE", "gts8wifi");
  2721 + B.put("PRODUCT", "gts8wifixx");
  2722 + B.put("BOARD", "gts8wifi");
  2723 + B.put("HARDWARE", "qcom");
  2724 + Class<?> build = XposedHelpers.findClass("android.os.Build", cl);
  2725 + for (java.util.Map.Entry<String, String> e : B.entrySet()) {
  2726 + try { XposedHelpers.setStaticObjectField(build, e.getKey(), e.getValue()); }
  2727 + catch (Throwable t) { XposedBridge.log("[WeChatHook] spoof Build." + e.getKey() + " failed: " + t); }
  2728 + }
  2729 + // ro.product.* + ro.build.characteristics=tablet (the strong tablet signal).
  2730 + final java.util.Map<String, String> P = new java.util.HashMap<>();
  2731 + P.put("ro.product.brand", "samsung");
  2732 + P.put("ro.product.manufacturer", "samsung");
  2733 + P.put("ro.product.model", "SM-X700");
  2734 + P.put("ro.product.device", "gts8wifi");
  2735 + P.put("ro.product.name", "gts8wifixx");
  2736 + P.put("ro.build.characteristics", "tablet");
  2737 + Class<?> sysProp = XposedHelpers.findClass("android.os.SystemProperties", cl);
  2738 + XC_MethodHook propHook = new XC_MethodHook() {
  2739 + @Override protected void afterHookedMethod(MethodHookParam p) {
  2740 + if (p.args == null || p.args.length == 0) return;
  2741 + Object k = p.args[0];
  2742 + if (k != null && P.containsKey(k.toString())) p.setResult(P.get(k.toString()));
  2743 + }
  2744 + };
  2745 + for (java.lang.reflect.Method m : sysProp.getDeclaredMethods()) {
  2746 + if ("get".equals(m.getName()) && m.getParameterTypes().length >= 1
  2747 + && m.getParameterTypes()[0] == String.class
  2748 + && m.getReturnType() == String.class) {
  2749 + XposedBridge.hookMethod(m, propHook);
  2750 + }
  2751 + }
  2752 + XposedBridge.log("[WeChatHook] hookDeviceSpoof: → Samsung SM-X700 tablet in " + procName
  2753 + + " (Build.MODEL now=" + XposedHelpers.getStaticObjectField(build, "MODEL") + ")");
  2754 + } catch (Throwable t) {
  2755 + XposedBridge.log("[WeChatHook] hookDeviceSpoof failed in " + procName + ": " + t);
  2756 + }
  2757 + }
  2758 +
  2759 + private void hookInTabletEnv(ClassLoader cl) {
  2760 + try {
  2761 + Class<?> bkClass = XposedHelpers.findClass("com.tencent.mm.ui.bk", cl);
  2762 + Class<?> x0Class = XposedHelpers.findClass("bm5.x0", cl);
  2763 + XposedHelpers.findAndHookMethod(bkClass, "B", x0Class, new XC_MethodHook() {
  2764 + @Override
  2765 + protected void beforeHookedMethod(MethodHookParam param) throws Throwable {
  2766 + param.setResult(Boolean.TRUE);
  2767 + }
  2768 + });
  2769 + XposedHelpers.findAndHookMethod(bkClass, "A", new XC_MethodHook() {
  2770 + @Override
  2771 + protected void beforeHookedMethod(MethodHookParam param) throws Throwable {
  2772 + XposedBridge.log("[WeChatHook] bk.A() called -> TRUE");
  2773 + param.setResult(Boolean.TRUE);
  2774 + }
  2775 + });
  2776 + XposedBridge.log("[WeChatHook] Hooked bk.B(x0) + bk.A()");
  2777 + } catch (Throwable t) {
  2778 + XposedBridge.log("[WeChatHook] hookInTabletEnv failed: " + t);
  2779 + }
  2780 + }
  2781 +
  2782 + // PRIMARY FIX: Hook LoginPasswordUI.onCreate(Bundle) afterHookedMethod.
  2783 + //
  2784 + // Previous attempts to hook initView() and LoginHistoryUI.initView() both failed
  2785 + // because ART AOT devirtualizes the call to this.initView() inside
  2786 + // LoginHistoryUI.onCreate() — the compiled native code jumps directly past the
  2787 + // Xposed entry point replacement.
  2788 + //
  2789 + // onCreate() itself is called by the Android framework via the ArtMethod pointer
  2790 + // directly (no vtable devirtualization possible), so this hook fires reliably.
  2791 + // By the time afterHookedMethod runs, super.onCreate() has completed which means
  2792 + // LoginHistoryUI.initView() has already run and this.N is assigned.
  2793 + private void hookLoginHistoryUIInitView(ClassLoader cl) {
  2794 + final Class<?> m4Class;
  2795 + try {
  2796 + m4Class = XposedHelpers.findClass("com.tencent.mm.plugin.account.ui.m4", cl);
  2797 + } catch (Throwable t) {
  2798 + XposedBridge.log("[WeChatHook] m4 class not found: " + t);
  2799 + return;
  2800 + }
  2801 +
  2802 + try {
  2803 + XposedHelpers.findAndHookMethod(
  2804 + "com.tencent.mm.plugin.account.ui.LoginPasswordUI", cl,
  2805 + "onCreate", Bundle.class, new XC_MethodHook() {
  2806 + @Override
  2807 + protected void afterHookedMethod(MethodHookParam param) throws Throwable {
  2808 + Object obj = param.thisObject;
  2809 + View tabletBtn = (View) XposedHelpers.getObjectField(obj, "N");
  2810 + if (tabletBtn == null) {
  2811 + XposedBridge.log("[WeChatHook] LoginPasswordUI.onCreate: N is null");
  2812 + return;
  2813 + }
  2814 + tabletBtn.setVisibility(View.VISIBLE);
  2815 + tabletBtn.setOnClickListener(
  2816 + (View.OnClickListener) XposedHelpers.newInstance(m4Class, obj));
  2817 + XposedBridge.log("[WeChatHook] LoginPasswordUI.onCreate() -> N VISIBLE + m4 attached");
  2818 + }
  2819 + });
  2820 + XposedBridge.log("[WeChatHook] Hooked LoginPasswordUI.onCreate()");
  2821 + } catch (Throwable t) {
  2822 + XposedBridge.log("[WeChatHook] hookLoginPasswordUI.onCreate failed: " + t);
  2823 + }
  2824 +
  2825 + // Belt-and-suspenders: also hook LoginHistoryUI.onCreate() for any subclass
  2826 + // that doesn't override onCreate but does have the N button.
  2827 + try {
  2828 + XposedHelpers.findAndHookMethod(
  2829 + "com.tencent.mm.plugin.account.ui.LoginHistoryUI", cl,
  2830 + "onCreate", Bundle.class, new XC_MethodHook() {
  2831 + @Override
  2832 + protected void afterHookedMethod(MethodHookParam param) throws Throwable {
  2833 + Object obj = param.thisObject;
  2834 + if (obj.getClass().getName().equals(
  2835 + "com.tencent.mm.plugin.account.ui.LoginPasswordUI")) return;
  2836 + View tabletBtn = (View) XposedHelpers.getObjectField(obj, "N");
  2837 + if (tabletBtn == null) {
  2838 + XposedBridge.log("[WeChatHook] LoginHistoryUI.onCreate: N is null");
  2839 + return;
  2840 + }
  2841 + tabletBtn.setVisibility(View.VISIBLE);
  2842 + tabletBtn.setOnClickListener(
  2843 + (View.OnClickListener) XposedHelpers.newInstance(m4Class, obj));
  2844 + XposedBridge.log("[WeChatHook] LoginHistoryUI.onCreate() -> N VISIBLE + m4 attached");
  2845 + }
  2846 + });
  2847 + XposedBridge.log("[WeChatHook] Hooked LoginHistoryUI.onCreate()");
  2848 + } catch (Throwable t) {
  2849 + XposedBridge.log("[WeChatHook] hookLoginHistoryUI.onCreate failed: " + t);
  2850 + }
  2851 + }
  2852 +
  2853 + private void hookLoginUIInitView(ClassLoader cl) {
  2854 + try {
  2855 + final Class<?> wbClass = XposedHelpers.findClass(
  2856 + "com.tencent.mm.plugin.account.ui.wb", cl);
  2857 + XposedHelpers.findAndHookMethod(
  2858 + "com.tencent.mm.plugin.account.ui.MobileInputUI", cl,
  2859 + "initView", new XC_MethodHook() {
  2860 + @Override
  2861 + protected void afterHookedMethod(MethodHookParam param) throws Throwable {
  2862 + Object mobileInputUI = param.thisObject;
  2863 + View tabletBtn = (View) XposedHelpers.getObjectField(mobileInputUI, "w");
  2864 + if (tabletBtn == null) {
  2865 + XposedBridge.log("[WeChatHook] MobileInputUI.w is null");
  2866 + return;
  2867 + }
  2868 + tabletBtn.setVisibility(View.VISIBLE);
  2869 + tabletBtn.setOnClickListener(
  2870 + (View.OnClickListener) XposedHelpers.newInstance(wbClass, mobileInputUI));
  2871 + XposedBridge.log("[WeChatHook] MobileInputUI.initView() -> w VISIBLE + wb attached");
  2872 + }
  2873 + });
  2874 + XposedBridge.log("[WeChatHook] Hooked MobileInputUI.initView()");
  2875 + } catch (Throwable t) {
  2876 + XposedBridge.log("[WeChatHook] hookLoginUIInitView(MobileInputUI) failed: " + t);
  2877 + }
  2878 + try {
  2879 + final Class<?> g7Class = XposedHelpers.findClass(
  2880 + "com.tencent.mm.plugin.account.ui.g7", cl);
  2881 + XposedHelpers.findAndHookMethod(
  2882 + "com.tencent.mm.plugin.account.ui.LoginUI", cl,
  2883 + "initView", new XC_MethodHook() {
  2884 + @Override
  2885 + protected void afterHookedMethod(MethodHookParam param) throws Throwable {
  2886 + Object loginUI = param.thisObject;
  2887 + View tabletBtn = (View) XposedHelpers.getObjectField(loginUI, "f992u");
  2888 + if (tabletBtn == null) return;
  2889 + tabletBtn.setVisibility(View.VISIBLE);
  2890 + tabletBtn.setOnClickListener(
  2891 + (View.OnClickListener) XposedHelpers.newInstance(g7Class, loginUI));
  2892 + XposedBridge.log("[WeChatHook] LoginUI.initView() -> f992u VISIBLE + g7 attached");
  2893 + }
  2894 + });
  2895 + XposedBridge.log("[WeChatHook] Hooked LoginUI.initView()");
  2896 + } catch (Throwable t) {
  2897 + XposedBridge.log("[WeChatHook] hookLoginUIInitView(LoginUI) failed: " + t);
  2898 + }
  2899 + }
  2900 +
  2901 + private void hookLoginSelectUICheck(ClassLoader cl) {
  2902 + try {
  2903 + Class<?> callbackClass = XposedHelpers.findClass(
  2904 + "com.tencent.mm.plugin.account.ui.LoginSelectUI$a", cl);
  2905 + XposedHelpers.findAndHookMethod(callbackClass, "a", boolean.class, new XC_MethodHook() {
  2906 + @Override
  2907 + protected void beforeHookedMethod(MethodHookParam param) throws Throwable {
  2908 + XposedBridge.log("[WeChatHook] LoginSelectUI$a.a(" + param.args[0] + ") -> false");
  2909 + param.args[0] = false;
  2910 + }
  2911 + });
  2912 + XposedBridge.log("[WeChatHook] Hooked LoginSelectUI$a.a()");
  2913 + } catch (Throwable t) {
  2914 + XposedBridge.log("[WeChatHook] hookLoginSelectUICheck failed: " + t);
  2915 + }
  2916 + }
  2917 +
  2918 + private void hookLoginButtonClick(ClassLoader cl) {
  2919 + try {
  2920 + Class<?> ljClass = XposedHelpers.findClass(
  2921 + "com.tencent.mm.plugin.account.ui.lj", cl);
  2922 + final Class<?> loginSelectUIClass = XposedHelpers.findClass(
  2923 + "com.tencent.mm.plugin.account.ui.LoginSelectUI", cl);
  2924 + XposedHelpers.findAndHookMethod(ljClass, "onClick", View.class, new XC_MethodHook() {
  2925 + @Override
  2926 + protected void beforeHookedMethod(MethodHookParam param) throws Throwable {
  2927 + XposedBridge.log("[WeChatHook] lj.onClick() -> forcing LoginSelectUI");
  2928 + Activity welcomeActivity = (Activity) XposedHelpers.getObjectField(
  2929 + param.thisObject, "d");
  2930 + Intent intent = new Intent(welcomeActivity, loginSelectUIClass);
  2931 + intent.putExtra("mobile_input_purpose", 1);
  2932 + welcomeActivity.startActivity(intent);
  2933 + param.setResult(null);
  2934 + }
  2935 + });
  2936 + XposedBridge.log("[WeChatHook] Hooked lj.onClick() -> LoginSelectUI");
  2937 + } catch (Throwable t) {
  2938 + XposedBridge.log("[WeChatHook] hookLoginButtonClick failed: " + t);
  2939 + }
  2940 + }
  2941 +
  2942 + // ── Send message via internal API ────────────────────────────────────────
  2943 + //
  2944 + // Registered in ALL WeChat processes. We don't yet know which process has gm0.j1
  2945 + // initialized — the first process where the receiver fires and succeeds wins.
  2946 + //
  2947 + // Trigger from ADB (must run as root — RECEIVER_NOT_EXPORTED):
  2948 + // adb shell su -c 'am broadcast -a com.hermes.wechat.SEND_MSG_INTERNAL \
  2949 + // --es to "filehelper" --es text "hello"'
  2950 + private void hookSendMessage(ClassLoader cl, String procName) {
  2951 + try {
  2952 + XposedHelpers.findAndHookMethod(
  2953 + "android.app.Application", null, "onCreate",
  2954 + new XC_MethodHook() {
  2955 + @Override
  2956 + protected void afterHookedMethod(MethodHookParam param) throws Throwable {
  2957 + Context ctx = (Context) param.thisObject;
  2958 + if (!"com.tencent.mm".equals(ctx.getPackageName())) return;
  2959 + sAppContext = ctx;
  2960 + // Capture Tinker-patched classloader (Tinker installs in attachBaseContext,
  2961 + // so by App.onCreate this loader has the patch DEX classes).
  2962 + sTinkerCL = ctx.getClassLoader();
  2963 + XposedBridge.log("[WeChatHook] App.onCreate proc=" + procName
  2964 + + " pid=" + android.os.Process.myPid()
  2965 + + " tinkerCL=" + (sTinkerCL != null ? sTinkerCL.getClass().getSimpleName() : "null"));
  2966 + // Re-hook with Tinker CL — w11.s1 and f9 may live in a Tinker DEX.
  2967 + // This ensures our factory and convertTo hooks fire on actual WeChat UI sends.
  2968 + if (sTinkerCL != sWeChatCL) {
  2969 + hookFactoryMethods(sTinkerCL, procName + "/tinker");
  2970 + hookF9ConvertTo(sTinkerCL, procName + "/tinker");
  2971 + hookF9ImgPathInjector(sTinkerCL, procName + "/tinker");
  2972 + hookR0H(sTinkerCL, procName + "/tinker");
  2973 + // Hook pt0.e0 in Tinker CL — real voice sends run in Tinker CL.
  2974 + hookPt0E0(sTinkerCL, procName + "/tinker");
  2975 + // NOTE: hookJg0 / hookVoiceEntry disabled — they hook WeChat's
  2976 + // generic coroutine classes (pu.o0/pu.m0) and every message
  2977 + // DB insert (g9.M9/N9), which adds anti-tamper risk and
  2978 + // destabilizes the fragile Pad login. Re-enable only for a
  2979 + // targeted investigation session, never during normal use.
  2980 + // hookJg0(sTinkerCL, procName + "/tinker");
  2981 + // hookVoiceEntry(sTinkerCL, procName + "/tinker");
  2982 + // Surgical probe: jg0.x + qi3.f0/g0 only (safe).
  2983 + hookVoiceProbe(sTinkerCL, procName + "/tinker");
  2984 + // Image send runs in Tinker CL — hook b80.u.pj/Bi there.
  2985 + hookImageSend(sTinkerCL, procName + "/tinker");
  2986 + // CDN upload trace runs in Tinker CL (real voice send path).
  2987 + hookCdnTrace(sTinkerCL, procName + "/tinker");
  2988 + // Capture the real uploadvoice cgi request in Tinker CL.
  2989 + hookVoiceCgiCapture(sTinkerCL, procName + "/tinker");
  2990 + hookVoiceCgiResult(sTinkerCL, procName + "/tinker");
  2991 + // Pad-login: the login UIs (LoginPasswordUI/LoginHistoryUI/
  2992 + // LoginUI) + bk run in Tinker CL — the base-CL hooks never
  2993 + // fire (0 N-VISIBLE). Register them here so the "以平板登录"
  2994 + // button is actually forced visible. Gated by the runtime
  2995 + // spoof flag (default OFF = clean, no tablet hooks).
  2996 + if (isDeviceSpoofEnabled()) {
  2997 + hookInTabletEnv(sTinkerCL);
  2998 + hookLoginHistoryUIInitView(sTinkerCL);
  2999 + hookLoginUIInitView(sTinkerCL);
  3000 + }
  3001 + }
  3002 + hookF9ConvertTo(sWeChatCL, procName);
  3003 + hookF9ImgPathInjector(sWeChatCL, procName);
  3004 + registerSendReceiver(ctx, procName);
  3005 + }
  3006 + });
  3007 + XposedBridge.log("[WeChatHook] Send hook registered in " + procName);
  3008 + } catch (Throwable t) {
  3009 + XposedBridge.log("[WeChatHook] hookSendMessage failed in " + procName + ": " + t);
  3010 + }
  3011 +
  3012 + // ── w11.r0 constructor hooks ───────────────────────────────────────────
  3013 + // Log "by=US" vs "by=WeChat" to tell if WeChat's own UI send uses this ctor.
  3014 + XC_MethodHook r0Hook = new XC_MethodHook() {
  3015 + @Override
  3016 + protected void beforeHookedMethod(MethodHookParam param) throws Throwable {
  3017 + String by = sCalledByUs ? "US" : "WeChat";
  3018 + XposedBridge.log("[WeChatHook] w11.r0 ctor by=" + by
  3019 + + " args=" + param.args.length + " proc=" + procName
  3020 + + " pid=" + android.os.Process.myPid());
  3021 + if (sCalledByUs) {
  3022 + forceInitDI();
  3023 + diagMCoreStorage("before-r0-ctor");
  3024 + }
  3025 + }
  3026 + @Override
  3027 + protected void afterHookedMethod(MethodHookParam param) throws Throwable {
  3028 + String by = sCalledByUs ? "US" : "WeChat";
  3029 + if (param.hasThrowable()) {
  3030 + Throwable t = param.getThrowable();
  3031 + XposedBridge.log("[WeChatHook] w11.r0 ctor THREW by=" + by
  3032 + + " proc=" + procName + ": " + t);
  3033 + if (sCalledByUs)
  3034 + XposedBridge.log("[WeChatHook] r0-ctor-stack: "
  3035 + + android.util.Log.getStackTraceString(t));
  3036 + return;
  3037 + }
  3038 + sFsGReady = true;
  3039 + XposedBridge.log("[WeChatHook] w11.r0 ctor OK by=" + by + " proc=" + procName);
  3040 + }
  3041 + };
  3042 + for (Class<?>[] sig : new Class<?>[][]{
  3043 + {String.class, String.class, int.class, int.class, long.class},
  3044 + {String.class, String.class, int.class, int.class, Object.class},
  3045 + {long.class, int.class, String.class},
  3046 + {}}) {
  3047 + try {
  3048 + XposedHelpers.findAndHookConstructor("w11.r0", cl,
  3049 + appendHook(sig, r0Hook));
  3050 + XposedBridge.log("[WeChatHook] Hooked w11.r0(" + sig.length + " params)");
  3051 + } catch (Throwable t) {
  3052 + XposedBridge.log("[WeChatHook] w11.r0(" + sig.length + "p) hook failed: " + t);
  3053 + }
  3054 + }
  3055 +
  3056 + // ── w11.r0.H(String) — the actual dispatch method ─────────────────────
  3057 + // Fires for EVERY send (ours AND WeChat UI sends). Shows if WeChat uses r0.
  3058 + // For real WeChat voice sends (type=34, by=WeChat), dumps ALL r0 fields so
  3059 + // we can find which field holds the SILK audio data (root cause of spinner).
  3060 + try {
  3061 + XposedHelpers.findAndHookMethod("w11.r0", cl, "H", String.class,
  3062 + new XC_MethodHook() {
  3063 + @Override
  3064 + protected void beforeHookedMethod(MethodHookParam param) throws Throwable {
  3065 + String by = sCalledByUs ? "US" : "WeChat";
  3066 + XposedBridge.log("[WeChatHook] w11.r0.H() called by=" + by
  3067 + + " proc=" + procName + " pid=" + android.os.Process.myPid());
  3068 + // Dump all r0 fields for real WeChat voice sends only.
  3069 + // This reveals which field holds the SILK audio data that H() uses for CDN upload.
  3070 + if (!sCalledByUs) {
  3071 + dumpR0Fields(param.thisObject, "r0.H-before");
  3072 + }
  3073 + }
  3074 + @Override
  3075 + protected void afterHookedMethod(MethodHookParam param) throws Throwable {
  3076 + if (param.hasThrowable())
  3077 + XposedBridge.log("[WeChatHook] w11.r0.H() threw: " + param.getThrowable());
  3078 + else
  3079 + XposedBridge.log("[WeChatHook] w11.r0.H() OK proc=" + procName);
  3080 + }
  3081 + });
  3082 + XposedBridge.log("[WeChatHook] Hooked w11.r0.H(String) in " + procName);
  3083 + } catch (Throwable t) {
  3084 + XposedBridge.log("[WeChatHook] w11.r0.H hook failed in " + procName + ": " + t);
  3085 + }
  3086 +
  3087 + // ── r0.I(int) and r0.K(int) — delivery completion hooks ──────────────────
  3088 + // Log when WeChat calls these so we understand their role in the send flow.
  3089 + // Our u0 stub now calls r0.I(0) after onSceneEnd — this confirms whether I()
  3090 + // is the right trigger for delivery completion and spinner removal.
  3091 + try {
  3092 + XposedHelpers.findAndHookMethod("w11.r0", cl, "I", int.class,
  3093 + new XC_MethodHook() {
  3094 + @Override protected void beforeHookedMethod(MethodHookParam p) throws Throwable {
  3095 + String by = sCalledByUs ? "US" : "WeChat";
  3096 + XposedBridge.log("[WeChatHook] r0.I(" + p.args[0] + ") by=" + by + " proc=" + procName);
  3097 + }
  3098 + });
  3099 + XposedBridge.log("[WeChatHook] Hooked w11.r0.I(int) in " + procName);
  3100 + } catch (Throwable t) {
  3101 + XposedBridge.log("[WeChatHook] r0.I hook failed in " + procName + ": " + t);
  3102 + }
  3103 + try {
  3104 + XposedHelpers.findAndHookMethod("w11.r0", cl, "K", int.class,
  3105 + new XC_MethodHook() {
  3106 + @Override protected void beforeHookedMethod(MethodHookParam p) throws Throwable {
  3107 + String by = sCalledByUs ? "US" : "WeChat";
  3108 + XposedBridge.log("[WeChatHook] r0.K(" + p.args[0] + ") by=" + by + " proc=" + procName);
  3109 + }
  3110 + });
  3111 + XposedBridge.log("[WeChatHook] Hooked w11.r0.K(int) in " + procName);
  3112 + } catch (Throwable t) {
  3113 + XposedBridge.log("[WeChatHook] r0.K hook failed in " + procName + ": " + t);
  3114 + }
  3115 +
  3116 + // ── Diagnostic probes on the constructor call chain ───────────────────
  3117 + // Each probe fires when the method is called from inside the r0 constructor
  3118 + // (by WeChat OR by us). They trace which methods succeed before the NPE.
  3119 + hookProbeMethod(cl, "gm0.j1", "u", "gm0.j1.u");
  3120 + hookProbeMethod(cl, "c01.z1", "r", "c01.z1.r");
  3121 + // gm0.b0.c() is a virtual method — probe it too
  3122 + try {
  3123 + XposedHelpers.findAndHookMethod("gm0.b0", cl, "c",
  3124 + new XC_MethodHook() {
  3125 + @Override protected void beforeHookedMethod(MethodHookParam p) {
  3126 + XposedBridge.log("[WeChatHook] PROBE gm0.b0.c() called");
  3127 + }
  3128 + @Override protected void afterHookedMethod(MethodHookParam p) {
  3129 + if (p.hasThrowable()) XposedBridge.log("[WeChatHook] PROBE gm0.b0.c() THREW: " + p.getThrowable());
  3130 + else XposedBridge.log("[WeChatHook] PROBE gm0.b0.c() → " + p.getResult());
  3131 + }
  3132 + });
  3133 + } catch (Throwable t) {
  3134 + XposedBridge.log("[WeChatHook] gm0.b0.c probe failed: " + t);
  3135 + }
  3136 + }
  3137 +
  3138 + private static Object[] appendHook(Class<?>[] params, XC_MethodHook hook) {
  3139 + Object[] result = new Object[params.length + 1];
  3140 + System.arraycopy(params, 0, result, 0, params.length);
  3141 + result[params.length] = hook;
  3142 + return result;
  3143 + }
  3144 +
  3145 + private void hookProbeMethod(ClassLoader cl, String cls, String method, String tag) {
  3146 + try {
  3147 + XposedHelpers.findAndHookMethod(cls, cl, method,
  3148 + new XC_MethodHook() {
  3149 + @Override protected void beforeHookedMethod(MethodHookParam p) {
  3150 + XposedBridge.log("[WeChatHook] PROBE " + tag + "() called");
  3151 + }
  3152 + @Override protected void afterHookedMethod(MethodHookParam p) {
  3153 + if (p.hasThrowable()) XposedBridge.log("[WeChatHook] PROBE " + tag + "() THREW: " + p.getThrowable());
  3154 + else XposedBridge.log("[WeChatHook] PROBE " + tag + "() → " + p.getResult());
  3155 + }
  3156 + });
  3157 + XposedBridge.log("[WeChatHook] Probe hooked: " + tag);
  3158 + } catch (Throwable t) {
  3159 + XposedBridge.log("[WeChatHook] Probe " + tag + " failed: " + t);
  3160 + }
  3161 + }
  3162 +
  3163 + private static boolean isDIReady() {
  3164 + if (sWeChatCL == null) return false;
  3165 + try {
  3166 + XposedHelpers.callStaticMethod(XposedHelpers.findClass("gm0.j1", sWeChatCL), "i");
  3167 + return true;
  3168 + } catch (Throwable e) { return false; }
  3169 + }
  3170 +
  3171 + // Hook gm0.j1.g(km0.c) to detect when WeChat DI kernel is initialized.
  3172 + // gm0.j1.g() sets gm0.j1.m (the singleton) and is called lazily — not during
  3173 + // Application.onCreate, so our broadcast often arrives first. After g() fires,
  3174 + // gm0.j1.i() will succeed and w11.r0 can be constructed.
  3175 + private static void hookDIInit(ClassLoader cl, String procName) {
  3176 + try {
  3177 + Class<?> km0cCls = XposedHelpers.findClass("km0.c", cl);
  3178 + XposedHelpers.findAndHookMethod("gm0.j1", cl, "g", km0cCls,
  3179 + new XC_MethodHook() {
  3180 + @Override
  3181 + protected void afterHookedMethod(MethodHookParam p) throws Throwable {
  3182 + XposedBridge.log("[WeChatHook] gm0.j1.g() fired — DI ready in " + procName);
  3183 + String t = sPendingTalker, x = sPendingText, pr = sPendingProc;
  3184 + if (t != null && x != null && procName.equals(pr)) {
  3185 + sPendingTalker = null; sPendingText = null; sPendingProc = null;
  3186 + XposedBridge.log("[WeChatHook] resuming pending send to=" + t + " proc=" + procName);
  3187 + new Handler(Looper.getMainLooper()).postDelayed(
  3188 + () -> trySendWithRetry(t, x, 0, procName), 50);
  3189 + }
  3190 + }
  3191 + });
  3192 + XposedBridge.log("[WeChatHook] gm0.j1.g hook OK in " + procName);
  3193 + } catch (Throwable t) {
  3194 + XposedBridge.log("[WeChatHook] gm0.j1.g hook failed in " + procName + ": " + t);
  3195 + }
  3196 + }
  3197 +
  3198 + private static void diagMCoreStorage(String where) {
  3199 + if (sWeChatCL == null) return;
  3200 + try {
  3201 + Class<?> j1Cls = XposedHelpers.findClass("gm0.j1", sWeChatCL);
  3202 + Object j1 = XposedHelpers.callStaticMethod(j1Cls, "i");
  3203 + Object mCS = (j1 != null) ? XposedHelpers.getObjectField(j1, "d") : null;
  3204 + XposedBridge.log("[WeChatHook] " + where + " mCoreStorage=" + mCS);
  3205 + } catch (Throwable e) {
  3206 + XposedBridge.log("[WeChatHook] " + where + " mCoreStorage-check: " + e);
  3207 + }
  3208 + }
  3209 +
  3210 + private static void forceInitDI() {
  3211 + if (sWeChatCL == null) return;
  3212 + try {
  3213 + Class<?> fsgCls = XposedHelpers.findClass("fs.g", sWeChatCL);
  3214 + java.lang.reflect.Field faFld = fsgCls.getDeclaredField("a");
  3215 + faFld.setAccessible(true);
  3216 + Object[] arr = (Object[]) faFld.get(null);
  3217 +
  3218 + if (arr == null || arr.length == 0 || arr[0] == null) {
  3219 + Class<?> k2Cls = XposedHelpers.findClass("fs.k2", sWeChatCL);
  3220 + java.lang.reflect.Field k2eFld = k2Cls.getDeclaredField("e");
  3221 + k2eFld.setAccessible(true);
  3222 + Object k2e = k2eFld.get(null);
  3223 + if (k2e == null) {
  3224 + XposedBridge.log("[WeChatHook] forceInitDI: fs.k2.e null");
  3225 + return;
  3226 + }
  3227 + if (arr == null || arr.length == 0) {
  3228 + arr = new Object[1];
  3229 + faFld.set(null, arr);
  3230 + }
  3231 + arr[0] = k2e;
  3232 + XposedBridge.log("[WeChatHook] forceInitDI: fs.g.a[0]=" + k2e.getClass().getName());
  3233 + }
  3234 + } catch (Throwable e) {
  3235 + XposedBridge.log("[WeChatHook] forceInitDI: " + e);
  3236 + }
  3237 + }
  3238 +
  3239 + private static void trySendWithRetry(String talker, String text, int attempt, String procName) {
  3240 + // Non-main WeChat processes (:push, :tools, etc.) lack storage init — fail immediately.
  3241 + if (procName != null && procName.contains(":")) {
  3242 + if (attempt == 0) XposedBridge.log("[WeChatHook] Skip send in non-main proc=" + procName);
  3243 + return;
  3244 + }
  3245 + // Route A2: tg3.t1 high-level service locator
  3246 + try {
  3247 + sendViaHighLevel(talker, text);
  3248 + XposedBridge.log("[WeChatHook] HighLevel send OK to=" + talker + " proc=" + procName);
  3249 + return;
  3250 + } catch (Throwable t) {
  3251 + if (attempt == 0)
  3252 + XposedBridge.log("[WeChatHook] HighLevel FAILED proc=" + procName + ": " + t);
  3253 + }
  3254 +
  3255 + // Route A1b: w11.s1 factory (mid-level)
  3256 + try {
  3257 + sendViaFactory(talker, text);
  3258 + XposedBridge.log("[WeChatHook] Factory send OK to=" + talker + " proc=" + procName);
  3259 + return;
  3260 + } catch (Throwable t) {
  3261 + // Log every 5 attempts to track what's changing
  3262 + if (attempt % 5 == 0)
  3263 + XposedBridge.log("[WeChatHook] Factory FAILED attempt=" + attempt
  3264 + + " proc=" + procName + ": " + t);
  3265 + }
  3266 +
  3267 + // Route A1: w11.r0 direct (requires gm0.j1 DI ready)
  3268 + if (isDIReady()) {
  3269 + try {
  3270 + sendViaR0(talker, text);
  3271 + XposedBridge.log("[WeChatHook] R0 send OK to=" + talker + " proc=" + procName);
  3272 + return;
  3273 + } catch (Throwable t) {
  3274 + if (attempt == 0)
  3275 + XposedBridge.log("[WeChatHook] R0 FAILED proc=" + procName + ": " + t);
  3276 + }
  3277 + }
  3278 +
  3279 + // All paths failed — poll every 1s up to 60 attempts waiting for WeChat DI init
  3280 + if (attempt < 60) {
  3281 + if (attempt == 0)
  3282 + XposedBridge.log("[WeChatHook] All paths not ready, polling proc=" + procName
  3283 + + " to=" + talker);
  3284 + new Handler(Looper.getMainLooper()).postDelayed(
  3285 + () -> trySendWithRetry(talker, text, attempt + 1, procName), 1000);
  3286 + } else {
  3287 + XposedBridge.log("[WeChatHook] GIVE UP after 60s proc=" + procName + " to=" + talker);
  3288 + }
  3289 + }
  3290 +
  3291 + // Route A1b: w11.s1 factory — SendMsgCgiFactory.a(talker) → w11.r1.e(content).b()
  3292 + private static void sendViaFactory(String talker, String text) throws Throwable {
  3293 + ClassLoader cl = sTinkerCL != null ? sTinkerCL : sWeChatCL;
  3294 + if (cl == null) throw new IllegalStateException("classloader null");
  3295 + Class<?> s1 = XposedHelpers.findClass("w11.s1", cl);
  3296 + Object r1 = XposedHelpers.callStaticMethod(s1, "a", talker);
  3297 + if (r1 == null) throw new IllegalStateException("w11.s1.a() returned null");
  3298 + try {
  3299 + XposedHelpers.callMethod(r1, "e", text);
  3300 + } catch (Throwable t) {
  3301 + throw new RuntimeException("r1.e() failed: " + t, t);
  3302 + }
  3303 + try {
  3304 + XposedHelpers.callMethod(r1, "b");
  3305 + } catch (Throwable t) {
  3306 + throw new RuntimeException("r1.b() failed: " + t, t);
  3307 + }
  3308 + }
  3309 +
  3310 + // Route A2: high-level API via tg3.t1 service locator → dk5.s5.Ni().
  3311 + // This path is what WeChat UI uses — internally handles DI, account context, etc.
  3312 + private static void sendViaHighLevel(String talker, String text) throws Throwable {
  3313 + ClassLoader cl = sTinkerCL != null ? sTinkerCL : sWeChatCL;
  3314 + if (cl == null) throw new IllegalStateException("classloader null");
  3315 + // Text = same PROVEN path as voice sendVoiceViaAPI: w11.r0 ctor (type=1) inserts
  3316 + // the f9 into the DB, then z2.b(NetSceneQueue) dispatches over the real network.
  3317 + // Text carries no CDN attachment, so unlike voice it completes fully. (The s1.a /
  3318 + // s1.b / dk5.s5.Ni builder paths either produced name-cards or silently failed to
  3319 + // persist — they need hidden r1 fields + MsgRetransmitUI.P6 that we can't set robustly.)
  3320 + Class<?> r0Class = XposedHelpers.findClass("w11.r0", cl);
  3321 + Object r0;
  3322 + sCalledByUs = true;
  3323 + try {
  3324 + // Object-ctor is the one that actually inserts the f9 (confirmed by
  3325 + // sendVoiceViaAPI). obj=null for text (no audio/msgsource extras).
  3326 + r0 = XposedHelpers.newInstance(r0Class,
  3327 + new Class<?>[]{String.class, String.class, int.class, int.class, Object.class},
  3328 + talker, text, 1, 0, null); // (talker, content, type=1 TEXT, flag=0, null)
  3329 + } finally { sCalledByUs = false; }
  3330 + Class<?> z2c = XposedHelpers.findClass("com.tencent.mm.modelbase.z2", cl);
  3331 + XposedHelpers.callStaticMethod(z2c, "b", r0); // enqueue → doScene → newsendmsg
  3332 + XposedBridge.log("[WeChatHook] sendViaHighLevel(TEXT via r0-obj+z2.b): OK to=" + talker);
  3333 + }
  3334 +
  3335 + private static void registerSendReceiver(Context ctx, String procName) {
  3336 + // SEND_MSG_INTERNAL: send a text message
  3337 + BroadcastReceiver sendRecv = new BroadcastReceiver() {
  3338 + @Override
  3339 + public void onReceive(Context context, Intent intent) {
  3340 + String talker = intent.getStringExtra("to");
  3341 + String rawText = intent.getStringExtra("text");
  3342 + // Non-ASCII (Chinese) via `am broadcast --es text` gets mangled by the
  3343 + // shell/am → server rejects → stuck "sending". Prefer base64: send with
  3344 + // --es text_b64 <base64(utf-8)> and we decode it here intact.
  3345 + String textB64 = intent.getStringExtra("text_b64");
  3346 + if (textB64 != null && !textB64.isEmpty()) {
  3347 + try {
  3348 + rawText = new String(android.util.Base64.decode(textB64, android.util.Base64.DEFAULT), "UTF-8");
  3349 + } catch (Throwable t) {
  3350 + XposedBridge.log("[WeChatHook] SEND_MSG_INTERNAL: bad text_b64: " + t);
  3351 + }
  3352 + }
  3353 + final String text = rawText;
  3354 + if (talker == null || talker.isEmpty() || text == null || text.isEmpty()) {
  3355 + XposedBridge.log("[WeChatHook] SEND_MSG_INTERNAL: missing 'to' or 'text'/'text_b64'");
  3356 + return;
  3357 + }
  3358 + XposedBridge.log("[WeChatHook] SEND_MSG_INTERNAL proc=" + procName
  3359 + + " pid=" + android.os.Process.myPid()
  3360 + + " to=" + talker + " len=" + text.length());
  3361 + new Handler(Looper.getMainLooper()).post(() -> trySendWithRetry(talker, text, 0, procName));
  3362 + }
  3363 + };
  3364 + ctx.registerReceiver(sendRecv, new IntentFilter(ACTION_SEND), Context.RECEIVER_NOT_EXPORTED);
  3365 +
  3366 + // SEND_IMAGE_INTERNAL: send an image via w11.s1.b internal API
  3367 + // Usage: adb shell su -c 'am broadcast -a com.hermes.wechat.SEND_IMAGE_INTERNAL \
  3368 + // --es to "filehelper" --es path "/sdcard/test.jpg"'
  3369 + BroadcastReceiver imgRecv = new BroadcastReceiver() {
  3370 + @Override
  3371 + public void onReceive(Context context, Intent intent) {
  3372 + // Only handle in main WeChat process (sub-processes lack storage init)
  3373 + if (procName.contains(":")) return;
  3374 + String talker = intent.getStringExtra("to");
  3375 + String path = intent.getStringExtra("path");
  3376 + if (talker == null || talker.isEmpty() || path == null || path.isEmpty()) {
  3377 + XposedBridge.log("[WeChatHook] SEND_IMAGE_INTERNAL: missing 'to' or 'path'");
  3378 + return;
  3379 + }
  3380 + XposedBridge.log("[WeChatHook] SEND_IMAGE_INTERNAL proc=" + procName
  3381 + + " to=" + talker + " path=" + path);
  3382 + new Handler(Looper.getMainLooper()).post(() -> {
  3383 + try {
  3384 + sendImageViaAPI(talker, path);
  3385 + } catch (Throwable t) {
  3386 + XposedBridge.log("[WeChatHook] SEND_IMAGE_INTERNAL failed: " + t
  3387 + + "\n" + android.util.Log.getStackTraceString(t));
  3388 + }
  3389 + });
  3390 + }
  3391 + };
  3392 + ctx.registerReceiver(imgRecv, new IntentFilter(ACTION_SEND_IMAGE), Context.RECEIVER_NOT_EXPORTED);
  3393 +
  3394 + // SEND_VOICE_INTERNAL: send a voice message via w11.r0 + f9 internal API.
  3395 + // The caller (root, Python helper) must pre-place the SILK file at:
  3396 + // /data/data/com.tencent.mm/MicroMsg/{hash}/voice2/{md5(id)[0:2]}/{md5(id)[2:4]}/msg_{id}.amr
  3397 + // Usage: adb shell su -c 'am broadcast -a com.hermes.wechat.SEND_VOICE_INTERNAL \
  3398 + // --es to "filehelper" --es voice_id "26hexchars" --ei duration 3000 [--es from_wxid "wxid_..."]'
  3399 + BroadcastReceiver voiceRecv = new BroadcastReceiver() {
  3400 + @Override
  3401 + public void onReceive(Context context, Intent intent) {
  3402 + if (procName.contains(":")) return;
  3403 + String talker = intent.getStringExtra("to");
  3404 + String voiceId = intent.getStringExtra("voice_id"); // pre-placed file
  3405 + String srcPath = intent.getStringExtra("src_path"); // hook copies from here
  3406 + int duration = intent.getIntExtra("duration", 0);
  3407 + String fromWxId = intent.getStringExtra("from_wxid");
  3408 + if (talker == null || talker.isEmpty()
  3409 + || (voiceId == null && srcPath == null)) {
  3410 + XposedBridge.log("[WeChatHook] SEND_VOICE_INTERNAL: need 'to' + 'voice_id' or 'src_path'");
  3411 + return;
  3412 + }
  3413 + if (duration <= 0) {
  3414 + XposedBridge.log("[WeChatHook] SEND_VOICE_INTERNAL: missing 'duration'");
  3415 + return;
  3416 + }
  3417 + XposedBridge.log("[WeChatHook] SEND_VOICE_INTERNAL proc=" + procName
  3418 + + " to=" + talker + " voice_id=" + voiceId + " src=" + srcPath + " dur=" + duration);
  3419 + final String fVoiceId = voiceId, fSrcPath = srcPath;
  3420 + new Handler(Looper.getMainLooper()).post(() -> {
  3421 + try {
  3422 + sendVoiceViaAPI(talker, fVoiceId, fSrcPath, duration, fromWxId);
  3423 + } catch (Throwable t) {
  3424 + XposedBridge.log("[WeChatHook] SEND_VOICE_INTERNAL failed: " + t
  3425 + + "\n" + android.util.Log.getStackTraceString(t));
  3426 + }
  3427 + });
  3428 + }
  3429 + };
  3430 + ctx.registerReceiver(voiceRecv, new IntentFilter(ACTION_SEND_VOICE), Context.RECEIVER_NOT_EXPORTED);
  3431 +
  3432 + // SEND_VOICE_CGI: fully-synthetic voice via the uploadvoice cgi (this device's
  3433 + // real path). No recording, no hot-swap — chunk the SILK and submit uploadvoice
  3434 + // scenes via z2.b. Usage: am broadcast -a com.hermes.wechat.SEND_VOICE_CGI \
  3435 + // --es to filehelper --es src_path /path/to.silk --ei duration 3000 [--es from_wxid ..]
  3436 + BroadcastReceiver voiceCgiRecv = new BroadcastReceiver() {
  3437 + @Override
  3438 + public void onReceive(Context context, Intent intent) {
  3439 + if (procName.contains(":")) return; // main process only
  3440 + String talker = intent.getStringExtra("to");
  3441 + String srcPath = intent.getStringExtra("src_path");
  3442 + int duration = intent.getIntExtra("duration", 0);
  3443 + String fromWxId = intent.getStringExtra("from_wxid");
  3444 + if (talker == null || talker.isEmpty() || srcPath == null || srcPath.isEmpty()) {
  3445 + XposedBridge.log("[WeChatHook] SEND_VOICE_CGI: need 'to' + 'src_path'"); return;
  3446 + }
  3447 + if (duration <= 0) { XposedBridge.log("[WeChatHook] SEND_VOICE_CGI: missing 'duration'"); return; }
  3448 + XposedBridge.log("[WeChatHook] SEND_VOICE_CGI proc=" + procName + " to=" + talker
  3449 + + " src=" + srcPath + " dur=" + duration);
  3450 + new Handler(Looper.getMainLooper()).post(() -> {
  3451 + try {
  3452 + sendVoiceViaCgi(talker, srcPath, duration, fromWxId);
  3453 + } catch (Throwable t) {
  3454 + XposedBridge.log("[WeChatHook] SEND_VOICE_CGI failed: " + t
  3455 + + "\n" + android.util.Log.getStackTraceString(t));
  3456 + }
  3457 + });
  3458 + }
  3459 + };
  3460 + ctx.registerReceiver(voiceCgiRecv, new IntentFilter(ACTION_SEND_VOICE_CGI), Context.RECEIVER_NOT_EXPORTED);
  3461 +
  3462 + // ARM_VOICE_SWAP: load our TTS SILK into memory and arm the jg0.x.w swap.
  3463 + // After arming, trigger a REAL recording (hold "按住说话") to the target chat —
  3464 + // WeChat records, but jg0.x.w uploads our TTS bytes instead → delivered.
  3465 + // Usage: am broadcast -a com.hermes.wechat.ARM_VOICE_SWAP \
  3466 + // --es src_path /data/data/com.tencent.mm/cache/hook_voice_src.amr --ei duration 3200
  3467 + BroadcastReceiver armRecv = new BroadcastReceiver() {
  3468 + @Override
  3469 + public void onReceive(Context context, Intent intent) {
  3470 + if (procName.contains(":")) return; // main process only (where recording runs)
  3471 + String srcPath = intent.getStringExtra("src_path");
  3472 + int duration = intent.getIntExtra("duration", 0);
  3473 + if (srcPath == null || srcPath.isEmpty()) {
  3474 + XposedBridge.log("[WeChatHook] ARM_VOICE_SWAP: missing 'src_path'");
  3475 + return;
  3476 + }
  3477 + try {
  3478 + java.io.File f = new java.io.File(srcPath);
  3479 + byte[] bytes = new byte[(int) f.length()];
  3480 + try (java.io.FileInputStream in = new java.io.FileInputStream(f)) {
  3481 + int off = 0, n;
  3482 + while (off < bytes.length && (n = in.read(bytes, off, bytes.length - off)) > 0) off += n;
  3483 + }
  3484 + sSwapSilk = bytes;
  3485 + sSwapDuration = duration;
  3486 + sSwapExpiresAt = System.currentTimeMillis() + Math.max(duration, 0) + 10000;
  3487 + sSwapArmed = true;
  3488 + XposedBridge.log("[WeChatHook] ARM_VOICE_SWAP: armed " + bytes.length
  3489 + + "B dur=" + duration + "ms from " + srcPath
  3490 + + " (expires in " + ((sSwapExpiresAt - System.currentTimeMillis()) / 1000)
  3491 + + "s — trigger a real recording now)");
  3492 + } catch (Throwable t) {
  3493 + XposedBridge.log("[WeChatHook] ARM_VOICE_SWAP failed: " + t);
  3494 + }
  3495 + }
  3496 + };
  3497 + ctx.registerReceiver(armRecv, new IntentFilter(ACTION_ARM_SWAP), Context.RECEIVER_NOT_EXPORTED);
  3498 +
  3499 + // DUMP_WECHAT_API: on-demand class introspection (main process only)
  3500 + // Usage: adb shell su -c 'am broadcast -a com.hermes.wechat.DUMP_WECHAT_API [--es class "w11.s1"]'
  3501 + BroadcastReceiver dumpRecv = new BroadcastReceiver() {
  3502 + @Override
  3503 + public void onReceive(Context context, Intent intent) {
  3504 + XposedBridge.log("[WeChatHook] DUMP_WECHAT_API received in " + procName);
  3505 + new Handler(Looper.getMainLooper()).post(() -> handleDumpApi(intent));
  3506 + }
  3507 + };
  3508 + ctx.registerReceiver(dumpRecv, new IntentFilter(ACTION_DUMP_API), Context.RECEIVER_NOT_EXPORTED);
  3509 +
  3510 + XposedBridge.log("[WeChatHook] BroadcastReceivers registered in " + procName);
  3511 + }
  3512 +
  3513 + private static void sendViaR0(String talker, String text) throws Throwable {
  3514 + ClassLoader cl = sTinkerCL != null ? sTinkerCL : sWeChatCL;
  3515 + if (cl == null) throw new IllegalStateException("classloader null");
  3516 + Class<?> r0Class = XposedHelpers.findClass("w11.r0", cl);
  3517 + long ts = System.currentTimeMillis();
  3518 + Object msg;
  3519 + sCalledByUs = true;
  3520 + try {
  3521 + try {
  3522 + msg = XposedHelpers.newInstance(r0Class,
  3523 + new Class<?>[]{String.class, String.class, int.class, int.class, long.class},
  3524 + talker, text, 1, 0, ts);
  3525 + } catch (Throwable t) {
  3526 + msg = XposedHelpers.newInstance(r0Class,
  3527 + new Class<?>[]{String.class, String.class, int.class, int.class, Object.class},
  3528 + talker, text, 1, 0, (Object) ts);
  3529 + }
  3530 + } finally {
  3531 + sCalledByUs = false;
  3532 + }
  3533 +
  3534 + try {
  3535 + Class<?> u0 = XposedHelpers.findClass("com.tencent.mm.modelbase.u0", cl);
  3536 + Object stub = Proxy.newProxyInstance(cl, new Class<?>[]{u0},
  3537 + (proxy, method, args) -> {
  3538 + XposedBridge.log("[WeChatHook] send-cb: " + method.getName());
  3539 + return null;
  3540 + });
  3541 + XposedHelpers.setObjectField(msg, "d", stub);
  3542 + } catch (Throwable ignored) {}
  3543 +
  3544 + sCalledByUs = true;
  3545 + try {
  3546 + XposedHelpers.callMethod(msg, "H", "");
  3547 + } finally {
  3548 + sCalledByUs = false;
  3549 + }
  3550 + }
  3551 +}
... ...
xhs_lsposed_a16_fix/wechathook_src/WeChatHook.java
... ... @@ -96,14 +96,23 @@ public class WeChatHook implements IXposedHookLoadPackage {
96 96 // 2) hookInTabletEnv → bk.A/B → true (inTabletEnv), belt-and-suspenders.
97 97 // 3) hookLoginHistoryUIInitView/hookLoginUIInitView → force the "以平板登录"
98 98 // button (field N) VISIBLE on LoginPasswordUI/LoginHistoryUI.onCreate + m4 click.
99   - // To revert to plain single-device login: DEVICE_SPOOF=false + comment these 4.
  99 + // RUNTIME TOGGLE (default OFF = clean un-spoofed Pixel, lowest 风控/ban risk):
  100 + // all 4 pad-login hooks install ONLY when the flag file exists. Flip it with the
  101 + // spoof_toggle shortcut (touch/rm the flag + restart WeChat). Keeping spoof off
  102 + // normally avoids the device-fingerprint mismatch that trips WeChat risk control
  103 + // (forced logout + 实名验证). Turn on only when you actually need pad login.
100 104 // When pad login breaks later, Hermes first-responds via the on-device skill
101 105 // wechat-pad-login-repair (diagnose logs/screen → no-rebuild fixes → escalate
102 106 // Case B). Recipe: wechat_pad_login/LESSONS.md + HERMES_REPAIR_SKILL.md.
103   - hookDeviceSpoof(cl, lpparam.processName);
104   - hookInTabletEnv(cl);
105   - hookLoginHistoryUIInitView(cl);
106   - hookLoginUIInitView(cl);
  107 + if (isDeviceSpoofEnabled()) {
  108 + XposedBridge.log("[WeChatHook] pad-spoof flag ON — installing device spoof + tablet-login hooks");
  109 + hookDeviceSpoof(cl, lpparam.processName);
  110 + hookInTabletEnv(cl);
  111 + hookLoginHistoryUIInitView(cl);
  112 + hookLoginUIInitView(cl);
  113 + } else {
  114 + XposedBridge.log("[WeChatHook] pad-spoof flag OFF — clean Pixel, no spoof/tablet hooks");
  115 + }
107 116 // hookProbe(cl);
108 117 // hookLoginSelectUICheck(cl);
109 118 // hookLoginButtonClick(cl);
... ... @@ -2693,10 +2702,14 @@ public class WeChatHook implements IXposedHookLoadPackage {
2693 2702 // secondary-device validation read android.os.Build fields + ro.product.* props.
2694 2703 // Pixel is never a tablet; spoof Build + SystemProperties to a real Samsung tablet
2695 2704 // so both the client gate and the server handshake see a legitimate pad model.
2696   - // Runs in the main WeChat process only. Toggle via DEVICE_SPOOF.
2697   - private static final boolean DEVICE_SPOOF = true;
  2705 + // Runs in the main WeChat process only. Enabled at RUNTIME by the flag file below
  2706 + // (default OFF). Flip with the spoof_toggle shortcut, then restart WeChat.
  2707 + private static final String SPOOF_FLAG = "/data/data/com.tencent.mm/files/.wcspoof";
  2708 + static boolean isDeviceSpoofEnabled() {
  2709 + try { return new java.io.File(SPOOF_FLAG).exists(); } catch (Throwable t) { return false; }
  2710 + }
2698 2711 private void hookDeviceSpoof(ClassLoader cl, String procName) {
2699   - if (!DEVICE_SPOOF) return;
  2712 + if (!isDeviceSpoofEnabled()) return;
2700 2713 if (procName.contains(":")) return; // main process only
2701 2714 try {
2702 2715 // Samsung Galaxy Tab S8 (Wi-Fi) — a well-known Android tablet.
... ... @@ -2978,10 +2991,13 @@ public class WeChatHook implements IXposedHookLoadPackage {
2978 2991 // Pad-login: the login UIs (LoginPasswordUI/LoginHistoryUI/
2979 2992 // LoginUI) + bk run in Tinker CL — the base-CL hooks never
2980 2993 // fire (0 N-VISIBLE). Register them here so the "以平板登录"
2981   - // button is actually forced visible.
2982   - hookInTabletEnv(sTinkerCL);
2983   - hookLoginHistoryUIInitView(sTinkerCL);
2984   - hookLoginUIInitView(sTinkerCL);
  2994 + // button is actually forced visible. Gated by the runtime
  2995 + // spoof flag (default OFF = clean, no tablet hooks).
  2996 + if (isDeviceSpoofEnabled()) {
  2997 + hookInTabletEnv(sTinkerCL);
  2998 + hookLoginHistoryUIInitView(sTinkerCL);
  2999 + hookLoginUIInitView(sTinkerCL);
  3000 + }
2985 3001 }
2986 3002 hookF9ConvertTo(sWeChatCL, procName);
2987 3003 hookF9ImgPathInjector(sWeChatCL, procName);
... ...